UNPKG

create-better-t-stack

Version:

A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations

1,542 lines (1,511 loc) 283 kB
#!/usr/bin/env node import { autocompleteMultiselect, cancel, confirm, group, groupMultiselect, intro, isCancel, log, multiselect, outro, select, spinner, text } from "@clack/prompts"; import pc from "picocolors"; import { createCli, trpcServer } from "trpc-cli"; import z from "zod"; import path from "node:path"; import consola, { consola as consola$1 } from "consola"; import fs from "fs-extra"; import { fileURLToPath } from "node:url"; import gradient from "gradient-string"; import * as JSONC from "jsonc-parser"; import { $, execa } from "execa"; import { IndentationText, Node, Project, QuoteKind, SyntaxKind } from "ts-morph"; import { glob } from "tinyglobby"; import handlebars from "handlebars"; import { Biome } from "@biomejs/js-api/nodejs"; import os from "node:os"; //#region src/utils/get-package-manager.ts const getUserPkgManager = () => { const userAgent = process.env.npm_config_user_agent; if (userAgent?.startsWith("pnpm")) return "pnpm"; if (userAgent?.startsWith("bun")) return "bun"; return "npm"; }; //#endregion //#region src/constants.ts const __filename = fileURLToPath(import.meta.url); const distPath = path.dirname(__filename); const PKG_ROOT = path.join(distPath, "../"); const DEFAULT_CONFIG_BASE = { projectName: "my-better-t-app", relativePath: "my-better-t-app", frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", auth: "better-auth", payments: "none", addons: ["turborepo"], examples: [], git: true, install: true, dbSetup: "none", backend: "hono", runtime: "bun", api: "trpc", webDeploy: "none", serverDeploy: "none" }; function getDefaultConfig() { return { ...DEFAULT_CONFIG_BASE, projectDir: path.resolve(process.cwd(), DEFAULT_CONFIG_BASE.projectName), packageManager: getUserPkgManager(), frontend: [...DEFAULT_CONFIG_BASE.frontend], addons: [...DEFAULT_CONFIG_BASE.addons], examples: [...DEFAULT_CONFIG_BASE.examples] }; } const DEFAULT_CONFIG = getDefaultConfig(); const dependencyVersionMap = { "better-auth": "^1.3.13", "@better-auth/expo": "^1.3.13", "@clerk/nextjs": "^6.31.5", "@clerk/clerk-react": "^5.45.0", "@clerk/tanstack-react-start": "^0.23.1", "@clerk/clerk-expo": "^2.14.25", "drizzle-orm": "^0.44.2", "drizzle-kit": "^0.31.2", "@planetscale/database": "^1.19.0", "@libsql/client": "^0.15.9", "@neondatabase/serverless": "^1.0.1", pg: "^8.14.1", "@types/pg": "^8.11.11", "@types/ws": "^8.18.1", ws: "^8.18.3", mysql2: "^3.14.0", "@prisma/client": "^6.15.0", prisma: "^6.15.0", "@prisma/adapter-d1": "^6.15.0", "@prisma/extension-accelerate": "^2.0.2", "@prisma/adapter-libsql": "^6.15.0", "@prisma/adapter-planetscale": "^6.15.0", mongoose: "^8.14.0", "vite-plugin-pwa": "^1.0.1", "@vite-pwa/assets-generator": "^1.0.0", "@tauri-apps/cli": "^2.4.0", "@biomejs/biome": "^2.2.0", oxlint: "^1.12.0", husky: "^9.1.7", "lint-staged": "^16.1.2", tsx: "^4.19.2", "@types/node": "^22.13.11", "@types/bun": "^1.2.6", "@elysiajs/node": "^1.3.1", "@elysiajs/cors": "^1.3.3", "@elysiajs/trpc": "^1.1.0", elysia: "^1.3.21", "@hono/node-server": "^1.14.4", "@hono/trpc-server": "^0.4.0", hono: "^4.8.2", cors: "^2.8.5", express: "^5.1.0", "@types/express": "^5.0.1", "@types/cors": "^2.8.17", fastify: "^5.3.3", "@fastify/cors": "^11.0.1", turbo: "^2.5.4", ai: "^5.0.39", "@ai-sdk/google": "^2.0.13", "@ai-sdk/vue": "^2.0.39", "@ai-sdk/svelte": "^3.0.39", "@ai-sdk/react": "^2.0.39", streamdown: "^1.3.0", shiki: "^3.12.2", "@orpc/server": "^1.8.6", "@orpc/client": "^1.8.6", "@orpc/openapi": "^1.8.6", "@orpc/zod": "^1.8.6", "@orpc/tanstack-query": "^1.8.6", "@trpc/tanstack-react-query": "^11.5.0", "@trpc/server": "^11.5.0", "@trpc/client": "^11.5.0", convex: "^1.27.0", "@convex-dev/react-query": "^0.0.0-alpha.8", "convex-svelte": "^0.0.11", "convex-nuxt": "0.1.5", "convex-vue": "^0.1.5", "@convex-dev/better-auth": "^0.8.4", "@tanstack/svelte-query": "^5.85.3", "@tanstack/svelte-query-devtools": "^5.85.3", "@tanstack/vue-query-devtools": "^5.83.0", "@tanstack/vue-query": "^5.83.0", "@tanstack/react-query-devtools": "^5.85.5", "@tanstack/react-query": "^5.85.5", "@tanstack/solid-query": "^5.87.4", "@tanstack/solid-query-devtools": "^5.87.4", "@tanstack/solid-router-devtools": "^1.131.44", wrangler: "^4.23.0", "@cloudflare/vite-plugin": "^1.9.0", "@opennextjs/cloudflare": "^1.6.5", "nitro-cloudflare-dev": "^0.2.2", "@sveltejs/adapter-cloudflare": "^7.2.1", "@cloudflare/workers-types": "^4.20250822.0", alchemy: "^0.67.0", nitropack: "^2.12.4", dotenv: "^17.2.1", "@polar-sh/better-auth": "^1.1.3", "@polar-sh/sdk": "^0.34.16" }; const ADDON_COMPATIBILITY = { pwa: [ "tanstack-router", "react-router", "solid", "next" ], tauri: [ "tanstack-router", "react-router", "nuxt", "svelte", "solid", "next" ], biome: [], husky: [], turborepo: [], starlight: [], ultracite: [], ruler: [], oxlint: [], fumadocs: [], none: [] }; //#endregion //#region src/types.ts const DatabaseSchema = z.enum([ "none", "sqlite", "postgres", "mysql", "mongodb" ]).describe("Database type"); const ORMSchema = z.enum([ "drizzle", "prisma", "mongoose", "none" ]).describe("ORM type"); const BackendSchema = z.enum([ "hono", "express", "fastify", "next", "elysia", "convex", "none" ]).describe("Backend framework"); const RuntimeSchema = z.enum([ "bun", "node", "workers", "none" ]).describe("Runtime environment"); const FrontendSchema = z.enum([ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "native-nativewind", "native-unistyles", "svelte", "solid", "none" ]).describe("Frontend framework"); const AddonsSchema = z.enum([ "pwa", "tauri", "starlight", "biome", "husky", "ruler", "turborepo", "fumadocs", "ultracite", "oxlint", "none" ]).describe("Additional addons"); const ExamplesSchema = z.enum([ "todo", "ai", "none" ]).describe("Example templates to include"); const PackageManagerSchema = z.enum([ "npm", "pnpm", "bun" ]).describe("Package manager"); const DatabaseSetupSchema = z.enum([ "turso", "neon", "prisma-postgres", "planetscale", "mongodb-atlas", "supabase", "d1", "docker", "none" ]).describe("Database hosting setup"); const APISchema = z.enum([ "trpc", "orpc", "none" ]).describe("API type"); const AuthSchema = z.enum([ "better-auth", "clerk", "none" ]).describe("Authentication provider"); const PaymentsSchema = z.enum(["polar", "none"]).describe("Payments provider"); const ProjectNameSchema = z.string().min(1, "Project name cannot be empty").max(255, "Project name must be less than 255 characters").refine((name) => name === "." || !name.startsWith("."), "Project name cannot start with a dot (except for '.')").refine((name) => name === "." || !name.startsWith("-"), "Project name cannot start with a dash").refine((name) => { const invalidChars = [ "<", ">", ":", "\"", "|", "?", "*" ]; return !invalidChars.some((char) => name.includes(char)); }, "Project name contains invalid characters").refine((name) => name.toLowerCase() !== "node_modules", "Project name is reserved").describe("Project name or path"); const WebDeploySchema = z.enum([ "wrangler", "alchemy", "none" ]).describe("Web deployment"); const ServerDeploySchema = z.enum([ "wrangler", "alchemy", "none" ]).describe("Server deployment"); const DirectoryConflictSchema = z.enum([ "merge", "overwrite", "increment", "error" ]).describe("How to handle existing directory conflicts"); //#endregion //#region src/utils/compatibility.ts const WEB_FRAMEWORKS = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid" ]; //#endregion //#region src/utils/errors.ts function isProgrammatic() { return process.env.BTS_PROGRAMMATIC === "1"; } function exitWithError(message) { consola.error(pc.red(message)); if (isProgrammatic()) throw new Error(message); process.exit(1); } function exitCancelled(message = "Operation cancelled") { cancel(pc.red(message)); if (isProgrammatic()) throw new Error(message); process.exit(0); } function handleError(error, fallbackMessage) { const message = error instanceof Error ? error.message : fallbackMessage || String(error); consola.error(pc.red(message)); if (isProgrammatic()) throw new Error(message); process.exit(1); } //#endregion //#region src/utils/compatibility-rules.ts function isWebFrontend(value) { return WEB_FRAMEWORKS.includes(value); } function splitFrontends(values = []) { const web = values.filter((f) => isWebFrontend(f)); const native = values.filter((f) => f === "native-nativewind" || f === "native-unistyles"); return { web, native }; } function ensureSingleWebAndNative(frontends) { const { web, native } = splitFrontends(frontends); if (web.length > 1) exitWithError("Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid"); if (native.length > 1) exitWithError("Cannot select multiple native frameworks. Choose only one of: native-nativewind, native-unistyles"); } function validateWorkersCompatibility(providedFlags, options, config) { if (providedFlags.has("runtime") && options.runtime === "workers" && config.backend && config.backend !== "hono") exitWithError(`Cloudflare Workers runtime (--runtime workers) is only supported with Hono backend (--backend hono). Current backend: ${config.backend}. Please use '--backend hono' or choose a different runtime.`); if (providedFlags.has("backend") && config.backend && config.backend !== "hono" && config.runtime === "workers") exitWithError(`Backend '${config.backend}' is not compatible with Cloudflare Workers runtime. Cloudflare Workers runtime is only supported with Hono backend. Please use '--backend hono' or choose a different runtime.`); if (providedFlags.has("runtime") && options.runtime === "workers" && config.database === "mongodb") exitWithError("Cloudflare Workers runtime (--runtime workers) is not compatible with MongoDB database. MongoDB requires Prisma or Mongoose ORM, but Workers runtime only supports Drizzle or Prisma ORM. Please use a different database or runtime."); if (providedFlags.has("runtime") && options.runtime === "workers" && config.dbSetup === "docker") exitWithError("Cloudflare Workers runtime (--runtime workers) is not compatible with Docker setup. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime."); if (providedFlags.has("database") && config.database === "mongodb" && config.runtime === "workers") exitWithError("MongoDB database is not compatible with Cloudflare Workers runtime. MongoDB requires Prisma or Mongoose ORM, but Workers runtime only supports Drizzle or Prisma ORM. Please use a different database or runtime."); if (providedFlags.has("dbSetup") && options.dbSetup === "docker" && config.runtime === "workers") exitWithError("Docker setup (--db-setup docker) is not compatible with Cloudflare Workers runtime. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime."); } function validateApiFrontendCompatibility(api, frontends = []) { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); const includesSolid = frontends.includes("solid"); if ((includesNuxt || includesSvelte || includesSolid) && api === "trpc") exitWithError(`tRPC API is not supported with '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : "solid"}' frontend. Please use --api orpc or --api none or remove '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : "solid"}' from --frontend.`); } function isFrontendAllowedWithBackend(frontend, backend, auth) { if (backend === "convex" && frontend === "solid") return false; if (auth === "clerk" && backend === "convex") { const incompatibleFrontends = [ "nuxt", "svelte", "solid" ]; if (incompatibleFrontends.includes(frontend)) return false; } return true; } function allowedApisForFrontends(frontends = []) { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); const includesSolid = frontends.includes("solid"); const base = [ "trpc", "orpc", "none" ]; if (includesNuxt || includesSvelte || includesSolid) return ["orpc", "none"]; return base; } function isExampleTodoAllowed(backend, database) { return !(backend !== "convex" && backend !== "none" && database === "none"); } function isExampleAIAllowed(_backend, frontends = []) { const includesSolid = frontends.includes("solid"); if (includesSolid) return false; return true; } function validateWebDeployRequiresWebFrontend(webDeploy, hasWebFrontendFlag) { if (webDeploy && webDeploy !== "none" && !hasWebFrontendFlag) exitWithError("'--web-deploy' requires a web frontend. Please select a web frontend or set '--web-deploy none'."); } function validateServerDeployRequiresBackend(serverDeploy, backend) { if (serverDeploy && serverDeploy !== "none" && (!backend || backend === "none")) exitWithError("'--server-deploy' requires a backend. Please select a backend or set '--server-deploy none'."); } function validateAddonCompatibility(addon, frontend, _auth) { const compatibleFrontends = ADDON_COMPATIBILITY[addon]; if (compatibleFrontends.length > 0) { const hasCompatibleFrontend = frontend.some((f) => compatibleFrontends.includes(f)); if (!hasCompatibleFrontend) { const frontendList = compatibleFrontends.join(", "); return { isCompatible: false, reason: `${addon} addon requires one of these frontends: ${frontendList}` }; } } return { isCompatible: true }; } function getCompatibleAddons(allAddons, frontend, existingAddons = [], auth) { return allAddons.filter((addon) => { if (existingAddons.includes(addon)) return false; if (addon === "none") return false; const { isCompatible } = validateAddonCompatibility(addon, frontend, auth); return isCompatible; }); } function validateAddonsAgainstFrontends(addons = [], frontends = [], auth) { for (const addon of addons) { if (addon === "none") continue; const { isCompatible, reason } = validateAddonCompatibility(addon, frontends, auth); if (!isCompatible) exitWithError(`Incompatible addon/frontend combination: ${reason}`); } } function validatePaymentsCompatibility(payments, auth, _backend, frontends = []) { if (!payments || payments === "none") return; if (payments === "polar") { if (!auth || auth === "none" || auth !== "better-auth") exitWithError("Polar payments requires Better Auth. Please use '--auth better-auth' or choose a different payments provider."); const { web } = splitFrontends(frontends); if (web.length === 0 && frontends.length > 0) exitWithError("Polar payments requires a web frontend or no frontend. Please select a web frontend or choose a different payments provider."); } } function validateExamplesCompatibility(examples, backend, database, frontend) { const examplesArr = examples ?? []; if (examplesArr.length === 0 || examplesArr.includes("none")) return; if (examplesArr.includes("todo") && backend !== "convex" && backend !== "none" && database === "none") exitWithError("The 'todo' example requires a database if a backend (other than Convex) is present. Cannot use --examples todo when database is 'none' and a backend is selected."); if (examplesArr.includes("ai") && (frontend ?? []).includes("solid")) exitWithError("The 'ai' example is not compatible with the Solid frontend."); } //#endregion //#region src/prompts/addons.ts function getAddonDisplay(addon) { let label; let hint; switch (addon) { case "turborepo": label = "Turborepo"; hint = "High-performance build system"; break; case "pwa": label = "PWA"; hint = "Make your app installable and work offline"; break; case "tauri": label = "Tauri"; hint = "Build native desktop apps from your web frontend"; break; case "biome": label = "Biome"; hint = "Format, lint, and more"; break; case "oxlint": label = "Oxlint"; hint = "Rust-powered linter"; break; case "ultracite": label = "Ultracite"; hint = "Zero-config Biome preset with AI integration"; break; case "ruler": label = "Ruler"; hint = "Centralize your AI rules"; break; case "husky": label = "Husky"; hint = "Modern native Git hooks made easy"; break; case "starlight": label = "Starlight"; hint = "Build stellar docs with astro"; break; case "fumadocs": label = "Fumadocs"; hint = "Build excellent documentation site"; break; default: label = addon; hint = `Add ${addon}`; } return { label, hint }; } const ADDON_GROUPS = { Documentation: ["starlight", "fumadocs"], Linting: [ "biome", "oxlint", "ultracite" ], Other: [ "ruler", "turborepo", "pwa", "tauri", "husky" ] }; async function getAddonsChoice(addons, frontends, auth) { if (addons !== void 0) return addons; const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); const groupedOptions = { Documentation: [], Linting: [], Other: [] }; const frontendsArray = frontends || []; for (const addon of allAddons) { const { isCompatible } = validateAddonCompatibility(addon, frontendsArray, auth); if (!isCompatible) continue; const { label, hint } = getAddonDisplay(addon); const option = { value: addon, label, hint }; if (ADDON_GROUPS.Documentation.includes(addon)) groupedOptions.Documentation.push(option); else if (ADDON_GROUPS.Linting.includes(addon)) groupedOptions.Linting.push(option); else if (ADDON_GROUPS.Other.includes(addon)) groupedOptions.Other.push(option); } Object.keys(groupedOptions).forEach((group$1) => { if (groupedOptions[group$1].length === 0) delete groupedOptions[group$1]; }); const initialValues = DEFAULT_CONFIG.addons.filter((addonValue) => Object.values(groupedOptions).some((options) => options.some((opt) => opt.value === addonValue))); const response = await groupMultiselect({ message: "Select addons", options: groupedOptions, initialValues, required: false, selectableGroups: false }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } async function getAddonsToAdd(frontend, existingAddons = [], auth) { const groupedOptions = { Documentation: [], Linting: [], Other: [] }; const frontendArray = frontend || []; const compatibleAddons = getCompatibleAddons(AddonsSchema.options.filter((addon) => addon !== "none"), frontendArray, existingAddons, auth); for (const addon of compatibleAddons) { const { label, hint } = getAddonDisplay(addon); const option = { value: addon, label, hint }; if (ADDON_GROUPS.Documentation.includes(addon)) groupedOptions.Documentation.push(option); else if (ADDON_GROUPS.Linting.includes(addon)) groupedOptions.Linting.push(option); else if (ADDON_GROUPS.Other.includes(addon)) groupedOptions.Other.push(option); } Object.keys(groupedOptions).forEach((group$1) => { if (groupedOptions[group$1].length === 0) delete groupedOptions[group$1]; }); if (Object.keys(groupedOptions).length === 0) return []; const response = await groupMultiselect({ message: "Select addons to add", options: groupedOptions, required: false, selectableGroups: false }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/api.ts async function getApiChoice(Api, frontend, backend) { if (backend === "convex" || backend === "none") return "none"; const allowed = allowedApisForFrontends(frontend ?? []); if (Api) return allowed.includes(Api) ? Api : allowed[0]; const apiOptions = allowed.map((a) => a === "trpc" ? { value: "trpc", label: "tRPC", hint: "End-to-end typesafe APIs made easy" } : a === "orpc" ? { value: "orpc", label: "oRPC", hint: "End-to-end type-safe APIs that adhere to OpenAPI standards" } : { value: "none", label: "None", hint: "No API layer (e.g. for full-stack frameworks like Next.js with Route Handlers)" }); const apiType = await select({ message: "Select API type", options: apiOptions, initialValue: apiOptions[0].value }); if (isCancel(apiType)) return exitCancelled("Operation cancelled"); return apiType; } //#endregion //#region src/prompts/auth.ts async function getAuthChoice(auth, hasDatabase, backend, frontend) { if (auth !== void 0) return auth; if (backend === "convex") { const supportedBetterAuthFrontends = frontend?.some((f) => [ "tanstack-router", "tanstack-start", "next" ].includes(f)); const hasClerkCompatibleFrontends = frontend?.some((f) => [ "react-router", "tanstack-router", "tanstack-start", "next", "native-nativewind", "native-unistyles" ].includes(f)); const options = []; if (supportedBetterAuthFrontends) options.push({ value: "better-auth", label: "Better-Auth", hint: "comprehensive auth framework for TypeScript" }); if (hasClerkCompatibleFrontends) options.push({ value: "clerk", label: "Clerk", hint: "More than auth, Complete User Management" }); options.push({ value: "none", label: "None", hint: "No auth" }); const response$1 = await select({ message: "Select authentication provider", options, initialValue: "none" }); if (isCancel(response$1)) return exitCancelled("Operation cancelled"); return response$1; } if (!hasDatabase) return "none"; const response = await select({ message: "Select authentication provider", options: [{ value: "better-auth", label: "Better-Auth", hint: "comprehensive auth framework for TypeScript" }, { value: "none", label: "None" }], initialValue: DEFAULT_CONFIG.auth }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/backend.ts async function getBackendFrameworkChoice(backendFramework, frontends) { if (backendFramework !== void 0) return backendFramework; const hasIncompatibleFrontend = frontends?.some((f) => f === "solid"); const backendOptions = [ { value: "hono", label: "Hono", hint: "Lightweight, ultrafast web framework" }, { value: "next", label: "Next.js", hint: "separate api routes only backend" }, { value: "express", label: "Express", hint: "Fast, unopinionated, minimalist web framework for Node.js" }, { value: "fastify", label: "Fastify", hint: "Fast, low-overhead web framework for Node.js" }, { value: "elysia", label: "Elysia", hint: "Ergonomic web framework for building backend servers" } ]; if (!hasIncompatibleFrontend) backendOptions.push({ value: "convex", label: "Convex", hint: "Reactive backend-as-a-service platform" }); backendOptions.push({ value: "none", label: "None", hint: "No backend server" }); const response = await select({ message: "Select backend", options: backendOptions, initialValue: DEFAULT_CONFIG.backend }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/database.ts async function getDatabaseChoice(database, backend, runtime) { if (backend === "convex" || backend === "none") return "none"; if (database !== void 0) return database; const databaseOptions = [ { value: "none", label: "None", hint: "No database setup" }, { value: "sqlite", label: "SQLite", hint: "lightweight, server-less, embedded relational database" }, { value: "postgres", label: "PostgreSQL", hint: "powerful, open source object-relational database system" }, { value: "mysql", label: "MySQL", hint: "popular open-source relational database system" } ]; if (runtime !== "workers") databaseOptions.push({ value: "mongodb", label: "MongoDB", hint: "open-source NoSQL database that stores data in JSON-like documents called BSON" }); const response = await select({ message: "Select database", options: databaseOptions, initialValue: DEFAULT_CONFIG.database }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/database-setup.ts async function getDBSetupChoice(databaseType, dbSetup, _orm, backend, runtime) { if (backend === "convex") return "none"; if (dbSetup !== void 0) return dbSetup; if (databaseType === "none") return "none"; let options = []; if (databaseType === "sqlite") options = [ { value: "turso", label: "Turso", hint: "SQLite for Production. Powered by libSQL" }, ...runtime === "workers" ? [{ value: "d1", label: "Cloudflare D1", hint: "Cloudflare's managed, serverless database with SQLite's SQL semantics" }] : [], { value: "none", label: "None", hint: "Manual setup" } ]; else if (databaseType === "postgres") options = [ { value: "neon", label: "Neon Postgres", hint: "Serverless Postgres with branching capability" }, { value: "planetscale", label: "PlanetScale", hint: "Postgres & Vitess (MySQL) on NVMe" }, { value: "supabase", label: "Supabase", hint: "Local Supabase stack (requires Docker)" }, { value: "prisma-postgres", label: "Prisma Postgres", hint: "Instant Postgres for Global Applications" }, { value: "docker", label: "Docker", hint: "Run locally with docker compose" }, { value: "none", label: "None", hint: "Manual setup" } ]; else if (databaseType === "mysql") options = [ { value: "planetscale", label: "PlanetScale", hint: "MySQL on Vitess (NVMe, HA)" }, { value: "docker", label: "Docker", hint: "Run locally with docker compose" }, { value: "none", label: "None", hint: "Manual setup" } ]; else if (databaseType === "mongodb") options = [ { value: "mongodb-atlas", label: "MongoDB Atlas", hint: "The most effective way to deploy MongoDB" }, { value: "docker", label: "Docker", hint: "Run locally with docker compose" }, { value: "none", label: "None", hint: "Manual setup" } ]; else return "none"; const response = await select({ message: `Select ${databaseType} setup option`, options, initialValue: "none" }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/examples.ts async function getExamplesChoice(examples, database, frontends, backend, api) { if (examples !== void 0) return examples; if (api === "none") { if (backend === "convex") return ["todo"]; return []; } if (backend === "convex") return ["todo"]; if (backend === "none") return []; if (database === "none") return []; let response = []; const options = []; if (isExampleTodoAllowed(backend, database)) options.push({ value: "todo", label: "Todo App", hint: "A simple CRUD example app" }); if (isExampleAIAllowed(backend, frontends ?? [])) options.push({ value: "ai", label: "AI Chat", hint: "A simple AI chat interface using AI SDK" }); if (options.length === 0) return []; response = await multiselect({ message: "Include examples", options, required: false, initialValues: DEFAULT_CONFIG.examples?.filter((ex) => options.some((o) => o.value === ex)) }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/frontend.ts async function getFrontendChoice(frontendOptions, backend, auth) { if (frontendOptions !== void 0) return frontendOptions; const frontendTypes = await multiselect({ message: "Select project type", options: [{ value: "web", label: "Web", hint: "React, Vue or Svelte Web Application" }, { value: "native", label: "Native", hint: "Create a React Native/Expo app" }], required: false, initialValues: ["web"] }); if (isCancel(frontendTypes)) return exitCancelled("Operation cancelled"); const result = []; if (frontendTypes.includes("web")) { const allWebOptions = [ { value: "tanstack-router", label: "TanStack Router", hint: "Modern and scalable routing for React Applications" }, { value: "react-router", label: "React Router", hint: "A user‑obsessed, standards‑focused, multi‑strategy router" }, { value: "next", label: "Next.js", hint: "The React Framework for the Web" }, { value: "nuxt", label: "Nuxt", hint: "The Progressive Web Framework for Vue.js" }, { value: "svelte", label: "Svelte", hint: "web development for the rest of us" }, { value: "solid", label: "Solid", hint: "Simple and performant reactivity for building user interfaces" }, { value: "tanstack-start", label: "TanStack Start", hint: "SSR, Server Functions, API Routes and more with TanStack Router" } ]; const webOptions = allWebOptions.filter((option) => isFrontendAllowedWithBackend(option.value, backend, auth)); const webFramework = await select({ message: "Choose web", options: webOptions, initialValue: DEFAULT_CONFIG.frontend[0] }); if (isCancel(webFramework)) return exitCancelled("Operation cancelled"); result.push(webFramework); } if (frontendTypes.includes("native")) { const nativeFramework = await select({ message: "Choose native", options: [{ value: "native-nativewind", label: "NativeWind", hint: "Use Tailwind CSS for React Native" }, { value: "native-unistyles", label: "Unistyles", hint: "Consistent styling for React Native" }], initialValue: "native-nativewind" }); if (isCancel(nativeFramework)) return exitCancelled("Operation cancelled"); result.push(nativeFramework); } return result; } //#endregion //#region src/prompts/git.ts async function getGitChoice(git) { if (git !== void 0) return git; const response = await confirm({ message: "Initialize git repository?", initialValue: DEFAULT_CONFIG.git }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/install.ts async function getinstallChoice(install) { if (install !== void 0) return install; const response = await confirm({ message: "Install dependencies?", initialValue: DEFAULT_CONFIG.install }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/orm.ts const ormOptions = { prisma: { value: "prisma", label: "Prisma", hint: "Powerful, feature-rich ORM" }, mongoose: { value: "mongoose", label: "Mongoose", hint: "Elegant object modeling tool" }, drizzle: { value: "drizzle", label: "Drizzle", hint: "Lightweight and performant TypeScript ORM" } }; async function getORMChoice(orm, hasDatabase, database, backend, runtime) { if (backend === "convex") return "none"; if (!hasDatabase) return "none"; if (orm !== void 0) return orm; const options = [...database === "mongodb" ? [ormOptions.prisma, ormOptions.mongoose] : [ormOptions.drizzle, ormOptions.prisma]]; const response = await select({ message: "Select ORM", options, initialValue: database === "mongodb" ? "prisma" : runtime === "workers" ? "drizzle" : DEFAULT_CONFIG.orm }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/package-manager.ts async function getPackageManagerChoice(packageManager) { if (packageManager !== void 0) return packageManager; const detectedPackageManager = getUserPkgManager(); const response = await select({ message: "Choose package manager", options: [ { value: "npm", label: "npm", hint: "Node Package Manager" }, { value: "pnpm", label: "pnpm", hint: "Fast, disk space efficient package manager" }, { value: "bun", label: "bun", hint: "All-in-one JavaScript runtime & toolkit" } ], initialValue: detectedPackageManager }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/payments.ts async function getPaymentsChoice(payments, auth, backend, frontends) { if (payments !== void 0) return payments; const isPolarCompatible = auth === "better-auth" && backend !== "convex" && (frontends?.length === 0 || splitFrontends(frontends).web.length > 0); if (!isPolarCompatible) return "none"; const options = [{ value: "polar", label: "Polar", hint: "Turn your software into a business. 6 lines of code." }, { value: "none", label: "None", hint: "No payments integration" }]; const response = await select({ message: "Select payments provider", options, initialValue: DEFAULT_CONFIG.payments }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/runtime.ts async function getRuntimeChoice(runtime, backend) { if (backend === "convex" || backend === "none") return "none"; if (runtime !== void 0) return runtime; if (backend === "next") return "node"; const runtimeOptions = [{ value: "bun", label: "Bun", hint: "Fast all-in-one JavaScript runtime" }, { value: "node", label: "Node.js", hint: "Traditional Node.js runtime" }]; if (backend === "hono") runtimeOptions.push({ value: "workers", label: "Cloudflare Workers", hint: "Edge runtime on Cloudflare's global network" }); const response = await select({ message: "Select runtime", options: runtimeOptions, initialValue: DEFAULT_CONFIG.runtime }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/server-deploy.ts function getDeploymentDisplay$1(deployment) { if (deployment === "wrangler") return { label: "Wrangler", hint: "Deploy to Cloudflare Workers using Wrangler" }; if (deployment === "alchemy") return { label: "Alchemy", hint: "Deploy to Cloudflare Workers using Alchemy" }; return { label: deployment, hint: `Add ${deployment} deployment` }; } async function getServerDeploymentChoice(deployment, runtime, backend, webDeploy) { if (deployment !== void 0) return deployment; if (backend === "none" || backend === "convex") return "none"; if (backend !== "hono") return "none"; const options = []; if (runtime !== "workers") return "none"; ["alchemy", "wrangler"].forEach((deploy) => { const { label, hint } = getDeploymentDisplay$1(deploy); options.unshift({ value: deploy, label, hint }); }); const response = await select({ message: "Select server deployment", options, initialValue: webDeploy === "alchemy" ? "alchemy" : runtime === "workers" ? "wrangler" : DEFAULT_CONFIG.serverDeploy }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } async function getServerDeploymentToAdd(runtime, existingDeployment, backend) { if (backend !== "hono") return "none"; const options = []; if (runtime === "workers") { if (existingDeployment !== "wrangler") { const { label, hint } = getDeploymentDisplay$1("wrangler"); options.push({ value: "wrangler", label, hint }); } if (existingDeployment !== "alchemy") { const { label, hint } = getDeploymentDisplay$1("alchemy"); options.push({ value: "alchemy", label, hint }); } } if (existingDeployment && existingDeployment !== "none") return "none"; if (options.length > 0) {} if (options.length === 0) return "none"; const response = await select({ message: "Select server deployment", options, initialValue: runtime === "workers" ? "wrangler" : DEFAULT_CONFIG.serverDeploy }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/web-deploy.ts function hasWebFrontend(frontends) { return frontends.some((f) => WEB_FRAMEWORKS.includes(f)); } function getDeploymentDisplay(deployment) { if (deployment === "wrangler") return { label: "Wrangler", hint: "Deploy to Cloudflare Workers using Wrangler" }; if (deployment === "alchemy") return { label: "Alchemy", hint: "Deploy to Cloudflare Workers using Alchemy" }; return { label: deployment, hint: `Add ${deployment} deployment` }; } async function getDeploymentChoice(deployment, _runtime, _backend, frontend = []) { if (deployment !== void 0) return deployment; if (!hasWebFrontend(frontend)) return "none"; const availableDeployments = [ "wrangler", "alchemy", "none" ]; const options = availableDeployments.map((deploy) => { const { label, hint } = getDeploymentDisplay(deploy); return { value: deploy, label, hint }; }); const response = await select({ message: "Select web deployment", options, initialValue: DEFAULT_CONFIG.webDeploy }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } async function getDeploymentToAdd(frontend, existingDeployment) { if (!hasWebFrontend(frontend)) return "none"; const options = []; if (existingDeployment !== "wrangler") { const { label, hint } = getDeploymentDisplay("wrangler"); options.push({ value: "wrangler", label, hint }); } if (existingDeployment !== "alchemy") { const { label, hint } = getDeploymentDisplay("alchemy"); options.push({ value: "alchemy", label, hint }); } if (existingDeployment && existingDeployment !== "none") return "none"; if (options.length > 0) options.push({ value: "none", label: "None", hint: "Skip deployment setup" }); if (options.length === 0) return "none"; const response = await select({ message: "Select web deployment", options, initialValue: DEFAULT_CONFIG.webDeploy }); if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } //#endregion //#region src/prompts/config-prompts.ts async function gatherConfig(flags, projectName, projectDir, relativePath) { const result = await group({ frontend: () => getFrontendChoice(flags.frontend, flags.backend, flags.auth), backend: ({ results }) => getBackendFrameworkChoice(flags.backend, results.frontend), runtime: ({ results }) => getRuntimeChoice(flags.runtime, results.backend), database: ({ results }) => getDatabaseChoice(flags.database, results.backend, results.runtime), orm: ({ results }) => getORMChoice(flags.orm, results.database !== "none", results.database, results.backend, results.runtime), api: ({ results }) => getApiChoice(flags.api, results.frontend, results.backend), auth: ({ results }) => getAuthChoice(flags.auth, results.database !== "none", results.backend, results.frontend), payments: ({ results }) => getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend), addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend, results.auth), examples: ({ results }) => getExamplesChoice(flags.examples, results.database, results.frontend, results.backend, results.api), dbSetup: ({ results }) => getDBSetupChoice(results.database ?? "none", flags.dbSetup, results.orm, results.backend, results.runtime), webDeploy: ({ results }) => getDeploymentChoice(flags.webDeploy, results.runtime, results.backend, results.frontend), serverDeploy: ({ results }) => getServerDeploymentChoice(flags.serverDeploy, results.runtime, results.backend, results.webDeploy), git: () => getGitChoice(flags.git), packageManager: () => getPackageManagerChoice(flags.packageManager), install: () => getinstallChoice(flags.install) }, { onCancel: () => exitCancelled("Operation cancelled") }); return { projectName, projectDir, relativePath, frontend: result.frontend, backend: result.backend, runtime: result.runtime, database: result.database, orm: result.orm, auth: result.auth, payments: result.payments, addons: result.addons, examples: result.examples, git: result.git, packageManager: result.packageManager, install: result.install, dbSetup: result.dbSetup, api: result.api, webDeploy: result.webDeploy, serverDeploy: result.serverDeploy }; } //#endregion //#region src/prompts/project-name.ts function isPathWithinCwd(targetPath) { const resolved = path.resolve(targetPath); const rel = path.relative(process.cwd(), resolved); return !rel.startsWith("..") && !path.isAbsolute(rel); } function validateDirectoryName(name) { if (name === ".") return void 0; const result = ProjectNameSchema.safeParse(name); if (!result.success) return result.error.issues[0]?.message || "Invalid project name"; return void 0; } async function getProjectName(initialName) { if (initialName) { if (initialName === ".") return initialName; const finalDirName = path.basename(initialName); const validationError = validateDirectoryName(finalDirName); if (!validationError) { const projectDir = path.resolve(process.cwd(), initialName); if (isPathWithinCwd(projectDir)) return initialName; consola.error(pc.red("Project path must be within current directory")); } } let isValid = false; let projectPath = ""; let defaultName = DEFAULT_CONFIG.projectName; let counter = 1; while (await fs.pathExists(path.resolve(process.cwd(), defaultName)) && (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0) { defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`; counter++; } while (!isValid) { const response = await text({ message: "Enter your project name or path (relative to current directory)", placeholder: defaultName, initialValue: initialName, defaultValue: defaultName, validate: (value) => { const nameToUse = String(value ?? "").trim() || defaultName; const finalDirName = path.basename(nameToUse); const validationError = validateDirectoryName(finalDirName); if (validationError) return validationError; if (nameToUse !== ".") { const projectDir = path.resolve(process.cwd(), nameToUse); if (!isPathWithinCwd(projectDir)) return "Project path must be within current directory"; } return void 0; } }); if (isCancel(response)) return exitCancelled("Operation cancelled."); projectPath = response || defaultName; isValid = true; } return projectPath; } //#endregion //#region src/utils/get-latest-cli-version.ts const getLatestCLIVersion = () => { const packageJsonPath = path.join(PKG_ROOT, "package.json"); const packageJsonContent = fs.readJSONSync(packageJsonPath); return packageJsonContent.version ?? "1.0.0"; }; //#endregion //#region src/utils/telemetry.ts /** * Returns true if telemetry/analytics should be enabled, false otherwise. * * - If BTS_TELEMETRY_DISABLED is present and "1", disables analytics. * - Otherwise, BTS_TELEMETRY: "0" disables, "1" enables (default: enabled). */ function isTelemetryEnabled() { const BTS_TELEMETRY_DISABLED = process.env.BTS_TELEMETRY_DISABLED; const BTS_TELEMETRY = "1"; if (BTS_TELEMETRY_DISABLED !== void 0) return BTS_TELEMETRY_DISABLED !== "1"; if (BTS_TELEMETRY !== void 0) return BTS_TELEMETRY === "1"; return true; } //#endregion //#region src/utils/analytics.ts const POSTHOG_API_KEY = "phc_8ZUxEwwfKMajJLvxz1daGd931dYbQrwKNficBmsdIrs"; const POSTHOG_HOST = "https://us.i.posthog.com"; function generateSessionId() { const rand = Math.random().toString(36).slice(2); const now = Date.now().toString(36); return `cli_${now}${rand}`; } async function trackProjectCreation(config, disableAnalytics = false) { if (!isTelemetryEnabled() || disableAnalytics) return; const sessionId = generateSessionId(); const { projectName, projectDir, relativePath,...safeConfig } = config; const payload = { api_key: POSTHOG_API_KEY, event: "project_created", properties: { ...safeConfig, cli_version: getLatestCLIVersion(), node_version: typeof process !== "undefined" ? process.version : "", platform: typeof process !== "undefined" ? process.platform : "", $ip: null }, distinct_id: sessionId }; try { await fetch(`${POSTHOG_HOST}/capture`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); } catch (_error) {} } //#endregion //#region src/utils/display-config.ts function displayConfig(config) { const configDisplay = []; if (config.projectName) configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`); if (config.frontend !== void 0) { const frontend = Array.isArray(config.frontend) ? config.frontend : [config.frontend]; const frontendText = frontend.length > 0 && frontend[0] !== void 0 ? frontend.join(", ") : "none"; configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`); } if (config.backend !== void 0) configDisplay.push(`${pc.blue("Backend:")} ${String(config.backend)}`); if (config.runtime !== void 0) configDisplay.push(`${pc.blue("Runtime:")} ${String(config.runtime)}`); if (config.api !== void 0) configDisplay.push(`${pc.blue("API:")} ${String(config.api)}`); if (config.database !== void 0) configDisplay.push(`${pc.blue("Database:")} ${String(config.database)}`); if (config.orm !== void 0) configDisplay.push(`${pc.blue("ORM:")} ${String(config.orm)}`); if (config.auth !== void 0) configDisplay.push(`${pc.blue("Auth:")} ${String(config.auth)}`); if (config.payments !== void 0) configDisplay.push(`${pc.blue("Payments:")} ${String(config.payments)}`); if (config.addons !== void 0) { const addons = Array.isArray(config.addons) ? config.addons : [config.addons]; const addonsText = addons.length > 0 && addons[0] !== void 0 ? addons.join(", ") : "none"; configDisplay.push(`${pc.blue("Addons:")} ${addonsText}`); } if (config.examples !== void 0) { const examples = Array.isArray(config.examples) ? config.examples : [config.examples]; const examplesText = examples.length > 0 && examples[0] !== void 0 ? examples.join(", ") : "none"; configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`); } if (config.git !== void 0) { const gitText = typeof config.git === "boolean" ? config.git ? "Yes" : "No" : String(config.git); configDisplay.push(`${pc.blue("Git Init:")} ${gitText}`); } if (config.packageManager !== void 0) configDisplay.push(`${pc.blue("Package Manager:")} ${String(config.packageManager)}`); if (config.install !== void 0) { const installText = typeof config.install === "boolean" ? config.install ? "Yes" : "No" : String(config.install); configDisplay.push(`${pc.blue("Install Dependencies:")} ${installText}`); } if (config.dbSetup !== void 0) configDisplay.push(`${pc.blue("Database Setup:")} ${String(config.dbSetup)}`); if (config.webDeploy !== void 0) configDisplay.push(`${pc.blue("Web Deployment:")} ${String(config.webDeploy)}`); if (config.serverDeploy !== void 0) configDisplay.push(`${pc.blue("Server Deployment:")} ${String(config.serverDeploy)}`); if (configDisplay.length === 0) return pc.yellow("No configuration selected."); return configDisplay.join("\n"); } //#endregion //#region src/utils/generate-reproducible-command.ts function generateReproducibleCommand(config) { const flags = []; if (config.frontend && config.frontend.length > 0) flags.push(`--frontend ${config.frontend.join(" ")}`); else flags.push("--frontend none"); flags.push(`--backend ${config.backend}`); flags.push(`--runtime ${config.runtime}`); flags.push(`--database ${config.database}`); flags.push(`--orm ${config.orm}`); flags.push(`--api ${config.api}`); flags.push(`--auth ${config.auth}`); flags.push(`--payments ${config.payments}`); if (config.addons && config.addons.length > 0) flags.push(`--addons ${config.addons.join(" ")}`); else flags.push("--addons none"); if (config.examples && config.examples.length > 0) flags.push(`--examples ${config.examples.join(" ")}`); else flags.push("--examples none"); flags.push(`--db-setup ${config.dbSetup}`); flags.push(`--web-deploy ${config.webDeploy}`); flags.push(`--server-deploy ${config.serverDeploy}`); flags.push(config.git ? "--git" : "--no-git"); flags.push(`--package-manager ${config.packageManager}`); flags.push(config.install ? "--install" : "--no-install"); let baseCommand = "npx create-better-t-stack@latest"; const pkgManager = config.packageManager; if (pkgManager === "bun") baseCommand = "bun create better-t-stack@latest"; else if (pkgManager === "pnpm") baseCommand = "pnpm create better-t-stack@latest"; else if (pkgManager === "npm") baseCommand = "npx create-better-t-stack@latest"; const projectPathArg = config.relativePath ? ` ${config.relativePath}` : ""; return `${baseCommand}${projectPathArg} ${flags.join(" ")}`; } //#endregion //#region src/utils/project-directory.ts async function handleDirectoryConflict(currentPathInput, silent = false) { while (true) { const resolvedPath = path.resolve(process.cwd(), currentPathInput); const dirExists = await fs.pathExists(resolvedPath); const dirIsNotEmpty = dirExists && (await fs.readdir(resolvedPath)).length > 0; if (!dirIsNotEmpty) return { finalPathInput: currentPathInput, shouldClearDirectory: false }; if (silent) throw new Error(`Directory "${currentPathInput}" already exists and is not empty. In silent mode, please provide a different project name or clear the directory manually.`); log.warn(`Directory "${pc.yellow(currentPathInput)}" already exists and is not empty.`); const action = await select({ message: "What would you like to do?", options: [ { value: "overwrite", label: "Overwrite", hint: "Empty the directory and create the project" }, { value: "merge", label: "Merge", hint: "Create project files inside, potentially overwriting conflicts" }, { value: "rename", label: "Choose a different name/path", hint: "Keep the existing directory and create a new one" }, { value: "cancel", label: "Cancel", hint: "Abort the process" } ], initialValue: "rename"