diff --git a/.changeset/smooth-cows-enter.md b/.changeset/smooth-cows-enter.md new file mode 100644 index 0000000..c4205d4 --- /dev/null +++ b/.changeset/smooth-cows-enter.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +add vibe-rules addon with a better t stack rules file diff --git a/.gitignore b/.gitignore index 85f3209..970c153 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ yarn-error.log* *.pem .vscode .env*.local + +.smoke \ No newline at end of file diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 2130559..79db83e 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -135,6 +135,7 @@ export const ADDON_COMPATIBILITY: Record = { turborepo: [], starlight: [], ultracite: [], + "vibe-rules": [], oxlint: [], fumadocs: [], none: [], diff --git a/apps/cli/src/helpers/database-providers/neon-setup.ts b/apps/cli/src/helpers/database-providers/neon-setup.ts index 4e6b6bb..94ef692 100644 --- a/apps/cli/src/helpers/database-providers/neon-setup.ts +++ b/apps/cli/src/helpers/database-providers/neon-setup.ts @@ -1,10 +1,11 @@ import path from "node:path"; -import { cancel, isCancel, log, select, spinner, text } from "@clack/prompts"; +import { 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 { PackageManager, ProjectConfig } from "../../types"; +import { exitCancelled } from "../../utils/errors"; import { getPackageExecutionCommand } from "../../utils/package-runner"; import { addEnvVariablesToFile, @@ -177,10 +178,7 @@ export async function setupNeonPostgres(config: ProjectConfig) { initialValue: "neondb", }); - if (isCancel(setupMethod)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(setupMethod)) return exitCancelled("Operation cancelled"); if (setupMethod === "neondb") { await setupWithNeonDb(projectDir, packageManager); @@ -198,10 +196,8 @@ export async function setupNeonPostgres(config: ProjectConfig) { initialValue: NEON_REGIONS[0].value, }); - if (isCancel(projectName) || isCancel(regionId)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(projectName) || isCancel(regionId)) + return exitCancelled("Operation cancelled"); const neonConfig = await createNeonProject( projectName as string, diff --git a/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts b/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts index bf54261..e9f7201 100644 --- a/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts +++ b/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts @@ -1,11 +1,12 @@ import path from "node:path"; -import { cancel, isCancel, log, select, text } from "@clack/prompts"; +import { isCancel, log, select, text } from "@clack/prompts"; import { consola } from "consola"; import { execa } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { ORM, PackageManager, ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; +import { exitCancelled } from "../../utils/errors"; import { getPackageExecutionCommand } from "../../utils/package-runner"; import { addEnvVariablesToFile, @@ -60,10 +61,7 @@ async function setupWithCreateDb( }, }); - if (isCancel(databaseUrl)) { - cancel("Database setup cancelled"); - return null; - } + if (isCancel(databaseUrl)) return null; return { databaseUrl: databaseUrl as string, @@ -115,10 +113,7 @@ async function initPrismaDatabase( }, }); - if (isCancel(databaseUrl)) { - cancel("Database setup cancelled"); - return null; - } + if (isCancel(databaseUrl)) return null; return { databaseUrl: databaseUrl as string, @@ -245,10 +240,7 @@ export async function setupPrismaPostgres(config: ProjectConfig) { initialValue: "create-db", }); - if (isCancel(setupMethod)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(setupMethod)) return exitCancelled("Operation cancelled"); let prismaConfig: PrismaConfig | null = null; diff --git a/apps/cli/src/helpers/database-providers/turso-setup.ts b/apps/cli/src/helpers/database-providers/turso-setup.ts index 4cbf734..e08b8b5 100644 --- a/apps/cli/src/helpers/database-providers/turso-setup.ts +++ b/apps/cli/src/helpers/database-providers/turso-setup.ts @@ -1,19 +1,12 @@ import os from "node:os"; import path from "node:path"; -import { - cancel, - confirm, - isCancel, - log, - select, - spinner, - text, -} from "@clack/prompts"; +import { confirm, isCancel, log, select, spinner, text } from "@clack/prompts"; import consola from "consola"; import { $ } from "execa"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { commandExists } from "../../utils/command-exists"; +import { exitCancelled } from "../../utils/errors"; import { addEnvVariablesToFile, type EnvVariable, @@ -129,10 +122,7 @@ async function selectTursoGroup(): Promise { options: groupOptions, }); - if (isCancel(selectedGroup)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(selectedGroup)) return exitCancelled("Operation cancelled"); return selectedGroup as string; } @@ -236,10 +226,7 @@ export async function setupTurso(config: ProjectConfig) { initialValue: true, }); - if (isCancel(shouldInstall)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(shouldInstall)) return exitCancelled("Operation cancelled"); if (!shouldInstall) { await writeEnvFile(projectDir); @@ -269,10 +256,7 @@ export async function setupTurso(config: ProjectConfig) { placeholder: suggestedName, }); - if (isCancel(dbNameResponse)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(dbNameResponse)) return exitCancelled("Operation cancelled"); dbName = dbNameResponse as string; diff --git a/apps/cli/src/helpers/project-generation/add-addons.ts b/apps/cli/src/helpers/project-generation/add-addons.ts index e930458..9329990 100644 --- a/apps/cli/src/helpers/project-generation/add-addons.ts +++ b/apps/cli/src/helpers/project-generation/add-addons.ts @@ -1,9 +1,10 @@ import path from "node:path"; -import { cancel, log } from "@clack/prompts"; +import { log } from "@clack/prompts"; import pc from "picocolors"; import type { AddInput, Addons, ProjectConfig } from "../../types"; import { validateAddonCompatibility } from "../../utils/addon-compatibility"; import { updateBtsConfig } from "../../utils/bts-config"; +import { exitWithError } from "../../utils/errors"; import { setupAddons } from "../setup/addons-setup"; import { detectProjectConfig, @@ -12,11 +13,6 @@ import { import { installDependencies } from "./install-dependencies"; import { setupAddonsTemplate } from "./template-manager"; -function exitWithError(message: string): never { - cancel(pc.red(message)); - process.exit(1); -} - export async function addAddonsToProject( input: AddInput & { addons: Addons[]; suppressInstallMessage?: boolean }, ) { @@ -71,10 +67,6 @@ export async function addAddonsToProject( } } - log.info( - `Adding ${input.addons.join(", ")} to ${config.frontend.join("/")}`, - ); - await setupAddonsTemplate(projectDir, config); await setupAddons(config, true); diff --git a/apps/cli/src/helpers/project-generation/add-deployment.ts b/apps/cli/src/helpers/project-generation/add-deployment.ts index 8575b94..7906ff0 100644 --- a/apps/cli/src/helpers/project-generation/add-deployment.ts +++ b/apps/cli/src/helpers/project-generation/add-deployment.ts @@ -1,8 +1,9 @@ import path from "node:path"; -import { cancel, log } from "@clack/prompts"; +import { log } from "@clack/prompts"; import pc from "picocolors"; import type { AddInput, ProjectConfig, WebDeploy } from "../../types"; import { updateBtsConfig } from "../../utils/bts-config"; +import { exitWithError } from "../../utils/errors"; import { setupWebDeploy } from "../setup/web-deploy-setup"; import { detectProjectConfig, @@ -11,11 +12,6 @@ import { import { installDependencies } from "./install-dependencies"; import { setupDeploymentTemplates } from "./template-manager"; -function exitWithError(message: string): never { - cancel(pc.red(message)); - process.exit(1); -} - export async function addDeploymentToProject( input: AddInput & { webDeploy: WebDeploy; suppressInstallMessage?: boolean }, ) { diff --git a/apps/cli/src/helpers/project-generation/command-handlers.ts b/apps/cli/src/helpers/project-generation/command-handlers.ts index d4f815c..6a1e440 100644 --- a/apps/cli/src/helpers/project-generation/command-handlers.ts +++ b/apps/cli/src/helpers/project-generation/command-handlers.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { cancel, intro, log, outro } from "@clack/prompts"; +import { intro, log, outro } from "@clack/prompts"; import fs from "fs-extra"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../../constants"; @@ -10,6 +10,7 @@ import { getDeploymentToAdd } from "../../prompts/web-deploy"; import type { AddInput, CreateInput, ProjectConfig } from "../../types"; import { trackProjectCreation } from "../../utils/analytics"; import { displayConfig } from "../../utils/display-config"; +import { exitWithError, handleError } from "../../utils/errors"; import { generateReproducibleCommand } from "../../utils/generate-reproducible-command"; import { handleDirectoryConflict, @@ -131,8 +132,7 @@ export async function createProjectHandler( ), ); } catch (error) { - console.error(error); - process.exit(1); + handleError(error, "Failed to create project"); } } @@ -142,12 +142,9 @@ export async function addAddonsHandler(input: AddInput) { const detectedConfig = await detectProjectConfig(projectDir); if (!detectedConfig) { - cancel( - pc.red( - "Could not detect project configuration. Please ensure this is a valid Better-T Stack project.", - ), + exitWithError( + "Could not detect project configuration. Please ensure this is a valid Better-T Stack project.", ); - process.exit(1); } if (!input.addons || input.addons.length === 0) { @@ -215,7 +212,6 @@ export async function addAddonsHandler(input: AddInput) { outro("Add command completed successfully!"); } catch (error) { - console.error(error); - process.exit(1); + handleError(error, "Failed to add addons or deployment"); } } diff --git a/apps/cli/src/helpers/project-generation/create-project.ts b/apps/cli/src/helpers/project-generation/create-project.ts index c3067c4..647464a 100644 --- a/apps/cli/src/helpers/project-generation/create-project.ts +++ b/apps/cli/src/helpers/project-generation/create-project.ts @@ -1,8 +1,8 @@ -import { cancel, log } from "@clack/prompts"; +import { log } from "@clack/prompts"; import fs from "fs-extra"; -import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { writeBtsConfig } from "../../utils/bts-config"; +import { exitWithError } from "../../utils/errors"; import { setupAddons } from "../setup/addons-setup"; import { setupApi } from "../setup/api-setup"; import { setupAuth } from "../setup/auth-setup"; @@ -104,13 +104,11 @@ export async function createProject(options: ProjectConfig) { return projectDir; } catch (error) { if (error instanceof Error) { - cancel(pc.red(`Error during project creation: ${error.message}`)); console.error(error.stack); - process.exit(1); + exitWithError(`Error during project creation: ${error.message}`); } else { - cancel(pc.red(`An unexpected error occurred: ${String(error)}`)); console.error(error); - process.exit(1); + exitWithError(`An unexpected error occurred: ${String(error)}`); } } } diff --git a/apps/cli/src/helpers/project-generation/template-manager.ts b/apps/cli/src/helpers/project-generation/template-manager.ts index 72197e9..b94b284 100644 --- a/apps/cli/src/helpers/project-generation/template-manager.ts +++ b/apps/cli/src/helpers/project-generation/template-manager.ts @@ -535,6 +535,8 @@ export async function setupAddonsTemplate( for (const addon of context.addons) { if (addon === "none") continue; + if (addon === "vibe-rules") continue; + let addonSrcDir = path.join(PKG_ROOT, `templates/addons/${addon}`); let addonDestDir = projectDir; diff --git a/apps/cli/src/helpers/setup/addons-setup.ts b/apps/cli/src/helpers/setup/addons-setup.ts index def58cc..7b0bb0a 100644 --- a/apps/cli/src/helpers/setup/addons-setup.ts +++ b/apps/cli/src/helpers/setup/addons-setup.ts @@ -10,6 +10,7 @@ import { setupFumadocs } from "./fumadocs-setup"; import { setupStarlight } from "./starlight-setup"; import { setupTauri } from "./tauri-setup"; import { setupUltracite } from "./ultracite-setup"; +import { setupVibeRules } from "./vibe-rules-setup"; import { addPwaToViteConfig } from "./vite-pwa-setup"; export async function setupAddons(config: ProjectConfig, isAddCommand = false) { @@ -85,6 +86,10 @@ ${pc.cyan("Docs:")} ${pc.underline("https://turborepo.com/docs")} if (addons.includes("starlight")) { await setupStarlight(config); } + + if (addons.includes("vibe-rules")) { + await setupVibeRules(config); + } if (addons.includes("fumadocs")) { await setupFumadocs(config); } diff --git a/apps/cli/src/helpers/setup/fumadocs-setup.ts b/apps/cli/src/helpers/setup/fumadocs-setup.ts index 2e669e9..de7d586 100644 --- a/apps/cli/src/helpers/setup/fumadocs-setup.ts +++ b/apps/cli/src/helpers/setup/fumadocs-setup.ts @@ -1,10 +1,11 @@ import path from "node:path"; -import { cancel, isCancel, log, select } from "@clack/prompts"; +import { isCancel, log, select } 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 { exitCancelled } from "../../utils/errors"; import { getPackageExecutionCommand } from "../../utils/package-runner"; type FumadocsTemplate = @@ -52,10 +53,7 @@ export async function setupFumadocs(config: ProjectConfig) { initialValue: "next-mdx", }); - if (isCancel(template)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(template)) return exitCancelled("Operation cancelled"); const templateArg = TEMPLATES[template].value; diff --git a/apps/cli/src/helpers/setup/ultracite-setup.ts b/apps/cli/src/helpers/setup/ultracite-setup.ts index 1c18934..5ae376e 100644 --- a/apps/cli/src/helpers/setup/ultracite-setup.ts +++ b/apps/cli/src/helpers/setup/ultracite-setup.ts @@ -1,8 +1,9 @@ -import { cancel, isCancel, log, multiselect } from "@clack/prompts"; +import { isCancel, log, multiselect } from "@clack/prompts"; import { execa } from "execa"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; +import { exitCancelled } from "../../utils/errors"; import { getPackageExecutionCommand } from "../../utils/package-runner"; import { setupBiome } from "./addons-setup"; @@ -71,10 +72,7 @@ export async function setupUltracite(config: ProjectConfig, hasHusky: boolean) { required: false, }); - if (isCancel(editors)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(editors)) return exitCancelled("Operation cancelled"); const rules = await multiselect({ message: "Choose rules", @@ -86,10 +84,7 @@ export async function setupUltracite(config: ProjectConfig, hasHusky: boolean) { required: false, }); - if (isCancel(rules)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(rules)) return exitCancelled("Operation cancelled"); const ultraciteArgs = ["init", "--pm", packageManager]; diff --git a/apps/cli/src/helpers/setup/vibe-rules-setup.ts b/apps/cli/src/helpers/setup/vibe-rules-setup.ts new file mode 100644 index 0000000..a4e898c --- /dev/null +++ b/apps/cli/src/helpers/setup/vibe-rules-setup.ts @@ -0,0 +1,112 @@ +import path from "node:path"; +import { isCancel, log, multiselect, spinner } from "@clack/prompts"; +import { execa } from "execa"; +import fs from "fs-extra"; +import pc from "picocolors"; +import { PKG_ROOT } from "../../constants"; +import type { ProjectConfig } from "../../types"; +import { exitCancelled } from "../../utils/errors"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; +import { processTemplate } from "../../utils/template-processor"; + +export async function setupVibeRules(config: ProjectConfig) { + const { packageManager, projectDir } = config; + + try { + log.info("Setting up vibe-rules..."); + + const rulesDir = path.join(projectDir, ".bts"); + const ruleFile = path.join(rulesDir, "rules.md"); + if (!(await fs.pathExists(ruleFile))) { + const templatePath = path.join( + PKG_ROOT, + "templates", + "addons", + "vibe-rules", + ".bts", + "rules.md.hbs", + ); + if (await fs.pathExists(templatePath)) { + await fs.ensureDir(rulesDir); + await processTemplate(templatePath, ruleFile, config); + } else { + log.error(pc.red("Rules template not found for vibe-rules addon")); + return; + } + } + + const EDITORS = { + cursor: { label: "Cursor", hint: ".cursor/rules/*.mdc" }, + windsurf: { label: "Windsurf", hint: ".windsurfrules" }, + "claude-code": { label: "Claude Code", hint: "CLAUDE.md" }, + vscode: { + label: "VSCode", + hint: ".github/instructions/*.instructions.md", + }, + gemini: { label: "Gemini", hint: "GEMINI.md" }, + codex: { label: "Codex", hint: "AGENTS.md" }, + clinerules: { label: "Cline/Roo", hint: ".clinerules/*.md" }, + roo: { label: "Roo", hint: ".clinerules/*.md" }, + zed: { label: "Zed", hint: ".rules/*.md" }, + unified: { label: "Unified", hint: ".rules/*.md" }, + } as const; + + const selectedEditors = await multiselect({ + message: "Choose editors to install BTS rule", + options: Object.entries(EDITORS).map(([key, v]) => ({ + value: key as keyof typeof EDITORS, + label: v.label, + hint: v.hint, + })), + required: false, + }); + + if (isCancel(selectedEditors)) return exitCancelled("Operation cancelled"); + + const editorsArg = selectedEditors.join(", "); + const s = spinner(); + s.start("Saving and applying BTS rules..."); + + try { + const saveCmd = getPackageExecutionCommand( + packageManager, + `vibe-rules@latest save bts -f ${JSON.stringify( + path.relative(projectDir, ruleFile), + )}`, + ); + await execa(saveCmd, { + cwd: projectDir, + env: { CI: "true" }, + shell: true, + }); + + for (const editor of selectedEditors) { + const loadCmd = getPackageExecutionCommand( + packageManager, + `vibe-rules@latest load bts ${editor}`, + ); + await execa(loadCmd, { + cwd: projectDir, + env: { CI: "true" }, + shell: true, + }); + } + + s.stop(`Applied BTS rules to: ${editorsArg}`); + } catch (error) { + s.stop(pc.red("Failed to apply BTS rules")); + throw error; + } + + try { + await fs.remove(rulesDir); + } catch (_) {} + + log.success("vibe-rules setup successfully!"); + } catch (error) { + log.error(pc.red("Failed to set up vibe-rules")); + if (error instanceof Error) { + console.error(pc.red(error.message)); + } + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 383ea9c..66a52fd 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,5 +1,4 @@ import { intro, log } from "@clack/prompts"; -import { consola } from "consola"; import pc from "picocolors"; import { createCli, trpcServer } from "trpc-cli"; import z from "zod"; @@ -21,6 +20,7 @@ import { RuntimeSchema, WebDeploySchema, } from "./types"; +import { handleError } from "./utils/errors"; import { getLatestCLIVersion } from "./utils/get-latest-cli-version"; import { openUrl } from "./utils/open-url"; import { renderTitle } from "./utils/render-title"; @@ -102,8 +102,7 @@ const router = t.router({ const sponsors = await fetchSponsors(); displaySponsors(sponsors); } catch (error) { - consola.error(error); - process.exit(1); + handleError(error, "Failed to display sponsors"); } }), docs: t.procedure diff --git a/apps/cli/src/prompts/addons.ts b/apps/cli/src/prompts/addons.ts index 9863800..c844673 100644 --- a/apps/cli/src/prompts/addons.ts +++ b/apps/cli/src/prompts/addons.ts @@ -1,11 +1,11 @@ -import { cancel, groupMultiselect, isCancel } from "@clack/prompts"; -import pc from "picocolors"; +import { groupMultiselect, isCancel } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import { type Addons, AddonsSchema, type Frontend } from "../types"; import { getCompatibleAddons, validateAddonCompatibility, } from "../utils/addon-compatibility"; +import { exitCancelled } from "../utils/errors"; type AddonOption = { value: Addons; @@ -42,6 +42,10 @@ function getAddonDisplay(addon: Addons): { label: string; hint: string } { label = "Ultracite"; hint = "Zero-config Biome preset with AI integration"; break; + case "vibe-rules": + label = "vibe-rules"; + hint = "Install and apply BTS rules to editors"; + break; case "husky": label = "Husky"; hint = "Modern native Git hooks made easy"; @@ -65,7 +69,7 @@ function getAddonDisplay(addon: Addons): { label: string; hint: string } { const ADDON_GROUPS = { Documentation: ["starlight", "fumadocs"], Linting: ["biome", "oxlint", "ultracite"], - Other: ["turborepo", "pwa", "tauri", "husky"], + Other: ["vibe-rules", "turborepo", "pwa", "tauri", "husky"], }; export async function getAddonsChoice( @@ -119,10 +123,7 @@ export async function getAddonsChoice( selectableGroups: false, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } @@ -175,10 +176,7 @@ export async function getAddonsToAdd( selectableGroups: false, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/api.ts b/apps/cli/src/prompts/api.ts index 26b0c78..67e4012 100644 --- a/apps/cli/src/prompts/api.ts +++ b/apps/cli/src/prompts/api.ts @@ -1,7 +1,7 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import type { API, Backend, Frontend } from "../types"; import { allowedApisForFrontends } from "../utils/compatibility-rules"; +import { exitCancelled } from "../utils/errors"; export async function getApiChoice( Api?: API | undefined, @@ -43,10 +43,7 @@ export async function getApiChoice( initialValue: apiOptions[0].value, }); - if (isCancel(apiType)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(apiType)) return exitCancelled("Operation cancelled"); return apiType; } diff --git a/apps/cli/src/prompts/auth.ts b/apps/cli/src/prompts/auth.ts index 1b7696e..46a79aa 100644 --- a/apps/cli/src/prompts/auth.ts +++ b/apps/cli/src/prompts/auth.ts @@ -1,7 +1,7 @@ -import { cancel, confirm, isCancel } from "@clack/prompts"; -import pc from "picocolors"; +import { confirm, isCancel } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { Backend } from "../types"; +import { exitCancelled } from "../utils/errors"; export async function getAuthChoice( auth: boolean | undefined, @@ -21,10 +21,7 @@ export async function getAuthChoice( initialValue: DEFAULT_CONFIG.auth, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/backend.ts b/apps/cli/src/prompts/backend.ts index b5d47dc..5ee902b 100644 --- a/apps/cli/src/prompts/backend.ts +++ b/apps/cli/src/prompts/backend.ts @@ -1,7 +1,7 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Frontend } from "../types"; +import { exitCancelled } from "../utils/errors"; export async function getBackendFrameworkChoice( backendFramework?: Backend, @@ -63,10 +63,7 @@ export async function getBackendFrameworkChoice( initialValue: DEFAULT_CONFIG.backend, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 34f287d..3870f34 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -1,5 +1,4 @@ -import { cancel, group } from "@clack/prompts"; -import pc from "picocolors"; +import { group } from "@clack/prompts"; import type { Addons, API, @@ -14,6 +13,7 @@ import type { Runtime, WebDeploy, } from "../types"; +import { exitCancelled } from "../utils/errors"; import { getAddonsChoice } from "./addons"; import { getApiChoice } from "./api"; import { getAuthChoice } from "./auth"; @@ -102,10 +102,7 @@ export async function gatherConfig( install: () => getinstallChoice(flags.install), }, { - onCancel: () => { - cancel(pc.red("Operation cancelled")); - process.exit(0); - }, + onCancel: () => exitCancelled("Operation cancelled"), }, ); diff --git a/apps/cli/src/prompts/database-setup.ts b/apps/cli/src/prompts/database-setup.ts index 164d275..2d27d7c 100644 --- a/apps/cli/src/prompts/database-setup.ts +++ b/apps/cli/src/prompts/database-setup.ts @@ -1,6 +1,6 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import type { Backend, DatabaseSetup, ORM, Runtime } from "../types"; +import { exitCancelled } from "../utils/errors"; export async function getDBSetupChoice( databaseType: string, @@ -101,10 +101,7 @@ export async function getDBSetupChoice( initialValue: "none", }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/database.ts b/apps/cli/src/prompts/database.ts index 3e1eae2..16c717d 100644 --- a/apps/cli/src/prompts/database.ts +++ b/apps/cli/src/prompts/database.ts @@ -1,7 +1,7 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Database, Runtime } from "../types"; +import { exitCancelled } from "../utils/errors"; export async function getDatabaseChoice( database?: Database, @@ -55,10 +55,7 @@ export async function getDatabaseChoice( initialValue: DEFAULT_CONFIG.database, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/examples.ts b/apps/cli/src/prompts/examples.ts index 13244ed..4d42424 100644 --- a/apps/cli/src/prompts/examples.ts +++ b/apps/cli/src/prompts/examples.ts @@ -1,11 +1,11 @@ -import { cancel, isCancel, multiselect } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, multiselect } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { API, Backend, Database, Examples, Frontend } from "../types"; import { isExampleAIAllowed, isExampleTodoAllowed, } from "../utils/compatibility-rules"; +import { exitCancelled } from "../utils/errors"; export async function getExamplesChoice( examples?: Examples[], @@ -63,10 +63,7 @@ export async function getExamplesChoice( ), }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/frontend.ts b/apps/cli/src/prompts/frontend.ts index c337ada..7bc2cb7 100644 --- a/apps/cli/src/prompts/frontend.ts +++ b/apps/cli/src/prompts/frontend.ts @@ -1,8 +1,8 @@ -import { cancel, isCancel, multiselect, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, multiselect, select } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Frontend } from "../types"; import { isFrontendAllowedWithBackend } from "../utils/compatibility-rules"; +import { exitCancelled } from "../utils/errors"; export async function getFrontendChoice( frontendOptions?: Frontend[], @@ -28,10 +28,7 @@ export async function getFrontendChoice( initialValues: ["web"], }); - if (isCancel(frontendTypes)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(frontendTypes)) return exitCancelled("Operation cancelled"); const result: Frontend[] = []; @@ -69,7 +66,7 @@ export async function getFrontendChoice( }, { value: "tanstack-start" as const, - label: "TanStack Start (vite)", + label: "TanStack Start", hint: "SSR, Server Functions, API Routes and more with TanStack Router", }, ]; @@ -84,10 +81,7 @@ export async function getFrontendChoice( initialValue: DEFAULT_CONFIG.frontend[0], }); - if (isCancel(webFramework)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(webFramework)) return exitCancelled("Operation cancelled"); result.push(webFramework); } @@ -110,10 +104,7 @@ export async function getFrontendChoice( initialValue: "native-nativewind", }); - if (isCancel(nativeFramework)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(nativeFramework)) return exitCancelled("Operation cancelled"); result.push(nativeFramework); } diff --git a/apps/cli/src/prompts/git.ts b/apps/cli/src/prompts/git.ts index 54eb10c..9f04cd5 100644 --- a/apps/cli/src/prompts/git.ts +++ b/apps/cli/src/prompts/git.ts @@ -1,6 +1,6 @@ -import { cancel, confirm, isCancel } from "@clack/prompts"; -import pc from "picocolors"; +import { confirm, isCancel } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; +import { exitCancelled } from "../utils/errors"; export async function getGitChoice(git?: boolean) { if (git !== undefined) return git; @@ -10,10 +10,7 @@ export async function getGitChoice(git?: boolean) { initialValue: DEFAULT_CONFIG.git, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/install.ts b/apps/cli/src/prompts/install.ts index 6ad5c5a..40a5e0f 100644 --- a/apps/cli/src/prompts/install.ts +++ b/apps/cli/src/prompts/install.ts @@ -1,6 +1,6 @@ -import { cancel, confirm, isCancel } from "@clack/prompts"; -import pc from "picocolors"; +import { confirm, isCancel } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; +import { exitCancelled } from "../utils/errors"; export async function getinstallChoice(install?: boolean) { if (install !== undefined) return install; @@ -10,10 +10,7 @@ export async function getinstallChoice(install?: boolean) { initialValue: DEFAULT_CONFIG.install, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/orm.ts b/apps/cli/src/prompts/orm.ts index 736f2a2..5e8f03e 100644 --- a/apps/cli/src/prompts/orm.ts +++ b/apps/cli/src/prompts/orm.ts @@ -1,7 +1,7 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Database, ORM, Runtime } from "../types"; +import { exitCancelled } from "../utils/errors"; const ormOptions = { prisma: { @@ -51,10 +51,7 @@ export async function getORMChoice( initialValue: database === "mongodb" ? "prisma" : DEFAULT_CONFIG.orm, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/package-manager.ts b/apps/cli/src/prompts/package-manager.ts index 25dcbb9..78bc740 100644 --- a/apps/cli/src/prompts/package-manager.ts +++ b/apps/cli/src/prompts/package-manager.ts @@ -1,6 +1,6 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import type { PackageManager } from "../types"; +import { exitCancelled } from "../utils/errors"; import { getUserPkgManager } from "../utils/get-package-manager"; export async function getPackageManagerChoice( @@ -28,10 +28,7 @@ export async function getPackageManagerChoice( initialValue: detectedPackageManager, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/project-name.ts b/apps/cli/src/prompts/project-name.ts index 762a163..9e1e478 100644 --- a/apps/cli/src/prompts/project-name.ts +++ b/apps/cli/src/prompts/project-name.ts @@ -1,10 +1,11 @@ import path from "node:path"; -import { cancel, isCancel, text } from "@clack/prompts"; +import { isCancel, text } from "@clack/prompts"; import consola from "consola"; import fs from "fs-extra"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; import { ProjectNameSchema } from "../types"; +import { exitCancelled } from "../utils/errors"; function isPathWithinCwd(targetPath: string): boolean { const resolved = path.resolve(targetPath); @@ -76,10 +77,7 @@ export async function getProjectName(initialName?: string): Promise { }, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled.")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled."); projectPath = response || defaultName; isValid = true; diff --git a/apps/cli/src/prompts/runtime.ts b/apps/cli/src/prompts/runtime.ts index 2a73ab9..0c630c3 100644 --- a/apps/cli/src/prompts/runtime.ts +++ b/apps/cli/src/prompts/runtime.ts @@ -1,7 +1,7 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Runtime } from "../types"; +import { exitCancelled } from "../utils/errors"; export async function getRuntimeChoice( runtime?: Runtime, @@ -48,10 +48,7 @@ export async function getRuntimeChoice( initialValue: DEFAULT_CONFIG.runtime, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/prompts/web-deploy.ts b/apps/cli/src/prompts/web-deploy.ts index e948758..eff0bea 100644 --- a/apps/cli/src/prompts/web-deploy.ts +++ b/apps/cli/src/prompts/web-deploy.ts @@ -1,8 +1,8 @@ -import { cancel, isCancel, select } from "@clack/prompts"; -import pc from "picocolors"; +import { isCancel, select } from "@clack/prompts"; import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Frontend, Runtime, WebDeploy } from "../types"; import { WEB_FRAMEWORKS } from "../utils/compatibility"; +import { exitCancelled } from "../utils/errors"; function hasWebFrontend(frontends: Frontend[]): boolean { return frontends.some((f) => WEB_FRAMEWORKS.includes(f)); @@ -56,10 +56,7 @@ export async function getDeploymentChoice( initialValue: DEFAULT_CONFIG.webDeploy, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } @@ -105,10 +102,7 @@ export async function getDeploymentToAdd( initialValue: DEFAULT_CONFIG.webDeploy, }); - if (isCancel(response)) { - cancel(pc.red("Operation cancelled")); - process.exit(0); - } + if (isCancel(response)) return exitCancelled("Operation cancelled"); return response; } diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index 4997ceb..ed65b20 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -43,6 +43,7 @@ export const AddonsSchema = z "starlight", "biome", "husky", + "vibe-rules", "turborepo", "fumadocs", "ultracite", diff --git a/apps/cli/src/utils/compatibility-rules.ts b/apps/cli/src/utils/compatibility-rules.ts index 637abdc..c7b4b6f 100644 --- a/apps/cli/src/utils/compatibility-rules.ts +++ b/apps/cli/src/utils/compatibility-rules.ts @@ -1,4 +1,3 @@ -import { consola } from "consola"; import type { Addons, API, @@ -9,6 +8,7 @@ import type { } from "../types"; import { validateAddonCompatibility } from "./addon-compatibility"; import { WEB_FRAMEWORKS } from "./compatibility"; +import { exitWithError } from "./errors"; export function isWebFrontend(value: Frontend): boolean { return WEB_FRAMEWORKS.includes(value); @@ -28,16 +28,14 @@ export function splitFrontends(values: Frontend[] = []): { export function ensureSingleWebAndNative(frontends: Frontend[]) { const { web, native } = splitFrontends(frontends); if (web.length > 1) { - consola.fatal( + exitWithError( "Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid", ); - process.exit(1); } if (native.length > 1) { - consola.fatal( + exitWithError( "Cannot select multiple native frameworks. Choose only one of: native-nativewind, native-unistyles", ); - process.exit(1); } } @@ -52,10 +50,9 @@ export function validateWorkersCompatibility( config.backend && config.backend !== "hono" ) { - consola.fatal( + exitWithError( `Cloudflare Workers runtime (--runtime workers) is only supported with Hono backend (--backend hono). Current backend: ${config.backend}. Please use '--backend hono' or choose a different runtime.`, ); - process.exit(1); } if ( @@ -64,10 +61,9 @@ export function validateWorkersCompatibility( config.backend !== "hono" && config.runtime === "workers" ) { - consola.fatal( + exitWithError( `Backend '${config.backend}' is not compatible with Cloudflare Workers runtime. Cloudflare Workers runtime is only supported with Hono backend. Please use '--backend hono' or choose a different runtime.`, ); - process.exit(1); } if ( @@ -77,10 +73,9 @@ export function validateWorkersCompatibility( config.orm !== "drizzle" && config.orm !== "none" ) { - consola.fatal( + 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.`, ); - process.exit(1); } if ( @@ -90,10 +85,9 @@ export function validateWorkersCompatibility( config.orm !== "none" && config.runtime === "workers" ) { - consola.fatal( + 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.`, ); - process.exit(1); } if ( @@ -101,10 +95,9 @@ export function validateWorkersCompatibility( options.runtime === "workers" && config.database === "mongodb" ) { - consola.fatal( + 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.", ); - process.exit(1); } if ( @@ -112,10 +105,9 @@ export function validateWorkersCompatibility( options.runtime === "workers" && config.dbSetup === "docker" ) { - consola.fatal( + exitWithError( "Cloudflare Workers runtime (--runtime workers) is not compatible with Docker setup. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime.", ); - process.exit(1); } if ( @@ -123,10 +115,9 @@ export function validateWorkersCompatibility( config.database === "mongodb" && config.runtime === "workers" ) { - consola.fatal( + 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.", ); - process.exit(1); } if ( @@ -134,10 +125,9 @@ export function validateWorkersCompatibility( options.dbSetup === "docker" && config.runtime === "workers" ) { - consola.fatal( + exitWithError( "Docker setup (--db-setup docker) is not compatible with Cloudflare Workers runtime. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime.", ); - process.exit(1); } } @@ -209,10 +199,9 @@ export function validateApiFrontendCompatibility( const includesSvelte = frontends.includes("svelte"); const includesSolid = frontends.includes("solid"); if ((includesNuxt || includesSvelte || includesSolid) && api === "trpc") { - consola.fatal( + exitWithError( `tRPC API is not supported with '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : "solid"}' frontend. Please use --api orpc or --api none or remove '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : "solid"}' from --frontend.`, ); - process.exit(1); } } @@ -257,10 +246,9 @@ export function validateWebDeployRequiresWebFrontend( hasWebFrontendFlag: boolean, ) { if (webDeploy && webDeploy !== "none" && !hasWebFrontendFlag) { - consola.fatal( + exitWithError( "'--web-deploy' requires a web frontend. Please select a web frontend or set '--web-deploy none'.", ); - process.exit(1); } } @@ -275,8 +263,7 @@ export function validateAddonsAgainstFrontends( frontends, ); if (!isCompatible) { - consola.fatal(`Incompatible addon/frontend combination: ${reason}`); - process.exit(1); + exitWithError(`Incompatible addon/frontend combination: ${reason}`); } } } @@ -295,21 +282,18 @@ export function validateExamplesCompatibility( backend !== "none" && database === "none" ) { - consola.fatal( + exitWithError( "The 'todo' example requires a database if a backend (other than Convex) is present. Cannot use --examples todo when database is 'none' and a backend is selected.", ); - process.exit(1); } if (examplesArr.includes("ai") && backend === "elysia") { - consola.fatal( + exitWithError( "The 'ai' example is not compatible with the Elysia backend.", ); - process.exit(1); } if (examplesArr.includes("ai") && (frontend ?? []).includes("solid")) { - consola.fatal( + exitWithError( "The 'ai' example is not compatible with the Solid frontend.", ); - process.exit(1); } } diff --git a/apps/cli/src/utils/errors.ts b/apps/cli/src/utils/errors.ts new file mode 100644 index 0000000..f5601f0 --- /dev/null +++ b/apps/cli/src/utils/errors.ts @@ -0,0 +1,20 @@ +import { cancel } from "@clack/prompts"; +import { consola } from "consola"; +import pc from "picocolors"; + +export function exitWithError(message: string): never { + consola.error(pc.red(message)); + process.exit(1); +} + +export function exitCancelled(message = "Operation cancelled"): never { + cancel(pc.red(message)); + process.exit(0); +} + +export function handleError(error: unknown, fallbackMessage?: string): never { + const message = + error instanceof Error ? error.message : fallbackMessage || String(error); + consola.error(pc.red(message)); + process.exit(1); +} diff --git a/apps/cli/src/utils/project-directory.ts b/apps/cli/src/utils/project-directory.ts index e380ce7..4ae02a4 100644 --- a/apps/cli/src/utils/project-directory.ts +++ b/apps/cli/src/utils/project-directory.ts @@ -1,9 +1,9 @@ import path from "node:path"; -import { cancel, isCancel, log, select, spinner } from "@clack/prompts"; -import { consola } from "consola"; +import { isCancel, log, select, spinner } from "@clack/prompts"; import fs from "fs-extra"; import pc from "picocolors"; import { getProjectName } from "../prompts/project-name"; +import { exitCancelled, handleError } from "./errors"; export async function handleDirectoryConflict( currentPathInput: string, @@ -49,10 +49,7 @@ export async function handleDirectoryConflict( initialValue: "rename", }); - if (isCancel(action)) { - cancel(pc.red("Operation cancelled.")); - process.exit(0); - } + if (isCancel(action)) return exitCancelled("Operation cancelled."); switch (action) { case "overwrite": @@ -73,8 +70,7 @@ export async function handleDirectoryConflict( return await handleDirectoryConflict(newPathInput); } case "cancel": - cancel(pc.red("Operation cancelled.")); - process.exit(0); + return exitCancelled("Operation cancelled."); } } } @@ -102,8 +98,7 @@ export async function setupProjectDirectory( s.stop(`Directory "${finalResolvedPath}" cleared.`); } catch (error) { s.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`)); - consola.error(error); - process.exit(1); + handleError(error); } } else { await fs.ensureDir(finalResolvedPath); diff --git a/apps/cli/src/utils/template-processor.ts b/apps/cli/src/utils/template-processor.ts index 4fa7bfa..ff1a726 100644 --- a/apps/cli/src/utils/template-processor.ts +++ b/apps/cli/src/utils/template-processor.ts @@ -29,6 +29,7 @@ export async function processTemplate( } handlebars.registerHelper("eq", (a, b) => a === b); +handlebars.registerHelper("ne", (a, b) => a !== b); handlebars.registerHelper("and", (a, b) => a && b); handlebars.registerHelper("or", (a, b) => a || b); diff --git a/apps/cli/src/validation.ts b/apps/cli/src/validation.ts index 830d8c6..177b02f 100644 --- a/apps/cli/src/validation.ts +++ b/apps/cli/src/validation.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import { consola } from "consola"; import { type Addons, type API, @@ -27,6 +26,7 @@ import { validateWebDeployRequiresWebFrontend, validateWorkersCompatibility, } from "./utils/compatibility-rules"; +import { exitWithError } from "./utils/errors"; export function processAndValidateFlags( options: CLIInput, @@ -43,10 +43,9 @@ export function processAndValidateFlags( !(options.examples.length === 1 && options.examples[0] === "none") && options.backend !== "convex" ) { - consola.fatal( + exitWithError( "Cannot use '--examples' when '--api' is set to 'none'. Please remove the --examples flag or choose an API type.", ); - process.exit(1); } } } @@ -62,10 +61,9 @@ export function processAndValidateFlags( config.backend !== "none" ) { if (providedFlags.has("runtime") && options.runtime === "none") { - consola.fatal( + exitWithError( `'--runtime none' is only supported with '--backend convex' or '--backend none'. Please choose 'bun', 'node', or remove the --runtime flag.`, ); - process.exit(1); } } @@ -101,12 +99,11 @@ export function processAndValidateFlags( if (projectName) { const result = ProjectNameSchema.safeParse(path.basename(projectName)); if (!result.success) { - consola.fatal( + exitWithError( `Invalid project name: ${ result.error.issues[0]?.message || "Invalid project name" }`, ); - process.exit(1); } config.projectName = projectName; } else if (options.projectDirectory) { @@ -115,12 +112,11 @@ export function processAndValidateFlags( ); const result = ProjectNameSchema.safeParse(baseName); if (!result.success) { - consola.fatal( + exitWithError( `Invalid project name: ${ result.error.issues[0]?.message || "Invalid project name" }`, ); - process.exit(1); } config.projectName = baseName; } @@ -128,8 +124,7 @@ export function processAndValidateFlags( if (options.frontend && options.frontend.length > 0) { if (options.frontend.includes("none")) { if (options.frontend.length > 1) { - consola.fatal(`Cannot combine 'none' with other frontend options.`); - process.exit(1); + exitWithError(`Cannot combine 'none' with other frontend options.`); } config.frontend = []; } else { @@ -153,8 +148,7 @@ export function processAndValidateFlags( if (options.addons && options.addons.length > 0) { if (options.addons.includes("none")) { if (options.addons.length > 1) { - consola.fatal(`Cannot combine 'none' with other addons.`); - process.exit(1); + exitWithError(`Cannot combine 'none' with other addons.`); } config.addons = []; } else { @@ -166,8 +160,7 @@ export function processAndValidateFlags( if (options.examples && options.examples.length > 0) { if (options.examples.includes("none")) { if (options.examples.length > 1) { - consola.fatal("Cannot combine 'none' with other examples."); - process.exit(1); + exitWithError("Cannot combine 'none' with other examples."); } config.examples = []; } else { @@ -187,12 +180,11 @@ export function processAndValidateFlags( options, ); if (incompatibleFlags.length > 0) { - consola.fatal( + exitWithError( `The following flags are incompatible with '--backend ${config.backend}': ${incompatibleFlags.join( ", ", )}. Please remove them.`, ); - process.exit(1); } if ( @@ -204,12 +196,11 @@ export function processAndValidateFlags( (f) => f === "solid", ); if (incompatibleFrontends.length > 0) { - consola.fatal( + exitWithError( `The following frontends are not compatible with '--backend convex': ${incompatibleFrontends.join( ", ", )}. Please choose a different frontend or backend.`, ); - process.exit(1); } } @@ -222,10 +213,9 @@ export function processAndValidateFlags( config.orm === "mongoose" && config.database !== "mongodb" ) { - consola.fatal( + exitWithError( "Mongoose ORM requires MongoDB database. Please use '--database mongodb' or choose a different ORM.", ); - process.exit(1); } if ( @@ -236,10 +226,9 @@ export function processAndValidateFlags( config.orm !== "mongoose" && config.orm !== "prisma" ) { - consola.fatal( + exitWithError( "MongoDB database requires Mongoose or Prisma ORM. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", ); - process.exit(1); } if ( @@ -248,10 +237,9 @@ export function processAndValidateFlags( config.orm === "drizzle" && config.database === "mongodb" ) { - consola.fatal( + exitWithError( "Drizzle ORM does not support MongoDB. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", ); - process.exit(1); } if ( @@ -261,10 +249,9 @@ export function processAndValidateFlags( config.database !== "none" && config.orm === "none" ) { - consola.fatal( + exitWithError( "Database selection requires an ORM. Please choose '--orm drizzle', '--orm prisma', or '--orm mongoose'.", ); - process.exit(1); } if ( @@ -274,10 +261,9 @@ export function processAndValidateFlags( config.orm !== "none" && config.database === "none" ) { - consola.fatal( + exitWithError( "ORM selection requires a database. Please choose a database or set '--orm none'.", ); - process.exit(1); } if ( @@ -286,10 +272,9 @@ export function processAndValidateFlags( config.auth && config.database === "none" ) { - consola.fatal( + exitWithError( "Authentication requires a database. Please choose a database or set '--no-auth'.", ); - process.exit(1); } if ( @@ -299,10 +284,9 @@ export function processAndValidateFlags( config.dbSetup !== "none" && config.database === "none" ) { - consola.fatal( + exitWithError( "Database setup requires a database. Please choose a database or set '--db-setup none'.", ); - process.exit(1); } if ( @@ -311,10 +295,9 @@ export function processAndValidateFlags( config.dbSetup === "turso" && config.database !== "sqlite" ) { - consola.fatal( + exitWithError( "Turso setup requires SQLite database. Please use '--database sqlite' or choose a different setup.", ); - process.exit(1); } if ( @@ -323,10 +306,9 @@ export function processAndValidateFlags( config.dbSetup === "neon" && config.database !== "postgres" ) { - consola.fatal( + exitWithError( "Neon setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", ); - process.exit(1); } if ( @@ -335,10 +317,9 @@ export function processAndValidateFlags( config.dbSetup === "prisma-postgres" && config.database !== "postgres" ) { - consola.fatal( + exitWithError( "Prisma PostgreSQL setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", ); - process.exit(1); } if ( @@ -347,10 +328,9 @@ export function processAndValidateFlags( config.dbSetup === "mongodb-atlas" && config.database !== "mongodb" ) { - consola.fatal( + exitWithError( "MongoDB Atlas setup requires MongoDB database. Please use '--database mongodb' or choose a different setup.", ); - process.exit(1); } if ( @@ -359,10 +339,9 @@ export function processAndValidateFlags( config.dbSetup === "supabase" && config.database !== "postgres" ) { - consola.fatal( + exitWithError( "Supabase setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", ); - process.exit(1); } if (config.dbSetup === "d1") { @@ -371,10 +350,9 @@ export function processAndValidateFlags( (providedFlags.has("dbSetup") && !config.database) ) { if (config.database !== "sqlite") { - consola.fatal( + exitWithError( "Cloudflare D1 setup requires SQLite database. Please use '--database sqlite' or choose a different setup.", ); - process.exit(1); } } @@ -383,10 +361,9 @@ export function processAndValidateFlags( (providedFlags.has("dbSetup") && !config.runtime) ) { if (config.runtime !== "workers") { - consola.fatal( + exitWithError( "Cloudflare D1 setup requires the Cloudflare Workers runtime. Please use '--runtime workers' or choose a different setup.", ); - process.exit(1); } } } @@ -397,10 +374,9 @@ export function processAndValidateFlags( config.dbSetup === "docker" && config.database === "sqlite" ) { - consola.fatal( + exitWithError( "Docker setup is not compatible with SQLite database. SQLite is file-based and doesn't require Docker. Please use '--database postgres', '--database mysql', '--database mongodb', or choose a different setup.", ); - process.exit(1); } if ( @@ -409,10 +385,9 @@ export function processAndValidateFlags( config.dbSetup === "docker" && config.runtime === "workers" ) { - consola.fatal( + exitWithError( "Docker setup is not compatible with Cloudflare Workers runtime. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime.", ); - process.exit(1); } validateWorkersCompatibility(providedFlags, options, config); diff --git a/apps/cli/templates/addons/vibe-rules/.bts/rules.md.hbs b/apps/cli/templates/addons/vibe-rules/.bts/rules.md.hbs new file mode 100644 index 0000000..58d2443 --- /dev/null +++ b/apps/cli/templates/addons/vibe-rules/.bts/rules.md.hbs @@ -0,0 +1,132 @@ +# Better-T-Stack Project Rules + +This is a {{projectName}} project created with Better-T-Stack CLI. + +## Project Structure + +This is a monorepo with the following structure: + +{{#if (or (includes frontend "tanstack-router") (includes frontend "react-router") (includes frontend "tanstack-start") +(includes frontend "next") (includes frontend "nuxt") (includes frontend "svelte") (includes frontend "solid"))}} +- **`apps/web/`** - Frontend application{{#if (includes frontend "tanstack-router")}} (React with TanStack Router){{else +if (includes frontend "react-router")}} (React with React Router){{else if (includes frontend "next")}} (Next.js){{else +if (includes frontend "nuxt")}} (Nuxt.js){{else if (includes frontend "svelte")}} (SvelteKit){{else if (includes +frontend "solid")}} (SolidStart){{/if}} +{{/if}} + +{{#if (ne backend "convex")}} +{{#if (ne backend "none")}} +- **`apps/server/`** - Backend server{{#if (eq backend "hono")}} (Hono){{else if (eq backend "express")}} +(Express){{else if (eq backend "fastify")}} (Fastify){{else if (eq backend "elysia")}} (Elysia){{else if (eq backend +"next")}} (Next.js API){{/if}} +{{/if}} +{{else}} +- **`packages/backend/`** - Convex backend functions +{{/if}} + +{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} +- **`apps/native/`** - React Native mobile app{{#if (includes frontend "native-nativewind")}} (with NativeWind){{else if +(includes frontend "native-unistyles")}} (with Unistyles){{/if}} +{{/if}} + +## Available Scripts + +- `{{packageManager}} run dev` - Start all apps in development mode +{{#if (or (includes frontend "tanstack-router") (includes frontend "react-router") (includes frontend "tanstack-start") +(includes frontend "next") (includes frontend "nuxt") (includes frontend "svelte") (includes frontend "solid"))}} +- `{{packageManager}} run dev:web` - Start only the web app +{{/if}} +{{#if (ne backend "none")}} +{{#if (ne backend "convex")}} +- `{{packageManager}} run dev:server` - Start only the server +{{/if}} +{{/if}} +{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} +- `{{packageManager}} run dev:native` - Start only the native app +{{/if}} + +{{#if (and (ne database "none") (ne orm "none") (ne backend "convex"))}} +## Database Commands + +All database operations should be run from the server workspace: + +- `{{packageManager}} run db:push` - Push schema changes to database +- `{{packageManager}} run db:studio` - Open database studio +- `{{packageManager}} run db:generate` - Generate {{#if (eq orm "drizzle")}}Drizzle{{else if (eq orm +"prisma")}}Prisma{{else}}{{orm}}{{/if}} files +- `{{packageManager}} run db:migrate` - Run database migrations + +{{#if (eq orm "drizzle")}} +Database schema files are located in `apps/server/src/db/schema/` +{{else if (eq orm "prisma")}} +Database schema is located in `apps/server/prisma/schema.prisma` +{{else if (eq orm "mongoose")}} +Database models are located in `apps/server/src/db/models/` +{{/if}} +{{/if}} + +{{#if (ne api "none")}} +## API Structure + +{{#if (eq api "trpc")}} +- tRPC routers are in `apps/server/src/routers/` +- Client-side tRPC utils are in `apps/web/src/utils/trpc.ts` +{{else if (eq api "orpc")}} +- oRPC endpoints are in `apps/server/src/api/` +- Client-side API utils are in `apps/web/src/utils/api.ts` +{{/if}} +{{/if}} + +{{#if auth}} +## Authentication + +Authentication is enabled in this project: +{{#if (ne backend "convex")}} +- Server auth logic is in `apps/server/src/lib/auth.ts` +{{#if (or (includes frontend "tanstack-router") (includes frontend "react-router") (includes frontend "tanstack-start") +(includes frontend "next") (includes frontend "nuxt") (includes frontend "svelte") (includes frontend "solid"))}} +- Web app auth client is in `apps/web/src/lib/auth-client.ts` +{{/if}} +{{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} +- Native app auth client is in `apps/native/src/lib/auth-client.ts` +{{/if}} +{{else}} +{{/if}} +{{/if}} + +## Adding More Features + +You can add additional addons or deployment options to your project using: + +```bash +{{#if (eq packageManager "bun")}}bunx{{else if (eq packageManager "pnpm")}}pnpx{{else}}npx{{/if}} create-better-t-stack +add +``` + +Available addons you can add: +- **Documentation**: Starlight, Fumadocs +- **Linting**: Biome, Oxlint, Ultracite +- **Other**: vibe-rules, Turborepo, PWA, Tauri, Husky + +You can also add web deployment configurations like Cloudflare Workers support. + +## Project Configuration + +This project includes a `bts.jsonc` configuration file that stores your Better-T-Stack settings: + +- Contains your selected stack configuration (database, ORM, backend, frontend, etc.) +- Used by the CLI to understand your project structure +- Safe to delete if not needed +- Updated automatically when using the `add` command + +## Key Points + +- This is a {{#if (includes addons "turborepo")}}Turborepo {{/if}}monorepo using {{packageManager}} workspaces +- Each app has its own `package.json` and dependencies +- Run commands from the root to execute across all workspaces +- Run workspace-specific commands with `{{packageManager}} run command-name` +{{#if (includes addons "turborepo")}} +- Turborepo handles build caching and parallel execution +{{/if}} +- Use `{{#if (eq packageManager "bun")}}bunx{{else if (eq packageManager "pnpm")}}pnpx{{else}}npx{{/if}} +create-better-t-stack add` to add more features later \ No newline at end of file diff --git a/apps/web/src/app/(home)/showcase/page.tsx b/apps/web/src/app/(home)/showcase/page.tsx index f24184a..4bd8898 100644 --- a/apps/web/src/app/(home)/showcase/page.tsx +++ b/apps/web/src/app/(home)/showcase/page.tsx @@ -48,7 +48,7 @@ const showcaseProjects = [ "https://screenshothis.com?utm_source=better-t-stack&utm_medium=showcase&utm_campaign=referer", tags: [ "oRPC", - "TanStack Start (vite)", + "TanStack Start", "Hono", "pnpm", "PostgreSQL", diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index e900ed7..e7910ca 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -57,7 +57,7 @@ export const TECH_OPTIONS: Record< }, { id: "tanstack-start", - name: "TanStack Start (vite)", + name: "TanStack Start", description: "Full-stack React and Solid framework powered by TanStack Router", icon: `${ICON_BASE_URL}/tanstack.svg`, diff --git a/biome.json b/biome.json index 672611a..6d2e4c9 100644 --- a/biome.json +++ b/biome.json @@ -19,7 +19,8 @@ "!**/package.json", "!**/analytics-minimal.json", "!**/schema.json", - "!**/_generated/**" + "!**/_generated/**", + "!**/.smoke/**" ] }, "formatter": {