diff --git a/.changeset/tiny-plants-like.md b/.changeset/tiny-plants-like.md new file mode 100644 index 0000000..f526006 --- /dev/null +++ b/.changeset/tiny-plants-like.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +add convex diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 432adfa..cfbcb13 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -88,6 +88,12 @@ export const dependencyVersionMap = { "@trpc/tanstack-react-query": "^11.0.0", "@trpc/server": "^11.0.0", "@trpc/client": "^11.0.0", + + convex: "^1.23.0", + "@convex-dev/react-query": "^0.0.0-alpha.8", + + "@tanstack/react-query-devtools": "^5.69.0", + "@tanstack/react-query": "^5.69.0", } as const; export type AvailableDependencies = keyof typeof dependencyVersionMap; diff --git a/apps/cli/src/helpers/api-setup.ts b/apps/cli/src/helpers/api-setup.ts index 67b6f0d..2278f37 100644 --- a/apps/cli/src/helpers/api-setup.ts +++ b/apps/cli/src/helpers/api-setup.ts @@ -1,80 +1,94 @@ -import * as path from "node:path"; +import path from "node:path"; +import consola from "consola"; // Import consola import fs from "fs-extra"; -import type { ProjectConfig } from "../types"; +import type { AvailableDependencies } from "../constants"; +import type { ProjectConfig, ProjectFrontend } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export async function setupApi(config: ProjectConfig): Promise { - const { api, projectName, frontend } = config; + const { api, projectName, frontend, backend, packageManager } = config; const projectDir = path.resolve(process.cwd(), projectName); + const isConvex = backend === "convex"; const webDir = path.join(projectDir, "apps/web"); - const serverDir = path.join(projectDir, "apps/server"); + const nativeDir = path.join(projectDir, "apps/native"); const webDirExists = await fs.pathExists(webDir); - const hasReactWeb = frontend.some((f) => - ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), - ); - const hasNuxtWeb = frontend.includes("nuxt"); - const hasSvelteWeb = frontend.includes("svelte"); + const nativeDirExists = await fs.pathExists(nativeDir); - if (api === "orpc") { - await addPackageDependency({ - dependencies: ["@orpc/server", "@orpc/client"], - projectDir: serverDir, - }); - } else if (api === "trpc") { - await addPackageDependency({ - dependencies: ["@trpc/server", "@trpc/client"], - projectDir: serverDir, - }); - if (config.backend === "hono") { - await addPackageDependency({ - dependencies: ["@hono/trpc-server"], - projectDir: serverDir, - }); - } else if (config.backend === "elysia") { - await addPackageDependency({ - dependencies: ["@elysiajs/trpc"], - projectDir: serverDir, - }); - } - } + if (!isConvex && api !== "none") { + const serverDir = path.join(projectDir, "apps/server"); + const serverDirExists = await fs.pathExists(serverDir); - if (webDirExists) { - if (hasReactWeb) { + const hasReactWeb = frontend.some((f) => + ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), + ); + const hasNuxtWeb = frontend.includes("nuxt"); + const hasSvelteWeb = frontend.includes("svelte"); + + if (serverDirExists) { if (api === "orpc") { await addPackageDependency({ - dependencies: ["@orpc/react-query", "@orpc/client", "@orpc/server"], - projectDir: webDir, + dependencies: ["@orpc/server", "@orpc/client"], + projectDir: serverDir, }); } else if (api === "trpc") { await addPackageDependency({ - dependencies: [ - "@trpc/tanstack-react-query", - "@trpc/client", - "@trpc/server", - ], - projectDir: webDir, + dependencies: ["@trpc/server", "@trpc/client"], + projectDir: serverDir, }); + if (config.backend === "hono") { + await addPackageDependency({ + dependencies: ["@hono/trpc-server"], + projectDir: serverDir, + }); + } else if (config.backend === "elysia") { + await addPackageDependency({ + dependencies: ["@elysiajs/trpc"], + projectDir: serverDir, + }); + } } - } else if (hasNuxtWeb) { - if (api === "orpc") { - await addPackageDependency({ - dependencies: ["@orpc/vue-query", "@orpc/client", "@orpc/server"], - projectDir: webDir, - }); - } - } else if (hasSvelteWeb) { - if (api === "orpc") { - await addPackageDependency({ - dependencies: ["@orpc/svelte-query", "@orpc/client", "@orpc/server"], - projectDir: webDir, - }); + } else { + } + + if (webDirExists) { + if (hasReactWeb) { + if (api === "orpc") { + await addPackageDependency({ + dependencies: ["@orpc/react-query", "@orpc/client", "@orpc/server"], + projectDir: webDir, + }); + } else if (api === "trpc") { + await addPackageDependency({ + dependencies: [ + "@trpc/tanstack-react-query", + "@trpc/client", + "@trpc/server", + ], + projectDir: webDir, + }); + } + } else if (hasNuxtWeb) { + if (api === "orpc") { + await addPackageDependency({ + dependencies: ["@orpc/vue-query", "@orpc/client", "@orpc/server"], + projectDir: webDir, + }); + } + } else if (hasSvelteWeb) { + if (api === "orpc") { + await addPackageDependency({ + dependencies: [ + "@orpc/svelte-query", + "@orpc/client", + "@orpc/server", + ], + projectDir: webDir, + }); + } } } - } - if (frontend.includes("native")) { - const nativeDir = path.join(projectDir, "apps/native"); - if (await fs.pathExists(nativeDir)) { + if (nativeDirExists) { if (api === "trpc") { await addPackageDependency({ dependencies: [ @@ -92,4 +106,131 @@ export async function setupApi(config: ProjectConfig): Promise { } } } + + const reactBasedFrontends: ProjectFrontend[] = [ + "react-router", + "tanstack-router", + "tanstack-start", + "next", + "native", + ]; + const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f)); + + if (needsReactQuery && !isConvex) { + const reactQueryDeps: AvailableDependencies[] = ["@tanstack/react-query"]; + const reactQueryDevDeps: AvailableDependencies[] = [ + "@tanstack/react-query-devtools", + ]; + + const hasReactWeb = frontend.some( + (f) => f !== "native" && reactBasedFrontends.includes(f), + ); + const hasNative = frontend.includes("native"); + + if (hasReactWeb && webDirExists) { + const webPkgJsonPath = path.join(webDir, "package.json"); + if (await fs.pathExists(webPkgJsonPath)) { + try { + await addPackageDependency({ + dependencies: reactQueryDeps, + devDependencies: reactQueryDevDeps, + projectDir: webDir, + }); + } catch (error) {} + } else { + } + } + + if (hasNative && nativeDirExists) { + const nativePkgJsonPath = path.join(nativeDir, "package.json"); + if (await fs.pathExists(nativePkgJsonPath)) { + try { + await addPackageDependency({ + dependencies: reactQueryDeps, + projectDir: nativeDir, + }); + } catch (error) {} + } else { + } + } + } else if (needsReactQuery && isConvex) { + } + + if (isConvex) { + if (webDirExists) { + const webPkgJsonPath = path.join(webDir, "package.json"); + if (await fs.pathExists(webPkgJsonPath)) { + try { + const webDepsToAdd: AvailableDependencies[] = ["convex"]; + if (frontend.includes("tanstack-start")) { + webDepsToAdd.push("@convex-dev/react-query"); + } + + await addPackageDependency({ + dependencies: webDepsToAdd, + projectDir: webDir, + }); + } catch (error) {} + } else { + } + } + + if (nativeDirExists) { + const nativePkgJsonPath = path.join(nativeDir, "package.json"); + if (await fs.pathExists(nativePkgJsonPath)) { + try { + await addPackageDependency({ + dependencies: ["convex"], + projectDir: nativeDir, + }); + } catch (error) {} + } else { + } + } + + const backendPackageName = `@${projectName}/backend`; + const backendWorkspaceVersion = + packageManager === "npm" ? "*" : "workspace:*"; + const addWorkspaceDepManually = async ( + pkgJsonPath: string, + depName: string, + depVersion: string, + ) => { + try { + const pkgJson = await fs.readJson(pkgJsonPath); + if (!pkgJson.dependencies) { + pkgJson.dependencies = {}; + } + if (pkgJson.dependencies[depName] !== depVersion) { + pkgJson.dependencies[depName] = depVersion; + await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); + } else { + } + } catch (error) {} + }; + + if (webDirExists) { + const webPkgJsonPath = path.join(webDir, "package.json"); + if (await fs.pathExists(webPkgJsonPath)) { + await addWorkspaceDepManually( + webPkgJsonPath, + backendPackageName, + backendWorkspaceVersion, + ); + } else { + } + } + + if (nativeDirExists) { + const nativePkgJsonPath = path.join(nativeDir, "package.json"); + if (await fs.pathExists(nativePkgJsonPath)) { + await addWorkspaceDepManually( + nativePkgJsonPath, + backendPackageName, + backendWorkspaceVersion, + ); + } else { + } + } + } } diff --git a/apps/cli/src/helpers/auth-setup.ts b/apps/cli/src/helpers/auth-setup.ts index cf0281d..db2edb0 100644 --- a/apps/cli/src/helpers/auth-setup.ts +++ b/apps/cli/src/helpers/auth-setup.ts @@ -6,8 +6,9 @@ import type { ProjectConfig } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export async function setupAuth(config: ProjectConfig): Promise { - const { projectName, auth, frontend } = config; - if (!auth) { + const { projectName, auth, frontend, backend } = config; + + if (backend === "convex" || !auth) { return; } @@ -18,12 +19,15 @@ export async function setupAuth(config: ProjectConfig): Promise { const clientDirExists = await fs.pathExists(clientDir); const nativeDirExists = await fs.pathExists(nativeDir); + const serverDirExists = await fs.pathExists(serverDir); try { - await addPackageDependency({ - dependencies: ["better-auth"], - projectDir: serverDir, - }); + if (serverDirExists) { + await addPackageDependency({ + dependencies: ["better-auth"], + projectDir: serverDir, + }); + } const hasWebFrontend = frontend.some((f) => [ @@ -48,10 +52,12 @@ export async function setupAuth(config: ProjectConfig): Promise { dependencies: ["better-auth", "@better-auth/expo"], projectDir: nativeDir, }); - await addPackageDependency({ - dependencies: ["@better-auth/expo"], - projectDir: serverDir, - }); + if (serverDirExists) { + await addPackageDependency({ + dependencies: ["@better-auth/expo"], + projectDir: serverDir, + }); + } } } catch (error) { consola.error(pc.red("Failed to configure authentication dependencies")); diff --git a/apps/cli/src/helpers/backend-framework-setup.ts b/apps/cli/src/helpers/backend-framework-setup.ts index fa03806..6bdf33f 100644 --- a/apps/cli/src/helpers/backend-framework-setup.ts +++ b/apps/cli/src/helpers/backend-framework-setup.ts @@ -8,6 +8,11 @@ export async function setupBackendDependencies( config: ProjectConfig, ): Promise { const { projectName, backend, runtime, api } = config; + + if (backend === "convex") { + return; + } + const projectDir = path.resolve(process.cwd(), projectName); const framework = backend; const serverDir = path.join(projectDir, "apps/server"); @@ -47,9 +52,11 @@ export async function setupBackendDependencies( devDependencies.push("@types/bun"); } - await addPackageDependency({ - dependencies, - devDependencies, - projectDir: serverDir, - }); + if (dependencies.length > 0 || devDependencies.length > 0) { + await addPackageDependency({ + dependencies, + devDependencies, + projectDir: serverDir, + }); + } } diff --git a/apps/cli/src/helpers/create-project.ts b/apps/cli/src/helpers/create-project.ts index 813665f..5c62516 100644 --- a/apps/cli/src/helpers/create-project.ts +++ b/apps/cli/src/helpers/create-project.ts @@ -28,44 +28,46 @@ import { export async function createProject(options: ProjectConfig) { const projectDir = path.resolve(process.cwd(), options.projectName); + const isConvex = options.backend === "convex"; try { await fs.ensureDir(projectDir); await copyBaseTemplate(projectDir, options); await setupFrontendTemplates(projectDir, options); - await setupBackendFramework(projectDir, options); - await setupBackendDependencies(options); - - await setupDbOrmTemplates(projectDir, options); - - await setupDatabase(options); - - await setupAuthTemplate(projectDir, options); - await setupAuth(options); - + if (!isConvex) { + await setupDbOrmTemplates(projectDir, options); + await setupAuthTemplate(projectDir, options); + } + if (options.examples.length > 0 && options.examples[0] !== "none") { + await setupExamplesTemplate(projectDir, options); + } await setupAddonsTemplate(projectDir, options); + + await setupApi(options); + + if (!isConvex) { + await setupBackendDependencies(options); + await setupDatabase(options); + await setupRuntime(options); + if (options.examples.length > 0 && options.examples[0] !== "none") { + await setupExamples(options); + } + } + if (options.addons.length > 0 && options.addons[0] !== "none") { await setupAddons(options); } - await setupExamplesTemplate(projectDir, options); - await handleExtras(projectDir, options); - - if (options.examples.length > 0 && options.examples[0] !== "none") { - await setupExamples(options); + if (!isConvex && options.auth) { + await setupAuth(options); } - await setupApi(options); - - await setupRuntime(options); - + await handleExtras(projectDir, options); await setupEnvironmentVariables(options); - await updatePackageConfigurations(projectDir, options); await createReadme(projectDir, options); - await initializeGit(projectDir, options.git); log.success("Project template successfully scaffolded!"); @@ -89,6 +91,10 @@ export async function createProject(options: ProjectConfig) { cancel(pc.red(`Error during project creation: ${error.message}`)); console.error(error.stack); process.exit(1); + } else { + cancel(pc.red(`An unexpected error occurred: ${String(error)}`)); + console.error(error); + process.exit(1); } } } diff --git a/apps/cli/src/helpers/db-setup.ts b/apps/cli/src/helpers/db-setup.ts index cbf1d13..f6dc93d 100644 --- a/apps/cli/src/helpers/db-setup.ts +++ b/apps/cli/src/helpers/db-setup.ts @@ -13,13 +13,25 @@ import { setupNeonPostgres } from "./neon-setup"; import type { ProjectConfig } from "../types"; export async function setupDatabase(config: ProjectConfig): Promise { - const { projectName, database, orm, dbSetup } = config; + const { projectName, database, orm, dbSetup, backend } = config; + + if (backend === "convex" || database === "none") { + if (backend !== "convex") { + const projectDir = path.resolve(process.cwd(), projectName); + const serverDir = path.join(projectDir, "apps/server"); + const serverDbDir = path.join(serverDir, "src/db"); + if (await fs.pathExists(serverDbDir)) { + await fs.remove(serverDbDir); + } + } + return; + } + const projectDir = path.resolve(process.cwd(), projectName); const s = spinner(); const serverDir = path.join(projectDir, "apps/server"); - if (database === "none") { - await fs.remove(path.join(serverDir, "src/db")); + if (!(await fs.pathExists(serverDir))) { return; } diff --git a/apps/cli/src/helpers/env-setup.ts b/apps/cli/src/helpers/env-setup.ts index cb45cec..3478108 100644 --- a/apps/cli/src/helpers/env-setup.ts +++ b/apps/cli/src/helpers/env-setup.ts @@ -5,7 +5,7 @@ import { generateAuthSecret } from "./auth-setup"; interface EnvVariable { key: string; - value: string; + value: string | null | undefined; condition: boolean; } @@ -21,41 +21,59 @@ async function addEnvVariablesToFile( } let modified = false; + let contentToAdd = ""; + for (const { key, value, condition } of variables) { if (condition) { const regex = new RegExp(`^${key}=.*$`, "m"); + const valueToWrite = value ?? ""; + if (regex.test(envContent)) { - if (value) { - envContent = envContent.replace(regex, `${key}=${value}`); + const existingMatch = envContent.match(regex); + if (existingMatch && existingMatch[0] !== `${key}=${valueToWrite}`) { + envContent = envContent.replace(regex, `${key}=${valueToWrite}`); modified = true; } } else { - envContent += `\n${key}=${value}`; + contentToAdd += `${key}=${valueToWrite}\n`; modified = true; } } } + if (contentToAdd) { + if (envContent.length > 0 && !envContent.endsWith("\n")) { + envContent += "\n"; + } + envContent += contentToAdd; + } + if (modified) { - await fs.writeFile(filePath, envContent.trim()); + await fs.writeFile(filePath, envContent.trimEnd()); } } export async function setupEnvironmentVariables( config: ProjectConfig, ): Promise { - const { projectName } = config; + const { + projectName, + backend, + frontend, + database, + orm, + auth, + examples, + dbSetup, + } = config; const projectDir = path.resolve(process.cwd(), projectName); - const options = config; - const serverDir = path.join(projectDir, "apps/server"); - const envPath = path.join(serverDir, ".env"); - const hasReactRouter = options.frontend.includes("react-router"); - const hasTanStackRouter = options.frontend.includes("tanstack-router"); - const hasTanStackStart = options.frontend.includes("tanstack-start"); - const hasNextJs = options.frontend.includes("next"); - const hasNuxt = options.frontend.includes("nuxt"); - const hasSvelte = options.frontend.includes("svelte"); + const hasReactRouter = frontend.includes("react-router"); + const hasTanStackRouter = frontend.includes("tanstack-router"); + const hasTanStackStart = frontend.includes("tanstack-start"); + const hasNextJs = frontend.includes("next"); + const hasNuxt = frontend.includes("nuxt"); + const hasSvelte = frontend.includes("svelte"); const hasWebFrontend = hasReactRouter || hasTanStackRouter || @@ -64,30 +82,99 @@ export async function setupEnvironmentVariables( hasNuxt || hasSvelte; + if (hasWebFrontend) { + const clientDir = path.join(projectDir, "apps/web"); + if (await fs.pathExists(clientDir)) { + let envVarName = "VITE_SERVER_URL"; + let serverUrl = "http://localhost:3000"; + + if (hasNextJs) { + envVarName = "NEXT_PUBLIC_SERVER_URL"; + } else if (hasNuxt) { + envVarName = "NUXT_PUBLIC_SERVER_URL"; + } else if (hasSvelte) { + envVarName = "PUBLIC_SERVER_URL"; + } + + if (backend === "convex") { + if (hasNextJs) envVarName = "NEXT_PUBLIC_CONVEX_URL"; + else if (hasNuxt) envVarName = "NUXT_PUBLIC_CONVEX_URL"; + else if (hasSvelte) envVarName = "PUBLIC_CONVEX_URL"; + else envVarName = "VITE_CONVEX_URL"; + + serverUrl = "https://"; + } + + const clientVars: EnvVariable[] = [ + { + key: envVarName, + value: serverUrl, + condition: true, + }, + ]; + await addEnvVariablesToFile(path.join(clientDir, ".env"), clientVars); + } + } + + if (frontend.includes("native")) { + const nativeDir = path.join(projectDir, "apps/native"); + if (await fs.pathExists(nativeDir)) { + let envVarName = "EXPO_PUBLIC_SERVER_URL"; + let serverUrl = "http://localhost:3000"; + + if (backend === "convex") { + envVarName = "EXPO_PUBLIC_CONVEX_URL"; + serverUrl = "https://"; + } + + const nativeVars: EnvVariable[] = [ + { + key: envVarName, + value: serverUrl, + condition: true, + }, + ]; + await addEnvVariablesToFile(path.join(nativeDir, ".env"), nativeVars); + } + } + + if (backend === "convex") { + return; + } + + const serverDir = path.join(projectDir, "apps/server"); + if (!(await fs.pathExists(serverDir))) { + return; + } + const envPath = path.join(serverDir, ".env"); + let corsOrigin = "http://localhost:3001"; if (hasReactRouter || hasSvelte) { corsOrigin = "http://localhost:5173"; - } else if (hasTanStackRouter || hasTanStackStart || hasNextJs || hasNuxt) { - corsOrigin = "http://localhost:3001"; } - let databaseUrl = ""; + let databaseUrl: string | null = null; const specializedSetup = - options.dbSetup === "turso" || - options.dbSetup === "prisma-postgres" || - options.dbSetup === "mongodb-atlas" || - options.dbSetup === "neon"; + dbSetup === "turso" || + dbSetup === "prisma-postgres" || + dbSetup === "mongodb-atlas" || + dbSetup === "neon"; - if (!specializedSetup) { - if (options.database === "postgres") { - databaseUrl = - "postgresql://postgres:postgres@localhost:5432/mydb?schema=public"; - } else if (options.database === "mysql") { - databaseUrl = "mysql://root:password@localhost:3306/mydb"; - } else if (options.database === "mongodb") { - databaseUrl = "mongodb://localhost:27017/mydatabase"; - } else if (options.database === "sqlite") { - databaseUrl = "file:./local.db"; + if (database !== "none" && !specializedSetup) { + switch (database) { + case "postgres": + databaseUrl = + "postgresql://postgres:postgres@localhost:5432/mydb?schema=public"; + break; + case "mysql": + databaseUrl = "mysql://root:password@localhost:3306/mydb"; + break; + case "mongodb": + databaseUrl = "mongodb://localhost:27017/mydatabase"; + break; + case "sqlite": + databaseUrl = "file:./local.db"; + break; } } @@ -100,59 +187,24 @@ export async function setupEnvironmentVariables( { key: "BETTER_AUTH_SECRET", value: generateAuthSecret(), - condition: !!options.auth, + condition: !!auth, }, { key: "BETTER_AUTH_URL", value: "http://localhost:3000", - condition: !!options.auth, + condition: !!auth, }, { key: "DATABASE_URL", value: databaseUrl, - condition: - options.database !== "none" && databaseUrl !== "" && !specializedSetup, + condition: database !== "none" && !specializedSetup, }, { key: "GOOGLE_GENERATIVE_AI_API_KEY", value: "", - condition: options.examples?.includes("ai") || false, + condition: examples?.includes("ai") || false, }, ]; await addEnvVariablesToFile(envPath, serverVars); - - if (hasWebFrontend) { - const clientDir = path.join(projectDir, "apps/web"); - let envVarName = "VITE_SERVER_URL"; - - if (hasNextJs) { - envVarName = "NEXT_PUBLIC_SERVER_URL"; - } else if (hasNuxt) { - envVarName = "NUXT_PUBLIC_SERVER_URL"; - } else if (hasSvelte) { - envVarName = "PUBLIC_SERVER_URL"; - } - - const clientVars: EnvVariable[] = [ - { - key: envVarName, - value: "http://localhost:3000", - condition: true, - }, - ]; - await addEnvVariablesToFile(path.join(clientDir, ".env"), clientVars); - } - - if (options.frontend.includes("native")) { - const nativeDir = path.join(projectDir, "apps/native"); - const nativeVars: EnvVariable[] = [ - { - key: "EXPO_PUBLIC_SERVER_URL", - value: "http://localhost:3000", - condition: true, - }, - ]; - await addEnvVariablesToFile(path.join(nativeDir, ".env"), nativeVars); - } } diff --git a/apps/cli/src/helpers/examples-setup.ts b/apps/cli/src/helpers/examples-setup.ts index e8f1517..f22f91a 100644 --- a/apps/cli/src/helpers/examples-setup.ts +++ b/apps/cli/src/helpers/examples-setup.ts @@ -5,13 +5,24 @@ import type { ProjectConfig } from "../types"; import { addPackageDependency } from "../utils/add-package-deps"; export async function setupExamples(config: ProjectConfig): Promise { - const { projectName, examples, frontend } = config; + const { projectName, examples, frontend, backend } = config; + + if ( + backend === "convex" || + !examples || + examples.length === 0 || + examples[0] === "none" + ) { + return; + } + const projectDir = path.resolve(process.cwd(), projectName); if (examples.includes("ai")) { const clientDir = path.join(projectDir, "apps/web"); const serverDir = path.join(projectDir, "apps/server"); const clientDirExists = await fs.pathExists(clientDir); + const serverDirExists = await fs.pathExists(serverDir); const hasNuxt = frontend.includes("nuxt"); const hasSvelte = frontend.includes("svelte"); @@ -22,6 +33,7 @@ export async function setupExamples(config: ProjectConfig): Promise { dependencies.push("@ai-sdk/vue"); } else if (hasSvelte) { dependencies.push("@ai-sdk/svelte"); + } else { } await addPackageDependency({ dependencies, @@ -29,9 +41,11 @@ export async function setupExamples(config: ProjectConfig): Promise { }); } - await addPackageDependency({ - dependencies: ["ai", "@ai-sdk/google"], - projectDir: serverDir, - }); + if (serverDirExists) { + await addPackageDependency({ + dependencies: ["ai", "@ai-sdk/google"], + projectDir: serverDir, + }); + } } } diff --git a/apps/cli/src/helpers/post-installation.ts b/apps/cli/src/helpers/post-installation.ts index d3e589c..45acc3e 100644 --- a/apps/cli/src/helpers/post-installation.ts +++ b/apps/cli/src/helpers/post-installation.ts @@ -1,6 +1,11 @@ import { consola } from "consola"; import pc from "picocolors"; -import type { ProjectDatabase, ProjectOrm, ProjectRuntime } from "../types"; +import type { + ProjectBackend, + ProjectDatabase, + ProjectOrm, + ProjectRuntime, +} from "../types"; import { getPackageExecutionCommand } from "../utils/get-package-execution-command"; import type { ProjectConfig } from "../types"; @@ -17,16 +22,20 @@ export function displayPostInstallInstructions( addons, runtime, frontend, + backend, } = config; + + const isConvex = backend === "convex"; const runCmd = packageManager === "npm" ? "npm run" : packageManager; const cdCmd = `cd ${projectName}`; const hasHuskyOrBiome = addons?.includes("husky") || addons?.includes("biome"); const databaseInstructions = - database !== "none" + !isConvex && database !== "none" ? getDatabaseInstructions(database, orm, runCmd, runtime) : ""; + const tauriInstructions = addons?.includes("tauri") ? getTauriInstructions(runCmd) : ""; @@ -34,7 +43,7 @@ export function displayPostInstallInstructions( ? getLintingInstructions(runCmd) : ""; const nativeInstructions = frontend?.includes("native") - ? getNativeInstructions() + ? getNativeInstructions(isConvex) : ""; const pwaInstructions = addons?.includes("pwa") && @@ -45,6 +54,7 @@ export function displayPostInstallInstructions( const starlightInstructions = addons?.includes("starlight") ? getStarlightInstructions(runCmd) : ""; + const hasWeb = frontend?.some((f) => [ "tanstack-router", @@ -56,78 +66,86 @@ export function displayPostInstallInstructions( ].includes(f), ); const hasNative = frontend?.includes("native"); + const bunWebNativeWarning = packageManager === "bun" && hasNative && hasWeb ? getBunWebNativeWarning() : ""; const noOrmWarning = - database !== "none" && orm === "none" ? getNoOrmWarning() : ""; + !isConvex && database !== "none" && orm === "none" ? getNoOrmWarning() : ""; - const hasTanstackRouter = frontend?.includes("tanstack-router"); - const hasTanstackStart = frontend?.includes("tanstack-start"); const hasReactRouter = frontend?.includes("react-router"); - const hasNuxt = frontend?.includes("nuxt"); const hasSvelte = frontend?.includes("svelte"); - const hasWebFrontend = - hasTanstackRouter || - hasReactRouter || - hasTanstackStart || - hasNuxt || - hasSvelte; - const hasNativeFrontend = frontend?.includes("native"); - const hasFrontend = hasWebFrontend || hasNativeFrontend; - const webPort = hasReactRouter || hasSvelte ? "5173" : "3001"; + const tazeCommand = getPackageExecutionCommand(packageManager, "taze -r"); - consola.box( - `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd} -${ - !depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : "" -}${pc.cyan(depsInstalled ? "2." : "3.")} ${runCmd} dev -${pc.bold("Your project will be available at:")} -${ - hasFrontend - ? `${ - hasWebFrontend - ? `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n` - : "" - }` - : `${pc.yellow( - "NOTE:", - )} You are creating a backend-only app (no frontend selected)\n` -}${pc.cyan("•")} Backend: http://localhost:3000 -${ - addons?.includes("starlight") - ? `${pc.cyan("•")} Docs: http://localhost:4321\n` - : "" -}${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${ - databaseInstructions ? `\n${databaseInstructions.trim()}` : "" -}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${ - lintingInstructions ? `\n${lintingInstructions.trim()}` : "" -}${pwaInstructions ? `\n${pwaInstructions.trim()}` : ""}${ - starlightInstructions ? `\n${starlightInstructions.trim()}` : "" -}${noOrmWarning ? `\n${noOrmWarning.trim()}` : ""}${ - bunWebNativeWarning ? `\n${bunWebNativeWarning.trim()}` : "" + let output = `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}\n`; + let stepCounter = 2; + + if (!depsInstalled) { + output += `${pc.cyan(`${stepCounter++}.`)} ${packageManager} install\n`; + } + + if (isConvex) { + output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup ${pc.dim("(this will guide you through Convex project setup)")}\n`; + output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`; + } else { + output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`; + } + + output += `${pc.bold("Your project will be available at:")}\n`; + + if (hasWeb) { + output += `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`; + } else if (!hasNative && !addons?.includes("starlight")) { + output += `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n`; + } + + if (!isConvex) { + output += `${pc.cyan("•")} Backend API: http://localhost:3000\n`; + } + + if (addons?.includes("starlight")) { + output += `${pc.cyan("•")} Docs: http://localhost:4321\n`; + } + + if (nativeInstructions) output += `\n${nativeInstructions.trim()}\n`; + if (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`; + if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`; + if (lintingInstructions) output += `\n${lintingInstructions.trim()}\n`; + if (pwaInstructions) output += `\n${pwaInstructions.trim()}\n`; + if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`; + + if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`; + if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`; + + output += `\n${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)}\n\n`; + output += `${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub:\n`; + output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack"); + + consola.box(output); } -${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)} +function getNativeInstructions(isConvex: boolean): string { + const envVar = isConvex ? "EXPO_PUBLIC_CONVEX_URL" : "EXPO_PUBLIC_SERVER_URL"; + const exampleUrl = isConvex + ? "https://" + : "http://:3000"; + const envFileName = ".env"; + const ipNote = isConvex + ? "your Convex deployment URL (find after running 'dev:setup')" + : "your local IP address"; -${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub: -${pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack")}`, - ); -} - -function getNativeInstructions(): string { return `${pc.yellow( "NOTE:", - )} For Expo connectivity issues, update apps/native/.env \nwith your local IP:\n${"EXPO_PUBLIC_SERVER_URL=http://192.168.0.103:3000"}\n`; + )} For Expo connectivity issues, update apps/native/${envFileName} \nwith ${ipNote}:\n${`${envVar}=${exampleUrl}`}\n`; } function getLintingInstructions(runCmd?: string): string { return `${pc.bold("Linting and formatting:")}\n${pc.cyan( "•", - )} Format and lint fix: ${`${runCmd} check`}\n\n`; + )} Format and lint fix: ${`${runCmd} check`}\n`; } function getDatabaseInstructions( @@ -161,10 +179,19 @@ function getDatabaseInstructions( } else if (orm === "drizzle") { instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`); instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`); + if (database === "sqlite") { + instructions.push( + `${pc.cyan("•")} Start local DB (if needed): ${`cd apps/server && ${runCmd} db:local`}`, + ); + } + } else if (orm === "none") { + instructions.push( + `${pc.yellow("NOTE:")} Manual database schema setup required.`, + ); } return instructions.length - ? `${pc.bold("Database commands:")}\n${instructions.join("\n")}\n\n` + ? `${pc.bold("Database commands:")}\n${instructions.join("\n")}` : ""; } @@ -175,31 +202,31 @@ function getTauriInstructions(runCmd?: string): string { "•", )} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow( "NOTE:", - )} Tauri requires Rust and platform-specific dependencies.\nSee: ${"https://v2.tauri.app/start/prerequisites/"}\n\n`; + )} Tauri requires Rust and platform-specific dependencies.\nSee: ${"https://v2.tauri.app/start/prerequisites/"}`; } function getPwaInstructions(): string { - return `${pc.bold("PWA with React Router v7:")}\n${pc.yellow( + return `\n${pc.bold("PWA with React Router v7:")}\n${pc.yellow( "NOTE:", - )} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809\n`; + )} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809`; } function getStarlightInstructions(runCmd?: string): string { - return `${pc.bold("Documentation with Starlight:")}\n${pc.cyan( + return `\n${pc.bold("Documentation with Starlight:")}\n${pc.cyan( "•", )} Start docs site: ${`cd apps/docs && ${runCmd} dev`}\n${pc.cyan( "•", - )} Build docs site: ${`cd apps/docs && ${runCmd} build`}\n`; + )} Build docs site: ${`cd apps/docs && ${runCmd} build`}`; } function getNoOrmWarning(): string { return `\n${pc.yellow( "WARNING:", - )} Database selected without an ORM. Features requiring database access (e.g., examples, auth) need manual setup.\n`; + )} Database selected without an ORM. Features requiring database access (e.g., examples, auth) need manual setup.`; } function getBunWebNativeWarning(): string { return `\n${pc.yellow( "WARNING:", - )} 'bun' might cause issues with web + native apps in a monorepo. Use 'pnpm' if problems arise.\n`; + )} 'bun' might cause issues with web + native apps in a monorepo. Use 'pnpm' if problems arise.`; } diff --git a/apps/cli/src/helpers/project-config.ts b/apps/cli/src/helpers/project-config.ts index 7085cb0..98e2b35 100644 --- a/apps/cli/src/helpers/project-config.ts +++ b/apps/cli/src/helpers/project-config.ts @@ -3,7 +3,6 @@ import { log } from "@clack/prompts"; import { $, execa } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; -import { dependencyVersionMap } from "../constants"; import type { ProjectConfig } from "../types"; export async function updatePackageConfigurations( @@ -11,7 +10,11 @@ export async function updatePackageConfigurations( options: ProjectConfig, ): Promise { await updateRootPackageJson(projectDir, options); - await updateServerPackageJson(projectDir, options); + if (options.backend !== "convex") { + await updateServerPackageJson(projectDir, options); + } else { + await updateConvexPackageJson(projectDir, options); + } } async function updateRootPackageJson( @@ -19,75 +22,148 @@ async function updateRootPackageJson( options: ProjectConfig, ): Promise { const rootPackageJsonPath = path.join(projectDir, "package.json"); - if (await fs.pathExists(rootPackageJsonPath)) { - const packageJson = await fs.readJson(rootPackageJsonPath); - packageJson.name = options.projectName; + if (!(await fs.pathExists(rootPackageJsonPath))) return; - const turboScripts = { - dev: "turbo dev", - build: "turbo build", - "check-types": "turbo check-types", - "dev:native": "turbo -F native dev", - "dev:web": "turbo -F web dev", - "dev:server": "turbo -F server dev", - "db:push": "turbo -F server db:push", - "db:studio": "turbo -F server db:studio", - }; + const packageJson = await fs.readJson(rootPackageJsonPath); + packageJson.name = options.projectName; - const pnpmScripts = { - dev: "pnpm -r dev", - build: "pnpm -r build", - "check-types": "pnpm -r check-types", - "dev:native": "pnpm --filter native dev", - "dev:web": "pnpm --filter web dev", - "dev:server": "pnpm --filter server dev", - "db:push": "pnpm --filter server db:push", - "db:studio": "pnpm --filter server db:studio", - }; + if (!packageJson.scripts) { + packageJson.scripts = {}; + } + const scripts = packageJson.scripts; - const npmScripts = { - dev: "npm run dev --workspaces", - build: "npm run build --workspaces", - "check-types": "npm run check-types --workspaces", - "dev:native": "npm run dev --workspace native", - "dev:web": "npm run dev --workspace web", - "dev:server": "npm run dev --workspace server", - "db:push": "npm run db:push --workspace server", - "db:studio": "npm run db:studio --workspace server", - }; + const backendPackageName = + options.backend === "convex" ? `@${options.projectName}/backend` : "server"; - const bunScripts = { - dev: "bun run --filter '*' dev", - build: "bun run --filter '*' build", - "check-types": "bun run --filter '*' check-types", - "dev:native": "bun run --filter native dev", - "dev:web": "bun run --filter web dev", - "dev:server": "bun run --filter server dev", - "db:push": "bun run --filter server db:push", - "db:studio": "bun run --filter server db:studio", - }; + let serverDevScript = ""; + if (options.addons.includes("turborepo")) { + serverDevScript = `turbo -F ${backendPackageName} dev`; + } else if (options.packageManager === "bun") { + serverDevScript = `bun run --filter ${backendPackageName} dev`; + } else if (options.packageManager === "pnpm") { + serverDevScript = `pnpm --filter ${backendPackageName} dev`; + } else if (options.packageManager === "npm") { + serverDevScript = `npm run dev --workspace ${backendPackageName}`; + } - if (options.addons.includes("turborepo")) { - packageJson.scripts = turboScripts; - } else { - if (options.packageManager === "pnpm") { - packageJson.scripts = pnpmScripts; - } else if (options.packageManager === "npm") { - packageJson.scripts = npmScripts; - } else if (options.packageManager === "bun") { - packageJson.scripts = bunScripts; - } else { - packageJson.scripts = {}; - } + let devScript = ""; + if (options.packageManager === "pnpm") { + devScript = "pnpm -r dev"; + } else if (options.packageManager === "npm") { + devScript = "npm run dev --workspaces"; + } else if (options.packageManager === "bun") { + devScript = "bun run --filter '*' dev"; + } + + const needsDbScripts = + options.backend !== "convex" && + options.database !== "none" && + options.orm !== "none"; + + if (options.addons.includes("turborepo")) { + scripts.dev = "turbo dev"; + scripts.build = "turbo build"; + scripts["check-types"] = "turbo check-types"; + scripts["dev:native"] = "turbo -F native dev"; + scripts["dev:web"] = "turbo -F web dev"; + scripts["dev:server"] = serverDevScript; + if (options.backend === "convex") { + scripts["dev:setup"] = `turbo -F ${backendPackageName} setup`; } + if (needsDbScripts) { + scripts["db:push"] = `turbo -F ${backendPackageName} db:push`; + scripts["db:studio"] = `turbo -F ${backendPackageName} db:studio`; + } + } else if (options.packageManager === "pnpm") { + scripts.dev = devScript; + scripts.build = "pnpm -r build"; + scripts["check-types"] = "pnpm -r check-types"; + scripts["dev:native"] = "pnpm --filter native dev"; + scripts["dev:web"] = "pnpm --filter web dev"; + scripts["dev:server"] = serverDevScript; + if (options.backend === "convex") { + scripts["dev:setup"] = `pnpm --filter ${backendPackageName} setup`; + } + if (needsDbScripts) { + scripts["db:push"] = `pnpm --filter ${backendPackageName} db:push`; + scripts["db:studio"] = `pnpm --filter ${backendPackageName} db:studio`; + } + } else if (options.packageManager === "npm") { + scripts.dev = devScript; + scripts.build = "npm run build --workspaces"; + scripts["check-types"] = "npm run check-types --workspaces"; + scripts["dev:native"] = "npm run dev --workspace native"; + scripts["dev:web"] = "npm run dev --workspace web"; + scripts["dev:server"] = serverDevScript; + if (options.backend === "convex") { + scripts["dev:setup"] = `npm run setup --workspace ${backendPackageName}`; + } + if (needsDbScripts) { + scripts["db:push"] = `npm run db:push --workspace ${backendPackageName}`; + scripts["db:studio"] = + `npm run db:studio --workspace ${backendPackageName}`; + } + } else if (options.packageManager === "bun") { + scripts.dev = devScript; + scripts.build = "bun run --filter '*' build"; + scripts["check-types"] = "bun run --filter '*' check-types"; + scripts["dev:native"] = "bun run --filter native dev"; + scripts["dev:web"] = "bun run --filter web dev"; + scripts["dev:server"] = serverDevScript; + if (options.backend === "convex") { + scripts["dev:setup"] = `bun run --filter ${backendPackageName} setup`; + } + if (needsDbScripts) { + scripts["db:push"] = `bun run --filter ${backendPackageName} db:push`; + scripts["db:studio"] = `bun run --filter ${backendPackageName} db:studio`; + } + } + if (options.addons.includes("biome")) { + scripts.check = "biome check --write ."; + } + if (options.addons.includes("husky")) { + scripts.prepare = "husky"; + packageJson["lint-staged"] = { + "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [ + "biome check --write .", + ], + }; + } + + try { const { stdout } = await execa(options.packageManager, ["-v"], { cwd: projectDir, }); packageJson.packageManager = `${options.packageManager}@${stdout.trim()}`; - - await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 }); + } catch (e) { + log.warn(`Could not determine ${options.packageManager} version.`); } + + if (!packageJson.workspaces) { + packageJson.workspaces = []; + } + const workspaces = packageJson.workspaces; + + if (options.backend === "convex") { + if (!workspaces.includes("packages/*")) { + workspaces.push("packages/*"); + } + const needsAppsDir = + options.frontend.length > 0 || options.addons.includes("starlight"); + if (needsAppsDir && !workspaces.includes("apps/*")) { + workspaces.push("apps/*"); + } + } else { + if (!workspaces.includes("apps/*")) { + workspaces.push("apps/*"); + } + if (!workspaces.includes("packages/*")) { + workspaces.push("packages/*"); + } + } + + await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 }); } async function updateServerPackageJson( @@ -99,28 +175,53 @@ async function updateServerPackageJson( "apps/server/package.json", ); - if (await fs.pathExists(serverPackageJsonPath)) { - const serverPackageJson = await fs.readJson(serverPackageJsonPath); + if (!(await fs.pathExists(serverPackageJsonPath))) return; - if (options.database !== "none") { - if (options.database === "sqlite" && options.orm === "drizzle") { - serverPackageJson.scripts["db:local"] = "turso dev --db-file local.db"; - } + const serverPackageJson = await fs.readJson(serverPackageJsonPath); - if (options.orm === "prisma") { - serverPackageJson.scripts["db:push"] = - "prisma db push --schema ./prisma/schema"; - serverPackageJson.scripts["db:studio"] = "prisma studio"; - } else if (options.orm === "drizzle") { - serverPackageJson.scripts["db:push"] = "drizzle-kit push"; - serverPackageJson.scripts["db:studio"] = "drizzle-kit studio"; - } + if (!serverPackageJson.scripts) { + serverPackageJson.scripts = {}; + } + const scripts = serverPackageJson.scripts; + + if (options.database !== "none") { + if (options.database === "sqlite" && options.orm === "drizzle") { + scripts["db:local"] = "turso dev --db-file local.db"; } - await fs.writeJson(serverPackageJsonPath, serverPackageJson, { - spaces: 2, - }); + if (options.orm === "prisma") { + scripts["db:push"] = "prisma db push --schema ./prisma/schema.prisma"; + scripts["db:studio"] = "prisma studio"; + } else if (options.orm === "drizzle") { + scripts["db:push"] = "drizzle-kit push"; + scripts["db:studio"] = "drizzle-kit studio"; + } } + + await fs.writeJson(serverPackageJsonPath, serverPackageJson, { + spaces: 2, + }); +} + +async function updateConvexPackageJson( + projectDir: string, + options: ProjectConfig, +): Promise { + const convexPackageJsonPath = path.join( + projectDir, + "packages/backend/package.json", + ); + + if (!(await fs.pathExists(convexPackageJsonPath))) return; + + const convexPackageJson = await fs.readJson(convexPackageJsonPath); + convexPackageJson.name = `@${options.projectName}/backend`; + + if (!convexPackageJson.scripts) { + convexPackageJson.scripts = {}; + } + + await fs.writeJson(convexPackageJsonPath, convexPackageJson, { spaces: 2 }); } export async function initializeGit( diff --git a/apps/cli/src/helpers/runtime-setup.ts b/apps/cli/src/helpers/runtime-setup.ts index 9af3cd7..5aa1c40 100644 --- a/apps/cli/src/helpers/runtime-setup.ts +++ b/apps/cli/src/helpers/runtime-setup.ts @@ -5,13 +5,18 @@ import { addPackageDependency } from "../utils/add-package-deps"; export async function setupRuntime(config: ProjectConfig): Promise { const { projectName, runtime, backend } = config; - const projectDir = path.resolve(process.cwd(), projectName); - if (backend === "next") { + + if (backend === "convex" || backend === "next" || runtime === "none") { return; } + const projectDir = path.resolve(process.cwd(), projectName); const serverDir = path.join(projectDir, "apps/server"); + if (!(await fs.pathExists(serverDir))) { + return; + } + if (runtime === "bun") { await setupBunRuntime(serverDir, backend); } else if (runtime === "node") { @@ -24,6 +29,8 @@ async function setupBunRuntime( backend: ProjectBackend, ): Promise { const packageJsonPath = path.join(serverDir, "package.json"); + if (!(await fs.pathExists(packageJsonPath))) return; + const packageJson = await fs.readJson(packageJsonPath); packageJson.scripts = { @@ -45,6 +52,8 @@ async function setupNodeRuntime( backend: ProjectBackend, ): Promise { const packageJsonPath = path.join(serverDir, "package.json"); + if (!(await fs.pathExists(packageJsonPath))) return; + const packageJson = await fs.readJson(packageJsonPath); packageJson.scripts = { diff --git a/apps/cli/src/helpers/template-manager.ts b/apps/cli/src/helpers/template-manager.ts index 2254085..89693e1 100644 --- a/apps/cli/src/helpers/template-manager.ts +++ b/apps/cli/src/helpers/template-manager.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import consola from "consola"; import fs from "fs-extra"; import { globby } from "globby"; import pc from "picocolors"; @@ -28,26 +27,28 @@ async function processAndCopyFiles( if (relativeSrcPath.endsWith(".hbs")) { relativeDestPath = relativeSrcPath.slice(0, -4); } - if (path.basename(relativeSrcPath) === "_gitignore") { + const basename = path.basename(relativeSrcPath); + if (basename === "_gitignore") { relativeDestPath = path.join(path.dirname(relativeSrcPath), ".gitignore"); - } - if (path.basename(relativeSrcPath) === "_npmrc") { + } else if (basename === "_npmrc") { relativeDestPath = path.join(path.dirname(relativeSrcPath), ".npmrc"); } const destPath = path.join(destDir, relativeDestPath); - await fs.ensureDir(path.dirname(destPath)); + try { + await fs.ensureDir(path.dirname(destPath)); - if (!overwrite && (await fs.pathExists(destPath))) { - continue; - } + if (!overwrite && (await fs.pathExists(destPath))) { + continue; + } - if (srcPath.endsWith(".hbs")) { - await processTemplate(srcPath, destPath, context); - } else { - await fs.copy(srcPath, destPath, { overwrite: true }); - } + if (srcPath.endsWith(".hbs")) { + await processTemplate(srcPath, destPath, context); + } else { + await fs.copy(srcPath, destPath, { overwrite: true }); + } + } catch (error) {} } } @@ -57,6 +58,7 @@ export async function copyBaseTemplate( ): Promise { const templateDir = path.join(PKG_ROOT, "templates/base"); await processAndCopyFiles(["**/*"], templateDir, projectDir, context); + await fs.ensureDir(path.join(projectDir, "packages")); } export async function setupFrontendTemplates( @@ -69,6 +71,7 @@ export async function setupFrontendTemplates( const hasNuxtWeb = context.frontend.includes("nuxt"); const hasSvelteWeb = context.frontend.includes("svelte"); const hasNative = context.frontend.includes("native"); + const isConvex = context.backend === "convex"; if (hasReactWeb || hasNuxtWeb || hasSvelteWeb) { const webAppDir = path.join(projectDir, "apps/web"); @@ -81,6 +84,7 @@ export async function setupFrontendTemplates( ); if (await fs.pathExists(webBaseDir)) { await processAndCopyFiles("**/*", webBaseDir, webAppDir, context); + } else { } const reactFramework = context.frontend.find((f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes( @@ -99,33 +103,47 @@ export async function setupFrontendTemplates( webAppDir, context, ); + } else { } - const apiWebBaseDir = path.join( - PKG_ROOT, - `templates/api/${context.api}/web/react/base`, - ); - if (await fs.pathExists(apiWebBaseDir)) { - await processAndCopyFiles("**/*", apiWebBaseDir, webAppDir, context); + if (!isConvex && context.api !== "none") { + const apiWebBaseDir = path.join( + PKG_ROOT, + `templates/api/${context.api}/web/react/base`, + ); + if (await fs.pathExists(apiWebBaseDir)) { + await processAndCopyFiles( + "**/*", + apiWebBaseDir, + webAppDir, + context, + ); + } else { + } } } } else if (hasNuxtWeb) { const nuxtBaseDir = path.join(PKG_ROOT, "templates/frontend/nuxt"); if (await fs.pathExists(nuxtBaseDir)) { await processAndCopyFiles("**/*", nuxtBaseDir, webAppDir, context); + } else { } - const apiWebNuxtDir = path.join( - PKG_ROOT, - `templates/api/${context.api}/web/nuxt`, - ); - if (await fs.pathExists(apiWebNuxtDir)) { - await processAndCopyFiles("**/*", apiWebNuxtDir, webAppDir, context); + if (!isConvex && context.api !== "none") { + const apiWebNuxtDir = path.join( + PKG_ROOT, + `templates/api/${context.api}/web/nuxt`, + ); + if (await fs.pathExists(apiWebNuxtDir)) { + await processAndCopyFiles("**/*", apiWebNuxtDir, webAppDir, context); + } else { + } } } else if (hasSvelteWeb) { const svelteBaseDir = path.join(PKG_ROOT, "templates/frontend/svelte"); if (await fs.pathExists(svelteBaseDir)) { await processAndCopyFiles("**/*", svelteBaseDir, webAppDir, context); + } else { } - if (context.api === "orpc") { + if (!isConvex && context.api === "orpc") { const apiWebSvelteDir = path.join( PKG_ROOT, `templates/api/${context.api}/web/svelte`, @@ -137,6 +155,7 @@ export async function setupFrontendTemplates( webAppDir, context, ); + } else { } } } @@ -149,22 +168,10 @@ export async function setupFrontendTemplates( const nativeBaseDir = path.join(PKG_ROOT, "templates/frontend/native"); if (await fs.pathExists(nativeBaseDir)) { await processAndCopyFiles("**/*", nativeBaseDir, nativeAppDir, context); + } else { } - if (context.api === "trpc") { - const apiNativeSrcDir = path.join( - PKG_ROOT, - `templates/api/${context.api}/native`, - ); - if (await fs.pathExists(apiNativeSrcDir)) { - await processAndCopyFiles( - "**/*", - apiNativeSrcDir, - nativeAppDir, - context, - ); - } - } else if (context.api === "orpc") { + if (!isConvex && (context.api === "trpc" || context.api === "orpc")) { const apiNativeSrcDir = path.join( PKG_ROOT, `templates/api/${context.api}/native`, @@ -176,6 +183,7 @@ export async function setupFrontendTemplates( nativeAppDir, context, ); + } else { } } } @@ -185,53 +193,89 @@ export async function setupBackendFramework( projectDir: string, context: ProjectConfig, ): Promise { - if ((context.backend as string) === "none") return; + if (context.backend === "convex") { + const convexBackendDestDir = path.join(projectDir, "packages/backend"); + const convexSrcDir = path.join( + PKG_ROOT, + "templates/backend/convex/packages/backend", + ); + + await fs.ensureDir(convexBackendDestDir); + + if (await fs.pathExists(convexSrcDir)) { + await processAndCopyFiles( + "**/*", + convexSrcDir, + convexBackendDestDir, + context, + ); + } else { + } + + const serverAppDir = path.join(projectDir, "apps/server"); + if (await fs.pathExists(serverAppDir)) { + await fs.remove(serverAppDir); + } + return; + } const serverAppDir = path.join(projectDir, "apps/server"); await fs.ensureDir(serverAppDir); - const serverBaseDir = path.join(PKG_ROOT, "templates/backend/server-base"); + const serverBaseDir = path.join( + PKG_ROOT, + "templates/backend/server/server-base", + ); if (await fs.pathExists(serverBaseDir)) { await processAndCopyFiles("**/*", serverBaseDir, serverAppDir, context); } else { - consola.warn( - pc.yellow(`Warning: server-base template not found at ${serverBaseDir}`), - ); } const frameworkSrcDir = path.join( PKG_ROOT, - `templates/backend/${context.backend}`, + `templates/backend/server/${context.backend}`, ); if (await fs.pathExists(frameworkSrcDir)) { - await processAndCopyFiles("**/*", frameworkSrcDir, serverAppDir, context); - } else { - consola.warn( - pc.yellow( - `Warning: Backend template directory not found, skipping: ${frameworkSrcDir}`, - ), - ); - } - - const apiServerBaseDir = path.join( - PKG_ROOT, - `templates/api/${context.api}/server/base`, - ); - if (await fs.pathExists(apiServerBaseDir)) { - await processAndCopyFiles("**/*", apiServerBaseDir, serverAppDir, context); - } - - const apiServerFrameworkDir = path.join( - PKG_ROOT, - `templates/api/${context.api}/server/${context.backend}`, - ); - if (await fs.pathExists(apiServerFrameworkDir)) { await processAndCopyFiles( "**/*", - apiServerFrameworkDir, + frameworkSrcDir, serverAppDir, context, + true, ); + } else { + } + + if (context.api !== "none") { + const apiServerBaseDir = path.join( + PKG_ROOT, + `templates/api/${context.api}/server/base`, + ); + if (await fs.pathExists(apiServerBaseDir)) { + await processAndCopyFiles( + "**/*", + apiServerBaseDir, + serverAppDir, + context, + true, + ); + } else { + } + + const apiServerFrameworkDir = path.join( + PKG_ROOT, + `templates/api/${context.api}/server/${context.backend}`, + ); + if (await fs.pathExists(apiServerFrameworkDir)) { + await processAndCopyFiles( + "**/*", + apiServerFrameworkDir, + serverAppDir, + context, + true, + ); + } else { + } } } @@ -239,7 +283,12 @@ export async function setupDbOrmTemplates( projectDir: string, context: ProjectConfig, ): Promise { - if (context.orm === "none" || context.database === "none") return; + if ( + context.backend === "convex" || + context.orm === "none" || + context.database === "none" + ) + return; const serverAppDir = path.join(projectDir, "apps/server"); await fs.ensureDir(serverAppDir); @@ -252,11 +301,6 @@ export async function setupDbOrmTemplates( if (await fs.pathExists(dbOrmSrcDir)) { await processAndCopyFiles("**/*", dbOrmSrcDir, serverAppDir, context); } else { - consola.warn( - pc.yellow( - `Warning: Database/ORM template directory not found, skipping: ${dbOrmSrcDir}`, - ), - ); } } @@ -264,7 +308,7 @@ export async function setupAuthTemplate( projectDir: string, context: ProjectConfig, ): Promise { - if (!context.auth) return; + if (context.backend === "convex" || !context.auth) return; const serverAppDir = path.join(projectDir, "apps/server"); const webAppDir = path.join(projectDir, "apps/web"); @@ -290,6 +334,7 @@ export async function setupAuthTemplate( serverAppDir, context, ); + } else { } if (context.backend === "next") { @@ -304,6 +349,7 @@ export async function setupAuthTemplate( serverAppDir, context, ); + } else { } } @@ -324,12 +370,7 @@ export async function setupAuthTemplate( } if (authDbSrc && (await fs.pathExists(authDbSrc))) { await processAndCopyFiles("**/*", authDbSrc, serverAppDir, context); - } else { - consola.warn( - pc.yellow( - `Warning: Auth template for ${orm}/${db} not found at ${authDbSrc}`, - ), - ); + } else if (authDbSrc) { } } } @@ -342,6 +383,7 @@ export async function setupAuthTemplate( ); if (await fs.pathExists(authWebBaseSrc)) { await processAndCopyFiles("**/*", authWebBaseSrc, webAppDir, context); + } else { } const reactFramework = context.frontend.find((f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes( @@ -360,12 +402,14 @@ export async function setupAuthTemplate( webAppDir, context, ); + } else { } } } else if (hasNuxtWeb) { const authWebNuxtSrc = path.join(PKG_ROOT, "templates/auth/web/nuxt"); if (await fs.pathExists(authWebNuxtSrc)) { await processAndCopyFiles("**/*", authWebNuxtSrc, webAppDir, context); + } else { } } else if (hasSvelteWeb) { if (context.api === "orpc") { @@ -380,6 +424,7 @@ export async function setupAuthTemplate( webAppDir, context, ); + } else { } } } @@ -390,11 +435,6 @@ export async function setupAuthTemplate( if (await fs.pathExists(authNativeSrc)) { await processAndCopyFiles("**/*", authNativeSrc, nativeAppDir, context); } else { - consola.warn( - pc.yellow( - `Warning: Auth native template not found at ${authNativeSrc}`, - ), - ); } } } @@ -430,8 +470,6 @@ export async function setupExamplesTemplate( projectDir: string, context: ProjectConfig, ): Promise { - if (!context.examples || context.examples.length === 0) return; - const serverAppDir = path.join(projectDir, "apps/server"); const webAppDir = path.join(projectDir, "apps/web"); @@ -445,6 +483,13 @@ export async function setupExamplesTemplate( const hasSvelteWeb = context.frontend.includes("svelte"); for (const example of context.examples) { + if ( + !context.examples || + context.examples.length === 0 || + context.examples[0] === "none" + ) + continue; + if (example === "none") continue; const exampleBaseDir = path.join(PKG_ROOT, `templates/examples/${example}`); @@ -452,23 +497,23 @@ export async function setupExamplesTemplate( if (serverAppDirExists) { const exampleServerSrc = path.join(exampleBaseDir, "server"); if (await fs.pathExists(exampleServerSrc)) { - if (context.orm !== "none") { - const exampleOrmBaseSrc = path.join( - exampleServerSrc, - context.orm, - "base", - ); - if (await fs.pathExists(exampleOrmBaseSrc)) { - await processAndCopyFiles( - "**/*", - exampleOrmBaseSrc, - serverAppDir, - context, - false, + if (context.backend !== "convex") { + if (context.orm !== "none" && context.database !== "none") { + const exampleOrmBaseSrc = path.join( + exampleServerSrc, + context.orm, + "base", ); - } + if (await fs.pathExists(exampleOrmBaseSrc)) { + await processAndCopyFiles( + "**/*", + exampleOrmBaseSrc, + serverAppDir, + context, + false, + ); + } - if (context.database !== "none") { const exampleDbSchemaSrc = path.join( exampleServerSrc, context.orm, @@ -484,63 +529,81 @@ export async function setupExamplesTemplate( ); } } + const generalServerFiles = await globby(["*.ts", "*.hbs"], { + cwd: exampleServerSrc, + onlyFiles: true, + deep: 1, + ignore: [`${context.orm}/**`], + }); + for (const file of generalServerFiles) { + const srcPath = path.join(exampleServerSrc, file); + const destPath = path.join(serverAppDir, file.replace(".hbs", "")); + if (srcPath.endsWith(".hbs")) { + await processTemplate(srcPath, destPath, context); + } else { + await fs.copy(srcPath, destPath, { overwrite: false }); + } + } } } } - if (hasReactWeb && webAppDirExists) { - const exampleWebSrc = path.join(exampleBaseDir, "web/react"); - if (await fs.pathExists(exampleWebSrc)) { - const reactFramework = context.frontend.find((f) => - [ - "next", - "react-router", - "tanstack-router", - "tanstack-start", - ].includes(f), - ); - if (reactFramework) { - const exampleWebFrameworkSrc = path.join( - exampleWebSrc, - reactFramework, + if (webAppDirExists) { + if (hasReactWeb) { + const exampleWebSrc = path.join(exampleBaseDir, "web/react"); + if (await fs.pathExists(exampleWebSrc)) { + const reactFramework = context.frontend.find((f) => + [ + "next", + "react-router", + "tanstack-router", + "tanstack-start", + ].includes(f), ); - if (await fs.pathExists(exampleWebFrameworkSrc)) { + if (reactFramework) { + const exampleWebFrameworkSrc = path.join( + exampleWebSrc, + reactFramework, + ); + if (await fs.pathExists(exampleWebFrameworkSrc)) { + await processAndCopyFiles( + "**/*", + exampleWebFrameworkSrc, + webAppDir, + context, + false, + ); + } else { + } + } + } + } else if (hasNuxtWeb) { + if (context.api === "orpc") { + const exampleWebNuxtSrc = path.join(exampleBaseDir, "web/nuxt"); + if (await fs.pathExists(exampleWebNuxtSrc)) { await processAndCopyFiles( "**/*", - exampleWebFrameworkSrc, + exampleWebNuxtSrc, webAppDir, context, false, ); + } else { } } - } - } else if (hasNuxtWeb && webAppDirExists) { - if (context.api === "orpc") { - const exampleWebNuxtSrc = path.join(exampleBaseDir, "web/nuxt"); - if (await fs.pathExists(exampleWebNuxtSrc)) { - await processAndCopyFiles( - "**/*", - exampleWebNuxtSrc, - webAppDir, - context, - false, - ); - } else { - } - } - } else if (hasSvelteWeb && webAppDirExists) { - if (context.api === "orpc") { - const exampleWebSvelteSrc = path.join(exampleBaseDir, "web/svelte"); - if (await fs.pathExists(exampleWebSvelteSrc)) { - await processAndCopyFiles( - "**/*", - exampleWebSvelteSrc, - webAppDir, - context, - false, - ); - } else { + } else if (hasSvelteWeb) { + if (context.api === "orpc") { + const exampleWebSvelteSrc = path.join(exampleBaseDir, "web/svelte"); + if (await fs.pathExists(exampleWebSvelteSrc)) { + await processAndCopyFiles( + "**/*", + exampleWebSvelteSrc, + webAppDir, + context, + false, + ); + } else { + } } } } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 9ab0e60..ad02e0c 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -123,17 +123,17 @@ async function main() { .option("backend", { type: "string", describe: "Backend framework", - choices: ["hono", "express", "next", "elysia"], + choices: ["hono", "express", "next", "elysia", "convex"], }) .option("runtime", { type: "string", describe: "Runtime", - choices: ["bun", "node"], + choices: ["bun", "node", "none"], }) .option("api", { type: "string", describe: "API type", - choices: ["trpc", "orpc"], + choices: ["trpc", "orpc", "none"], }) .completion() .recommendCommands() @@ -168,11 +168,17 @@ async function main() { ...flagConfig, }; - if (config.database === "none") { + if (config.backend === "convex") { + config.auth = false; + config.database = "none"; + config.orm = "none"; + config.api = "none"; + config.runtime = "none"; + config.dbSetup = "none"; + } else if (config.database === "none") { config.orm = "none"; config.auth = false; config.dbSetup = "none"; - config.examples = config.examples.filter((ex) => ex !== "todo"); } log.info(pc.yellow("Using these default/flag options:")); @@ -216,7 +222,9 @@ async function main() { cancel(pc.red(`Invalid arguments: ${error.message}`)); } else { consola.error(`An unexpected error occurred: ${error.message}`); - consola.error(error.stack); + if (!error.message.includes("is only supported with")) { + consola.error(error.stack); + } } process.exit(1); } else { @@ -232,6 +240,32 @@ function processAndValidateFlags( projectDirectory?: string, ): Partial { const config: Partial = {}; + const providedFlags: Set = new Set( + Object.keys(options).filter((key) => key !== "_" && key !== "$0"), + ); + + if (options.backend) { + config.backend = options.backend as ProjectBackend; + } + + if ( + providedFlags.has("backend") && + config.backend && + config.backend !== "convex" + ) { + if (providedFlags.has("api") && options.api === "none") { + consola.fatal( + `'--api none' is only supported with '--backend convex'. Please choose 'trpc', 'orpc', or remove the --api flag.`, + ); + process.exit(1); + } + if (providedFlags.has("runtime") && options.runtime === "none") { + consola.fatal( + `'--runtime none' is only supported with '--backend convex'. Please choose 'bun', 'node', or remove the --runtime flag.`, + ); + process.exit(1); + } + } if (options.database) { config.database = options.database as ProjectDatabase; @@ -248,12 +282,22 @@ function processAndValidateFlags( if (options.install !== undefined) { config.install = options.install; } - if (options.backend) { - config.backend = options.backend as ProjectBackend; - } if (options.runtime) { config.runtime = options.runtime as ProjectRuntime; } + if (options.api) { + config.api = options.api as ProjectApi; + } + if (options.dbSetup) { + config.dbSetup = options.dbSetup as ProjectDBSetup; + } + if (options.packageManager) { + config.packageManager = options.packageManager as ProjectPackageManager; + } + if (projectDirectory) { + config.projectName = projectDirectory; + } + if (options.frontend && options.frontend.length > 0) { if (options.frontend.includes("none")) { if (options.frontend.length > 1) { @@ -283,9 +327,6 @@ function processAndValidateFlags( config.frontend = validOptions; } } - if (options.api) { - config.api = options.api as ProjectApi; - } if (options.addons && options.addons.length > 0) { if (options.addons.includes("none")) { if (options.addons.length > 1) { @@ -310,231 +351,283 @@ function processAndValidateFlags( config.examples = options.examples.filter( (ex): ex is ProjectExamples => ex !== "none", ); + if (config.backend !== "convex" && options.examples.includes("none")) { + config.examples = []; + } else { + config.examples = ["todo"]; + } } } - if (options.packageManager) { - config.packageManager = options.packageManager as ProjectPackageManager; - } - if (projectDirectory) { - config.projectName = projectDirectory; - } - if (options.dbSetup) { - config.dbSetup = options.dbSetup as ProjectDBSetup; - } - const effectiveDatabase = - config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined); - const effectiveOrm = - config.orm ?? (options.yes ? DEFAULT_CONFIG.orm : undefined); - const effectiveAuth = - config.auth ?? (options.yes ? DEFAULT_CONFIG.auth : undefined); - const effectiveDbSetup = - config.dbSetup ?? (options.yes ? DEFAULT_CONFIG.dbSetup : undefined); - const effectiveExamples = - config.examples ?? (options.yes ? DEFAULT_CONFIG.examples : undefined); - const effectiveFrontend = - config.frontend ?? (options.yes ? DEFAULT_CONFIG.frontend : undefined); - const effectiveApi = - config.api ?? (options.yes ? DEFAULT_CONFIG.api : undefined); - const effectiveBackend = - config.backend ?? (options.yes ? DEFAULT_CONFIG.backend : undefined); + if (config.backend === "convex") { + const incompatibleFlags: string[] = []; - if (effectiveDatabase === "none") { - if (effectiveOrm && effectiveOrm !== "none") { + if (providedFlags.has("auth") && options.auth === true) + incompatibleFlags.push("--auth"); + if (providedFlags.has("database") && options.database !== "none") + incompatibleFlags.push(`--database ${options.database}`); + if (providedFlags.has("orm") && options.orm !== "none") + incompatibleFlags.push(`--orm ${options.orm}`); + if (providedFlags.has("api") && options.api !== "none") + incompatibleFlags.push(`--api ${options.api}`); + if (providedFlags.has("runtime") && options.runtime !== "none") + incompatibleFlags.push(`--runtime ${options.runtime}`); + if (providedFlags.has("dbSetup") && options.dbSetup !== "none") + incompatibleFlags.push(`--db-setup ${options.dbSetup}`); + if (providedFlags.has("examples")) { + incompatibleFlags.push("--examples"); + } + + if (incompatibleFlags.length > 0) { consola.fatal( - `Cannot use ORM '--orm ${effectiveOrm}' when database is 'none'.`, + `The following flags are incompatible with '--backend convex': ${incompatibleFlags.join( + ", ", + )}. Please remove them. The 'todo' example is included automatically with Convex.`, ); process.exit(1); } - config.orm = "none"; - if (effectiveAuth === true) { - consola.fatal( - "Authentication requires a database. Cannot use --auth when database is 'none'.", - ); - process.exit(1); - } config.auth = false; - - if (effectiveDbSetup && effectiveDbSetup !== "none") { - consola.fatal( - `Database setup '--db-setup ${effectiveDbSetup}' requires a database. Cannot use when database is 'none'.`, - ); - process.exit(1); - } + config.database = "none"; + config.orm = "none"; + config.api = "none"; + config.runtime = "none"; config.dbSetup = "none"; + config.examples = ["todo"]; + } else { + const effectiveDatabase = + config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined); + const effectiveOrm = + config.orm ?? (options.yes ? DEFAULT_CONFIG.orm : undefined); + const effectiveAuth = + config.auth ?? (options.yes ? DEFAULT_CONFIG.auth : undefined); + const effectiveDbSetup = + config.dbSetup ?? (options.yes ? DEFAULT_CONFIG.dbSetup : undefined); + const effectiveExamples = + config.examples ?? (options.yes ? DEFAULT_CONFIG.examples : undefined); + const effectiveFrontend = + config.frontend ?? (options.yes ? DEFAULT_CONFIG.frontend : undefined); + const effectiveApi = + config.api ?? (options.yes ? DEFAULT_CONFIG.api : undefined); + const effectiveBackend = + config.backend ?? (options.yes ? DEFAULT_CONFIG.backend : undefined); - if (effectiveExamples?.includes("todo")) { + if (effectiveDatabase === "none") { + if (providedFlags.has("orm") && options.orm !== "none") { + consola.fatal( + `Cannot use ORM '--orm ${options.orm}' when database is 'none'.`, + ); + process.exit(1); + } + config.orm = "none"; + + if (providedFlags.has("auth") && options.auth === true) { + consola.fatal( + "Authentication requires a database. Cannot use --auth when database is 'none'.", + ); + process.exit(1); + } + config.auth = false; + + if (providedFlags.has("dbSetup") && options.dbSetup !== "none") { + consola.fatal( + `Database setup '--db-setup ${options.dbSetup}' requires a database. Cannot use when database is 'none'.`, + ); + process.exit(1); + } + config.dbSetup = "none"; + } + + if (effectiveDatabase === "mongodb" && effectiveOrm === "drizzle") { consola.fatal( - "The 'todo' example requires a database. Cannot use --examples todo when database is 'none'.", + "MongoDB is only available with Prisma. Cannot use --database mongodb with --orm drizzle", ); process.exit(1); } - if (config.examples) { - config.examples = config.examples.filter((ex) => ex !== "todo"); + + if (config.dbSetup && config.dbSetup !== "none") { + const dbSetup = config.dbSetup; + if (dbSetup === "turso") { + if (effectiveDatabase && effectiveDatabase !== "sqlite") { + consola.fatal( + `Turso setup requires SQLite. Cannot use --db-setup turso with --database ${effectiveDatabase}`, + ); + process.exit(1); + } + if (effectiveOrm === "prisma") { + consola.fatal( + "Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma", + ); + process.exit(1); + } + config.database = "sqlite"; + config.orm = "drizzle"; + } else if (dbSetup === "prisma-postgres") { + if (effectiveDatabase && effectiveDatabase !== "postgres") { + consola.fatal( + `Prisma PostgreSQL setup requires PostgreSQL. Cannot use --db-setup prisma-postgres with --database ${effectiveDatabase}.`, + ); + process.exit(1); + } + if ( + effectiveOrm && + effectiveOrm !== "prisma" && + effectiveOrm !== "none" + ) { + consola.fatal( + `Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with --orm ${effectiveOrm}.`, + ); + process.exit(1); + } + config.database = "postgres"; + config.orm = "prisma"; + } else if (dbSetup === "mongodb-atlas") { + if (effectiveDatabase && effectiveDatabase !== "mongodb") { + consola.fatal( + `MongoDB Atlas setup requires MongoDB. Cannot use --db-setup mongodb-atlas with --database ${effectiveDatabase}.`, + ); + process.exit(1); + } + if ( + effectiveOrm && + effectiveOrm !== "prisma" && + effectiveOrm !== "none" + ) { + consola.fatal( + `MongoDB Atlas setup requires Prisma ORM. Cannot use --db-setup mongodb-atlas with --orm ${effectiveOrm}.`, + ); + process.exit(1); + } + config.database = "mongodb"; + config.orm = "prisma"; + } else if (dbSetup === "neon") { + if (effectiveDatabase && effectiveDatabase !== "postgres") { + consola.fatal( + `Neon PostgreSQL setup requires PostgreSQL. Cannot use --db-setup neon with --database ${effectiveDatabase}.`, + ); + process.exit(1); + } + config.database = "postgres"; + } } - } - if (effectiveDatabase === "mongodb" && effectiveOrm === "drizzle") { - consola.fatal( - "MongoDB is only available with Prisma. Cannot use --database mongodb with --orm drizzle", - ); - process.exit(1); - } + const includesNuxt = effectiveFrontend?.includes("nuxt"); + const includesSvelte = effectiveFrontend?.includes("svelte"); - if (config.dbSetup && config.dbSetup !== "none") { - const dbSetup = config.dbSetup; - if (dbSetup === "turso") { - if (effectiveDatabase && effectiveDatabase !== "sqlite") { + if ((includesNuxt || includesSvelte) && effectiveApi === "trpc") { + consola.fatal( + `tRPC API is not supported with '${ + includesNuxt ? "nuxt" : "svelte" + }' frontend. Please use --api orpc or remove '${ + includesNuxt ? "nuxt" : "svelte" + }' from --frontend.`, + ); + process.exit(1); + } + if ( + (includesNuxt || includesSvelte) && + effectiveApi !== "orpc" && + (!options.api || (options.yes && options.api !== "trpc")) + ) { + if (config.api !== "none") { + config.api = "orpc"; + } + } + + if (config.addons && config.addons.length > 0) { + const webSpecificAddons = ["pwa", "tauri"]; + const hasWebSpecificAddons = config.addons.some((addon) => + webSpecificAddons.includes(addon), + ); + const hasCompatibleWebFrontend = effectiveFrontend?.some( + (f) => + f === "tanstack-router" || + f === "react-router" || + (f === "nuxt" && + config.addons?.includes("tauri") && + !config.addons?.includes("pwa")) || + (f === "svelte" && + config.addons?.includes("tauri") && + !config.addons?.includes("pwa")), + ); + + if (hasWebSpecificAddons && !hasCompatibleWebFrontend) { + let incompatibleAddon = ""; + if (config.addons.includes("pwa") && includesNuxt) { + incompatibleAddon = "PWA addon is not compatible with Nuxt."; + } else if ( + config.addons.includes("pwa") || + config.addons.includes("tauri") + ) { + incompatibleAddon = + "PWA and Tauri addons require tanstack-router, react-router, or Nuxt/Svelte (Tauri only)."; + } consola.fatal( - `Turso setup requires SQLite. Cannot use --db-setup turso with --database ${effectiveDatabase}`, + `${incompatibleAddon} Cannot use these addons with your frontend selection.`, ); process.exit(1); } - if (effectiveOrm === "prisma") { - consola.fatal( - "Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma", + + if (config.addons.includes("husky") && !config.addons.includes("biome")) { + consola.warn( + "Husky addon is recommended to be used with Biome for lint-staged configuration.", ); - process.exit(1); - } - config.database = "sqlite"; - config.orm = "drizzle"; - } else if (dbSetup === "prisma-postgres") { - if (effectiveDatabase && effectiveDatabase !== "postgres") { - consola.fatal( - `Prisma PostgreSQL setup requires PostgreSQL. Cannot use --db-setup prisma-postgres with --database ${effectiveDatabase}.`, - ); - process.exit(1); } + config.addons = [...new Set(config.addons)]; + } + + const onlyNativeFrontend = + effectiveFrontend && + effectiveFrontend.length === 1 && + effectiveFrontend[0] === "native"; + + if ( + onlyNativeFrontend && + config.examples && + config.examples.length > 0 && + !config.examples.includes("none") + ) { + consola.fatal( + "Examples are not supported when only the 'native' frontend is selected.", + ); + process.exit(1); + } + + if ( + config.examples && + config.examples.length > 0 && + !config.examples.includes("none") + ) { if ( - effectiveOrm && - effectiveOrm !== "prisma" && - effectiveOrm !== "none" + config.examples.includes("todo") && + effectiveBackend !== "convex" && + effectiveDatabase === "none" ) { consola.fatal( - `Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with --orm ${effectiveOrm}.`, + "The 'todo' example requires a database (unless using Convex). Cannot use --examples todo when database is 'none'.", ); process.exit(1); } - config.database = "postgres"; - config.orm = "prisma"; - } else if (dbSetup === "mongodb-atlas") { - if (effectiveDatabase && effectiveDatabase !== "mongodb") { + + if (config.examples.includes("ai") && effectiveBackend === "elysia") { consola.fatal( - `MongoDB Atlas setup requires MongoDB. Cannot use --db-setup mongodb-atlas with --database ${effectiveDatabase}.`, + "The 'ai' example is not compatible with the Elysia backend.", ); process.exit(1); } - if ( - effectiveOrm && - effectiveOrm !== "prisma" && - effectiveOrm !== "none" - ) { - consola.fatal( - `MongoDB Atlas setup requires Prisma ORM. Cannot use --db-setup mongodb-atlas with --orm ${effectiveOrm}.`, - ); - process.exit(1); - } - config.database = "mongodb"; - config.orm = "prisma"; - } else if (dbSetup === "neon") { - if (effectiveDatabase && effectiveDatabase !== "postgres") { - consola.fatal( - `Neon PostgreSQL setup requires PostgreSQL. Cannot use --db-setup neon with --database ${effectiveDatabase}.`, - ); - process.exit(1); - } - config.database = "postgres"; - } - } - const includesNuxt = effectiveFrontend?.includes("nuxt"); - const includesSvelte = effectiveFrontend?.includes("svelte"); - - if ((includesNuxt || includesSvelte) && effectiveApi === "trpc") { - consola.fatal( - `tRPC API is not supported with '${ - includesNuxt ? "nuxt" : "svelte" - }' frontend. Please use --api orpc or remove '${ - includesNuxt ? "nuxt" : "svelte" - }' from --frontend.`, - ); - process.exit(1); - } - if ( - (includesNuxt || includesSvelte) && - effectiveApi !== "orpc" && - (!options.api || (options.yes && options.api !== "trpc")) - ) { - config.api = "orpc"; - } - - if (config.addons && config.addons.length > 0) { - const webSpecificAddons = ["pwa", "tauri"]; - const hasWebSpecificAddons = config.addons.some((addon) => - webSpecificAddons.includes(addon), - ); - const hasCompatibleWebFrontend = effectiveFrontend?.some( - (f) => - f === "tanstack-router" || - f === "react-router" || - (f === "nuxt" && - config.addons?.includes("tauri") && - !config.addons?.includes("pwa")) || - (f === "svelte" && - config.addons?.includes("tauri") && - !config.addons?.includes("pwa")), - ); - - if (hasWebSpecificAddons && !hasCompatibleWebFrontend) { - let incompatibleAddon = ""; - if (config.addons.includes("pwa") && includesNuxt) { - incompatibleAddon = "PWA addon is not compatible with Nuxt."; - } else if ( - config.addons.includes("pwa") || - config.addons.includes("tauri") - ) { - incompatibleAddon = - "PWA and Tauri addons require tanstack-router, react-router, or Nuxt/Svelte (Tauri only)."; - } - consola.fatal( - `${incompatibleAddon} Cannot use these addons with your frontend selection.`, + const hasWebFrontendForExamples = effectiveFrontend?.some((f) => + [ + "tanstack-router", + "react-router", + "tanstack-start", + "next", + "nuxt", + "svelte", + ].includes(f), ); - process.exit(1); - } - - if (config.addons.includes("husky") && !config.addons.includes("biome")) { - consola.warn( - "Husky addon is recommended to be used with Biome for lint-staged configuration.", - ); - } - config.addons = [...new Set(config.addons)]; - } - - if (config.examples && config.examples.length > 0) { - if (config.examples.includes("ai") && effectiveBackend === "elysia") { - consola.fatal( - "The 'ai' example is not compatible with the Elysia backend.", - ); - process.exit(1); - } - - const hasWebFrontendForExamples = effectiveFrontend?.some((f) => - [ - "tanstack-router", - "react-router", - "tanstack-start", - "next", - "nuxt", - "svelte", - ].includes(f), - ); - - if (config.examples.length > 0 && !hasWebFrontendForExamples) { - consola.fatal( - "Examples require a web frontend (tanstack-router, react-router, tanstack-start, next, nuxt, or svelte).", - ); - process.exit(1); + const noFrontendSelected = + !effectiveFrontend || effectiveFrontend.length === 0; } } @@ -544,7 +637,14 @@ function processAndValidateFlags( main().catch((err) => { consola.error("Aborting installation due to unexpected error..."); if (err instanceof Error) { - consola.error(err.message); + if ( + !err.message.includes("is only supported with") && + !err.message.includes("incompatible with") + ) { + consola.error(err.message); + consola.error(err.stack); + } else { + } } else { console.error(err); } diff --git a/apps/cli/src/prompts/api.ts b/apps/cli/src/prompts/api.ts index 9949040..1c38e24 100644 --- a/apps/cli/src/prompts/api.ts +++ b/apps/cli/src/prompts/api.ts @@ -1,12 +1,17 @@ -import { cancel, isCancel, select } from "@clack/prompts"; +import { cancel, isCancel, log, select } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; -import type { ProjectApi, ProjectFrontend } from "../types"; +import type { ProjectApi, ProjectBackend, ProjectFrontend } from "../types"; export async function getApiChoice( Api?: ProjectApi | undefined, frontend?: ProjectFrontend[], + backend?: ProjectBackend, ): Promise { + if (backend === "convex") { + return "none"; + } + if (Api) return Api; const includesNuxt = frontend?.includes("nuxt"); diff --git a/apps/cli/src/prompts/auth.ts b/apps/cli/src/prompts/auth.ts index 68ff755..a85a255 100644 --- a/apps/cli/src/prompts/auth.ts +++ b/apps/cli/src/prompts/auth.ts @@ -1,11 +1,17 @@ -import { cancel, confirm, isCancel } from "@clack/prompts"; +import { cancel, confirm, isCancel, log } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; +import type { ProjectBackend } from "../types"; export async function getAuthChoice( auth: boolean | undefined, hasDatabase: boolean, + backend?: ProjectBackend, ): Promise { + if (backend === "convex") { + return false; + } + if (!hasDatabase) return false; if (auth !== undefined) return auth; diff --git a/apps/cli/src/prompts/backend-framework.ts b/apps/cli/src/prompts/backend-framework.ts index c266c60..6d0381f 100644 --- a/apps/cli/src/prompts/backend-framework.ts +++ b/apps/cli/src/prompts/backend-framework.ts @@ -31,6 +31,11 @@ export async function getBackendFrameworkChoice( label: "Elysia", hint: "Ergonomic web framework for building backend servers", }, + { + value: "convex", + label: "Convex", + hint: "Reactive backend-as-a-service platform", + }, ], initialValue: DEFAULT_CONFIG.backend, }); diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 95822ef..701ba4a 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -30,19 +30,19 @@ import { getRuntimeChoice } from "./runtime"; type PromptGroupResults = { projectName: string; + frontend: ProjectFrontend[]; + backend: ProjectBackend; + runtime: ProjectRuntime; database: ProjectDatabase; orm: ProjectOrm; + api: ProjectApi; auth: boolean; addons: ProjectAddons[]; examples: ProjectExamples[]; + dbSetup: ProjectDBSetup; git: boolean; packageManager: ProjectPackageManager; install: boolean; - dbSetup: ProjectDBSetup; - backend: ProjectBackend; - runtime: ProjectRuntime; - frontend: ProjectFrontend[]; - api: ProjectApi; }; export async function gatherConfig( @@ -57,12 +57,19 @@ export async function gatherConfig( backend: () => getBackendFrameworkChoice(flags.backend), runtime: ({ results }) => getRuntimeChoice(flags.runtime, results.backend), - database: () => getDatabaseChoice(flags.database), + database: ({ results }) => + getDatabaseChoice(flags.database, results.backend), orm: ({ results }) => - getORMChoice(flags.orm, results.database !== "none", results.database), - api: ({ results }) => getApiChoice(flags.api, results.frontend), + getORMChoice( + flags.orm, + results.database !== "none", + results.database, + results.backend, + ), + api: ({ results }) => + getApiChoice(flags.api, results.frontend, results.backend), auth: ({ results }) => - getAuthChoice(flags.auth, results.database !== "none"), + getAuthChoice(flags.auth, results.database !== "none", results.backend), addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend), examples: ({ results }) => getExamplesChoice( @@ -76,6 +83,7 @@ export async function gatherConfig( results.database ?? "none", flags.dbSetup, results.orm, + results.backend, ), git: () => getGitChoice(flags.git), packageManager: () => getPackageManagerChoice(flags.packageManager), @@ -89,9 +97,20 @@ export async function gatherConfig( }, ); + if (result.backend === "convex") { + result.runtime = "none"; + result.database = "none"; + result.orm = "none"; + result.api = "none"; + result.auth = false; + result.dbSetup = "none"; + } + return { projectName: result.projectName, frontend: result.frontend, + backend: result.backend, + runtime: result.runtime, database: result.database, orm: result.orm, auth: result.auth, @@ -101,8 +120,6 @@ export async function gatherConfig( packageManager: result.packageManager, install: result.install, dbSetup: result.dbSetup, - backend: result.backend, - runtime: result.runtime, api: result.api, }; } diff --git a/apps/cli/src/prompts/database.ts b/apps/cli/src/prompts/database.ts index 1a126e9..7d08e19 100644 --- a/apps/cli/src/prompts/database.ts +++ b/apps/cli/src/prompts/database.ts @@ -1,11 +1,16 @@ -import { cancel, isCancel, select } from "@clack/prompts"; +import { cancel, isCancel, log, select } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; -import type { ProjectDatabase } from "../types"; +import type { ProjectBackend, ProjectDatabase } from "../types"; export async function getDatabaseChoice( database?: ProjectDatabase, + backend?: ProjectBackend, ): Promise { + if (backend === "convex") { + return "none"; + } + if (database !== undefined) return database; const response = await select({ diff --git a/apps/cli/src/prompts/db-setup.ts b/apps/cli/src/prompts/db-setup.ts index e3c7430..0ce2d72 100644 --- a/apps/cli/src/prompts/db-setup.ts +++ b/apps/cli/src/prompts/db-setup.ts @@ -1,14 +1,23 @@ -import { cancel, isCancel, select } from "@clack/prompts"; +import { cancel, isCancel, log, select } from "@clack/prompts"; import pc from "picocolors"; -import type { ProjectDBSetup, ProjectOrm } from "../types"; +import type { ProjectBackend, ProjectDBSetup, ProjectOrm } from "../types"; export async function getDBSetupChoice( databaseType: string, dbSetup: ProjectDBSetup | undefined, orm?: ProjectOrm, + backend?: ProjectBackend, ): Promise { + if (backend === "convex") { + return "none"; + } + if (dbSetup !== undefined) return dbSetup as ProjectDBSetup; + if (databaseType === "none") { + return "none"; + } + if (databaseType === "sqlite" && orm === "prisma") { return "none"; } diff --git a/apps/cli/src/prompts/examples.ts b/apps/cli/src/prompts/examples.ts index faa6c42..716ff97 100644 --- a/apps/cli/src/prompts/examples.ts +++ b/apps/cli/src/prompts/examples.ts @@ -1,4 +1,4 @@ -import { cancel, isCancel, multiselect } from "@clack/prompts"; +import { cancel, isCancel, log, multiselect } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; import type { @@ -16,17 +16,32 @@ export async function getExamplesChoice( ): Promise { if (examples !== undefined) return examples; + if (backend === "convex") { + return ["todo"]; + } + if (database === "none") return []; - const hasWebFrontend = - frontends?.includes("react-router") || - frontends?.includes("tanstack-router") || - frontends?.includes("tanstack-start") || - frontends?.includes("next") || - frontends?.includes("nuxt") || - frontends?.includes("svelte"); + const onlyNative = + frontends && frontends.length === 1 && frontends[0] === "native"; + if (onlyNative) { + return []; + } - if (!hasWebFrontend) return []; + const hasWebFrontend = + frontends?.some((f) => + [ + "react-router", + "tanstack-router", + "tanstack-start", + "next", + "nuxt", + "svelte", + ].includes(f), + ) ?? false; + const noFrontendSelected = !frontends || frontends.length === 0; + + if (!hasWebFrontend && !noFrontendSelected) return []; let response: ProjectExamples[] | symbol = []; const options: { value: ProjectExamples; label: string; hint: string }[] = [ diff --git a/apps/cli/src/prompts/orm.ts b/apps/cli/src/prompts/orm.ts index 56d5070..8dc0628 100644 --- a/apps/cli/src/prompts/orm.ts +++ b/apps/cli/src/prompts/orm.ts @@ -1,13 +1,18 @@ import { cancel, isCancel, log, select } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; -import type { ProjectDatabase, ProjectOrm } from "../types"; +import type { ProjectBackend, ProjectDatabase, ProjectOrm } from "../types"; export async function getORMChoice( orm: ProjectOrm | undefined, hasDatabase: boolean, database?: ProjectDatabase, + backend?: ProjectBackend, ): Promise { + if (backend === "convex") { + return "none"; + } + if (!hasDatabase) return "none"; if (orm !== undefined) return orm; diff --git a/apps/cli/src/prompts/runtime.ts b/apps/cli/src/prompts/runtime.ts index 8dfb221..9940f67 100644 --- a/apps/cli/src/prompts/runtime.ts +++ b/apps/cli/src/prompts/runtime.ts @@ -1,4 +1,4 @@ -import { cancel, isCancel, select } from "@clack/prompts"; +import { cancel, isCancel, log, select } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; import type { ProjectBackend, ProjectRuntime } from "../types"; @@ -7,6 +7,10 @@ export async function getRuntimeChoice( runtime?: ProjectRuntime, backend?: ProjectBackend, ): Promise { + if (backend === "convex") { + return "none"; + } + if (runtime !== undefined) return runtime; if (backend === "next") { diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 8ab1d73..e3f679f 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -14,8 +14,8 @@ export type ProjectAddons = | "starlight" | "turborepo" | "none"; -export type ProjectBackend = "hono" | "elysia" | "express" | "next"; -export type ProjectRuntime = "node" | "bun"; +export type ProjectBackend = "hono" | "elysia" | "express" | "next" | "convex"; +export type ProjectRuntime = "node" | "bun" | "none"; export type ProjectExamples = "todo" | "ai" | "none"; export type ProjectFrontend = | "react-router" @@ -32,7 +32,7 @@ export type ProjectDBSetup = | "mongodb-atlas" | "neon" | "none"; -export type ProjectApi = "trpc" | "orpc"; +export type ProjectApi = "trpc" | "orpc" | "none"; export interface ProjectConfig { projectName: string; diff --git a/apps/cli/src/utils/display-config.ts b/apps/cli/src/utils/display-config.ts index 4d54cc1..d521bb3 100644 --- a/apps/cli/src/utils/display-config.ts +++ b/apps/cli/src/utils/display-config.ts @@ -13,7 +13,7 @@ export function displayConfig(config: Partial) { ? config.frontend : [config.frontend]; const frontendText = - frontend.length > 0 && frontend[0] !== undefined && frontend[0] !== "" + frontend.length > 0 && frontend[0] !== undefined ? frontend.join(", ") : "none"; configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`); diff --git a/apps/cli/templates/addons/turborepo/turbo.json b/apps/cli/templates/addons/turborepo/turbo.json.hbs similarity index 72% rename from apps/cli/templates/addons/turborepo/turbo.json rename to apps/cli/templates/addons/turborepo/turbo.json.hbs index f146972..5444744 100644 --- a/apps/cli/templates/addons/turborepo/turbo.json +++ b/apps/cli/templates/addons/turborepo/turbo.json.hbs @@ -16,7 +16,12 @@ "dev": { "cache": false, "persistent": true - }, + }{{#if (eq backend "convex")}}, + "setup": { + "cache": false, + "persistent": true + } + {{else}}{{#unless (or (eq database "none") (eq orm "none"))}}, "db:push": { "cache": false, "persistent": true @@ -25,5 +30,6 @@ "cache": false, "persistent": true } + {{/unless}}{{/if}} } } diff --git a/apps/cli/templates/api/orpc/server/base/src/lib/context.ts.hbs b/apps/cli/templates/api/orpc/server/base/src/lib/context.ts.hbs index 2bd35d4..3c651c2 100644 --- a/apps/cli/templates/api/orpc/server/base/src/lib/context.ts.hbs +++ b/apps/cli/templates/api/orpc/server/base/src/lib/context.ts.hbs @@ -92,8 +92,6 @@ export async function createContext(opts: any) { } {{else}} -// Default or fallback context if backend is not recognized or none -// This might need adjustment based on your default behavior export async function createContext() { return { session: null, diff --git a/apps/cli/templates/auth/web/nuxt/app/components/SignInForm.vue b/apps/cli/templates/auth/web/nuxt/app/components/SignInForm.vue index f7ef51f..508f238 100644 --- a/apps/cli/templates/auth/web/nuxt/app/components/SignInForm.vue +++ b/apps/cli/templates/auth/web/nuxt/app/components/SignInForm.vue @@ -1,6 +1,5 @@