From 1f4399d403005c6f4c382dca7288324ca45e02f4 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 15:22:39 +0300 Subject: [PATCH 01/36] fix(cli): report accurate scaffold status and roll back partial output on failure Install, database-setup and native-build steps no longer swallow errors: they return structured SetupStepResult values that createProject collects, so the CLI reports 'created with N unfinished setup step(s)' plus a recovery hint instead of a false 'created successfully'. A fatal scaffolding error now removes the half-written directory it created (never a pre-existing/merge dir); set BTS_KEEP_FAILED_OUTPUT=1 to keep partial output for debugging. --- apps/cli/src/helpers/core/add-handler.ts | 16 ++- apps/cli/src/helpers/core/command-handlers.ts | 32 +++-- apps/cli/src/helpers/core/create-project.ts | 79 +++++++++--- apps/cli/src/helpers/core/db-setup.ts | 18 ++- .../src/helpers/core/install-dependencies.ts | 112 +++++++++++++----- apps/cli/test/install-dependencies.test.ts | 42 ++++++- 6 files changed, 234 insertions(+), 65 deletions(-) diff --git a/apps/cli/src/helpers/core/add-handler.ts b/apps/cli/src/helpers/core/add-handler.ts index 5fdbff7f5..359cefa54 100644 --- a/apps/cli/src/helpers/core/add-handler.ts +++ b/apps/cli/src/helpers/core/add-handler.ts @@ -15,8 +15,8 @@ import type { AddInput, Addons, ProjectConfig } from "../../types"; import { getDefaultConfig } from "../../constants"; import { getAddonsToAdd } from "../../prompts/addons"; import { readBtsConfig, updateBtsConfig } from "../../utils/bts-config"; -import { applyDependencyVersionChannel } from "../../utils/dependency-version-channel"; import { isSilent, runWithContextAsync } from "../../utils/context"; +import { applyDependencyVersionChannel } from "../../utils/dependency-version-channel"; import { CLIError, UserCancelledError } from "../../utils/errors"; import { renderTitle } from "../../utils/render-title"; import { setupAddons } from "../addons/addons-setup"; @@ -189,11 +189,13 @@ async function addHandlerInternal(input: AddInput): Promise { await updateBtsConfig(projectDir, configUpdates); + let addonInstallFailed = false; if (input.install) { - await installDependencies({ + const installResult = await installDependencies({ projectDir, packageManager: config.packageManager, }); + addonInstallFailed = !installResult.success; } if (!isSilent()) { @@ -201,10 +203,16 @@ async function addHandlerInternal(input: AddInput): Promise { for (const warning of setupWarnings) { log.warn(pc.yellow(warning)); } + const installCmd = + config.packageManager === "npm" ? "npm install" : `${config.packageManager} install`; if (!input.install) { - const installCmd = - config.packageManager === "npm" ? "npm install" : `${config.packageManager} install`; log.info(pc.yellow(`Run '${installCmd}' to install new dependencies.`)); + } else if (addonInstallFailed) { + log.warn( + pc.yellow( + `Dependency installation failed. Run '${installCmd}' after resolving the error above.`, + ), + ); } outro(pc.magenta("Addons added successfully!")); } diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index 549dd5f20..1f3df220e 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -438,9 +438,8 @@ export async function createProjectHandler( } if (input.dryRun) { - const { generateVirtualProject, EMBEDDED_TEMPLATES } = await import( - "@better-fullstack/template-generator" - ); + const { generateVirtualProject, EMBEDDED_TEMPLATES } = + await import("@better-fullstack/template-generator"); const result = await generateVirtualProject({ config, templates: EMBEDDED_TEMPLATES, @@ -511,9 +510,10 @@ export async function createProjectHandler( }; } - await createProject(config, { + const createResult = await createProject(config, { manualDb: cliInput.manualDb ?? input.manualDb, }); + const setupFailures = createResult?.setupFailures ?? []; if (cliInput.verify ?? input.verify) { await runGeneratedChecks(config); @@ -544,9 +544,26 @@ export async function createProjectHandler( const elapsedTimeMs = Date.now() - startTime; if (!isSilent()) { const elapsedTimeInSeconds = (elapsedTimeMs / 1000).toFixed(2); - outro( - pc.magenta(`Project created successfully in ${pc.bold(elapsedTimeInSeconds)} seconds!`), - ); + if (setupFailures.length > 0) { + const stepList = setupFailures.map((f) => f.step).join(", "); + const installCmd = + config.packageManager === "npm" ? "npm install" : `${config.packageManager} install`; + log.warn( + pc.yellow( + `Project files were scaffolded in ${config.relativePath}, but ${setupFailures.length} setup step(s) did not complete: ${stepList}.\n` + + `Review the errors above, then finish setup manually (for example, run '${installCmd}' inside the project).`, + ), + ); + outro( + pc.yellow( + `Project created with ${setupFailures.length} unfinished setup step(s) in ${pc.bold(elapsedTimeInSeconds)}s.`, + ), + ); + } else { + outro( + pc.magenta(`Project created successfully in ${pc.bold(elapsedTimeInSeconds)} seconds!`), + ); + } } return { @@ -557,6 +574,7 @@ export async function createProjectHandler( elapsedTimeMs, projectDirectory: config.projectDir, relativePath: config.relativePath, + setupFailures, }; } catch (error) { if (error instanceof UserCancelledError) { diff --git a/apps/cli/src/helpers/core/create-project.ts b/apps/cli/src/helpers/core/create-project.ts index 263eaa7b2..3652a6621 100644 --- a/apps/cli/src/helpers/core/create-project.ts +++ b/apps/cli/src/helpers/core/create-project.ts @@ -7,8 +7,8 @@ import path from "node:path"; import type { ProjectConfig } from "../../types"; import { writeBtsConfig } from "../../utils/bts-config"; -import { applyDependencyVersionChannel } from "../../utils/dependency-version-channel"; import { isSilent } from "../../utils/context"; +import { applyDependencyVersionChannel } from "../../utils/dependency-version-channel"; import { exitWithError } from "../../utils/errors"; import { formatProject } from "../../utils/file-formatter"; import { setupAddons } from "../addons/addons-setup"; @@ -22,6 +22,7 @@ import { runUvSync, runGoModTidy, runMixCompile, + type SetupStepResult, } from "./install-dependencies"; import { displayPostInstallInstructions } from "./post-installation"; @@ -32,6 +33,13 @@ export interface CreateProjectOptions { export async function createProject(options: ProjectConfig, cliInput: CreateProjectOptions = {}) { const projectDir = options.projectDir; const isConvex = options.backend === "convex"; + const setupFailures: SetupStepResult[] = []; + + // Track whether the target directory already had user content before we + // started writing. If it did (merge mode), we must never delete it on + // failure; if it was empty/new, we own everything in it and can roll back. + const dirHadContentBefore = + (await fs.pathExists(projectDir)) && (await fs.readdir(projectDir)).length > 0; try { await fs.ensureDir(projectDir); @@ -58,7 +66,8 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj await ensurePackageManagerProjectFiles(projectDir, options.packageManager); if (!isConvex && options.database !== "none") { - await setupDatabase(options, cliInput); + const dbResult = await setupDatabase(options, cliInput); + if (dbResult && !dbResult.success) setupFailures.push(dbResult); } if (options.addons.length > 0 && options.addons[0] !== "none") { @@ -78,42 +87,43 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj options.install && (options.ecosystem === "typescript" || options.ecosystem === "react-native") ) { - await installDependencies({ + const result = await installDependencies({ projectDir, packageManager: options.packageManager, }); + if (!result.success) setupFailures.push(result); } // Run cargo build for Rust projects if (options.install && options.ecosystem === "rust") { - await runCargoBuild({ projectDir }); + const result = await runCargoBuild({ projectDir }); + if (!result.success) setupFailures.push(result); } // Run uv sync for Python projects if (options.install && options.ecosystem === "python") { - await runUvSync({ projectDir }); + const result = await runUvSync({ projectDir }); + if (!result.success) setupFailures.push(result); } // Run go mod tidy for Go projects if (options.install && options.ecosystem === "go") { - await runGoModTidy({ projectDir }); + const result = await runGoModTidy({ projectDir }); + if (!result.success) setupFailures.push(result); } // Run wrapper-based verification for Java projects - if ( - options.install && - options.ecosystem === "java" && - options.javaBuildTool !== "none" - ) { - if (options.javaBuildTool === "gradle") { - await runGradleTests({ projectDir }); - } else { - await runMavenTests({ projectDir }); - } + if (options.install && options.ecosystem === "java" && options.javaBuildTool !== "none") { + const result = + options.javaBuildTool === "gradle" + ? await runGradleTests({ projectDir }) + : await runMavenTests({ projectDir }); + if (!result.success) setupFailures.push(result); } if (options.install && options.ecosystem === "elixir") { - await runMixCompile({ projectDir }); + const result = await runMixCompile({ projectDir }); + if (!result.success) setupFailures.push(result); } await initializeGit(projectDir, options.git); @@ -125,8 +135,9 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj }); } - return projectDir; + return { projectDir, setupFailures }; } catch (error) { + await rollbackPartialProject(projectDir, dirHadContentBefore); if (error instanceof Error) { if (!isSilent()) console.error(error.stack); exitWithError(`Error during project creation: ${error.message}`); @@ -137,6 +148,38 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj } } +/** + * Remove a half-written project directory after a fatal scaffolding error so the + * user is not left with a broken, partially generated project. Only removes + * directories we created ourselves (empty/new before scaffolding) — never one + * that already had user content (merge mode). Set BTS_KEEP_FAILED_OUTPUT=1 to + * keep the partial output for debugging template failures. + */ +async function rollbackPartialProject( + projectDir: string, + dirHadContentBefore: boolean, +): Promise { + if (dirHadContentBefore || process.env.BTS_KEEP_FAILED_OUTPUT) { + if (!isSilent() && dirHadContentBefore) { + log.warn( + `Left partially created files in ${projectDir} (directory already existed). Review and clean up manually.`, + ); + } + return; + } + + try { + await fs.remove(projectDir); + if (!isSilent()) { + log.warn( + `Cleaned up partially created project at ${projectDir}. Set BTS_KEEP_FAILED_OUTPUT=1 to keep it for debugging.`, + ); + } + } catch { + // Best-effort cleanup; surface nothing if removal itself fails. + } +} + async function setPackageManagerVersion( projectDir: string, packageManager: ProjectConfig["packageManager"], diff --git a/apps/cli/src/helpers/core/db-setup.ts b/apps/cli/src/helpers/core/db-setup.ts index 95c067826..ee4b5c83e 100644 --- a/apps/cli/src/helpers/core/db-setup.ts +++ b/apps/cli/src/helpers/core/db-setup.ts @@ -10,6 +10,7 @@ import path from "node:path"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; +import type { SetupStepResult } from "./install-dependencies"; import { setupCloudflareD1 } from "../database-providers/d1-setup"; import { setupDockerCompose } from "../database-providers/docker-compose-setup"; @@ -21,7 +22,10 @@ import { setupSupabase } from "../database-providers/supabase-setup"; import { setupTurso } from "../database-providers/turso-setup"; import { setupUpstash } from "../database-providers/upstash-setup"; -export async function setupDatabase(config: ProjectConfig, cliInput?: { manualDb?: boolean }) { +export async function setupDatabase( + config: ProjectConfig, + cliInput?: { manualDb?: boolean }, +): Promise { const { database, dbSetup, backend, projectDir } = config; if (backend === "convex" || database === "none") { @@ -32,12 +36,12 @@ export async function setupDatabase(config: ProjectConfig, cliInput?: { manualDb await fs.remove(serverDbDir); } } - return; + return null; } const dbPackageDir = path.join(projectDir, "packages/db"); if (!(await fs.pathExists(dbPackageDir))) { - return; + return null; } try { @@ -65,9 +69,11 @@ export async function setupDatabase(config: ProjectConfig, cliInput?: { manualDb } else if (database === "redis" && dbSetup === "upstash") { await setupUpstash(config, cliInput); } + + return { step: "Database setup", success: true }; } catch (error) { - if (error instanceof Error) { - consola.error(pc.red(error.message)); - } + const errorMessage = error instanceof Error ? error.message : String(error); + consola.error(pc.red(errorMessage)); + return { step: "Database setup", success: false, errorMessage }; } } diff --git a/apps/cli/src/helpers/core/install-dependencies.ts b/apps/cli/src/helpers/core/install-dependencies.ts index 4e92474cb..ed23b4409 100644 --- a/apps/cli/src/helpers/core/install-dependencies.ts +++ b/apps/cli/src/helpers/core/install-dependencies.ts @@ -5,7 +5,27 @@ import pc from "picocolors"; import type { Addons, PackageManager } from "../../types"; -export function getInstallEnvironment(packageManager: PackageManager): NodeJS.ProcessEnv | undefined { +/** + * Result of a post-scaffold setup step (dependency install, native build, db setup). + * Steps still log their own errors, but no longer swallow failure silently — callers + * collect these so the CLI reports an accurate final status instead of always + * printing "Project created successfully" on top of a broken install. + */ +export interface SetupStepResult { + /** Human-readable step name, e.g. "Install dependencies". */ + step: string; + success: boolean; + /** Present when success is false. */ + errorMessage?: string; +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function getInstallEnvironment( + packageManager: PackageManager, +): NodeJS.ProcessEnv | undefined { if (packageManager === "yarn") { return { // Fresh generated workspaces need to create yarn.lock on first install. @@ -37,8 +57,9 @@ export async function installDependencies({ projectDir: string; packageManager: PackageManager; addons?: Addons[]; -}) { +}): Promise { const s = spinner(); + const step = "Install dependencies"; try { s.start(`Running ${packageManager} install...`); @@ -54,16 +75,22 @@ export async function installDependencies({ })`${packageManager} ${installArgs}`; s.stop("Dependencies installed successfully"); + return { step, success: true }; } catch (error) { s.stop(pc.red("Failed to install dependencies")); - if (error instanceof Error) { - consola.error(pc.red(`Installation error: ${error.message}`)); - } + const errorMessage = toErrorMessage(error); + consola.error(pc.red(`Installation error: ${errorMessage}`)); + return { step, success: false, errorMessage }; } } -export async function runCargoBuild({ projectDir }: { projectDir: string }) { +export async function runCargoBuild({ + projectDir, +}: { + projectDir: string; +}): Promise { const s = spinner(); + const step = "Cargo build"; try { s.start("Running cargo build..."); @@ -74,16 +101,18 @@ export async function runCargoBuild({ projectDir }: { projectDir: string }) { })`cargo build`; s.stop("Cargo build completed"); + return { step, success: true }; } catch (error) { s.stop(pc.red("Cargo build failed")); - if (error instanceof Error) { - consola.error(pc.red(`Cargo build error: ${error.message}`)); - } + const errorMessage = toErrorMessage(error); + consola.error(pc.red(`Cargo build error: ${errorMessage}`)); + return { step, success: false, errorMessage }; } } -export async function runUvSync({ projectDir }: { projectDir: string }) { +export async function runUvSync({ projectDir }: { projectDir: string }): Promise { const s = spinner(); + const step = "uv sync (Python dependencies)"; try { s.start("Running uv sync..."); @@ -94,16 +123,22 @@ export async function runUvSync({ projectDir }: { projectDir: string }) { })`uv sync`; s.stop("Python dependencies installed successfully"); + return { step, success: true }; } catch (error) { s.stop(pc.red("uv sync failed")); - if (error instanceof Error) { - consola.error(pc.red(`uv sync error: ${error.message}`)); - } + const errorMessage = toErrorMessage(error); + consola.error(pc.red(`uv sync error: ${errorMessage}`)); + return { step, success: false, errorMessage }; } } -export async function runGoModTidy({ projectDir }: { projectDir: string }) { +export async function runGoModTidy({ + projectDir, +}: { + projectDir: string; +}): Promise { const s = spinner(); + const step = "go mod tidy"; try { s.start("Running go mod tidy..."); @@ -114,17 +149,23 @@ export async function runGoModTidy({ projectDir }: { projectDir: string }) { })`go mod tidy`; s.stop("Go dependencies installed successfully"); + return { step, success: true }; } catch (error) { s.stop(pc.red("go mod tidy failed")); - if (error instanceof Error) { - consola.error(pc.red(`go mod tidy error: ${error.message}`)); - } + const errorMessage = toErrorMessage(error); + consola.error(pc.red(`go mod tidy error: ${errorMessage}`)); + return { step, success: false, errorMessage }; } } -export async function runMavenTests({ projectDir }: { projectDir: string }) { +export async function runMavenTests({ + projectDir, +}: { + projectDir: string; +}): Promise { const s = spinner(); const mvnw = process.platform === "win32" ? "mvnw.cmd" : "./mvnw"; + const step = "Maven tests"; try { s.start("Running Maven tests..."); @@ -135,17 +176,23 @@ export async function runMavenTests({ projectDir }: { projectDir: string }) { })`${mvnw} test`; s.stop("Maven tests completed"); + return { step, success: true }; } catch (error) { s.stop(pc.red("Maven tests failed")); - if (error instanceof Error) { - consola.error(pc.red(`Maven test error: ${error.message}`)); - } + const errorMessage = toErrorMessage(error); + consola.error(pc.red(`Maven test error: ${errorMessage}`)); + return { step, success: false, errorMessage }; } } -export async function runGradleTests({ projectDir }: { projectDir: string }) { +export async function runGradleTests({ + projectDir, +}: { + projectDir: string; +}): Promise { const s = spinner(); const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew"; + const step = "Gradle tests"; try { s.start("Running Gradle tests..."); @@ -156,16 +203,22 @@ export async function runGradleTests({ projectDir }: { projectDir: string }) { })`${gradlew} test`; s.stop("Gradle tests completed"); + return { step, success: true }; } catch (error) { s.stop(pc.red("Gradle tests failed")); - if (error instanceof Error) { - consola.error(pc.red(`Gradle test error: ${error.message}`)); - } + const errorMessage = toErrorMessage(error); + consola.error(pc.red(`Gradle test error: ${errorMessage}`)); + return { step, success: false, errorMessage }; } } -export async function runMixCompile({ projectDir }: { projectDir: string }) { +export async function runMixCompile({ + projectDir, +}: { + projectDir: string; +}): Promise { const s = spinner(); + const step = "mix deps.get / compile"; try { s.start("Running mix deps.get and mix compile..."); @@ -181,10 +234,11 @@ export async function runMixCompile({ projectDir }: { projectDir: string }) { })`mix compile`; s.stop("Elixir dependencies installed and project compiled"); + return { step, success: true }; } catch (error) { s.stop(pc.red("mix compile failed")); - if (error instanceof Error) { - consola.error(pc.red(`Mix error: ${error.message}`)); - } + const errorMessage = toErrorMessage(error); + consola.error(pc.red(`Mix error: ${errorMessage}`)); + return { step, success: false, errorMessage }; } } diff --git a/apps/cli/test/install-dependencies.test.ts b/apps/cli/test/install-dependencies.test.ts index dd1096771..f06d66950 100644 --- a/apps/cli/test/install-dependencies.test.ts +++ b/apps/cli/test/install-dependencies.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "bun:test"; +import os from "node:os"; +import path from "node:path"; -import { getInstallArgs, getInstallEnvironment } from "../src/helpers/core/install-dependencies"; +import type { ProjectConfig } from "../src/types"; + +import { setupDatabase } from "../src/helpers/core/db-setup"; +import { + getInstallArgs, + getInstallEnvironment, + installDependencies, +} from "../src/helpers/core/install-dependencies"; describe("getInstallEnvironment", () => { it("disables immutable Yarn CI defaults for fresh scaffolds", () => { @@ -28,3 +37,34 @@ describe("getInstallArgs", () => { expect(getInstallArgs("yarn")).toEqual(["install"]); }); }); + +describe("installDependencies", () => { + it("returns a failure result instead of swallowing the error when install fails", async () => { + // A non-existent cwd makes the install command fail fast (ENOENT) without + // running a real install. The function must report the failure to its + // caller rather than silently logging it (the old behavior). + const missingDir = path.join(os.tmpdir(), `bfs-nonexistent-${process.pid}-${Date.now()}`); + + const result = await installDependencies({ + projectDir: missingDir, + packageManager: "bun", + }); + + expect(result.success).toBe(false); + expect(result.step).toBe("Install dependencies"); + expect(result.errorMessage).toBeTruthy(); + }); +}); + +describe("setupDatabase", () => { + it("returns null when there is no external database to provision", async () => { + const config = { + backend: "convex", + database: "none", + dbSetup: "none", + projectDir: os.tmpdir(), + } as unknown as ProjectConfig; + + expect(await setupDatabase(config)).toBeNull(); + }); +}); From 908f0afa54506649a384aa8716434fdba631c27e Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 15:23:18 +0300 Subject: [PATCH 02/36] fix(testing): gate CI smoke on any real step failure Previously only 'template'-classified failures set a non-zero exit, so genuine compile/build/typecheck errors whose output did not match a curated TEMPLATE_PATTERN were classified 'unknown' and silently passed CI. The smoke runner now fails on any non-skipped, non-advisory step failure; transient 'environment' failures stay non-gating but are surfaced. --- testing/smoke-test.ts | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/testing/smoke-test.ts b/testing/smoke-test.ts index 8995c5b5e..0b26037db 100644 --- a/testing/smoke-test.ts +++ b/testing/smoke-test.ts @@ -486,7 +486,37 @@ await writeFile( }), ); -const hasTemplateBug = results.some((r) => - r.steps.some((s) => !s.success && !s.skipped && s.classification === "template"), +// CI gating: fail on any real (non-skipped, non-advisory) step failure. +// Previously only `template`-classified failures gated CI, so genuine +// compile/build/typecheck errors whose output didn't match a curated +// TEMPLATE_PATTERN were classified `unknown` and silently passed. Transient +// `environment` failures (network/registry/timeouts) stay non-gating to avoid +// flakiness, but are surfaced so real hangs/regressions are not invisible. +const realFailures = results.flatMap((r) => + r.steps + .filter((s) => !s.success && !s.skipped && !s.advisory) + .map((s) => ({ + combo: r.comboName, + step: s.step, + classification: s.classification ?? "unknown", + })), ); -if (hasTemplateBug) process.exitCode = 1; +const environmentFailures = realFailures.filter((f) => f.classification === "environment"); +const gatingFailures = realFailures.filter((f) => f.classification !== "environment"); + +if (environmentFailures.length > 0) { + console.warn( + `\n⚠ ${environmentFailures.length} environment-classified failure(s) did not gate CI (possible flakiness):`, + ); + for (const f of environmentFailures) { + console.warn(` - ${f.combo}: ${f.step}`); + } +} + +if (gatingFailures.length > 0) { + console.error(`\n✗ ${gatingFailures.length} step failure(s) gating CI:`); + for (const f of gatingFailures) { + console.error(` - ${f.combo}: ${f.step} [${f.classification}]`); + } + process.exitCode = 1; +} From c65553a4269030cd9653b2224053cfb87ad3a947 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 15:23:19 +0300 Subject: [PATCH 03/36] feat(templates): add check-types to generated packages + coverage guard Adds a check-types script to 17 previously-unchecked generated packages (db, auth server, all api servers, native, and react frontends) so the root 'turbo/bun check-types' actually type-checks them, closing the silent type-drift hole that let heroui-native and stripe apiVersion regressions ship. A new meta-test (check-types-coverage.test.ts) enforces that every generated package either defines check-types or is explicitly allowlisted with a reason. Validated via real install + check-types on TypeScript and react-native stacks. --- .../template-snapshots.test.ts.snap | 100 +++++++++++++---- apps/cli/test/check-types-coverage.test.ts | 104 ++++++++++++++++++ .../api/garph/server/package.json.hbs | 4 +- .../api/graphql-yoga/server/package.json.hbs | 4 +- .../api/openapi/server/package.json.hbs | 1 + .../api/orpc/server/package.json.hbs | 4 +- .../api/trpc/server/package.json.hbs | 4 +- .../api/ts-rest/server/package.json.hbs | 4 +- .../better-auth/server/base/package.json.hbs | 4 +- .../templates/db/base/package.json.hbs | 4 +- .../frontend/native/bare/package.json.hbs | 1 + .../native/unistyles/package.json.hbs | 1 + .../frontend/native/uniwind/package.json.hbs | 1 + .../templates/frontend/qwik/package.json.hbs | 1 + .../frontend/react/next/package.json.hbs | 1 + .../react/react-router/package.json.hbs | 1 + .../react/react-vite/package.json.hbs | 1 + .../frontend/react/vinext/package.json.hbs | 1 + .../templates/frontend/solid/package.json.hbs | 1 + 19 files changed, 215 insertions(+), 27 deletions(-) create mode 100644 apps/cli/test/check-types-coverage.test.ts diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 80d2ca9eb..710ba94a9 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -1815,7 +1815,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-tanstack-router-minimal/config": "workspace:*" @@ -1963,6 +1965,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -2130,6 +2133,7 @@ export default nextConfig; "version": "0.1.0", "private": true, "scripts": { + "check-types": "tsc --noEmit", "dev": "next dev --port 3001", "build": "next build", "start": "next start" @@ -2438,7 +2442,9 @@ export const trpc = createTRPCOptionsProxy({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "^6.0.3", "@snapshot-next-self-fullstack/config": "workspace:*" @@ -2546,7 +2552,9 @@ export type AppRouter = typeof appRouter; } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "^6.0.3", "@snapshot-next-self-fullstack/config": "workspace:*" @@ -2692,6 +2700,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", @@ -3123,7 +3132,9 @@ export const orpc = createTanstackQueryUtils(client) } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-astro-react-integration/config": "workspace:*" @@ -3269,6 +3280,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -3646,7 +3658,9 @@ export default defineNuxtConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-nuxt-standalone/config": "workspace:*" @@ -3792,6 +3806,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -4417,7 +4432,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-express-node-trpc/config": "workspace:*", @@ -4544,6 +4561,7 @@ export type AppRouter = typeof appRouter; } }, "scripts": { + "check-types": "tsc --noEmit", "postinstall": "prisma generate", "db:push": "prisma db push", "db:generate": "prisma generate", @@ -5204,7 +5222,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-hono-bun-orpc/config": "workspace:*" @@ -5351,6 +5371,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -6034,6 +6055,7 @@ export default defineConfig({ }, "type": "module", "scripts": { + "check-types": "tsc --noEmit", "openapi:verify": "bun src/verify-openapi.ts" }, "devDependencies": { @@ -6227,7 +6249,9 @@ export const openApiDocument = createOpenApiDocument(); } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-hono-openapi/config": "workspace:*" @@ -6379,6 +6403,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -8232,7 +8257,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-better-auth-full/config": "workspace:*" @@ -8340,7 +8367,9 @@ export type AppRouter = typeof appRouter; } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-better-auth-full/config": "workspace:*" @@ -8492,6 +8521,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", @@ -9281,6 +9311,7 @@ export default nextConfig; "version": "0.1.0", "private": true, "scripts": { + "check-types": "tsc --noEmit", "dev": "next dev --port 3001", "build": "next build", "start": "next start" @@ -9562,7 +9593,9 @@ export const trpc = createTRPCOptionsProxy({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "^6.0.3", "@snapshot-self-next-clerk/config": "workspace:*" @@ -9710,6 +9743,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -10256,7 +10290,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-self-tanstack-start-clerk/config": "workspace:*" @@ -10402,6 +10438,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -11019,7 +11056,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-mongodb-mongoose/config": "workspace:*" @@ -11145,7 +11184,9 @@ export type AppRouter = typeof appRouter; "default": "./src/*.ts" } }, - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-mongodb-mongoose/config": "workspace:*" @@ -11748,7 +11789,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-postgres-prisma/config": "workspace:*" @@ -11875,6 +11918,7 @@ export type AppRouter = typeof appRouter; } }, "scripts": { + "check-types": "tsc --noEmit", "postinstall": "prisma generate", "db:push": "prisma db push", "db:generate": "prisma generate", @@ -12502,7 +12546,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-algolia-search-hono/config": "workspace:*" @@ -12650,6 +12696,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -15469,7 +15516,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-ai-cli-root-tooling/config": "workspace:*" @@ -15617,6 +15666,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -16248,7 +16298,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-nx-root-tooling/config": "workspace:*" @@ -16396,6 +16448,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -17181,6 +17234,7 @@ fontWeight: "bold", "version": "1.0.0", "main": "expo-router/entry", "scripts": { + "check-types": "tsc --noEmit", "dev": "expo start --clear", "build": "expo export", "android": "expo run:android", @@ -17455,7 +17509,9 @@ export default app; } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-native-react-native/config": "workspace:*" @@ -17603,6 +17659,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -17884,6 +17941,7 @@ registerRootComponent(App); "version": "1.0.0", "main": "index.js", "scripts": { + "check-types": "tsc --noEmit", "dev": "expo start --clear", "build": "expo export", "android": "expo run:android", @@ -18206,7 +18264,9 @@ export default app; } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-native-mobile-integrations/config": "workspace:*" diff --git a/apps/cli/test/check-types-coverage.test.ts b/apps/cli/test/check-types-coverage.test.ts new file mode 100644 index 000000000..aa3532e78 --- /dev/null +++ b/apps/cli/test/check-types-coverage.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const TEMPLATES_DIR = path.resolve( + import.meta.dir, + "../../../packages/template-generator/templates", +); + +/** + * Generated workspace packages that intentionally do NOT ship a `check-types` + * script. Every entry needs a documented reason. When adding a new generated + * package, prefer giving it a real `check-types` script over allowlisting it — + * a package that is never type-checked is exactly how drift (e.g. the + * heroui-native and stripe apiVersion regressions) shipped silently. + */ +const CHECK_TYPES_ALLOWLIST = new Map([ + [ + "base/package.json.hbs", + "Root workspace package; check-types is injected as `turbo check-types` by post-process/package-configs.ts", + ], + [ + "packages/config/package.json.hbs", + "Stub package with no TypeScript source; `tsc --noEmit` would fail with TS18003 (no inputs found)", + ], + [ + "packages/env/package.json.hbs", + "Source is fully conditional; deferred pending per-config validation", + ], + ["packages/infra/package.json.hbs", "Alchemy IaC package with no tsconfig"], + [ + "backend/convex/packages/backend/package.json.hbs", + "Convex codegen-managed types; needs `convex codegen` before tsc", + ], + [ + "frontend/redwood/package.json.hbs", + "RedwoodJS/CedarJS uses its own framework-managed type-check", + ], + ["frontend/redwood/web/package.json.hbs", "RedwoodJS/CedarJS framework-managed type-check"], + ["frontend/redwood/api/package.json.hbs", "RedwoodJS/CedarJS framework-managed type-check"], + [ + "frontend/react/tanstack-start/package.json.hbs", + "Needs `tsr generate` + @tanstack/router-cli before tsc (follow-up, mirror tanstack-router)", + ], + ["frontend/nuxt/package.json.hbs", "Needs `nuxt typecheck` + vue-tsc (follow-up)"], + ["frontend/astro/package.json.hbs", "Needs `astro check` + @astrojs/check (follow-up)"], + [ + "frontend/angular/package.json.hbs", + "Needs Angular compiler-based type-check, not plain tsc (follow-up)", + ], +]); + +function findPackageJsonHbs(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + findPackageJsonHbs(full, acc); + } else if (entry === "package.json.hbs") { + acc.push(full); + } + } + return acc; +} + +function hasCheckTypes(file: string): boolean { + return readFileSync(file, "utf8").includes('"check-types"'); +} + +describe("generated package check-types coverage", () => { + const files = findPackageJsonHbs(TEMPLATES_DIR); + + it("discovers the generated package.json templates", () => { + expect(files.length).toBeGreaterThan(30); + }); + + it("every generated package defines check-types or is explicitly allowlisted", () => { + const offenders = files + .map((file) => path.relative(TEMPLATES_DIR, file)) + .filter((rel) => !CHECK_TYPES_ALLOWLIST.has(rel)) + .filter((rel) => !hasCheckTypes(path.join(TEMPLATES_DIR, rel))); + + // A non-empty list means a package would be generated without ever being + // type-checked. Add a `check-types` script, or allowlist it with a reason. + expect(offenders).toEqual([]); + }); + + it("keeps the allowlist honest (no missing or already-fixed entries)", () => { + const stale: string[] = []; + for (const rel of CHECK_TYPES_ALLOWLIST.keys()) { + const full = path.join(TEMPLATES_DIR, rel); + let content: string; + try { + content = readFileSync(full, "utf8"); + } catch { + stale.push(`${rel} (file no longer exists)`); + continue; + } + if (content.includes('"check-types"')) { + stale.push(`${rel} (now defines check-types — remove from allowlist)`); + } + } + expect(stale).toEqual([]); + }); +}); diff --git a/packages/template-generator/templates/api/garph/server/package.json.hbs b/packages/template-generator/templates/api/garph/server/package.json.hbs index c0e1ce628..5cb67668b 100644 --- a/packages/template-generator/templates/api/garph/server/package.json.hbs +++ b/packages/template-generator/templates/api/garph/server/package.json.hbs @@ -9,6 +9,8 @@ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {} } diff --git a/packages/template-generator/templates/api/graphql-yoga/server/package.json.hbs b/packages/template-generator/templates/api/graphql-yoga/server/package.json.hbs index c0e1ce628..5cb67668b 100644 --- a/packages/template-generator/templates/api/graphql-yoga/server/package.json.hbs +++ b/packages/template-generator/templates/api/graphql-yoga/server/package.json.hbs @@ -9,6 +9,8 @@ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {} } diff --git a/packages/template-generator/templates/api/openapi/server/package.json.hbs b/packages/template-generator/templates/api/openapi/server/package.json.hbs index 162d3c959..cba1b9921 100644 --- a/packages/template-generator/templates/api/openapi/server/package.json.hbs +++ b/packages/template-generator/templates/api/openapi/server/package.json.hbs @@ -10,6 +10,7 @@ }, "type": "module", "scripts": { + "check-types": "tsc --noEmit", "openapi:verify": "bun src/verify-openapi.ts" }, "devDependencies": {}, diff --git a/packages/template-generator/templates/api/orpc/server/package.json.hbs b/packages/template-generator/templates/api/orpc/server/package.json.hbs index 661607b0d..3962f426d 100644 --- a/packages/template-generator/templates/api/orpc/server/package.json.hbs +++ b/packages/template-generator/templates/api/orpc/server/package.json.hbs @@ -9,7 +9,9 @@ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {}, "dependencies": {} } diff --git a/packages/template-generator/templates/api/trpc/server/package.json.hbs b/packages/template-generator/templates/api/trpc/server/package.json.hbs index c0e1ce628..5cb67668b 100644 --- a/packages/template-generator/templates/api/trpc/server/package.json.hbs +++ b/packages/template-generator/templates/api/trpc/server/package.json.hbs @@ -9,6 +9,8 @@ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {} } diff --git a/packages/template-generator/templates/api/ts-rest/server/package.json.hbs b/packages/template-generator/templates/api/ts-rest/server/package.json.hbs index c0e1ce628..5cb67668b 100644 --- a/packages/template-generator/templates/api/ts-rest/server/package.json.hbs +++ b/packages/template-generator/templates/api/ts-rest/server/package.json.hbs @@ -9,6 +9,8 @@ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {} } diff --git a/packages/template-generator/templates/auth/better-auth/server/base/package.json.hbs b/packages/template-generator/templates/auth/better-auth/server/base/package.json.hbs index 093e040ab..f2761f352 100644 --- a/packages/template-generator/templates/auth/better-auth/server/base/package.json.hbs +++ b/packages/template-generator/templates/auth/better-auth/server/base/package.json.hbs @@ -11,6 +11,8 @@ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {} } diff --git a/packages/template-generator/templates/db/base/package.json.hbs b/packages/template-generator/templates/db/base/package.json.hbs index 300111c9d..103de59be 100644 --- a/packages/template-generator/templates/db/base/package.json.hbs +++ b/packages/template-generator/templates/db/base/package.json.hbs @@ -9,6 +9,8 @@ "default": "./src/*.ts" } }, - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {} } diff --git a/packages/template-generator/templates/frontend/native/bare/package.json.hbs b/packages/template-generator/templates/frontend/native/bare/package.json.hbs index d4d2ba004..d87ff96e1 100644 --- a/packages/template-generator/templates/frontend/native/bare/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/bare/package.json.hbs @@ -3,6 +3,7 @@ "version": "1.0.0", "main": "{{#if (eq mobileNavigation 'expo-router')}}expo-router/entry{{else}}index.js{{/if}}", "scripts": { + "check-types": "tsc --noEmit", "dev": "expo start --clear", "build": "expo export", "android": "expo run:android", diff --git a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs index 43247d20b..a90df3f1f 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs @@ -4,6 +4,7 @@ "private": true, "main": "index.js", "scripts": { + "check-types": "tsc --noEmit", "dev": "expo start --clear", "build": "expo export", "android": "expo run:android", diff --git a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs index f9d9bb970..6641bbfb1 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs @@ -4,6 +4,7 @@ "private": true, "main": "{{#if (eq mobileNavigation 'expo-router')}}expo-router/entry{{else}}index.js{{/if}}", "scripts": { + "check-types": "tsc --noEmit", "start": "expo start", "dev": "expo start --clear", "build": "expo export", diff --git a/packages/template-generator/templates/frontend/qwik/package.json.hbs b/packages/template-generator/templates/frontend/qwik/package.json.hbs index 12981665c..1e506e8a7 100644 --- a/packages/template-generator/templates/frontend/qwik/package.json.hbs +++ b/packages/template-generator/templates/frontend/qwik/package.json.hbs @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "check-types": "tsc --noEmit", "build": "qwik build", "build.client": "vite build", "build.preview": "vite build --ssr src/entry.preview.tsx", diff --git a/packages/template-generator/templates/frontend/react/next/package.json.hbs b/packages/template-generator/templates/frontend/react/next/package.json.hbs index 1fd50d70f..3f4a3307e 100644 --- a/packages/template-generator/templates/frontend/react/next/package.json.hbs +++ b/packages/template-generator/templates/frontend/react/next/package.json.hbs @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { + "check-types": "tsc --noEmit", "dev": "next dev --port 3001", "build": "next build", "start": "next start" diff --git a/packages/template-generator/templates/frontend/react/react-router/package.json.hbs b/packages/template-generator/templates/frontend/react/react-router/package.json.hbs index 3dae058ac..343f9111a 100644 --- a/packages/template-generator/templates/frontend/react/react-router/package.json.hbs +++ b/packages/template-generator/templates/frontend/react/react-router/package.json.hbs @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "check-types": "react-router typegen && tsc", "build": "react-router build", "dev": "react-router dev", "start": "react-router-serve ./build/server/index.js", diff --git a/packages/template-generator/templates/frontend/react/react-vite/package.json.hbs b/packages/template-generator/templates/frontend/react/react-vite/package.json.hbs index d9a4e869e..c85eecffa 100644 --- a/packages/template-generator/templates/frontend/react/react-vite/package.json.hbs +++ b/packages/template-generator/templates/frontend/react/react-vite/package.json.hbs @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "check-types": "tsc --noEmit", "dev": "vite dev", "build": "vite build", "serve": "vite preview", diff --git a/packages/template-generator/templates/frontend/react/vinext/package.json.hbs b/packages/template-generator/templates/frontend/react/vinext/package.json.hbs index 7fe1fd5c7..6502baafb 100644 --- a/packages/template-generator/templates/frontend/react/vinext/package.json.hbs +++ b/packages/template-generator/templates/frontend/react/vinext/package.json.hbs @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "check-types": "tsc --noEmit", "dev": "vinext dev --port 3001", "build": "vinext build", "start": "vinext start", diff --git a/packages/template-generator/templates/frontend/solid/package.json.hbs b/packages/template-generator/templates/frontend/solid/package.json.hbs index 2e9355d4d..33dbdaa98 100644 --- a/packages/template-generator/templates/frontend/solid/package.json.hbs +++ b/packages/template-generator/templates/frontend/solid/package.json.hbs @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "check-types": "tsc --noEmit", "dev": "vite dev", "build": "vite build", "serve": "vite preview", From 062acb1845d5f69cad063c82a2c0618325f93b23 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 15:23:20 +0300 Subject: [PATCH 04/36] docs: add next-updates roadmap from codebase + platform analysis --- docs/next-updates-roadmap.md | 209 +++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/next-updates-roadmap.md diff --git a/docs/next-updates-roadmap.md b/docs/next-updates-roadmap.md new file mode 100644 index 000000000..b50a64f95 --- /dev/null +++ b/docs/next-updates-roadmap.md @@ -0,0 +1,209 @@ +# Better-Fullstack — Next Updates, Features & Improvements + +> **Generated:** 2026-06-18 from a multi-agent analysis of `apps/cli`, `packages/template-generator`, `apps/web`, the MCP server, the testing/CI/release systems, and the competitive landscape. +> +> **How to read this:** Start at **🔴 Fix first** (active correctness/trust bugs). Then work the tiers in order — Tier 2 (quality foundation) is the unlock that makes everything else shippable with confidence. Appendices hold the dense, file-cited findings per subsystem. + +--- + +## Strategic read + +BFS has the **widest scaffolding surface in the market** — 8 ecosystems, 677 options, a web builder, *and* an MCP server. No competitor (including its TS-only upstream `create-better-t-stack`) matches that breadth. Three things cap the value of that breadth: + +1. **Quality assurance gap** — generated projects are *not compiled in CI*, and the CLI reports "success" even when a project is broken. This is the root cause of nearly every template bug in project memory. +2. **Lifecycle gap** — BFS is a one-shot scaffolder. Competitors ship `astro add` / `sv add` (add features to existing projects) and `nx migrate` (codemod upgrades). BFS is one step from both. +3. **Under-marketed moat + dormant features** — the polyglot multi-ecosystem monorepo is the unique wedge but it's buried, and a showcase gallery + analytics dashboard are already built but not routed. + +**The opportunity bet:** position BFS as **the deterministic, verified golden-path layer that AI agents scaffold into** — because AI app-builders (Lovable, v0, bolt) win greenfield demos but ship 45–80% vulnerable code and hit an iteration wall. + +--- + +## 🔴 Fix first — correctness/trust bugs (not features) + +These four together explain BFS's entire recurring-bug history. They bleed regardless of roadmap. + +> **Implementation status (2026-06-18):** #1, #2, #3 **landed & verified**. #4 **substantially landed**: `check-types` added to 17 high-confidence generated packages (every documented drift target — better-auth/stripe, orpc/trpc context, heroui-native), enforced by a new coverage meta-test (`apps/cli/test/check-types-coverage.test.ts`) with a 12-entry documented allowlist; validated end-to-end on real installs (TS next+hono+trpc+drizzle+better-auth and react-native uniwind). Remaining allowlisted framework packages (nuxt/astro/angular/tanstack-start/redwood/convex) need per-framework check commands and are tracked as follow-ups. Side effect: regenerating the template bundle also fixed 10 stale-snapshot test failures. + +| # | Bug | Where | Why it matters | +|---|---|---|---| +| 1 | **Install/db-setup errors are swallowed → CLI prints "Project created successfully" on broken projects** | `apps/cli/src/helpers/core/install-dependencies.ts` (catch+log, never rethrow); `db-setup.ts` (same) | Worst trust bug. Users get green "success" + history entry + analytics event on a broken scaffold. | +| 2 | **No rollback / cleanup on fatal failure** → half-scaffolded dir left behind | `apps/cli/src/helpers/core/create-project.ts` (catch path calls `exitWithError` with no cleanup) | Users must manually `rm` and can't cleanly retry. Add `--keep-on-error` escape hatch. | +| 3 | **CI "pass" is gated by a regex, not step success** — install/build/typecheck failures stay green unless error text matches curated `TEMPLATE_PATTERNS` | `testing/smoke-test.ts` (`classification === "template"` gate); `testing/lib/verify.ts` (regex classifier) | This is *why* the stripe/heroui/native bugs shipped. Highest-leverage single fix in the repo. | +| 4 | **~29 of 37 generated packages have no `check-types` script** (verifier silently skips them) | `testing/lib/verify.ts` (skip-when-absent); `templates/frontend/native/package.json.hbs`, `templates/auth/*`, `api/*` | Type drift ships silently — exactly the heroui-native & stripe-`apiVersion` drift in memory. | + +**Fix approach (summary):** +- **#1** Collect per-step status (install, db-setup, build checks); rethrow or propagate failure so the outro reads "created with errors" + recovery hint; do **not** write history/fire analytics as "success" on failure. +- **#2** If the CLI created the target dir (not merge mode), `fs.remove` it on fatal error, or print exact recovery steps; add `--keep-on-error`. +- **#3** Replace the `classification === "template"` gate with "fail on any step where `!success && !skipped && !advisory`"; keep `environment` as the only soft class, but count + surface it. +- **#4** Add `"check-types": "tsc --noEmit"` (and `tsr generate &&` where needed) to every generated `package.json.hbs` lacking it; run `turbo check-types` across all workspace packages in the verifier; add a meta-test asserting every generated package defines it. + +--- + +## 🟢 Tier 1 — Quick wins (high impact, low effort — mostly *finishing existing work*) + +| Item | Effort | Notes | +|---|---|---| +| **Ship the `/showcase` route** | S | `apps/web/src/components/showcase/showcase-page.tsx` fully built, just unrouted. Social proof + SEO. | +| **Ship public `/analytics` "Popular / Trending stacks" page** | S–M | Dashboard + `analytics.getStats()` (`stackCombinations`, `dbOrmCombinations`) already exist and are unused. Doubles as a builder hint. | +| **Make `AGENTS.md` a *default* ai-docs output** (alongside CLAUDE.md) | S | create-next-app 16.2 default; now a Linux Foundation standard (60k+ repos). Currently opt-in only. | +| **Dynamic OG images for shared stacks** | M | No `og:image` exists today — every share unfurls as a generic terminal PNG. | +| **Fix the ".NET = 8th ecosystem" stat** across site/SEO/JSON-LD | S | `apps/web/src/lib/project-stats.ts` hardcodes `ECOSYSTEM_COUNT = 7`; under-sells the product to users *and* LLMs. | +| **Re-run from history**: `create --from-history ` / `--config ` | S | History already stores full config; `create()` already accepts one. | +| **First-run telemetry notice + `bfs telemetry` toggle** | S | Currently silent opt-out that sends the whole config — reputational/compliance risk. | +| **Fix MCP `astroIntegration` (latent bug) + stop hardcoding `aiDocs`** | S | MCP-built Astro projects can't pick a framework integration; agents can't request `agents-md`. | +| **⌘K command-palette search across 677 options** in the builder | M | Biggest builder friction is scrolling 48 categories. | + +--- + +## 🟡 Tier 2 — Quality foundation (makes the breadth *trustworthy*) + +The unlock that converts "677 options" from a liability into a guarantee. Two agents independently flagged this as the #1 systemic gap. + +| Item | Effort | Impact | +|---|---|---| +| **PR-gate a minimal generated-project typecheck matrix** (~6–8 presets, one per ecosystem: real `install + check-types/build`) | M | High | +| **Emit `check-types` in every generated workspace package** + run `turbo check-types` across all | S–M | High | +| **Schema↔template coverage test** (every non-`none` enum value must have a real template — auto-catches the .NET "selectable but not generated" class) | M | High | +| **Property-test the compatibility engine** (3,733 lines / ~471 conditionals vs 56 example tests): idempotence, "adjusted config satisfies all constraints," "adjusted config scaffolds" | M | High | +| **Gate npm releases on a real smoke run** (today `release.yaml` runs only the static lane) | S–M | High | +| **Generated-project health roll-up** (aggregator job + pass/fail table; the weekly matrix has no aggregation) | S–M | Med-High | +| **API-literal drift guard** (stripe `apiVersion`, expo `web.output`, drizzle mysql `connection`) | M | Med-High | + +--- + +## 🔵 Tier 3 — Lifecycle & moat (strategic bets for the next major) + +| Item | Effort | Why it's the moat | +|---|---|---| +| **`bfs add` for *real* features** (db/auth/api/payments/email...) into existing projects, not just addons | L | Headline lifecycle gap. `astro add`/`sv add` prove demand; compatibility engine + `bts.jsonc` make it uniquely doable *cross-ecosystem*. MCP `plan_addition` has the bones. | +| **`bfs upgrade` migration engine** (ordered, reviewable codemods on top of `update-deps`) | L | Biggest *unsolved* problem in the space. `nx migrate` is the gold standard. | +| **`bfs doctor`** (validate `bts.jsonc`, deps, env vars, run build checks) | M | Mostly wiring existing `--verify` infra into a diagnostic command. | +| **MCP: structured outputs + tool annotations + `bfs_list_presets` + `bfs_recommend_stack`** (NL brief → validated stack) | M | Turns BFS from "schema you must fill" into "describe it and go." | +| **AI builder assistant on the web** ("describe your app → stack"), reusing the compatibility engine | L | Flagship differentiator for the 677-option overwhelm. | +| **Publish a Claude Code plugin / Agent Skill** (CLI+Skill pattern; keep MCP for orchestration) | S–M | Validated 2026 distribution; CLI tool-calls are 10–32× cheaper in tokens than MCP. | + +--- + +## 🟣 Tier 4 — New integrations & growth + +- **Vector DB category** (`pgvector`/Pinecone/Qdrant/Chroma/Weaviate) — S–M, High. The one obvious 2026 AI primitive missing, despite 11 TS + 10 Python AI SDKs. +- **GitHub Actions CI addon** — generated projects currently ship with *zero* CI. M, High. +- **Golden-path "complete app" templates** (SaaS-in-a-box: auth + billing + admin + marketing) vs today's `todo`/`ai` examples. M–L, High. +- **Programmatic per-combo SEO/GEO landing pages** ("Go + React monorepo starter") — owns long-tail no competitor can match. M, High. +- **Preview export: ZIP download + "Open in StackBlitz"** — turns the dead-end read-only preview into a try-it loop. M. +- **Short shareable links + a Convex `shares` table** (custom stacks currently get ugly `/new?...` URLs). M. +- **Reposition around the polyglot monorepo + "verified golden path for AI agents"**; add `create-better-t-stack` and AI app-builders to `/compare`; fix README "450+/7" vs MCP "677/8" inconsistency. S. + +--- + +## Recommended sequencing + +1. **Sprint 0 (now):** the four 🔴 correctness bugs + the Tier-1 "finish existing work" items (showcase, analytics, AGENTS.md default, .NET stat). Cheap; stops active bleeding and unlocks dormant value. +2. **Sprint 1:** Tier 2 quality foundation. Once generated projects are actually compiled in CI, everything else ships with confidence instead of via manual `bun create` discovery. +3. **Next major:** pick **one** flagship moat bet — recommend **`bfs add` for full features** first (compounds with the MCP story + AI builder assistant; plumbing partly exists). + +> **Confidence note:** External competitive facts (version numbers, ARR, competitor features) are web-research dated June 2026 — sanity-check before using in marketing copy. Code-cited findings (file paths, the CI gating hole, swallowed errors) were verified against the repo. + +--- + +# Appendices — detailed, file-cited findings + +## Appendix A — CLI experience & architecture + +**Current state:** oRPC router via `trpc-cli` (`apps/cli/src/run.ts`) exposes `create`/`add`/`history`/`update-deps`/`sponsors`/`docs`/`builder`/`mcp`. Interactive UX is `@clack/prompts` + a custom navigation layer (`prompts/navigable.ts`, `navigable-group.ts`) with back/forward and smart back-skip. ~150 zod flags (`create-command-input.ts`), `--yes`/`--yolo`/`--part`/`--dry-run`/`--verify`. Validation is imperative (`compatibility-rules.ts` ~808 lines + `config-validation.ts` ~1317 lines). Excellent per-ecosystem post-install instructions (`post-installation.ts`). Telemetry is opt-out. + +**Gaps / bugs:** +- Install failures swallowed (`install-dependencies.ts:57-62`, plus `runCargoBuild`/`runUvSync`/`runGoModTidy`/`runMavenTests`/`runGradleTests`/`runMixCompile`) → false "success" (`command-handlers.ts:506`). +- DB-setup failures swallowed (`db-setup.ts:68-72`). +- No rollback on fatal error (`create-project.ts:129-137`). +- `add` command is addon/deploy-only (`run.ts:233-255`, `add-handler.ts`) — can't add db/orm/auth/api/payments/email/etc. +- No `upgrade`/`doctor`/`eject`/`migrate`/`config` commands. +- `update-deps` is maintainer-only but misleadingly named (`commands/update-deps.ts:47-69`). +- Telemetry: opt-out, no first-run notice, sends whole config (`utils/analytics.ts:18`). +- Auto-adjustments invisible under `--yes`/CI (gated on `!isSilent()`). +- Multi-ecosystem `.env`/README collisions, bypassable with `--yolo` (`generator.ts:166-179`). +- No final confirm gate in interactive mode; no re-run/edit-last-config; presets are TS/RN-only (`utils/templates.ts`: mern/pern/t3/uniwind). + +**Top recommendations:** fix swallowed errors (S/High), rollback (M/High), `bfs add` for real features (L/High), `bfs doctor` (M/High), telemetry notice + toggle (S/High), `create --from-history`/`--config` (S/High), surface auto-adjustments in non-interactive (S/Med-High), final confirm gate (S/Med), per-ecosystem presets (M/Med-High), `create --json` (S/Med). + +## Appendix B — Template generator & coverage + +**Current state:** single generator runs in Node (CLI) + browser (web) via bundled `templates.generated.ts` (~2.3 MB). TS/RN use a modular pipeline (44 processors, 32 handlers, ~423 pinned versions in `utils/add-deps.ts`); non-TS ecosystems use one monolithic handler each + Handlebars-conditional dep files. Source of truth: `packages/types/src/schemas.ts`. + +**Ecosystem depth:** TypeScript/RN/Python/Go/Java = production; Elixir = partial (ueberauth/guardian/gRPC are deps-only placeholders); **.NET = stub** (12 files / 439 lines, everything inline in `Program.cs.hbs`). Frontend breadth is excellent (next/nuxt/svelte/solid/astro/qwik/angular/redwood/fresh/react-router/tanstack-*/native) — coverage isn't the gap; **depth & integrations** are. + +**Recurring bug classes:** `noUnusedLocals` unused imports/vars (fets/orpc/trpc/Go); node16/nodenext missing `.js` extensions (drizzle/mikroorm/sequelize/typeorm barrels); missing deps/generated files (graph `--part` empty `packages/database/package.json`; `@tanstack/react-form`; `routeTree.gen.ts`); in-code API drift invisible to version checks (stripe `apiVersion`, drizzle mysql `connection`, expo `web.output:"static"`). + +**Missing integrations devs expect in 2026:** no vector DB anywhere; no CI/CD output in generated projects; no IaC (Terraform/Pulumi) / k8s/Helm; Clerk has no native/Expo; CMS all web-only; OpenAPI API server-only (no client). + +**Top recommendations:** PR-gate generated-project typecheck (M/High); `check-types` in every package (S/High); schema↔template coverage test (M/High); GitHub Actions CI addon (M/High); vector DB category (M/High); decide .NET (implement to parity or prune schema) (L or S/High); in-code API-drift guard (M/Med-High); generalize auth0/kinde/workos/clerk beyond Next-only (M/Med-High); declarative tool-option manifest to collapse the 15–29-file add-an-option checklist (L/Med-High). + +## Appendix C — Web platform & builder + +**Current state:** TanStack Start app. Builder (`components/stack-builder/stack-builder.tsx`, ~3430 lines) serves `/new`, `/stack`, `/$stackShare`; 4 view tabs (command/preview/presets/saved); URL state via `stack-url-state.ts`; client-side preview via `@better-fullstack/template-generator/browser`. Saved stacks are localStorage-only. Clean share slugs only for the 8 default stacks. `compare.tsx` is a static competitor table. Convex backends (`packages/backend/convex`, `apps/analytics/convex`) hold analytics/showcase/testimonials/videos/tweets. SEO: llms.txt + sitemap + JSON-LD; 1 blog post. + +**Gaps:** +- No dynamic OG images (`$stackShare.tsx`/`stack.tsx`/`new.tsx` set no `og:image`). +- No short links for custom stacks; no `shares` Convex table. +- Rich analytics captured but surfaced nowhere (`analytics.getStats()`; full dashboard built but unrouted). +- Showcase gallery built but unrouted (`showcase-page.tsx`). +- No search across 677 options in builder; 12 categories collapsed by default. +- Preview is a dead-end (no ZIP / Open-in-StackBlitz / WebContainer). +- No accounts/personalization; web builder is not instrumented. +- `.NET` under-reported: `project-stats.ts` hardcodes `ECOSYSTEM_COUNT = 7`, propagates to compare/llms.txt/JSON-LD. +- Thin blog, no RSS; pretty stack landing pages absent from sitemap. + +**Top recommendations:** dynamic OG images (M/High); public "Popular/Trending" page from existing data (M/High); ⌘K option search (M/High); short links + `shares` table (M/High); fix .NET count (S/Med); wire `/showcase` (S–M/Med-High); instrument the builder (S–M/Med); preview ZIP + StackBlitz (M–L/High); AI builder assistant (L/High); RSS + extend search to guides/blog (S/Med). + +## Appendix D — MCP server & AI integration + +**Current state:** stdio-only server (`apps/cli/src/mcp.ts`, ~1348 lines). 7 tools (`bfs_get_guidance`/`get_schema`/`check_compatibility`/`plan_project`/`create_project`/`plan_addition`/`add_feature`), all returning JSON-in-text (no `structuredContent`/`outputSchema`/annotations). 3 resources (`docs://compatibility-rules`/`stack-options`/`getting-started`). No MCP prompts. `packages/create-bfs` is a pure alias. Install is local stdio across 8 agent presets. + +**Gaps / bugs:** +- `bfs_create_project` hardcodes `process.cwd()` (`mcp.ts:1123`), no `targetDir`. +- Create surface narrower than CLI: omits `astroIntegration` (latent Astro bug — hardcoded `"none"` at `:628`), `aiDocs` (hardcoded `["claude-md"]` at `:637` — agents can't request `agents-md`), `analytics`/`effect`/`versionChannel`/`shadcn*`/`verify`/`part`. +- Throws away structured compat signal — uses `analyzeStackCompatibility` strings, ignores `evaluateCompatibility()` (`packages/types/src/compatibility.ts`) which already returns `code`/`optionId`/`suggestions`. +- Brownfield is addon-only + low-fidelity (plan and apply are different code paths; can overwrite files silently). +- No preset discovery; no NL→stack recommendation; thin per-option metadata (only `auth` has descriptions); token-heavy `plan_project` (full file list every call). + +**Top recommendations:** expose missing create fields incl. aiDocs/astroIntegration (S/High); `targetDir` input (S/High); structured outputs + annotations (S–M/High); surface `evaluateCompatibility` issues+suggestions (S–M/High); `bfs_list_presets` (S–M/High); token-efficient `plan_project` (S/Med); brownfield safety + plan/apply fidelity (S–M/High); `bfs_recommend_stack` (M–L/High); `bfs_explain_option` + enrich metadata (M/Med-High); register MCP prompts (S/Med); hosted/remote MCP + registry listing (M–L/Med-High); expand `add_feature` to non-addon categories (L/High). + +## Appendix E — Quality, testing, CI & release + +**Current state:** 5 test layers, only smoke (`testing/smoke-test.ts` + `testing/lib/verify.ts`) actually scaffolds + installs + builds/typechecks; unit/snapshot/parity layers never compile. E2E (`apps/cli/test/e2e/e2e.e2e.ts`) is TS-only, 9 configs. Combo sampling = weighted random (seeded), deduped vs a 316-row ledger. Compatibility engine = 3,733 lines / ~471 conditionals vs **56** example tests. CI on PR runs static release-guard + lint + unit + web build + smoke-strict-core/broad; the real install+dev-server `e2e-runtime` job is schedule-only. Release (`release.yaml`) runs only the static `test:release` lane before publish. No observability/dashboard/roll-up. + +**Risks (file-cited):** +1. CI pass/fail gated by regex classification, not step success (`smoke-test.ts:489-492`, `verify.ts:47-95`). Highest risk. +2. ~29/37 generated `package.json.hbs` lack `check-types`; verifier skips silently (`verify.ts:308-320`). +3. Releases not gated on generated-project health (`release.yaml:101-103`). +4. No rollback for stable releases (only canary can deprecate). +5. Compatibility engine under-tested (no property/idempotence/fuzz). +6. API-shape/literal drift never validated (stripe `apiVersion` at `templates/payments/stripe/server/base/src/lib/stripe.ts.hbs:5`). +7. Sampler sparse; `dotnet` excluded from nightly (`smoke-test.ts:27-35`); preset source-of-truth drift (`template-matrix.yaml` hardcodes 17 vs `presets.ts` groups). +8. Dep PRs auto-accept snapshots (`dep-freshness.yaml:67`, `deps-check.yaml:126`), masking regressions. +9. `upstream-gap.yml` is `workflow_dispatch`-only despite docs claiming a Monday cron. +10. Flaky timeouts self-mask (forced to `environment` → non-gating); graph/multi-ecosystem mode has zero compile coverage. + +**Top recommendations:** fix the smoke gating hole (S/High); unify preset source-of-truth (S/Med); gate releases on real smoke (S–M/High); mandatory `check-types` everywhere (M/High); health roll-up (S–M/High); property-test compat engine (M/High); coverage-guided sampler + add dotnet + raise nightly count (M/High); API-literal drift detection (M/Med-High); stop auto-accepting snapshots in dep PRs (S/Med); wire upstream-gap cron (S/Low-Med); flaky-rate metric (M/Med); route-check in nightly pr-core (M/Med); graph compile coverage (M/Med); local pre-push static gate (S/Low). + +## Appendix F — Competitive & market + +**Landscape (June 2026):** +- **create-better-t-stack** (upstream sibling): v3.33.x, ~457 releases, ~5.5k stars, TS-only but deep; live `/analytics` + `/showcase`, 19 paying sponsors. BFS is far broader but BTS crushes it on release velocity, mindshare, community/growth infra. +- **create-t3-app**: stalled (~28.6k stars); explicitly no add-to-existing / no upgrade. +- **create-next-app** v16.2: AI-agent-first — ships `AGENTS.md` + `CLAUDE.md` by default, bundles version-matched docs for agents. +- **create-vite** (Vite 8 / Rolldown): pure framework starters; no lifecycle tooling. +- **create-astro / `astro add`**: gold-standard config-rewriting integration installer on existing projects; `--template `; `@astrojs/upgrade`. +- **`sv` (Svelte CLI)**: `sv add` (better-auth/drizzle/mcp/paraglide), `sv migrate`. +- **Nx**: `nx migrate` is the industry gold standard (ordered reviewable codemods + Migrate UI + agentic migrations); Nx MCP server; self-healing CI. +- **Turborepo**: `turbo gen` custom generators. +- **RedwoodJS**: sunset/split → CedarJS fork + RedwoodSDK (Cloudflare-only). +- **Wasp / OpenSaaS**: TS spec + Mage (AI app-from-prompt); OpenSaaS = batteries-included SaaS starter shipping AGENTS.md + agent skills. +- **Epic Stack / Bulletproof React**: opinionated templates/guides — Bulletproof's ~31.9k stars prove devs value conventions/docs as much as scaffolds. +- **AI app builders** (Lovable ~$400M ARR, v0 Platform API, bolt, Replit Agent, Cursor, Claude Code): generate full apps from prompts — but 45–80% ship vulnerable, and hit an iteration wall / React-only lock-in. + +**Feature gaps to close:** automated project upgrade/migration (codemods); `add` that installs full integrations into existing projects; `AGENTS.md` as default; published Agent Skill / Claude Code plugin; golden-path SaaS-in-a-box templates; live showcase; public analytics; `--template `; one-command deploy + "Open in" handoffs; sponsor monetization engine. + +**Differentiation / trend bets:** double down on 8-ecosystem breadth + polyglot monorepo (`--part`); scaffolder-as-MCP with a compatibility engine; "deterministic golden-path layer for AI agents"; secure-by-default/verified-scaffold positioning; edge/serverless-first defaults; conventions/docs as product; TS-native config (`bts.jsonc` → optional typed `bfs.config.ts`). + +**Top recommendations:** default AGENTS.md (S/High); ship `/showcase` (S/High); publish Agent Skill (S–M/High); public `/analytics` + trending (S–M/High); programmatic per-combo SEO pages (M/High); reframe positioning + fix stat inconsistencies + expand `/compare` (S/High); expand `add` to full integrations (M–L/High); `bfs upgrade` migration engine (L/High); golden-path complete-app templates (M–L/High); verified-scaffold guarantee/badge (S–M/Med-High); deploy + "Open in" buttons (M/Med); `--template ` (M/Med); sponsor engine (S–M/Med); edge-first preset + changelog page (M/Med). From 0cdfbf60160dd55f087b5ec87be19e8dd92f3d28 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 15:27:32 +0300 Subject: [PATCH 05/36] fix(web): derive ecosystem count/names from schema (include .NET = 8) ECOSYSTEM_COUNT was hardcoded to 7 and ECOSYSTEM_NAMES omitted .NET, so the marketing/SEO surface (compare page, llms.txt, SoftwareApplication JSON-LD) under-reported the product as 7 ecosystems. Now derived from the canonical EcosystemSchema via a typed display-name Record, so the count and names can never drift from the schema again. --- apps/web/src/lib/project-stats.ts | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/web/src/lib/project-stats.ts b/apps/web/src/lib/project-stats.ts index c3f4e2073..478a41cd6 100644 --- a/apps/web/src/lib/project-stats.ts +++ b/apps/web/src/lib/project-stats.ts @@ -1,21 +1,28 @@ -import { OPTION_CATEGORY_METADATA } from "@better-fullstack/types"; +import { ECOSYSTEM_VALUES, OPTION_CATEGORY_METADATA } from "@better-fullstack/types"; export const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce( (sum, metadata) => sum + metadata.options.length, 0, ); export const CATEGORY_COUNT = Object.keys(OPTION_CATEGORY_METADATA).length; -const ECOSYSTEM_COUNT = 7; -export const ECOSYSTEM_NAMES = [ - "TypeScript", - "React Native", - "Rust", - "Python", - "Go", - "Java", - "Elixir", -] as const; + +// Display names keyed by the canonical EcosystemSchema values. Using a typed +// Record means adding an ecosystem to the schema fails the build here until a +// label is provided, so the count and names can never silently drift again +// (previously this was a hardcoded `7` that omitted .NET). +const ECOSYSTEM_DISPLAY_NAMES: Record<(typeof ECOSYSTEM_VALUES)[number], string> = { + typescript: "TypeScript", + "react-native": "React Native", + rust: "Rust", + python: "Python", + go: "Go", + java: "Java", + elixir: "Elixir", + dotnet: ".NET", +}; + +export const ECOSYSTEM_NAMES = ECOSYSTEM_VALUES.map((value) => ECOSYSTEM_DISPLAY_NAMES[value]); export const OPTION_COUNT_LABEL = `${OPTION_ENTRY_COUNT}`; -export const ECOSYSTEM_COUNT_LABEL = `${ECOSYSTEM_COUNT}`; +export const ECOSYSTEM_COUNT_LABEL = `${ECOSYSTEM_NAMES.length}`; From 53390292f35465a7ca5d59eb839698e1a2fb057b Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 15:43:02 +0300 Subject: [PATCH 06/36] feat(ai-docs): generate AGENTS.md by default with the standard uppercase filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds agents-md to the default aiDocs selection (alongside claude-md) so every scaffold ships both CLAUDE.md and AGENTS.md by default — matching create-next-app and the cross-tool AGENTS.md standard read by Codex/Cursor/Copilot/etc. Also fixes the generated filename from non-standard 'Agents.md' to 'AGENTS.md' (matters on case-sensitive filesystems), and syncs the display labels plus the web builder default (DEFAULT_STACK_SELECTION) so CLI/web --yes parity holds. --- apps/cli/src/prompts/ai-docs.ts | 2 +- apps/web/src/lib/constant.ts | 2 +- .../template-generator/src/processors/ai-docs-generator.ts | 4 ++-- packages/types/src/defaults.ts | 4 +++- packages/types/src/option-metadata.ts | 2 +- packages/types/src/schemas.ts | 2 +- packages/types/src/stack-translation.ts | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/prompts/ai-docs.ts b/apps/cli/src/prompts/ai-docs.ts index 22c268719..463958863 100644 --- a/apps/cli/src/prompts/ai-docs.ts +++ b/apps/cli/src/prompts/ai-docs.ts @@ -12,7 +12,7 @@ const AI_DOCS_OPTIONS = [ }, { value: "agents-md" as const, - label: "Agents.md", + label: "AGENTS.md", hint: "Generic AI assistant documentation", }, { diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index be0604fd5..bb337e37b 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -2958,7 +2958,7 @@ export const TECH_OPTIONS: Record< }, { id: "agents-md", - name: "Agents.md", + name: "AGENTS.md", description: "Generic AI assistant docs", icon: "", color: "from-purple-400 to-purple-600", diff --git a/packages/template-generator/src/processors/ai-docs-generator.ts b/packages/template-generator/src/processors/ai-docs-generator.ts index 3608d1eb0..35749bb0b 100644 --- a/packages/template-generator/src/processors/ai-docs-generator.ts +++ b/packages/template-generator/src/processors/ai-docs-generator.ts @@ -34,7 +34,7 @@ function getFilename(docType: AiDocs): string { case "claude-md": return "CLAUDE.md"; case "agents-md": - return "Agents.md"; + return "AGENTS.md"; case "cursorrules": return ".cursorrules"; default: @@ -427,7 +427,7 @@ function generateCommandsSection(config: ProjectConfig): string { function generateMaintenanceSection(docType: AiDocs): string { const fileName = - docType === "claude-md" ? "CLAUDE.md" : docType === "agents-md" ? "Agents.md" : "this file"; + docType === "claude-md" ? "CLAUDE.md" : docType === "agents-md" ? "AGENTS.md" : "this file"; return ` ## Maintenance diff --git a/packages/types/src/defaults.ts b/packages/types/src/defaults.ts index 68e89f8a3..0f4226bd7 100644 --- a/packages/types/src/defaults.ts +++ b/packages/types/src/defaults.ts @@ -139,6 +139,8 @@ export function createCliDefaultProjectConfigBase( elixirQuality: "credo", elixirDeploy: "none", elixirLibraries: [], - aiDocs: ["claude-md"], + // Ship both CLAUDE.md (Claude Code) and AGENTS.md (the cross-tool standard, + // read by Codex/Cursor/Copilot/etc.) by default, matching create-next-app. + aiDocs: ["claude-md", "agents-md"], }; } diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index 1e41bb055..8406746a3 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -1108,7 +1108,7 @@ const EXACT_LABEL_OVERRIDES: Partial Date: Thu, 18 Jun 2026 15:51:06 +0300 Subject: [PATCH 07/36] feat(web): add /showcase route for the community project gallery Wires up the built-but-unrouted ShowcasePage (components/showcase). A server-side loader fetches projects via Convex's lightweight HTTP client so the reactive Convex React SDK stays out of the client entry chunk, and degrades to an empty gallery when Convex is unconfigured/unreachable. Adds /showcase to the sitemap. --- apps/web/src/lib/sitemap-core.ts | 1 + apps/web/src/routeTree.gen.ts | 21 +++++++++ apps/web/src/routes/showcase.tsx | 78 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 apps/web/src/routes/showcase.tsx diff --git a/apps/web/src/lib/sitemap-core.ts b/apps/web/src/lib/sitemap-core.ts index ef76aa013..219b2aa0f 100644 --- a/apps/web/src/lib/sitemap-core.ts +++ b/apps/web/src/lib/sitemap-core.ts @@ -19,6 +19,7 @@ const staticSitemapEntries: SitemapEntry[] = [ { path: "/new", changefreq: "daily", priority: 0.9 }, { path: "/compare", changefreq: "weekly", priority: 0.8 }, { path: "/mcp", changefreq: "weekly", priority: 0.7 }, + { path: "/showcase", changefreq: "weekly", priority: 0.7 }, ]; function escapeXml(value: string) { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 94b900c77..2bff9997e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as StackRouteImport } from './routes/stack' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' +import { Route as ShowcaseRouteImport } from './routes/showcase' import { Route as NewRouteImport } from './routes/new' import { Route as McpRouteImport } from './routes/mcp' import { Route as LlmsDottxtRouteImport } from './routes/llms[.]txt' @@ -36,6 +37,11 @@ const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ path: '/sitemap.xml', getParentRoute: () => rootRouteImport, } as any) +const ShowcaseRoute = ShowcaseRouteImport.update({ + id: '/showcase', + path: '/showcase', + getParentRoute: () => rootRouteImport, +} as any) const NewRoute = NewRouteImport.update({ id: '/new', path: '/new', @@ -114,6 +120,7 @@ export interface FileRoutesByFullPath { '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute '/new': typeof NewRoute + '/showcase': typeof ShowcaseRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/stack': typeof StackRoute '/api/preview': typeof ApiPreviewRoute @@ -132,6 +139,7 @@ export interface FileRoutesByTo { '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute '/new': typeof NewRoute + '/showcase': typeof ShowcaseRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/stack': typeof StackRoute '/api/preview': typeof ApiPreviewRoute @@ -151,6 +159,7 @@ export interface FileRoutesById { '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute '/new': typeof NewRoute + '/showcase': typeof ShowcaseRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/stack': typeof StackRoute '/api/preview': typeof ApiPreviewRoute @@ -171,6 +180,7 @@ export interface FileRouteTypes { | '/llms.txt' | '/mcp' | '/new' + | '/showcase' | '/sitemap.xml' | '/stack' | '/api/preview' @@ -189,6 +199,7 @@ export interface FileRouteTypes { | '/llms.txt' | '/mcp' | '/new' + | '/showcase' | '/sitemap.xml' | '/stack' | '/api/preview' @@ -207,6 +218,7 @@ export interface FileRouteTypes { | '/llms.txt' | '/mcp' | '/new' + | '/showcase' | '/sitemap.xml' | '/stack' | '/api/preview' @@ -226,6 +238,7 @@ export interface RootRouteChildren { LlmsDottxtRoute: typeof LlmsDottxtRoute McpRoute: typeof McpRoute NewRoute: typeof NewRoute + ShowcaseRoute: typeof ShowcaseRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute StackRoute: typeof StackRoute ApiPreviewRoute: typeof ApiPreviewRoute @@ -254,6 +267,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SitemapDotxmlRouteImport parentRoute: typeof rootRouteImport } + '/showcase': { + id: '/showcase' + path: '/showcase' + fullPath: '/showcase' + preLoaderRoute: typeof ShowcaseRouteImport + parentRoute: typeof rootRouteImport + } '/new': { id: '/new' path: '/new' @@ -362,6 +382,7 @@ const rootRouteChildren: RootRouteChildren = { LlmsDottxtRoute: LlmsDottxtRoute, McpRoute: McpRoute, NewRoute: NewRoute, + ShowcaseRoute: ShowcaseRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, StackRoute: StackRoute, ApiPreviewRoute: ApiPreviewRoute, diff --git a/apps/web/src/routes/showcase.tsx b/apps/web/src/routes/showcase.tsx new file mode 100644 index 000000000..39b009f79 --- /dev/null +++ b/apps/web/src/routes/showcase.tsx @@ -0,0 +1,78 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import ShowcasePage from "@/components/showcase/showcase-page"; +import { + DEFAULT_OG_IMAGE_ALT, + DEFAULT_OG_IMAGE_HEIGHT, + DEFAULT_OG_IMAGE_URL, + DEFAULT_OG_IMAGE_WIDTH, + DEFAULT_ROBOTS, + DEFAULT_X_IMAGE_URL, + canonicalUrl, +} from "@/lib/seo"; + +type ShowcaseProject = { + _id: string; + _creationTime: number; + title: string; + description: string; + imageUrl: string; + liveUrl: string; + tags: string[]; +}; + +// Fetch showcase projects server-side via Convex's lightweight HTTP client so the +// reactive Convex React SDK never enters the client entry chunk. Degrades to an +// empty gallery when Convex is not configured or unreachable. +async function loadShowcaseProjects(): Promise { + const convexUrl = import.meta.env.VITE_CONVEX_URL; + if (!convexUrl) return []; + + try { + const [{ ConvexHttpClient }, { api }] = await Promise.all([ + import("convex/browser"), + import("@better-fullstack/backend/convex/_generated/api"), + ]); + const client = new ConvexHttpClient(convexUrl); + return await client.query(api.showcase.getShowcaseProjects, {}); + } catch { + return []; + } +} + +export const Route = createFileRoute("/showcase")({ + head: () => { + const title = "Showcase — Better Fullstack"; + const description = + "Real projects built with Better Fullstack. See what the community is shipping and get inspired for your next stack."; + + return { + meta: [ + { title }, + { name: "description", content: description }, + { name: "robots", content: DEFAULT_ROBOTS }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:type", content: "website" }, + { property: "og:url", content: canonicalUrl("/showcase") }, + { property: "og:image", content: DEFAULT_OG_IMAGE_URL }, + { property: "og:image:alt", content: DEFAULT_OG_IMAGE_ALT }, + { property: "og:image:width", content: String(DEFAULT_OG_IMAGE_WIDTH) }, + { property: "og:image:height", content: String(DEFAULT_OG_IMAGE_HEIGHT) }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: DEFAULT_X_IMAGE_URL }, + { name: "twitter:image:alt", content: DEFAULT_OG_IMAGE_ALT }, + ], + links: [{ rel: "canonical", href: canonicalUrl("/showcase") }], + }; + }, + loader: async () => ({ showcaseProjects: await loadShowcaseProjects() }), + component: ShowcaseRoute, +}); + +function ShowcaseRoute() { + const { showcaseProjects } = Route.useLoaderData(); + return ; +} From db608a93da804d2e71fb390e6811598b879ca804 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 16:09:13 +0300 Subject: [PATCH 08/36] feat(web): add /analytics popular-stacks dashboard + nav links Adds a public /analytics route that aggregates the Convex telemetry (getStats + getDailyStats) into the dashboard's data shape and renders the existing prop-driven chart sections (metrics, timeline, stack-configuration, dev-tools). Uses a server-side loader via Convex's HTTP client (no reactive Convex SDK in the client bundle) and degrades to an empty dashboard when Convex is unconfigured; the live event feed is intentionally left for a follow-up. Adds Showcase + Analytics entries to the nav and /analytics to the sitemap. --- apps/web/messages/en.json | 2 + apps/web/src/components/navbar.tsx | 12 ++ apps/web/src/lib/analytics-aggregate.ts | 172 ++++++++++++++++++ apps/web/src/lib/sitemap-core.ts | 1 + apps/web/src/paraglide/messages/_index.js | 2 + .../src/paraglide/messages/navanalytics1.js | 53 ++++++ .../src/paraglide/messages/navshowcase1.js | 53 ++++++ apps/web/src/routeTree.gen.ts | 21 +++ apps/web/src/routes/analytics.tsx | 112 ++++++++++++ 9 files changed, 428 insertions(+) create mode 100644 apps/web/src/lib/analytics-aggregate.ts create mode 100644 apps/web/src/paraglide/messages/navanalytics1.js create mode 100644 apps/web/src/paraglide/messages/navshowcase1.js create mode 100644 apps/web/src/routes/analytics.tsx diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f6059d672..f5585a699 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -21,6 +21,8 @@ "navBlog": "Blog", "navMcp": "MCP", "navSkill": "Skill", + "navShowcase": "Showcase", + "navAnalytics": "Analytics", "navTryNow": "Try now", "navCopy": "Copy", "navCopied": "Copied", diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 5dae0b644..01d80d0fb 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -173,6 +173,18 @@ function DocsMenuItems() { > {m.navSkill()} + } + className="cursor-pointer font-mono text-[11px] uppercase tracking-[0.18em]" + > + {m.navShowcase()} + + } + className="cursor-pointer font-mono text-[11px] uppercase tracking-[0.18em]" + > + {m.navAnalytics()} + ); } diff --git a/apps/web/src/lib/analytics-aggregate.ts b/apps/web/src/lib/analytics-aggregate.ts new file mode 100644 index 000000000..049b4de67 --- /dev/null +++ b/apps/web/src/lib/analytics-aggregate.ts @@ -0,0 +1,172 @@ +import type { + AggregatedAnalyticsData, + Distribution, + HourlyData, + MonthlyData, + TimeSeriesData, + VersionDistribution, +} from "@/components/analytics/types"; + +type Dist = Record; + +// The subset of the Convex `getStats` result that drives the analytics dashboard. +// `getStats` returns each distribution as a `Record` (a count map); +// the dashboard components expect sorted `{ name, value }` arrays plus a computed +// summary, which is what `buildAggregatedAnalyticsData` produces. +export type RawAnalyticsStats = { + totalProjects: number; + lastEventTime?: number; + frontend: Dist; + backend: Dist; + database: Dist; + orm: Dist; + api: Dist; + auth: Dist; + runtime: Dist; + packageManager: Dist; + platform: Dist; + dbSetup: Dist; + addons: Dist; + examples: Dist; + git: Dist; + install: Dist; + webDeploy: Dist; + serverDeploy: Dist; + payments: Dist; + nodeVersion: Dist; + cliVersion: Dist; + hourlyDistribution: Dist; + stackCombinations: Dist; + dbOrmCombinations: Dist; +}; + +function toDistribution(record: Dist): Distribution { + return Object.entries(record) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); +} + +function toVersionDistribution(record: Dist): VersionDistribution { + return Object.entries(record) + .map(([version, count]) => ({ version, count })) + .sort((a, b) => b.count - a.count); +} + +function toHourly(record: Dist): HourlyData { + return Object.entries(record) + .map(([hour, count]) => ({ hour, count })) + .sort((a, b) => a.hour.localeCompare(b.hour)); +} + +function toMonthly(daily: TimeSeriesData): MonthlyData { + const byMonth = new Map(); + for (const { date, count } of daily) { + const month = date.slice(0, 7); // YYYY-MM + byMonth.set(month, (byMonth.get(month) ?? 0) + count); + } + return Array.from(byMonth, ([month, count]) => ({ month, count })).sort((a, b) => + a.month.localeCompare(b.month), + ); +} + +function topName(dist: Distribution): string { + return dist[0]?.name ?? "-"; +} + +export const EMPTY_ANALYTICS_DATA: AggregatedAnalyticsData = { + lastUpdated: null, + totalProjects: 0, + avgProjectsPerDay: 0, + timeSeries: [], + monthlyTimeSeries: [], + hourlyDistribution: [], + platformDistribution: [], + packageManagerDistribution: [], + backendDistribution: [], + databaseDistribution: [], + ormDistribution: [], + dbSetupDistribution: [], + apiDistribution: [], + frontendDistribution: [], + authDistribution: [], + runtimeDistribution: [], + addonsDistribution: [], + examplesDistribution: [], + gitDistribution: [], + installDistribution: [], + webDeployDistribution: [], + serverDeployDistribution: [], + paymentsDistribution: [], + nodeVersionDistribution: [], + cliVersionDistribution: [], + popularStackCombinations: [], + databaseORMCombinations: [], + summary: { + mostPopularFrontend: "-", + mostPopularBackend: "-", + mostPopularDatabase: "-", + mostPopularORM: "-", + mostPopularAPI: "-", + mostPopularAuth: "-", + mostPopularPackageManager: "-", + mostPopularRuntime: "-", + }, +}; + +export function buildAggregatedAnalyticsData( + stats: RawAnalyticsStats, + daily: TimeSeriesData, +): AggregatedAnalyticsData { + const frontendDistribution = toDistribution(stats.frontend); + const backendDistribution = toDistribution(stats.backend); + const databaseDistribution = toDistribution(stats.database); + const ormDistribution = toDistribution(stats.orm); + const apiDistribution = toDistribution(stats.api); + const authDistribution = toDistribution(stats.auth); + const runtimeDistribution = toDistribution(stats.runtime); + const packageManagerDistribution = toDistribution(stats.packageManager); + + const totalDailyCount = daily.reduce((sum, day) => sum + day.count, 0); + const avgProjectsPerDay = + daily.length > 0 ? Math.round((totalDailyCount / daily.length) * 10) / 10 : 0; + + return { + lastUpdated: stats.lastEventTime ? new Date(stats.lastEventTime).toISOString() : null, + totalProjects: stats.totalProjects, + avgProjectsPerDay, + timeSeries: daily, + monthlyTimeSeries: toMonthly(daily), + hourlyDistribution: toHourly(stats.hourlyDistribution), + platformDistribution: toDistribution(stats.platform), + packageManagerDistribution, + backendDistribution, + databaseDistribution, + ormDistribution, + dbSetupDistribution: toDistribution(stats.dbSetup), + apiDistribution, + frontendDistribution, + authDistribution, + runtimeDistribution, + addonsDistribution: toDistribution(stats.addons), + examplesDistribution: toDistribution(stats.examples), + gitDistribution: toDistribution(stats.git), + installDistribution: toDistribution(stats.install), + webDeployDistribution: toDistribution(stats.webDeploy), + serverDeployDistribution: toDistribution(stats.serverDeploy), + paymentsDistribution: toDistribution(stats.payments), + nodeVersionDistribution: toVersionDistribution(stats.nodeVersion), + cliVersionDistribution: toVersionDistribution(stats.cliVersion), + popularStackCombinations: toDistribution(stats.stackCombinations), + databaseORMCombinations: toDistribution(stats.dbOrmCombinations), + summary: { + mostPopularFrontend: topName(frontendDistribution), + mostPopularBackend: topName(backendDistribution), + mostPopularDatabase: topName(databaseDistribution), + mostPopularORM: topName(ormDistribution), + mostPopularAPI: topName(apiDistribution), + mostPopularAuth: topName(authDistribution), + mostPopularPackageManager: topName(packageManagerDistribution), + mostPopularRuntime: topName(runtimeDistribution), + }, + }; +} diff --git a/apps/web/src/lib/sitemap-core.ts b/apps/web/src/lib/sitemap-core.ts index 219b2aa0f..a52c3249c 100644 --- a/apps/web/src/lib/sitemap-core.ts +++ b/apps/web/src/lib/sitemap-core.ts @@ -20,6 +20,7 @@ const staticSitemapEntries: SitemapEntry[] = [ { path: "/compare", changefreq: "weekly", priority: 0.8 }, { path: "/mcp", changefreq: "weekly", priority: 0.7 }, { path: "/showcase", changefreq: "weekly", priority: 0.7 }, + { path: "/analytics", changefreq: "daily", priority: 0.7 }, ]; function escapeXml(value: string) { diff --git a/apps/web/src/paraglide/messages/_index.js b/apps/web/src/paraglide/messages/_index.js index ea0da6043..2ab5389fd 100644 --- a/apps/web/src/paraglide/messages/_index.js +++ b/apps/web/src/paraglide/messages/_index.js @@ -22,6 +22,8 @@ export * from './navcompare1.js' export * from './navblog1.js' export * from './navmcp1.js' export * from './navskill1.js' +export * from './navshowcase1.js' +export * from './navanalytics1.js' export * from './navtrynow2.js' export * from './navcopy1.js' export * from './navcopied1.js' diff --git a/apps/web/src/paraglide/messages/navanalytics1.js b/apps/web/src/paraglide/messages/navanalytics1.js new file mode 100644 index 000000000..f771393a6 --- /dev/null +++ b/apps/web/src/paraglide/messages/navanalytics1.js @@ -0,0 +1,53 @@ +/* eslint-disable */ +import { getLocale, experimentalStaticLocale } from '../runtime.js'; + +/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ + +/** @typedef {{}} Navanalytics1Inputs */ + +const en_navanalytics1 = /** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Analytics`) +}; + +/** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ +const es_navanalytics1 = en_navanalytics1; + +/** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ +const zh_navanalytics1 = en_navanalytics1; + +/** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ +const ja_navanalytics1 = en_navanalytics1; + +/** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ +const ko_navanalytics1 = en_navanalytics1; + +/** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ +const zh_hant1_navanalytics1 = zh_navanalytics1; + +/** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ +const de_navanalytics1 = en_navanalytics1; + +/** @type {(inputs: Navanalytics1Inputs) => LocalizedString} */ +const fr_navanalytics1 = en_navanalytics1; + +/** +* | output | +* | --- | +* | "Analytics" | +* +* @param {Navanalytics1Inputs} inputs +* @param {{ locale?: "en" | "es" | "zh" | "ja" | "ko" | "zh-Hant" | "de" | "fr" }} options +* @returns {LocalizedString} +*/ +const navanalytics1 = /** @type {((inputs?: Navanalytics1Inputs, options?: { locale?: "en" | "es" | "zh" | "ja" | "ko" | "zh-Hant" | "de" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { + const locale = experimentalStaticLocale ?? options.locale ?? getLocale() + if (locale === "en") return en_navanalytics1(inputs) + if (locale === "es") return es_navanalytics1(inputs) + if (locale === "zh") return zh_navanalytics1(inputs) + if (locale === "ja") return ja_navanalytics1(inputs) + if (locale === "ko") return ko_navanalytics1(inputs) + if (locale === "zh-Hant") return zh_hant1_navanalytics1(inputs) + if (locale === "de") return de_navanalytics1(inputs) + return fr_navanalytics1(inputs) +}); +export { navanalytics1 as "navAnalytics" } \ No newline at end of file diff --git a/apps/web/src/paraglide/messages/navshowcase1.js b/apps/web/src/paraglide/messages/navshowcase1.js new file mode 100644 index 000000000..c5dbe36e6 --- /dev/null +++ b/apps/web/src/paraglide/messages/navshowcase1.js @@ -0,0 +1,53 @@ +/* eslint-disable */ +import { getLocale, experimentalStaticLocale } from '../runtime.js'; + +/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ + +/** @typedef {{}} Navshowcase1Inputs */ + +const en_navshowcase1 = /** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Showcase`) +}; + +/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ +const es_navshowcase1 = en_navshowcase1; + +/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ +const zh_navshowcase1 = en_navshowcase1; + +/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ +const ja_navshowcase1 = en_navshowcase1; + +/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ +const ko_navshowcase1 = en_navshowcase1; + +/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ +const zh_hant1_navshowcase1 = zh_navshowcase1; + +/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ +const de_navshowcase1 = en_navshowcase1; + +/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ +const fr_navshowcase1 = en_navshowcase1; + +/** +* | output | +* | --- | +* | "Showcase" | +* +* @param {Navshowcase1Inputs} inputs +* @param {{ locale?: "en" | "es" | "zh" | "ja" | "ko" | "zh-Hant" | "de" | "fr" }} options +* @returns {LocalizedString} +*/ +const navshowcase1 = /** @type {((inputs?: Navshowcase1Inputs, options?: { locale?: "en" | "es" | "zh" | "ja" | "ko" | "zh-Hant" | "de" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { + const locale = experimentalStaticLocale ?? options.locale ?? getLocale() + if (locale === "en") return en_navshowcase1(inputs) + if (locale === "es") return es_navshowcase1(inputs) + if (locale === "zh") return zh_navshowcase1(inputs) + if (locale === "ja") return ja_navshowcase1(inputs) + if (locale === "ko") return ko_navshowcase1(inputs) + if (locale === "zh-Hant") return zh_hant1_navshowcase1(inputs) + if (locale === "de") return de_navshowcase1(inputs) + return fr_navshowcase1(inputs) +}); +export { navshowcase1 as "navShowcase" } \ No newline at end of file diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2bff9997e..c391202f1 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as NewRouteImport } from './routes/new' import { Route as McpRouteImport } from './routes/mcp' import { Route as LlmsDottxtRouteImport } from './routes/llms[.]txt' import { Route as CompareRouteImport } from './routes/compare' +import { Route as AnalyticsRouteImport } from './routes/analytics' import { Route as StackShareRouteImport } from './routes/$stackShare' import { Route as IndexRouteImport } from './routes/index' import { Route as GuidesIndexRouteImport } from './routes/guides/index' @@ -62,6 +63,11 @@ const CompareRoute = CompareRouteImport.update({ path: '/compare', getParentRoute: () => rootRouteImport, } as any) +const AnalyticsRoute = AnalyticsRouteImport.update({ + id: '/analytics', + path: '/analytics', + getParentRoute: () => rootRouteImport, +} as any) const StackShareRoute = StackShareRouteImport.update({ id: '/$stackShare', path: '/$stackShare', @@ -116,6 +122,7 @@ const ApiPreviewRoute = ApiPreviewRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$stackShare': typeof StackShareRoute + '/analytics': typeof AnalyticsRoute '/compare': typeof CompareRoute '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute @@ -135,6 +142,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/$stackShare': typeof StackShareRoute + '/analytics': typeof AnalyticsRoute '/compare': typeof CompareRoute '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute @@ -155,6 +163,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/$stackShare': typeof StackShareRoute + '/analytics': typeof AnalyticsRoute '/compare': typeof CompareRoute '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute @@ -176,6 +185,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/$stackShare' + | '/analytics' | '/compare' | '/llms.txt' | '/mcp' @@ -195,6 +205,7 @@ export interface FileRouteTypes { to: | '/' | '/$stackShare' + | '/analytics' | '/compare' | '/llms.txt' | '/mcp' @@ -214,6 +225,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/$stackShare' + | '/analytics' | '/compare' | '/llms.txt' | '/mcp' @@ -234,6 +246,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute StackShareRoute: typeof StackShareRoute + AnalyticsRoute: typeof AnalyticsRoute CompareRoute: typeof CompareRoute LlmsDottxtRoute: typeof LlmsDottxtRoute McpRoute: typeof McpRoute @@ -302,6 +315,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CompareRouteImport parentRoute: typeof rootRouteImport } + '/analytics': { + id: '/analytics' + path: '/analytics' + fullPath: '/analytics' + preLoaderRoute: typeof AnalyticsRouteImport + parentRoute: typeof rootRouteImport + } '/$stackShare': { id: '/$stackShare' path: '/$stackShare' @@ -378,6 +398,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, StackShareRoute: StackShareRoute, + AnalyticsRoute: AnalyticsRoute, CompareRoute: CompareRoute, LlmsDottxtRoute: LlmsDottxtRoute, McpRoute: McpRoute, diff --git a/apps/web/src/routes/analytics.tsx b/apps/web/src/routes/analytics.tsx new file mode 100644 index 000000000..8ee963129 --- /dev/null +++ b/apps/web/src/routes/analytics.tsx @@ -0,0 +1,112 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import type { AggregatedAnalyticsData } from "@/components/analytics/types"; + +import { AnalyticsHeader } from "@/components/analytics/analytics-header"; +import { DevToolsSection } from "@/components/analytics/dev-environment-charts"; +import { MetricsCards } from "@/components/analytics/metrics-cards"; +import { StackSection } from "@/components/analytics/stack-configuration-charts"; +import { TimelineSection } from "@/components/analytics/timeline-charts"; +import Footer from "@/components/home/footer"; +import { + EMPTY_ANALYTICS_DATA, + buildAggregatedAnalyticsData, + type RawAnalyticsStats, +} from "@/lib/analytics-aggregate"; +import { + DEFAULT_OG_IMAGE_ALT, + DEFAULT_OG_IMAGE_HEIGHT, + DEFAULT_OG_IMAGE_URL, + DEFAULT_OG_IMAGE_WIDTH, + DEFAULT_ROBOTS, + DEFAULT_X_IMAGE_URL, + canonicalUrl, +} from "@/lib/seo"; + +// Fetch and aggregate analytics server-side via Convex's lightweight HTTP client +// (no reactive Convex React SDK in the client bundle). The live event feed is +// intentionally omitted here — it needs a client-side subscription and is a +// separate follow-up. Degrades to an empty dashboard when Convex is unconfigured. +async function loadAnalytics(): Promise { + const convexUrl = import.meta.env.VITE_CONVEX_URL; + if (!convexUrl) return EMPTY_ANALYTICS_DATA; + + try { + const [{ ConvexHttpClient }, { api }] = await Promise.all([ + import("convex/browser"), + import("@better-fullstack/backend/convex/_generated/api"), + ]); + const client = new ConvexHttpClient(convexUrl); + const [stats, daily] = await Promise.all([ + client.query(api.analytics.getStats, {}), + client.query(api.analytics.getDailyStats, { days: 30 }), + ]); + if (!stats) return EMPTY_ANALYTICS_DATA; + return buildAggregatedAnalyticsData(stats as RawAnalyticsStats, daily ?? []); + } catch { + return EMPTY_ANALYTICS_DATA; + } +} + +export const Route = createFileRoute("/analytics")({ + head: () => { + const title = "Analytics — Better Fullstack"; + const description = + "See which stacks developers actually pick: the most popular frontends, backends, databases, ORMs, and full stack combinations scaffolded with Better Fullstack."; + + return { + meta: [ + { title }, + { name: "description", content: description }, + { name: "robots", content: DEFAULT_ROBOTS }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:type", content: "website" }, + { property: "og:url", content: canonicalUrl("/analytics") }, + { property: "og:image", content: DEFAULT_OG_IMAGE_URL }, + { property: "og:image:alt", content: DEFAULT_OG_IMAGE_ALT }, + { property: "og:image:width", content: String(DEFAULT_OG_IMAGE_WIDTH) }, + { property: "og:image:height", content: String(DEFAULT_OG_IMAGE_HEIGHT) }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: DEFAULT_X_IMAGE_URL }, + { name: "twitter:image:alt", content: DEFAULT_OG_IMAGE_ALT }, + ], + links: [{ rel: "canonical", href: canonicalUrl("/analytics") }], + }; + }, + loader: async () => ({ data: await loadAnalytics() }), + component: AnalyticsRoute, +}); + +function AnalyticsRoute() { + const { data } = Route.useLoaderData(); + const legacy = useMemo( + () => ({ + total: data.totalProjects, + avgPerDay: data.avgProjectsPerDay, + lastUpdatedIso: data.lastUpdated ?? "", + source: "convex", + }), + [data], + ); + + return ( +
+
+ + + + + +
+
+
+ ); +} From 826c6da2890d9ec0e1623766705187c55b4a0463 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 16:41:28 +0300 Subject: [PATCH 09/36] test(smoke): cover react-native in the per-PR smoke matrix (pr-core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke preset suite had no react-native preset, so Expo was never install+typechecked by the per-PR strict-smoke matrix (e2e-test.yaml's smoke-strict-core runs --preset pr-core on every PR) — only occasionally via nightly random generation — despite heroui-native drift being a recurring RN bug. Adds a native-uniwind-trpc preset to pr-core; combined with the native check-types added in this branch, every PR now type-checks an Expo frontend. Validated end-to-end through the smoke harness (1/1 pass). --- testing/lib/presets.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/testing/lib/presets.ts b/testing/lib/presets.ts index 2d28bfb5e..1e20c82fd 100644 --- a/testing/lib/presets.ts +++ b/testing/lib/presets.ts @@ -649,6 +649,26 @@ const SMOKE_TEST_PRESETS: Record = { elixirDeploy: "mix-release", }, }, + "native-uniwind-trpc": { + ecosystem: "react-native", + overrides: { + frontend: ["native-uniwind"], + backend: "hono", + runtime: "bun", + database: "sqlite", + orm: "drizzle", + auth: "better-auth", + api: "trpc", + packageManager: "bun", + mobileNavigation: "expo-router", + mobileUI: "none", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "none", + }, + }, }; const PRESET_GROUPS = { @@ -663,6 +683,7 @@ const PRESET_GROUPS = { "go-gin-gorm", "java-spring-maven", "elixir-plain-worker", + "native-uniwind-trpc", "frontend-only-react-vite", ], "pr-broad": [ From 6da04259ffd0d32b03809a1226fa3705ba93c806 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:27:09 +0300 Subject: [PATCH 10/36] fix(types): update default-aiDocs test for the AGENTS.md default DEFAULT_STACK_SELECTION.aiDocs became ["claude-md","agents-md"] (AGENTS.md default), so the stack-translation default test must compare against that. Uses reversed order to also exercise the array-insensitive comparison. --- packages/types/test/stack-translation.test.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/types/test/stack-translation.test.ts b/packages/types/test/stack-translation.test.ts index 1f05cc890..f82f06245 100644 --- a/packages/types/test/stack-translation.test.ts +++ b/packages/types/test/stack-translation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { StackSelectionInput } from "../src/stack-translation"; + import { DEFAULT_STACK_SELECTION, STACK_SELECTION_KEYS, @@ -39,13 +40,12 @@ describe("stack selection translation", () => { }); it("checks stack defaults with array-insensitive comparison and Convex adjustments", () => { - expect(isStackSelectionDefault(DEFAULT_SELECTION, "aiDocs", ["claude-md"])).toBe(true); + // Default aiDocs is ["claude-md", "agents-md"]; reversed order must still match (array-insensitive). + expect(isStackSelectionDefault(DEFAULT_SELECTION, "aiDocs", ["agents-md", "claude-md"])).toBe( + true, + ); expect( - isStackSelectionDefault( - { ...DEFAULT_SELECTION, backend: "convex" }, - "runtime", - "none", - ), + isStackSelectionDefault({ ...DEFAULT_SELECTION, backend: "convex" }, "runtime", "none"), ).toBe(true); }); @@ -162,7 +162,9 @@ describe("stack selection translation", () => { const specs = config.stackParts?.map((part) => { const owner = config.stackParts?.find((candidate) => candidate.id === part.ownerPartId); - return owner ? `${owner.role}.${part.role}:${part.ecosystem}:${part.toolId}` : `${part.role}:${part.ecosystem}:${part.toolId}`; + return owner + ? `${owner.role}.${part.role}:${part.ecosystem}:${part.toolId}` + : `${part.role}:${part.ecosystem}:${part.toolId}`; }); expect(config.database).toBe("postgres"); @@ -521,7 +523,9 @@ describe("stack selection translation", () => { ], }); - expect(config.stackParts?.map((part) => `${part.role}:${part.ecosystem}:${part.toolId}`)).toEqual( + expect( + config.stackParts?.map((part) => `${part.role}:${part.ecosystem}:${part.toolId}`), + ).toEqual( expect.arrayContaining([ "frontend:typescript:next", "backend:go:gin", @@ -598,8 +602,9 @@ describe("stack selection translation", () => { expect(config.pythonAi).toEqual([]); expect(config.javaLibraries).toEqual(["spring-actuator"]); expect(config.javaTestingLibraries).toEqual(["junit5"]); - expect(generateStackSelectionCommand({ ...DEFAULT_SELECTION, ecosystem: "go", aiDocs: [] })) - .toContain("--ai-docs none"); + expect( + generateStackSelectionCommand({ ...DEFAULT_SELECTION, ecosystem: "go", aiDocs: [] }), + ).toContain("--ai-docs none"); }); it("applies compatibility adjustments before producing ProjectConfig", () => { From 9f939b7cf76106072478196a36d8df9538bc04eb Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:27:37 +0300 Subject: [PATCH 11/36] =?UTF-8?q?test:=20add=20compat=20property=20tests,?= =?UTF-8?q?=20schema=E2=86=94template=20coverage,=20and=20API-literal=20dr?= =?UTF-8?q?ift=20guards=20(Tier-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compatibility-properties.test.ts: fast-check invariants (determinism, changes⟺adjusted, key-set preservation, well-formed issues, YOLO bypass) over the compatibility engine. Deliberately omits idempotence/convergence/constraint-satisfaction (empirically false on the real engine). - schema-template-coverage.test.ts: asserts every non-none enum value is generatable (template dir / hbs conditional / processor ref), with a documented allowlist of the real selectable-but-not-generated gaps (mostly .NET stubs). - api-literal-drift.test.ts: guards the stripe apiVersion literal against the pinned stripe major (fails closed on a major bump). --- .../cli/test/schema-template-coverage.test.ts | 405 ++++++++++++++++++ bun.lock | 21 +- .../test/api-literal-drift.test.ts | 121 ++++++ packages/types/package.json | 1 + .../test/compatibility-properties.test.ts | 261 +++++++++++ 5 files changed, 801 insertions(+), 8 deletions(-) create mode 100644 apps/cli/test/schema-template-coverage.test.ts create mode 100644 packages/template-generator/test/api-literal-drift.test.ts create mode 100644 packages/types/test/compatibility-properties.test.ts diff --git a/apps/cli/test/schema-template-coverage.test.ts b/apps/cli/test/schema-template-coverage.test.ts new file mode 100644 index 000000000..d2209a2cf --- /dev/null +++ b/apps/cli/test/schema-template-coverage.test.ts @@ -0,0 +1,405 @@ +import { + ADDONS_VALUES, + AI_DOCS_VALUES, + AI_VALUES, + ANALYTICS_VALUES, + ANIMATION_VALUES, + API_VALUES, + ASTRO_INTEGRATION_VALUES, + AUTH_VALUES, + BACKEND_VALUES, + CACHING_VALUES, + CMS_VALUES, + CSS_FRAMEWORK_VALUES, + DATABASE_SETUP_VALUES, + DATABASE_VALUES, + DOTNET_API_VALUES, + DOTNET_AUTH_VALUES, + DOTNET_CACHING_VALUES, + DOTNET_DEPLOY_VALUES, + DOTNET_JOB_QUEUE_VALUES, + DOTNET_OBSERVABILITY_VALUES, + DOTNET_ORM_VALUES, + DOTNET_REALTIME_VALUES, + DOTNET_TESTING_VALUES, + DOTNET_VALIDATION_VALUES, + DOTNET_WEB_FRAMEWORK_VALUES, + EFFECT_VALUES, + ELIXIR_API_VALUES, + ELIXIR_AUTH_VALUES, + ELIXIR_CACHING_VALUES, + ELIXIR_DEPLOY_VALUES, + ELIXIR_EMAIL_VALUES, + ELIXIR_HTTP_VALUES, + ELIXIR_JOBS_VALUES, + ELIXIR_JSON_VALUES, + ELIXIR_LIBRARIES_VALUES, + ELIXIR_OBSERVABILITY_VALUES, + ELIXIR_ORM_VALUES, + ELIXIR_QUALITY_VALUES, + ELIXIR_REALTIME_VALUES, + ELIXIR_TESTING_VALUES, + ELIXIR_VALIDATION_VALUES, + ELIXIR_WEB_FRAMEWORK_VALUES, + EMAIL_VALUES, + EXAMPLES_VALUES, + FEATURE_FLAGS_VALUES, + FILE_STORAGE_VALUES, + FILE_UPLOAD_VALUES, + FORMS_VALUES, + FRONTEND_VALUES, + GO_API_VALUES, + GO_AUTH_VALUES, + GO_CACHING_VALUES, + GO_CLI_VALUES, + GO_CONFIG_VALUES, + GO_LOGGING_VALUES, + GO_MESSAGE_QUEUE_VALUES, + GO_OBSERVABILITY_VALUES, + GO_ORM_VALUES, + GO_REALTIME_VALUES, + GO_TESTING_VALUES, + GO_WEB_FRAMEWORK_VALUES, + I18N_VALUES, + JAVA_API_VALUES, + JAVA_AUTH_VALUES, + JAVA_BUILD_TOOL_VALUES, + JAVA_LIBRARIES_VALUES, + JAVA_LOGGING_VALUES, + JAVA_ORM_VALUES, + JAVA_TESTING_LIBRARIES_VALUES, + JAVA_WEB_FRAMEWORK_VALUES, + JOB_QUEUE_VALUES, + LOGGING_VALUES, + MOBILE_DEEP_LINKING_VALUES, + MOBILE_NAVIGATION_VALUES, + MOBILE_OTA_VALUES, + MOBILE_PUSH_VALUES, + MOBILE_STORAGE_VALUES, + MOBILE_TESTING_VALUES, + MOBILE_UI_VALUES, + OBSERVABILITY_VALUES, + ORM_VALUES, + PAYMENTS_VALUES, + PYTHON_AI_VALUES, + PYTHON_API_VALUES, + PYTHON_AUTH_VALUES, + PYTHON_CACHING_VALUES, + PYTHON_CLI_VALUES, + PYTHON_GRAPHQL_VALUES, + PYTHON_OBSERVABILITY_VALUES, + PYTHON_ORM_VALUES, + PYTHON_QUALITY_VALUES, + PYTHON_REALTIME_VALUES, + PYTHON_TASK_QUEUE_VALUES, + PYTHON_TESTING_VALUES, + PYTHON_VALIDATION_VALUES, + PYTHON_WEB_FRAMEWORK_VALUES, + RATE_LIMIT_VALUES, + REALTIME_VALUES, + RUNTIME_VALUES, + RUST_API_VALUES, + RUST_AUTH_VALUES, + RUST_CACHING_VALUES, + RUST_CLI_VALUES, + RUST_ERROR_HANDLING_VALUES, + RUST_FRONTEND_VALUES, + RUST_LIBRARIES_VALUES, + RUST_LOGGING_VALUES, + RUST_MESSAGE_QUEUE_VALUES, + RUST_OBSERVABILITY_VALUES, + RUST_ORM_VALUES, + RUST_REALTIME_VALUES, + RUST_TEMPLATING_VALUES, + RUST_WEB_FRAMEWORK_VALUES, + SEARCH_VALUES, + SERVER_DEPLOY_VALUES, + STATE_MANAGEMENT_VALUES, + TESTING_VALUES, + UI_LIBRARY_VALUES, + VALIDATION_VALUES, + WEB_DEPLOY_VALUES, +} from "@better-fullstack/types"; +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +/** + * Tier-2 coverage guard (generalises check-types-coverage.test.ts). + * + * For every category enum in the schema, every non-"none" value must be + * "generatable" — i.e. something in the template tree or the generator source + * actually produces output for it. A value is generatable when: + * (a) a template directory is named after the value (e.g. api/trpc, auth/clerk), OR + * (b) the value appears as a quoted Handlebars/string literal in a `.hbs` + * (typically `{{#if (eq x "value")}}`), OR + * (c) the value appears as a quoted string literal in a processor/handler `.ts` + * (e.g. `config.dotnetOrm === "ef-core"`). + * + * This catches the ".NET selectable-but-not-generated" class: a schema enum + * exposes a value to users but no template/handler ever branches on it, so the + * scaffold silently ignores the choice. + * + * Genuine gaps live in ALLOWLIST with a documented reason. Prefer implementing + * the value over allowlisting it. + */ + +const GENERATOR_ROOT = path.resolve(import.meta.dir, "../../../packages/template-generator"); +const TEMPLATES_DIR = path.join(GENERATOR_ROOT, "templates"); +const SRC_DIR = path.join(GENERATOR_ROOT, "src"); + +const BINARY_EXT = /\.(png|jpe?g|gif|ico|webp|woff2?|ttf|otf|eot|wasm|avif|mp4|pdf)$/i; + +function collect(dir: string, opts: { dirs?: Set; files: string[] }): void { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + opts.dirs?.add(entry); + collect(full, opts); + } else { + opts.files.push(full); + } + } +} + +const dirNames = new Set(); +const files: string[] = []; +collect(TEMPLATES_DIR, { dirs: dirNames, files }); +collect(SRC_DIR, { files }); + +let corpus = ""; +for (const file of files) { + if (BINARY_EXT.test(file)) continue; + try { + corpus += `\n${readFileSync(file, "utf8")}`; + } catch { + // ignore unreadable/binary files + } +} + +function isGeneratable(value: string): boolean { + return dirNames.has(value) || corpus.includes(`"${value}"`) || corpus.includes(`'${value}'`); +} + +const CATEGORY_VALUES: Record = { + database: DATABASE_VALUES, + orm: ORM_VALUES, + backend: BACKEND_VALUES, + runtime: RUNTIME_VALUES, + api: API_VALUES, + auth: AUTH_VALUES, + payments: PAYMENTS_VALUES, + dbSetup: DATABASE_SETUP_VALUES, + frontend: FRONTEND_VALUES, + addons: ADDONS_VALUES, + examples: EXAMPLES_VALUES, + ai: AI_VALUES, + effect: EFFECT_VALUES, + stateManagement: STATE_MANAGEMENT_VALUES, + forms: FORMS_VALUES, + testing: TESTING_VALUES, + email: EMAIL_VALUES, + cssFramework: CSS_FRAMEWORK_VALUES, + uiLibrary: UI_LIBRARY_VALUES, + validation: VALIDATION_VALUES, + realtime: REALTIME_VALUES, + jobQueue: JOB_QUEUE_VALUES, + animation: ANIMATION_VALUES, + fileUpload: FILE_UPLOAD_VALUES, + logging: LOGGING_VALUES, + observability: OBSERVABILITY_VALUES, + featureFlags: FEATURE_FLAGS_VALUES, + analytics: ANALYTICS_VALUES, + cms: CMS_VALUES, + caching: CACHING_VALUES, + rateLimit: RATE_LIMIT_VALUES, + i18n: I18N_VALUES, + search: SEARCH_VALUES, + fileStorage: FILE_STORAGE_VALUES, + webDeploy: WEB_DEPLOY_VALUES, + serverDeploy: SERVER_DEPLOY_VALUES, + astroIntegration: ASTRO_INTEGRATION_VALUES, + aiDocs: AI_DOCS_VALUES, + mobileNavigation: MOBILE_NAVIGATION_VALUES, + mobileUI: MOBILE_UI_VALUES, + mobileStorage: MOBILE_STORAGE_VALUES, + mobileTesting: MOBILE_TESTING_VALUES, + mobilePush: MOBILE_PUSH_VALUES, + mobileOTA: MOBILE_OTA_VALUES, + mobileDeepLinking: MOBILE_DEEP_LINKING_VALUES, + rustWebFramework: RUST_WEB_FRAMEWORK_VALUES, + rustFrontend: RUST_FRONTEND_VALUES, + rustOrm: RUST_ORM_VALUES, + rustApi: RUST_API_VALUES, + rustCli: RUST_CLI_VALUES, + rustLibraries: RUST_LIBRARIES_VALUES, + rustLogging: RUST_LOGGING_VALUES, + rustErrorHandling: RUST_ERROR_HANDLING_VALUES, + rustCaching: RUST_CACHING_VALUES, + rustAuth: RUST_AUTH_VALUES, + rustRealtime: RUST_REALTIME_VALUES, + rustMessageQueue: RUST_MESSAGE_QUEUE_VALUES, + rustObservability: RUST_OBSERVABILITY_VALUES, + rustTemplating: RUST_TEMPLATING_VALUES, + pythonWebFramework: PYTHON_WEB_FRAMEWORK_VALUES, + pythonOrm: PYTHON_ORM_VALUES, + pythonValidation: PYTHON_VALIDATION_VALUES, + pythonAi: PYTHON_AI_VALUES, + pythonAuth: PYTHON_AUTH_VALUES, + pythonApi: PYTHON_API_VALUES, + pythonTaskQueue: PYTHON_TASK_QUEUE_VALUES, + pythonGraphql: PYTHON_GRAPHQL_VALUES, + pythonQuality: PYTHON_QUALITY_VALUES, + pythonTesting: PYTHON_TESTING_VALUES, + pythonCaching: PYTHON_CACHING_VALUES, + pythonRealtime: PYTHON_REALTIME_VALUES, + pythonObservability: PYTHON_OBSERVABILITY_VALUES, + pythonCli: PYTHON_CLI_VALUES, + goWebFramework: GO_WEB_FRAMEWORK_VALUES, + goOrm: GO_ORM_VALUES, + goApi: GO_API_VALUES, + goCli: GO_CLI_VALUES, + goLogging: GO_LOGGING_VALUES, + goAuth: GO_AUTH_VALUES, + goTesting: GO_TESTING_VALUES, + goRealtime: GO_REALTIME_VALUES, + goMessageQueue: GO_MESSAGE_QUEUE_VALUES, + goCaching: GO_CACHING_VALUES, + goConfig: GO_CONFIG_VALUES, + goObservability: GO_OBSERVABILITY_VALUES, + javaWebFramework: JAVA_WEB_FRAMEWORK_VALUES, + javaBuildTool: JAVA_BUILD_TOOL_VALUES, + javaOrm: JAVA_ORM_VALUES, + javaAuth: JAVA_AUTH_VALUES, + javaApi: JAVA_API_VALUES, + javaLogging: JAVA_LOGGING_VALUES, + javaLibraries: JAVA_LIBRARIES_VALUES, + javaTestingLibraries: JAVA_TESTING_LIBRARIES_VALUES, + dotnetWebFramework: DOTNET_WEB_FRAMEWORK_VALUES, + dotnetOrm: DOTNET_ORM_VALUES, + dotnetAuth: DOTNET_AUTH_VALUES, + dotnetApi: DOTNET_API_VALUES, + dotnetTesting: DOTNET_TESTING_VALUES, + dotnetJobQueue: DOTNET_JOB_QUEUE_VALUES, + dotnetRealtime: DOTNET_REALTIME_VALUES, + dotnetObservability: DOTNET_OBSERVABILITY_VALUES, + dotnetValidation: DOTNET_VALIDATION_VALUES, + dotnetCaching: DOTNET_CACHING_VALUES, + dotnetDeploy: DOTNET_DEPLOY_VALUES, + elixirWebFramework: ELIXIR_WEB_FRAMEWORK_VALUES, + elixirOrm: ELIXIR_ORM_VALUES, + elixirAuth: ELIXIR_AUTH_VALUES, + elixirApi: ELIXIR_API_VALUES, + elixirLibraries: ELIXIR_LIBRARIES_VALUES, + elixirRealtime: ELIXIR_REALTIME_VALUES, + elixirJobs: ELIXIR_JOBS_VALUES, + elixirValidation: ELIXIR_VALIDATION_VALUES, + elixirHttp: ELIXIR_HTTP_VALUES, + elixirJson: ELIXIR_JSON_VALUES, + elixirEmail: ELIXIR_EMAIL_VALUES, + elixirCaching: ELIXIR_CACHING_VALUES, + elixirObservability: ELIXIR_OBSERVABILITY_VALUES, + elixirTesting: ELIXIR_TESTING_VALUES, + elixirQuality: ELIXIR_QUALITY_VALUES, + elixirDeploy: ELIXIR_DEPLOY_VALUES, +}; + +/** + * Schema values that are selectable but intentionally (or knowingly) not gated + * on their own literal in the generator. Keyed `category:value`; every entry + * needs a documented reason. Two flavours: + * - "Unimplemented": no template/handler produces anything for the value. + * - "Baseline": the feature ships unconditionally (or via a derived flag), + * so it is never matched against its own enum literal. + */ +const ALLOWLIST = new Map([ + // Addons selectable in the schema but with no generator output. + ["addons:skills", "Unimplemented: no template dir or processor/handler reference."], + [ + "addons:fumadocs", + "Unimplemented: only in catalogs.ts version list; no template/handler output.", + ], + ["addons:ultracite", "Unimplemented: no template dir or processor/handler reference."], + ["addons:opentui", "Unimplemented: no template dir or processor/handler reference."], + ["addons:wxt", "Unimplemented: no template dir or processor/handler reference."], + // Baseline/default values that ship unconditionally. + [ + "forms:react-hook-form", + "Baseline: default React forms lib bundled in templates; not gated on literal.", + ], + [ + "javaTestingLibraries:junit5", + "Baseline: JUnit5 always-on via starter-test; never gated on the literal.", + ], + // .NET: dotnet-base only implements a subset of each enum. + ["dotnetOrm:dapper", "Unimplemented: dotnet-base only implements ef-core; no dapper branch."], + ["dotnetOrm:linq2db", "Unimplemented: dotnet-base only implements ef-core; no linq2db branch."], + [ + "dotnetAuth:duende-identityserver", + "Unimplemented: dotnet-base only implements aspnet-identity.", + ], + ["dotnetAuth:auth0-aspnet", "Unimplemented: dotnet-base only implements aspnet-identity."], + ["dotnetDeploy:azure", "Unimplemented: no Azure target; dotnet-base only handles docker."], + // Elixir: Phoenix baselines / derived-flag-gated, not literal-gated. + ["elixirOrm:ecto", "Baseline: Ecto ships via hasEcto derived flag; not gated on 'ecto' literal."], + [ + "elixirRealtime:pubsub", + "Baseline: Phoenix.PubSub configured unconditionally; not literal-gated.", + ], + ["elixirRealtime:live-view-streams", "Unimplemented: no template/handler reference."], + [ + "elixirValidation:ecto-changesets", + "Baseline: Ecto changesets default validation; not literal-gated.", + ], + ["elixirObservability:telemetry", "Baseline: telemetry deps ship in mix.exs; not literal-gated."], + ["elixirTesting:ex_unit", "Baseline: ExUnit always-on test framework; not literal-gated."], +]); + +describe("schema/template generatability coverage", () => { + it("discovers the generator template tree and source corpus", () => { + expect(files.length).toBeGreaterThan(500); + expect(dirNames.size).toBeGreaterThan(100); + expect(Object.keys(CATEGORY_VALUES).length).toBeGreaterThan(100); + }); + + it("every non-none schema value is generatable or explicitly allowlisted", () => { + const offenders: string[] = []; + for (const [category, values] of Object.entries(CATEGORY_VALUES)) { + for (const value of values) { + if (value === "none") continue; + const key = `${category}:${value}`; + if (ALLOWLIST.has(key)) continue; + if (!isGeneratable(value)) offenders.push(key); + } + } + // A non-empty list means a schema enum value can be selected by users but + // has no template dir, Handlebars conditional, or processor/handler that + // generates anything for it. Implement it, or allowlist it with a reason. + expect(offenders).toEqual([]); + }); + + it("keeps the allowlist honest (entries are valid and still ungenerated)", () => { + const stale: string[] = []; + for (const [key, reason] of ALLOWLIST) { + if (!reason || reason.trim().length === 0) { + stale.push(`${key} (missing reason)`); + continue; + } + const [category, ...rest] = key.split(":"); + const value = rest.join(":"); + const values = CATEGORY_VALUES[category]; + if (!values) { + stale.push(`${key} (unknown category)`); + continue; + } + if (!values.includes(value)) { + stale.push(`${key} (value not in ${category} enum)`); + continue; + } + if (isGeneratable(value)) { + stale.push(`${key} (now generatable — remove from allowlist)`); + } + } + expect(stale).toEqual([]); + }); +}); diff --git a/bun.lock b/bun.lock index 28042d39e..22bafe659 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ }, "apps/cli": { "name": "create-better-fullstack", - "version": "2.0.2", + "version": "2.0.3", "bin": { "create-better-fullstack": "dist/cli.mjs", }, @@ -160,7 +160,7 @@ }, "packages/create-bfs": { "name": "create-bfs", - "version": "1.6.3", + "version": "2.0.3", "bin": { "create-bfs": "cli.js", }, @@ -170,7 +170,7 @@ }, "packages/template-generator": { "name": "@better-fullstack/template-generator", - "version": "1.6.3", + "version": "2.0.3", "dependencies": { "@better-fullstack/types": "workspace:*", "handlebars": "^4.7.9", @@ -190,11 +190,12 @@ }, "packages/types": { "name": "@better-fullstack/types", - "version": "1.6.3", + "version": "2.0.3", "dependencies": { "zod": "4.4.3", }, "devDependencies": { + "fast-check": "^4.8.0", "tsdown": "^0.17.2", "typescript": "^5.9.3", }, @@ -1277,7 +1278,7 @@ "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], - "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1821,7 +1822,7 @@ "publint": ["publint@0.3.21", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], - "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], @@ -2205,12 +2206,12 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@inlang/paraglide-js/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@inlang/paraglide-js/consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="], - "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@mdx-js/mdx/@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -2301,6 +2302,8 @@ "crossws/srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="], + "effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -2433,6 +2436,8 @@ "create-better-fullstack/tsdown/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "fs-minipass/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], diff --git a/packages/template-generator/test/api-literal-drift.test.ts b/packages/template-generator/test/api-literal-drift.test.ts new file mode 100644 index 000000000..ca671198f --- /dev/null +++ b/packages/template-generator/test/api-literal-drift.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { dependencyVersionMap } from "../src/utils/add-deps"; + +/** + * Tier-2 "API-surface literal drift" guard. + * + * Some templates hard-code string literals that are really part of a + * dependency's API surface and are version-coupled: the value is only valid + * for a given MAJOR of the package it talks to. Examples we have shipped + * regressions on: the Stripe `apiVersion`, the expo `app.json` `web.output`, + * and the drizzle mysql2 `connection` shape. + * + * Plain text/version tests don't catch these because the literal lives in a + * `.hbs` template while the version lives in `add-deps.ts`. This guard ties + * the two together: each entry pins the EXPECTED literal per package major. + * When the pinned dependency's major changes, the major drops out of the + * expected map and the guard fails until a human re-verifies the literal + * against the new major's API and updates the map. When the literal itself is + * edited away from the expected value, the guard fails too. + * + * Keep this focused and low-false-positive: only literals that are genuinely + * coupled to a package major belong here. Add new guards to API_LITERAL_GUARDS. + */ + +const TEMPLATES_DIR = path.resolve(import.meta.dir, "../templates"); + +type DependencyName = keyof typeof dependencyVersionMap; + +interface ApiLiteralGuard { + /** Human label used in test names. */ + readonly label: string; + /** Dependency in dependencyVersionMap that owns this API surface. */ + readonly dependency: DependencyName; + /** Template file (relative to templates/) containing the literal. */ + readonly templateFile: string; + /** + * Extracts the hard-coded literal from the template source. Returns null + * when the literal can no longer be found (template was restructured) so the + * guard fails loudly instead of silently passing. + */ + readonly extractLiteral: (source: string) => string | null; + /** + * Expected literal value keyed by the dependency's MAJOR version. When the + * pinned major is missing here the guard fails, forcing a human to verify + * the literal against the new major and extend the map. + */ + readonly expectedByMajor: Readonly>; +} + +const API_LITERAL_GUARDS: readonly ApiLiteralGuard[] = [ + { + label: "stripe apiVersion", + dependency: "stripe", + templateFile: "payments/stripe/server/base/src/lib/stripe.ts.hbs", + extractLiteral: (source) => source.match(/apiVersion:\s*"([^"]+)"/)?.[1] ?? null, + // Stripe's `apiVersion` string is tied to the major of the `stripe` SDK. + // Verify the pinned value against https://docs.stripe.com/api/versioning + // whenever the major below changes, then add the new entry. + expectedByMajor: { + 22: "2024-12-18", + }, + }, +]; + +function majorOf(versionSpec: string): number { + const cleaned = versionSpec.replace(/^[\^~>=<\s]+/, ""); + const major = Number.parseInt(cleaned.split(".")[0] ?? "", 10); + return major; +} + +function readTemplate(templateFile: string): string { + return readFileSync(path.join(TEMPLATES_DIR, templateFile), "utf8"); +} + +describe("API-surface literal drift", () => { + for (const guard of API_LITERAL_GUARDS) { + describe(guard.label, () => { + const versionSpec = dependencyVersionMap[guard.dependency]; + const major = majorOf(versionSpec); + + it(`pins a parseable major for ${guard.dependency} (${versionSpec})`, () => { + expect(Number.isNaN(major)).toBe(false); + }); + + it(`tracks an expected literal for ${guard.dependency} major ${major}`, () => { + if (!(major in guard.expectedByMajor)) { + throw new Error( + `${guard.dependency} is pinned to ${versionSpec} (major ${major}) but ` + + `no expected ${guard.label} literal is registered for that major. ` + + `Verify the literal in ${guard.templateFile} against the new major's ` + + `API surface, then add ${major} -> "" to expectedByMajor.`, + ); + } + expect(guard.expectedByMajor[major]).toBeDefined(); + }); + + it(`matches the hard-coded literal in ${guard.templateFile}`, () => { + const source = readTemplate(guard.templateFile); + const actual = guard.extractLiteral(source); + + if (actual === null) { + throw new Error( + `Could not locate the ${guard.label} literal in ${guard.templateFile}. ` + + `The template was likely restructured; update extractLiteral for this guard.`, + ); + } + + const expected = guard.expectedByMajor[major]; + if (expected === undefined) { + // Covered by the dedicated test above; skip the value comparison here. + return; + } + + expect(actual).toBe(expected); + }); + }); + } +}); diff --git a/packages/types/package.json b/packages/types/package.json index 673278773..afa163330 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -63,6 +63,7 @@ "zod": "4.4.3" }, "devDependencies": { + "fast-check": "^4.8.0", "tsdown": "^0.17.2", "typescript": "^5.9.3" } diff --git a/packages/types/test/compatibility-properties.test.ts b/packages/types/test/compatibility-properties.test.ts new file mode 100644 index 000000000..2ae17c05f --- /dev/null +++ b/packages/types/test/compatibility-properties.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "bun:test"; +import * as fc from "fast-check"; + +import { analyzeStackCompatibility, evaluateCompatibility } from "../src/compatibility"; +import { DEFAULT_STACK_SELECTION } from "../src/stack-translation"; + +/** + * Property-based tests for the compatibility engine. + * + * These assert *structural* and *determinism* invariants that hold for the real + * engine across the whole input space. They deliberately do NOT assert: + * - single-pass idempotence, + * - convergence of repeated `analyzeStackCompatibility` to a fixpoint, or + * - "re-evaluation reports no remaining issues" (constraint-satisfaction), + * because the current engine genuinely violates all three for a small but + * reachable fraction of inputs (see the file-level note at the bottom). Adding + * those would produce flaky/failing tests rather than guarding a true invariant. + */ + +type Stack = typeof DEFAULT_STACK_SELECTION; + +// Value pools drawn from the real schema enums in src/schemas.ts. `backend` uses +// the expanded `self-*` runtime values the engine actually branches on (the Zod +// enum collapses these to "self"), so it is listed explicitly. +const ECOSYSTEMS = [ + "typescript", + "react-native", + "rust", + "python", + "go", + "java", + "elixir", + "dotnet", +] as const; + +const WEB_FRONTENDS = [ + "tanstack-router", + "react-router", + "react-vite", + "tanstack-start", + "next", + "vinext", + "nuxt", + "svelte", + "solid", + "solid-start", + "astro", + "qwik", + "angular", + "fresh", + "none", +] as const; + +const NATIVE_FRONTENDS = ["native-bare", "native-uniwind", "native-unistyles", "none"] as const; + +const BACKENDS = [ + "hono", + "express", + "fastify", + "elysia", + "nestjs", + "adonisjs", + "nitro", + "convex", + "self-next", + "self-vinext", + "self-tanstack-start", + "self-astro", + "self-nuxt", + "self-svelte", + "self-solid-start", + "none", +] as const; + +const RUNTIMES = ["bun", "node", "workers", "none"] as const; +const DATABASES = ["none", "sqlite", "postgres", "mysql", "mongodb", "redis"] as const; +const ORMS = [ + "drizzle", + "prisma", + "mongoose", + "typeorm", + "kysely", + "mikroorm", + "sequelize", + "none", +] as const; +const DB_SETUPS = [ + "turso", + "neon", + "prisma-postgres", + "planetscale", + "mongodb-atlas", + "supabase", + "d1", + "docker", + "none", +] as const; +const APIS = ["trpc", "orpc", "ts-rest", "none"] as const; +const AUTHS = ["better-auth", "better-auth-organizations", "clerk", "nextauth", "none"] as const; +const PAYMENTS = ["polar", "stripe", "dodo", "none"] as const; +const UI_LIBRARIES = [ + "shadcn-ui", + "shadcn-svelte", + "daisyui", + "radix-ui", + "headless-ui", + "park-ui", + "nextui", + "mui", + "antd", + "none", +] as const; +const CSS_FRAMEWORKS = ["tailwind", "none"] as const; +const EXAMPLES = ["ai", "chat-sdk", "tanstack-showcase", "none"] as const; +const AI_SDKS = ["vercel-ai", "tanstack-ai", "none"] as const; +const ASTRO_INTEGRATIONS = ["react", "vue", "svelte", "solid", "none"] as const; +const WEB_DEPLOYS = ["cloudflare", "vercel", "netlify", "render", "none"] as const; +const SERVER_DEPLOYS = ["cloudflare", "fly", "railway", "none"] as const; +const APP_PLATFORMS = ["pwa", "tauri", "turborepo", "none"] as const; + +const oneOrTwo = (values: readonly T[]) => + fc.uniqueArray(fc.constantFrom(...values), { minLength: 1, maxLength: 2 }); + +/** + * A bounded, structurally-valid stack arbitrary: it starts from the CLI default + * config and overrides a curated set of high-impact fields with values sampled + * from the real enum pools. The structural/determinism invariants under test are + * domain-independent, so this generator stays broad on purpose to maximise the + * input space exercised. + */ +const stackArb: fc.Arbitrary = fc + .record({ + ecosystem: fc.constantFrom(...ECOSYSTEMS), + webFrontend: oneOrTwo(WEB_FRONTENDS), + nativeFrontend: oneOrTwo(NATIVE_FRONTENDS), + astroIntegration: fc.constantFrom(...ASTRO_INTEGRATIONS), + backend: fc.constantFrom(...BACKENDS), + runtime: fc.constantFrom(...RUNTIMES), + database: fc.constantFrom(...DATABASES), + orm: fc.constantFrom(...ORMS), + dbSetup: fc.constantFrom(...DB_SETUPS), + api: fc.constantFrom(...APIS), + auth: fc.constantFrom(...AUTHS), + payments: fc.constantFrom(...PAYMENTS), + uiLibrary: fc.constantFrom(...UI_LIBRARIES), + cssFramework: fc.constantFrom(...CSS_FRAMEWORKS), + examples: oneOrTwo(EXAMPLES), + aiSdk: fc.constantFrom(...AI_SDKS), + webDeploy: fc.constantFrom(...WEB_DEPLOYS), + serverDeploy: fc.constantFrom(...SERVER_DEPLOYS), + appPlatforms: oneOrTwo(APP_PLATFORMS), + }) + .map((overrides) => ({ ...DEFAULT_STACK_SELECTION, ...overrides, yolo: "false" })); + +const RUNS = { numRuns: 500 } as const; + +describe("compatibility engine — property invariants", () => { + it("is deterministic: analyzeStackCompatibility(x) === analyzeStackCompatibility(x)", () => { + fc.assert( + fc.property(stackArb, (stack) => { + expect(analyzeStackCompatibility(stack)).toEqual(analyzeStackCompatibility(stack)); + }), + RUNS, + ); + }); + + it("is deterministic: evaluateCompatibility(x) === evaluateCompatibility(x)", () => { + fc.assert( + fc.property(stackArb, (stack) => { + expect(evaluateCompatibility(stack)).toEqual(evaluateCompatibility(stack)); + }), + RUNS, + ); + }); + + it("reports changes iff it produced an adjusted stack", () => { + fc.assert( + fc.property(stackArb, (stack) => { + const result = analyzeStackCompatibility(stack); + expect(result.adjustedStack === null).toBe(result.changes.length === 0); + }), + RUNS, + ); + }); + + it("never adds or drops keys and never mutates ecosystem or projectName", () => { + fc.assert( + fc.property(stackArb, (stack) => { + const { adjustedStack } = analyzeStackCompatibility(stack); + if (adjustedStack === null) return; + expect(Object.keys(adjustedStack).sort()).toEqual(Object.keys(stack).sort()); + expect(adjustedStack.ecosystem).toBe(stack.ecosystem); + expect(adjustedStack.projectName).toBe(stack.projectName); + }), + RUNS, + ); + }); + + it("emits only well-formed change entries (non-empty category and message)", () => { + fc.assert( + fc.property(stackArb, (stack) => { + for (const change of analyzeStackCompatibility(stack).changes) { + expect(typeof change.category).toBe("string"); + expect(change.category.length).toBeGreaterThan(0); + expect(typeof change.message).toBe("string"); + expect(change.message.length).toBeGreaterThan(0); + } + }), + RUNS, + ); + }); + + it("emits only well-formed evaluation issues (code, message, category present)", () => { + fc.assert( + fc.property(stackArb, (stack) => { + for (const issue of evaluateCompatibility(stack).issues) { + expect(typeof issue.code).toBe("string"); + expect(issue.code.length).toBeGreaterThan(0); + expect(typeof issue.message).toBe("string"); + expect(issue.message.length).toBeGreaterThan(0); + expect(issue.category).toBeDefined(); + } + }), + RUNS, + ); + }); + + it("short-circuits entirely in YOLO mode (no adjustments, no changes, no notes)", () => { + fc.assert( + fc.property(stackArb, (stack) => { + const result = analyzeStackCompatibility({ ...stack, yolo: "true" }); + expect(result.adjustedStack).toBeNull(); + expect(result.changes).toEqual([]); + expect(result.notes).toEqual({}); + }), + RUNS, + ); + }); +}); + +/** + * NOTE — invariants intentionally NOT asserted, with evidence: + * + * 1. Single-pass idempotence (`apply(apply(x)) deepEquals apply(x)`) is FALSE. + * Example: dbSetup="d1" sets runtime="workers"+backend="hono" in the database + * phase, but the runtime phase (which fixes serverDeploy for Workers) already + * ran, so a second pass flips serverDeploy "none" -> "cloudflare". + * + * 2. Convergence to a fixpoint is FALSE for a small (~1-2%) but reachable set of + * inputs. Some rules set `changed = true` while re-assigning an + * already-correct value (e.g. the Elixir phoenix-live-view -> + * elixirRealtime="live-view-streams" rule fires on every pass), so + * `adjustedStack` is perpetually non-null even though the value is stable. + * + * 3. Constraint-satisfaction ("adjusted output re-evaluates clean") is FALSE. + * analyzeStackCompatibility does not scrub cross-ecosystem leftover fields, + * while evaluateCompatibility checks every ecosystem's fields unconditionally, + * so the adjusted output still reports issues the analyzer never touches. + * + * Only determinism, from the original candidate list, holds; it is covered above. + */ From 2f862f6756e68d918e9311db0fcbfcfecbe9f905 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:27:39 +0300 Subject: [PATCH 12/36] feat(cli): add `bfs doctor` command Diagnoses a scaffolded project: parses bts.jsonc, checks lockfile/node_modules, required env vars (from .env.example), and runs the ecosystem build/type checks (reusing runGeneratedChecks). Supports --skip-checks and --json. --- apps/cli/src/commands/doctor.ts | 356 ++++++++++++++++++++++++++++++++ apps/cli/src/run.ts | 36 ++++ 2 files changed, 392 insertions(+) create mode 100644 apps/cli/src/commands/doctor.ts diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts new file mode 100644 index 000000000..f9fdb4821 --- /dev/null +++ b/apps/cli/src/commands/doctor.ts @@ -0,0 +1,356 @@ +import type { Dirent } from "node:fs"; + +import { intro, log, spinner } from "@clack/prompts"; +import { $ } from "execa"; +import fs from "fs-extra"; +import path from "node:path"; +import pc from "picocolors"; + +import type { BetterTStackConfig, ProjectConfig } from "../types"; + +import { readBtsConfig } from "../utils/bts-config"; +import { handleError } from "../utils/errors"; +import { runGeneratedChecks } from "../utils/generated-checks"; +import { renderTitle } from "../utils/render-title"; + +export type DoctorCommandInput = { + projectDir?: string; + skipChecks?: boolean; + json?: boolean; +}; + +type CheckStatus = "pass" | "warn" | "fail"; + +type DoctorCheck = { + label: string; + status: CheckStatus; + detail?: string; +}; + +const NON_TS_BACKEND_ECOSYSTEMS = new Set(["go", "rust", "python", "elixir", "java", "dotnet"]); + +const IGNORED_DIRECTORIES = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".expo", + ".turbo", + "target", + ".venv", + "deps", + "_build", +]); + +const JS_LOCKFILES = ["bun.lock", "bun.lockb", "pnpm-lock.yaml", "package-lock.json", "yarn.lock"]; + +const NATIVE_LOCKFILES: Record = { + rust: { file: "Cargo.lock", hint: "cargo build" }, + go: { file: "go.sum", hint: "go mod tidy" }, + python: { file: "uv.lock", hint: "uv sync" }, + elixir: { file: "mix.lock", hint: "mix deps.get" }, +}; + +function statusIcon(status: CheckStatus): string { + switch (status) { + case "pass": + return pc.green("✓"); + case "warn": + return pc.yellow("!"); + case "fail": + return pc.red("✗"); + } +} + +function hasNativeChecks(config: Pick): boolean { + if (NON_TS_BACKEND_ECOSYSTEMS.has(config.ecosystem)) { + return true; + } + return (config.stackParts ?? []).some( + (part) => + part.source !== "provided" && + part.role === "backend" && + NON_TS_BACKEND_ECOSYSTEMS.has(part.ecosystem), + ); +} + +async function checkInstalledDependencies( + projectDir: string, + config: BetterTStackConfig, +): Promise { + const checks: DoctorCheck[] = []; + + if (await fs.pathExists(path.join(projectDir, "package.json"))) { + const lockfile = JS_LOCKFILES.find((name) => fs.existsSync(path.join(projectDir, name))); + checks.push( + lockfile + ? { label: "Lockfile", status: "pass", detail: lockfile } + : { + label: "Lockfile", + status: "warn", + detail: "No JavaScript lockfile found at the project root", + }, + ); + + const nodeModulesExists = await fs.pathExists(path.join(projectDir, "node_modules")); + checks.push( + nodeModulesExists + ? { label: "node_modules", status: "pass" } + : { + label: "node_modules", + status: "fail", + detail: `Dependencies are not installed. Run \`${config.packageManager ?? "npm"} install\`.`, + }, + ); + } + + const native = NATIVE_LOCKFILES[config.ecosystem]; + if (native) { + const exists = + (await fs.pathExists(path.join(projectDir, native.file))) || + (await fs.pathExists(path.join(projectDir, "apps/server", native.file))); + checks.push( + exists + ? { label: native.file, status: "pass" } + : { + label: native.file, + status: "warn", + detail: `Not found. Run \`${native.hint}\` to fetch dependencies.`, + }, + ); + } + + return checks; +} + +async function findEnvExampleFiles(rootDir: string): Promise { + const results: string[] = []; + + async function walk(dir: string, depth: number): Promise { + if (depth > 5) return; + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (IGNORED_DIRECTORIES.has(entry.name)) continue; + await walk(path.join(dir, entry.name), depth + 1); + } else if (entry.name === ".env.example") { + results.push(path.join(dir, entry.name)); + } + } + } + + await walk(rootDir, 0); + return results; +} + +function parseEnvKeys(content: string): Map { + const map = new Map(); + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + if (key) { + map.set(key, line.slice(eq + 1).trim()); + } + } + return map; +} + +async function checkEnvFiles(projectDir: string): Promise { + const checks: DoctorCheck[] = []; + const exampleFiles = await findEnvExampleFiles(projectDir); + + for (const examplePath of exampleFiles) { + const envPath = examplePath.replace(/\.example$/, ""); + const relExample = path.relative(projectDir, examplePath) || ".env.example"; + const exampleKeys = parseEnvKeys(await fs.readFile(examplePath, "utf-8")); + if (exampleKeys.size === 0) continue; + + if (!(await fs.pathExists(envPath))) { + checks.push({ + label: relExample, + status: "warn", + detail: `Missing ${path.relative(projectDir, envPath)} (copy from .env.example and fill in values)`, + }); + continue; + } + + const envKeys = parseEnvKeys(await fs.readFile(envPath, "utf-8")); + const missing: string[] = []; + for (const key of exampleKeys.keys()) { + const value = envKeys.get(key); + if (value === undefined || value === "") { + missing.push(key); + } + } + + checks.push( + missing.length > 0 + ? { + label: path.relative(projectDir, envPath), + status: "warn", + detail: `Missing or empty: ${missing.join(", ")}`, + } + : { label: path.relative(projectDir, envPath), status: "pass" }, + ); + } + + return checks; +} + +async function runBuildChecks(config: ProjectConfig, json: boolean): Promise { + const checks: DoctorCheck[] = []; + const projectDir = config.projectDir; + + const rootPackageJsonPath = path.join(projectDir, "package.json"); + if (await fs.pathExists(rootPackageJsonPath)) { + const pkg = (await fs.readJson(rootPackageJsonPath).catch(() => ({}))) as { + scripts?: Record; + }; + if (pkg.scripts?.["check-types"]) { + const pm = config.packageManager ?? "npm"; + const s = json ? null : spinner(); + s?.start("Running type checks (check-types)..."); + const result = await $({ + cwd: projectDir, + reject: false, + stdout: json ? "ignore" : "inherit", + stderr: json ? "ignore" : "inherit", + })`${pm} run check-types`; + if (result.exitCode === 0) { + s?.stop("Type checks passed"); + checks.push({ label: "check-types", status: "pass" }); + } else { + s?.stop(pc.red("Type checks failed")); + checks.push({ + label: "check-types", + status: "fail", + detail: `\`${pm} run check-types\` exited with code ${result.exitCode}`, + }); + } + } + } + + if (hasNativeChecks(config)) { + if (json) { + checks.push({ + label: "ecosystem build checks", + status: "warn", + detail: "Skipped in --json mode. Re-run without --json to execute native build checks.", + }); + } else { + try { + await runGeneratedChecks(config); + checks.push({ label: "ecosystem build checks", status: "pass" }); + } catch (error) { + checks.push({ + label: "ecosystem build checks", + status: "fail", + detail: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return checks; +} + +export async function doctorCommand(input: DoctorCommandInput): Promise { + const projectDir = path.resolve(input.projectDir || process.cwd()); + const json = input.json ?? false; + + const btsConfig = await readBtsConfig(projectDir); + if (!btsConfig) { + if (json) { + console.log( + JSON.stringify( + { + projectDir, + ok: false, + error: "No Better Fullstack project found (bts.jsonc missing or invalid).", + }, + null, + 2, + ), + ); + process.exitCode = 1; + return; + } + handleError(`No Better Fullstack project found in ${projectDir}. Make sure bts.jsonc exists.`); + } + + const config = { ...btsConfig, projectDir } as unknown as ProjectConfig; + + if (!json) { + renderTitle(); + intro(pc.magenta(`Diagnosing ${pc.cyan(path.basename(projectDir))}`)); + log.info(pc.dim(`Path: ${projectDir}`)); + log.info(pc.dim(`Ecosystem: ${btsConfig.ecosystem}`)); + if (btsConfig.graphSummary) { + log.info(pc.dim(`Stack: ${btsConfig.graphSummary}`)); + } + } + + const checks: DoctorCheck[] = [ + { label: "bts.jsonc", status: "pass", detail: `version ${btsConfig.version}` }, + ]; + checks.push(...(await checkInstalledDependencies(projectDir, btsConfig))); + checks.push(...(await checkEnvFiles(projectDir))); + + if (!input.skipChecks) { + checks.push(...(await runBuildChecks(config, json))); + } + + const counts: Record = { pass: 0, warn: 0, fail: 0 }; + for (const check of checks) { + counts[check.status] += 1; + } + + if (json) { + console.log( + JSON.stringify( + { + projectDir, + ecosystem: btsConfig.ecosystem, + ok: counts.fail === 0, + summary: counts, + checks, + }, + null, + 2, + ), + ); + } else { + log.message(""); + for (const check of checks) { + log.message( + `${statusIcon(check.status)} ${check.label}${ + check.detail ? pc.dim(` — ${check.detail}`) : "" + }`, + ); + } + log.message(""); + const summaryLine = `${pc.green(`${counts.pass} passed`)}, ${pc.yellow( + `${counts.warn} warnings`, + )}, ${pc.red(`${counts.fail} failed`)}`; + if (counts.fail > 0) { + log.error(`Diagnosis complete: ${summaryLine}`); + } else if (counts.warn > 0) { + log.warn(`Diagnosis complete: ${summaryLine}`); + } else { + log.success(`Diagnosis complete: ${summaryLine}`); + } + } + + if (counts.fail > 0) { + process.exitCode = 1; + } +} diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 76c78f771..07099bf63 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -323,6 +323,32 @@ export const router = os.router({ log.message("MCP server is started via the 'mcp' subcommand intercepted in cli.ts."); log.message("Run: create-better-fullstack mcp"); }), + doctor: os + .meta({ + description: + "Diagnose a scaffolded Better Fullstack project: verify its bts.jsonc, installed dependencies, required env vars, and run ecosystem build/type checks", + }) + .input( + z.tuple([ + z + .string() + .optional() + .describe("Project directory to diagnose (defaults to current directory)"), + z.object({ + skipChecks: z + .boolean() + .optional() + .default(false) + .describe("Skip the ecosystem build/type checks (config + deps + env only)"), + json: z.boolean().optional().default(false).describe("Output the diagnosis as JSON"), + }), + ]), + ) + .handler(async ({ input }) => { + const [projectDir, options] = input; + const { doctorCommand } = await import("./commands/doctor.js"); + await doctorCommand({ projectDir, ...options }); + }), }); const caller = createRouterClient(router, { context: {} }); @@ -416,3 +442,13 @@ export async function telemetry( ) { return caller.telemetry([action, { json: options?.json ?? false }]); } + +export async function doctor( + projectDir?: string, + options?: { skipChecks?: boolean; json?: boolean }, +) { + return caller.doctor([ + projectDir, + { skipChecks: options?.skipChecks ?? false, json: options?.json ?? false }, + ]); +} From 9b3be20d77893e8443830b49f7f3bffcfd213f7e Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:27:41 +0300 Subject: [PATCH 13/36] feat(mcp): expose missing create fields + targetDir bfs_create_project/bfs_plan_project now accept astroIntegration (fixes a latent bug where MCP-built Astro projects could never pick an integration), aiDocs (agents can request AGENTS.md/cursorrules, defaulting to the canonical ["claude-md","agents-md"]), analytics, effect, and versionChannel; and bfs_create_project takes an optional targetDir parent directory. --- apps/cli/src/mcp.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 8c7b954c2..59be73d37 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -4,6 +4,7 @@ import { type AddInput, AddonsSchema, AISchema, + AiDocsSchema, AnalyticsSchema, AnimationSchema, APISchema, @@ -130,6 +131,7 @@ import { TestingSchema, UILibrarySchema, ValidationSchema, + VersionChannelSchema, WebDeploySchema, analyzeStackCompatibility, CATEGORY_ORDER, @@ -625,8 +627,6 @@ function buildProjectConfig( mobileDeepLinking: (input.mobileDeepLinking as ProjectConfig["mobileDeepLinking"]) ?? (hasMobileProject ? "expo-linking" : "none"), - astroIntegration: "none", - versionChannel: "stable", shadcnBase: "radix", shadcnStyle: "nova", shadcnIconLibrary: "lucide", @@ -634,7 +634,7 @@ function buildProjectConfig( shadcnBaseColor: "neutral", shadcnFont: "inter", shadcnRadius: "default", - aiDocs: ["claude-md"], + aiDocs: (input.aiDocs as ProjectConfig["aiDocs"]) ?? ["claude-md", "agents-md"], git: !!overrides, install: false, }; @@ -1076,6 +1076,18 @@ export async function startMcpServer() { ...mobileInputSchema, fileUpload: FileUploadSchema.optional().describe("File upload"), ...deploymentInputSchema, + effect: EffectSchema.optional().describe("Effect ecosystem (effect, effect-full)"), + analytics: AnalyticsSchema.optional().describe("Privacy-focused analytics provider"), + astroIntegration: AstroIntegrationSchema.optional().describe( + "Astro UI framework integration (react, vue, svelte, solid)", + ), + aiDocs: z + .array(AiDocsSchema) + .optional() + .describe("AI documentation files (claude-md, agents-md, cursorrules)"), + versionChannel: VersionChannelSchema.optional().describe( + "Dependency version channel (stable, latest, beta)", + ), ...crossEcosystemInputSchema, }; @@ -1112,7 +1124,7 @@ export async function startMcpServer() { registerTool( "bfs_create_project", "Creates a new fullstack project on disk. Dependencies are NOT installed (agent must tell user to install manually). Call bfs_plan_project first to preview.", - mcpInputSchema({ ...planCreateSchema, projectName: z.string().describe("Project name (kebab-case). Will be the directory name.") }), + mcpInputSchema({ ...planCreateSchema, projectName: z.string().describe("Project name (kebab-case). Will be the directory name."), targetDir: z.string().optional().describe("Absolute path to the parent directory in which to create the project folder (default: current working directory).") }), async (input: Record & { projectName: string }) => { try { const { generateVirtualProject, EMBEDDED_TEMPLATES } = await import("@better-fullstack/template-generator"); @@ -1120,7 +1132,10 @@ export async function startMcpServer() { const path = await import("node:path"); const projectName = sanitizePath(input.projectName); - const projectDir = path.resolve(process.cwd(), projectName); + const targetDir = input.targetDir + ? sanitizePath(input.targetDir as string) + : undefined; + const projectDir = path.resolve(targetDir ?? process.cwd(), projectName); const config = buildProjectConfig(input, { projectDir }); const fs = await import("node:fs/promises"); From 35c4514da6beb12b9c71f9b010363026f05b8f65 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:29:31 +0300 Subject: [PATCH 14/36] test(smoke): add dotnet to the combo generator + supported ecosystems createDraft had no dotnet case (returned undefined -> dotnet combos silently skipped) and DEFAULT_ECOSYSTEM_WEIGHTS lacked a dotnet entry. Adds makeDotnetDraft (only generatable .NET values: aspnet-minimal + minimal-api/graphql-hotchocolate/ grpc-dotnet, ef-core, xunit), the switch case, the weight, and dotnet to SUPPORTED_SMOKE_ECOSYSTEMS so nightly random generation covers .NET. --- testing/lib/generate-combos/options.ts | 41 ++++++++++++++++++++++++++ testing/lib/generate-combos/types.ts | 1 + testing/smoke-test.ts | 1 + 3 files changed, 43 insertions(+) diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts index e85bf20dc..a863155e7 100644 --- a/testing/lib/generate-combos/options.ts +++ b/testing/lib/generate-combos/options.ts @@ -565,6 +565,45 @@ function makeElixirDraft(args: GeneratorArgs): CandidateDraft { }; } +function makeDotnetDraft(args: GeneratorArgs): CandidateDraft { + // .NET template coverage is currently a stub: only the API style + // (minimal-api / graphql-hotchocolate / grpc-dotnet) and validation + // (fluentvalidation / data-annotations) actually drive generation, and the + // generated test project only compiles with xunit. Restrict the pools to + // values that produce a buildable project: avoid aspnet-mvc/blazor, dapper, + // linq2db, and the auth/extra values that have no template yet. + return { + ecosystem: "dotnet", + options: { + ...createCommonOptions("dotnet", args), + dotnetWebFramework: "aspnet-minimal", + dotnetOrm: sampleScalar(["ef-core", "none"] as const, 0.3, "dotnetOrm"), + dotnetAuth: "none", + dotnetApi: sampleScalar( + ["minimal-api", "graphql-hotchocolate", "grpc-dotnet"] as const, + 0, + "dotnetApi", + ), + dotnetTesting: ["xunit"], + dotnetJobQueue: sampleScalar(["hosted-services", "none"] as const, 0.5, "dotnetJobQueue"), + dotnetRealtime: sampleScalar(["signalr", "none"] as const, 0.5, "dotnetRealtime"), + dotnetObservability: sampleArray( + ["serilog", "health-checks"] as const, + 0.4, + 2, + "dotnetObservability", + ), + dotnetValidation: sampleScalar( + ["fluentvalidation", "data-annotations", "none"] as const, + 0.3, + "dotnetValidation", + ), + dotnetCaching: sampleScalar(["memory-cache", "none"] as const, 0.5, "dotnetCaching"), + dotnetDeploy: sampleScalar(["docker", "none"] as const, 0.4, "dotnetDeploy"), + }, + }; +} + function buildProvidedFlags(options: CLIInput): Set { const providedFlags = new Set(); @@ -802,6 +841,8 @@ function createDraft(ecosystem: Ecosystem, args: GeneratorArgs): CandidateDraft return makeJavaDraft(args); case "elixir": return makeElixirDraft(args); + case "dotnet": + return makeDotnetDraft(args); } } diff --git a/testing/lib/generate-combos/types.ts b/testing/lib/generate-combos/types.ts index fe6604c58..d86d8ab93 100644 --- a/testing/lib/generate-combos/types.ts +++ b/testing/lib/generate-combos/types.ts @@ -32,6 +32,7 @@ export const DEFAULT_ECOSYSTEM_WEIGHTS: Record = { go: 2, java: 2, elixir: 2, + dotnet: 2, }; export const TEMPLATE_FINGERPRINT_KEYS = [ diff --git a/testing/smoke-test.ts b/testing/smoke-test.ts index 0b26037db..a7518159a 100644 --- a/testing/smoke-test.ts +++ b/testing/smoke-test.ts @@ -32,6 +32,7 @@ const SUPPORTED_SMOKE_ECOSYSTEMS: readonly Ecosystem[] = [ "go", "java", "elixir", + "dotnet", ]; // ── Types ─────────────────────────────────────────────────────────────── From e4a70d33d29972b3c4fe1ba24a0bfe009bae9a26 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:46:58 +0300 Subject: [PATCH 15/36] fix(cli): make bfs doctor exit non-zero on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trpc-cli calls process.exit(0) after a handler resolves, which silently overrode doctor's process.exitCode=1 — so `bfs doctor && deploy` proceeded even when the diagnosis failed. Exit synchronously with process.exit(1) on the failure paths instead. Also report `signal X` when check-types is killed by a signal (exitCode null). --- apps/cli/src/commands/doctor.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index f9fdb4821..e3a37dd37 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -233,7 +233,7 @@ async function runBuildChecks(config: ProjectConfig, json: boolean): Promise { 2, ), ); - process.exitCode = 1; - return; + // Exit synchronously: trpc-cli calls process.exit(0) after the handler + // resolves, which would override process.exitCode and break doctor as a gate. + process.exit(1); } handleError(`No Better Fullstack project found in ${projectDir}. Make sure bts.jsonc exists.`); } @@ -350,7 +351,10 @@ export async function doctorCommand(input: DoctorCommandInput): Promise { } } + // Exit synchronously on failure: trpc-cli calls process.exit(0) after the + // handler resolves, which would override process.exitCode and let CI pipelines + // (e.g. `bfs doctor && deploy`) proceed despite a failed diagnosis. if (counts.fail > 0) { - process.exitCode = 1; + process.exit(1); } } From 18fcf6d35771caaaec049b6b8823857cd17958e8 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:47:04 +0300 Subject: [PATCH 16/36] fix(cli): complete the AGENTS.md default (createVirtual + MCP compat defaults) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AGENTS.md default change missed two spots that still used ["claude-md"]: the programmatic createVirtual API (index.ts) and MCP_COMPATIBILITY_DEFAULTS (mcp.ts). Aligning createVirtual means every scaffold (incl. the snapshot fixtures) now ships AGENTS.md by default — snapshots regenerated accordingly. --- apps/cli/src/index.ts | 4 +- apps/cli/src/mcp.ts | 2 +- .../template-snapshots.test.ts.snap | 302 +++++++++++++++--- 3 files changed, 262 insertions(+), 46 deletions(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index a6a7e0966..961184339 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -203,8 +203,8 @@ export async function createVirtual( elixirQuality: options.elixirQuality || (options.ecosystem === "elixir" ? "credo" : "none"), elixirDeploy: options.elixirDeploy || "none", elixirLibraries: options.elixirLibraries || [], - // AI documentation files - aiDocs: options.aiDocs || ["claude-md"], + // AI documentation files (canonical default ships both CLAUDE.md + AGENTS.md) + aiDocs: options.aiDocs || ["claude-md", "agents-md"], }; if (options.stackParts) { config.stackParts = options.stackParts; diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 59be73d37..3fbd980e7 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -458,7 +458,7 @@ const MCP_COMPATIBILITY_DEFAULTS = { versionChannel: "stable", examples: [], aiSdk: "none", - aiDocs: ["claude-md"], + aiDocs: ["claude-md", "agents-md"], git: "true", install: "false", api: "none", diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 710ba94a9..87e1901ad 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -2,6 +2,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: tanstack-router-minimal 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -58,6 +59,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: tanstack-ro exports[`Template Snapshots File Structure Snapshots file structure: next-self-fullstack 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -122,6 +124,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: next-self-f exports[`Template Snapshots File Structure Snapshots file structure: astro-react-integration 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -164,6 +167,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: astro-react exports[`Template Snapshots File Structure Snapshots file structure: nuxt-standalone 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -206,6 +210,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: nuxt-standa exports[`Template Snapshots File Structure Snapshots file structure: express-node-trpc 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -261,6 +266,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: express-nod exports[`Template Snapshots File Structure Snapshots file structure: hono-bun-orpc 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -317,6 +323,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: hono-bun-or exports[`Template Snapshots File Structure Snapshots file structure: hono-openapi 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -444,6 +451,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: hono-apollo exports[`Template Snapshots File Structure Snapshots file structure: better-auth-full 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -509,6 +517,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: better-auth exports[`Template Snapshots File Structure Snapshots file structure: convex-clerk 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -557,6 +566,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: convex-cler exports[`Template Snapshots File Structure Snapshots file structure: self-next-clerk 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -613,6 +623,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: self-next-c exports[`Template Snapshots File Structure Snapshots file structure: self-tanstack-start-clerk 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -666,6 +677,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: self-tansta exports[`Template Snapshots File Structure Snapshots file structure: mongodb-mongoose 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -719,6 +731,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: mongodb-mon exports[`Template Snapshots File Structure Snapshots file structure: postgres-prisma 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -774,6 +787,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: postgres-pr exports[`Template Snapshots File Structure Snapshots file structure: algolia-search-hono 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -1007,6 +1021,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: i18n-paragl exports[`Template Snapshots File Structure Snapshots file structure: ai-cli-root-tooling 1`] = ` [ ".env", + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -1063,6 +1078,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: ai-cli-root exports[`Template Snapshots File Structure Snapshots file structure: nx-root-tooling 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -1120,6 +1136,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: nx-root-too exports[`Template Snapshots File Structure Snapshots file structure: frontend-only-no-backend 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -1159,6 +1176,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: frontend-on exports[`Template Snapshots File Structure Snapshots file structure: native-react-native 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/native/.env", @@ -1215,6 +1233,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-reac exports[`Template Snapshots File Structure Snapshots file structure: native-mobile-integrations 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/native/.env", @@ -1267,6 +1286,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-mobi exports[`Template Snapshots File Structure Snapshots file structure: java-spring-boot-jpa-security 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "build.gradle.kts", @@ -1294,6 +1314,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: java-spring [ ".env.example", ".mvn/wrapper/maven-wrapper.properties", + "AGENTS.md", "CLAUDE.md", "README.md", "mvnw", @@ -1323,8 +1344,12 @@ exports[`Template Snapshots File Structure Snapshots file structure: java-spring exports[`Template Snapshots Key File Content Snapshots key files: tanstack-router-minimal 1`] = ` { - "fileCount": 56, + "fileCount": 57, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -2100,8 +2125,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: next-self-fullstack 1`] = ` { - "fileCount": 65, + "fileCount": 66, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -2927,8 +2956,12 @@ export const accountRelations = relations(account, ({ one }) => ({ exports[`Template Snapshots Key File Content Snapshots key files: astro-react-integration 1`] = ` { - "fileCount": 42, + "fileCount": 43, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -3414,8 +3447,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: nuxt-standalone 1`] = ` { - "fileCount": 42, + "fileCount": 43, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -3942,8 +3979,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: express-node-trpc 1`] = ` { - "fileCount": 55, + "fileCount": 56, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -4692,8 +4733,12 @@ export default prisma; exports[`Template Snapshots Key File Content Snapshots key files: hono-bun-orpc 1`] = ` { - "fileCount": 56, + "fileCount": 57, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -5506,8 +5551,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: hono-openapi 1`] = ` { - "fileCount": 65, + "fileCount": 66, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -7727,8 +7776,12 @@ export const accountRelations = relations(account, ({ one }) => ({ exports[`Template Snapshots Key File Content Snapshots key files: better-auth-full 1`] = ` { - "fileCount": 66, + "fileCount": 67, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -8747,8 +8800,12 @@ export const accountRelations = relations(account, ({ one }) => ({ exports[`Template Snapshots Key File Content Snapshots key files: convex-clerk 1`] = ` { - "fileCount": 46, + "fileCount": 47, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -9278,8 +9335,12 @@ export default defineSchema({}); exports[`Template Snapshots Key File Content Snapshots key files: self-next-clerk 1`] = ` { - "fileCount": 56, + "fileCount": 57, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -9879,8 +9940,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: self-tanstack-start-clerk 1`] = ` { - "fileCount": 52, + "fileCount": 53, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -10573,8 +10638,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: mongodb-mongoose 1`] = ` { - "fileCount": 53, + "fileCount": 54, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -11300,8 +11369,12 @@ export { client }; exports[`Template Snapshots Key File Content Snapshots key files: postgres-prisma 1`] = ` { - "fileCount": 55, + "fileCount": 56, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -12049,8 +12122,12 @@ export default prisma; exports[`Template Snapshots Key File Content Snapshots key files: algolia-search-hono 1`] = ` { - "fileCount": 57, + "fileCount": 58, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -15014,12 +15091,16 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: ai-cli-root-tooling 1`] = ` { - "fileCount": 57, + "fileCount": 58, "files": [ { "content": "[exists]", "path": ".env", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -15801,8 +15882,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: nx-root-tooling 1`] = ` { - "fileCount": 57, + "fileCount": 58, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -16583,8 +16668,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: frontend-only-no-backend 1`] = ` { - "fileCount": 36, + "fileCount": 37, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -16984,8 +17073,12 @@ export default defineConfig({ exports[`Template Snapshots Key File Content Snapshots key files: native-react-native 1`] = ` { - "fileCount": 66, + "fileCount": 67, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/native/.env", @@ -17794,8 +17887,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: native-mobile-integrations 1`] = ` { - "fileCount": 60, + "fileCount": 61, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/native/__tests__/mobile-ui-provider.test.tsx", @@ -18443,7 +18540,7 @@ export type AppRouterClient = RouterClient; exports[`Template Snapshots Key File Content Snapshots key files: java-spring-boot-jpa-security 1`] = ` { - "fileCount": 23, + "fileCount": 24, "files": [ { "content": @@ -18455,6 +18552,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "build.gradle.kts", @@ -18542,6 +18643,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-leptos-seaorm 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "Cargo.toml", "README.md", @@ -18566,6 +18668,7 @@ exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: actix-dioxus-sqlx 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "Cargo.toml", "README.md", @@ -18589,6 +18692,7 @@ exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: cli-clap 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "Cargo.toml", "README.md", @@ -18610,6 +18714,7 @@ exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-envlogger 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "Cargo.toml", "README.md", @@ -18629,6 +18734,7 @@ exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-eyre 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "Cargo.toml", "README.md", @@ -18648,6 +18754,7 @@ exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: rocket-seaorm 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "Cargo.toml", "README.md", @@ -18666,7 +18773,7 @@ exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-leptos-seaorm 1`] = ` { - "fileCount": 20, + "fileCount": 21, "files": [ { "content": @@ -18691,6 +18798,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[workspace] @@ -19093,7 +19204,7 @@ async fn main() -> anyhow::Result<()> { exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: actix-dioxus-sqlx 1`] = ` { - "fileCount": 19, + "fileCount": 20, "files": [ { "content": @@ -19118,6 +19229,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[workspace] @@ -19511,7 +19626,7 @@ async fn main() -> anyhow::Result<()> { exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: cli-clap 1`] = ` { - "fileCount": 17, + "fileCount": 18, "files": [ { "content": @@ -19536,6 +19651,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[workspace] @@ -19926,7 +20045,7 @@ async fn main() -> anyhow::Result<()> { exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-envlogger 1`] = ` { - "fileCount": 15, + "fileCount": 16, "files": [ { "content": @@ -19951,6 +20070,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[workspace] @@ -20187,7 +20310,7 @@ async fn main() -> anyhow::Result<()> { exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-eyre 1`] = ` { - "fileCount": 15, + "fileCount": 16, "files": [ { "content": @@ -20212,6 +20335,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[workspace] @@ -20421,7 +20548,7 @@ async fn main() -> eyre::Result<()> { exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: rocket-seaorm 1`] = ` { - "fileCount": 15, + "fileCount": 16, "files": [ { "content": @@ -20446,6 +20573,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[workspace] @@ -20671,6 +20802,7 @@ async fn rocket() -> _ { exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-gorm-zap 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/server/main.go", @@ -20684,6 +20816,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-sqlc-grpc 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/server/main.go", @@ -20705,6 +20838,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: fiber-gorm-zap 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/server/main.go", @@ -20718,6 +20852,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: chi-gorm 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/server/main.go", @@ -20731,6 +20866,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: cli-cobra 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/cli/main.go", @@ -20742,6 +20878,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-gorm-zerolog 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/server/main.go", @@ -20755,6 +20892,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-sqlc-slog 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/server/main.go", @@ -20772,6 +20910,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-casbin 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "auth_model.conf", @@ -20786,6 +20925,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-jwt-auth 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "cmd/server/main.go", @@ -20797,7 +20937,7 @@ exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file stru exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-gorm-zap 1`] = ` { - "fileCount": 9, + "fileCount": 10, "files": [ { "content": @@ -20817,6 +20957,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -20851,7 +20995,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-sqlc-grpc 1`] = ` { - "fileCount": 17, + "fileCount": 18, "files": [ { "content": @@ -20871,6 +21015,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -20937,7 +21085,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: fiber-gorm-zap 1`] = ` { - "fileCount": 9, + "fileCount": 10, "files": [ { "content": @@ -20957,6 +21105,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -20991,7 +21143,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: chi-gorm 1`] = ` { - "fileCount": 9, + "fileCount": 10, "files": [ { "content": @@ -21011,6 +21163,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21045,7 +21201,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: cli-cobra 1`] = ` { - "fileCount": 7, + "fileCount": 8, "files": [ { "content": @@ -21065,6 +21221,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21091,7 +21251,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-gorm-zerolog 1`] = ` { - "fileCount": 9, + "fileCount": 10, "files": [ { "content": @@ -21111,6 +21271,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21145,7 +21309,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-sqlc-slog 1`] = ` { - "fileCount": 13, + "fileCount": 14, "files": [ { "content": @@ -21165,6 +21329,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21215,7 +21383,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-casbin 1`] = ` { - "fileCount": 10, + "fileCount": 11, "files": [ { "content": @@ -21239,6 +21407,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "auth_model.conf", @@ -21277,7 +21449,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-jwt-auth 1`] = ` { - "fileCount": 8, + "fileCount": 9, "files": [ { "content": @@ -21300,6 +21472,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21331,6 +21507,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-sqlalchemy-celery 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "alembic.ini", @@ -21356,6 +21533,7 @@ exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots f exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: django-sqlmodel-langchain 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "alembic.ini", @@ -21379,6 +21557,7 @@ exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots f exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-ai-multi 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "pyproject.toml", @@ -21398,6 +21577,7 @@ exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots f exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: flask-pydantic-ruff 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "pyproject.toml", @@ -21413,6 +21593,7 @@ exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots f exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: litestar-pydantic-ruff 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "pyproject.toml", @@ -21428,6 +21609,7 @@ exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots f exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-tortoise-orm 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "pyproject.toml", @@ -21444,7 +21626,7 @@ exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots f exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-sqlalchemy-celery 1`] = ` { - "fileCount": 21, + "fileCount": 22, "files": [ { "content": @@ -21472,6 +21654,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "alembic.ini", @@ -21554,7 +21740,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: django-sqlmodel-langchain 1`] = ` { - "fileCount": 19, + "fileCount": 20, "files": [ { "content": @@ -21582,6 +21768,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "alembic.ini", @@ -21656,7 +21846,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-ai-multi 1`] = ` { - "fileCount": 15, + "fileCount": 16, "files": [ { "content": @@ -21684,6 +21874,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21742,7 +21936,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: flask-pydantic-ruff 1`] = ` { - "fileCount": 11, + "fileCount": 12, "files": [ { "content": @@ -21770,6 +21964,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21812,7 +22010,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: litestar-pydantic-ruff 1`] = ` { - "fileCount": 11, + "fileCount": 12, "files": [ { "content": @@ -21840,6 +22038,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21882,7 +22084,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-tortoise-orm 1`] = ` { - "fileCount": 13, + "fileCount": 14, "files": [ { "content": @@ -21910,6 +22112,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -21961,6 +22167,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots file structure: phoenix-ecto-rest 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "README.md", "config/config.exs", @@ -21998,6 +22205,7 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots file structure: phoenix-liveview-full 1`] = ` [ ".env.example", + "AGENTS.md", "CLAUDE.md", "Dockerfile", "README.md", @@ -22046,7 +22254,7 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-ecto-rest 1`] = ` { - "fileCount": 33, + "fileCount": 34, "files": [ { "content": @@ -22063,6 +22271,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", @@ -22193,7 +22405,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-liveview-full 1`] = ` { - "fileCount": 45, + "fileCount": 46, "files": [ { "content": @@ -22210,6 +22422,10 @@ CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "CLAUDE.md", From 538062c289c143945c244d82984ecb23fe9a1a44 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 17:47:05 +0300 Subject: [PATCH 17/36] =?UTF-8?q?test:=20scope=20dotnet=20matching=20in=20?= =?UTF-8?q?the=20schema=E2=86=94template=20coverage=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global quoted-substring corpus false-positived dotnet values via the graph-backend display-name map (aspnet-mvc/aspnet-blazor) and a cross-ecosystem SST template (dotnetDeploy:aws), masking the exact selectable-but-not-generated class the guard targets. dotnet categories now match only against dotnet-relevant files; the genuinely-unimplemented .NET values are explicitly allowlisted. --- .../cli/test/schema-template-coverage.test.ts | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/cli/test/schema-template-coverage.test.ts b/apps/cli/test/schema-template-coverage.test.ts index d2209a2cf..d003b7121 100644 --- a/apps/cli/test/schema-template-coverage.test.ts +++ b/apps/cli/test/schema-template-coverage.test.ts @@ -167,17 +167,33 @@ const files: string[] = []; collect(TEMPLATES_DIR, { dirs: dirNames, files }); collect(SRC_DIR, { files }); -let corpus = ""; -for (const file of files) { - if (BINARY_EXT.test(file)) continue; - try { - corpus += `\n${readFileSync(file, "utf8")}`; - } catch { - // ignore unreadable/binary files +function buildCorpus(fileList: string[]): string { + let text = ""; + for (const file of fileList) { + if (BINARY_EXT.test(file)) continue; + try { + text += `\n${readFileSync(file, "utf8")}`; + } catch { + // ignore unreadable/binary files + } } + return text; } -function isGeneratable(value: string): boolean { +const corpus = buildCorpus(files); + +// .NET is a stub ecosystem whose values are gated only inside templates/dotnet-base +// and the dotnet handler. Matching dotnet values against the GLOBAL corpus +// false-positives (e.g. "aspnet-mvc"/"aspnet-blazor" appear only as keys in the +// graph-backend display-name map; "aws" appears in the SST deploy template), which +// would mask genuinely selectable-but-not-generated .NET options. So dotnet +// categories are scoped to dotnet-relevant files only. +const dotnetCorpus = buildCorpus(files.filter((file) => file.includes("dotnet"))); + +function isGeneratable(value: string, category: string): boolean { + if (category.startsWith("dotnet")) { + return dotnetCorpus.includes(`"${value}"`) || dotnetCorpus.includes(`'${value}'`); + } return dirNames.has(value) || corpus.includes(`"${value}"`) || corpus.includes(`'${value}'`); } @@ -332,6 +348,19 @@ const ALLOWLIST = new Map([ "Baseline: JUnit5 always-on via starter-test; never gated on the literal.", ], // .NET: dotnet-base only implements a subset of each enum. + [ + "dotnetWebFramework:aspnet-minimal", + "Baseline: dotnet-base emits the minimal-API scaffold unconditionally; never gated on the literal.", + ], + [ + "dotnetWebFramework:aspnet-mvc", + "Unimplemented: dotnet-base never branches on dotnetWebFramework; MVC yields the same minimal-API scaffold.", + ], + [ + "dotnetWebFramework:aspnet-blazor", + "Unimplemented: dotnet-base never branches on dotnetWebFramework; Blazor yields the same minimal-API scaffold (no Blazor components).", + ], + ["dotnetDeploy:aws", "Unimplemented: no AWS target; dotnet-base only handles docker."], ["dotnetOrm:dapper", "Unimplemented: dotnet-base only implements ef-core; no dapper branch."], ["dotnetOrm:linq2db", "Unimplemented: dotnet-base only implements ef-core; no linq2db branch."], [ @@ -369,7 +398,7 @@ describe("schema/template generatability coverage", () => { if (value === "none") continue; const key = `${category}:${value}`; if (ALLOWLIST.has(key)) continue; - if (!isGeneratable(value)) offenders.push(key); + if (!isGeneratable(value, category)) offenders.push(key); } } // A non-empty list means a schema enum value can be selected by users but @@ -396,7 +425,7 @@ describe("schema/template generatability coverage", () => { stale.push(`${key} (value not in ${category} enum)`); continue; } - if (isGeneratable(value)) { + if (isGeneratable(value, category)) { stale.push(`${key} (now generatable — remove from allowlist)`); } } From ebe4eb2eedacc9479803b32aaeb3f47f8f758256 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 20:09:34 +0300 Subject: [PATCH 18/36] feat(addons): add github-actions CI addon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `github-actions` addon emits .github/workflows/ci.yml — install + lint (when biome/oxlint is selected) + check-types + build — parameterized by package manager (bun/pnpm/npm/yarn) and ecosystem (TS/rust/go/python, with a degrade branch for the rest). Pure file emission via the addon handler's catch-all; no new deps. Wired through schema/option-metadata/compatibility/stack-graph/ stack-translation + the CLI prompt + web option metadata (cli-builder-sync parity). --- apps/cli/src/constants.ts | 1 + apps/cli/src/prompts/addons.ts | 6 +- .../stack-builder/stack-builder.tsx | 2 +- apps/web/src/lib/constant.ts | 8 + apps/web/src/lib/tech-icons.ts | 1 + apps/web/src/lib/tech-resource-links.ts | 4 + .../.github/workflows/ci.yml.hbs | 169 ++++++++++++++++++ packages/types/src/compatibility.ts | 1 + packages/types/src/option-metadata.ts | 2 + packages/types/src/schemas.ts | 1 + packages/types/src/stack-graph.ts | 1 + packages/types/src/stack-translation.ts | 1 + 12 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index ce0fde5e8..162f89fab 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -79,6 +79,7 @@ export const ADDON_COMPATIBILITY = { wxt: [], devcontainer: [], "docker-compose": [], + "github-actions": [], msw: [], storybook: ["tanstack-router", "react-router", "react-vite", "next", "vinext", "nuxt", "svelte", "solid"], swr: ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext", "astro", "redwood"], diff --git a/apps/cli/src/prompts/addons.ts b/apps/cli/src/prompts/addons.ts index 7be9a6141..4c9ec397b 100644 --- a/apps/cli/src/prompts/addons.ts +++ b/apps/cli/src/prompts/addons.ts @@ -130,6 +130,10 @@ function getAddonDisplay(addon: Addons): { label: string; hint: string } { label = "Docker Compose"; hint = "Containerize your app for deployment"; break; + case "github-actions": + label = "GitHub Actions"; + hint = "Ship a CI workflow (install, lint, type-check, build)"; + break; default: label = addon; hint = `Add ${addon}`; @@ -139,7 +143,7 @@ function getAddonDisplay(addon: Addons): { label: string; hint: string } { } const ADDON_GROUPS: Record = { - Tooling: ["turborepo", "nx", "biome", "oxlint", "ultracite", "husky", "lefthook"], + Tooling: ["turborepo", "nx", "github-actions", "biome", "oxlint", "ultracite", "husky", "lefthook"], Documentation: ["starlight", "fumadocs"], Extensions: ["pwa", "tauri", "opentui", "wxt", "ruler", "devcontainer", "docker-compose"], Integrations: ["msw", "storybook", "backend-utils"], diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx index ded7f0764..745e1e2bf 100644 --- a/apps/web/src/components/stack-builder/stack-builder.tsx +++ b/apps/web/src/components/stack-builder/stack-builder.tsx @@ -176,7 +176,7 @@ const GRAPH_FRONTEND_CONFIGS: GraphFrontendConfig[] = [ const APP_PLATFORM_OPTION_GROUPS = [ { headingKey: "workspacePlatforms", - ids: ["turborepo", "nx", "docker-compose", "pwa", "tauri", "wxt", "opentui"], + ids: ["turborepo", "nx", "docker-compose", "github-actions", "pwa", "tauri", "wxt", "opentui"], }, { headingKey: "aiAgents", diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index bb337e37b..7d1716fc8 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -2293,6 +2293,14 @@ export const TECH_OPTIONS: Record< color: "from-sky-500 to-blue-700", default: false, }, + { + id: "github-actions", + name: "GitHub Actions", + description: "Ship a CI workflow (install, lint, type-check, build)", + icon: "https://cdn.simpleicons.org/githubactions/2088FF", + color: "from-slate-500 to-slate-800", + default: false, + }, { id: "wxt", name: "WXT", diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index 0caec7e06..8e7b37c82 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -215,6 +215,7 @@ export const ICON_REGISTRY: Record = { docker: { type: "si", slug: "docker", hex: "2496ED" }, "docker-compose": { type: "si", slug: "docker", hex: "2496ED" }, devcontainer: { type: "si", slug: "docker", hex: "2496ED" }, + "github-actions": { type: "si", slug: "githubactions", hex: "2088FF" }, nx: { type: "si", slug: "nx", hex: "143055", needsInvert: "dark" }, // ─── Deploy ──────────────────────────────────────────────────────────────── diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index 9542dcc92..048a86fbe 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -353,6 +353,10 @@ const BASE_LINKS: LinkMap = { docsUrl: "https://containers.dev/", githubUrl: "https://github.com/devcontainers/spec", }, + "github-actions": { + docsUrl: "https://docs.github.com/actions", + githubUrl: "https://github.com/features/actions", + }, cloudflare: { docsUrl: "https://developers.cloudflare.com/", githubUrl: "https://github.com/cloudflare/cloudflare-docs", diff --git a/packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs b/packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs new file mode 100644 index 000000000..93855b8b9 --- /dev/null +++ b/packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs @@ -0,0 +1,169 @@ +name: CI + +on: + push: + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + +jobs: + ci: + name: Lint, Type Check & Build + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + +{{#if (eq ecosystem "typescript")}} +{{#if (eq packageManager "bun")}} + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install +{{#if (includes addons "biome")}} + + - name: Lint + run: bunx @biomejs/biome check . +{{else if (includes addons "oxlint")}} + + - name: Lint + run: bunx oxlint +{{/if}} + + - name: Type check + run: bun run check-types + + - name: Build + run: bun run build +{{else if (eq packageManager "pnpm")}} + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile +{{#if (includes addons "biome")}} + + - name: Lint + run: pnpm exec biome check . +{{else if (includes addons "oxlint")}} + + - name: Lint + run: pnpm exec oxlint +{{/if}} + + - name: Type check + run: pnpm run check-types + + - name: Build + run: pnpm run build +{{else if (eq packageManager "yarn")}} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - name: Install dependencies + run: yarn install --immutable +{{#if (includes addons "biome")}} + + - name: Lint + run: yarn exec biome check . +{{else if (includes addons "oxlint")}} + + - name: Lint + run: yarn exec oxlint +{{/if}} + + - name: Type check + run: yarn run check-types + + - name: Build + run: yarn run build +{{else}} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm install +{{#if (includes addons "biome")}} + + - name: Lint + run: npx @biomejs/biome check . +{{else if (includes addons "oxlint")}} + + - name: Lint + run: npx oxlint +{{/if}} + + - name: Type check + run: npm run check-types + + - name: Build + run: npm run build +{{/if}} +{{else if (eq ecosystem "rust")}} + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Format check + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Test + run: cargo test --verbose +{{else if (eq ecosystem "go")}} + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install dependencies + run: go mod download + + - name: Vet + run: go vet ./... + + - name: Build + run: go build ./... + + - name: Test + run: go test ./... +{{else if (eq ecosystem "python")}} + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f pyproject.toml ]; then pip install -e .; fi + + - name: Compile sources + run: python -m compileall -q . +{{else}} + - name: Build + run: echo "Add CI steps for the {{ecosystem}} ecosystem" +{{/if}} diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index 9314082c2..e362ccb23 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -3310,6 +3310,7 @@ const ADDON_COMPATIBILITY: Record = { "backend-utils": [], "docker-compose": [], devcontainer: [], + "github-actions": [], none: [], }; diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index 8406746a3..182dfde18 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -694,6 +694,7 @@ const APP_PLATFORM_VALUES = [ "backend-utils", "devcontainer", "docker-compose", + "github-actions", ] as const satisfies readonly string[]; const EXAMPLE_VALUES = ["ai", "chat-sdk"] as const satisfies readonly string[]; @@ -1084,6 +1085,7 @@ const EXACT_LABEL_OVERRIDES: Partial Date: Thu, 18 Jun 2026 20:09:41 +0300 Subject: [PATCH 19/36] feat(mcp): structured outputs + annotations + list_presets/recommend_stack tools All tools now declare outputSchema/structuredContent (typed results) and annotations (readOnlyHint on the read tools, non-read on create/add) so clients can auto-approve reads and gate writes; bfs_check_compatibility also surfaces evaluateCompatibility issues (code/optionId/suggestions). Adds bfs_list_presets (mern/pern/t3/uniwind with stack summaries) and bfs_recommend_stack (deterministic NL-brief -> compatibility-validated config + rationale + reproducible command; e.g. 'SaaS with payments and auth' -> better-auth + stripe + postgres, matched to pern). --- apps/cli/src/mcp.ts | 1045 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 868 insertions(+), 177 deletions(-) diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 3fbd980e7..969574653 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -1,5 +1,3 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { type AddInput, AddonsSchema, @@ -15,6 +13,7 @@ import { CachingSchema, CMSSchema, type CompatibilityInput, + type CreateInput, CSSFrameworkSchema, DatabaseSchema, DatabaseSetupSchema, @@ -134,14 +133,21 @@ import { VersionChannelSchema, WebDeploySchema, analyzeStackCompatibility, + evaluateCompatibility, CATEGORY_ORDER, getCategoryOrderForEcosystem, + TEMPLATE_VALUES, + type Template, } from "@better-fullstack/types"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import z from "zod"; import { previewBtsConfigUpdate, readBtsConfig, writeBtsConfig } from "./utils/bts-config"; +import { generateReproducibleCommand } from "./utils/generate-reproducible-command"; import { getLatestCLIVersion } from "./utils/get-latest-cli-version"; import { getEffectiveStack, getGraphSummary } from "./utils/graph-summary"; +import { getTemplateConfig, getTemplateDescription } from "./utils/templates"; const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce( (sum, metadata) => sum + metadata.options.length, @@ -188,26 +194,22 @@ function getGuidance() { python: "Backend/AI: web framework (fastapi/django), ORM (sqlalchemy/sqlmodel), AI/ML integrations, task queues.", go: "Backend/CLI: web framework (gin/echo), ORM (gorm/sqlc), gRPC, CLI tools, logging.", - java: - "Backend/API: Spring Boot with Maven or Gradle Wrapper, optional Spring Data JPA, Spring Security, app libraries, and Java testing libraries.", + java: "Backend/API: Spring Boot with Maven or Gradle Wrapper, optional Spring Data JPA, Spring Security, app libraries, and Java testing libraries.", dotnet: "Backend/API: ASP.NET Core Minimal APIs, MVC, or Blazor with EF Core/Dapper, Identity/Auth0, SignalR, xUnit, and Docker-ready output.", elixir: "Phoenix: Phoenix or Phoenix LiveView with Ecto SQL, PostgreSQL-ready config, REST or Absinthe, Channels/Presence, Oban, and Mix releases/Docker.", }, fieldRules: { - projectName: - "kebab-case directory name. Required for bfs_create_project.", - ecosystem: - "Must be set first. Determines which other fields are relevant.", + projectName: "kebab-case directory name. Required for bfs_create_project.", + ecosystem: "Must be set first. Determines which other fields are relevant.", frontend: "ARRAY of strings. TypeScript only. Supports multiple frontends in one monorepo. Use [] for API-only.", arrayFields: 'Use arrays for frontend, addons, examples, aiDocs, rustLibraries, pythonAi, pythonTesting, pythonCli, goTesting, javaLibraries, javaTestingLibraries, dotnetTesting, dotnetObservability, and elixirLibraries. Use [] for "none" on multi-select fields.', backend: 'String. "self" means fullstack mode (Next.js/Vinext/TanStack Start/Nuxt/Astro API routes). "none" for frontend-only.', - runtime: - '"bun" or "node". Must be "none" when backend is "self" or "convex".', + runtime: '"bun" or "node". Must be "none" when backend is "self" or "convex".', addons: "ARRAY of strings. Monorepo tools, code quality, desktop (tauri), browser extensions (wxt), etc.", email: @@ -347,13 +349,14 @@ function getSchemaOptions(category?: string, ecosystem?: string) { if (category) { const options = getMcpSchemaOptionValues(category); if (options.length === 0) { - return { error: `Unknown category: ${category}. Available: ${MCP_ALL_SCHEMA_KEYS.join(", ")}` }; + return { + error: `Unknown category: ${category}. Available: ${MCP_ALL_SCHEMA_KEYS.join(", ")}`, + }; } return { category, options }; } - const allowedKeys = ecosystem && isMcpEcosystem(ecosystem) - ? getMcpSchemaKeysForEcosystem(ecosystem) - : null; + const allowedKeys = + ecosystem && isMcpEcosystem(ecosystem) ? getMcpSchemaKeysForEcosystem(ecosystem) : null; const result: Record = {}; for (const key of MCP_ALL_SCHEMA_KEYS) { if (allowedKeys && !allowedKeys.has(key)) continue; @@ -370,10 +373,14 @@ function getInstallCommand( javaWebFramework?: string, ): string { switch (ecosystem) { - case "rust": return `cd ${projectName} && cargo build`; - case "python": return `cd ${projectName} && uv sync`; - case "go": return `cd ${projectName} && go mod tidy`; - case "elixir": return `cd ${projectName} && mix deps.get && mix compile && mix test`; + case "rust": + return `cd ${projectName} && cargo build`; + case "python": + return `cd ${projectName} && uv sync`; + case "go": + return `cd ${projectName} && go mod tidy`; + case "elixir": + return `cd ${projectName} && mix deps.get && mix compile && mix test`; case "java": if (javaWebFramework === "quarkus") { return javaBuildTool === "gradle" @@ -383,16 +390,23 @@ function getInstallCommand( return javaBuildTool === "gradle" ? `cd ${projectName} && ./gradlew test && ./gradlew bootRun` : `cd ${projectName} && ./mvnw test && ./mvnw spring-boot:run`; - default: return `cd ${projectName} && ${packageManager ?? "bun"} install`; + default: + return `cd ${projectName} && ${packageManager ?? "bun"} install`; } } - function mcpInputSchema>(schema: T): Record { return schema; } -function filterCompatibilityResult(result: { adjustedStack: CompatibilityInput | null; notes: Record; changes: { category: string; message: string }[] }, ecosystem: string) { +function filterCompatibilityResult( + result: { + adjustedStack: CompatibilityInput | null; + notes: Record; + changes: { category: string; message: string }[]; + }, + ecosystem: string, +) { const { adjustedStack, changes } = result; if (!adjustedStack) return { adjustedStack: null, changes }; @@ -408,7 +422,14 @@ function filterCompatibilityResult(result: { adjustedStack: CompatibilityInput | return { adjustedStack: filtered, changes }; } -const MCP_CODE_QUALITY_ADDONS = new Set(["biome", "oxlint", "ultracite", "lefthook", "husky", "ruler"]); +const MCP_CODE_QUALITY_ADDONS = new Set([ + "biome", + "oxlint", + "ultracite", + "lefthook", + "husky", + "ruler", +]); const MCP_DOCUMENTATION_ADDONS = new Set(["starlight", "fumadocs"]); const MCP_COMPATIBILITY_DEFAULTS = { @@ -575,17 +596,17 @@ function applyMcpInputDefaults) { - return applyMcpInputDefaults( - MCP_COMPATIBILITY_DEFAULTS, - input, - ) as Pick; + return applyMcpInputDefaults(MCP_COMPATIBILITY_DEFAULTS, input) as Pick< + CompatibilityInput, + keyof typeof MCP_COMPATIBILITY_DEFAULTS + >; } function getMcpProjectConfigDefaults(input: Record) { - return applyMcpInputDefaults( - MCP_PROJECT_CONFIG_DEFAULTS, - input, - ) as Pick; + return applyMcpInputDefaults(MCP_PROJECT_CONFIG_DEFAULTS, input) as Pick< + ProjectConfig, + keyof typeof MCP_PROJECT_CONFIG_DEFAULTS + >; } function buildProjectConfig( @@ -664,8 +685,7 @@ function buildCompatibilityInput(input: Record): CompatibilityI const codeQuality = addons.filter((a) => MCP_CODE_QUALITY_ADDONS.has(a)); const documentation = addons.filter((a) => MCP_DOCUMENTATION_ADDONS.has(a)); const appPlatforms = addons.filter( - (a) => - ![...codeQuality, ...documentation, "none"].includes(a), + (a) => ![...codeQuality, ...documentation, "none"].includes(a), ); return { @@ -685,7 +705,11 @@ function buildCompatibilityInput(input: Record): CompatibilityI }; } -function summarizeTree(tree: { fileCount: number; directoryCount: number; root: { children: { type: string; name: string; children?: unknown[] }[] } }) { +function summarizeTree(tree: { + fileCount: number; + directoryCount: number; + root: { children: { type: string; name: string; children?: unknown[] }[] }; +}) { const paths: string[] = []; function walk(nodes: { type: string; name: string; children?: unknown[] }[], prefix: string) { for (const node of nodes) { @@ -839,46 +863,559 @@ const GETTING_STARTED_MD = `# Getting Started with Better-Fullstack MCP 3. Service categories such as email and observability are scaffold-time options. To add those to an existing app, inspect the generated templates from bfs_plan_project and apply the equivalent dependency, env var, and initialization changes manually. `; +type McpToolAnnotations = { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +}; + +const guidanceOutputSchema = { + workflow: z.array(z.string()), + ecosystems: z.record(z.string(), z.string()), + fieldRules: z.record(z.string(), z.string()), + ambiguityRules: z.array(z.string()), + criticalConstraints: z.array(z.string()), +}; + +const schemaOutputSchema = { + category: z.string().optional(), + options: z.array(z.string()).optional(), + categories: z.record(z.string(), z.array(z.string())).optional(), + error: z.string().optional(), +}; + +const compatibilityIssueOutputSchema = z.object({ + code: z.string(), + message: z.string(), + category: z.string().optional(), + optionId: z.string().optional(), + provided: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(), + suggestions: z.array(z.string()).optional(), +}); + +const compatibilityOutputSchema = { + adjustedStack: z.record(z.string(), z.unknown()).nullable(), + changes: z.array(z.object({ category: z.string(), message: z.string() })), + issues: z.array(compatibilityIssueOutputSchema), + hasIssues: z.boolean(), +}; + +const graphPreviewOutputShape = { + graphSummary: z.string().optional(), + effectiveStack: z.record(z.string(), z.string()).optional(), + stackPartSpecs: z.array(z.string()).optional(), +}; + +const planProjectOutputSchema = { + success: z.boolean(), + fileCount: z.number().optional(), + directoryCount: z.number().optional(), + files: z.array(z.string()).optional(), + ...graphPreviewOutputShape, +}; + +const createProjectOutputSchema = { + success: z.boolean(), + projectDirectory: z.string().optional(), + fileCount: z.number().optional(), + addonWarnings: z.array(z.string()).optional(), + message: z.string().optional(), + ...graphPreviewOutputShape, +}; + +const planAdditionOutputSchema = { + success: z.boolean(), + existingConfig: z + .object({ + ecosystem: z.string(), + frontend: z.array(z.string()).optional(), + backend: z.string().optional(), + addons: z.array(z.string()).optional(), + graphSummary: z.string().optional(), + effectiveStack: z.record(z.string(), z.string()).optional(), + stackPartSpecs: z.array(z.string()), + }) + .optional(), + proposedAdditions: z + .object({ + newAddons: z.array(z.string()), + webDeploy: z.string().nullable(), + serverDeploy: z.string().nullable(), + graphSummary: z.string().optional(), + effectiveStack: z.record(z.string(), z.string()).optional(), + stackPartSpecs: z.array(z.string()), + }) + .optional(), + alreadyPresent: z.array(z.string()).optional(), + compatibilityWarnings: z.array(z.string()).optional(), +}; + +const addFeatureOutputSchema = { + success: z.boolean(), + addedAddons: z.array(z.string()).optional(), + projectDir: z.string().optional(), + message: z.string().optional(), + ...graphPreviewOutputShape, +}; + +function buildPresetStackSummary(config: CreateInput): string { + const parts: string[] = []; + const frontend = (config.frontend ?? []).filter((item) => item !== "none"); + if (frontend.length > 0) parts.push(`frontend: ${frontend.join("+")}`); + if (config.backend && config.backend !== "none") parts.push(`backend: ${config.backend}`); + if (config.runtime && config.runtime !== "none") parts.push(`runtime: ${config.runtime}`); + if (config.database && config.database !== "none") parts.push(`database: ${config.database}`); + if (config.orm && config.orm !== "none") parts.push(`orm: ${config.orm}`); + if (config.api && config.api !== "none") parts.push(`api: ${config.api}`); + if (config.auth && config.auth !== "none") parts.push(`auth: ${config.auth}`); + if (config.payments && config.payments !== "none") parts.push(`payments: ${config.payments}`); + const addons = (config.addons ?? []).filter((item) => item !== "none"); + if (addons.length > 0) parts.push(`addons: ${addons.join("+")}`); + return parts.join(", "); +} + +function listMcpPresets() { + const presets: { + id: Template; + name: string; + description: string; + ecosystem: "typescript" | "react-native"; + stackSummary: string; + stack: CreateInput; + }[] = []; + for (const id of TEMPLATE_VALUES) { + if (id === "none") continue; + const config = getTemplateConfig(id); + if (!config) continue; + const frontend = (config.frontend ?? []) as string[]; + const ecosystem = frontend.some((item) => item.startsWith("native-")) + ? "react-native" + : "typescript"; + presets.push({ + id, + name: id.toUpperCase(), + description: getTemplateDescription(id), + ecosystem, + stackSummary: buildPresetStackSummary(config), + stack: config, + }); + } + return presets; +} + +function briefMatches(text: string, keywords: string[]): boolean { + const tokens = new Set(text.split(/[^a-z0-9]+/i).filter(Boolean)); + return keywords.some((keyword) => + keyword.includes(" ") ? text.includes(keyword) : tokens.has(keyword), + ); +} + +function matchNearestPreset(input: Record): Template | null { + const signatureKeys = ["database", "backend", "api", "auth"] as const; + const inputFrontend = (input.frontend as string[] | undefined)?.[0]; + let best: { id: Template; score: number } | null = null; + for (const id of TEMPLATE_VALUES) { + if (id === "none") continue; + const config = getTemplateConfig(id); + if (!config) continue; + let score = 0; + for (const key of signatureKeys) { + const value = config[key]; + if (value !== undefined && value === input[key]) score += 1; + } + const presetFrontend = (config.frontend ?? [])[0]; + if (presetFrontend && presetFrontend === inputFrontend) score += 1; + if (!best || score > best.score) best = { id, score }; + } + return best && best.score >= 3 ? best.id : null; +} + +function recommendStackFromBrief( + brief: string, + ecosystemHint?: ProjectConfig["ecosystem"], +): { input: Record; rationale: string[]; matchedPreset: Template | null } { + const text = brief.toLowerCase(); + const has = (...keywords: string[]) => briefMatches(text, keywords); + const rationale: string[] = []; + const input: Record = {}; + + if (ecosystemHint && ecosystemHint !== "typescript") { + input.ecosystem = ecosystemHint; + rationale.push( + `Ecosystem forced to ${ecosystemHint} from the provided hint; using ${ecosystemHint} defaults.`, + ); + return { input, rationale, matchedPreset: null }; + } + + const wantsMobile = + has("mobile", "ios", "android", "expo") || + text.includes("react native") || + text.includes("react-native") || + text.includes("app store") || + text.includes("play store"); + if (wantsMobile) { + input.ecosystem = "react-native"; + input.frontend = ["native-uniwind"]; + input.backend = "none"; + input.runtime = "none"; + input.api = "none"; + input.database = "none"; + input.orm = "none"; + rationale.push( + "Mobile app detected: React Native (Expo) with the native-uniwind styling preset and no bundled backend.", + ); + return { input, rationale, matchedPreset: "uniwind" }; + } + + input.ecosystem = "typescript"; + input.frontend = ["tanstack-router"]; + input.backend = "hono"; + input.runtime = "bun"; + input.database = "sqlite"; + input.orm = "drizzle"; + input.api = "trpc"; + rationale.push( + "Default TypeScript fullstack baseline: TanStack Router + Hono + tRPC on SQLite/Drizzle (Bun).", + ); + + if (has("postgres", "postgresql", "supabase", "neon")) { + input.database = "postgres"; + input.orm = "drizzle"; + rationale.push("Postgres requested: database=postgres, orm=drizzle."); + } else if (has("mysql", "planetscale")) { + input.database = "mysql"; + input.orm = "drizzle"; + rationale.push("MySQL requested: database=mysql, orm=drizzle."); + } else if (has("mongo", "mongodb")) { + input.database = "mongodb"; + input.orm = "mongoose"; + rationale.push("MongoDB requested: database=mongodb, orm=mongoose."); + } + + const wantsSaas = has( + "saas", + "payment", + "payments", + "billing", + "subscription", + "subscriptions", + "checkout", + "stripe", + "ecommerce", + ); + if (wantsSaas) { + input.payments = "stripe"; + input.auth = "better-auth"; + if (input.database === "sqlite") { + input.database = "postgres"; + input.orm = "drizzle"; + } + rationale.push( + "SaaS/payments detected: Stripe + better-auth, upgraded to Postgres/Drizzle for production data.", + ); + } else if ( + has( + "auth", + "login", + "signin", + "signup", + "account", + "accounts", + "user", + "users", + "authentication", + ) + ) { + input.auth = "better-auth"; + rationale.push("Authentication requested: auth=better-auth."); + } + + if ( + has("ai", "chatbot", "llm", "gpt", "rag", "agent", "agents", "openai", "assistant", "copilot") + ) { + input.ai = "vercel-ai"; + input.examples = ["ai"]; + rationale.push("AI/chatbot detected: Vercel AI SDK with the bundled AI example."); + } + + if (has("blog", "content", "cms", "marketing", "landing", "publishing")) { + input.cms = "sanity"; + rationale.push("Content/marketing site detected: Sanity CMS."); + } + + if ( + has("realtime", "collaborative", "collaboration", "multiplayer", "presence") || + text.includes("real-time") || + text.includes("real time") + ) { + input.realtime = "socket-io"; + rationale.push("Realtime/collaboration detected: Socket.IO."); + } + + let matchedPreset: Template | null = null; + if (has("t3")) matchedPreset = "t3"; + else if (has("mern")) matchedPreset = "mern"; + else if (has("pern")) matchedPreset = "pern"; + else matchedPreset = matchNearestPreset(input); + if (matchedPreset) { + rationale.push(`Closest ready-made preset: ${matchedPreset}.`); + } + + return { input, rationale, matchedPreset }; +} + +function normalizeAdjustedToInput( + adjusted: Record, + base: Record, +): Record { + const webFrontend = (adjusted.webFrontend as string[] | undefined) ?? []; + const nativeFrontend = (adjusted.nativeFrontend as string[] | undefined) ?? []; + const frontend = [...webFrontend, ...nativeFrontend]; + const codeQuality = (adjusted.codeQuality as string[] | undefined) ?? []; + const documentation = (adjusted.documentation as string[] | undefined) ?? []; + const appPlatforms = (adjusted.appPlatforms as string[] | undefined) ?? []; + return { + ...adjusted, + projectName: base.projectName, + ecosystem: adjusted.ecosystem ?? base.ecosystem, + frontend: frontend.length > 0 ? frontend : (base.frontend as string[] | undefined), + addons: [...codeQuality, ...documentation, ...appPlatforms], + ai: adjusted.aiSdk ?? base.ai, + }; +} + +function summarizeRecommendedConfig(config: ProjectConfig) { + return { + projectName: config.projectName, + ecosystem: config.ecosystem, + frontend: config.frontend, + backend: config.backend, + runtime: config.runtime, + database: config.database, + orm: config.orm, + api: config.api, + auth: config.auth, + payments: config.payments, + ai: config.ai, + cms: config.cms, + realtime: config.realtime, + examples: config.examples, + addons: config.addons, + }; +} + export async function startMcpServer() { const server = new McpServer( { name: "better-fullstack", version: getLatestCLIVersion() }, { instructions: INSTRUCTIONS, capabilities: { logging: {} } }, ); - const registerTool = server.tool.bind(server) as unknown as >( + const registerTool = = Record>( name: string, - description: string, - inputSchema: Record, + config: { + description: string; + inputSchema?: Record; + outputSchema?: Record; + annotations?: McpToolAnnotations; + }, cb: (input: Input) => unknown, - ) => void; + ): void => { + ( + server.registerTool as unknown as ( + toolName: string, + toolConfig: Record, + toolCb: (input: Input) => unknown, + ) => void + )(name, config, cb); + }; registerTool( "bfs_get_guidance", - "Returns workflow rules, field semantics, ambiguity rules, and critical constraints. Call this FIRST before using other tools.", - mcpInputSchema({}), + { + description: + "Returns workflow rules, field semantics, ambiguity rules, and critical constraints. Call this FIRST before using other tools.", + inputSchema: mcpInputSchema({}), + outputSchema: guidanceOutputSchema, + annotations: { + title: "Get guidance", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, async () => { const guidance = getGuidance(); return { content: [{ type: "text", text: JSON.stringify(guidance, null, 2) }], + structuredContent: guidance, }; }, ); registerTool( "bfs_get_schema", - "Returns valid options for a specific category (e.g., 'database', 'frontend', 'backend') or ALL categories. Use ecosystem to filter to relevant categories only.", - mcpInputSchema({ - category: z.string().optional().describe("Category name (e.g., 'database', 'orm', 'frontend'). Omit for all categories."), - ecosystem: EcosystemSchema.optional().describe("Filter categories to this ecosystem (e.g., 'rust' returns only Rust + shared categories)."), - }), - async ({ category, ecosystem }: { category?: string; ecosystem?: ProjectConfig["ecosystem"] }) => { + { + description: + "Returns valid options for a specific category (e.g., 'database', 'frontend', 'backend') or ALL categories. Use ecosystem to filter to relevant categories only.", + inputSchema: mcpInputSchema({ + category: z + .string() + .optional() + .describe( + "Category name (e.g., 'database', 'orm', 'frontend'). Omit for all categories.", + ), + ecosystem: EcosystemSchema.optional().describe( + "Filter categories to this ecosystem (e.g., 'rust' returns only Rust + shared categories).", + ), + }), + outputSchema: schemaOutputSchema, + annotations: { + title: "Get schema options", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ + category, + ecosystem, + }: { + category?: string; + ecosystem?: ProjectConfig["ecosystem"]; + }) => { const result = getSchemaOptions(category, ecosystem); + const structuredContent = + "error" in result + ? { error: result.error } + : "category" in result + ? { category: result.category, options: result.options } + : { categories: result }; return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + structuredContent, + }; + }, + ); + + registerTool( + "bfs_list_presets", + { + description: + "Lists the ready-made stack presets available to the CLI (mern, pern, t3, uniwind) with id, name, description, ecosystem, and a stack summary. Use to discover a starting point before bfs_recommend_stack, bfs_plan_project, or bfs_create_project.", + inputSchema: mcpInputSchema({}), + annotations: { + title: "List presets", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async () => { + const presets = listMcpPresets(); + return { + content: [{ type: "text", text: JSON.stringify({ presets }, null, 2) }], }; }, ); + registerTool( + "bfs_recommend_stack", + { + description: + "Recommends a compatibility-validated stack from a natural-language brief using deterministic keyword rules (no LLM). Returns the config, rationale, any auto-applied compatibility adjustments, the nearest matching preset, and a reproducible CLI command.", + inputSchema: mcpInputSchema({ + brief: z + .string() + .describe( + "Natural-language description of the app to build (e.g., 'a SaaS with payments and auth').", + ), + ecosystem: EcosystemSchema.optional().describe( + "Force a language ecosystem. Omit to let the brief decide (defaults to TypeScript).", + ), + projectName: z + .string() + .optional() + .describe("Project name (kebab-case). Default: 'my-app'."), + }), + annotations: { + title: "Recommend stack", + readOnlyHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ + brief, + ecosystem, + projectName, + }: { + brief: string; + ecosystem?: ProjectConfig["ecosystem"]; + projectName?: string; + }) => { + try { + const { + input: recommended, + rationale, + matchedPreset, + } = recommendStackFromBrief(brief, ecosystem); + const baseInput: Record = { + projectName: projectName ?? "my-app", + ...recommended, + }; + const compatResult = analyzeStackCompatibility(buildCompatibilityInput(baseInput)); + const normalizedInput = compatResult.adjustedStack + ? normalizeAdjustedToInput( + compatResult.adjustedStack as unknown as Record, + baseInput, + ) + : baseInput; + const finalConfig = buildProjectConfig(normalizedInput, { + projectDir: `/${baseInput.projectName as string}`, + }); + const adjustments = compatResult.changes.map( + (change) => `${change.category}: ${change.message}`, + ); + const graphPreview = getMcpGraphPreview(finalConfig); + const reproducibleCommand = generateReproducibleCommand(finalConfig); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + brief, + config: summarizeRecommendedConfig(finalConfig), + rationale, + adjustments, + matchedPreset, + reproducibleCommand, + ...graphPreview, + nextSteps: + "Call bfs_plan_project with this config to preview the files, then bfs_create_project to scaffold it.", + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Recommend stack failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + }, + ); + const mobileInputSchema = { mobileNavigation: MobileNavigationSchema.optional().describe("Mobile navigation"), mobileUI: MobileUISchema.optional().describe("Mobile UI"), @@ -981,58 +1518,91 @@ export async function startMcpServer() { registerTool( "bfs_check_compatibility", - "Validates a stack combination and returns auto-adjusted selections with warnings. Call BEFORE creating a project to avoid invalid combinations.", - mcpInputSchema({ - ecosystem: EcosystemSchema.describe("Language ecosystem"), - frontend: z.array(z.string()).optional().describe("Web frontend frameworks (TypeScript only)"), - backend: z.string().optional().describe("Backend framework"), - runtime: z.string().optional().describe("JavaScript runtime"), - database: z.string().optional().describe("Database type"), - orm: z.string().optional().describe("ORM"), - api: z.string().optional().describe("API layer"), - auth: z.string().optional().describe("Auth provider"), - payments: z.string().optional().describe("Payments provider"), - email: EmailSchema.optional().describe("Email provider"), - fileUpload: FileUploadSchema.optional().describe("File upload provider"), - ai: AISchema.optional().describe("AI SDK"), - stateManagement: StateManagementSchema.optional().describe("State management"), - forms: FormsSchema.optional().describe("Forms library"), - validation: ValidationSchema.optional().describe("Validation library"), - testing: TestingSchema.optional().describe("Testing framework"), - realtime: RealtimeSchema.optional().describe("Realtime library"), - jobQueue: JobQueueSchema.optional().describe("Job queue"), - animation: AnimationSchema.optional().describe("Animation library"), - logging: LoggingSchema.optional().describe("Logging library"), - observability: ObservabilitySchema.optional().describe("Observability provider"), - featureFlags: FeatureFlagsSchema.optional().describe("Feature flags provider"), - analytics: AnalyticsSchema.optional().describe("Analytics provider"), - cms: CMSSchema.optional().describe("CMS"), - caching: CachingSchema.optional().describe("Caching solution"), - rateLimit: RateLimitSchema.optional().describe("Rate limiting solution"), - i18n: I18nSchema.optional().describe("Internationalization library"), - search: SearchSchema.optional().describe("Search engine"), - fileStorage: FileStorageSchema.optional().describe("File storage"), - ...mobileInputSchema, - ...deploymentInputSchema, - astroIntegration: AstroIntegrationSchema.optional().describe("Astro UI framework integration"), - uiLibrary: z.string().optional().describe("UI component library"), - cssFramework: z.string().optional().describe("CSS framework"), - addons: z.array(AddonsSchema).optional().describe("Addon list"), - examples: z.array(ExamplesSchema).optional().describe("Example templates"), - packageManager: PackageManagerSchema.optional().describe("Package manager"), - ...crossEcosystemInputSchema, - }), + { + description: + "Validates a stack combination and returns auto-adjusted selections with warnings. Call BEFORE creating a project to avoid invalid combinations.", + outputSchema: compatibilityOutputSchema, + annotations: { + title: "Check stack compatibility", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + inputSchema: mcpInputSchema({ + ecosystem: EcosystemSchema.describe("Language ecosystem"), + frontend: z + .array(z.string()) + .optional() + .describe("Web frontend frameworks (TypeScript only)"), + backend: z.string().optional().describe("Backend framework"), + runtime: z.string().optional().describe("JavaScript runtime"), + database: z.string().optional().describe("Database type"), + orm: z.string().optional().describe("ORM"), + api: z.string().optional().describe("API layer"), + auth: z.string().optional().describe("Auth provider"), + payments: z.string().optional().describe("Payments provider"), + email: EmailSchema.optional().describe("Email provider"), + fileUpload: FileUploadSchema.optional().describe("File upload provider"), + ai: AISchema.optional().describe("AI SDK"), + stateManagement: StateManagementSchema.optional().describe("State management"), + forms: FormsSchema.optional().describe("Forms library"), + validation: ValidationSchema.optional().describe("Validation library"), + testing: TestingSchema.optional().describe("Testing framework"), + realtime: RealtimeSchema.optional().describe("Realtime library"), + jobQueue: JobQueueSchema.optional().describe("Job queue"), + animation: AnimationSchema.optional().describe("Animation library"), + logging: LoggingSchema.optional().describe("Logging library"), + observability: ObservabilitySchema.optional().describe("Observability provider"), + featureFlags: FeatureFlagsSchema.optional().describe("Feature flags provider"), + analytics: AnalyticsSchema.optional().describe("Analytics provider"), + cms: CMSSchema.optional().describe("CMS"), + caching: CachingSchema.optional().describe("Caching solution"), + rateLimit: RateLimitSchema.optional().describe("Rate limiting solution"), + i18n: I18nSchema.optional().describe("Internationalization library"), + search: SearchSchema.optional().describe("Search engine"), + fileStorage: FileStorageSchema.optional().describe("File storage"), + ...mobileInputSchema, + ...deploymentInputSchema, + astroIntegration: AstroIntegrationSchema.optional().describe( + "Astro UI framework integration", + ), + uiLibrary: z.string().optional().describe("UI component library"), + cssFramework: z.string().optional().describe("CSS framework"), + addons: z.array(AddonsSchema).optional().describe("Addon list"), + examples: z.array(ExamplesSchema).optional().describe("Example templates"), + packageManager: PackageManagerSchema.optional().describe("Package manager"), + ...crossEcosystemInputSchema, + }), + }, async (input: Record) => { try { const compatInput = buildCompatibilityInput(input); const result = analyzeStackCompatibility(compatInput); const filtered = filterCompatibilityResult(result, input.ecosystem as string); + const evaluation = evaluateCompatibility(compatInput); + const structuredContent = { + adjustedStack: filtered.adjustedStack, + changes: filtered.changes, + issues: evaluation.issues, + hasIssues: evaluation.issues.length > 0, + }; return { - content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }], + content: [ + { + type: "text", + text: JSON.stringify({ ...filtered, issues: evaluation.issues }, null, 2), + }, + ], + structuredContent, }; } catch (error) { return { - content: [{ type: "text", text: `Compatibility check failed: ${error instanceof Error ? error.message : String(error)}` }], + content: [ + { + type: "text", + text: `Compatibility check failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], isError: true, }; } @@ -1093,28 +1663,51 @@ export async function startMcpServer() { registerTool( "bfs_plan_project", - "Dry-run: generates a project in-memory and returns the file tree WITHOUT writing to disk. Use this to preview what would be created.", - mcpInputSchema(planCreateSchema), + { + description: + "Dry-run: generates a project in-memory and returns the file tree WITHOUT writing to disk. Use this to preview what would be created.", + inputSchema: mcpInputSchema(planCreateSchema), + outputSchema: planProjectOutputSchema, + annotations: { + title: "Plan project (dry run)", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, async (input: Record) => { try { - const { generateVirtualProject, EMBEDDED_TEMPLATES } = await import("@better-fullstack/template-generator"); + const { generateVirtualProject, EMBEDDED_TEMPLATES } = + await import("@better-fullstack/template-generator"); const config = buildProjectConfig(input); const result = await generateVirtualProject({ config, templates: EMBEDDED_TEMPLATES }); if (result.success && result.tree) { const summary = summarizeTree(result.tree); const graphPreview = getMcpGraphPreview(config); + const payload = { success: true as const, ...summary, ...graphPreview }; return { - content: [{ type: "text", text: JSON.stringify({ success: true, ...summary, ...graphPreview }, null, 2) }], + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, }; } return { - content: [{ type: "text", text: JSON.stringify({ success: false, error: result.error ?? "Unknown error" }) }], + content: [ + { + type: "text", + text: JSON.stringify({ success: false, error: result.error ?? "Unknown error" }), + }, + ], isError: true, }; } catch (error) { return { - content: [{ type: "text", text: `Plan failed: ${error instanceof Error ? error.message : String(error)}` }], + content: [ + { + type: "text", + text: `Plan failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], isError: true, }; } @@ -1123,18 +1716,38 @@ export async function startMcpServer() { registerTool( "bfs_create_project", - "Creates a new fullstack project on disk. Dependencies are NOT installed (agent must tell user to install manually). Call bfs_plan_project first to preview.", - mcpInputSchema({ ...planCreateSchema, projectName: z.string().describe("Project name (kebab-case). Will be the directory name."), targetDir: z.string().optional().describe("Absolute path to the parent directory in which to create the project folder (default: current working directory).") }), + { + description: + "Creates a new fullstack project on disk. Dependencies are NOT installed (agent must tell user to install manually). Call bfs_plan_project first to preview.", + inputSchema: mcpInputSchema({ + ...planCreateSchema, + projectName: z.string().describe("Project name (kebab-case). Will be the directory name."), + targetDir: z + .string() + .optional() + .describe( + "Absolute path to the parent directory in which to create the project folder (default: current working directory).", + ), + }), + outputSchema: createProjectOutputSchema, + annotations: { + title: "Create project", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, async (input: Record & { projectName: string }) => { try { - const { generateVirtualProject, EMBEDDED_TEMPLATES } = await import("@better-fullstack/template-generator"); - const { writeTreeToFilesystem } = await import("@better-fullstack/template-generator/fs-writer"); + const { generateVirtualProject, EMBEDDED_TEMPLATES } = + await import("@better-fullstack/template-generator"); + const { writeTreeToFilesystem } = + await import("@better-fullstack/template-generator/fs-writer"); const path = await import("node:path"); const projectName = sanitizePath(input.projectName); - const targetDir = input.targetDir - ? sanitizePath(input.targetDir as string) - : undefined; + const targetDir = input.targetDir ? sanitizePath(input.targetDir as string) : undefined; const projectDir = path.resolve(targetDir ?? process.cwd(), projectName); const config = buildProjectConfig(input, { projectDir }); @@ -1144,7 +1757,15 @@ export async function startMcpServer() { const result = await generateVirtualProject({ config, templates: EMBEDDED_TEMPLATES }); if (!result.success || !result.tree) { return { - content: [{ type: "text", text: JSON.stringify({ success: false, error: result.error ?? "Generation failed" }) }], + content: [ + { + type: "text", + text: JSON.stringify({ + success: false, + error: result.error ?? "Generation failed", + }), + }, + ], isError: true, }; } @@ -1168,22 +1789,26 @@ export async function startMcpServer() { input.javaBuildTool as string | undefined, input.javaWebFramework as string | undefined, ); + const payload = { + success: true as const, + projectDirectory: projectDir, + fileCount: result.tree.fileCount, + ...graphPreview, + ...(addonWarnings.length > 0 ? { addonWarnings } : {}), + message: `Project created at ${projectDir}. Tell the user to run: ${installCmd}`, + }; return { - content: [{ - type: "text", - text: JSON.stringify({ - success: true, - projectDirectory: projectDir, - fileCount: result.tree.fileCount, - ...graphPreview, - ...(addonWarnings.length > 0 ? { addonWarnings } : {}), - message: `Project created at ${projectDir}. Tell the user to run: ${installCmd}`, - }, null, 2), - }], + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, }; } catch (error) { return { - content: [{ type: "text", text: `Project creation failed: ${error instanceof Error ? error.message : String(error)}` }], + content: [ + { + type: "text", + text: `Project creation failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], isError: true, }; } @@ -1192,20 +1817,48 @@ export async function startMcpServer() { registerTool( "bfs_plan_addition", - "Validates what would be added to an existing project. Reads the project config (bts.jsonc) and checks which addons are new.", - mcpInputSchema({ - projectDir: z.string().describe("Absolute path to the existing project directory"), - addons: z.array(AddonsSchema).optional().describe("Addons to add"), - webDeploy: WebDeploySchema.optional().describe("Web deployment option"), - serverDeploy: ServerDeploySchema.optional().describe("Server deployment option"), - }), - async ({ projectDir, addons, webDeploy, serverDeploy }: { projectDir: string; addons?: ProjectConfig["addons"]; webDeploy?: ProjectConfig["webDeploy"]; serverDeploy?: ProjectConfig["serverDeploy"] }) => { + { + description: + "Validates what would be added to an existing project. Reads the project config (bts.jsonc) and checks which addons are new.", + inputSchema: mcpInputSchema({ + projectDir: z.string().describe("Absolute path to the existing project directory"), + addons: z.array(AddonsSchema).optional().describe("Addons to add"), + webDeploy: WebDeploySchema.optional().describe("Web deployment option"), + serverDeploy: ServerDeploySchema.optional().describe("Server deployment option"), + }), + outputSchema: planAdditionOutputSchema, + annotations: { + title: "Plan feature addition", + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ + projectDir, + addons, + webDeploy, + serverDeploy, + }: { + projectDir: string; + addons?: ProjectConfig["addons"]; + webDeploy?: ProjectConfig["webDeploy"]; + serverDeploy?: ProjectConfig["serverDeploy"]; + }) => { try { const safePath = sanitizePath(projectDir); const config = await readBtsConfig(safePath); if (!config) { return { - content: [{ type: "text", text: JSON.stringify({ success: false, error: `No bts.jsonc found in ${safePath}. Is this a Better-Fullstack project?` }) }], + content: [ + { + type: "text", + text: JSON.stringify({ + success: false, + error: `No bts.jsonc found in ${safePath}. Is this a Better-Fullstack project?`, + }), + }, + ], isError: true, }; } @@ -1228,40 +1881,43 @@ export async function startMcpServer() { serverDeploy: serverDeploy ?? config.serverDeploy, }); const compatResult = analyzeStackCompatibility(compatInput); - const compatibilityWarnings = compatResult.changes.length > 0 - ? compatResult.changes.map((c) => c.message) - : undefined; - + const compatibilityWarnings = + compatResult.changes.length > 0 ? compatResult.changes.map((c) => c.message) : undefined; + + const payload = { + success: true as const, + existingConfig: { + ecosystem: config.ecosystem, + frontend: config.frontend, + backend: config.backend, + addons: config.addons, + graphSummary: existingGraphPreview.graphSummary, + effectiveStack: existingGraphPreview.effectiveStack, + stackPartSpecs: existingGraphPreview.stackPartSpecs, + }, + proposedAdditions: { + newAddons, + webDeploy: webDeploy ?? null, + serverDeploy: serverDeploy ?? null, + graphSummary: proposedGraphPreview.graphSummary, + effectiveStack: proposedGraphPreview.effectiveStack, + stackPartSpecs: proposedGraphPreview.stackPartSpecs, + }, + alreadyPresent: (addons ?? []).filter((a) => existingAddons.has(a)), + ...(compatibilityWarnings ? { compatibilityWarnings } : {}), + }; return { - content: [{ - type: "text", - text: JSON.stringify({ - success: true, - existingConfig: { - ecosystem: config.ecosystem, - frontend: config.frontend, - backend: config.backend, - addons: config.addons, - graphSummary: existingGraphPreview.graphSummary, - effectiveStack: existingGraphPreview.effectiveStack, - stackPartSpecs: existingGraphPreview.stackPartSpecs, - }, - proposedAdditions: { - newAddons, - webDeploy: webDeploy ?? null, - serverDeploy: serverDeploy ?? null, - graphSummary: proposedGraphPreview.graphSummary, - effectiveStack: proposedGraphPreview.effectiveStack, - stackPartSpecs: proposedGraphPreview.stackPartSpecs, - }, - alreadyPresent: (addons ?? []).filter((a) => existingAddons.has(a)), - ...(compatibilityWarnings ? { compatibilityWarnings } : {}), - }, null, 2), - }], + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, }; } catch (error) { return { - content: [{ type: "text", text: `Plan addition failed: ${error instanceof Error ? error.message : String(error)}` }], + content: [ + { + type: "text", + text: `Plan addition failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], isError: true, }; } @@ -1270,14 +1926,25 @@ export async function startMcpServer() { registerTool( "bfs_add_feature", - "Adds addons/features to an existing Better-Fullstack project. Dependencies are NOT installed. Call bfs_plan_addition first to validate.", - mcpInputSchema({ - projectDir: z.string().describe("Absolute path to the existing project directory"), - addons: z.array(AddonsSchema).optional().describe("Addons to add"), - webDeploy: WebDeploySchema.optional().describe("Web deployment option"), - serverDeploy: ServerDeploySchema.optional().describe("Server deployment option"), - packageManager: PackageManagerSchema.optional().describe("Package manager to use"), - }), + { + description: + "Adds addons/features to an existing Better-Fullstack project. Dependencies are NOT installed. Call bfs_plan_addition first to validate.", + inputSchema: mcpInputSchema({ + projectDir: z.string().describe("Absolute path to the existing project directory"), + addons: z.array(AddonsSchema).optional().describe("Addons to add"), + webDeploy: WebDeploySchema.optional().describe("Web deployment option"), + serverDeploy: ServerDeploySchema.optional().describe("Server deployment option"), + packageManager: PackageManagerSchema.optional().describe("Package manager to use"), + }), + outputSchema: addFeatureOutputSchema, + annotations: { + title: "Add feature", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, async (input: Record & { projectDir: string }) => { try { const safePath = sanitizePath(input.projectDir); @@ -1305,26 +1972,38 @@ export async function startMcpServer() { existingConfig?.javaBuildTool, existingConfig?.javaWebFramework, ); + const payload = { + success: true as const, + addedAddons: result.addedAddons, + projectDir: result.projectDir, + ...graphPreview, + message: `Added ${result.addedAddons.join(", ")} to project. Tell the user to run: ${installCmd}`, + }; return { - content: [{ - type: "text", - text: JSON.stringify({ - success: true, - addedAddons: result.addedAddons, - projectDir: result.projectDir, - ...graphPreview, - message: `Added ${result.addedAddons.join(", ")} to project. Tell the user to run: ${installCmd}`, - }, null, 2), - }], + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, }; } return { - content: [{ type: "text", text: JSON.stringify({ success: false, error: result?.error ?? "Add command returned no result" }) }], + content: [ + { + type: "text", + text: JSON.stringify({ + success: false, + error: result?.error ?? "Add command returned no result", + }), + }, + ], isError: true, }; } catch (error) { return { - content: [{ type: "text", text: `Add feature failed: ${error instanceof Error ? error.message : String(error)}` }], + content: [ + { + type: "text", + text: `Add feature failed: ${error instanceof Error ? error.message : String(error)}`, + }, + ], isError: true, }; } @@ -1334,7 +2013,11 @@ export async function startMcpServer() { server.resource( "compatibility-rules", "docs://compatibility-rules", - { description: "Stack compatibility rules — which frontend/backend/API/ORM combinations are valid. Read this BEFORE scaffolding.", mimeType: "text/markdown" }, + { + description: + "Stack compatibility rules — which frontend/backend/API/ORM combinations are valid. Read this BEFORE scaffolding.", + mimeType: "text/markdown", + }, async () => ({ contents: [{ uri: "docs://compatibility-rules", text: COMPATIBILITY_RULES_MD }], }), @@ -1343,16 +2026,24 @@ export async function startMcpServer() { server.resource( "stack-options", "docs://stack-options", - { description: "All available technology options per category for every ecosystem.", mimeType: "application/json" }, + { + description: "All available technology options per category for every ecosystem.", + mimeType: "application/json", + }, async () => ({ - contents: [{ uri: "docs://stack-options", text: JSON.stringify(getSchemaOptions(), null, 2) }], + contents: [ + { uri: "docs://stack-options", text: JSON.stringify(getSchemaOptions(), null, 2) }, + ], }), ); server.resource( "getting-started", "docs://getting-started", - { description: "Quick start guide for scaffolding projects with Better-Fullstack MCP.", mimeType: "text/markdown" }, + { + description: "Quick start guide for scaffolding projects with Better-Fullstack MCP.", + mimeType: "text/markdown", + }, async () => ({ contents: [{ uri: "docs://getting-started", text: GETTING_STARTED_MD }], }), From 23336005250b36ff1098e7113b0252464700b9e4 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 18 Jun 2026 20:32:09 +0300 Subject: [PATCH 20/36] fix(mcp,ci): ecosystem-correct compatibility issues, recommend config, and CI for all ecosystems Adversarial-verification fixes for the roadmap work: - bfs_check_compatibility: filter evaluation.issues to the target ecosystem so a clean TS stack no longer reports cross-ecosystem false positives (hasIssues now reflects only relevant categories). - bfs_recommend_stack: gate TS-web fields (frontend/backend/runtime/api) out of the summary for native-backend ecosystems, and add an explicit rationale note that brief keyword analysis is TypeScript-only. - recommendStackFromBrief: treat admin/dashboard/portal/members/rbac/ permissions/roles as auth signals (better-auth). - config-prompts: allow the github-actions addon for non-TS ecosystems (was silently dropped alongside docker-compose). - ci.yml addon: route react-native through the JS package-manager flow instead of the degrade echo; make pnpm/yarn install non-strict to match bun/npm; add real CI jobs for elixir, java (maven+gradle), and dotnet so the addon is useful across all 8 ecosystems. --- apps/cli/src/mcp.ts | 46 +++++++++++++--- apps/cli/src/prompts/config-prompts.ts | 17 +++--- .../.github/workflows/ci.yml.hbs | 55 ++++++++++++++++++- 3 files changed, 101 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 969574653..64bf28be7 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -1046,6 +1046,9 @@ function recommendStackFromBrief( rationale.push( `Ecosystem forced to ${ecosystemHint} from the provided hint; using ${ecosystemHint} defaults.`, ); + rationale.push( + `Brief keyword analysis (database/auth/payments/AI feature detection) currently applies to the TypeScript ecosystem only — configure those features explicitly for ${ecosystemHint} via bfs_check_compatibility.`, + ); return { input, rationale, matchedPreset: null }; } @@ -1126,6 +1129,13 @@ function recommendStackFromBrief( "user", "users", "authentication", + "admin", + "dashboard", + "portal", + "members", + "rbac", + "permissions", + "roles", ) ) { input.auth = "better-auth"; @@ -1187,15 +1197,23 @@ function normalizeAdjustedToInput( } function summarizeRecommendedConfig(config: ProjectConfig) { + // frontend/backend/runtime/api are TypeScript-web concepts; for native backend + // ecosystems (rust/go/python/java/dotnet/elixir) the framework lives in stackParts, + // so surfacing the TS-shaped defaults here would misrepresent the recommendation. + const isTsWeb = config.ecosystem === "typescript" || config.ecosystem === "react-native"; return { projectName: config.projectName, ecosystem: config.ecosystem, - frontend: config.frontend, - backend: config.backend, - runtime: config.runtime, + ...(isTsWeb + ? { + frontend: config.frontend, + backend: config.backend, + runtime: config.runtime, + api: config.api, + } + : {}), database: config.database, orm: config.orm, - api: config.api, auth: config.auth, payments: config.payments, ai: config.ai, @@ -1580,17 +1598,31 @@ export async function startMcpServer() { const result = analyzeStackCompatibility(compatInput); const filtered = filterCompatibilityResult(result, input.ecosystem as string); const evaluation = evaluateCompatibility(compatInput); + // Filter issues to the selected ecosystem, mirroring filterCompatibilityResult. + // buildCompatibilityInput injects every ecosystem's defaults, so evaluate() + // otherwise flags cross-ecosystem leftovers (elixir*/cssFramework/etc.) as + // spurious INCOMPATIBLE issues on a perfectly valid stack. + const relevantEcosystem = isMcpEcosystem(input.ecosystem as string) + ? (input.ecosystem as OptionCategoryEcosystem) + : "typescript"; + const relevantIssueKeys = new Set([ + ...getMcpCategoryKeysForEcosystem(relevantEcosystem), + ...MCP_SHARED_COMPATIBILITY_KEYS, + ]); + const issues = evaluation.issues.filter( + (issue) => !issue.category || relevantIssueKeys.has(issue.category), + ); const structuredContent = { adjustedStack: filtered.adjustedStack, changes: filtered.changes, - issues: evaluation.issues, - hasIssues: evaluation.issues.length > 0, + issues, + hasIssues: issues.length > 0, }; return { content: [ { type: "text", - text: JSON.stringify({ ...filtered, issues: evaluation.issues }, null, 2), + text: JSON.stringify({ ...filtered, issues }, null, 2), }, ], structuredContent, diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 0352bfea4..d4110b168 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -527,13 +527,16 @@ export async function gatherConfig( if (results.ecosystem !== "typescript") return Promise.resolve("none" as Effect); return getEffectChoice(flags.effect); }, - addons: ({ results }) => { - if (results.ecosystem !== "typescript") { - const nonTypeScriptAddons = (flags.addons ?? []).filter( - (addon): addon is Addons => addon === "docker-compose" || addon === "devcontainer", - ); - return Promise.resolve(nonTypeScriptAddons); - } + addons: ({ results }) => { + if (results.ecosystem !== "typescript") { + const nonTypeScriptAddons = (flags.addons ?? []).filter( + (addon): addon is Addons => + addon === "docker-compose" || + addon === "devcontainer" || + addon === "github-actions", + ); + return Promise.resolve(nonTypeScriptAddons); + } return getAddonsChoice( flags.addons, results.frontend, diff --git a/packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs b/packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs index 93855b8b9..b49b4bae9 100644 --- a/packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs +++ b/packages/template-generator/templates/addons/github-actions/.github/workflows/ci.yml.hbs @@ -15,7 +15,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 -{{#if (eq ecosystem "typescript")}} +{{#if (or (eq ecosystem "typescript") (eq ecosystem "react-native"))}} {{#if (eq packageManager "bun")}} - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -50,7 +50,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install {{#if (includes addons "biome")}} - name: Lint @@ -74,7 +74,7 @@ jobs: cache: yarn - name: Install dependencies - run: yarn install --immutable + run: yarn install {{#if (includes addons "biome")}} - name: Lint @@ -163,6 +163,55 @@ jobs: - name: Compile sources run: python -m compileall -q . +{{else if (eq ecosystem "elixir")}} + - name: Setup Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: "27" + elixir-version: "1.17" + + - name: Install dependencies + run: mix deps.get + + - name: Format check + run: mix format --check-formatted + + - name: Compile (warnings as errors) + run: mix compile --warnings-as-errors + + - name: Test + run: mix test +{{else if (eq ecosystem "java")}} + - name: Setup JDK + uses: actions/setup-java@v4 + with: + java-version: "21" + distribution: temurin +{{#if (eq javaBuildTool "gradle")}} + cache: gradle + + - name: Build & Test + run: ./gradlew build +{{else}} + cache: maven + + - name: Build & Test + run: ./mvnw --batch-mode verify +{{/if}} +{{else if (eq ecosystem "dotnet")}} + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "9.0.x" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release {{else}} - name: Build run: echo "Add CI steps for the {{ecosystem}} ecosystem" From e1310e0a582b6fbc543451dd664064c406f8a227 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 20 Jun 2026 00:14:58 +0300 Subject: [PATCH 21/36] feat(types,cli,web): add vectorDb category (pgvector/qdrant/chroma/pinecone) New TypeScript-ecosystem option category for AI embeddings / semantic search, modeled on the existing backend-service categories (search/cms/fileStorage). - types: VectorDbSchema + VECTOR_DB_VALUES; wired through option-metadata (TYPESCRIPT_CATEGORY_ORDER only), stack-translation (defaults, url keys, part-role maps, reproducible command), compatibility (convex/firebase/ react-native force-none, standalone-backend requirement, TS-only gate), defaults, stack-graph (legacy single categories + defineTools), and the VectorDb type. - template-generator: server-side lib templates for all four providers under templates/vector-db//server/base/src/lib/vector.ts; handler + generator wiring, dependency processor, version pins, preflight rule. - cli: --vector-db flag, interactive prompt (TS + standalone backend only), config-prompts resolver, MCP input schema + field docs, default-config and bts.jsonc round-trip, reproducible-command emission. - web: TECH_OPTIONS + tech-icons + resource links + stack-builder category wiring so the builder exposes the category at parity with the CLI. Each provider is a standalone service (pgvector connects to a dedicated Postgres+pgvector instance via PGVECTOR_DATABASE_URL), so there is no coupling to the project's primary database. Verified: all four generated projects install + type-check clean against their real client SDKs. --- apps/cli/src/create-command-input.ts | 4 + apps/cli/src/helpers/core/command-handlers.ts | 2 + apps/cli/src/index.ts | 1 + apps/cli/src/mcp.ts | 6 ++ apps/cli/src/prompts/config-prompts.ts | 10 ++ apps/cli/src/prompts/vector-db.ts | 100 ++++++++++++++++++ apps/cli/src/utils/bts-config.ts | 1 + .../utils/generate-reproducible-command.ts | 10 ++ .../cli/test/schema-template-coverage.test.ts | 2 + apps/cli/test/test-utils.ts | 1 + .../stack-builder/stack-builder.tsx | 2 + apps/web/src/lib/constant.ts | 42 ++++++++ apps/web/src/lib/tech-icons.ts | 6 ++ apps/web/src/lib/tech-resource-links.ts | 16 +++ packages/template-generator/src/generator.ts | 3 + .../src/preflight-validation.ts | 1 + .../src/processors/index.ts | 3 + .../src/processors/vector-db-deps.ts | 47 ++++++++ .../src/template-handlers/index.ts | 1 + .../src/template-handlers/vector-db.ts | 26 +++++ .../template-generator/src/utils/add-deps.ts | 12 +++ .../chroma/server/base/src/lib/vector.ts.hbs | 50 +++++++++ .../server/base/src/lib/vector.ts.hbs | 86 +++++++++++++++ .../server/base/src/lib/vector.ts.hbs | 53 ++++++++++ .../qdrant/server/base/src/lib/vector.ts.hbs | 73 +++++++++++++ .../test/_fixtures/config-factory.ts | 1 + packages/types/src/compatibility.ts | 20 ++++ packages/types/src/defaults.ts | 1 + packages/types/src/option-metadata.ts | 11 ++ packages/types/src/schemas.ts | 11 ++ packages/types/src/stack-graph.ts | 3 + packages/types/src/stack-translation.ts | 8 ++ packages/types/src/types.ts | 2 + 33 files changed, 615 insertions(+) create mode 100644 apps/cli/src/prompts/vector-db.ts create mode 100644 packages/template-generator/src/processors/vector-db-deps.ts create mode 100644 packages/template-generator/src/template-handlers/vector-db.ts create mode 100644 packages/template-generator/templates/vector-db/chroma/server/base/src/lib/vector.ts.hbs create mode 100644 packages/template-generator/templates/vector-db/pgvector/server/base/src/lib/vector.ts.hbs create mode 100644 packages/template-generator/templates/vector-db/pinecone/server/base/src/lib/vector.ts.hbs create mode 100644 packages/template-generator/templates/vector-db/qdrant/server/base/src/lib/vector.ts.hbs diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts index c1d7b0cdf..c41f9a515 100644 --- a/apps/cli/src/create-command-input.ts +++ b/apps/cli/src/create-command-input.ts @@ -119,6 +119,7 @@ import { RustOrmSchema, RustWebFrameworkSchema, SearchSchema, + VectorDbSchema, ServerDeploySchema, ShadcnBaseColorSchema, ShadcnBaseSchema, @@ -196,6 +197,9 @@ export const CreateCommandOptionsSchema = z.object({ rateLimit: RateLimitSchema.optional().describe("Rate limiting solution"), i18n: I18nSchema.optional().describe("Internationalization (i18n) library"), search: SearchSchema.optional().describe("Search engine solution"), + vectorDb: VectorDbSchema.optional().describe( + "Vector database for AI embeddings (pgvector, qdrant, chroma, pinecone)", + ), fileStorage: FileStorageSchema.optional().describe("File storage solution (S3, R2)"), mobileNavigation: MobileNavigationSchema.optional().describe( "Mobile navigation (expo-router, react-navigation)", diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index 1f3df220e..488e1e042 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -75,6 +75,7 @@ function getYesBaseConfig(flagConfig: Partial): ProjectConfig { rateLimit: "none", i18n: "none", search: "none", + vectorDb: "none", fileStorage: "none", mobileNavigation: "expo-router", mobileUI: "none", @@ -251,6 +252,7 @@ export async function createProjectHandler( rateLimit: "none", i18n: "none", search: "none", + vectorDb: "none", featureFlags: "none", analytics: "none", fileStorage: "none", diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 961184339..11f4ccbea 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -112,6 +112,7 @@ export async function createVirtual( rateLimit: options.rateLimit || "none", i18n: options.i18n || "none", search: options.search || "none", + vectorDb: options.vectorDb || "none", fileStorage: options.fileStorage || "none", // Rust ecosystem options rustWebFramework: options.rustWebFramework || "none", diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 64bf28be7..bd5fbe072 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -125,6 +125,7 @@ import { RustOrmSchema, RustWebFrameworkSchema, SearchSchema, + VectorDbSchema, ServerDeploySchema, StateManagementSchema, TestingSchema, @@ -218,6 +219,8 @@ function getGuidance() { "String. TypeScript supports multiple providers; Rust, Python, Go, and Java currently support sentry or none.", search: "String. TypeScript supports multiple providers; Rust, Python, Go, and Java currently support meilisearch or none.", + vectorDb: + "String. TypeScript-only vector database for AI embeddings: pgvector, qdrant, chroma, pinecone, or none. Each provider is a standalone service (pgvector connects to a dedicated Postgres+pgvector instance via PGVECTOR_DATABASE_URL). Requires a standalone backend (not convex/none).", }, ambiguityRules: [ "If the user request leaves major stack choices unspecified, ASK the user before proceeding. Do not guess.", @@ -469,6 +472,7 @@ const MCP_COMPATIBILITY_DEFAULTS = { shadcnRadius: "default", cms: "none", search: "none", + vectorDb: "none", fileStorage: "none", mobileUI: "none", mobileStorage: "none", @@ -1578,6 +1582,7 @@ export async function startMcpServer() { rateLimit: RateLimitSchema.optional().describe("Rate limiting solution"), i18n: I18nSchema.optional().describe("Internationalization library"), search: SearchSchema.optional().describe("Search engine"), + vectorDb: VectorDbSchema.optional().describe("Vector database (TypeScript only)"), fileStorage: FileStorageSchema.optional().describe("File storage"), ...mobileInputSchema, ...deploymentInputSchema, @@ -1670,6 +1675,7 @@ export async function startMcpServer() { observability: ObservabilitySchema.optional().describe("Observability"), featureFlags: FeatureFlagsSchema.optional().describe("Feature flag provider"), search: SearchSchema.optional().describe("Search engine"), + vectorDb: VectorDbSchema.optional().describe("Vector database (TypeScript only)"), caching: CachingSchema.optional().describe("Caching solution"), rateLimit: RateLimitSchema.optional().describe("Rate limiting solution"), i18n: I18nSchema.optional().describe("Internationalization (i18n) library"), diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index d4110b168..49902171a 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -115,6 +115,7 @@ import type { RustWebFramework, Runtime, Search, + VectorDb, FileStorage, ServerDeploy, StateManagement, @@ -262,6 +263,7 @@ import { getRustWebFrameworkChoice, } from "./rust-ecosystem"; import { getSearchChoice } from "./search"; +import { getVectorDbChoice } from "./vector-db"; import { getServerDeploymentChoice } from "./server-deploy"; import { getShadcnOptions, type ShadcnOptions } from "./shadcn-options"; import { getStateManagementChoice } from "./state-management"; @@ -311,6 +313,7 @@ type PromptGroupResults = { rateLimit: RateLimit; i18n: I18n; search: Search; + vectorDb: VectorDb; fileStorage: FileStorage; mobileNavigation: MobileNavigation; mobileUI: MobileUI; @@ -672,6 +675,12 @@ export async function gatherConfig( } return getSearchChoice(flags.search, results.backend, results.ecosystem); }, + vectorDb: ({ results }) => { + if (results.ecosystem !== "typescript") { + return Promise.resolve("none" as VectorDb); + } + return getVectorDbChoice(flags.vectorDb, results.backend, results.ecosystem); + }, fileStorage: ({ results }) => { if (results.ecosystem !== "typescript") return Promise.resolve("none" as FileStorage); return getFileStorageChoice(flags.fileStorage, results.backend); @@ -1150,6 +1159,7 @@ export async function gatherConfig( rateLimit: result.rateLimit, i18n: result.i18n, search: result.search, + vectorDb: result.vectorDb, fileStorage: result.fileStorage, mobileNavigation: result.mobileNavigation, mobileUI: result.mobileUI, diff --git a/apps/cli/src/prompts/vector-db.ts b/apps/cli/src/prompts/vector-db.ts new file mode 100644 index 000000000..8039d8531 --- /dev/null +++ b/apps/cli/src/prompts/vector-db.ts @@ -0,0 +1,100 @@ +import type { Backend, Ecosystem, VectorDb } from "../types"; + +import { exitCancelled } from "../utils/errors"; +import type { PromptSingleResolution } from "./prompt-contract"; +import { isCancel, navigableSelect } from "./navigable"; + +const VECTOR_DB_PROMPT_OPTIONS = [ + { + value: "pgvector" as const, + label: "pgvector", + hint: "Self-hosted Postgres + pgvector extension for embeddings", + }, + { + value: "qdrant" as const, + label: "Qdrant", + hint: "High-performance open-source vector database", + }, + { + value: "chroma" as const, + label: "Chroma", + hint: "Lightweight open-source embedding database", + }, + { + value: "pinecone" as const, + label: "Pinecone", + hint: "Fully managed serverless vector database", + }, + { + value: "none" as const, + label: "None", + hint: "Skip vector database setup", + }, +]; + +type VectorDbPromptContext = { + vectorDb?: VectorDb; + backend?: Backend; + ecosystem?: Ecosystem; +}; + +/** + * Vector DB is a TypeScript-ecosystem feature backed by a standalone server. + * Every provider (including pgvector via a dedicated Postgres instance) is a + * separate service, so there is no dependency on the primary database choice. + */ +export function resolveVectorDbPrompt( + context: VectorDbPromptContext = {}, +): PromptSingleResolution { + const skip = (): PromptSingleResolution => ({ + shouldPrompt: false, + mode: "single", + options: [], + autoValue: "none", + }); + + // TypeScript ecosystem only. + if (context.ecosystem && context.ecosystem !== "typescript") { + return skip(); + } + + // Needs a standalone backend (Convex has built-in vector search). + if (context.backend === "none" || context.backend === "convex") { + return skip(); + } + + return context.vectorDb !== undefined + ? { + shouldPrompt: false, + mode: "single", + options: VECTOR_DB_PROMPT_OPTIONS, + autoValue: context.vectorDb, + } + : { + shouldPrompt: true, + mode: "single", + options: VECTOR_DB_PROMPT_OPTIONS, + initialValue: "none", + }; +} + +export async function getVectorDbChoice( + vectorDb?: VectorDb, + backend?: Backend, + ecosystem?: Ecosystem, +) { + const resolution = resolveVectorDbPrompt({ vectorDb, backend, ecosystem }); + if (!resolution.shouldPrompt) { + return resolution.autoValue ?? "none"; + } + + const response = await navigableSelect({ + message: "Select vector database", + options: resolution.options, + initialValue: resolution.initialValue as VectorDb, + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index 08bf8c58f..72d6aea05 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -298,6 +298,7 @@ export function buildBtsConfigForPersistence( rateLimit: persistedConfig.rateLimit, i18n: persistedConfig.i18n, search: persistedConfig.search, + vectorDb: persistedConfig.vectorDb, fileStorage: persistedConfig.fileStorage, rustWebFramework: persistedConfig.rustWebFramework, rustFrontend: persistedConfig.rustFrontend, diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 132be7908..fbcf71351 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -476,6 +476,15 @@ function appendGraphExtraFlags(flags: string[], config: ProjectConfig) { ); appendChangedGraphStringFlag(flags, config, "cms", "typescript", "cms", config.cms, "none"); appendChangedGraphStringFlag(flags, config, "search", "typescript", "search", config.search, "none"); + appendChangedGraphStringFlag( + flags, + config, + "vectorDb", + "typescript", + "vectorDb", + config.vectorDb, + "none", + ); appendChangedGraphStringFlag( flags, config, @@ -850,6 +859,7 @@ function getTypeScriptFlags(config: ProjectConfig) { flags.push(`--i18n ${config.i18n}`); flags.push(`--cms ${config.cms}`); flags.push(`--search ${config.search}`); + flags.push(`--vector-db ${config.vectorDb}`); flags.push(`--file-storage ${config.fileStorage}`); flags.push(`--mobile-navigation ${config.mobileNavigation}`); flags.push(`--mobile-ui ${config.mobileUI}`); diff --git a/apps/cli/test/schema-template-coverage.test.ts b/apps/cli/test/schema-template-coverage.test.ts index d003b7121..a76bbf63c 100644 --- a/apps/cli/test/schema-template-coverage.test.ts +++ b/apps/cli/test/schema-template-coverage.test.ts @@ -113,6 +113,7 @@ import { RUST_TEMPLATING_VALUES, RUST_WEB_FRAMEWORK_VALUES, SEARCH_VALUES, + VECTOR_DB_VALUES, SERVER_DEPLOY_VALUES, STATE_MANAGEMENT_VALUES, TESTING_VALUES, @@ -231,6 +232,7 @@ const CATEGORY_VALUES: Record = { rateLimit: RATE_LIMIT_VALUES, i18n: I18N_VALUES, search: SEARCH_VALUES, + vectorDb: VECTOR_DB_VALUES, fileStorage: FILE_STORAGE_VALUES, webDeploy: WEB_DEPLOY_VALUES, serverDeploy: SERVER_DEPLOY_VALUES, diff --git a/apps/cli/test/test-utils.ts b/apps/cli/test/test-utils.ts index cb8ec8d4f..ab7a41d60 100644 --- a/apps/cli/test/test-utils.ts +++ b/apps/cli/test/test-utils.ts @@ -57,6 +57,7 @@ function createTestCoreDefaults(): Partial { rateLimit: SHARED_TEST_DEFAULTS.rateLimit, i18n: SHARED_TEST_DEFAULTS.i18n, search: SHARED_TEST_DEFAULTS.search, + vectorDb: SHARED_TEST_DEFAULTS.vectorDb, fileStorage: SHARED_TEST_DEFAULTS.fileStorage, cms: SHARED_TEST_DEFAULTS.cms, ai: SHARED_TEST_DEFAULTS.ai, diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx index 745e1e2bf..664c40369 100644 --- a/apps/web/src/components/stack-builder/stack-builder.tsx +++ b/apps/web/src/components/stack-builder/stack-builder.tsx @@ -334,6 +334,7 @@ const GRAPH_BACKEND_ADVANCED_CATEGORY_ORDER_BY_ECOSYSTEM = { "i18n", "cms", "search", + "vectorDb", "fileStorage", ], rust: [ @@ -1000,6 +1001,7 @@ const INITIALLY_COLLAPSED_SET = new Set([ "caching", "rateLimit", "search", + "vectorDb", "fileStorage", "animation", "cms", diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 7d1716fc8..a81878a12 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -2762,6 +2762,48 @@ export const TECH_OPTIONS: Record< default: true, }, ], + vectorDb: [ + { + id: "pgvector", + name: "pgvector", + description: "Self-hosted Postgres + pgvector extension for embeddings and semantic search", + icon: "https://cdn.simpleicons.org/postgresql/4169E1", + color: "from-blue-500 to-indigo-700", + default: false, + }, + { + id: "qdrant", + name: "Qdrant", + description: "High-performance open-source vector database for AI embeddings", + icon: "https://cdn.simpleicons.org/qdrant/DC244C", + color: "from-red-500 to-rose-700", + default: false, + }, + { + id: "chroma", + name: "Chroma", + description: "Lightweight open-source embedding database for AI applications", + icon: "https://cdn.simpleicons.org/chromadb/FF6F00", + color: "from-amber-400 to-orange-600", + default: false, + }, + { + id: "pinecone", + name: "Pinecone", + description: "Fully managed serverless vector database for production AI", + icon: "https://cdn.simpleicons.org/pinecone/000000", + color: "from-emerald-500 to-teal-700", + default: false, + }, + { + id: "none", + name: "No Vector DB", + description: "Skip vector database setup", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], fileStorage: [ { id: "s3", diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index 8e7b37c82..19cfa8f82 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -396,6 +396,12 @@ export const ICON_REGISTRY: Record = { opensearch: { type: "si", slug: "opensearch", hex: "005EB8" }, algolia: { type: "si", slug: "algolia", hex: "003DFF" }, + // ─── Vector DB ─────────────────────────────────────────────────────────────── + pgvector: { type: "si", slug: "postgresql", hex: "4169E1" }, + qdrant: { type: "si", slug: "qdrant", hex: "DC244C" }, + chroma: { type: "si", slug: "chromadb", hex: "FF6F00" }, + pinecone: { type: "si", slug: "pinecone", hex: "000000" }, + // ─── File Storage ────────────────────────────────────────────────────────── s3: { type: "local", src: "/icon/aws-s3.svg" }, r2: { type: "si", slug: "cloudflare", hex: "F38020" }, diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index 048a86fbe..1113f9df7 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -962,6 +962,22 @@ const BASE_LINKS: LinkMap = { docsUrl: "https://www.algolia.com/doc/", githubUrl: "https://github.com/algolia/algoliasearch-client-javascript", }, + pgvector: { + docsUrl: "https://github.com/pgvector/pgvector", + githubUrl: "https://github.com/pgvector/pgvector", + }, + qdrant: { + docsUrl: "https://qdrant.tech/documentation/", + githubUrl: "https://github.com/qdrant/qdrant", + }, + chroma: { + docsUrl: "https://docs.trychroma.com/", + githubUrl: "https://github.com/chroma-core/chroma", + }, + pinecone: { + docsUrl: "https://docs.pinecone.io/", + githubUrl: "https://github.com/pinecone-io/pinecone-ts-client", + }, s3: { docsUrl: "https://docs.aws.amazon.com/AmazonS3/", githubUrl: "https://github.com/aws/aws-sdk-js-v3", diff --git a/packages/template-generator/src/generator.ts b/packages/template-generator/src/generator.ts index f6e887733..7ac42d1f0 100644 --- a/packages/template-generator/src/generator.ts +++ b/packages/template-generator/src/generator.ts @@ -52,6 +52,7 @@ import { processCMSTemplates, processI18nTemplates, processSearchTemplates, + processVectorDbTemplates, processFileStorageTemplates, processTestingTemplates, } from "./template-handlers"; @@ -128,6 +129,7 @@ async function processGraphTemplates( await processCMSTemplates(vfs, templates, tsConfig); await processI18nTemplates(vfs, templates, tsConfig); await processSearchTemplates(vfs, templates, tsConfig); + await processVectorDbTemplates(vfs, templates, tsConfig); await processFileStorageTemplates(vfs, templates, tsConfig); await processTestingTemplates(vfs, templates, tsConfig); processPackageConfigs(vfs, tsConfig); @@ -238,6 +240,7 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise await processCMSTemplates(vfs, templates, config); await processI18nTemplates(vfs, templates, config); await processSearchTemplates(vfs, templates, config); + await processVectorDbTemplates(vfs, templates, config); await processFileStorageTemplates(vfs, templates, config); await processTestingTemplates(vfs, templates, config); diff --git a/packages/template-generator/src/preflight-validation.ts b/packages/template-generator/src/preflight-validation.ts index efcd071e2..76e41dd58 100644 --- a/packages/template-generator/src/preflight-validation.ts +++ b/packages/template-generator/src/preflight-validation.ts @@ -117,6 +117,7 @@ const backendFeature = ( const PREFLIGHT_RULES: readonly PreflightRule[] = [ serverFeature("search-no-server", "search", "Search"), + serverFeature("vector-db-no-server", "vectorDb", "Vector database"), serverFeature("file-storage-no-server", "fileStorage", "File Storage"), serverFeature("job-queue-no-server", "jobQueue", "Job Queue"), diff --git a/packages/template-generator/src/processors/index.ts b/packages/template-generator/src/processors/index.ts index 635061b0c..4a52261b2 100644 --- a/packages/template-generator/src/processors/index.ts +++ b/packages/template-generator/src/processors/index.ts @@ -38,6 +38,7 @@ import { processReadme } from "./readme-generator"; import { processRealtimeDeps } from "./realtime-deps"; import { processRuntimeDeps } from "./runtime-deps"; import { processSearchDeps } from "./search-deps"; +import { processVectorDbDeps } from "./vector-db-deps"; import { processStateManagementDeps } from "./state-management-deps"; import { processTestingDeps } from "./testing-deps"; import { processNxConfig } from "./nx-generator"; @@ -79,6 +80,7 @@ export function processDependencies(vfs: VirtualFileSystem, config: ProjectConfi processCachingDeps(vfs, config); processI18nDeps(vfs, config); processSearchDeps(vfs, config); + processVectorDbDeps(vfs, config); processFileStorageDeps(vfs, config); processNxConfig(vfs, config); processTurboConfig(vfs, config); @@ -95,6 +97,7 @@ export { processCachingDeps, processI18nDeps, processSearchDeps, + processVectorDbDeps, processFileStorageDeps, processCMSDeps, processCSSAndUILibraryDeps, diff --git a/packages/template-generator/src/processors/vector-db-deps.ts b/packages/template-generator/src/processors/vector-db-deps.ts new file mode 100644 index 000000000..f65875d27 --- /dev/null +++ b/packages/template-generator/src/processors/vector-db-deps.ts @@ -0,0 +1,47 @@ +import type { ProjectConfig } from "@better-fullstack/types"; + +import type { VirtualFileSystem } from "../core/virtual-fs"; + +import { addPackageDependency, type AvailableDependencies } from "../utils/add-deps"; + +export function processVectorDbDeps(vfs: VirtualFileSystem, config: ProjectConfig): void { + const { vectorDb, backend } = config; + + // Skip if not selected or set to "none" + if (!vectorDb || vectorDb === "none") return; + + // Skip if no backend to host the vector store (convex has built-in vector search) + if (backend === "none" || backend === "convex") return; + + const deps = getVectorDbDeps(vectorDb); + if (deps.length === 0) return; + + // Add server-side vector database dependencies + const serverPath = "apps/server/package.json"; + if (vfs.exists(serverPath)) { + addPackageDependency({ vfs, packagePath: serverPath, dependencies: deps }); + } + + // For fullstack frameworks (self), add to the web package + if (backend === "self") { + const webPath = "apps/web/package.json"; + if (vfs.exists(webPath)) { + addPackageDependency({ vfs, packagePath: webPath, dependencies: deps }); + } + } +} + +function getVectorDbDeps(vectorDb: ProjectConfig["vectorDb"]): AvailableDependencies[] { + switch (vectorDb) { + case "pgvector": + return ["postgres"]; + case "qdrant": + return ["@qdrant/js-client-rest"]; + case "chroma": + return ["chromadb"]; + case "pinecone": + return ["@pinecone-database/pinecone"]; + default: + return []; + } +} diff --git a/packages/template-generator/src/template-handlers/index.ts b/packages/template-generator/src/template-handlers/index.ts index fa05d39f5..7b22c7726 100644 --- a/packages/template-generator/src/template-handlers/index.ts +++ b/packages/template-generator/src/template-handlers/index.ts @@ -27,5 +27,6 @@ export { processJobQueueTemplates } from "./job-queue"; export { processCMSTemplates } from "./cms"; export { processI18nTemplates } from "./i18n"; export { processSearchTemplates } from "./search"; +export { processVectorDbTemplates } from "./vector-db"; export { processFileStorageTemplates } from "./file-storage"; export { processTestingTemplates } from "./testing"; diff --git a/packages/template-generator/src/template-handlers/vector-db.ts b/packages/template-generator/src/template-handlers/vector-db.ts new file mode 100644 index 000000000..2509664cf --- /dev/null +++ b/packages/template-generator/src/template-handlers/vector-db.ts @@ -0,0 +1,26 @@ +import type { ProjectConfig } from "@better-fullstack/types"; + +import type { VirtualFileSystem } from "../core/virtual-fs"; + +import { type TemplateData, processTemplatesFromPrefix } from "./utils"; + +export async function processVectorDbTemplates( + vfs: VirtualFileSystem, + templates: TemplateData, + config: ProjectConfig, +): Promise { + if (!config.vectorDb || config.vectorDb === "none") return; + if (config.backend === "convex") return; + if (config.backend === "none") return; + + const destPrefix = config.backend === "self" ? "apps/web" : "apps/server"; + + // Process server-side vector database templates + processTemplatesFromPrefix( + vfs, + templates, + `vector-db/${config.vectorDb}/server/base`, + destPrefix, + config, + ); +} diff --git a/packages/template-generator/src/utils/add-deps.ts b/packages/template-generator/src/utils/add-deps.ts index 530ecd56f..618d133ff 100644 --- a/packages/template-generator/src/utils/add-deps.ts +++ b/packages/template-generator/src/utils/add-deps.ts @@ -773,6 +773,18 @@ export const dependencyVersionMap = { // Search - Algolia algoliasearch: "^5.54.0", + // Vector DB - pgvector (Postgres driver) + postgres: "^3.4.7", + + // Vector DB - Qdrant + "@qdrant/js-client-rest": "^1.15.1", + + // Vector DB - Chroma + chromadb: "^1.10.5", + + // Vector DB - Pinecone + "@pinecone-database/pinecone": "^6.1.2", + // EdgeDB edgedb: "^2.0.1", "@edgedb/generate": "^0.6.1", diff --git a/packages/template-generator/templates/vector-db/chroma/server/base/src/lib/vector.ts.hbs b/packages/template-generator/templates/vector-db/chroma/server/base/src/lib/vector.ts.hbs new file mode 100644 index 000000000..202e9e494 --- /dev/null +++ b/packages/template-generator/templates/vector-db/chroma/server/base/src/lib/vector.ts.hbs @@ -0,0 +1,50 @@ +import { ChromaClient, type Metadata } from "chromadb"; + +/** + * Chroma vector database client. + * @see https://docs.trychroma.com/ + */ +export const vectorClient = new ChromaClient({ + path: process.env.CHROMA_URL ?? "http://localhost:8000", +}); + +export interface EmbeddingInput { + ids: string[]; + embeddings: number[][]; + documents?: string[]; + metadatas?: Metadata[]; +} + +/** + * Insert or update embeddings in a collection (created if missing). + */ +export async function addEmbeddings( + collectionName: string, + items: EmbeddingInput, +): Promise { + const collection = await vectorClient.getOrCreateCollection({ name: collectionName }); + await collection.add(items); +} + +/** + * Query a collection for the nearest embeddings. + */ +export async function queryEmbeddings( + collectionName: string, + queryEmbedding: number[], + nResults = 5, +) { + const collection = await vectorClient.getOrCreateCollection({ name: collectionName }); + return collection.query({ queryEmbeddings: [queryEmbedding], nResults }); +} + +/** + * Delete embeddings by id. + */ +export async function deleteEmbeddings( + collectionName: string, + ids: string[], +): Promise { + const collection = await vectorClient.getOrCreateCollection({ name: collectionName }); + await collection.delete({ ids }); +} diff --git a/packages/template-generator/templates/vector-db/pgvector/server/base/src/lib/vector.ts.hbs b/packages/template-generator/templates/vector-db/pgvector/server/base/src/lib/vector.ts.hbs new file mode 100644 index 000000000..1cf3e208d --- /dev/null +++ b/packages/template-generator/templates/vector-db/pgvector/server/base/src/lib/vector.ts.hbs @@ -0,0 +1,86 @@ +import postgres from "postgres"; + +/** + * pgvector vector store backed by a Postgres database with the `vector` + * extension. Point PGVECTOR_DATABASE_URL at any Postgres instance that has (or + * can install) the pgvector extension — it is intentionally separate from your + * primary application database so it works with any stack. Falls back to + * DATABASE_URL when a dedicated URL isn't provided. + * + * Run `initVectorStore()` once (e.g. in a migration or setup script) to enable + * the extension and create the table. + * + * @see https://github.com/pgvector/pgvector + */ +export const sql = postgres(process.env.PGVECTOR_DATABASE_URL ?? process.env.DATABASE_URL!); + +export interface EmbeddingRecord { + id: string; + content: string; + metadata?: Record; +} + +export interface SimilarityResult extends EmbeddingRecord { + similarity: number; +} + +function serializeVector(values: number[]): string { + return `[${values.join(",")}]`; +} + +/** + * Enable the pgvector extension and create the embeddings table. + * Call once during setup/migration. + */ +export async function initVectorStore(dimensions = 1536): Promise { + await sql`CREATE EXTENSION IF NOT EXISTS vector`; + await sql.unsafe( + `CREATE TABLE IF NOT EXISTS embeddings ( + id text PRIMARY KEY, + content text NOT NULL, + embedding vector(${dimensions}), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb + )`, + ); +} + +/** + * Insert or update an embedding. + */ +export async function upsertEmbedding( + record: EmbeddingRecord & { embedding: number[] }, +): Promise { + const vector = serializeVector(record.embedding); + await sql` + INSERT INTO embeddings (id, content, embedding, metadata) + VALUES (${record.id}, ${record.content}, ${vector}::vector, ${JSON.stringify(record.metadata ?? {})}::jsonb) + ON CONFLICT (id) DO UPDATE + SET content = EXCLUDED.content, + embedding = EXCLUDED.embedding, + metadata = EXCLUDED.metadata + `; +} + +/** + * Find the most similar embeddings by cosine distance. + */ +export async function searchSimilar( + embedding: number[], + limit = 5, +): Promise { + const vector = serializeVector(embedding); + const rows = await sql` + SELECT id, content, metadata, 1 - (embedding <=> ${vector}::vector) AS similarity + FROM embeddings + ORDER BY embedding <=> ${vector}::vector + LIMIT ${limit} + `; + return [...rows]; +} + +/** + * Delete an embedding by id. + */ +export async function deleteEmbedding(id: string): Promise { + await sql`DELETE FROM embeddings WHERE id = ${id}`; +} diff --git a/packages/template-generator/templates/vector-db/pinecone/server/base/src/lib/vector.ts.hbs b/packages/template-generator/templates/vector-db/pinecone/server/base/src/lib/vector.ts.hbs new file mode 100644 index 000000000..947fa80c0 --- /dev/null +++ b/packages/template-generator/templates/vector-db/pinecone/server/base/src/lib/vector.ts.hbs @@ -0,0 +1,53 @@ +import { Pinecone, type RecordMetadata } from "@pinecone-database/pinecone"; + +/** + * Pinecone vector database client. + * @see https://docs.pinecone.io/ + */ +export const vectorClient = new Pinecone({ + apiKey: process.env.PINECONE_API_KEY!, +}); + +const DEFAULT_INDEX = process.env.PINECONE_INDEX ?? "embeddings"; + +export interface VectorRecord { + id: string; + values: number[]; + metadata?: RecordMetadata; +} + +/** + * Insert or update vectors in an index. + */ +export async function upsertVectors( + records: VectorRecord[], + indexName: string = DEFAULT_INDEX, +): Promise { + await vectorClient.index(indexName).upsert(records); +} + +/** + * Query an index for the nearest vectors. + */ +export async function queryVectors( + vector: number[], + topK = 5, + indexName: string = DEFAULT_INDEX, +) { + const result = await vectorClient.index(indexName).query({ + vector, + topK, + includeMetadata: true, + }); + return result.matches ?? []; +} + +/** + * Delete vectors by id. + */ +export async function deleteVectors( + ids: string[], + indexName: string = DEFAULT_INDEX, +): Promise { + await vectorClient.index(indexName).deleteMany(ids); +} diff --git a/packages/template-generator/templates/vector-db/qdrant/server/base/src/lib/vector.ts.hbs b/packages/template-generator/templates/vector-db/qdrant/server/base/src/lib/vector.ts.hbs new file mode 100644 index 000000000..7aa9ee29c --- /dev/null +++ b/packages/template-generator/templates/vector-db/qdrant/server/base/src/lib/vector.ts.hbs @@ -0,0 +1,73 @@ +import { QdrantClient } from "@qdrant/js-client-rest"; + +/** + * Qdrant vector database client. + * @see https://qdrant.tech/documentation/ + */ +export const vectorClient = new QdrantClient({ + url: process.env.QDRANT_URL ?? "http://localhost:6333", + apiKey: process.env.QDRANT_API_KEY, +}); + +export interface VectorPoint { + id: string | number; + vector: number[]; + payload?: Record; +} + +export interface SearchHit { + id: string | number; + score: number; + payload: Record | null; +} + +/** + * Create a collection if it doesn't already exist. + */ +export async function ensureCollection( + collection: string, + size = 1536, + distance: "Cosine" | "Euclid" | "Dot" = "Cosine", +): Promise { + const { exists } = await vectorClient.collectionExists(collection); + if (exists) return; + await vectorClient.createCollection(collection, { + vectors: { size, distance }, + }); +} + +/** + * Insert or update points in a collection. + */ +export async function upsertPoints(collection: string, points: VectorPoint[]): Promise { + await vectorClient.upsert(collection, { + wait: true, + points: points.map((p) => ({ id: p.id, vector: p.vector, payload: p.payload })), + }); +} + +/** + * Search for the nearest vectors in a collection. + */ +export async function searchPoints( + collection: string, + vector: number[], + limit = 5, +): Promise { + const results = await vectorClient.search(collection, { vector, limit }); + return results.map((r) => ({ + id: r.id, + score: r.score, + payload: (r.payload ?? null) as Record | null, + })); +} + +/** + * Delete points by id. + */ +export async function deletePoints( + collection: string, + ids: (string | number)[], +): Promise { + await vectorClient.delete(collection, { wait: true, points: ids }); +} diff --git a/packages/template-generator/test/_fixtures/config-factory.ts b/packages/template-generator/test/_fixtures/config-factory.ts index 2c8e030e5..c77b44e32 100644 --- a/packages/template-generator/test/_fixtures/config-factory.ts +++ b/packages/template-generator/test/_fixtures/config-factory.ts @@ -58,6 +58,7 @@ const DEFAULT_CONFIG = { caching: "none", i18n: "none", search: "none", + vectorDb: "none", fileStorage: "none", rateLimit: "none", rustWebFramework: "none", diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index e362ccb23..a36811e22 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -121,6 +121,7 @@ export type CompatibilityInput = { cms: string; i18n: string; search: string; + vectorDb: string; fileStorage: string; mobileNavigation: string; mobileUI: string; @@ -329,6 +330,7 @@ export const analyzeStackCompatibility = ( dbSetup: "none", serverDeploy: "none", search: "none", + vectorDb: "none", rateLimit: "none", fileStorage: "none", }; @@ -398,6 +400,7 @@ export const analyzeStackCompatibility = ( serverDeploy: "none", payments: "none", search: "none", + vectorDb: "none", rateLimit: "none", fileStorage: "none", }; @@ -971,6 +974,7 @@ export const analyzeStackCompatibility = ( ["payments", "none", "Payments set to 'None' (React Native ecosystem)"], ["email", "none", "Email set to 'None' (React Native ecosystem)"], ["search", "none", "Search set to 'None' (React Native ecosystem)"], + ["vectorDb", "none", "Vector database set to 'None' (React Native ecosystem)"], ["rateLimit", "none", "Rate limiting set to 'None' (React Native ecosystem)"], ["fileStorage", "none", "File storage set to 'None' (React Native ecosystem)"], ["cms", "none", "CMS set to 'None' (React Native ecosystem)"], @@ -1566,6 +1570,9 @@ export const getDisabledReason = ( if (category === "search" && optionId !== "none") { return "Search requires a standalone backend"; } + if (category === "vectorDb" && optionId !== "none") { + return "Vector database requires a standalone backend (Convex has built-in vector search)"; + } if (category === "rateLimit" && optionId !== "none") { return "Rate limiting requires a standalone backend"; } @@ -1641,6 +1648,9 @@ export const getDisabledReason = ( if (category === "search" && optionId !== "none") { return "No backend selected"; } + if (category === "vectorDb" && optionId !== "none") { + return "No backend selected"; + } if (category === "rateLimit" && optionId !== "none") { return "No backend selected"; } @@ -2109,6 +2119,16 @@ export const getDisabledReason = ( } } + // ============================================ + // VECTOR DATABASE CONSTRAINTS + // ============================================ + if (category === "vectorDb" && optionId !== "none") { + // Vector DB is a TypeScript-ecosystem feature only. + if (currentStack.ecosystem !== "typescript") { + return "Vector database is only available for the TypeScript ecosystem"; + } + } + // ============================================ // AI CONSTRAINTS // ============================================ diff --git a/packages/types/src/defaults.ts b/packages/types/src/defaults.ts index 0f4226bd7..1b6f35584 100644 --- a/packages/types/src/defaults.ts +++ b/packages/types/src/defaults.ts @@ -63,6 +63,7 @@ export function createCliDefaultProjectConfigBase( rateLimit: "none", i18n: "none", search: "none", + vectorDb: "none", fileStorage: "none", rustWebFramework: "none", rustFrontend: "none", diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index 182dfde18..cf98d178a 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -112,6 +112,7 @@ import { RUST_ORM_VALUES, RUST_WEB_FRAMEWORK_VALUES, SEARCH_VALUES, + VECTOR_DB_VALUES, SHADCN_BASE_COLOR_VALUES, SHADCN_BASE_VALUES, SHADCN_COLOR_THEME_VALUES, @@ -156,6 +157,7 @@ export type OptionCategory = | "rateLimit" | "i18n" | "search" + | "vectorDb" | "fileStorage" | "animation" | "cssFramework" @@ -316,6 +318,7 @@ export const TYPESCRIPT_CATEGORY_ORDER = [ "rateLimit", "i18n", "search", + "vectorDb", "fileStorage", "animation", "cms", @@ -749,6 +752,7 @@ const CATEGORY_VALUE_IDS: Record = { rateLimit: RATE_LIMIT_VALUES, i18n: I18N_VALUES, search: SEARCH_VALUES, + vectorDb: VECTOR_DB_VALUES, fileStorage: FILE_STORAGE_VALUES, animation: ANIMATION_VALUES, cssFramework: CSS_FRAMEWORK_VALUES, @@ -997,6 +1001,12 @@ const EXACT_LABEL_OVERRIDES: Partial; export type RateLimit = z.infer; export type I18n = z.infer; export type Search = z.infer; +export type VectorDb = z.infer; export type FileStorage = z.infer; export type Ecosystem = z.infer; export type RustWebFramework = z.infer; From d499799c7bdac4333cabed4df8d362ebec163868 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 20 Jun 2026 00:36:30 +0300 Subject: [PATCH 22/36] fix(web,testing): vectorDb icons + smoke command emits --vector-db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chroma/pinecone aren't on simpleicons (CDN 404 failed validate:tech-icons); ship local /icon/chroma.svg + /icon/pinecone.svg and point the registry + TECH_OPTIONS at them (qdrant/pgvector keep their valid simpleicons slugs). - smoke/e2e presets hung on the new interactive vectorDb prompt because the command builder never emitted the flag; render buildCommand now emits --vector-db (withExplicitScalar → 'none' default) for the TypeScript flag set, mirroring --search. --- apps/web/public/icon/chroma.svg | 6 ++++++ apps/web/public/icon/pinecone.svg | 12 ++++++++++++ apps/web/src/lib/constant.ts | 4 ++-- apps/web/src/lib/tech-icons.ts | 4 ++-- testing/lib/generate-combos/render.ts | 1 + 5 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 apps/web/public/icon/chroma.svg create mode 100644 apps/web/public/icon/pinecone.svg diff --git a/apps/web/public/icon/chroma.svg b/apps/web/public/icon/chroma.svg new file mode 100644 index 000000000..d0e981e18 --- /dev/null +++ b/apps/web/public/icon/chroma.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/icon/pinecone.svg b/apps/web/public/icon/pinecone.svg new file mode 100644 index 000000000..f591ce2b8 --- /dev/null +++ b/apps/web/public/icon/pinecone.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index a81878a12..497d595b7 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -2783,7 +2783,7 @@ export const TECH_OPTIONS: Record< id: "chroma", name: "Chroma", description: "Lightweight open-source embedding database for AI applications", - icon: "https://cdn.simpleicons.org/chromadb/FF6F00", + icon: "/icon/chroma.svg", color: "from-amber-400 to-orange-600", default: false, }, @@ -2791,7 +2791,7 @@ export const TECH_OPTIONS: Record< id: "pinecone", name: "Pinecone", description: "Fully managed serverless vector database for production AI", - icon: "https://cdn.simpleicons.org/pinecone/000000", + icon: "/icon/pinecone.svg", color: "from-emerald-500 to-teal-700", default: false, }, diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index 19cfa8f82..2b97280ea 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -399,8 +399,8 @@ export const ICON_REGISTRY: Record = { // ─── Vector DB ─────────────────────────────────────────────────────────────── pgvector: { type: "si", slug: "postgresql", hex: "4169E1" }, qdrant: { type: "si", slug: "qdrant", hex: "DC244C" }, - chroma: { type: "si", slug: "chromadb", hex: "FF6F00" }, - pinecone: { type: "si", slug: "pinecone", hex: "000000" }, + chroma: { type: "local", src: "/icon/chroma.svg" }, + pinecone: { type: "local", src: "/icon/pinecone.svg" }, // ─── File Storage ────────────────────────────────────────────────────────── s3: { type: "local", src: "/icon/aws-s3.svg" }, diff --git a/testing/lib/generate-combos/render.ts b/testing/lib/generate-combos/render.ts index b87669f80..16fcc9e07 100644 --- a/testing/lib/generate-combos/render.ts +++ b/testing/lib/generate-combos/render.ts @@ -160,6 +160,7 @@ export function buildCommand(name: string, config: ProjectConfig): string { ["rate-limit", config.rateLimit], ["i18n", config.i18n], ["search", config.search], + ["vector-db", withExplicitScalar(config.vectorDb)], ["file-storage", config.fileStorage], ["mobile-navigation", withExplicitScalar(config.mobileNavigation)], ["mobile-ui", withExplicitScalar(config.mobileUI)], From a4a5e4abbb6617cf6e39e36dc631ed7b61bf2ee4 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 20 Jun 2026 01:21:55 +0300 Subject: [PATCH 23/36] fix(types): tolerate omitted vectorDb in analyzeStackCompatibility Inline compatibility fixtures (and any caller) that predate the vectorDb category omit the field entirely. The no-backend service override then saw `undefined !== "none"` and reported defaulting it as a spurious adjustment, breaking 'no change expected' assertions (e.g. Go better-auth stacks). Default a missing vectorDb to 'none' up front so it is never mistaken for an adjustment. --- packages/types/src/compatibility.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index a36811e22..6f42214dd 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -308,6 +308,12 @@ export const analyzeStackCompatibility = ( } const nextStack = { ...stack }; + // vectorDb is a newer optional field; callers and fixtures that predate it omit + // it entirely. Treat a missing value as "none" up front so that defaulting it is + // not reported as an adjustment (e.g. by the no-backend service override below). + if (nextStack.vectorDb === undefined) { + nextStack.vectorDb = "none"; + } let changed = false; const notes: CompatibilityAnalysisResult["notes"] = {}; const changes: CompatibilityAdjustment[] = []; From 82ab51d7d444dcc901a94f7fa6745329fc8bea6a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 20 Jun 2026 01:36:33 +0300 Subject: [PATCH 24/36] fix(templates): stripe env/apiVersion + better-auth api dep + go handlers import Smoke-gating template bugs surfaced by the strict smoke gate: - stripe: env server schema never declared STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET (only a polar block existed), and stripe.ts pinned apiVersion '2024-12-18' while stripe@^22 now types it as '2026-05-27.dahlia'. Add the stripe env block, bump the literal, and update the api-literal-drift guard's expected value for major 22. - api package: graphql-yoga/ts-rest/garph contexts import better-auth types directly for any backend, but the dep was only added for express/fastify -> 'Cannot find module better-auth' on e.g. adonisjs. Broaden the condition. - go: cmd/server/main.go imported internal/handlers whenever an ORM was set, but handlers is only generated for gin/echo/fiber/chi web frameworks -> 'package .../internal/handlers is not in std' for web-framework=none. Gate the handlers import on the web framework, decoupled from the database import. Verified: stripe combo (solid+adonisjs+graphql-yoga+better-auth+stripe) and go combo (sqlc+gqlgen+none) both install/tidy + type-check/build clean; gin still imports handlers and builds. --- .../template-generator/src/processors/api-deps.ts | 13 +++++++++++-- .../templates/go-base/cmd/server/main.go.hbs | 2 ++ .../templates/packages/env/src/server.ts.hbs | 4 ++++ .../stripe/server/base/src/lib/stripe.ts.hbs | 2 +- .../test/api-literal-drift.test.ts | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/template-generator/src/processors/api-deps.ts b/packages/template-generator/src/processors/api-deps.ts index 874e447ff..231f29390 100644 --- a/packages/template-generator/src/processors/api-deps.ts +++ b/packages/template-generator/src/processors/api-deps.ts @@ -128,8 +128,17 @@ function addApiPackageDeps( addPackageDependency({ vfs, packagePath: pkgPath, dependencies: ["next"] }); } - // Add better-auth for express/fastify backends - if (isBetterAuth(auth) && (backend === "express" || backend === "fastify")) { + // The api package imports better-auth types directly when better-auth is + // selected: graphql-yoga/ts-rest/garph contexts (any backend) plus the + // express/fastify server wiring. + if ( + isBetterAuth(auth) && + (backend === "express" || + backend === "fastify" || + api === "graphql-yoga" || + api === "ts-rest" || + api === "garph") + ) { addPackageDependency({ vfs, packagePath: pkgPath, dependencies: ["better-auth"] }); } diff --git a/packages/template-generator/templates/go-base/cmd/server/main.go.hbs b/packages/template-generator/templates/go-base/cmd/server/main.go.hbs index 4d3542dad..b58e6519d 100644 --- a/packages/template-generator/templates/go-base/cmd/server/main.go.hbs +++ b/packages/template-generator/templates/go-base/cmd/server/main.go.hbs @@ -33,6 +33,8 @@ import ( {{#if (or (or (eq goOrm "gorm") (eq goOrm "sqlc")) (eq goOrm "ent"))}} "{{projectName}}/internal/database" +{{/if}} +{{#if (or (or (or (eq goWebFramework "gin") (eq goWebFramework "echo")) (eq goWebFramework "fiber")) (eq goWebFramework "chi"))}} "{{projectName}}/internal/handlers" {{/if}} {{#if (or (eq auth "go-better-auth") (ne goAuth "none"))}} diff --git a/packages/template-generator/templates/packages/env/src/server.ts.hbs b/packages/template-generator/templates/packages/env/src/server.ts.hbs index 76b456292..d4afc8989 100644 --- a/packages/template-generator/templates/packages/env/src/server.ts.hbs +++ b/packages/template-generator/templates/packages/env/src/server.ts.hbs @@ -51,6 +51,10 @@ export const env = createEnv({ POLAR_ACCESS_TOKEN: z.string().min(1), POLAR_SUCCESS_URL: z.url(), {{/if}} +{{#if (eq payments "stripe")}} + STRIPE_SECRET_KEY: z.string().min(1), + STRIPE_WEBHOOK_SECRET: z.string().min(1), +{{/if}} {{#if (eq observability "datadog")}} DD_SERVICE: z.string().min(1), DD_ENV: z.string().min(1), diff --git a/packages/template-generator/templates/payments/stripe/server/base/src/lib/stripe.ts.hbs b/packages/template-generator/templates/payments/stripe/server/base/src/lib/stripe.ts.hbs index 3a231b333..87736a817 100644 --- a/packages/template-generator/templates/payments/stripe/server/base/src/lib/stripe.ts.hbs +++ b/packages/template-generator/templates/payments/stripe/server/base/src/lib/stripe.ts.hbs @@ -2,7 +2,7 @@ import Stripe from "stripe"; import { env } from "@{{projectName}}/env/server"; export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: "2024-12-18", + apiVersion: "2026-05-27.dahlia", }); export async function createCheckoutSession(params: { diff --git a/packages/template-generator/test/api-literal-drift.test.ts b/packages/template-generator/test/api-literal-drift.test.ts index ca671198f..b232e52cb 100644 --- a/packages/template-generator/test/api-literal-drift.test.ts +++ b/packages/template-generator/test/api-literal-drift.test.ts @@ -60,7 +60,7 @@ const API_LITERAL_GUARDS: readonly ApiLiteralGuard[] = [ // Verify the pinned value against https://docs.stripe.com/api/versioning // whenever the major below changes, then add the new entry. expectedByMajor: { - 22: "2024-12-18", + 22: "2026-05-27.dahlia", }, }, ]; From 015231e3b71cd1b54404df7515020565ac16e78e Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 20 Jun 2026 01:47:25 +0300 Subject: [PATCH 25/36] fix(templates): vinext base-ui trigger + better-auth no-db + python redis/celery More smoke/e2e gating template bugs: - vinext mode-toggle still used the radix `asChild` API while its dropdown-menu is @base-ui (render prop) like the other react frontends -> web typecheck failure. Switch to render={ + }> + + + Toggle theme setTheme("light")}> diff --git a/packages/template-generator/templates/python-base/pyproject.toml.hbs b/packages/template-generator/templates/python-base/pyproject.toml.hbs index bd745fd1a..d2df4954a 100644 --- a/packages/template-generator/templates/python-base/pyproject.toml.hbs +++ b/packages/template-generator/templates/python-base/pyproject.toml.hbs @@ -119,20 +119,20 @@ dependencies = [ {{/if}} {{#if (eq pythonTaskQueue "rq")}} "rq>=2.6.0", - "redis>=7.0.0", + "redis>=5.0.1", {{/if}} {{#if (eq pythonTaskQueue "dramatiq")}} "dramatiq[redis,watch]>=1.18.0", {{/if}} {{#if (eq pythonTaskQueue "huey")}} "huey>=2.5.0", - "redis>=7.0.0", + "redis>=5.0.1", {{/if}} {{#if (eq pythonTaskQueue "taskiq")}} "taskiq>=0.11.0", {{/if}} {{#if (eq pythonCaching "redis")}} - "redis>=7.0.0", + "redis>=5.0.1", {{/if}} {{#if (eq pythonCaching "aiocache")}} "aiocache>=0.12.3", From 2ad6ca29450cda2b362dd15387fb3e4878254413 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 20 Jun 2026 02:19:19 +0300 Subject: [PATCH 26/36] test(cli): update RQ pyproject assertion to redis>=5.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the redis/celery pin fix — the RQ task-queue test hard-coded the old redis>=7.0.0 floor. --- apps/cli/test/python-language.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/test/python-language.test.ts b/apps/cli/test/python-language.test.ts index 77142b125..ef41415ba 100644 --- a/apps/cli/test/python-language.test.ts +++ b/apps/cli/test/python-language.test.ts @@ -2900,7 +2900,7 @@ describe("Python Language Support", () => { const pyprojectContent = getFileContent(root, "pyproject.toml"); expect(pyprojectContent).toBeDefined(); expect(pyprojectContent).toContain("rq>=2.6.0"); - expect(pyprojectContent).toContain("redis>=7.0.0"); + expect(pyprojectContent).toContain("redis>=5.0.1"); }); it("should create RQ queue and task helpers", async () => { From 955e17ec3ce4480f28bde3582b7cb120fc58bd4e Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 20 Jun 2026 14:41:16 +0300 Subject: [PATCH 27/36] fix(templates,testing): redis zscore type, garph ctx/context, better-auth redis, go handlers import, dev-check timeout Smoke / strict-smoke gating failures on PR #234: - db/redis/base: ioredis zscore returns string|null but zset.score() was declared number|null -> db typecheck failure on *-redis combos. Await and coerce with Number(). - api/garph routers: createResolvers(ctx) only reads ctx when auth=better-auth -> TS6133 unused param when auth=none. Emit _ctx when not better-auth. - api/garph context: context.ts only branched on hono/elysia/express/fastify/fets/adonisjs/self(next|tanstack|astro); nestjs (and self+other frontends) produced an empty file the generator skips -> "Cannot find module '../context'". Add a generic session-only fallback for uncovered backends/frontends. - auth/better-auth: database=redis is a KV store, not a SQL adapter, but the orm=none / kysely / mikroorm blocks still passed `database: env.DATABASE_URL` (string) -> server typecheck failure ('string' not assignable to better-auth database). Exclude redis so better-auth falls back to its in-memory default. - go cmd/server/main.go: internal/handlers was imported for any gin/echo/fiber/chi web framework, but handlers are only referenced when goOrm is gorm/sqlc/ent -> "imported and not used" for orm=none (e.g. echo+grpc-go). Gate the import on web framework AND orm. - testing/dev-check: bump URL-detection (60s->120s) and total (90s->150s) timeouts so heavy Next presets (preset-t3) reliably print their dev URL under CI load. Verified: the four failing TS/Go combos scaffold + install + type-check / build clean; full apps/cli suite (./test/*.test.ts) 3290 pass / 0 fail. --- .../api/garph/server/src/context.ts.hbs | 36 +++++++++++++++++++ .../api/garph/server/src/routers/index.ts.hbs | 2 +- .../better-auth/server/base/src/index.ts.hbs | 4 +-- .../templates/db/redis/base/src/index.ts.hbs | 3 +- .../templates/go-base/cmd/server/main.go.hbs | 2 +- testing/lib/dev-check.ts | 4 +-- 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/template-generator/templates/api/garph/server/src/context.ts.hbs b/packages/template-generator/templates/api/garph/server/src/context.ts.hbs index 5f4b75c44..46769687b 100644 --- a/packages/template-generator/templates/api/garph/server/src/context.ts.hbs +++ b/packages/template-generator/templates/api/garph/server/src/context.ts.hbs @@ -182,5 +182,41 @@ export function createContext(astroContext: APIContext{{#if (isBetterAuth auth)} {{/if}} }; } +{{else}} +{{#if (isBetterAuth auth)}} +import type { Session, User } from "better-auth"; +{{/if}} + +export type Context = { +{{#if (isBetterAuth auth)}} + session: { user: User; session: Session } | null; +{{/if}} +}; + +export function createContext({{#if (isBetterAuth auth)}}session: { user: User; session: Session } | null{{/if}}): Context { + return { +{{#if (isBetterAuth auth)}} + session, +{{/if}} + }; +} {{/if}} +{{else}} +{{#if (isBetterAuth auth)}} +import type { Session, User } from "better-auth"; +{{/if}} + +export type Context = { +{{#if (isBetterAuth auth)}} + session: { user: User; session: Session } | null; +{{/if}} +}; + +export function createContext({{#if (isBetterAuth auth)}}session: { user: User; session: Session } | null{{/if}}): Context { + return { +{{#if (isBetterAuth auth)}} + session, +{{/if}} + }; +} {{/if}} diff --git a/packages/template-generator/templates/api/garph/server/src/routers/index.ts.hbs b/packages/template-generator/templates/api/garph/server/src/routers/index.ts.hbs index 3053839a4..e1673e928 100644 --- a/packages/template-generator/templates/api/garph/server/src/routers/index.ts.hbs +++ b/packages/template-generator/templates/api/garph/server/src/routers/index.ts.hbs @@ -1,7 +1,7 @@ import { g, buildSchema, type InferResolvers, queryType, mutationType } from "../index{{#if (eq backend "adonisjs")}}.js{{/if}}"; import type { Context } from "../context{{#if (eq backend "adonisjs")}}.js{{/if}}"; -export function createResolvers(ctx: Context): InferResolvers<{ Query: typeof queryType; Mutation: typeof mutationType }, { context: Context }> { +export function createResolvers({{#if (isBetterAuth auth)}}ctx{{else}}_ctx{{/if}}: Context): InferResolvers<{ Query: typeof queryType; Mutation: typeof mutationType }, { context: Context }> { return { Query: { health: () => "OK", diff --git a/packages/template-generator/templates/auth/better-auth/server/base/src/index.ts.hbs b/packages/template-generator/templates/auth/better-auth/server/base/src/index.ts.hbs index b38a2e3f2..748f89b10 100644 --- a/packages/template-generator/templates/auth/better-auth/server/base/src/index.ts.hbs +++ b/packages/template-generator/templates/auth/better-auth/server/base/src/index.ts.hbs @@ -325,7 +325,7 @@ import { polarClient } from "./lib/payments"; {{/if}} export const auth = betterAuth({ -{{#if (ne database "none")}} +{{#if (and (ne database "none") (ne database "redis"))}} database: env.DATABASE_URL, {{/if}} trustedOrigins: [ @@ -395,7 +395,7 @@ import { polarClient } from "./lib/payments"; export const auth = betterAuth({ -{{#if (ne database "none")}} +{{#if (and (ne database "none") (ne database "redis"))}} database: env.DATABASE_URL, {{/if}} trustedOrigins: [ diff --git a/packages/template-generator/templates/db/redis/base/src/index.ts.hbs b/packages/template-generator/templates/db/redis/base/src/index.ts.hbs index 5ed217c57..e762304cb 100644 --- a/packages/template-generator/templates/db/redis/base/src/index.ts.hbs +++ b/packages/template-generator/templates/db/redis/base/src/index.ts.hbs @@ -294,7 +294,8 @@ export const zset = { async score(key: string, member: unknown): Promise { const serialized = typeof member === "string" ? member : JSON.stringify(member); - return redis.zscore(key, serialized); + const score = await redis.zscore(key, serialized); + return score === null ? null : Number(score); }, async rank(key: string, member: unknown): Promise { diff --git a/packages/template-generator/templates/go-base/cmd/server/main.go.hbs b/packages/template-generator/templates/go-base/cmd/server/main.go.hbs index b58e6519d..b5b1a42b4 100644 --- a/packages/template-generator/templates/go-base/cmd/server/main.go.hbs +++ b/packages/template-generator/templates/go-base/cmd/server/main.go.hbs @@ -34,7 +34,7 @@ import ( "{{projectName}}/internal/database" {{/if}} -{{#if (or (or (or (eq goWebFramework "gin") (eq goWebFramework "echo")) (eq goWebFramework "fiber")) (eq goWebFramework "chi"))}} +{{#if (and (or (or (or (eq goWebFramework "gin") (eq goWebFramework "echo")) (eq goWebFramework "fiber")) (eq goWebFramework "chi")) (or (or (eq goOrm "gorm") (eq goOrm "sqlc")) (eq goOrm "ent")))}} "{{projectName}}/internal/handlers" {{/if}} {{#if (or (eq auth "go-better-auth") (ne goAuth "none"))}} diff --git a/testing/lib/dev-check.ts b/testing/lib/dev-check.ts index 60102b301..a89b29a24 100644 --- a/testing/lib/dev-check.ts +++ b/testing/lib/dev-check.ts @@ -3,8 +3,8 @@ import { getLocalWebDevPort } from "@better-fullstack/types"; import type { StepResult } from "./verify"; -const DEV_STARTUP_TIMEOUT_MS = 60_000; -const TOTAL_DEV_CHECK_TIMEOUT_MS = 90_000; +const DEV_STARTUP_TIMEOUT_MS = 120_000; +const TOTAL_DEV_CHECK_TIMEOUT_MS = 150_000; const HTTP_REQUEST_TIMEOUT_MS = 10_000; const POLL_INTERVAL_MS = 2_000; const MAX_FETCH_RETRIES = 3; From db393fa32c6d31802bc6d19b4ccbdbb78d9eaff5 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 21 Jun 2026 21:02:51 +0300 Subject: [PATCH 28/36] feat(web): slim analytics into a stack leaderboard; remove /showcase Reshape /analytics from the recharts dashboard into a benchmark-style ranked leaderboard (total scaffolded + top stacks + frontend/backend/ database/orm) matching the homepage ScaffBench card. The loader now narrows getStats to that small public subset server-side, so version / deploy / dev-tool / timeline telemetry no longer reaches the client. Delete the /showcase route, its components, the navbar entry, the sitemap entry, and the navShowcase message; drop the now-unused recharts analytics components. --- apps/web/messages/en.json | 1 - .../components/analytics/analytics-header.tsx | 120 ----------- .../components/analytics/analytics-page.tsx | 79 ------- .../src/components/analytics/chart-card.tsx | 28 --- .../analytics/dev-environment-charts.tsx | 201 ------------------ .../src/components/analytics/live-logs.tsx | 185 ---------------- .../components/analytics/metrics-cards.tsx | 117 ---------- .../analytics/stack-configuration-charts.tsx | 161 -------------- .../analytics/stack-leaderboard.tsx | 121 +++++++++++ .../components/analytics/timeline-charts.tsx | 186 ---------------- apps/web/src/components/analytics/types.ts | 52 ----- apps/web/src/components/navbar.tsx | 6 - .../src/components/showcase/ShowcaseItem.tsx | 114 ---------- .../src/components/showcase/showcase-page.tsx | 75 ------- apps/web/src/lib/analytics-aggregate.ts | 190 ++++------------- apps/web/src/lib/sitemap-core.ts | 1 - apps/web/src/paraglide/messages/_index.js | 1 - .../src/paraglide/messages/navshowcase1.js | 53 ----- apps/web/src/routeTree.gen.ts | 21 -- apps/web/src/routes/analytics.tsx | 58 ++--- apps/web/src/routes/showcase.tsx | 78 ------- 21 files changed, 173 insertions(+), 1675 deletions(-) delete mode 100644 apps/web/src/components/analytics/analytics-header.tsx delete mode 100644 apps/web/src/components/analytics/analytics-page.tsx delete mode 100644 apps/web/src/components/analytics/chart-card.tsx delete mode 100644 apps/web/src/components/analytics/dev-environment-charts.tsx delete mode 100644 apps/web/src/components/analytics/live-logs.tsx delete mode 100644 apps/web/src/components/analytics/metrics-cards.tsx delete mode 100644 apps/web/src/components/analytics/stack-configuration-charts.tsx create mode 100644 apps/web/src/components/analytics/stack-leaderboard.tsx delete mode 100644 apps/web/src/components/analytics/timeline-charts.tsx delete mode 100644 apps/web/src/components/analytics/types.ts delete mode 100644 apps/web/src/components/showcase/ShowcaseItem.tsx delete mode 100644 apps/web/src/components/showcase/showcase-page.tsx delete mode 100644 apps/web/src/paraglide/messages/navshowcase1.js delete mode 100644 apps/web/src/routes/showcase.tsx diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f5585a699..24ee453fd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -21,7 +21,6 @@ "navBlog": "Blog", "navMcp": "MCP", "navSkill": "Skill", - "navShowcase": "Showcase", "navAnalytics": "Analytics", "navTryNow": "Try now", "navCopy": "Copy", diff --git a/apps/web/src/components/analytics/analytics-header.tsx b/apps/web/src/components/analytics/analytics-header.tsx deleted file mode 100644 index 729669328..000000000 --- a/apps/web/src/components/analytics/analytics-header.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { format } from "date-fns"; -import { Terminal } from "lucide-react"; - -export type AnalyticsConnectionStatus = "online" | "reconnecting" | "offline" | "disabled"; - -export function AnalyticsHeader({ - lastUpdated, - legacy, - connectionStatus, -}: { - lastUpdated: string | null; - legacy: { - total: number; - avgPerDay: number; - lastUpdatedIso: string; - source: string; - }; - connectionStatus: AnalyticsConnectionStatus; -}) { - const formattedDate = lastUpdated - ? format(new Date(lastUpdated), "MMM d, yyyy 'at' HH:mm") - : null; - const legacyDate = format(new Date(legacy.lastUpdatedIso), "MMM d, yyyy 'at' HH:mm"); - const statusColor = - connectionStatus === "online" - ? "text-green-500" - : connectionStatus === "reconnecting" - ? "text-amber-500" - : connectionStatus === "disabled" - ? "text-muted-foreground" - : "text-red-500"; - const statusLabel = - connectionStatus === "online" - ? "online" - : connectionStatus === "reconnecting" - ? "reconnecting" - : connectionStatus === "offline" - ? "offline" - : "not-configured"; - - return ( -
-
-
-
- -

CLI_ANALYTICS.JSON

-
-

- Real-time usage statistics from create-better-fullstack -

-
-
- -
-
-
- $ - status: - {statusLabel} -
- {formattedDate && ( -
- last_event: - {formattedDate} -
- )} -
- -
- -
-
- > - No personal data collected - anonymous usage stats only -
-
- > - - Source code:{" "} - - apps/cli/src/utils/analytics.ts - - -
-
- -
-
- # - Legacy Data (pre-Convex) -
-
-
- Total:{" "} - {legacy.total.toLocaleString()} -
-
- Avg/Day:{" "} - {legacy.avgPerDay.toFixed(1)} -
-
- As of:{" "} - {legacyDate} -
-
- Source:{" "} - {legacy.source} -
-
-
-
-
- ); -} diff --git a/apps/web/src/components/analytics/analytics-page.tsx b/apps/web/src/components/analytics/analytics-page.tsx deleted file mode 100644 index f947bd4e0..000000000 --- a/apps/web/src/components/analytics/analytics-page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useConvexConnectionState } from "convex/react"; - -import Footer from "@/components/home/footer"; -import { isConvexConfigured } from "@/lib/convex"; - -import type { AggregatedAnalyticsData } from "./types"; - -import { AnalyticsHeader, type AnalyticsConnectionStatus } from "./analytics-header"; -import { DevToolsSection } from "./dev-environment-charts"; -import { LiveLogs } from "./live-logs"; -import { MetricsCards } from "./metrics-cards"; -import { StackSection } from "./stack-configuration-charts"; -import { TimelineSection } from "./timeline-charts"; - -type AnalyticsPageProps = { - data: AggregatedAnalyticsData; - legacy: { - total: number; - avgPerDay: number; - lastUpdatedIso: string; - source: string; - }; -}; - -function getConnectionStatus(connectionState: { - isWebSocketConnected: boolean; - hasInflightRequests: boolean; -}): AnalyticsConnectionStatus { - if (connectionState.isWebSocketConnected) return "online"; - if (connectionState.hasInflightRequests) return "reconnecting"; - return "offline"; -} - -function AnalyticsLayout({ - data, - legacy, - connectionStatus, -}: AnalyticsPageProps & { connectionStatus: AnalyticsConnectionStatus }) { - return ( -
-
- - - - -
-
- -
-
- - - - - - -
-
-
- ); -} - -function ConnectedAnalyticsLayout(props: AnalyticsPageProps) { - const connectionState = useConvexConnectionState(); - const connectionStatus = getConnectionStatus(connectionState); - return ; -} - -export default function AnalyticsPage({ data, legacy }: AnalyticsPageProps) { - if (!isConvexConfigured) { - return ; - } - - return ; -} diff --git a/apps/web/src/components/analytics/chart-card.tsx b/apps/web/src/components/analytics/chart-card.tsx deleted file mode 100644 index 4c663485c..000000000 --- a/apps/web/src/components/analytics/chart-card.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { ReactNode } from "react"; - -export function ChartCard({ - title, - description, - children, -}: { - title: string; - description: string; - children: ReactNode; -}) { - return ( -
-
-
-
-
- $ - {title} -
-

{description}

-
-
- {children} -
-
- ); -} diff --git a/apps/web/src/components/analytics/dev-environment-charts.tsx b/apps/web/src/components/analytics/dev-environment-charts.tsx deleted file mode 100644 index 22d5eefd1..000000000 --- a/apps/web/src/components/analytics/dev-environment-charts.tsx +++ /dev/null @@ -1,201 +0,0 @@ - -import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts"; - -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; - -import type { AggregatedAnalyticsData, Distribution, VersionDistribution } from "./types"; - -import { ChartCard } from "./chart-card"; - -const CHART_COLORS = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -function getChartConfig(data: Distribution): ChartConfig { - const config: ChartConfig = {}; - for (const [index, item] of data.entries()) { - config[item.name] = { - label: item.name, - color: CHART_COLORS[index % CHART_COLORS.length], - }; - } - return config; -} - -function getVersionChartConfig(): ChartConfig { - return { - count: { label: "Count", color: "var(--chart-5)" }, - }; -} - -function VerticalBarChart({ data, height = 280 }: { data: Distribution; height?: number }) { - const chartConfig = getChartConfig(data); - - return ( - - - - (value.length > 20 ? `${value.slice(0, 20)}…` : value)} - /> - - } /> - - {data.map((entry, index) => ( - - ))} - - - - ); -} - -function VersionBarChart({ data, height = 280 }: { data: VersionDistribution; height?: number }) { - const chartConfig = getVersionChartConfig(); - - return ( - - - - (value.length > 7 ? `${value.slice(0, 7)}…` : value)} - /> - - } /> - - - - ); -} - -function PieChartComponent({ data }: { data: Distribution }) { - const chartConfig = getChartConfig(data); - - return ( - - - } /> - - {data.map((entry, index) => ( - - ))} - - } /> - - - ); -} - -export function DevToolsSection({ data }: { data: AggregatedAnalyticsData }) { - const { - packageManagerDistribution, - gitDistribution, - installDistribution, - addonsDistribution, - examplesDistribution, - nodeVersionDistribution, - cliVersionDistribution, - webDeployDistribution, - serverDeployDistribution, - } = data; - - return ( -
-
- DEV_TOOLS_AND_CONFIG -
- [TOOLING] -
- -
- - - - - - - - - - - - - - - -
- - {addonsDistribution.length > 0 && ( - - - - )} - - {examplesDistribution.length > 0 && ( - - - - )} - - {(webDeployDistribution.length > 0 || serverDeployDistribution.length > 0) && ( -
- {webDeployDistribution.length > 0 && ( - - - - )} - {serverDeployDistribution.length > 0 && ( - - - - )} -
- )} - - {cliVersionDistribution.length > 0 && ( - - - - )} -
- ); -} diff --git a/apps/web/src/components/analytics/live-logs.tsx b/apps/web/src/components/analytics/live-logs.tsx deleted file mode 100644 index e9f1b7a97..000000000 --- a/apps/web/src/components/analytics/live-logs.tsx +++ /dev/null @@ -1,185 +0,0 @@ - -import { api } from "@better-fullstack/backend/convex/_generated/api"; -import { useQuery } from "convex/react"; -import { ChevronRight, Terminal, Radio } from "lucide-react"; -import { motion, AnimatePresence } from "motion/react"; -import { useState } from "react"; - -import type { AnalyticsConnectionStatus } from "@/components/analytics/analytics-header"; - -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { isConvexConfigured } from "@/lib/convex"; -import { cn } from "@/lib/utils"; - -function getConnectionLabel(status: AnalyticsConnectionStatus): string { - if (status === "online") return "[CONNECTED]"; - if (status === "reconnecting") return "[RECONNECTING]"; - if (status === "offline") return "[OFFLINE]"; - return "[NOT CONFIGURED]"; -} - -function getConnectionClass(status: AnalyticsConnectionStatus): string { - if (status === "online") return "text-emerald-400/80"; - if (status === "reconnecting") return "text-amber-400/80"; - if (status === "offline") return "text-red-400/80"; - return "text-muted-foreground/60"; -} - -function LiveLogsContent({ connectionStatus }: { connectionStatus: AnalyticsConnectionStatus }) { - const [isOpen, setIsOpen] = useState(false); - - // Only fetch when expanded - pass "skip" to skip the query when closed - const events = useQuery(api.analytics.getRecentEvents, isOpen ? {} : "skip"); - - return ( -
- - - - {isOpen && ( - -
- {!events || events.length === 0 ? ( -
-
-
- -
-
-

- NO_RECENT_ACTIVITY.LOG -

-

- Listening for events... -

-
-
- $ - tail -f /logs/live - -
-
-
- ) : ( - -
- - {events.map((event, index) => { - const time = new Date(event._creationTime).toLocaleTimeString([], { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - - const { _id, _creationTime, ...logData } = event; - - return ( - - - {time} - - -
- - INFO - -
- {Object.entries(logData).map(([key, value]) => ( - - - {key}= - - - {Array.isArray(value) - ? `[${value.map((v) => `"${v}"`).join(",")}]` - : typeof value === "string" - ? `"${value}"` - : String(value)} - - - ))} -
-
-
- ); - })} -
-
-
- )} -
-
- )} -
-
- ); -} - -export function LiveLogs({ connectionStatus }: { connectionStatus: AnalyticsConnectionStatus }) { - if (!isConvexConfigured) { - // Return a simplified version without Convex - return ( -
-
-
- - - LIVE_PROJECT_LOGS.SH - -
- - [NOT CONFIGURED] - -
-
- ); - } - return ; -} diff --git a/apps/web/src/components/analytics/metrics-cards.tsx b/apps/web/src/components/analytics/metrics-cards.tsx deleted file mode 100644 index 0eace6049..000000000 --- a/apps/web/src/components/analytics/metrics-cards.tsx +++ /dev/null @@ -1,117 +0,0 @@ - -import NumberFlow from "@number-flow/react"; -import { Code2, Database, Globe, Layers, Server, Terminal, TrendingUp, Zap } from "lucide-react"; - -import type { AggregatedAnalyticsData } from "./types"; - -type MetricCardProps = { - title: string; - value: string | number; - subtitle: string; - icon: React.ReactNode; - highlight?: boolean; - animate?: boolean; -}; - -function MetricCard({ title, value, subtitle, icon, highlight, animate }: MetricCardProps) { - return ( -
-
-
- - {icon} - {title} - -
- - {animate && typeof value === "number" ? ( - - ) : ( -
- {typeof value === "number" ? value.toLocaleString() : value} -
- )} - -
-

{subtitle}

-
-
-
- ); -} - -export function MetricsCards({ data }: { data: AggregatedAnalyticsData }) { - const { summary, totalProjects, avgProjectsPerDay } = data; - - return ( -
-
- KEY_METRICS -
-
- -
- } - highlight - animate - /> - } - highlight - animate - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> -
-
- ); -} diff --git a/apps/web/src/components/analytics/stack-configuration-charts.tsx b/apps/web/src/components/analytics/stack-configuration-charts.tsx deleted file mode 100644 index 94b0e23c1..000000000 --- a/apps/web/src/components/analytics/stack-configuration-charts.tsx +++ /dev/null @@ -1,161 +0,0 @@ - -import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts"; - -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; - -import type { AggregatedAnalyticsData, Distribution } from "./types"; - -import { ChartCard } from "./chart-card"; - -const CHART_COLORS = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -function getChartConfig(data: Distribution): ChartConfig { - const config: ChartConfig = {}; - for (const [index, item] of data.entries()) { - config[item.name] = { - label: item.name, - color: CHART_COLORS[index % CHART_COLORS.length], - }; - } - return config; -} - -function BarChartComponent({ data, height = 280 }: { data: Distribution; height?: number }) { - const chartConfig = getChartConfig(data); - - return ( - - - - (value.length > 20 ? `${value.slice(0, 20)}…` : value)} - /> - - } /> - - {data.map((entry, index) => ( - - ))} - - - - ); -} - -function PieChartComponent({ data }: { data: Distribution }) { - const chartConfig = getChartConfig(data); - - return ( - - - } /> - - {data.map((entry, index) => ( - - ))} - - } /> - - - ); -} - -export function StackSection({ data }: { data: AggregatedAnalyticsData }) { - const { - popularStackCombinations, - frontendDistribution, - backendDistribution, - databaseDistribution, - ormDistribution, - dbSetupDistribution, - apiDistribution, - authDistribution, - runtimeDistribution, - databaseORMCombinations, - } = data; - - return ( -
-
- STACK_CONFIGURATION -
- [CORE_CHOICES] -
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - -
- ); -} diff --git a/apps/web/src/components/analytics/stack-leaderboard.tsx b/apps/web/src/components/analytics/stack-leaderboard.tsx new file mode 100644 index 000000000..3a40d5542 --- /dev/null +++ b/apps/web/src/components/analytics/stack-leaderboard.tsx @@ -0,0 +1,121 @@ +import { cn } from "@/lib/utils"; + +import type { StackAnalyticsData, StackDistribution } from "@/lib/analytics-aggregate"; + +const CARD_CLASS = + "overflow-hidden rounded-2xl border border-[#e1e0d8] bg-[#faf9f5] text-[#1b1a17] [color-scheme:light] dark:border-[rgba(237,235,228,0.10)] dark:bg-[#161614] dark:text-[#dad8d0] dark:[color-scheme:dark]"; +const DIVIDER = "border-[#e1e0d8] dark:border-[rgba(237,235,228,0.10)]"; +const MUTED = "text-[#71706a] dark:text-[#8f8d84]"; +const TRACK = "bg-black/[0.06] dark:bg-white/[0.07]"; + +const countFormatter = new Intl.NumberFormat("en-US"); + +function LeaderboardRow({ + item, + max, + nameClass, +}: { + item: StackDistribution[number]; + max: number; + nameClass: string; +}) { + const barWidth = max > 0 ? `${Math.max((item.value / max) * 100, 2)}%` : "0%"; + + return ( +
  • + + {item.name} + +
    +
    +
    + + {countFormatter.format(item.value)} + + + {Math.round(item.pct * 100)}% + +
  • + ); +} + +function Leaderboard({ + title, + items, + wide = false, +}: { + title: string; + items: StackDistribution; + wide?: boolean; +}) { + const max = items[0]?.value ?? 0; + const nameClass = wide ? "w-40 sm:w-56" : "w-24 sm:w-28"; + + return ( +
    +
    +

    {title}

    + share +
    + {items.length === 0 ? ( +

    No data yet

    + ) : ( +
      + {items.map((item) => ( + + ))} +
    + )} +
    + ); +} + +export function StackLeaderboard({ data }: { data: StackAnalyticsData }) { + const { totalProjects, topStacks, frontend, backend, database, orm } = data; + const hasData = totalProjects > 0; + + return ( +
    +
    +
    +

    Stack analytics

    +

    + What developers actually pick when scaffolding with Better Fullstack. +

    +
    +
    +
    + {countFormatter.format(totalProjects)} +
    +
    + projects scaffolded +
    +
    +
    + + {hasData ? ( +
    + +
    + + + + +
    +

    + Aggregated from anonymous, opt-in CLI telemetry +

    +
    + ) : ( +
    +

    Live analytics are not available right now.

    +
    + )} +
    + ); +} diff --git a/apps/web/src/components/analytics/timeline-charts.tsx b/apps/web/src/components/analytics/timeline-charts.tsx deleted file mode 100644 index 6c21d4ae7..000000000 --- a/apps/web/src/components/analytics/timeline-charts.tsx +++ /dev/null @@ -1,186 +0,0 @@ - -import { format, parseISO } from "date-fns"; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - Pie, - PieChart, - XAxis, - YAxis, -} from "recharts"; - -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; - -import type { AggregatedAnalyticsData, Distribution } from "./types"; - -import { ChartCard } from "./chart-card"; - -const CHART_COLORS = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -function getChartConfig(data: Distribution): ChartConfig { - const config: ChartConfig = {}; - for (const [index, item] of data.entries()) { - config[item.name] = { - label: item.name, - color: CHART_COLORS[index % CHART_COLORS.length], - }; - } - return config; -} - -const areaChartConfig = { - count: { label: "Projects", color: "var(--chart-1)" }, -} satisfies ChartConfig; - -const barChartConfig = { - count: { label: "Projects", color: "var(--chart-2)" }, -} satisfies ChartConfig; - -const hourlyChartConfig = { - count: { label: "Projects", color: "var(--chart-3)" }, -} satisfies ChartConfig; - -export function TimelineSection({ data }: { data: AggregatedAnalyticsData }) { - const { timeSeries, monthlyTimeSeries, platformDistribution, hourlyDistribution } = data; - const platformChartConfig = getChartConfig(platformDistribution); - - return ( -
    -
    - TIMELINE_ANALYSIS -
    -
    - -
    - - - - - format(parseISO(val), "d")} - interval="preserveStartEnd" - /> - - { - const item = payload?.[0]?.payload as { date: string } | undefined; - return item ? format(parseISO(item.date), "MMM d, yyyy") : ""; - }} - hideIndicator - /> - } - /> - - - - - - - - - - val.slice(5)} - /> - - } /> - - - - - - - - - } /> - - {platformDistribution.map((entry, index) => ( - - ))} - - } /> - - - - - - - - - val.replace(":00", "")} - /> - - `${value} UTC`} hideIndicator /> - } - /> - - - - -
    -
    - ); -} diff --git a/apps/web/src/components/analytics/types.ts b/apps/web/src/components/analytics/types.ts deleted file mode 100644 index 75ce85390..000000000 --- a/apps/web/src/components/analytics/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ChartConfig } from "@/components/ui/chart"; - -export type Distribution = Array<{ name: string; value: number }>; -export type VersionDistribution = Array<{ version: string; count: number }>; -export type TimeSeriesData = Array<{ date: string; count: number }>; -export type MonthlyData = Array<{ month: string; count: number }>; -export type HourlyData = Array<{ hour: string; count: number }>; - -export type AggregatedAnalyticsData = { - lastUpdated: string | null; - totalProjects: number; - avgProjectsPerDay: number; - timeSeries: TimeSeriesData; - monthlyTimeSeries: MonthlyData; - hourlyDistribution: HourlyData; - platformDistribution: Distribution; - packageManagerDistribution: Distribution; - backendDistribution: Distribution; - databaseDistribution: Distribution; - ormDistribution: Distribution; - dbSetupDistribution: Distribution; - apiDistribution: Distribution; - frontendDistribution: Distribution; - authDistribution: Distribution; - runtimeDistribution: Distribution; - addonsDistribution: Distribution; - examplesDistribution: Distribution; - gitDistribution: Distribution; - installDistribution: Distribution; - webDeployDistribution: Distribution; - serverDeployDistribution: Distribution; - paymentsDistribution: Distribution; - nodeVersionDistribution: VersionDistribution; - cliVersionDistribution: VersionDistribution; - popularStackCombinations: Distribution; - databaseORMCombinations: Distribution; - summary: { - mostPopularFrontend: string; - mostPopularBackend: string; - mostPopularDatabase: string; - mostPopularORM: string; - mostPopularAPI: string; - mostPopularAuth: string; - mostPopularPackageManager: string; - mostPopularRuntime: string; - }; -}; - -export const chartConfig = { - value: { label: "Projects", color: "var(--chart-1)" }, - count: { label: "Projects", color: "var(--chart-1)" }, -} satisfies ChartConfig; diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 01d80d0fb..123967418 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -173,12 +173,6 @@ function DocsMenuItems() { > {m.navSkill()} - } - className="cursor-pointer font-mono text-[11px] uppercase tracking-[0.18em]" - > - {m.navShowcase()} - } className="cursor-pointer font-mono text-[11px] uppercase tracking-[0.18em]" diff --git a/apps/web/src/components/showcase/ShowcaseItem.tsx b/apps/web/src/components/showcase/ShowcaseItem.tsx deleted file mode 100644 index 31c965015..000000000 --- a/apps/web/src/components/showcase/ShowcaseItem.tsx +++ /dev/null @@ -1,114 +0,0 @@ - -import { ExternalLink, File, Github, Monitor } from "lucide-react"; - -export interface ShowcaseItemProps { - title: string; - description: string; - imageUrl: string; - liveUrl?: string; - sourceUrl?: string; - tags: string[]; - index?: number; -} - -export default function ShowcaseItem({ - title, - description, - imageUrl, - liveUrl, - sourceUrl, - tags, - index = 0, -}: ShowcaseItemProps) { - const projectId = `PROJECT_${String(index + 1).padStart(3, "0")}`; - - return ( -
    -
    -
    - - - {projectId}.PROJECT - -
    - - {tags.length} DEPS -
    -
    -
    - -
    - {title} -
    -
    - -
    -

    {title}

    - -

    - {description} -

    - -
    -
    - DEPENDENCIES: -
    -
    - {tags.map((tag) => ( - - {tag} - - ))} -
    -
    - -
    -
    - {liveUrl && ( - - - LAUNCH_DEMO.SH - - - )} - {sourceUrl && ( - - - VIEW_SOURCE.GIT - - - )} -
    - -
    -
    - $ - echo "Status: READY" -
    -
    - ONLINE -
    -
    -
    -
    -
    -
    - ); -} diff --git a/apps/web/src/components/showcase/showcase-page.tsx b/apps/web/src/components/showcase/showcase-page.tsx deleted file mode 100644 index bb11c9879..000000000 --- a/apps/web/src/components/showcase/showcase-page.tsx +++ /dev/null @@ -1,75 +0,0 @@ - -import { Terminal } from "lucide-react"; - -import Footer from "@/components/home/footer"; - -import ShowcaseItem from "./ShowcaseItem"; - -type ShowcaseProject = { - _id: string; - _creationTime: number; - title: string; - description: string; - imageUrl: string; - liveUrl: string; - tags: string[]; -}; - -export default function ShowcasePage({ - showcaseProjects, -}: { - showcaseProjects: Array; -}) { - return ( -
    -
    -
    -
    -
    - - PROJECT_SHOWCASE.SH -
    -
    - - [{showcaseProjects.length} PROJECTS FOUND] - -
    -
    - - {showcaseProjects.length === 0 ? ( -
    -
    -
    - NO_SHOWCASE_PROJECTS_FOUND.NULL -
    -
    - $ - - Be the first to showcase your project! - -
    -
    -
    - ) : ( -
    - {showcaseProjects.map((project, index) => ( - - ))} -
    - )} - -
    -
    -
    - $ - - Want to showcase your project? Submit via GitHub issues - -
    -
    -
    -
    -
    -
    - ); -} diff --git a/apps/web/src/lib/analytics-aggregate.ts b/apps/web/src/lib/analytics-aggregate.ts index 049b4de67..bf3367483 100644 --- a/apps/web/src/lib/analytics-aggregate.ts +++ b/apps/web/src/lib/analytics-aggregate.ts @@ -1,172 +1,58 @@ -import type { - AggregatedAnalyticsData, - Distribution, - HourlyData, - MonthlyData, - TimeSeriesData, - VersionDistribution, -} from "@/components/analytics/types"; +export type StackDistributionItem = { + name: string; + value: number; + pct: number; +}; +export type StackDistribution = StackDistributionItem[]; + +export type StackAnalyticsData = { + totalProjects: number; + topStacks: StackDistribution; + frontend: StackDistribution; + backend: StackDistribution; + database: StackDistribution; + orm: StackDistribution; +}; type Dist = Record; -// The subset of the Convex `getStats` result that drives the analytics dashboard. -// `getStats` returns each distribution as a `Record` (a count map); -// the dashboard components expect sorted `{ name, value }` arrays plus a computed -// summary, which is what `buildAggregatedAnalyticsData` produces. export type RawAnalyticsStats = { totalProjects: number; - lastEventTime?: number; frontend: Dist; backend: Dist; database: Dist; orm: Dist; - api: Dist; - auth: Dist; - runtime: Dist; - packageManager: Dist; - platform: Dist; - dbSetup: Dist; - addons: Dist; - examples: Dist; - git: Dist; - install: Dist; - webDeploy: Dist; - serverDeploy: Dist; - payments: Dist; - nodeVersion: Dist; - cliVersion: Dist; - hourlyDistribution: Dist; - stackCombinations: Dist; - dbOrmCombinations: Dist; + stackCombinations?: Dist; }; -function toDistribution(record: Dist): Distribution { - return Object.entries(record) - .map(([name, value]) => ({ name, value })) - .sort((a, b) => b.value - a.value); -} - -function toVersionDistribution(record: Dist): VersionDistribution { - return Object.entries(record) - .map(([version, count]) => ({ version, count })) - .sort((a, b) => b.count - a.count); -} - -function toHourly(record: Dist): HourlyData { - return Object.entries(record) - .map(([hour, count]) => ({ hour, count })) - .sort((a, b) => a.hour.localeCompare(b.hour)); -} - -function toMonthly(daily: TimeSeriesData): MonthlyData { - const byMonth = new Map(); - for (const { date, count } of daily) { - const month = date.slice(0, 7); // YYYY-MM - byMonth.set(month, (byMonth.get(month) ?? 0) + count); - } - return Array.from(byMonth, ([month, count]) => ({ month, count })).sort((a, b) => - a.month.localeCompare(b.month), - ); -} - -function topName(dist: Distribution): string { - return dist[0]?.name ?? "-"; -} - -export const EMPTY_ANALYTICS_DATA: AggregatedAnalyticsData = { - lastUpdated: null, +export const EMPTY_STACK_ANALYTICS: StackAnalyticsData = { totalProjects: 0, - avgProjectsPerDay: 0, - timeSeries: [], - monthlyTimeSeries: [], - hourlyDistribution: [], - platformDistribution: [], - packageManagerDistribution: [], - backendDistribution: [], - databaseDistribution: [], - ormDistribution: [], - dbSetupDistribution: [], - apiDistribution: [], - frontendDistribution: [], - authDistribution: [], - runtimeDistribution: [], - addonsDistribution: [], - examplesDistribution: [], - gitDistribution: [], - installDistribution: [], - webDeployDistribution: [], - serverDeployDistribution: [], - paymentsDistribution: [], - nodeVersionDistribution: [], - cliVersionDistribution: [], - popularStackCombinations: [], - databaseORMCombinations: [], - summary: { - mostPopularFrontend: "-", - mostPopularBackend: "-", - mostPopularDatabase: "-", - mostPopularORM: "-", - mostPopularAPI: "-", - mostPopularAuth: "-", - mostPopularPackageManager: "-", - mostPopularRuntime: "-", - }, + topStacks: [], + frontend: [], + backend: [], + database: [], + orm: [], }; -export function buildAggregatedAnalyticsData( - stats: RawAnalyticsStats, - daily: TimeSeriesData, -): AggregatedAnalyticsData { - const frontendDistribution = toDistribution(stats.frontend); - const backendDistribution = toDistribution(stats.backend); - const databaseDistribution = toDistribution(stats.database); - const ormDistribution = toDistribution(stats.orm); - const apiDistribution = toDistribution(stats.api); - const authDistribution = toDistribution(stats.auth); - const runtimeDistribution = toDistribution(stats.runtime); - const packageManagerDistribution = toDistribution(stats.packageManager); +function toRankedDistribution(record: Dist | undefined, limit: number): StackDistribution { + if (!record) return []; + const entries = Object.entries(record).filter(([name, value]) => name !== "" && value > 0); + const total = entries.reduce((sum, [, value]) => sum + value, 0); + if (total === 0) return []; - const totalDailyCount = daily.reduce((sum, day) => sum + day.count, 0); - const avgProjectsPerDay = - daily.length > 0 ? Math.round((totalDailyCount / daily.length) * 10) / 10 : 0; + return entries + .map(([name, value]) => ({ name, value, pct: value / total })) + .sort((a, b) => b.value - a.value) + .slice(0, limit); +} +export function buildStackAnalytics(stats: RawAnalyticsStats): StackAnalyticsData { return { - lastUpdated: stats.lastEventTime ? new Date(stats.lastEventTime).toISOString() : null, totalProjects: stats.totalProjects, - avgProjectsPerDay, - timeSeries: daily, - monthlyTimeSeries: toMonthly(daily), - hourlyDistribution: toHourly(stats.hourlyDistribution), - platformDistribution: toDistribution(stats.platform), - packageManagerDistribution, - backendDistribution, - databaseDistribution, - ormDistribution, - dbSetupDistribution: toDistribution(stats.dbSetup), - apiDistribution, - frontendDistribution, - authDistribution, - runtimeDistribution, - addonsDistribution: toDistribution(stats.addons), - examplesDistribution: toDistribution(stats.examples), - gitDistribution: toDistribution(stats.git), - installDistribution: toDistribution(stats.install), - webDeployDistribution: toDistribution(stats.webDeploy), - serverDeployDistribution: toDistribution(stats.serverDeploy), - paymentsDistribution: toDistribution(stats.payments), - nodeVersionDistribution: toVersionDistribution(stats.nodeVersion), - cliVersionDistribution: toVersionDistribution(stats.cliVersion), - popularStackCombinations: toDistribution(stats.stackCombinations), - databaseORMCombinations: toDistribution(stats.dbOrmCombinations), - summary: { - mostPopularFrontend: topName(frontendDistribution), - mostPopularBackend: topName(backendDistribution), - mostPopularDatabase: topName(databaseDistribution), - mostPopularORM: topName(ormDistribution), - mostPopularAPI: topName(apiDistribution), - mostPopularAuth: topName(authDistribution), - mostPopularPackageManager: topName(packageManagerDistribution), - mostPopularRuntime: topName(runtimeDistribution), - }, + topStacks: toRankedDistribution(stats.stackCombinations, 8), + frontend: toRankedDistribution(stats.frontend, 6), + backend: toRankedDistribution(stats.backend, 6), + database: toRankedDistribution(stats.database, 6), + orm: toRankedDistribution(stats.orm, 6), }; } diff --git a/apps/web/src/lib/sitemap-core.ts b/apps/web/src/lib/sitemap-core.ts index a52c3249c..27183df37 100644 --- a/apps/web/src/lib/sitemap-core.ts +++ b/apps/web/src/lib/sitemap-core.ts @@ -19,7 +19,6 @@ const staticSitemapEntries: SitemapEntry[] = [ { path: "/new", changefreq: "daily", priority: 0.9 }, { path: "/compare", changefreq: "weekly", priority: 0.8 }, { path: "/mcp", changefreq: "weekly", priority: 0.7 }, - { path: "/showcase", changefreq: "weekly", priority: 0.7 }, { path: "/analytics", changefreq: "daily", priority: 0.7 }, ]; diff --git a/apps/web/src/paraglide/messages/_index.js b/apps/web/src/paraglide/messages/_index.js index 2ab5389fd..44e0dc8e5 100644 --- a/apps/web/src/paraglide/messages/_index.js +++ b/apps/web/src/paraglide/messages/_index.js @@ -22,7 +22,6 @@ export * from './navcompare1.js' export * from './navblog1.js' export * from './navmcp1.js' export * from './navskill1.js' -export * from './navshowcase1.js' export * from './navanalytics1.js' export * from './navtrynow2.js' export * from './navcopy1.js' diff --git a/apps/web/src/paraglide/messages/navshowcase1.js b/apps/web/src/paraglide/messages/navshowcase1.js deleted file mode 100644 index c5dbe36e6..000000000 --- a/apps/web/src/paraglide/messages/navshowcase1.js +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable */ -import { getLocale, experimentalStaticLocale } from '../runtime.js'; - -/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ - -/** @typedef {{}} Navshowcase1Inputs */ - -const en_navshowcase1 = /** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ () => { - return /** @type {LocalizedString} */ (`Showcase`) -}; - -/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ -const es_navshowcase1 = en_navshowcase1; - -/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ -const zh_navshowcase1 = en_navshowcase1; - -/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ -const ja_navshowcase1 = en_navshowcase1; - -/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ -const ko_navshowcase1 = en_navshowcase1; - -/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ -const zh_hant1_navshowcase1 = zh_navshowcase1; - -/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ -const de_navshowcase1 = en_navshowcase1; - -/** @type {(inputs: Navshowcase1Inputs) => LocalizedString} */ -const fr_navshowcase1 = en_navshowcase1; - -/** -* | output | -* | --- | -* | "Showcase" | -* -* @param {Navshowcase1Inputs} inputs -* @param {{ locale?: "en" | "es" | "zh" | "ja" | "ko" | "zh-Hant" | "de" | "fr" }} options -* @returns {LocalizedString} -*/ -const navshowcase1 = /** @type {((inputs?: Navshowcase1Inputs, options?: { locale?: "en" | "es" | "zh" | "ja" | "ko" | "zh-Hant" | "de" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { - const locale = experimentalStaticLocale ?? options.locale ?? getLocale() - if (locale === "en") return en_navshowcase1(inputs) - if (locale === "es") return es_navshowcase1(inputs) - if (locale === "zh") return zh_navshowcase1(inputs) - if (locale === "ja") return ja_navshowcase1(inputs) - if (locale === "ko") return ko_navshowcase1(inputs) - if (locale === "zh-Hant") return zh_hant1_navshowcase1(inputs) - if (locale === "de") return de_navshowcase1(inputs) - return fr_navshowcase1(inputs) -}); -export { navshowcase1 as "navShowcase" } \ No newline at end of file diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index c391202f1..269a5a9e3 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,7 +11,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as StackRouteImport } from './routes/stack' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' -import { Route as ShowcaseRouteImport } from './routes/showcase' import { Route as NewRouteImport } from './routes/new' import { Route as McpRouteImport } from './routes/mcp' import { Route as LlmsDottxtRouteImport } from './routes/llms[.]txt' @@ -38,11 +37,6 @@ const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ path: '/sitemap.xml', getParentRoute: () => rootRouteImport, } as any) -const ShowcaseRoute = ShowcaseRouteImport.update({ - id: '/showcase', - path: '/showcase', - getParentRoute: () => rootRouteImport, -} as any) const NewRoute = NewRouteImport.update({ id: '/new', path: '/new', @@ -127,7 +121,6 @@ export interface FileRoutesByFullPath { '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute '/new': typeof NewRoute - '/showcase': typeof ShowcaseRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/stack': typeof StackRoute '/api/preview': typeof ApiPreviewRoute @@ -147,7 +140,6 @@ export interface FileRoutesByTo { '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute '/new': typeof NewRoute - '/showcase': typeof ShowcaseRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/stack': typeof StackRoute '/api/preview': typeof ApiPreviewRoute @@ -168,7 +160,6 @@ export interface FileRoutesById { '/llms.txt': typeof LlmsDottxtRoute '/mcp': typeof McpRoute '/new': typeof NewRoute - '/showcase': typeof ShowcaseRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/stack': typeof StackRoute '/api/preview': typeof ApiPreviewRoute @@ -190,7 +181,6 @@ export interface FileRouteTypes { | '/llms.txt' | '/mcp' | '/new' - | '/showcase' | '/sitemap.xml' | '/stack' | '/api/preview' @@ -210,7 +200,6 @@ export interface FileRouteTypes { | '/llms.txt' | '/mcp' | '/new' - | '/showcase' | '/sitemap.xml' | '/stack' | '/api/preview' @@ -230,7 +219,6 @@ export interface FileRouteTypes { | '/llms.txt' | '/mcp' | '/new' - | '/showcase' | '/sitemap.xml' | '/stack' | '/api/preview' @@ -251,7 +239,6 @@ export interface RootRouteChildren { LlmsDottxtRoute: typeof LlmsDottxtRoute McpRoute: typeof McpRoute NewRoute: typeof NewRoute - ShowcaseRoute: typeof ShowcaseRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute StackRoute: typeof StackRoute ApiPreviewRoute: typeof ApiPreviewRoute @@ -280,13 +267,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SitemapDotxmlRouteImport parentRoute: typeof rootRouteImport } - '/showcase': { - id: '/showcase' - path: '/showcase' - fullPath: '/showcase' - preLoaderRoute: typeof ShowcaseRouteImport - parentRoute: typeof rootRouteImport - } '/new': { id: '/new' path: '/new' @@ -403,7 +383,6 @@ const rootRouteChildren: RootRouteChildren = { LlmsDottxtRoute: LlmsDottxtRoute, McpRoute: McpRoute, NewRoute: NewRoute, - ShowcaseRoute: ShowcaseRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, StackRoute: StackRoute, ApiPreviewRoute: ApiPreviewRoute, diff --git a/apps/web/src/routes/analytics.tsx b/apps/web/src/routes/analytics.tsx index 8ee963129..9844de04f 100644 --- a/apps/web/src/routes/analytics.tsx +++ b/apps/web/src/routes/analytics.tsx @@ -1,18 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useMemo } from "react"; -import type { AggregatedAnalyticsData } from "@/components/analytics/types"; - -import { AnalyticsHeader } from "@/components/analytics/analytics-header"; -import { DevToolsSection } from "@/components/analytics/dev-environment-charts"; -import { MetricsCards } from "@/components/analytics/metrics-cards"; -import { StackSection } from "@/components/analytics/stack-configuration-charts"; -import { TimelineSection } from "@/components/analytics/timeline-charts"; +import { StackLeaderboard } from "@/components/analytics/stack-leaderboard"; import Footer from "@/components/home/footer"; import { - EMPTY_ANALYTICS_DATA, - buildAggregatedAnalyticsData, + EMPTY_STACK_ANALYTICS, + buildStackAnalytics, type RawAnalyticsStats, + type StackAnalyticsData, } from "@/lib/analytics-aggregate"; import { DEFAULT_OG_IMAGE_ALT, @@ -24,13 +18,9 @@ import { canonicalUrl, } from "@/lib/seo"; -// Fetch and aggregate analytics server-side via Convex's lightweight HTTP client -// (no reactive Convex React SDK in the client bundle). The live event feed is -// intentionally omitted here — it needs a client-side subscription and is a -// separate follow-up. Degrades to an empty dashboard when Convex is unconfigured. -async function loadAnalytics(): Promise { +async function loadAnalytics(): Promise { const convexUrl = import.meta.env.VITE_CONVEX_URL; - if (!convexUrl) return EMPTY_ANALYTICS_DATA; + if (!convexUrl) return EMPTY_STACK_ANALYTICS; try { const [{ ConvexHttpClient }, { api }] = await Promise.all([ @@ -38,14 +28,11 @@ async function loadAnalytics(): Promise { import("@better-fullstack/backend/convex/_generated/api"), ]); const client = new ConvexHttpClient(convexUrl); - const [stats, daily] = await Promise.all([ - client.query(api.analytics.getStats, {}), - client.query(api.analytics.getDailyStats, { days: 30 }), - ]); - if (!stats) return EMPTY_ANALYTICS_DATA; - return buildAggregatedAnalyticsData(stats as RawAnalyticsStats, daily ?? []); + const stats = await client.query(api.analytics.getStats, {}); + if (!stats) return EMPTY_STACK_ANALYTICS; + return buildStackAnalytics(stats as RawAnalyticsStats); } catch { - return EMPTY_ANALYTICS_DATA; + return EMPTY_STACK_ANALYTICS; } } @@ -53,7 +40,7 @@ export const Route = createFileRoute("/analytics")({ head: () => { const title = "Analytics — Better Fullstack"; const description = - "See which stacks developers actually pick: the most popular frontends, backends, databases, ORMs, and full stack combinations scaffolded with Better Fullstack."; + "A live leaderboard of the stacks developers actually pick: the most popular frontends, backends, databases, ORMs, and full-stack combinations scaffolded with Better Fullstack."; return { meta: [ @@ -83,28 +70,11 @@ export const Route = createFileRoute("/analytics")({ function AnalyticsRoute() { const { data } = Route.useLoaderData(); - const legacy = useMemo( - () => ({ - total: data.totalProjects, - avgPerDay: data.avgProjectsPerDay, - lastUpdatedIso: data.lastUpdated ?? "", - source: "convex", - }), - [data], - ); return ( -
    -
    - - - - - +
    +
    +
    diff --git a/apps/web/src/routes/showcase.tsx b/apps/web/src/routes/showcase.tsx deleted file mode 100644 index 39b009f79..000000000 --- a/apps/web/src/routes/showcase.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import ShowcasePage from "@/components/showcase/showcase-page"; -import { - DEFAULT_OG_IMAGE_ALT, - DEFAULT_OG_IMAGE_HEIGHT, - DEFAULT_OG_IMAGE_URL, - DEFAULT_OG_IMAGE_WIDTH, - DEFAULT_ROBOTS, - DEFAULT_X_IMAGE_URL, - canonicalUrl, -} from "@/lib/seo"; - -type ShowcaseProject = { - _id: string; - _creationTime: number; - title: string; - description: string; - imageUrl: string; - liveUrl: string; - tags: string[]; -}; - -// Fetch showcase projects server-side via Convex's lightweight HTTP client so the -// reactive Convex React SDK never enters the client entry chunk. Degrades to an -// empty gallery when Convex is not configured or unreachable. -async function loadShowcaseProjects(): Promise { - const convexUrl = import.meta.env.VITE_CONVEX_URL; - if (!convexUrl) return []; - - try { - const [{ ConvexHttpClient }, { api }] = await Promise.all([ - import("convex/browser"), - import("@better-fullstack/backend/convex/_generated/api"), - ]); - const client = new ConvexHttpClient(convexUrl); - return await client.query(api.showcase.getShowcaseProjects, {}); - } catch { - return []; - } -} - -export const Route = createFileRoute("/showcase")({ - head: () => { - const title = "Showcase — Better Fullstack"; - const description = - "Real projects built with Better Fullstack. See what the community is shipping and get inspired for your next stack."; - - return { - meta: [ - { title }, - { name: "description", content: description }, - { name: "robots", content: DEFAULT_ROBOTS }, - { property: "og:title", content: title }, - { property: "og:description", content: description }, - { property: "og:type", content: "website" }, - { property: "og:url", content: canonicalUrl("/showcase") }, - { property: "og:image", content: DEFAULT_OG_IMAGE_URL }, - { property: "og:image:alt", content: DEFAULT_OG_IMAGE_ALT }, - { property: "og:image:width", content: String(DEFAULT_OG_IMAGE_WIDTH) }, - { property: "og:image:height", content: String(DEFAULT_OG_IMAGE_HEIGHT) }, - { name: "twitter:card", content: "summary_large_image" }, - { name: "twitter:title", content: title }, - { name: "twitter:description", content: description }, - { name: "twitter:image", content: DEFAULT_X_IMAGE_URL }, - { name: "twitter:image:alt", content: DEFAULT_OG_IMAGE_ALT }, - ], - links: [{ rel: "canonical", href: canonicalUrl("/showcase") }], - }; - }, - loader: async () => ({ showcaseProjects: await loadShowcaseProjects() }), - component: ShowcaseRoute, -}); - -function ShowcaseRoute() { - const { showcaseProjects } = Route.useLoaderData(); - return ; -} From 124c8479a048e0d21aa2cd3c2c53c31fef87b8ba Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 21 Jun 2026 21:43:05 +0300 Subject: [PATCH 29/36] feat(analytics): collect all-ecosystem telemetry + ecosystem leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture per-ecosystem option choices (Go/Python/Java/.NET/Elixir/mobile specifics plus the TS vectorDb/search/i18n/etc. categories) via a generic optionStats map in Convex. The HTTP ingest gates captured fields by the project's ecosystem, so cross-ecosystem config defaults (a --yes TS project still sends goWebFramework/pythonWebFramework defaults) never pollute the per-ecosystem stats, and named distributions are not duplicated. Surface an Ecosystem leaderboard on /analytics (TypeScript, React Native, Rust, Python, Go, Java, .NET, Elixir) and show share % only — no raw counts. Filter the "none + none" combo and bare "none" picks from the leaderboards. --- .../analytics/stack-leaderboard.tsx | 28 +++-- apps/web/src/lib/analytics-aggregate.ts | 46 ++++++-- packages/backend/convex/analytics.ts | 25 ++++ packages/backend/convex/http.ts | 107 ++++++++++++++++++ packages/backend/convex/schema.ts | 2 + 5 files changed, 188 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/analytics/stack-leaderboard.tsx b/apps/web/src/components/analytics/stack-leaderboard.tsx index 3a40d5542..bc8835c9d 100644 --- a/apps/web/src/components/analytics/stack-leaderboard.tsx +++ b/apps/web/src/components/analytics/stack-leaderboard.tsx @@ -8,6 +8,12 @@ const DIVIDER = "border-[#e1e0d8] dark:border-[rgba(237,235,228,0.10)]"; const MUTED = "text-[#71706a] dark:text-[#8f8d84]"; const TRACK = "bg-black/[0.06] dark:bg-white/[0.07]"; +const NAME_WIDTH = { + sm: "w-24 sm:w-28", + md: "w-28 sm:w-40", + lg: "w-40 sm:w-56", +} as const; + const countFormatter = new Intl.NumberFormat("en-US"); function LeaderboardRow({ @@ -29,10 +35,7 @@ function LeaderboardRow({
    - - {countFormatter.format(item.value)} - - + {Math.round(item.pct * 100)}% @@ -42,14 +45,14 @@ function LeaderboardRow({ function Leaderboard({ title, items, - wide = false, + size = "sm", }: { title: string; items: StackDistribution; - wide?: boolean; + size?: keyof typeof NAME_WIDTH; }) { const max = items[0]?.value ?? 0; - const nameClass = wide ? "w-40 sm:w-56" : "w-24 sm:w-28"; + const nameClass = NAME_WIDTH[size]; return (
    @@ -71,7 +74,7 @@ function Leaderboard({ } export function StackLeaderboard({ data }: { data: StackAnalyticsData }) { - const { totalProjects, topStacks, frontend, backend, database, orm } = data; + const { totalProjects, ecosystems, topStacks, frontend, backend, database, orm } = data; const hasData = totalProjects > 0; return ( @@ -99,15 +102,16 @@ export function StackLeaderboard({ data }: { data: StackAnalyticsData }) { {hasData ? ( -
    - -
    +
    + + +
    -

    +

    Aggregated from anonymous, opt-in CLI telemetry

    diff --git a/apps/web/src/lib/analytics-aggregate.ts b/apps/web/src/lib/analytics-aggregate.ts index bf3367483..eb4c8eeee 100644 --- a/apps/web/src/lib/analytics-aggregate.ts +++ b/apps/web/src/lib/analytics-aggregate.ts @@ -7,6 +7,7 @@ export type StackDistribution = StackDistributionItem[]; export type StackAnalyticsData = { totalProjects: number; + ecosystems: StackDistribution; topStacks: StackDistribution; frontend: StackDistribution; backend: StackDistribution; @@ -18,6 +19,7 @@ type Dist = Record; export type RawAnalyticsStats = { totalProjects: number; + ecosystem: Dist; frontend: Dist; backend: Dist; database: Dist; @@ -25,8 +27,20 @@ export type RawAnalyticsStats = { stackCombinations?: Dist; }; +const ECOSYSTEM_LABELS: Record = { + typescript: "TypeScript", + "react-native": "React Native", + rust: "Rust", + python: "Python", + go: "Go", + java: "Java", + dotnet: ".NET", + elixir: "Elixir", +}; + export const EMPTY_STACK_ANALYTICS: StackAnalyticsData = { totalProjects: 0, + ecosystems: [], topStacks: [], frontend: [], backend: [], @@ -34,14 +48,29 @@ export const EMPTY_STACK_ANALYTICS: StackAnalyticsData = { orm: [], }; -function toRankedDistribution(record: Dist | undefined, limit: number): StackDistribution { +type RankOptions = { + excludeNone?: boolean; + excludeKeys?: string[]; + labels?: Record; +}; + +function toRankedDistribution( + record: Dist | undefined, + limit: number, + options: RankOptions = {}, +): StackDistribution { if (!record) return []; - const entries = Object.entries(record).filter(([name, value]) => name !== "" && value > 0); + const excluded = new Set(options.excludeKeys ?? []); + let entries = Object.entries(record).filter( + ([name, value]) => name !== "" && value > 0 && !excluded.has(name), + ); + if (options.excludeNone) entries = entries.filter(([name]) => name !== "none"); + const total = entries.reduce((sum, [, value]) => sum + value, 0); if (total === 0) return []; return entries - .map(([name, value]) => ({ name, value, pct: value / total })) + .map(([name, value]) => ({ name: options.labels?.[name] ?? name, value, pct: value / total })) .sort((a, b) => b.value - a.value) .slice(0, limit); } @@ -49,10 +78,11 @@ function toRankedDistribution(record: Dist | undefined, limit: number): StackDis export function buildStackAnalytics(stats: RawAnalyticsStats): StackAnalyticsData { return { totalProjects: stats.totalProjects, - topStacks: toRankedDistribution(stats.stackCombinations, 8), - frontend: toRankedDistribution(stats.frontend, 6), - backend: toRankedDistribution(stats.backend, 6), - database: toRankedDistribution(stats.database, 6), - orm: toRankedDistribution(stats.orm, 6), + ecosystems: toRankedDistribution(stats.ecosystem, 8, { labels: ECOSYSTEM_LABELS }), + topStacks: toRankedDistribution(stats.stackCombinations, 8, { excludeKeys: ["none + none"] }), + frontend: toRankedDistribution(stats.frontend, 6, { excludeNone: true }), + backend: toRankedDistribution(stats.backend, 6, { excludeNone: true }), + database: toRankedDistribution(stats.database, 6, { excludeNone: true }), + orm: toRankedDistribution(stats.orm, 6, { excludeNone: true }), }; } diff --git a/packages/backend/convex/analytics.ts b/packages/backend/convex/analytics.ts index 94e205487..210e8b668 100644 --- a/packages/backend/convex/analytics.ts +++ b/packages/backend/convex/analytics.ts @@ -37,6 +37,24 @@ function getMajorVersion(version: string | undefined): string | undefined { return `v${clean.split(".")[0]}`; } +function mergeOptionStats( + current: Record> | undefined, + options: Record | undefined, +): Record> { + const result: Record> = { ...current }; + if (!options) return result; + for (const [category, value] of Object.entries(options)) { + const values = Array.isArray(value) ? value : [value]; + const dist = { ...result[category] }; + for (const item of values) { + if (!item) continue; + dist[item] = (dist[item] ?? 0) + 1; + } + result[category] = dist; + } + return result; +} + export const ingestEvent = internalMutation({ args: { // Core @@ -95,6 +113,7 @@ export const ingestEvent = internalMutation({ cli_version: v.optional(v.string()), node_version: v.optional(v.string()), platform: v.optional(v.string()), + options: v.optional(v.record(v.string(), v.union(v.string(), v.array(v.string())))), }, returns: v.null(), handler: async (ctx, args) => { @@ -176,6 +195,7 @@ export const ingestEvent = internalMutation({ hourlyDistribution: incrementKey(existingStats.hourlyDistribution || {}, hourKey), stackCombinations: incrementKey(existingStats.stackCombinations || {}, stackKey), dbOrmCombinations: incrementKey(existingStats.dbOrmCombinations || {}, dbOrmKey), + optionStats: mergeOptionStats(existingStats.optionStats, args.options), }); } else { const emptyDist: Record = {}; @@ -242,6 +262,7 @@ export const ingestEvent = internalMutation({ hourlyDistribution: incrementKey(emptyDist, hourKey), stackCombinations: incrementKey(emptyDist, stackKey), dbOrmCombinations: incrementKey(emptyDist, dbOrmKey), + optionStats: mergeOptionStats(undefined, args.options), }); } @@ -329,6 +350,7 @@ export const getStats = query({ hourlyDistribution: distributionValidator, stackCombinations: distributionValidator, dbOrmCombinations: distributionValidator, + optionStats: v.record(v.string(), distributionValidator), }), v.null(), ), @@ -396,6 +418,7 @@ export const getStats = query({ hourlyDistribution: stats.hourlyDistribution || {}, stackCombinations: stats.stackCombinations || {}, dbOrmCombinations: stats.dbOrmCombinations || {}, + optionStats: stats.optionStats ?? {}, }; }, }); @@ -523,6 +546,7 @@ export const backfillStats = mutation({ hourlyDistribution: { ...emptyDist }, stackCombinations: { ...emptyDist }, dbOrmCombinations: { ...emptyDist }, + optionStats: {} as Record>, }; const dailyCounts = new Map(); @@ -601,6 +625,7 @@ export const backfillStats = mutation({ stats.hourlyDistribution = incrementKey(stats.hourlyDistribution, hourKey); stats.stackCombinations = incrementKey(stats.stackCombinations, stackKey); stats.dbOrmCombinations = incrementKey(stats.dbOrmCombinations, dbOrmKey); + stats.optionStats = mergeOptionStats(stats.optionStats, ev.options); const date = new Date(ev._creationTime).toISOString().slice(0, 10); dailyCounts.set(date, (dailyCounts.get(date) || 0) + 1); diff --git a/packages/backend/convex/http.ts b/packages/backend/convex/http.ts index f521d07ec..d1d1b8aa5 100644 --- a/packages/backend/convex/http.ts +++ b/packages/backend/convex/http.ts @@ -73,6 +73,112 @@ http.route({ return new Response("Bad Request", { status: 400 }); } + const EXTRA_OPTIONS_BY_ECOSYSTEM: Record = { + typescript: ["vectorDb", "search", "i18n", "featureFlags", "rateLimit", "fileStorage", "analytics"], + "react-native": [ + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + ], + rust: [ + "rustLogging", + "rustErrorHandling", + "rustCaching", + "rustAuth", + "rustRealtime", + "rustMessageQueue", + "rustObservability", + "rustTemplating", + ], + python: [ + "pythonWebFramework", + "pythonOrm", + "pythonValidation", + "pythonAi", + "pythonAuth", + "pythonApi", + "pythonTaskQueue", + "pythonGraphql", + "pythonQuality", + "pythonTesting", + "pythonCaching", + "pythonRealtime", + "pythonObservability", + "pythonCli", + ], + go: [ + "goWebFramework", + "goOrm", + "goApi", + "goCli", + "goLogging", + "goAuth", + "goTesting", + "goRealtime", + "goMessageQueue", + "goCaching", + "goConfig", + "goObservability", + ], + java: [ + "javaWebFramework", + "javaBuildTool", + "javaOrm", + "javaAuth", + "javaApi", + "javaLogging", + "javaLibraries", + "javaTestingLibraries", + ], + dotnet: [ + "dotnetWebFramework", + "dotnetOrm", + "dotnetAuth", + "dotnetApi", + "dotnetTesting", + "dotnetJobQueue", + "dotnetRealtime", + "dotnetObservability", + "dotnetValidation", + "dotnetCaching", + "dotnetDeploy", + ], + elixir: [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + "elixirLibraries", + ], + }; + const raw = body as Record; + const relevantKeys = + EXTRA_OPTIONS_BY_ECOSYSTEM[typeof body.ecosystem === "string" ? body.ecosystem : ""] ?? []; + const options: Record = {}; + for (const key of relevantKeys) { + const value = raw[key]; + if (typeof value === "string") { + if (value) options[key] = value; + } else if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + if (value.length > 0) options[key] = value as string[]; + } + } + const ingest = internal.analytics?.ingestEvent; if (ingest) { try { @@ -133,6 +239,7 @@ http.route({ cli_version: body.cli_version, node_version: body.node_version, platform: body.platform, + options, }); } catch (error) { console.error("Failed to ingest analytics:", error); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 89a07152a..65f866908 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -79,6 +79,7 @@ export default defineSchema({ cli_version: v.optional(v.string()), node_version: v.optional(v.string()), platform: v.optional(v.string()), + options: v.optional(v.record(v.string(), v.union(v.string(), v.array(v.string())))), }), analyticsStats: defineTable({ @@ -144,6 +145,7 @@ export default defineSchema({ hourlyDistribution: v.optional(distributionValidator), stackCombinations: v.optional(distributionValidator), dbOrmCombinations: v.optional(distributionValidator), + optionStats: v.optional(v.record(v.string(), distributionValidator)), }), analyticsDailyStats: defineTable({ From 35d84bc395c1d7c9c16ef6f5c62ea89c02e90fcb Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 22 Jun 2026 21:38:06 +0300 Subject: [PATCH 30/36] test(cli): refresh template snapshots after rebase --- .../template-snapshots.test.ts.snap | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 87e1901ad..7be1fd931 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -388,6 +388,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: hono-openap exports[`Template Snapshots File Structure Snapshots file structure: hono-apollo-server 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -845,6 +846,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: algolia-sea exports[`Template Snapshots File Structure Snapshots file structure: opensearch-search-hono 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -902,6 +904,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: opensearch- exports[`Template Snapshots File Structure Snapshots file structure: cms-keystatic-next 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/web/.env", @@ -961,6 +964,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: cms-keystat exports[`Template Snapshots File Structure Snapshots file structure: i18n-paraglide-tanstack-router 1`] = ` [ + "AGENTS.md", "CLAUDE.md", "README.md", "apps/server/.env", @@ -6697,8 +6701,12 @@ export const accountRelations = relations(account, ({ one }) => ({ exports[`Template Snapshots Key File Content Snapshots key files: hono-apollo-server 1`] = ` { - "fileCount": 64, + "fileCount": 65, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -7380,7 +7388,9 @@ export async function executeApolloRequest( } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-hono-apollo-server/config": "workspace:*" @@ -7532,6 +7542,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -12908,8 +12919,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: opensearch-search-hono 1`] = ` { - "fileCount": 57, + "fileCount": 58, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -13405,7 +13420,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-opensearch-search-hono/config": "workspace:*" @@ -13553,6 +13570,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -13687,8 +13705,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: cms-keystatic-next 1`] = ` { - "fileCount": 59, + "fileCount": 60, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/web/.env", @@ -13724,6 +13746,7 @@ export default nextConfig; "version": "0.1.0", "private": true, "scripts": { + "check-types": "tsc --noEmit", "dev": "next dev --port 3001", "build": "next build", "start": "next start" @@ -14015,7 +14038,9 @@ export const trpc = createTRPCOptionsProxy({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "^6.0.3", "@snapshot-cms-keystatic-next/config": "workspace:*" @@ -14163,6 +14188,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", @@ -14298,8 +14324,12 @@ export const db = drizzle({ client, schema }); exports[`Template Snapshots Key File Content Snapshots key files: i18n-paraglide-tanstack-router 1`] = ` { - "fileCount": 59, + "fileCount": 60, "files": [ + { + "content": "[exists]", + "path": "AGENTS.md", + }, { "content": "[exists]", "path": "apps/server/.env", @@ -14809,7 +14839,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-i18n-paraglide-tanstack-router/config": "workspace:*" @@ -14957,6 +14989,7 @@ export default defineConfig({ } }, "scripts": { + "check-types": "tsc --noEmit", "db:local": "turso dev --db-file local.db", "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", From 22648ec26452472a1d6bdf43948c7fe7c5466481 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 22 Jun 2026 21:48:29 +0300 Subject: [PATCH 31/36] test(cli): align CI guards after rebase --- apps/cli/test/__snapshots__/template-snapshots.test.ts.snap | 4 +++- apps/cli/test/api-literal-drift.test.ts | 2 +- .../templates/api/apollo-server/server/package.json.hbs | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 7be1fd931..76b23109d 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -7191,7 +7191,9 @@ export default defineConfig({ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": { "typescript": "catalog:", "@snapshot-hono-apollo-server/config": "workspace:*" diff --git a/apps/cli/test/api-literal-drift.test.ts b/apps/cli/test/api-literal-drift.test.ts index 0a0937d45..c50a32b2d 100644 --- a/apps/cli/test/api-literal-drift.test.ts +++ b/apps/cli/test/api-literal-drift.test.ts @@ -47,7 +47,7 @@ const DRIZZLE_MYSQL_TEMPLATE = resolve( ); // Bumping this requires an intentional template change (see file header). -const EXPECTED_STRIPE_API_VERSION = "2024-12-18"; +const EXPECTED_STRIPE_API_VERSION = "2026-05-27.dahlia"; // Drizzle's mysql2 driver expects a flat connection string plus `mode`. // The old, broken shape was `connection: { uri: env.DATABASE_URL }`. diff --git a/packages/template-generator/templates/api/apollo-server/server/package.json.hbs b/packages/template-generator/templates/api/apollo-server/server/package.json.hbs index 661607b0d..3962f426d 100644 --- a/packages/template-generator/templates/api/apollo-server/server/package.json.hbs +++ b/packages/template-generator/templates/api/apollo-server/server/package.json.hbs @@ -9,7 +9,9 @@ } }, "type": "module", - "scripts": {}, + "scripts": { + "check-types": "tsc --noEmit" + }, "devDependencies": {}, "dependencies": {} } From 21ac00aefc7fd3b9f3bfff64c6d1ef610a2f1c2a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 22 Jun 2026 22:06:54 +0300 Subject: [PATCH 32/36] fix(generator): repair smoke failures after rebase --- .../src/processors/cms-deps.ts | 1 + .../template-generator/src/utils/add-deps.ts | 1 + .../api/orpc/server/src/context.ts.hbs | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/packages/template-generator/src/processors/cms-deps.ts b/packages/template-generator/src/processors/cms-deps.ts index 8206e412d..4eb83fb45 100644 --- a/packages/template-generator/src/processors/cms-deps.ts +++ b/packages/template-generator/src/processors/cms-deps.ts @@ -90,6 +90,7 @@ export function processCMSDeps(vfs: VirtualFileSystem, config: ProjectConfig): v vfs, packagePath: webPath, dependencies: ["@strapi/client", "qs"], + devDependencies: ["@types/qs"], }); } diff --git a/packages/template-generator/src/utils/add-deps.ts b/packages/template-generator/src/utils/add-deps.ts index 618d133ff..81544b40f 100644 --- a/packages/template-generator/src/utils/add-deps.ts +++ b/packages/template-generator/src/utils/add-deps.ts @@ -715,6 +715,7 @@ export const dependencyVersionMap = { // Headless CMS - Strapi "@strapi/client": "^1.6.2", qs: "^6.15.2", + "@types/qs": "^6.14.0", // Headless CMS - Directus "@directus/sdk": "^22.0.0", diff --git a/packages/template-generator/templates/api/orpc/server/src/context.ts.hbs b/packages/template-generator/templates/api/orpc/server/src/context.ts.hbs index 94243b617..b024b6b13 100644 --- a/packages/template-generator/templates/api/orpc/server/src/context.ts.hbs +++ b/packages/template-generator/templates/api/orpc/server/src/context.ts.hbs @@ -238,6 +238,47 @@ export async function createContext(_opts?: CreateContextOptions) { } {{/if}} +{{else if (eq backend 'adonisjs')}} +import type { IncomingHttpHeaders } from "node:http"; +{{#if (isBetterAuth auth)}} +import { auth } from "@{{projectName}}/auth"; +{{/if}} + +type CreateContextOptions = { + req: { + headers: IncomingHttpHeaders; + }; +}; + +{{#if (isBetterAuth auth)}} +function nodeHeadersToHeaders(headers: IncomingHttpHeaders) { + const webHeaders = new Headers(); + for (const [key, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const item of value) webHeaders.append(key, item); + } else if (typeof value === "string") { + webHeaders.set(key, value); + } + } + return webHeaders; +} + +export async function createContext({ req }: CreateContextOptions) { + const session = await auth.api.getSession({ + headers: nodeHeadersToHeaders(req.headers), + }); + return { + session, + }; +} +{{else}} +export async function createContext(_opts: CreateContextOptions) { + return { + session: null, + }; +} +{{/if}} + {{else}} export async function createContext() { return { From 437bc417c5d0224741a9d24f3512768b6f62253d Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 22 Jun 2026 22:27:13 +0300 Subject: [PATCH 33/36] fix(generator): align smoke templates with dependency updates --- .../__snapshots__/template-snapshots.test.ts.snap | 2 ++ .../templates/db/mikroorm/mysql/src/index.ts.hbs | 6 +++--- .../db/mikroorm/postgres/src/index.ts.hbs | 6 +++--- .../templates/db/mikroorm/sqlite/src/index.ts.hbs | 4 ++-- .../frontend/native/bare/package.json.hbs | 1 + .../base/__tests__/mobile-ui-provider.test.tsx.hbs | 14 +++++++++----- .../frontend/native/unistyles/package.json.hbs | 1 + .../frontend/native/uniwind/package.json.hbs | 1 + 8 files changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 76b23109d..adaa09133 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -17392,6 +17392,7 @@ fontWeight: "bold", "expo-web-browser": "^56.0.5", "react": "^19.2.7", "react-dom": "^19.2.7", + "@babel/runtime": "^7.29.7", "react-native": "^0.86.0", "react-native-gesture-handler": "^3.0.1", "react-native-reanimated": "^4.4.1", @@ -18107,6 +18108,7 @@ registerRootComponent(App); "expo-web-browser": "^56.0.5", "react": "^19.2.7", "react-dom": "^19.2.7", + "@babel/runtime": "^7.29.7", "react-native": "^0.86.0", "react-native-mmkv": "^4.3.1", "react-native-gesture-handler": "^3.0.1", diff --git a/packages/template-generator/templates/db/mikroorm/mysql/src/index.ts.hbs b/packages/template-generator/templates/db/mikroorm/mysql/src/index.ts.hbs index 806d43755..b04b0e85e 100644 --- a/packages/template-generator/templates/db/mikroorm/mysql/src/index.ts.hbs +++ b/packages/template-generator/templates/db/mikroorm/mysql/src/index.ts.hbs @@ -2,7 +2,7 @@ import { MikroORM, type Options } from "@mikro-orm/core"; import { MySqlDriver } from "@mikro-orm/mysql"; import { env } from "@{{projectName}}/env/server"; -const config: Options = { +const config = { driver: MySqlDriver, clientUrl: env.DATABASE_URL, entities: ["./src/entities"], @@ -16,8 +16,8 @@ const config: Options = { }, }, }, -{{/if}} -}; + {{/if}} +} as Options; let orm: MikroORM | null = null; diff --git a/packages/template-generator/templates/db/mikroorm/postgres/src/index.ts.hbs b/packages/template-generator/templates/db/mikroorm/postgres/src/index.ts.hbs index 65151a4f2..cb3089418 100644 --- a/packages/template-generator/templates/db/mikroorm/postgres/src/index.ts.hbs +++ b/packages/template-generator/templates/db/mikroorm/postgres/src/index.ts.hbs @@ -2,7 +2,7 @@ import { MikroORM, type Options } from "@mikro-orm/core"; import { PostgreSqlDriver } from "@mikro-orm/postgresql"; import { env } from "@{{projectName}}/env/server"; -const config: Options = { +const config = { driver: PostgreSqlDriver, clientUrl: env.DATABASE_URL, entities: ["./src/entities"], @@ -16,8 +16,8 @@ const config: Options = { }, }, }, -{{/if}} -}; + {{/if}} +} as Options; let orm: MikroORM | null = null; diff --git a/packages/template-generator/templates/db/mikroorm/sqlite/src/index.ts.hbs b/packages/template-generator/templates/db/mikroorm/sqlite/src/index.ts.hbs index 05b2e5695..48a8f89d0 100644 --- a/packages/template-generator/templates/db/mikroorm/sqlite/src/index.ts.hbs +++ b/packages/template-generator/templates/db/mikroorm/sqlite/src/index.ts.hbs @@ -2,13 +2,13 @@ import { MikroORM, type Options } from "@mikro-orm/core"; import { BetterSqliteDriver } from "@mikro-orm/better-sqlite"; import { env } from "@{{projectName}}/env/server"; -const config: Options = { +const config = { driver: BetterSqliteDriver, dbName: env.DATABASE_URL.replace("file:", ""), entities: ["./src/entities"], entitiesTs: ["./src/entities"], debug: process.env.NODE_ENV !== "production", -}; +} as Options; let orm: MikroORM | null = null; diff --git a/packages/template-generator/templates/frontend/native/bare/package.json.hbs b/packages/template-generator/templates/frontend/native/bare/package.json.hbs index d87ff96e1..b7fe7f879 100644 --- a/packages/template-generator/templates/frontend/native/bare/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/bare/package.json.hbs @@ -59,6 +59,7 @@ "expo-web-browser": "^56.0.5", "react": "^19.2.7", "react-dom": "^19.2.7", + "@babel/runtime": "^7.29.7", "react-native": "^0.86.0", {{#if (eq mobileStorage "mmkv")}} "react-native-mmkv": "^4.3.1", diff --git a/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs b/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs index 91e2489e2..a834f2ce3 100644 --- a/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs @@ -6,9 +6,13 @@ import { render } from "@testing-library/react-native"; import { Text } from "react-native"; {{#if (eq mobileUI "gluestack-ui")}} -jest.mock("@gluestack-ui/themed", () => ({ - GluestackUIProvider: ({ children }: { children: ReactNode }) => children, -})); +jest.mock( + "@gluestack-ui/themed", + () => ({ + GluestackUIProvider: ({ children }: { children: ReactNode }) => children, + }), + { virtual: true }, +); {{/if}} {{#if (eq mobileUI "tamagui")}} @@ -21,8 +25,8 @@ jest.mock("tamagui", () => ({ {{/if}} import { MobileUIProvider } from "@/components/mobile-ui-provider"; -test("renders children inside the mobile provider", () => { - const screen = render( +test("renders children inside the mobile provider", async () => { + const screen = await render( Mobile ready , diff --git a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs index a90df3f1f..b3ea3927c 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs @@ -58,6 +58,7 @@ "expo-web-browser": "^56.0.5", "react": "^19.2.7", "react-dom": "^19.2.7", + "@babel/runtime": "^7.29.7", "react-native": "^0.86.0", "react-native-edge-to-edge": "^1.8.1", {{#if (eq mobileStorage "mmkv")}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs index 6641bbfb1..65523bed2 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs @@ -55,6 +55,7 @@ "heroui-native": "^1.0.4", "react": "^19.2.7", "react-dom": "^19.2.7", + "@babel/runtime": "^7.29.7", "react-native": "^0.86.0", {{#if (eq mobileStorage "mmkv")}} "react-native-mmkv": "^4.3.1", From b4849bbe76004a401c12d76b1dfaefa122eaf47a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 22 Jun 2026 22:46:20 +0300 Subject: [PATCH 34/36] fix(generator): cover additional smoke edge cases --- apps/cli/test/__snapshots__/template-snapshots.test.ts.snap | 4 ++-- .../templates/addons/msw/apps/server/src/mocks/server.ts.hbs | 2 +- .../template-generator/templates/db/base/package.json.hbs | 2 +- .../templates/go-base/internal/cache/redis.go.hbs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index adaa09133..3374fa245 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -4606,7 +4606,7 @@ export type AppRouter = typeof appRouter; } }, "scripts": { - "check-types": "tsc --noEmit", + "check-types": "tsc --noEmit --declaration false --declarationMap false --composite false", "postinstall": "prisma generate", "db:push": "prisma db push", "db:generate": "prisma generate", @@ -12004,7 +12004,7 @@ export type AppRouter = typeof appRouter; } }, "scripts": { - "check-types": "tsc --noEmit", + "check-types": "tsc --noEmit --declaration false --declarationMap false --composite false", "postinstall": "prisma generate", "db:push": "prisma db push", "db:generate": "prisma generate", diff --git a/packages/template-generator/templates/addons/msw/apps/server/src/mocks/server.ts.hbs b/packages/template-generator/templates/addons/msw/apps/server/src/mocks/server.ts.hbs index eddc1d5ec..9e5d7d23a 100644 --- a/packages/template-generator/templates/addons/msw/apps/server/src/mocks/server.ts.hbs +++ b/packages/template-generator/templates/addons/msw/apps/server/src/mocks/server.ts.hbs @@ -7,6 +7,6 @@ * @see https://mswjs.io/docs/integrations/node */ import { setupServer } from "msw/node"; -import { handlers } from "./handlers"; +import { handlers } from "./handlers.js"; export const server = setupServer(...handlers); diff --git a/packages/template-generator/templates/db/base/package.json.hbs b/packages/template-generator/templates/db/base/package.json.hbs index 103de59be..d53d041db 100644 --- a/packages/template-generator/templates/db/base/package.json.hbs +++ b/packages/template-generator/templates/db/base/package.json.hbs @@ -10,7 +10,7 @@ } }, "scripts": { - "check-types": "tsc --noEmit" + "check-types": "{{#if (eq orm "prisma")}}tsc --noEmit --declaration false --declarationMap false --composite false{{else}}tsc --noEmit{{/if}}" }, "devDependencies": {} } diff --git a/packages/template-generator/templates/go-base/internal/cache/redis.go.hbs b/packages/template-generator/templates/go-base/internal/cache/redis.go.hbs index cd15fdf6f..9de8ea7db 100644 --- a/packages/template-generator/templates/go-base/internal/cache/redis.go.hbs +++ b/packages/template-generator/templates/go-base/internal/cache/redis.go.hbs @@ -1,4 +1,4 @@ -{{#if (eq caching "upstash-redis")}} +{{#if (and (eq caching "upstash-redis") (not (eq goCaching "redis")))}} package cache import ( From ef76c7bc84f24c4b01b565ac1434131a58af24ed Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 22 Jun 2026 23:01:51 +0300 Subject: [PATCH 35/36] fix(generator): configure elixir swoosh finch client --- .../templates/elixir-base/config/config.exs.hbs | 2 ++ packages/template-generator/templates/elixir-base/mix.exs.hbs | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/template-generator/templates/elixir-base/config/config.exs.hbs b/packages/template-generator/templates/elixir-base/config/config.exs.hbs index 5ada23d12..22f189b9f 100644 --- a/packages/template-generator/templates/elixir-base/config/config.exs.hbs +++ b/packages/template-generator/templates/elixir-base/config/config.exs.hbs @@ -47,6 +47,8 @@ config :{{elixirAppName}}, {{elixirModuleName}}.Scheduler, {{#if (eq elixirEmail "swoosh")}} config :{{elixirAppName}}, {{elixirModuleName}}.Mailer, adapter: Swoosh.Adapters.Local + +config :swoosh, api_client: Swoosh.ApiClient.Finch {{/if}} import_config "#{config_env()}.exs" diff --git a/packages/template-generator/templates/elixir-base/mix.exs.hbs b/packages/template-generator/templates/elixir-base/mix.exs.hbs index aafb41715..a8ab32f3b 100644 --- a/packages/template-generator/templates/elixir-base/mix.exs.hbs +++ b/packages/template-generator/templates/elixir-base/mix.exs.hbs @@ -74,7 +74,7 @@ defmodule {{elixirModuleName}}.MixProject do {{#if (eq elixirHttp "req")}} {:req, "~> 0.5"}, {{/if}} -{{#if (eq elixirHttp "finch")}} +{{#if (or (eq elixirHttp "finch") (eq elixirEmail "swoosh"))}} {:finch, "~> 0.19"}, {{/if}} {{#if (or (ne elixirWebFramework "none") (eq elixirJson "jason"))}} @@ -82,7 +82,6 @@ defmodule {{elixirModuleName}}.MixProject do {{/if}} {{#if (eq elixirEmail "swoosh")}} {:swoosh, "~> 1.17"}, - {:finch, "~> 0.19"}, {{/if}} {{#if (eq elixirCaching "cachex")}} {:cachex, "~> 4.0"}, From d79cb22ecbf84d039fcb917118d648d81ee3ad64 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 22 Jun 2026 23:32:06 +0300 Subject: [PATCH 36/36] test(smoke): probe expected dev url during startup --- testing/lib/dev-check.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/testing/lib/dev-check.ts b/testing/lib/dev-check.ts index a89b29a24..eed223081 100644 --- a/testing/lib/dev-check.ts +++ b/testing/lib/dev-check.ts @@ -6,7 +6,9 @@ import type { StepResult } from "./verify"; const DEV_STARTUP_TIMEOUT_MS = 120_000; const TOTAL_DEV_CHECK_TIMEOUT_MS = 150_000; const HTTP_REQUEST_TIMEOUT_MS = 10_000; +const STARTUP_PROBE_TIMEOUT_MS = 5_000; const POLL_INTERVAL_MS = 2_000; +const STARTUP_PROBE_INTERVAL_MS = 10_000; const MAX_FETCH_RETRIES = 3; const KILL_GRACE_MS = 3_000; @@ -264,6 +266,7 @@ export async function startDevServer( let serverUrl: string | null = null; const urlDeadline = Date.now() + DEV_STARTUP_TIMEOUT_MS; + let nextStartupProbeAt = Date.now() + STARTUP_PROBE_INTERVAL_MS; while (Date.now() < urlDeadline && !serverUrl) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); @@ -278,6 +281,18 @@ export async function startDevServer( const port = getExpectedPort(config); serverUrl = extractUrlFromOutput(_stdoutBuf, port) || extractUrlFromOutput(_stderrBuf, port); + + if (!serverUrl && Date.now() >= nextStartupProbeAt) { + nextStartupProbeAt = Date.now() + STARTUP_PROBE_INTERVAL_MS; + const fallbackUrl = getExpectedDevUrl(config); + try { + const resp = await fetch(fallbackUrl, { + signal: AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS), + }); + await resp.text(); + serverUrl = fallbackUrl; + } catch {} + } } if (!serverUrl) {