diff --git a/.changeset/all-doodles-hear.md b/.changeset/all-doodles-hear.md new file mode 100644 index 0000000..5432998 --- /dev/null +++ b/.changeset/all-doodles-hear.md @@ -0,0 +1,5 @@ +--- +"create-better-t-stack": minor +--- + +Add 'add' command for adding addons to existing projects diff --git a/apps/cli/package.json b/apps/cli/package.json index e2d25e4..c7b70b2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -60,15 +60,16 @@ "globby": "^14.1.0", "gradient-string": "^3.0.0", "handlebars": "^4.7.8", + "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "posthog-node": "^4.18.0", "trpc-cli": "^0.8.0", - "zod": "^3.25.57" + "zod": "^3.25.67" }, "devDependencies": { "@types/fs-extra": "^11.0.4", - "@types/node": "^24.0.0", - "tsdown": "^0.12.7", + "@types/node": "^24.0.3", + "tsdown": "^0.12.8", "typescript": "^5.8.3" } } diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 1190429..451dbeb 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -110,3 +110,13 @@ export const dependencyVersionMap = { } as const; export type AvailableDependencies = keyof typeof dependencyVersionMap; + +export const ADDON_COMPATIBILITY = { + pwa: ["tanstack-router", "react-router", "solid"], + tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"], + biome: [], + husky: [], + turborepo: [], + starlight: [], + none: [], +} as const; diff --git a/apps/cli/src/helpers/project-generation/add-addons.ts b/apps/cli/src/helpers/project-generation/add-addons.ts new file mode 100644 index 0000000..623be49 --- /dev/null +++ b/apps/cli/src/helpers/project-generation/add-addons.ts @@ -0,0 +1,104 @@ +import path from "node:path"; +import { cancel, 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 { setupAddons } from "../setup/addons-setup"; +import { + detectProjectConfig, + isBetterTStackProject, +} from "./detect-project-config"; +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[] }, +): Promise { + try { + const projectDir = input.projectDir || process.cwd(); + + const isBetterTStack = await isBetterTStackProject(projectDir); + if (!isBetterTStack) { + exitWithError( + "This doesn't appear to be a Better-T Stack project. Please run this command from the root of a Better-T Stack project.", + ); + } + + const detectedConfig = await detectProjectConfig(projectDir); + if (!detectedConfig) { + exitWithError( + "Could not detect the project configuration. Please ensure this is a valid Better-T Stack project.", + ); + } + + const config: ProjectConfig = { + projectName: detectedConfig.projectName || path.basename(projectDir), + projectDir, + relativePath: ".", + database: detectedConfig.database || "none", + orm: detectedConfig.orm || "none", + backend: detectedConfig.backend || "none", + runtime: detectedConfig.runtime || "none", + frontend: detectedConfig.frontend || [], + addons: input.addons, + examples: detectedConfig.examples || [], + auth: detectedConfig.auth || false, + git: false, + packageManager: + input.packageManager || detectedConfig.packageManager || "npm", + install: input.install || false, + dbSetup: detectedConfig.dbSetup || "none", + api: detectedConfig.api || "none", + }; + + for (const addon of input.addons) { + const { isCompatible, reason } = validateAddonCompatibility( + addon, + config.frontend, + ); + if (!isCompatible) { + exitWithError( + reason || + `${addon} addon is not compatible with current frontend configuration`, + ); + } + } + + log.info( + pc.green( + `Adding ${input.addons.join(", ")} to ${config.frontend.join("/")}`, + ), + ); + + await setupAddonsTemplate(projectDir, config); + await setupAddons(config, true); + + const currentAddons = detectedConfig.addons || []; + const mergedAddons = [...new Set([...currentAddons, ...input.addons])]; + await updateBtsConfig(projectDir, { addons: mergedAddons }); + + if (config.install) { + await installDependencies({ + projectDir, + packageManager: config.packageManager, + }); + } else { + log.info( + pc.yellow( + `Run ${pc.bold( + `${config.packageManager} install`, + )} to install dependencies`, + ), + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + exitWithError(`Error adding addons: ${message}`); + } +} diff --git a/apps/cli/src/helpers/project-generation/create-project.ts b/apps/cli/src/helpers/project-generation/create-project.ts index 4f09a90..34aa53f 100644 --- a/apps/cli/src/helpers/project-generation/create-project.ts +++ b/apps/cli/src/helpers/project-generation/create-project.ts @@ -2,6 +2,7 @@ import { cancel, 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 { setupAddons } from "../setup/addons-setup"; import { setupApi } from "../setup/api-setup"; import { setupAuth } from "../setup/auth-setup"; @@ -71,6 +72,9 @@ export async function createProject(options: ProjectConfig) { await setupEnvironmentVariables(options); await updatePackageConfigurations(projectDir, options); await createReadme(projectDir, options); + + await writeBtsConfig(options); + await initializeGit(projectDir, options.git); log.success("Project template successfully scaffolded!"); diff --git a/apps/cli/src/helpers/project-generation/detect-project-config.ts b/apps/cli/src/helpers/project-generation/detect-project-config.ts new file mode 100644 index 0000000..78d9e63 --- /dev/null +++ b/apps/cli/src/helpers/project-generation/detect-project-config.ts @@ -0,0 +1,43 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { ProjectConfig } from "../../types"; +import { readBtsConfig } from "../../utils/bts-config"; + +export async function detectProjectConfig( + projectDir: string, +): Promise | null> { + try { + const btsConfig = await readBtsConfig(projectDir); + if (btsConfig) { + return { + projectDir, + projectName: path.basename(projectDir), + database: btsConfig.database, + orm: btsConfig.orm, + backend: btsConfig.backend, + runtime: btsConfig.runtime, + frontend: btsConfig.frontend, + addons: btsConfig.addons, + examples: btsConfig.examples, + auth: btsConfig.auth, + packageManager: btsConfig.packageManager, + dbSetup: btsConfig.dbSetup, + api: btsConfig.api, + }; + } + + return null; + } catch (_error) { + return null; + } +} + +export async function isBetterTStackProject( + projectDir: string, +): Promise { + try { + return await fs.pathExists(path.join(projectDir, "bts.jsonc")); + } catch (_error) { + return false; + } +} diff --git a/apps/cli/src/helpers/setup/addons-setup.ts b/apps/cli/src/helpers/setup/addons-setup.ts index df64166..cbbaa40 100644 --- a/apps/cli/src/helpers/setup/addons-setup.ts +++ b/apps/cli/src/helpers/setup/addons-setup.ts @@ -1,11 +1,13 @@ import path from "node:path"; +import { log } from "@clack/prompts"; import fs from "fs-extra"; +import pc from "picocolors"; import type { Frontend, ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; import { setupStarlight } from "./starlight-setup"; import { setupTauri } from "./tauri-setup"; -export async function setupAddons(config: ProjectConfig) { +export async function setupAddons(config: ProjectConfig, isAddCommand = false) { const { addons, frontend, projectDir } = config; const hasReactWebFrontend = frontend.includes("react-router") || @@ -21,6 +23,20 @@ export async function setupAddons(config: ProjectConfig) { devDependencies: ["turbo"], projectDir, }); + + if (isAddCommand) { + log.info(`${pc.yellow("Update your package.json scripts:")} + +${pc.dim("Replace:")} ${pc.yellow('"pnpm -r dev"')} ${pc.dim("→")} ${pc.green( + '"turbo dev"', + )} +${pc.dim("Replace:")} ${pc.yellow('"pnpm --filter web dev"')} ${pc.dim( + "→", + )} ${pc.green('"turbo -F web dev"')} + +${pc.cyan("Docs:")} ${pc.underline("https://turborepo.com/docs")} + `); + } } if (addons.includes("pwa") && (hasReactWebFrontend || hasSolidFrontend)) { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index acd39a4..e560eb5 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -13,10 +13,13 @@ import fs from "fs-extra"; import pc from "picocolors"; import { createCli, trpcServer, zod as z } from "trpc-cli"; import { DEFAULT_CONFIG } from "./constants"; +import { addAddonsToProject } from "./helpers/project-generation/add-addons"; import { createProject } from "./helpers/project-generation/create-project"; +import { detectProjectConfig } from "./helpers/project-generation/detect-project-config"; +import { getAddonsToAdd } from "./prompts/addons"; import { gatherConfig } from "./prompts/config-prompts"; import { getProjectName } from "./prompts/project-name"; -import type { CreateInput, ProjectConfig } from "./types"; +import type { AddInput, CreateInput, ProjectConfig } from "./types"; import { AddonsSchema, APISchema, @@ -259,6 +262,53 @@ async function createProjectHandler( } } +async function addAddonsHandler(input: AddInput): Promise { + try { + if (!input.addons || input.addons.length === 0) { + const projectDir = input.projectDir || process.cwd(); + 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.", + ), + ); + process.exit(1); + } + + const addonsPrompt = await getAddonsToAdd( + detectedConfig.frontend || [], + detectedConfig.addons || [], + ); + + if (addonsPrompt.length === 0) { + outro( + pc.yellow( + "No addons to add or all compatible addons are already present.", + ), + ); + return; + } + + input.addons = addonsPrompt; + } + + if (!input.addons || input.addons.length === 0) { + outro(pc.yellow("No addons specified to add.")); + return; + } + + await addAddonsToProject({ + ...input, + addons: input.addons, + }); + } catch (error) { + console.error(error); + process.exit(1); + } +} + const router = t.router({ init: t.procedure .meta({ @@ -301,6 +351,31 @@ const router = t.router({ }; await createProjectHandler(combinedInput); }), + add: t.procedure + .meta({ + description: "Add addons to an existing Better-T Stack project", + }) + .input( + z.tuple([ + z + .object({ + addons: z.array(AddonsSchema).optional().default([]), + projectDir: z.string().optional(), + install: z + .boolean() + .optional() + .default(false) + .describe("Install dependencies after adding addons"), + packageManager: PackageManagerSchema.optional(), + }) + .optional() + .default({}), + ]), + ) + .mutation(async ({ input }) => { + const [options] = input; + await addAddonsHandler(options); + }), sponsors: t.procedure .meta({ description: "Show Better-T Stack sponsors" }) .mutation(async () => { diff --git a/apps/cli/src/prompts/addons.ts b/apps/cli/src/prompts/addons.ts index 7e5c31b..006bb83 100644 --- a/apps/cli/src/prompts/addons.ts +++ b/apps/cli/src/prompts/addons.ts @@ -1,7 +1,11 @@ import { cancel, isCancel, multiselect } from "@clack/prompts"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; -import type { Addons, Frontend } from "../types"; +import { type Addons, AddonsSchema, type Frontend } from "../types"; +import { + getCompatibleAddons, + validateAddonCompatibility, +} from "../utils/addon-compatibility"; type AddonOption = { value: Addons; @@ -9,63 +13,73 @@ type AddonOption = { hint: string; }; +function getAddonDisplay( + addon: Addons, + isRecommended = false, +): { label: string; hint: string } { + let label: string; + let hint: string; + + if (addon === "turborepo") { + label = isRecommended ? "Turborepo (Recommended)" : "Turborepo"; + hint = "High-performance build system for JavaScript and TypeScript"; + } else if (addon === "pwa") { + label = "PWA (Progressive Web App)"; + hint = "Make your app installable and work offline"; + } else if (addon === "tauri") { + label = isRecommended ? "Tauri Desktop App" : "Tauri"; + hint = "Build native desktop apps from your web frontend"; + } else if (addon === "biome") { + label = "Biome"; + hint = isRecommended + ? "Add Biome for linting and formatting" + : "Fast formatter and linter for JavaScript, TypeScript, JSX"; + } else if (addon === "husky") { + label = "Husky"; + hint = isRecommended + ? "Add Git hooks with Husky, lint-staged (requires Biome)" + : "Git hooks made easy"; + } else if (addon === "starlight") { + label = "Starlight"; + hint = isRecommended + ? "Add Astro Starlight documentation site" + : "Documentation site with Astro"; + } else { + label = addon; + hint = `Add ${addon}`; + } + + return { label, hint }; +} + export async function getAddonsChoice( addons?: Addons[], frontends?: Frontend[], ): Promise { if (addons !== undefined) return addons; - const hasCompatiblePwaFrontend = - frontends?.includes("react-router") || - frontends?.includes("tanstack-router") || - frontends?.includes("solid") || - frontends?.includes("next"); + const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); - const hasCompatibleTauriFrontend = - frontends?.includes("react-router") || - frontends?.includes("tanstack-router") || - frontends?.includes("nuxt") || - frontends?.includes("svelte") || - frontends?.includes("solid") || - frontends?.includes("next"); + const allPossibleOptions: AddonOption[] = []; - const allPossibleOptions: AddonOption[] = [ - { - value: "turborepo", - label: "Turborepo (Recommended)", - hint: "Optimize builds for monorepos", - }, - { - value: "starlight", - label: "Starlight", - hint: "Add Astro Starlight documentation site", - }, - { - value: "biome", - label: "Biome", - hint: "Add Biome for linting and formatting", - }, - { - value: "husky", - label: "Husky", - hint: "Add Git hooks with Husky, lint-staged (requires Biome)", - }, - { - value: "pwa", - label: "PWA (Progressive Web App)", - hint: "Make your app installable and work offline", - }, - { - value: "tauri", - label: "Tauri Desktop App", - hint: "Build native desktop apps from your web frontend", - }, - ]; + for (const addon of allAddons) { + const { isCompatible } = validateAddonCompatibility(addon, frontends || []); - const options = allPossibleOptions.filter((option) => { - if (option.value === "pwa") return hasCompatiblePwaFrontend; - if (option.value === "tauri") return hasCompatibleTauriFrontend; - return true; + if (isCompatible) { + const { label, hint } = getAddonDisplay(addon, true); + + allPossibleOptions.push({ + value: addon, + label, + hint, + }); + } + } + + const options = allPossibleOptions.sort((a, b) => { + if (a.value === "turborepo") return -1; + if (b.value === "turborepo") return 1; + return 0; }); const initialValues = DEFAULT_CONFIG.addons.filter((addonValue) => @@ -90,3 +104,45 @@ export async function getAddonsChoice( return response; } + +export async function getAddonsToAdd( + frontend: Frontend[], + existingAddons: Addons[] = [], +): Promise { + const options: AddonOption[] = []; + + const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); + + const compatibleAddons = getCompatibleAddons( + allAddons, + frontend, + existingAddons, + ); + + for (const addon of compatibleAddons) { + const { label, hint } = getAddonDisplay(addon, false); + + options.push({ + value: addon, + label, + hint, + }); + } + + if (options.length === 0) { + return []; + } + + const response = await multiselect({ + message: "Select addons", + options: options, + required: true, + }); + + if (isCancel(response)) { + cancel(pc.red("Operation cancelled")); + process.exit(0); + } + + return response; +} diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index db8b4fd..6a561fa 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -110,6 +110,13 @@ export type CreateInput = { api?: API; }; +export type AddInput = { + addons?: Addons[]; + projectDir?: string; + install?: boolean; + packageManager?: PackageManager; +}; + export type CLIInput = CreateInput & { projectDirectory?: string; }; @@ -133,4 +140,20 @@ export interface ProjectConfig { api: API; } +export interface BetterTStackConfig { + version: string; + createdAt: string; + database: Database; + orm: ORM; + backend: Backend; + runtime: Runtime; + frontend: Frontend[]; + addons: Addons[]; + examples: Examples[]; + auth: boolean; + packageManager: PackageManager; + dbSetup: DatabaseSetup; + api: API; +} + export type AvailablePackageManagers = "npm" | "pnpm" | "bun"; diff --git a/apps/cli/src/utils/addon-compatibility.ts b/apps/cli/src/utils/addon-compatibility.ts new file mode 100644 index 0000000..5279d51 --- /dev/null +++ b/apps/cli/src/utils/addon-compatibility.ts @@ -0,0 +1,42 @@ +import { ADDON_COMPATIBILITY } from "../constants"; +import type { Addons, Frontend } from "../types"; + +export function validateAddonCompatibility( + addon: Addons, + frontend: Frontend[], +): { isCompatible: boolean; reason?: string } { + const compatibleFrontends = ADDON_COMPATIBILITY[addon]; + + if (compatibleFrontends.length === 0) { + return { isCompatible: true }; + } + + const hasCompatibleFrontend = frontend.some((f) => + (compatibleFrontends as readonly string[]).includes(f), + ); + + if (!hasCompatibleFrontend) { + const frontendList = compatibleFrontends.join(", "); + return { + isCompatible: false, + reason: `${addon} addon requires one of these frontends: ${frontendList}`, + }; + } + + return { isCompatible: true }; +} + +export function getCompatibleAddons( + allAddons: Addons[], + frontend: Frontend[], + existingAddons: Addons[] = [], +): Addons[] { + return allAddons.filter((addon) => { + if (existingAddons.includes(addon)) return false; + + if (addon === "none") return false; + + const { isCompatible } = validateAddonCompatibility(addon, frontend); + return isCompatible; + }); +} diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts new file mode 100644 index 0000000..e312e6d --- /dev/null +++ b/apps/cli/src/utils/bts-config.ts @@ -0,0 +1,120 @@ +import path from "node:path"; +import fs from "fs-extra"; +import * as JSONC from "jsonc-parser"; +import type { BetterTStackConfig, ProjectConfig } from "../types"; +import { getLatestCLIVersion } from "./get-latest-cli-version"; + +const BTS_CONFIG_FILE = "bts.jsonc"; + +export async function writeBtsConfig( + projectConfig: ProjectConfig, +): Promise { + const btsConfig: BetterTStackConfig = { + version: getLatestCLIVersion(), + createdAt: new Date().toISOString(), + database: projectConfig.database, + orm: projectConfig.orm, + backend: projectConfig.backend, + runtime: projectConfig.runtime, + frontend: projectConfig.frontend, + addons: projectConfig.addons, + examples: projectConfig.examples, + auth: projectConfig.auth, + packageManager: projectConfig.packageManager, + dbSetup: projectConfig.dbSetup, + api: projectConfig.api, + }; + + const baseContent = { + $schema: "https://better-t-stack.dev/schema.json", + version: btsConfig.version, + createdAt: btsConfig.createdAt, + database: btsConfig.database, + orm: btsConfig.orm, + backend: btsConfig.backend, + runtime: btsConfig.runtime, + frontend: btsConfig.frontend, + addons: btsConfig.addons, + examples: btsConfig.examples, + auth: btsConfig.auth, + packageManager: btsConfig.packageManager, + dbSetup: btsConfig.dbSetup, + api: btsConfig.api, + }; + + let configContent = JSON.stringify(baseContent); + + const formatResult = JSONC.format(configContent, undefined, { + tabSize: 2, + insertSpaces: true, + eol: "\n", + }); + + configContent = JSONC.applyEdits(configContent, formatResult); + + const finalContent = `// Better-T-Stack configuration file +// safe to delete + +${configContent}`; + const configPath = path.join(projectConfig.projectDir, BTS_CONFIG_FILE); + await fs.writeFile(configPath, finalContent, "utf-8"); +} + +export async function readBtsConfig( + projectDir: string, +): Promise { + try { + const configPath = path.join(projectDir, BTS_CONFIG_FILE); + + if (!(await fs.pathExists(configPath))) { + return null; + } + + const configContent = await fs.readFile(configPath, "utf-8"); + + const errors: JSONC.ParseError[] = []; + const config = JSONC.parse(configContent, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as BetterTStackConfig; + + if (errors.length > 0) { + console.warn("Warning: Found errors parsing bts.jsonc:", errors); + return null; + } + + return config; + } catch (_error) { + return null; + } +} + +export async function updateBtsConfig( + projectDir: string, + updates: Partial>, +): Promise { + try { + const configPath = path.join(projectDir, BTS_CONFIG_FILE); + + if (!(await fs.pathExists(configPath))) { + return; + } + + const configContent = await fs.readFile(configPath, "utf-8"); + + let modifiedContent = configContent; + + for (const [key, value] of Object.entries(updates)) { + const editResult = JSONC.modify(modifiedContent, [key], value, { + formattingOptions: { + tabSize: 2, + insertSpaces: true, + eol: "\n", + }, + }); + modifiedContent = JSONC.applyEdits(modifiedContent, editResult); + } + + await fs.writeFile(configPath, modifiedContent, "utf-8"); + } catch (_error) {} +} diff --git a/apps/cli/templates/base/package.json b/apps/cli/templates/base/package.json.hbs similarity index 100% rename from apps/cli/templates/base/package.json rename to apps/cli/templates/base/package.json.hbs diff --git a/apps/cli/templates/frontend/nuxt/package.json b/apps/cli/templates/frontend/nuxt/package.json.hbs similarity index 100% rename from apps/cli/templates/frontend/nuxt/package.json rename to apps/cli/templates/frontend/nuxt/package.json.hbs diff --git a/apps/cli/templates/frontend/react/react-router/package.json b/apps/cli/templates/frontend/react/react-router/package.json.hbs similarity index 100% rename from apps/cli/templates/frontend/react/react-router/package.json rename to apps/cli/templates/frontend/react/react-router/package.json.hbs diff --git a/apps/cli/templates/frontend/react/tanstack-router/package.json b/apps/cli/templates/frontend/react/tanstack-router/package.json.hbs similarity index 100% rename from apps/cli/templates/frontend/react/tanstack-router/package.json rename to apps/cli/templates/frontend/react/tanstack-router/package.json.hbs diff --git a/apps/cli/templates/frontend/react/tanstack-start/package.json b/apps/cli/templates/frontend/react/tanstack-start/package.json.hbs similarity index 100% rename from apps/cli/templates/frontend/react/tanstack-start/package.json rename to apps/cli/templates/frontend/react/tanstack-start/package.json.hbs diff --git a/apps/cli/templates/frontend/solid/package.json b/apps/cli/templates/frontend/solid/package.json.hbs similarity index 100% rename from apps/cli/templates/frontend/solid/package.json rename to apps/cli/templates/frontend/solid/package.json.hbs diff --git a/apps/cli/templates/frontend/svelte/package.json b/apps/cli/templates/frontend/svelte/package.json.hbs similarity index 100% rename from apps/cli/templates/frontend/svelte/package.json rename to apps/cli/templates/frontend/svelte/package.json.hbs diff --git a/apps/web/package.json b/apps/web/package.json index 0f87a15..55a5b25 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,8 @@ "start": "next start", "check": "biome check --write .", "postinstall": "fumadocs-mdx", - "generate-analytics": "bun scripts/generate-analytics.ts" + "generate-analytics": "bun scripts/generate-analytics.ts", + "generate-schema": "bun scripts/generate-schema.ts" }, "dependencies": { "babel-plugin-react-compiler": "^19.1.0-rc.2", @@ -19,7 +20,7 @@ "fumadocs-mdx": "11.6.7", "fumadocs-ui": "15.5.1", "lucide-react": "^0.513.0", - "motion": "^12.16.0", + "motion": "^12.18.1", "next": "15.3.3", "next-themes": "^0.4.6", "nuqs": "^2.4.3", @@ -28,21 +29,21 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-tweet": "^3.2.2", - "recharts": "^2.15.3", + "recharts": "^2.15.4", "sonner": "^2.0.5", - "tailwind-merge": "^3.3.0" + "tailwind-merge": "^3.3.1" }, "devDependencies": { - "@types/papaparse": "^5.3.16", - "@tailwindcss/postcss": "^4.1.8", + "@tailwindcss/postcss": "^4.1.10", "@types/mdx": "^2.0.13", "@types/node": "24.0.0", - "@types/react": "^19.1.7", + "@types/papaparse": "^5.3.16", + "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "eslint": "^9.28.0", + "eslint": "^9.29.0", "eslint-config-next": "15.3.3", - "postcss": "^8.5.4", - "tailwindcss": "^4.1.8", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.10", "tw-animate-css": "^1.3.4", "typescript": "^5.8.3" } diff --git a/apps/web/public/example-bts.jsonc b/apps/web/public/example-bts.jsonc new file mode 100644 index 0000000..b348481 --- /dev/null +++ b/apps/web/public/example-bts.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "https://better-t-stack.dev/schema.json", + // Better-T-Stack configuration file + // This file was automatically generated when the project was created + // It contains the stack configuration used to generate this project + + "version": "2.21.0", + "createdAt": "2024-01-15T10:30:00.000Z", + "database": "sqlite", + "orm": "drizzle", + "backend": "hono", + "runtime": "bun", + "frontend": ["tanstack-router"], + "addons": ["turborepo", "biome"], + "examples": ["todo"], + "auth": true, + "packageManager": "bun", + "dbSetup": "turso", + "api": "trpc" +} diff --git a/apps/web/public/schema.json b/apps/web/public/schema.json new file mode 100644 index 0000000..13e293f --- /dev/null +++ b/apps/web/public/schema.json @@ -0,0 +1,137 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://better-t-stack.dev/schema.json", + "title": "Better-T-Stack Configuration", + "description": "Configuration file for Better-T-Stack projects", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for validation" + }, + "version": { + "type": "string", + "description": "CLI version used to create this project", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Timestamp when the project was created" + }, + "database": { + "type": "string", + "enum": ["none", "sqlite", "postgres", "mysql", "mongodb"], + "description": "Database type" + }, + "orm": { + "type": "string", + "enum": ["drizzle", "prisma", "mongoose", "none"], + "description": "ORM type" + }, + "backend": { + "type": "string", + "enum": [ + "hono", + "express", + "fastify", + "next", + "elysia", + "convex", + "none" + ], + "description": "Backend framework" + }, + "runtime": { + "type": "string", + "enum": ["bun", "node", "workers", "none"], + "description": "Runtime environment (workers only available with hono backend and drizzle orm)" + }, + "frontend": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tanstack-router", + "react-router", + "tanstack-start", + "next", + "nuxt", + "native-nativewind", + "native-unistyles", + "svelte", + "solid", + "none" + ] + }, + "description": "Frontend framework" + }, + "addons": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "pwa", + "tauri", + "starlight", + "biome", + "husky", + "turborepo", + "none" + ] + }, + "description": "Additional addons" + }, + "examples": { + "type": "array", + "items": { + "type": "string", + "enum": ["todo", "ai", "none"] + }, + "description": "Example templates to include" + }, + "auth": { + "type": "boolean", + "description": "Whether authentication is enabled" + }, + "packageManager": { + "type": "string", + "enum": ["npm", "pnpm", "bun"], + "description": "Package manager" + }, + "dbSetup": { + "type": "string", + "enum": [ + "turso", + "neon", + "prisma-postgres", + "mongodb-atlas", + "supabase", + "d1", + "none" + ], + "description": "Database hosting setup" + }, + "api": { + "type": "string", + "enum": ["trpc", "orpc", "none"], + "description": "API type" + } + }, + "required": [ + "version", + "createdAt", + "database", + "orm", + "backend", + "runtime", + "frontend", + "addons", + "examples", + "auth", + "packageManager", + "dbSetup", + "api" + ], + "additionalProperties": false +} diff --git a/apps/web/scripts/generate-schema.ts b/apps/web/scripts/generate-schema.ts new file mode 100644 index 0000000..1f9ada0 --- /dev/null +++ b/apps/web/scripts/generate-schema.ts @@ -0,0 +1,137 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { + AddonsSchema, + APISchema, + BackendSchema, + DatabaseSchema, + DatabaseSetupSchema, + ExamplesSchema, + FrontendSchema, + ORMSchema, + PackageManagerSchema, + RuntimeSchema, +} from "../../cli/src/types"; + +const DATABASE_VALUES = DatabaseSchema.options; +const ORM_VALUES = ORMSchema.options; +const BACKEND_VALUES = BackendSchema.options; +const RUNTIME_VALUES = RuntimeSchema.options; +const FRONTEND_VALUES = FrontendSchema.options; +const ADDONS_VALUES = AddonsSchema.options; +const EXAMPLES_VALUES = ExamplesSchema.options; +const PACKAGE_MANAGER_VALUES = PackageManagerSchema.options; +const DATABASE_SETUP_VALUES = DatabaseSetupSchema.options; +const API_VALUES = APISchema.options; + +const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + $id: "https://better-t-stack.dev/schema.json", + title: "Better-T-Stack Configuration", + description: "Configuration file for Better-T-Stack projects", + type: "object" as const, + properties: { + $schema: { + type: "string" as const, + description: "JSON Schema reference for validation", + }, + version: { + type: "string" as const, + description: "CLI version used to create this project", + pattern: "^\\d+\\.\\d+\\.\\d+$", + }, + createdAt: { + type: "string" as const, + format: "date-time" as const, + description: "Timestamp when the project was created", + }, + database: { + type: "string" as const, + enum: DATABASE_VALUES, + description: DatabaseSchema.description, + }, + orm: { + type: "string" as const, + enum: ORM_VALUES, + description: ORMSchema.description, + }, + backend: { + type: "string" as const, + enum: BACKEND_VALUES, + description: BackendSchema.description, + }, + runtime: { + type: "string" as const, + enum: RUNTIME_VALUES, + description: RuntimeSchema.description, + }, + frontend: { + type: "array" as const, + items: { + type: "string" as const, + enum: FRONTEND_VALUES, + }, + description: FrontendSchema.description, + }, + addons: { + type: "array" as const, + items: { + type: "string" as const, + enum: ADDONS_VALUES, + }, + description: AddonsSchema.description, + }, + examples: { + type: "array" as const, + items: { + type: "string" as const, + enum: EXAMPLES_VALUES, + }, + description: ExamplesSchema.description, + }, + auth: { + type: "boolean" as const, + description: "Whether authentication is enabled", + }, + packageManager: { + type: "string" as const, + enum: PACKAGE_MANAGER_VALUES, + description: PackageManagerSchema.description, + }, + dbSetup: { + type: "string" as const, + enum: DATABASE_SETUP_VALUES, + description: DatabaseSetupSchema.description, + }, + api: { + type: "string" as const, + enum: API_VALUES, + description: APISchema.description, + }, + }, + required: [ + "version", + "createdAt", + "database", + "orm", + "backend", + "runtime", + "frontend", + "addons", + "examples", + "auth", + "packageManager", + "dbSetup", + "api", + ], + additionalProperties: false, +}; + +async function generateSchema() { + const schemaPath = path.join(process.cwd(), "public", "schema.json"); + await fs.ensureDir(path.dirname(schemaPath)); + await fs.writeFile(schemaPath, JSON.stringify(schema, null, 2), "utf-8"); + console.log("✅ Generated schema.json from shared types package"); +} + +generateSchema().catch(console.error); diff --git a/apps/web/src/app/(home)/_components/stack-builder.tsx b/apps/web/src/app/(home)/_components/stack-builder.tsx index a9d875e..fe31a6b 100644 --- a/apps/web/src/app/(home)/_components/stack-builder.tsx +++ b/apps/web/src/app/(home)/_components/stack-builder.tsx @@ -1465,7 +1465,6 @@ const StackBuilder = () => { )}