feat(cli): prisma + workers, prisma + turso, planetscale (postgres/mysql) support (#567)

This commit is contained in:
Aman Varshney
2025-09-08 12:15:26 +05:30
committed by GitHub
parent 33344d91be
commit cd5d0f0aeb
66 changed files with 1486 additions and 729 deletions

View File

@@ -1,90 +1,88 @@
{
"name": "create-better-t-stack",
"version": "2.40.5",
"description": "A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations",
"type": "module",
"license": "MIT",
"author": "Aman Varshney",
"bin": {
"create-better-t-stack": "dist/cli.js"
},
"files": [
"templates",
"dist"
],
"keywords": [
"better-t-stack",
"typescript",
"boilerplate",
"starter",
"cli",
"turborepo",
"trpc",
"better-auth",
"monorepo",
"fullstack",
"type-safety",
"react",
"react-native",
"expo",
"hono",
"elysia",
"drizzle",
"prisma",
"tanstack",
"tailwind",
"shadcn",
"pwa",
"tauri",
"biome"
],
"repository": {
"type": "git",
"url": "git+https://github.com/AmanVarshney01/create-better-t-stack.git",
"directory": "apps/cli"
},
"publishConfig": {
"access": "public"
},
"homepage": "https://better-t-stack.dev/",
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"check-types": "tsc --noEmit",
"check": "biome check --write .",
"test": "bun run build && vitest run",
"test:ui": "bun run build && vitest --ui",
"test:with-build": "bun run build && WITH_BUILD=1 vitest --ui",
"prepublishOnly": "npm run build"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@biomejs/js-api": "^3.0.0",
"@biomejs/wasm-nodejs": "^2.2.0",
"@clack/prompts": "^1.0.0-alpha.4",
"consola": "^3.4.2",
"execa": "^9.6.0",
"fs-extra": "^11.3.1",
"gradient-string": "^3.0.0",
"handlebars": "^4.7.8",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.14",
"trpc-cli": "^0.10.2",
"ts-morph": "^26.0.0",
"zod": "^4.0.17"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^24.3.0",
"@vitest/ui": "^3.2.4",
"tsdown": "^0.14.1",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
"name": "create-better-t-stack",
"version": "2.40.4",
"description": "A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations",
"type": "module",
"license": "MIT",
"author": "Aman Varshney",
"bin": {
"create-better-t-stack": "dist/cli.js"
},
"files": [
"templates",
"dist"
],
"keywords": [
"better-t-stack",
"typescript",
"boilerplate",
"starter",
"cli",
"turborepo",
"trpc",
"better-auth",
"monorepo",
"fullstack",
"type-safety",
"react",
"react-native",
"expo",
"hono",
"elysia",
"drizzle",
"prisma",
"tanstack",
"tailwind",
"shadcn",
"pwa",
"tauri",
"biome"
],
"repository": {
"type": "git",
"url": "git+https://github.com/AmanVarshney01/create-better-t-stack.git",
"directory": "apps/cli"
},
"publishConfig": {
"access": "public"
},
"homepage": "https://better-t-stack.dev/",
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"check-types": "tsc --noEmit",
"check": "biome check --write .",
"test": "bun run build && vitest run",
"test:ui": "bun run build && vitest --ui",
"test:with-build": "bun run build && WITH_BUILD=1 vitest --ui",
"prepublishOnly": "npm run build"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@clack/prompts": "^1.0.0-alpha.4",
"consola": "^3.4.2",
"execa": "^9.6.0",
"fs-extra": "^11.3.1",
"gradient-string": "^3.0.0",
"handlebars": "^4.7.8",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.15",
"trpc-cli": "^0.10.2",
"ts-morph": "^27.0.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^24.3.1",
"@vitest/ui": "^3.2.4",
"tsdown": "^0.14.2",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}

View File

@@ -50,6 +50,7 @@ export const dependencyVersionMap = {
"drizzle-orm": "^0.44.2",
"drizzle-kit": "^0.31.2",
"@planetscale/database": "^1.19.0",
"@libsql/client": "^0.15.9",
@@ -63,7 +64,11 @@ export const dependencyVersionMap = {
"@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",
@@ -141,12 +146,12 @@ export const dependencyVersionMap = {
wrangler: "^4.23.0",
"@cloudflare/vite-plugin": "^1.9.0",
"@opennextjs/cloudflare": "^1.3.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.63.0",
alchemy: "^0.65.0",
// temporary workaround for alchemy + tanstack start
nitropack: "^2.12.4",

View File

@@ -3,7 +3,6 @@ import fs from "fs-extra";
import type { ProjectConfig } from "../../types";
import { writeBtsConfig } from "../../utils/bts-config";
import { exitWithError } from "../../utils/errors";
import { formatProjectWithBiome } from "../../utils/format-with-biome";
import { setupAddons } from "../addons/addons-setup";
import { setupExamples } from "../addons/examples-setup";
import { setupApi } from "../core/api-setup";
@@ -86,8 +85,6 @@ export async function createProject(options: ProjectConfig) {
await writeBtsConfig(options);
await formatProjectWithBiome(projectDir);
if (isConvex) {
await runConvexCodegen(projectDir, options.packageManager);
}

View File

@@ -9,6 +9,7 @@ import { setupCloudflareD1 } from "../database-providers/d1-setup";
import { setupDockerCompose } from "../database-providers/docker-compose-setup";
import { setupMongoDBAtlas } from "../database-providers/mongodb-atlas-setup";
import { setupNeonPostgres } from "../database-providers/neon-setup";
import { setupPlanetScale } from "../database-providers/planetscale-setup";
import { setupPrismaPostgres } from "../database-providers/prisma-postgres-setup";
import { setupSupabase } from "../database-providers/supabase-setup";
import { setupTurso } from "../database-providers/turso-setup";
@@ -36,11 +37,29 @@ export async function setupDatabase(config: ProjectConfig) {
try {
if (orm === "prisma") {
await addPackageDependency({
dependencies: ["@prisma/client"],
devDependencies: ["prisma"],
projectDir: serverDir,
});
if (database === "mysql" && dbSetup === "planetscale") {
await addPackageDependency({
dependencies: [
"@prisma/client",
"@prisma/adapter-planetscale",
"@planetscale/database",
],
devDependencies: ["prisma"],
projectDir: serverDir,
});
} else if (database === "sqlite" && dbSetup === "turso") {
await addPackageDependency({
dependencies: ["@prisma/client", "@prisma/adapter-libsql"],
devDependencies: ["prisma"],
projectDir: serverDir,
});
} else {
await addPackageDependency({
dependencies: ["@prisma/client"],
devDependencies: ["prisma"],
projectDir: serverDir,
});
}
} else if (orm === "drizzle") {
if (database === "sqlite") {
await addPackageDependency({
@@ -55,6 +74,12 @@ export async function setupDatabase(config: ProjectConfig) {
devDependencies: ["drizzle-kit", "@types/ws"],
projectDir: serverDir,
});
} else if (dbSetup === "planetscale") {
await addPackageDependency({
dependencies: ["drizzle-orm", "pg"],
devDependencies: ["drizzle-kit", "@types/pg"],
projectDir: serverDir,
});
} else {
await addPackageDependency({
dependencies: ["drizzle-orm", "pg"],
@@ -63,11 +88,19 @@ export async function setupDatabase(config: ProjectConfig) {
});
}
} else if (database === "mysql") {
await addPackageDependency({
dependencies: ["drizzle-orm", "mysql2"],
devDependencies: ["drizzle-kit"],
projectDir: serverDir,
});
if (dbSetup === "planetscale") {
await addPackageDependency({
dependencies: ["drizzle-orm", "@planetscale/database"],
devDependencies: ["drizzle-kit"],
projectDir: serverDir,
});
} else {
await addPackageDependency({
dependencies: ["drizzle-orm", "mysql2"],
devDependencies: ["drizzle-kit"],
projectDir: serverDir,
});
}
}
} else if (orm === "mongoose") {
await addPackageDependency({
@@ -88,9 +121,15 @@ export async function setupDatabase(config: ProjectConfig) {
await setupPrismaPostgres(config);
} else if (dbSetup === "neon") {
await setupNeonPostgres(config);
} else if (dbSetup === "planetscale") {
await setupPlanetScale(config);
} else if (dbSetup === "supabase") {
await setupSupabase(config);
}
} else if (database === "mysql") {
if (dbSetup === "planetscale") {
await setupPlanetScale(config);
}
} else if (database === "mongodb" && dbSetup === "mongodb-atlas") {
await setupMongoDBAtlas(config);
}

View File

@@ -232,16 +232,8 @@ export async function setupEnvironmentVariables(config: ProjectConfig) {
}
let databaseUrl: string | null = null;
const specializedSetup =
dbSetup === "turso" ||
dbSetup === "prisma-postgres" ||
dbSetup === "mongodb-atlas" ||
dbSetup === "neon" ||
dbSetup === "supabase" ||
dbSetup === "d1" ||
dbSetup === "docker";
if (database !== "none" && !specializedSetup) {
if (database !== "none" && dbSetup === "none") {
switch (database) {
case "postgres":
databaseUrl = "postgresql://postgres:password@localhost:5432/postgres";
@@ -281,7 +273,7 @@ export async function setupEnvironmentVariables(config: ProjectConfig) {
{
key: "DATABASE_URL",
value: databaseUrl,
condition: database !== "none" && !specializedSetup,
condition: database !== "none" && dbSetup === "none",
},
{
key: "GOOGLE_GENERATIVE_AI_API_KEY",

View File

@@ -287,22 +287,40 @@ async function getDatabaseInstructions(
}
if (dbSetup === "d1" && serverDeploy === "alchemy") {
instructions.push(
`${pc.yellow(
"NOTE:",
)} D1 migrations are automatically handled by Alchemy`,
);
}
if (orm === "prisma") {
if (dbSetup === "turso") {
if (orm === "drizzle") {
instructions.push(
`${pc.yellow(
"NOTE:",
)} Turso support with Prisma is in Early Access and requires\n additional setup. Learn more at:\n https://www.prisma.io/docs/orm/overview/databases/turso`,
)} D1 migrations are automatically handled by Alchemy`,
);
} else if (orm === "prisma") {
instructions.push(
`${pc.cyan("•")} Generate migrations: ${`${runCmd} db:generate`}`,
);
instructions.push(
`${pc.cyan("•")} Apply migrations: ${`${runCmd} db:migrate`}`,
);
}
}
if (dbSetup === "planetscale") {
if (database === "mysql" && orm === "drizzle") {
instructions.push(
`${pc.yellow(
"NOTE:",
)} Enable foreign key constraints in PlanetScale database settings`,
);
}
if (database === "mysql" && orm === "prisma") {
instructions.push(
`${pc.yellow(
"NOTE:",
)} How to handle Prisma migrations with PlanetScale:\n https://github.com/prisma/prisma/issues/7292`,
);
}
}
if (orm === "prisma") {
if (database === "mongodb" && dbSetup === "docker") {
instructions.push(
`${pc.yellow(

View File

@@ -76,9 +76,7 @@ async function updateRootPackageJson(
}
if (options.orm === "prisma") {
scripts["db:generate"] = `turbo -F ${backendPackageName} db:generate`;
if (!(options.dbSetup === "d1" && options.serverDeploy === "alchemy")) {
scripts["db:migrate"] = `turbo -F ${backendPackageName} db:migrate`;
}
scripts["db:migrate"] = `turbo -F ${backendPackageName} db:migrate`;
} else if (options.orm === "drizzle") {
scripts["db:generate"] = `turbo -F ${backendPackageName} db:generate`;
if (!(options.dbSetup === "d1" && options.serverDeploy === "alchemy")) {
@@ -110,10 +108,8 @@ async function updateRootPackageJson(
if (options.orm === "prisma") {
scripts["db:generate"] =
`pnpm --filter ${backendPackageName} db:generate`;
if (!(options.dbSetup === "d1" && options.serverDeploy === "alchemy")) {
scripts["db:migrate"] =
`pnpm --filter ${backendPackageName} db:migrate`;
}
scripts["db:migrate"] =
`pnpm --filter ${backendPackageName} db:migrate`;
} else if (options.orm === "drizzle") {
scripts["db:generate"] =
`pnpm --filter ${backendPackageName} db:generate`;
@@ -149,10 +145,8 @@ async function updateRootPackageJson(
if (options.orm === "prisma") {
scripts["db:generate"] =
`npm run db:generate --workspace ${backendPackageName}`;
if (!(options.dbSetup === "d1" && options.serverDeploy === "alchemy")) {
scripts["db:migrate"] =
`npm run db:migrate --workspace ${backendPackageName}`;
}
scripts["db:migrate"] =
`npm run db:migrate --workspace ${backendPackageName}`;
} else if (options.orm === "drizzle") {
scripts["db:generate"] =
`npm run db:generate --workspace ${backendPackageName}`;
@@ -189,10 +183,8 @@ async function updateRootPackageJson(
if (options.orm === "prisma") {
scripts["db:generate"] =
`bun run --filter ${backendPackageName} db:generate`;
if (!(options.dbSetup === "d1" && options.serverDeploy === "alchemy")) {
scripts["db:migrate"] =
`bun run --filter ${backendPackageName} db:migrate`;
}
scripts["db:migrate"] =
`bun run --filter ${backendPackageName} db:migrate`;
} else if (options.orm === "drizzle") {
scripts["db:generate"] =
`bun run --filter ${backendPackageName} db:generate`;
@@ -278,9 +270,7 @@ async function updateServerPackageJson(
scripts["db:studio"] = "prisma studio";
}
scripts["db:generate"] = "prisma generate";
if (!(options.dbSetup === "d1" && options.serverDeploy === "alchemy")) {
scripts["db:migrate"] = "prisma migrate dev";
}
scripts["db:migrate"] = "prisma migrate dev";
} else if (options.orm === "drizzle") {
scripts["db:push"] = "drizzle-kit push";
if (!(options.dbSetup === "d1" && options.serverDeploy === "alchemy")) {

View File

@@ -1,9 +1,10 @@
import path from "node:path";
import type { ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
export async function setupCloudflareD1(config: ProjectConfig) {
const { projectDir, serverDeploy } = config;
const { projectDir, serverDeploy, orm } = config;
if (serverDeploy === "wrangler") {
const envPath = path.join(projectDir, "apps/server", ".env");
@@ -30,4 +31,28 @@ export async function setupCloudflareD1(config: ProjectConfig) {
await addEnvVariablesToFile(envPath, variables);
} catch (_err) {}
}
if (
(serverDeploy === "wrangler" || serverDeploy === "alchemy") &&
orm === "prisma"
) {
const envPath = path.join(projectDir, "apps/server", ".env");
const variables: EnvVariable[] = [
{
key: "DATABASE_URL",
value: "file:./local.db",
condition: true,
},
];
try {
await addEnvVariablesToFile(envPath, variables);
} catch (_err) {}
const serverDir = path.join(projectDir, "apps/server");
await addPackageDependency({
dependencies: ["@prisma/adapter-d1"],
projectDir: serverDir,
});
}
}

View File

@@ -1,11 +1,12 @@
import path from "node:path";
import { cancel, isCancel, log, spinner, text } from "@clack/prompts";
import { cancel, isCancel, log, select, spinner, text } from "@clack/prompts";
import consola from "consola";
import { execa } from "execa";
import fs from "fs-extra";
import pc from "picocolors";
import type { ProjectConfig } from "../../types";
import { commandExists } from "../../utils/command-exists";
import { exitCancelled } from "../../utils/errors";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
type MongoDBConfig = {
@@ -130,6 +131,32 @@ export async function setupMongoDBAtlas(config: ProjectConfig) {
try {
await fs.ensureDir(serverDir);
const mode = await select({
message: "MongoDB Atlas setup: choose mode",
options: [
{
label: "Automatic",
value: "auto",
hint: "Automated setup with provider CLI, sets .env",
},
{
label: "Manual",
value: "manual",
hint: "Manual setup, add env vars yourself",
},
],
initialValue: "auto",
});
if (isCancel(mode)) return exitCancelled("Operation cancelled");
if (mode === "manual") {
mainSpinner.stop("MongoDB Atlas manual setup selected");
await writeEnvFile(projectDir);
displayManualSetupInstructions();
return;
}
mainSpinner.stop("MongoDB Atlas setup ready");
const config = await initMongoDBAtlas(serverDir);

View File

@@ -158,6 +158,31 @@ export async function setupNeonPostgres(config: ProjectConfig) {
const { packageManager, projectDir } = config;
try {
const mode = await select({
message: "Neon setup: choose mode",
options: [
{
label: "Automatic",
value: "auto",
hint: "Automated setup with provider CLI, sets .env",
},
{
label: "Manual",
value: "manual",
hint: "Manual setup, add env vars yourself",
},
],
initialValue: "auto",
});
if (isCancel(mode)) return exitCancelled("Operation cancelled");
if (mode === "manual") {
await writeEnvFile(projectDir);
displayManualSetupInstructions();
return;
}
const setupMethod = await select({
message: "Choose your Neon setup method:",
options: [

View File

@@ -0,0 +1,79 @@
import path from "node:path";
import fs from "fs-extra";
import type { ProjectConfig } from "../../types";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
export async function setupPlanetScale(config: ProjectConfig) {
const { projectDir, database, orm } = config;
const envPath = path.join(projectDir, "apps/server", ".env");
if (database === "mysql" && orm === "drizzle") {
const variables: EnvVariable[] = [
{
key: "DATABASE_URL",
value:
'mysql://username:password@host/database?ssl={"rejectUnauthorized":true}',
condition: true,
},
{
key: "DATABASE_HOST",
value: "",
condition: true,
},
{
key: "DATABASE_USERNAME",
value: "",
condition: true,
},
{
key: "DATABASE_PASSWORD",
value: "",
condition: true,
},
];
await fs.ensureDir(path.join(projectDir, "apps/server"));
await addEnvVariablesToFile(envPath, variables);
}
if (database === "postgres" && orm === "prisma") {
const variables: EnvVariable[] = [
{
key: "DATABASE_URL",
value: "postgresql://username:password@host/database?sslaccept=strict",
condition: true,
},
];
await fs.ensureDir(path.join(projectDir, "apps/server"));
await addEnvVariablesToFile(envPath, variables);
}
if (database === "postgres" && orm === "drizzle") {
const variables: EnvVariable[] = [
{
key: "DATABASE_URL",
value:
"postgresql://username:password@host/database?sslmode=verify-full",
condition: true,
},
];
await fs.ensureDir(path.join(projectDir, "apps/server"));
await addEnvVariablesToFile(envPath, variables);
}
if (database === "mysql" && orm === "prisma") {
const variables: EnvVariable[] = [
{
key: "DATABASE_URL",
value: "mysql://username:password@host/database?sslaccept=strict",
condition: true,
},
];
await fs.ensureDir(path.join(projectDir, "apps/server"));
await addEnvVariablesToFile(envPath, variables);
}
}

View File

@@ -245,6 +245,31 @@ export async function setupPrismaPostgres(config: ProjectConfig) {
try {
await fs.ensureDir(serverDir);
const mode = await select({
message: "Prisma Postgres setup: choose mode",
options: [
{
label: "Automatic",
value: "auto",
hint: "Automated setup with provider CLI, sets .env",
},
{
label: "Manual",
value: "manual",
hint: "Manual setup, add env vars yourself",
},
],
initialValue: "auto",
});
if (isCancel(mode)) return exitCancelled("Operation cancelled");
if (mode === "manual") {
await writeEnvFile(projectDir);
displayManualSetupInstructions();
return;
}
const setupOptions = [
{
label: "Quick setup with create-db",

View File

@@ -1,10 +1,11 @@
import path from "node:path";
import { log } from "@clack/prompts";
import { isCancel, log, select } from "@clack/prompts";
import { consola } from "consola";
import { type ExecaError, execa } from "execa";
import fs from "fs-extra";
import pc from "picocolors";
import type { PackageManager, ProjectConfig } from "../../types";
import { exitCancelled } from "../../utils/errors";
import { getPackageExecutionCommand } from "../../utils/package-runner";
import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup";
@@ -159,6 +160,31 @@ export async function setupSupabase(config: ProjectConfig) {
try {
await fs.ensureDir(serverDir);
const mode = await select({
message: "Supabase setup: choose mode",
options: [
{
label: "Automatic",
value: "auto",
hint: "Automated setup with provider CLI, sets .env",
},
{
label: "Manual",
value: "manual",
hint: "Manual setup, add env vars yourself",
},
],
initialValue: "auto",
});
if (isCancel(mode)) return exitCancelled("Operation cancelled");
if (mode === "manual") {
displayManualSupabaseInstructions();
await writeSupabaseEnvFile(projectDir, "");
return;
}
const initialized = await initializeSupabase(serverDir, packageManager);
if (!initialized) {
displayManualSupabaseInstructions();

View File

@@ -197,23 +197,49 @@ export async function setupTurso(config: ProjectConfig) {
const { orm, projectDir } = config;
const _isDrizzle = orm === "drizzle";
const setupSpinner = spinner();
setupSpinner.start("Checking Turso CLI availability...");
try {
const mode = await select({
message: "Turso setup: choose mode",
options: [
{
label: "Automatic",
value: "auto",
hint: "Automated setup with provider CLI, sets .env",
},
{
label: "Manual",
value: "manual",
hint: "Manual setup, add env vars yourself",
},
],
initialValue: "auto",
});
if (isCancel(mode)) return exitCancelled("Operation cancelled");
if (mode === "manual") {
await writeEnvFile(projectDir);
displayManualSetupInstructions();
return;
}
setupSpinner.start("Checking Turso CLI availability...");
const platform = os.platform();
const isMac = platform === "darwin";
const _isLinux = platform === "linux";
const isWindows = platform === "win32";
if (isWindows) {
setupSpinner.stop(pc.yellow("Turso setup not supported on Windows"));
if (setupSpinner)
setupSpinner.stop(pc.yellow("Turso setup not supported on Windows"));
log.warn(pc.yellow("Automatic Turso setup is not supported on Windows."));
await writeEnvFile(projectDir);
displayManualSetupInstructions();
return;
}
setupSpinner.stop("Turso CLI availability checked");
if (setupSpinner) setupSpinner.stop("Turso CLI availability checked");
const isCliInstalled = await isTursoInstalled();
@@ -273,7 +299,8 @@ export async function setupTurso(config: ProjectConfig) {
log.success("Turso database setup completed successfully!");
} catch (error) {
setupSpinner.stop(pc.red("Turso CLI availability check failed"));
if (setupSpinner)
setupSpinner.stop(pc.red("Turso CLI availability check failed"));
consola.error(
pc.red(
`Error during Turso setup: ${

View File

@@ -12,7 +12,8 @@ export async function setupNextAlchemyDeploy(
if (!(await fs.pathExists(webAppDir))) return;
await addPackageDependency({
devDependencies: ["alchemy", "dotenv"],
dependencies: ["@opennextjs/cloudflare"],
devDependencies: ["alchemy", "dotenv", "wrangler"],
projectDir: webAppDir,
});
@@ -29,4 +30,22 @@ export async function setupNextAlchemyDeploy(
}
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
}
const openNextConfigPath = path.join(webAppDir, "open-next.config.ts");
const openNextConfigContent = `import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({});
`;
await fs.writeFile(openNextConfigPath, openNextConfigContent);
const gitignorePath = path.join(webAppDir, ".gitignore");
if (await fs.pathExists(gitignorePath)) {
const gitignoreContent = await fs.readFile(gitignorePath, "utf-8");
if (!gitignoreContent.includes("wrangler.jsonc")) {
await fs.appendFile(gitignorePath, "\nwrangler.jsonc\n");
}
} else {
await fs.writeFile(gitignorePath, "wrangler.jsonc\n");
}
}

View File

@@ -5,6 +5,7 @@ import fs from "fs-extra";
import pc from "picocolors";
import type { PackageManager, ProjectConfig } from "../../types";
import { addPackageDependency } from "../../utils/add-package-deps";
import { getPackageExecutionCommand } from "../../utils/package-runner";
export async function setupServerDeploy(config: ProjectConfig) {
const { serverDeploy, webDeploy, projectDir } = config;
@@ -64,8 +65,11 @@ async function generateCloudflareWorkerTypes({
const s = spinner();
try {
s.start("Generating Cloudflare Workers types...");
const runCmd = packageManager === "npm" ? "npm" : packageManager;
await execa(runCmd, ["run", "cf-typegen"], { cwd: serverDir });
const runCmd = getPackageExecutionCommand(
packageManager,
"wrangler types --env-interface CloudflareBindings",
);
await execa(runCmd, { cwd: serverDir, shell: true });
s.stop("Cloudflare Workers types generated successfully!");
} catch {
s.stop(pc.yellow("Failed to generate Cloudflare Workers types"));

View File

@@ -5,7 +5,7 @@ import { exitCancelled } from "../utils/errors";
export async function getDBSetupChoice(
databaseType: string,
dbSetup: DatabaseSetup | undefined,
orm?: ORM,
_orm?: ORM,
backend?: Backend,
runtime?: Runtime,
): Promise<DatabaseSetup> {
@@ -19,10 +19,6 @@ export async function getDBSetupChoice(
return "none";
}
if (databaseType === "sqlite" && orm === "prisma") {
return "none";
}
let options: Array<{ value: DatabaseSetup; label: string; hint: string }> =
[];
@@ -51,6 +47,11 @@ export async function getDBSetupChoice(
label: "Neon Postgres",
hint: "Serverless Postgres with branching capability",
},
{
value: "planetscale" as const,
label: "PlanetScale",
hint: "Serverless MySQL platform with branching (Postgres compatible)",
},
{
value: "supabase" as const,
label: "Supabase",
@@ -70,6 +71,11 @@ export async function getDBSetupChoice(
];
} else if (databaseType === "mysql") {
options = [
{
value: "planetscale" as const,
label: "PlanetScale",
hint: "Serverless MySQL platform with branching",
},
{
value: "docker" as const,
label: "Docker",

View File

@@ -35,10 +35,6 @@ export async function getORMChoice(
if (!hasDatabase) return "none";
if (orm !== undefined) return orm;
if (runtime === "workers") {
return "drizzle";
}
const options = [
...(database === "mongodb"
? [ormOptions.prisma, ormOptions.mongoose]
@@ -48,7 +44,12 @@ export async function getORMChoice(
const response = await select<ORM>({
message: "Select ORM",
options,
initialValue: database === "mongodb" ? "prisma" : DEFAULT_CONFIG.orm,
initialValue:
database === "mongodb"
? "prisma"
: runtime === "workers"
? "drizzle"
: DEFAULT_CONFIG.orm,
});
if (isCancel(response)) return exitCancelled("Operation cancelled");

View File

@@ -47,10 +47,7 @@ export async function getDeploymentChoice(
return "none";
}
const hasIncompatibleFrontend = frontend.some((f) => f === "next");
const availableDeployments = hasIncompatibleFrontend
? ["wrangler", "none"]
: ["wrangler", "alchemy", "none"];
const availableDeployments = ["wrangler", "alchemy", "none"];
const options: DeploymentOption[] = availableDeployments.map((deploy) => {
const { label, hint } = getDeploymentDisplay(deploy as WebDeploy);
@@ -64,9 +61,7 @@ export async function getDeploymentChoice(
const response = await select<WebDeploy>({
message: "Select web deployment",
options,
initialValue: hasIncompatibleFrontend
? "wrangler"
: DEFAULT_CONFIG.webDeploy,
initialValue: DEFAULT_CONFIG.webDeploy,
});
if (isCancel(response)) return exitCancelled("Operation cancelled");
@@ -82,8 +77,6 @@ export async function getDeploymentToAdd(
return "none";
}
const hasIncompatibleFrontend = frontend.some((f) => f === "next");
const options: DeploymentOption[] = [];
if (existingDeployment !== "wrangler") {
@@ -95,7 +88,7 @@ export async function getDeploymentToAdd(
});
}
if (existingDeployment !== "alchemy" && !hasIncompatibleFrontend) {
if (existingDeployment !== "alchemy") {
const { label, hint } = getDeploymentDisplay("alchemy");
options.push({
value: "alchemy",
@@ -123,9 +116,7 @@ export async function getDeploymentToAdd(
const response = await select<WebDeploy>({
message: "Select web deployment",
options,
initialValue: hasIncompatibleFrontend
? "wrangler"
: DEFAULT_CONFIG.webDeploy,
initialValue: DEFAULT_CONFIG.webDeploy,
});
if (isCancel(response)) return exitCancelled("Operation cancelled");

View File

@@ -68,6 +68,7 @@ export const DatabaseSetupSchema = z
"turso",
"neon",
"prisma-postgres",
"planetscale",
"mongodb-atlas",
"supabase",
"d1",

View File

@@ -68,37 +68,13 @@ export function validateWorkersCompatibility(
);
}
if (
providedFlags.has("runtime") &&
options.runtime === "workers" &&
config.orm &&
config.orm !== "drizzle" &&
config.orm !== "none"
) {
exitWithError(
`Cloudflare Workers runtime (--runtime workers) is only supported with Drizzle ORM (--orm drizzle) or no ORM (--orm none). Current ORM: ${config.orm}. Please use '--orm drizzle', '--orm none', or choose a different runtime.`,
);
}
if (
providedFlags.has("orm") &&
config.orm &&
config.orm !== "drizzle" &&
config.orm !== "none" &&
config.runtime === "workers"
) {
exitWithError(
`ORM '${config.orm}' is not compatible with Cloudflare Workers runtime. Cloudflare Workers runtime is only supported with Drizzle ORM or no ORM. Please use '--orm drizzle', '--orm none', 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 ORM. Please use a different database or runtime.",
"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.",
);
}
@@ -118,7 +94,7 @@ export function validateWorkersCompatibility(
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 ORM. Please use a different database or runtime.",
"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.",
);
}
@@ -261,29 +237,3 @@ export function validateExamplesCompatibility(
);
}
}
export function validateAlchemyCompatibility(
webDeploy: WebDeploy | undefined,
serverDeploy: ServerDeploy | undefined,
frontends: Frontend[] = [],
) {
const isAlchemyWebDeploy = webDeploy === "alchemy";
const isAlchemyServerDeploy = serverDeploy === "alchemy";
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
const incompatibleFrontends = frontends.filter((f) => f === "next");
if (incompatibleFrontends.length > 0) {
const deployType =
isAlchemyWebDeploy && isAlchemyServerDeploy
? "web and server deployment"
: isAlchemyWebDeploy
? "web deployment"
: "server deployment";
exitWithError(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")} frontend(s). Please choose a different frontend or deployment option.`,
);
}
}
}

View File

@@ -9,7 +9,6 @@ import {
ensureSingleWebAndNative,
isWebFrontend,
validateAddonsAgainstFrontends,
validateAlchemyCompatibility,
validateApiFrontendCompatibility,
validateExamplesCompatibility,
validateServerDeployRequiresBackend,
@@ -126,6 +125,10 @@ export function validateDatabaseSetup(
errorMessage:
"Prisma PostgreSQL setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.",
},
planetscale: {
errorMessage:
"PlanetScale setup requires PostgreSQL or MySQL database. Please use '--database postgres' or '--database mysql' or choose a different setup.",
},
"mongodb-atlas": {
database: "mongodb",
errorMessage:
@@ -152,8 +155,15 @@ export function validateDatabaseSetup(
if (dbSetup && dbSetup !== "none") {
const validation = setupValidations[dbSetup];
if (validation.database && database !== validation.database) {
exitWithError(validation.errorMessage);
// Special handling for PlanetScale - supports both postgres and mysql
if (dbSetup === "planetscale") {
if (database !== "postgres" && database !== "mysql") {
exitWithError(validation.errorMessage);
}
} else {
if (validation.database && database !== validation.database) {
exitWithError(validation.errorMessage);
}
}
if (validation.runtime && runtime !== validation.runtime) {
@@ -416,12 +426,6 @@ export function validateFullConfig(
config.database,
config.frontend ?? [],
);
validateAlchemyCompatibility(
config.webDeploy,
config.serverDeploy,
config.frontend ?? [],
);
}
export function validateConfigForProgrammaticUse(

View File

@@ -1,61 +0,0 @@
import path from "node:path";
import { Biome } from "@biomejs/js-api/nodejs";
import fs from "fs-extra";
import { glob } from "tinyglobby";
export async function formatProjectWithBiome(projectDir: string) {
const biome = new Biome();
const { projectKey } = biome.openProject(projectDir);
biome.applyConfiguration(projectKey, {
formatter: {
enabled: true,
indentStyle: "tab",
},
javascript: {
formatter: {
quoteStyle: "double",
},
},
});
const files = await glob("**/*", {
cwd: projectDir,
dot: true,
absolute: true,
onlyFiles: true,
});
for (const filePath of files) {
try {
const ext = path.extname(filePath).toLowerCase();
const supported = new Set([
".ts",
".tsx",
".js",
".jsx",
".cjs",
".mjs",
".cts",
".mts",
".json",
".jsonc",
".md",
".mdx",
".css",
".scss",
".html",
]);
if (!supported.has(ext)) continue;
const original = await fs.readFile(filePath, "utf8");
const result = biome.formatContent(projectKey, original, { filePath });
const content = result?.content;
if (typeof content !== "string") continue;
if (content.length === 0 && original.length > 0) continue;
if (content !== original) {
await fs.writeFile(filePath, content);
}
} catch {}
}
}

View File

@@ -4,7 +4,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}}
import { expo } from "@better-auth/expo";
{{/if}}
import prisma from "../../prisma";
import prisma from "../db";
export const auth = betterAuth({
database: prismaAdapter(prisma, {

View File

@@ -1,4 +1,15 @@
{{#if (or (eq runtime "bun") (eq runtime "node"))}}
{{#if (eq dbSetup "planetscale")}}
import { drizzle } from "drizzle-orm/planetscale-serverless";
export const db = drizzle({
connection: {
host: process.env.DATABASE_HOST,
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
},
});
{{else}}
import { drizzle } from "drizzle-orm/mysql2";
export const db = drizzle({
@@ -7,8 +18,21 @@ export const db = drizzle({
},
});
{{/if}}
{{/if}}
{{#if (eq runtime "workers")}}
{{#if (eq dbSetup "planetscale")}}
import { drizzle } from "drizzle-orm/planetscale-serverless";
import { env } from "cloudflare:workers";
export const db = drizzle({
connection: {
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
},
});
{{else}}
import { drizzle } from "drizzle-orm/mysql2";
import { env } from "cloudflare:workers";
@@ -18,3 +42,4 @@ export const db = drizzle({
},
});
{{/if}}
{{/if}}

View File

@@ -1,5 +0,0 @@
import { PrismaClient } from "./generated/client";
const prisma = new PrismaClient();
export default prisma;

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from "../../prisma/generated/client";
const prisma = new PrismaClient();
export default prisma;

View File

@@ -1,5 +0,0 @@
import { PrismaClient } from "./generated/client";
const prisma = new PrismaClient();
export default prisma;

View File

@@ -11,9 +11,15 @@ generator client {
{{#if (eq runtime "workers")}}
runtime = "workerd"
{{/if}}
{{#if (eq dbSetup "planetscale")}}
previewFeatures = ["driverAdapters"]
{{/if}}
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
{{#if (eq dbSetup "planetscale")}}
relationMode = "prisma"
{{/if}}
}

View File

@@ -0,0 +1,12 @@
import { PrismaClient } from "../../prisma/generated/client";
{{#if (eq dbSetup "planetscale")}}
import { PrismaPlanetScale } from '@prisma/adapter-planetscale'
const adapter = new PrismaPlanetScale({ url: process.env.DATABASE_URL })
const prisma = new PrismaClient({adapter});
{{else}}
const prisma = new PrismaClient();
{{/if}}
export default prisma;

View File

@@ -1,5 +0,0 @@
import { PrismaClient } from "./generated/client";
const prisma = new PrismaClient();
export default prisma;

View File

@@ -19,4 +19,7 @@ datasource db {
{{#if (eq dbSetup "supabase")}}
directUrl = env("DIRECT_URL")
{{/if}}
{{#if (eq dbSetup "planetscale")}}
relationMode = "prisma"
{{/if}}
}

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from "../../prisma/generated/client";
const prisma = new PrismaClient();
export default prisma;

View File

@@ -1,10 +1,38 @@
import "dotenv/config";
import path from "node:path";
import type { PrismaConfig } from "prisma";
{{#if (eq dbSetup "d1")}}
import { PrismaD1 } from "@prisma/adapter-d1";
{{/if}}
{{#if (eq dbSetup "turso")}}
import { PrismaLibSQL } from "@prisma/adapter-libsql";
{{/if}}
export default {
{{#if (or (eq dbSetup "d1") (eq dbSetup "turso"))}}
experimental: {
adapter: true
},
{{/if}}
schema: path.join("prisma", "schema"),
migrations: {
path: path.join("prisma", "migrations"),
}
},
{{#if (eq dbSetup "d1")}}
async adapter() {
return new PrismaD1({
CLOUDFLARE_D1_TOKEN: process.env.CLOUDFLARE_D1_TOKEN,
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
CLOUDFLARE_DATABASE_ID: process.env.CLOUDFLARE_DATABASE_ID,
});
},
{{/if}}
{{#if (eq dbSetup "turso")}}
async adapter() {
return new PrismaLibSQL({
url: process.env.DATABASE_URL || "",
authToken: process.env.DATABASE_AUTH_TOKEN,
});
},
{{/if}}
} satisfies PrismaConfig;

View File

@@ -1,5 +0,0 @@
import { PrismaClient } from "./generated/client";
const prisma = new PrismaClient();
export default prisma;

View File

@@ -10,10 +10,20 @@ generator client {
{{/if}}
{{#if (eq runtime "workers")}}
runtime = "workerd"
{{#if (eq dbSetup "d1")}}
previewFeatures = ["driverAdapters"]
{{/if}}
{{/if}}
{{#if (eq dbSetup "turso")}}
previewFeatures = ["driverAdapters"]
{{/if}}
}
datasource db {
provider = "sqlite"
{{#if (eq dbSetup "turso")}}
url = "file:./local.db"
{{else}}
url = env("DATABASE_URL")
{{/if}}
}

View File

@@ -0,0 +1,28 @@
{{#if (eq dbSetup "d1")}}
import { env } from "cloudflare:workers";
import { PrismaD1 } from "@prisma/adapter-d1";
import { PrismaClient } from "../../prisma/generated/client";
const adapter = new PrismaD1(env.DB);
const prisma = new PrismaClient({ adapter });
export default prisma;
{{else if (eq dbSetup "turso")}}
import { PrismaLibSQL } from "@prisma/adapter-libsql";
import { PrismaClient } from "../../prisma/generated/client";
const adapter = new PrismaLibSQL({
url: process.env.DATABASE_URL || "",
authToken: process.env.DATABASE_AUTH_TOKEN,
});
const prisma = new PrismaClient({ adapter });
export default prisma;
{{else}}
import { PrismaClient } from "../../prisma/generated/client";
const prisma = new PrismaClient();
export default prisma;
{{/if}}

View File

@@ -1,7 +1,7 @@
import alchemy from "alchemy";
{{#if (eq webDeploy "alchemy")}}
{{#if (includes frontend "next")}}
import { Next } from "alchemy/cloudflare";
import { Nextjs } from "alchemy/cloudflare";
{{else if (includes frontend "nuxt")}}
import { Nuxt } from "alchemy/cloudflare";
{{else if (includes frontend "svelte")}}
@@ -44,13 +44,17 @@ await Exec("db-generate", {
});
const db = await D1Database("database", {
{{#if (eq orm "prisma")}}
migrationsDir: "apps/server/prisma/migrations",
{{else if (eq orm "drizzle")}}
migrationsDir: "apps/server/src/db/migrations",
{{/if}}
});
{{/if}}
{{#if (eq webDeploy "alchemy")}}
{{#if (includes frontend "next")}}
export const web = await Next("web", {
export const web = await Nextjs("web", {
{{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}}
bindings: {
{{#if (eq backend "convex")}}

View File

@@ -27,7 +27,12 @@
"database_name": "YOUR_DB_NAME",
"database_id": "YOUR_DB_ID",
"preview_database_id": "local-test-db",
{{#if (eq orm "drizzle")}}
"migrations_dir": "./src/db/migrations"
{{/if}}
{{#if (eq orm "prisma")}}
"migrations_dir": "./prisma/migrations"
{{/if}}
}
]
{{/if}}

View File

@@ -1,6 +1,6 @@
{{#if (eq api "orpc")}}
import z from "zod";
import prisma from "../../prisma";
import prisma from "../db";
import { publicProcedure } from "../lib/orpc";
export const todoRouter = {
@@ -52,7 +52,7 @@ export const todoRouter = {
{{#if (eq api "trpc")}}
import { TRPCError } from "@trpc/server";
import z from "zod";
import prisma from "../../prisma";
import prisma from "../db";
import { publicProcedure, router } from "../lib/trpc";
export const todoRouter = router({

View File

@@ -1,7 +1,2 @@
[install]
{{#if (or (or (includes frontend "nuxt") (includes frontend "native-nativewind")) (includes frontend
"native-unistyles"))}}
# linker = "isolated"
{{else}}
linker = "isolated"
{{/if}}

View File

@@ -1,3 +1,6 @@
{{#if (or (eq webDeploy "alchemy") (eq webDeploy "wrangler"))}}
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
{{/if}}
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
@@ -5,3 +8,7 @@ const nextConfig: NextConfig = {
};
export default nextConfig;
{{#if (or (eq webDeploy "alchemy") (eq webDeploy "wrangler"))}}
initOpenNextCloudflareForDev();
{{/if}}

View File

@@ -2936,6 +2936,190 @@ describe("create-better-t-stack smoke", () => {
});
});
it("scaffolds with MySQL + Drizzle + PlanetScale", async () => {
const projectName = "app-mysql-drizzle-planetscale";
await runCli(
[
projectName,
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"bun",
"--database",
"mysql",
"--orm",
"drizzle",
"--api",
"trpc",
"--auth",
"none",
"--addons",
"none",
"--examples",
"none",
"--db-setup",
"planetscale",
"--web-deploy",
"none",
"--server-deploy",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
assertScaffoldedProject(projectDir);
assertBtsConfig(projectDir, {
database: "mysql",
orm: "drizzle",
});
});
it("scaffolds with MySQL + Prisma + PlanetScale", async () => {
const projectName = "app-mysql-prisma-planetscale";
await runCli(
[
projectName,
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"bun",
"--database",
"mysql",
"--orm",
"prisma",
"--api",
"trpc",
"--auth",
"none",
"--addons",
"none",
"--examples",
"none",
"--db-setup",
"planetscale",
"--web-deploy",
"none",
"--server-deploy",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
assertScaffoldedProject(projectDir);
assertBtsConfig(projectDir, {
database: "mysql",
orm: "prisma",
});
});
it("scaffolds with PostgreSQL + Drizzle + PlanetScale", async () => {
const projectName = "app-postgres-drizzle-planetscale";
await runCli(
[
projectName,
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"bun",
"--database",
"postgres",
"--orm",
"drizzle",
"--api",
"trpc",
"--auth",
"none",
"--addons",
"none",
"--examples",
"none",
"--db-setup",
"planetscale",
"--web-deploy",
"none",
"--server-deploy",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
database: "postgres",
orm: "drizzle",
});
});
it("scaffolds with PostgreSQL + Prisma + PlanetScale", async () => {
const projectName = "app-postgres-prisma-planetscale";
await runCli(
[
projectName,
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"bun",
"--database",
"postgres",
"--orm",
"prisma",
"--api",
"trpc",
"--auth",
"none",
"--addons",
"none",
"--examples",
"none",
"--db-setup",
"planetscale",
"--web-deploy",
"none",
"--server-deploy",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
assertScaffoldedProject(projectDir);
assertBtsConfig(projectDir, {
database: "postgres",
orm: "prisma",
});
});
it("scaffolds oRPC with Next.js", async () => {
const projectName = "app-orpc-next";
await runCli(
@@ -3210,6 +3394,100 @@ describe("create-better-t-stack smoke", () => {
runtime: "node",
});
});
it("scaffolds with MySQL + Drizzle + PlanetScale + Node runtime", async () => {
const projectName = "app-mysql-drizzle-planetscale-node";
await runCli(
[
projectName,
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"node",
"--database",
"mysql",
"--orm",
"drizzle",
"--api",
"trpc",
"--auth",
"none",
"--addons",
"none",
"--examples",
"none",
"--db-setup",
"planetscale",
"--web-deploy",
"none",
"--server-deploy",
"none",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
await assertScaffoldedProject(projectDir);
await assertBtsConfig(projectDir, {
database: "mysql",
orm: "drizzle",
runtime: "node",
});
});
it("scaffolds with MySQL + Prisma + PlanetScale + Workers runtime", async () => {
const projectName = "app-mysql-prisma-planetscale-workers";
await runCli(
[
projectName,
"--frontend",
"tanstack-router",
"--backend",
"hono",
"--runtime",
"workers",
"--database",
"mysql",
"--orm",
"prisma",
"--api",
"trpc",
"--auth",
"none",
"--addons",
"none",
"--examples",
"none",
"--db-setup",
"planetscale",
"--web-deploy",
"none",
"--server-deploy",
"wrangler",
"--package-manager",
"bun",
"--no-install",
"--no-git",
],
workdir,
);
const projectDir = join(workdir, projectName);
assertScaffoldedProject(projectDir);
assertBtsConfig(projectDir, {
database: "mysql",
orm: "prisma",
runtime: "workers",
});
});
});
(process.env.WITH_BUILD === "1" ? describe : describe.skip)(
@@ -3296,12 +3574,18 @@ describe("create-better-t-stack smoke", () => {
"app-with-auth",
"app-mysql-prisma",
"app-mysql-drizzle",
"app-mysql-drizzle-planetscale",
"app-mysql-prisma-planetscale",
"app-postgres-drizzle-planetscale",
"app-postgres-prisma-planetscale",
"app-orpc-next",
"app-orpc-nuxt",
"app-orpc-svelte",
"app-orpc-solid",
"app-backend-next",
"app-node-runtime",
"app-mysql-drizzle-planetscale-node",
"app-mysql-prisma-planetscale-workers",
].forEach((n) => {
projectNames.add(n);
});

View File

@@ -42,7 +42,7 @@ Cloudflare Workers has specific compatibility requirements:
| Component | Requirement | Reason |
|-----------|-------------|--------|
| Backend | Must be `hono` | Only Hono supports Workers runtime |
| ORM | Must be `drizzle` or `none` | Workers doesn't support Prisma/Mongoose |
| ORM | Must be `drizzle` or `prisma` | Workers supports Drizzle and Prisma; Mongoose is not supported |
| Database | Cannot be `mongodb` | MongoDB requires Prisma/Mongoose |
| Database Setup | Cannot be `docker` | Workers is serverless, no Docker support |
@@ -52,6 +52,9 @@ create-better-t-stack --runtime workers --backend express
# ✅ Valid - Workers with Hono
create-better-t-stack --runtime workers --backend hono --database sqlite --orm drizzle --db-setup d1
# ✅ Also valid - Workers with Prisma (D1)
create-better-t-stack --runtime workers --backend hono --database sqlite --orm prisma --db-setup d1
```
### Backend Presets
@@ -123,8 +126,8 @@ create-better-t-stack --frontend next native-nativewind
| Setup Provider | Required Database | Notes |
|---------------|------------------|-------|
| `turso` | `sqlite` | Distributed SQLite |
| `d1` | `sqlite` | Cloudflare D1 (requires Workers runtime) |
| `turso` | `sqlite` | Distributed SQLite; works with Drizzle and Prisma |
| `d1` | `sqlite` | Cloudflare D1 (requires Workers runtime); works with Drizzle and Prisma |
| `neon` | `postgres` | Serverless PostgreSQL |
| `supabase` | `postgres` | PostgreSQL with additional features |
| `prisma-postgres` | `postgres` | Managed PostgreSQL via Prisma |

View File

@@ -1,30 +0,0 @@
---
title: Compatibility
description: Valid and invalid combinations across frontend, backend, runtime, database, and addons
---
## Rules
- **Convex backend**: Sets database, ORM, and API to `none`; auth to `clerk` (if compatible frontends) or `none`
- **Backend `none`**: Forces API, ORM, database, authentication, and runtime to `none`; disables examples
- **Frontend `none`**: Backend-only project; PWA/Tauri/examples may be disabled
- **API `none`**: No tRPC/oRPC setup; use framework-native APIs
- **Database `none`**: Disables ORM and Better-Auth (but allows Clerk with Convex)
- **ORM `none`**: No ORM setup; manage DB manually
- **Runtime `none`**: Only with Convex backend or when backend is `none`
- **Auth `clerk`**: Only available with Convex backend and compatible frontends
## Cloudflare Workers
- Backend: `hono` only
- Database: `sqlite` with Cloudflare D1
- ORM: `drizzle` (or none)
- Not compatible with MongoDB
## Framework Notes
- SvelteKit, Nuxt, and SolidJS frontends are only compatible with `orpc` API layer
- PWA addon requires a web frontend: TanStack Router, React Router, Next.js, or SolidJS
- Tauri addon requires React (TanStack Router/React Router), Nuxt, SvelteKit, SolidJS, or Next.js
- AI example is not compatible with Elysia backend or SolidJS frontend

View File

@@ -7,7 +7,6 @@
"bts-config",
"analytics",
"contributing",
"compatibility",
"faq"
]
}

View File

@@ -1,72 +1,74 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --turbopack",
"start": "next start",
"check": "biome check --write .",
"postinstall": "fumadocs-mdx",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"generate-analytics": "bun scripts/generate-analytics.ts",
"generate-schema": "bun scripts/generate-schema.ts"
},
"dependencies": {
"@better-t-stack/backend": "workspace:*",
"@erquhart/convex-oss-stats": "^0.8.1",
"@number-flow/react": "^0.5.10",
"@opennextjs/cloudflare": "^1.6.3",
"@orama/orama": "^3.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.25.4",
"convex-helpers": "^0.1.104",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"fumadocs-core": "15.6.7",
"fumadocs-mdx": "11.7.3",
"fumadocs-ui": "15.6.7",
"lucide-react": "^0.536.0",
"motion": "^12.23.12",
"next": "15.3.5",
"next-themes": "^0.4.6",
"nuqs": "^2.5.2",
"papaparse": "^5.5.3",
"posthog-js": "^1.258.5",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-tweet": "^3.2.2",
"recharts": "2.15.4",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-mdx": "^3.1.0",
"shiki": "^3.9.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@types/culori": "^4.0.0",
"@types/mdx": "^2.0.13",
"@types/node": "24.1.0",
"@types/papaparse": "^5.3.16",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"eslint": "^9.32.0",
"eslint-config-next": "15.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"typescript": "^5.9.2",
"wrangler": "^4.27.0"
}
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --turbopack",
"start": "next start",
"check": "biome check --write .",
"postinstall": "fumadocs-mdx",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"generate-analytics": "bun scripts/generate-analytics.ts",
"generate-schema": "bun scripts/generate-schema.ts"
},
"dependencies": {
"@better-t-stack/backend": "workspace:*",
"@erquhart/convex-oss-stats": "^0.8.1",
"@number-flow/react": "^0.5.10",
"@opennextjs/cloudflare": "^1.6.3",
"@orama/orama": "^3.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"babel-plugin-react-compiler": "^19.1.0-rc.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"convex": "^1.25.4",
"convex-helpers": "^0.1.104",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"fumadocs-core": "15.6.7",
"fumadocs-mdx": "11.7.3",
"fumadocs-ui": "15.6.7",
"lucide-react": "^0.536.0",
"motion": "^12.23.12",
"next": "15.3.5",
"next-themes": "^0.4.6",
"nuqs": "^2.5.2",
"papaparse": "^5.5.3",
"posthog-js": "^1.258.5",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-tweet": "^3.2.2",
"recharts": "2.15.4",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-mdx": "^3.1.0",
"shiki": "^3.9.1",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@types/culori": "^4.0.0",
"@types/mdx": "^2.0.13",
"@types/node": "24.1.0",
"@types/papaparse": "^5.3.16",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"eslint": "^9.32.0",
"eslint-config-next": "15.4.5",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"typescript": "^5.9.2",
"wrangler": "^4.27.0"
}
}

View File

@@ -0,0 +1,62 @@
"use client";
import { RefreshCw, Settings, Shuffle, Star } from "lucide-react";
interface ActionButtonsProps {
onReset: () => void;
onRandom: () => void;
onSave: () => void;
onLoad: () => void;
hasSavedStack: boolean;
}
export function ActionButtons({
onReset,
onRandom,
onSave,
onLoad,
hasSavedStack,
}: ActionButtonsProps) {
return (
<div className="flex gap-1">
<button
type="button"
onClick={onReset}
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Reset to defaults"
>
<RefreshCw className="h-3 w-3" />
Reset
</button>
<button
type="button"
onClick={onRandom}
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Generate a random stack"
>
<Shuffle className="h-3 w-3" />
Random
</button>
<button
type="button"
onClick={onSave}
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Save current preferences"
>
<Star className="h-3 w-3" />
Save
</button>
{hasSavedStack && (
<button
type="button"
onClick={onLoad}
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Load saved preferences"
>
<Settings className="h-3 w-3" />
Load
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { ChevronDown, Zap } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { PRESET_TEMPLATES } from "@/lib/constant";
interface PresetDropdownProps {
onApplyPreset: (presetId: string) => void;
}
export function PresetDropdown({ onApplyPreset }: PresetDropdownProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
>
<Zap className="h-3 w-3" />
Presets
<ChevronDown className="ml-auto h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 bg-fd-background">
{PRESET_TEMPLATES.map((preset) => (
<DropdownMenuItem
key={preset.id}
onClick={() => onApplyPreset(preset.id)}
className="flex flex-col items-start gap-1 p-3"
>
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs">{preset.description}</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Share2 } from "lucide-react";
import { ShareDialog } from "@/components/ui/share-dialog";
import type { StackState } from "@/lib/constant";
interface ShareButtonProps {
stackUrl: string;
stackState: StackState;
}
export function ShareButton({ stackUrl, stackState }: ShareButtonProps) {
return (
<ShareDialog stackUrl={stackUrl} stackState={stackState}>
<button
type="button"
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Share your stack"
>
<Share2 className="h-3 w-3" />
Share
</button>
</ShareDialog>
);
}

View File

@@ -5,13 +5,8 @@ import {
ChevronDown,
ClipboardCopy,
InfoIcon,
RefreshCw,
Settings,
Share2,
Shuffle,
Star,
Terminal,
Zap,
} from "lucide-react";
import { motion } from "motion/react";
import type React from "react";
@@ -27,11 +22,9 @@ import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ShareDialog } from "@/components/ui/share-dialog";
import {
Tooltip,
TooltipContent,
@@ -51,7 +44,10 @@ import {
generateStackSharingUrl,
} from "@/lib/stack-utils";
import { cn } from "@/lib/utils";
import { ActionButtons } from "./action-buttons";
import { getBadgeColors } from "./get-badge-color";
import { PresetDropdown } from "./preset-dropdown";
import { ShareButton } from "./share-button";
import { TechIcon } from "./tech-icon";
import {
analyzeStackCompatibility,
@@ -60,6 +56,7 @@ import {
isOptionCompatible,
validateProjectName,
} from "./utils";
import { YoloToggle } from "./yolo-toggle";
const StackBuilder = () => {
const [stack, setStack] = useStackState();
@@ -407,7 +404,12 @@ const StackBuilder = () => {
const applyPreset = (presetId: string) => {
const preset = PRESET_TEMPLATES.find(
(template) => template.id === presetId,
(template: {
id: string;
name: string;
description: string;
stack: StackState;
}) => template.id === presetId,
);
if (preset) {
startTransition(() => {
@@ -505,92 +507,41 @@ const StackBuilder = () => {
<div className="mt-auto border-border border-t pt-4">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={resetStack}
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Reset to defaults"
>
<RefreshCw className="h-3.5 w-3.5" />
Reset
</button>
<button
type="button"
onClick={getRandomStack}
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Generate a random stack"
>
<Shuffle className="h-3.5 w-3.5" />
Random
</button>
</div>
<ActionButtons
onReset={resetStack}
onRandom={getRandomStack}
onSave={saveCurrentStack}
onLoad={loadSavedStack}
hasSavedStack={!!lastSavedStack}
/>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={saveCurrentStack}
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Save current preferences"
>
<Star className="h-3.5 w-3.5" />
Save
</button>
{lastSavedStack ? (
<button
type="button"
onClick={loadSavedStack}
className="flex items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Load saved preferences"
>
<Settings className="h-3.5 w-3.5" />
Load
</button>
) : (
<div className="h-9" />
)}
</div>
<div className="flex gap-1">
<ShareButton stackUrl={getStackUrl()} stackState={stack} />
<ShareDialog stackUrl={getStackUrl()} stackState={stack}>
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
title="Share your stack"
>
<Share2 className="h-3.5 w-3.5" />
Share Stack
</button>
</ShareDialog>
<PresetDropdown onApplyPreset={applyPreset} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-fd-background px-3 py-2 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
>
<Zap className="h-3.5 w-3.5" />
Quick Preset
<ChevronDown className="ml-auto h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-64 bg-fd-background"
>
{PRESET_TEMPLATES.map((preset) => (
<DropdownMenuItem
key={preset.id}
onClick={() => applyPreset(preset.id)}
className="flex flex-col items-start gap-1 p-3"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border bg-fd-background px-2 py-1.5 font-medium text-muted-foreground text-xs transition-all hover:border-muted-foreground/30 hover:bg-muted hover:text-foreground"
>
<div className="font-medium text-sm">
{preset.name}
</div>
<div className="text-xs">{preset.description}</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Settings className="h-3 w-3" />
Settings
<ChevronDown className="ml-auto h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-64 bg-fd-background"
>
<YoloToggle
stack={stack}
onToggle={(yolo) => setStack({ yolo })}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>

View File

@@ -61,6 +61,15 @@ interface CompatibilityResult {
export const analyzeStackCompatibility = (
stack: StackState,
): CompatibilityResult => {
// Skip all validation if YOLO mode is enabled
if (stack.yolo === "true") {
return {
adjustedStack: null,
notes: {},
changes: [],
};
}
const nextStack = { ...stack };
let changed = false;
const notes: CompatibilityResult["notes"] = {};
@@ -352,12 +361,12 @@ export const analyzeStackCompatibility = (
"Database set to 'SQLite' (Turso hosting requires SQLite database)",
});
}
if (nextStack.orm !== "drizzle") {
if (nextStack.orm !== "drizzle" && nextStack.orm !== "prisma") {
notes.dbSetup.notes.push(
"Turso requires Drizzle ORM. It will be selected.",
"Turso requires Drizzle or Prisma ORM. Drizzle will be selected.",
);
notes.orm.notes.push(
"Turso DB setup requires Drizzle ORM. It will be selected.",
"Turso DB setup requires Drizzle or Prisma ORM. Drizzle will be selected.",
);
notes.dbSetup.hasIssue = true;
notes.orm.hasIssue = true;
@@ -366,7 +375,7 @@ export const analyzeStackCompatibility = (
changes.push({
category: "dbSetup",
message:
"ORM set to 'Drizzle' (Turso hosting requires Drizzle ORM)",
"ORM set to 'Drizzle' (Turso hosting requires Drizzle or Prisma ORM)",
});
}
} else if (nextStack.dbSetup === "prisma-postgres") {
@@ -454,6 +463,27 @@ export const analyzeStackCompatibility = (
"Database set to 'PostgreSQL' (Supabase hosting requires PostgreSQL database)",
});
}
} else if (nextStack.dbSetup === "planetscale") {
if (
nextStack.database !== "postgres" &&
nextStack.database !== "mysql"
) {
notes.dbSetup.notes.push(
"PlanetScale requires PostgreSQL or MySQL. PostgreSQL will be selected.",
);
notes.database.notes.push(
"PlanetScale DB setup requires PostgreSQL or MySQL. PostgreSQL will be selected.",
);
notes.dbSetup.hasIssue = true;
notes.database.hasIssue = true;
nextStack.database = "postgres";
changed = true;
changes.push({
category: "dbSetup",
message:
"Database set to 'PostgreSQL' (PlanetScale supports PostgreSQL and MySQL)",
});
}
} else if (nextStack.dbSetup === "d1") {
if (nextStack.database !== "sqlite") {
notes.dbSetup.notes.push(
@@ -471,6 +501,23 @@ export const analyzeStackCompatibility = (
message: "Database set to 'SQLite' (required by Cloudflare D1)",
});
}
if (nextStack.orm !== "drizzle" && nextStack.orm !== "prisma") {
notes.dbSetup.notes.push(
"Cloudflare D1 requires Drizzle or Prisma ORM. Drizzle will be selected.",
);
notes.orm.notes.push(
"Cloudflare D1 DB setup requires Drizzle or Prisma ORM. Drizzle will be selected.",
);
notes.dbSetup.hasIssue = true;
notes.orm.hasIssue = true;
nextStack.orm = "drizzle";
changed = true;
changes.push({
category: "dbSetup",
message:
"ORM set to 'Drizzle' (Cloudflare D1 requires Drizzle or Prisma ORM)",
});
}
if (nextStack.runtime !== "workers") {
notes.dbSetup.notes.push(
"Cloudflare D1 requires Cloudflare Workers runtime. It will be selected.",
@@ -487,22 +534,6 @@ export const analyzeStackCompatibility = (
message: "Runtime set to 'Cloudflare Workers' (required by D1)",
});
}
if (nextStack.orm !== "drizzle") {
notes.dbSetup.notes.push(
"Cloudflare D1 requires Drizzle ORM. It will be selected.",
);
notes.orm.notes.push(
"Cloudflare D1 DB setup requires Drizzle ORM. It will be selected.",
);
notes.dbSetup.hasIssue = true;
notes.orm.hasIssue = true;
nextStack.orm = "drizzle";
changed = true;
changes.push({
category: "dbSetup",
message: "ORM set to 'Drizzle' (required by Cloudflare D1)",
});
}
if (nextStack.backend !== "hono") {
notes.dbSetup.notes.push(
"Cloudflare D1 requires Hono backend. It will be selected.",
@@ -581,6 +612,9 @@ export const analyzeStackCompatibility = (
} else if (nextStack.dbSetup === "mongodb-atlas") {
selectedDatabase = "mongodb";
databaseName = "MongoDB";
} else if (nextStack.dbSetup === "planetscale") {
selectedDatabase = "postgres";
databaseName = "PostgreSQL";
}
notes.dbSetup.notes.push(
@@ -618,24 +652,6 @@ export const analyzeStackCompatibility = (
});
}
if (nextStack.orm !== "drizzle" && nextStack.orm !== "none") {
notes.runtime.notes.push(
"Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.",
);
notes.orm.notes.push(
"Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.",
);
notes.runtime.hasIssue = true;
notes.orm.hasIssue = true;
nextStack.orm = "drizzle";
changed = true;
changes.push({
category: "runtime",
message:
"ORM set to 'Drizzle' (Cloudflare Workers runtime only supports Drizzle or no ORM)",
});
}
if (nextStack.database === "mongodb") {
notes.runtime.notes.push(
"Cloudflare Workers runtime is not compatible with MongoDB. SQLite will be selected.",
@@ -1050,49 +1066,6 @@ export const analyzeStackCompatibility = (
});
}
const isAlchemyWebDeploy = nextStack.webDeploy === "alchemy";
const isAlchemyServerDeploy = nextStack.serverDeploy === "alchemy";
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
const incompatibleFrontends = nextStack.webFrontend.filter(
(f) => f === "next",
);
if (incompatibleFrontends.length > 0) {
const deployType =
isAlchemyWebDeploy && isAlchemyServerDeploy
? "web and server deployment"
: isAlchemyWebDeploy
? "web deployment"
: "server deployment";
notes.webFrontend.notes.push(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}. These frontends will be removed.`,
);
notes.webDeploy.notes.push(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`,
);
notes.serverDeploy.notes.push(
`Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`,
);
notes.webFrontend.hasIssue = true;
notes.webDeploy.hasIssue = true;
notes.serverDeploy.hasIssue = true;
nextStack.webFrontend = nextStack.webFrontend.filter((f) => f !== "next");
if (nextStack.webFrontend.length === 0) {
nextStack.webFrontend = ["tanstack-router"];
}
changed = true;
changes.push({
category: "alchemy",
message: `Removed ${incompatibleFrontends.join(" and ")} frontend (temporarily not compatible with Alchemy ${deployType} - support coming soon)`,
});
}
}
if (
nextStack.serverDeploy === "alchemy" &&
(nextStack.runtime !== "workers" || nextStack.backend !== "hono")
@@ -1223,15 +1196,6 @@ export const getDisabledReason = (
const { adjustedStack } = analyzeStackCompatibility(simulatedStack);
const finalStack = adjustedStack ?? simulatedStack;
if (category === "webFrontend" && optionId === "next") {
const isAlchemyWebDeploy = finalStack.webDeploy === "alchemy";
const isAlchemyServerDeploy = finalStack.serverDeploy === "alchemy";
if (isAlchemyWebDeploy || isAlchemyServerDeploy) {
return "Next.js is temporarily not compatible with Alchemy deployment. Support coming soon!";
}
}
if (category === "webFrontend" && optionId === "solid") {
if (finalStack.backend === "convex") {
return "Solid is not compatible with Convex backend. Try TanStack Router, React Router, or Next.js instead.";
@@ -1383,11 +1347,17 @@ export const getDisabledReason = (
if (finalStack.database === "none") {
return "Prisma ORM requires a database. Select a database first (SQLite, PostgreSQL, MySQL, or MongoDB).";
}
if (finalStack.dbSetup === "turso" && finalStack.database !== "sqlite") {
return "Turso setup requires SQLite database. Select SQLite first.";
}
if (finalStack.dbSetup === "d1" && finalStack.database !== "sqlite") {
return "Cloudflare D1 setup requires SQLite database. Select SQLite first.";
}
}
if (category === "dbSetup" && optionId === "turso") {
if (finalStack.orm !== "drizzle") {
return "Turso requires Drizzle ORM. Select Drizzle first.";
if (finalStack.orm !== "drizzle" && finalStack.orm !== "prisma") {
return "Turso requires Drizzle or Prisma ORM. Select Drizzle or Prisma first.";
}
}
@@ -1398,8 +1368,8 @@ export const getDisabledReason = (
}
if (category === "dbSetup" && optionId === "d1") {
if (finalStack.orm !== "drizzle") {
return "Cloudflare D1 requires Drizzle ORM. Select Drizzle first.";
if (finalStack.orm !== "drizzle" && finalStack.orm !== "prisma") {
return "Cloudflare D1 requires Drizzle or Prisma ORM. Select Drizzle or Prisma first.";
}
if (finalStack.runtime !== "workers") {
return "Cloudflare D1 requires Cloudflare Workers runtime. Select Workers runtime first.";
@@ -1461,15 +1431,20 @@ export const getDisabledReason = (
finalStack.dbSetup !== "docker" &&
finalStack.dbSetup !== "prisma-postgres" &&
finalStack.dbSetup !== "neon" &&
finalStack.dbSetup !== "supabase"
finalStack.dbSetup !== "supabase" &&
finalStack.dbSetup !== "planetscale"
) {
return "PostgreSQL database only works with Docker, Prisma PostgreSQL, Neon, Supabase, or Basic Setup. Select one of these options or change database.";
return "PostgreSQL database only works with Docker, Prisma PostgreSQL, Neon, Supabase, PlanetScale, or Basic Setup. Select one of these options or change database.";
}
}
if (category === "database" && optionId === "mysql") {
if (finalStack.dbSetup !== "none" && finalStack.dbSetup !== "docker") {
return "MySQL database only works with Docker or Basic Setup. Select one of these options or change database.";
if (
finalStack.dbSetup !== "none" &&
finalStack.dbSetup !== "docker" &&
finalStack.dbSetup !== "planetscale"
) {
return "MySQL database only works with Docker, PlanetScale, or Basic Setup. Select one of these options or change database.";
}
}
@@ -1584,12 +1559,27 @@ export const getDisabledReason = (
}
}
if (category === "dbSetup" && optionId === "planetscale") {
if (finalStack.database !== "postgres" && finalStack.database !== "mysql") {
return "PlanetScale requires PostgreSQL or MySQL database. Select PostgreSQL or MySQL first.";
}
}
if (category === "dbSetup" && optionId === "supabase") {
if ((finalStack.database as string) !== "postgres") {
return "Supabase requires PostgreSQL database. Select PostgreSQL first.";
}
}
if (
category === "database" &&
(finalStack.dbSetup as string) === "planetscale"
) {
if (optionId !== "postgres" && optionId !== "mysql") {
return "Selected DB Setup 'PlanetScale' requires PostgreSQL or MySQL. Select PostgreSQL or MySQL, or change DB Setup.";
}
}
if (
category === "database" &&
(finalStack.dbSetup as string) === "supabase"
@@ -1607,5 +1597,8 @@ export const isOptionCompatible = (
category: keyof typeof TECH_OPTIONS,
optionId: string,
): boolean => {
if (currentStack.yolo === "true") {
return true;
}
return getDisabledReason(currentStack, category, optionId) === null;
};

View File

@@ -0,0 +1,49 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { StackState } from "@/lib/constant";
import { cn } from "@/lib/utils";
interface YoloToggleProps {
stack: StackState;
onToggle: (yolo: string) => void;
}
export function YoloToggle({ stack, onToggle }: YoloToggleProps) {
const isYoloEnabled = stack.yolo === "true";
return (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<div className="flex w-full items-center gap-3 p-3">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<div className="flex flex-1 flex-col items-start">
<div className="font-medium text-sm">YOLO Mode</div>
<div className="text-muted-foreground text-xs">
{isYoloEnabled ? "Enabled" : "Disabled"}
</div>
</div>
<Switch
checked={isYoloEnabled}
onCheckedChange={(checked) => onToggle(checked ? "true" : "false")}
className={cn(
isYoloEnabled && "data-[state=checked]:bg-destructive",
)}
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-xs">
<p className="text-xs">
Disables all validation and adds --yolo flag to the command. Use at
your own risk!
</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -114,7 +114,6 @@ export function ShareDialog({
);
};
// Generate QR code using local qrcode library
useEffect(() => {
const generateQRCode = async () => {
try {
@@ -264,25 +263,6 @@ export function ShareDialog({
</div>
</div>
</div>
<div className="rounded border border-border">
<div className="border-border border-b px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-primary text-xs"></span>
<span className="font-mono font-semibold text-foreground text-xs">
OUTPUT.URL
</span>
</div>
</div>
<div className="p-3">
<div className="flex items-center gap-2 text-xs">
<span className="text-primary">$</span>
<code className="flex-1 truncate text-muted-foreground">
{stackUrl}
</code>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,6 +1,6 @@
"use client";
import { Switch as SwitchPrimitive } from "radix-ui";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -0,0 +1,47 @@
"use client";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@@ -328,6 +328,13 @@ export const TECH_OPTIONS: Record<
icon: `${ICON_BASE_URL}/supabase.svg`,
color: "from-emerald-400 to-emerald-600",
},
{
id: "planetscale",
name: "PlanetScale",
description: "Serverless MySQL platform with branching",
icon: `${ICON_BASE_URL}/planetscale.svg`,
color: "from-orange-400 to-orange-600",
},
{
id: "docker",
name: "Docker",
@@ -604,6 +611,7 @@ export const PRESET_TEMPLATES = [
api: "trpc",
webDeploy: "none",
serverDeploy: "none",
yolo: "false",
},
},
{
@@ -628,6 +636,7 @@ export const PRESET_TEMPLATES = [
api: "none",
webDeploy: "none",
serverDeploy: "none",
yolo: "false",
},
},
{
@@ -652,6 +661,7 @@ export const PRESET_TEMPLATES = [
api: "trpc",
webDeploy: "none",
serverDeploy: "none",
yolo: "false",
},
},
{
@@ -676,6 +686,7 @@ export const PRESET_TEMPLATES = [
api: "trpc",
webDeploy: "none",
serverDeploy: "none",
yolo: "false",
},
},
{
@@ -700,6 +711,7 @@ export const PRESET_TEMPLATES = [
api: "trpc",
webDeploy: "alchemy",
serverDeploy: "alchemy",
yolo: "false",
},
},
];
@@ -722,6 +734,7 @@ export type StackState = {
api: string;
webDeploy: string;
serverDeploy: string;
yolo: string;
};
export const DEFAULT_STACK: StackState = {
@@ -742,6 +755,7 @@ export const DEFAULT_STACK: StackState = {
api: "trpc",
webDeploy: "none",
serverDeploy: "none",
yolo: "false",
};
export const isStackDefault = <K extends keyof StackState>(

View File

@@ -19,4 +19,5 @@ export const stackUrlKeys: UrlKeys<Record<keyof StackState, unknown>> = {
install: "i",
webDeploy: "wd",
serverDeploy: "sd",
yolo: "yolo",
};

View File

@@ -61,6 +61,9 @@ export const stackParsers = {
serverDeploy: parseAsStringEnum<StackState["serverDeploy"]>(
getValidIds("serverDeploy"),
).withDefault(DEFAULT_STACK.serverDeploy),
yolo: parseAsStringEnum<StackState["yolo"]>(["true", "false"]).withDefault(
DEFAULT_STACK.yolo,
),
};
export const stackQueryStatesOptions = {

View File

@@ -67,6 +67,10 @@ const serverStackParsers = {
serverDeploy: parseAsStringEnumServer<StackState["serverDeploy"]>(
getValidIds("serverDeploy"),
).withDefault(DEFAULT_STACK.serverDeploy),
yolo: parseAsStringEnumServer<StackState["yolo"]>([
"true",
"false",
]).withDefault(DEFAULT_STACK.yolo),
};
export const loadStackParams = createLoader(serverStackParsers, {

View File

@@ -119,6 +119,10 @@ export function generateStackCommand(stack: StackState): string {
`--examples ${stack.examples.join(" ") || "none"}`,
];
if (stack.yolo === "true") {
flags.push("--yolo");
}
return `${base} ${projectName} ${flags.join(" ")}`;
}