From 0ffb00762a9d7ad881e95d1e03b0c46f33192043 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 17:01:42 +0300 Subject: [PATCH 01/14] docs(reference): cross-link compatibility + verified-combinations from option pages Adds a consistent 'Valid values vs. valid combinations' callout to all 7 ecosystem option-reference pages (typescript/go/java/python/rust/elixir/ react-native) pointing to the Compatibility Matrix and Verified Combinations, since the option tables list valid values but not valid combinations. Editing existing pages only (no new i18n bundles). docs-content-contract 8/0. Advances task #9 (the compatibility cross-link item). --- apps/web/content/docs/reference/options/elixir.mdx | 4 ++++ apps/web/content/docs/reference/options/go.mdx | 4 ++++ apps/web/content/docs/reference/options/java.mdx | 4 ++++ apps/web/content/docs/reference/options/python.mdx | 4 ++++ apps/web/content/docs/reference/options/react-native.mdx | 4 ++++ apps/web/content/docs/reference/options/rust.mdx | 4 ++++ apps/web/content/docs/reference/options/typescript.mdx | 4 ++++ 7 files changed, 28 insertions(+) diff --git a/apps/web/content/docs/reference/options/elixir.mdx b/apps/web/content/docs/reference/options/elixir.mdx index abc5cca3b..14e4ec8b7 100644 --- a/apps/web/content/docs/reference/options/elixir.mdx +++ b/apps/web/content/docs/reference/options/elixir.mdx @@ -9,6 +9,10 @@ Use these values with `--ecosystem elixir` in CLI commands, MCP payloads, and St Elixir templates keep Phoenix, Ecto, auth, API, realtime, jobs, services, testing, and workflow flags separate from TypeScript web options. + +This page lists valid Elixir option values, not which combinations work together. Check the [Compatibility Matrix](/docs/reference/compatibility/) to validate a stack, and [Verified Combinations](/docs/reference/verified-combinations/) for scaffolds with current passing evidence. + + ## Option table The table below is generated from the canonical option metadata so it stays in sync with the CLI and Stack Builder. diff --git a/apps/web/content/docs/reference/options/go.mdx b/apps/web/content/docs/reference/options/go.mdx index 2e016a751..0e76bd47d 100644 --- a/apps/web/content/docs/reference/options/go.mdx +++ b/apps/web/content/docs/reference/options/go.mdx @@ -9,6 +9,10 @@ Use these values with `--ecosystem go` in CLI commands, MCP payloads, and Stack Go templates keep API, CLI, logging, auth, libraries, services, and workflow flags separate from TypeScript web options. GoBetterAuth targets Go projects and is documented here. + +This page lists valid Go option values, not which combinations work together. Check the [Compatibility Matrix](/docs/reference/compatibility/) to validate a stack, and [Verified Combinations](/docs/reference/verified-combinations/) for scaffolds with current passing evidence. + + ## Option table The table below is generated from the canonical option metadata so it stays in sync with the CLI and Stack Builder. diff --git a/apps/web/content/docs/reference/options/java.mdx b/apps/web/content/docs/reference/options/java.mdx index a34da4959..6362309a1 100644 --- a/apps/web/content/docs/reference/options/java.mdx +++ b/apps/web/content/docs/reference/options/java.mdx @@ -9,6 +9,10 @@ Use these values with `--ecosystem java` in CLI commands, MCP payloads, and Stac Java templates keep framework, build tool, ORM, auth, libraries, testing, services, and workflow flags separate from TypeScript web options. + +This page lists valid Java option values, not which combinations work together. Check the [Compatibility Matrix](/docs/reference/compatibility/) to validate a stack, and [Verified Combinations](/docs/reference/verified-combinations/) for scaffolds with current passing evidence. + + ## Option table The table below is generated from the canonical option metadata so it stays in sync with the CLI and Stack Builder. diff --git a/apps/web/content/docs/reference/options/python.mdx b/apps/web/content/docs/reference/options/python.mdx index a42f48047..19f4cad51 100644 --- a/apps/web/content/docs/reference/options/python.mdx +++ b/apps/web/content/docs/reference/options/python.mdx @@ -9,6 +9,10 @@ Use these values with `--ecosystem python` in CLI commands, MCP payloads, and St Python templates use `uv`. Python AI is a Python-specific multi-select and is separate from the TypeScript AI option. Empty conditional files are skipped when selected options do not need them. + +This page lists valid Python option values, not which combinations work together. Check the [Compatibility Matrix](/docs/reference/compatibility/) to validate a stack, and [Verified Combinations](/docs/reference/verified-combinations/) for scaffolds with current passing evidence. + + ## Option table The table below is generated from the canonical option metadata so it stays in sync with the CLI and Stack Builder. diff --git a/apps/web/content/docs/reference/options/react-native.mdx b/apps/web/content/docs/reference/options/react-native.mdx index a1a1469b1..559134c38 100644 --- a/apps/web/content/docs/reference/options/react-native.mdx +++ b/apps/web/content/docs/reference/options/react-native.mdx @@ -9,6 +9,10 @@ React Native options are selected with `--ecosystem react-native`. They generate `native-uniwind` selects `mobile-ui: uniwind`. `native-unistyles` selects `mobile-ui: unistyles`. Tamagui and gluestack-ui are intended for `native-bare` to avoid conflicting style systems. + +This page lists valid React Native option values, not which combinations work together. Check the [Compatibility Matrix](/docs/reference/compatibility/) to validate a stack, and [Verified Combinations](/docs/reference/verified-combinations/) for scaffolds with current passing evidence. + + ## Option table The table below is generated from the canonical option metadata so it stays in sync with the CLI and Stack Builder. diff --git a/apps/web/content/docs/reference/options/rust.mdx b/apps/web/content/docs/reference/options/rust.mdx index 45dfeca8b..1e9a15537 100644 --- a/apps/web/content/docs/reference/options/rust.mdx +++ b/apps/web/content/docs/reference/options/rust.mdx @@ -9,6 +9,10 @@ Use these values with `--ecosystem rust` in CLI commands, MCP payloads, and Stac Rust templates are Cargo-first. Framework, frontend, ORM, API, CLI, logging, error handling, libraries, services, and workflow flags are all validated against the selected stack. + +This page lists valid Rust option values, not which combinations work together. Check the [Compatibility Matrix](/docs/reference/compatibility/) to validate a stack, and [Verified Combinations](/docs/reference/verified-combinations/) for scaffolds with current passing evidence. + + ## Option table The table below is generated from the canonical option metadata so it stays in sync with the CLI and Stack Builder. diff --git a/apps/web/content/docs/reference/options/typescript.mdx b/apps/web/content/docs/reference/options/typescript.mdx index aad76f7b1..ec91655d4 100644 --- a/apps/web/content/docs/reference/options/typescript.mdx +++ b/apps/web/content/docs/reference/options/typescript.mdx @@ -9,6 +9,10 @@ TypeScript is the broadest Better Fullstack ecosystem. Use these CLI values in c The table lists valid values, not every valid combination. tRPC is React-oriented. `backend: self` is for fullstack frameworks and pairs with `runtime: none`. Workers runtime, UI libraries, providers, addons, and deploy targets all have stack-specific constraints. + +This page lists valid TypeScript option values, not which combinations work together. Check the [Compatibility Matrix](/docs/reference/compatibility/) to validate a stack, and [Verified Combinations](/docs/reference/verified-combinations/) for scaffolds with current passing evidence. + + React Native and Expo options live on the [React Native Options](/docs/reference/options/react-native/) page. ## Option table From 607c1c61460f5bb33f21ad993ab6db240f5a593f Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 17:30:39 +0300 Subject: [PATCH 02/14] feat(java): add gRPC-java API option (protoc codegen + Spring Boot server) Adds 'grpc' as a javaApi value: a Greeter proto + grpc-java service + a SmartLifecycle bean starting the gRPC server alongside Spring Boot. Gradle (com.google.protobuf plugin + protobuf{} codegen) and Maven (protobuf-maven-plugin + os-maven-plugin) both wire protoc + grpc-java stubs onto the compile classpath. Mirrors spring-graphql/openapi-generator wiring and the Go grpc-go reference; spring-boot-gated like the other javaApi values. Verified: ./gradlew compileJava = BUILD SUCCESSFUL and mvn compile = BUILD SUCCESS (both protoc codegen), java-ecosystem tests 24(web)+27(cli) pass, schema-template -coverage 3/3, template-snapshots 95/0. Advances task #6 (Java gRPC; Kotlin variant remains). --- apps/cli/src/prompts/java-ecosystem.ts | 5 ++ apps/cli/test/java-ecosystem.test.ts | 3 + apps/web/src/lib/constant.ts | 9 +++ apps/web/src/lib/tech-icons.ts | 1 + apps/web/test/java-ecosystem.test.ts | 7 ++ .../src/template-handlers/java-base.ts | 11 +++ .../templates/java-base/build.gradle.kts.hbs | 46 ++++++++++++ .../templates/java-base/pom.xml.hbs | 71 +++++++++++++++++++ .../grpc/GreeterService.java.hbs | 31 ++++++++ .../grpc/GrpcServerLifecycle.java.hbs | 71 +++++++++++++++++++ .../src/main/proto/greeter.proto.hbs | 24 +++++++ packages/types/src/option-metadata.ts | 1 + packages/types/src/schemas.ts | 2 +- 13 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GreeterService.java.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GrpcServerLifecycle.java.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/proto/greeter.proto.hbs diff --git a/apps/cli/src/prompts/java-ecosystem.ts b/apps/cli/src/prompts/java-ecosystem.ts index 211f65e55..0774d2eca 100644 --- a/apps/cli/src/prompts/java-ecosystem.ts +++ b/apps/cli/src/prompts/java-ecosystem.ts @@ -401,6 +401,11 @@ const JAVA_API_PROMPT_OPTIONS: PromptOption[] = [ label: "OpenAPI Generator", hint: "Generate Spring API interfaces + models from an OpenAPI spec at build time", }, + { + value: "grpc", + label: "gRPC", + hint: "grpc-java service (protoc codegen) started alongside Spring Boot", + }, { value: "none", label: "None", diff --git a/apps/cli/test/java-ecosystem.test.ts b/apps/cli/test/java-ecosystem.test.ts index c13d047c7..c9cf42eb4 100644 --- a/apps/cli/test/java-ecosystem.test.ts +++ b/apps/cli/test/java-ecosystem.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_STACK_SELECTION, EcosystemSchema, evaluateCompatibility, + JavaApiSchema, JavaAuthSchema, JavaBuildToolSchema, JavaLibrariesSchema, @@ -74,6 +75,7 @@ const JAVA_BUILD_TOOLS = extractEnumValues(JavaBuildToolSchema); const JAVA_LIBRARIES = extractEnumValues(JavaLibrariesSchema); const JAVA_ORMS = extractEnumValues(JavaOrmSchema); const JAVA_AUTHS = extractEnumValues(JavaAuthSchema); +const JAVA_APIS = extractEnumValues(JavaApiSchema); const JAVA_TESTING_LIBRARIES = extractEnumValues(JavaTestingLibrariesSchema); describe("Java Ecosystem", () => { @@ -116,6 +118,7 @@ describe("Java Ecosystem", () => { ]); expect(JAVA_ORMS).toEqual(["spring-data-jpa", "jooq", "mybatis", "none"]); expect(JAVA_AUTHS).toEqual(["spring-security", "keycloak", "none"]); + expect(JAVA_APIS).toEqual(["spring-graphql", "openapi-generator", "grpc", "none"]); expect(JAVA_TESTING_LIBRARIES).toEqual([ "junit5", "mockito", diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 73deea889..021e2539d 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -5032,6 +5032,15 @@ export const TECH_OPTIONS: Record< default: false, isNew: true, }, + { + id: "grpc", + name: "gRPC", + description: "grpc-java service (protoc codegen) started alongside Spring Boot", + icon: "/icon/grpc.svg", + color: "from-sky-500 to-blue-600", + default: false, + isNew: true, + }, { id: "none", name: "No API Layer", diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index f8f03ce2b..d4be3ce41 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -473,6 +473,7 @@ export const ICON_REGISTRY: Record = { chi: { type: "si", slug: "go", hex: "00ADD8" }, stdlib: { type: "si", slug: "go", hex: "00ADD8" }, "grpc-go": { type: "local", src: "/icon/grpc.svg" }, + grpc: { type: "local", src: "/icon/grpc.svg" }, "urfave-cli": { type: "si", slug: "go", hex: "00ADD8" }, logrus: { type: "si", slug: "go", hex: "00ADD8" }, diff --git a/apps/web/test/java-ecosystem.test.ts b/apps/web/test/java-ecosystem.test.ts index 531575f28..fb209403e 100644 --- a/apps/web/test/java-ecosystem.test.ts +++ b/apps/web/test/java-ecosystem.test.ts @@ -75,6 +75,7 @@ describe("Java Ecosystem Tab", () => { "javaBuildTool", "javaOrm", "javaAuth", + "javaApi", "javaLibraries", "javaTestingLibraries", ]; @@ -105,6 +106,12 @@ describe("Java Ecosystem Tab", () => { "none", ]); expect(TECH_OPTIONS.javaBuildTool.map((option) => option.id)).toContain("gradle"); + expect(TECH_OPTIONS.javaApi.map((option) => option.id)).toEqual([ + "spring-graphql", + "openapi-generator", + "grpc", + "none", + ]); expect(TECH_OPTIONS.javaLibraries.map((option) => option.id)).toEqual([ "spring-actuator", "spring-validation", diff --git a/packages/template-generator/src/template-handlers/java-base.ts b/packages/template-generator/src/template-handlers/java-base.ts index 6b4b9c30e..433711dbb 100644 --- a/packages/template-generator/src/template-handlers/java-base.ts +++ b/packages/template-generator/src/template-handlers/java-base.ts @@ -23,6 +23,7 @@ type JavaTemplateContext = ProjectConfig & { hasJavaKeycloak: boolean; hasJavaGraphql: boolean; isJavaOpenApiGenerator: boolean; + hasJavaGrpc: boolean; hasJavaLogback: boolean; hasJavaLog4j2: boolean; hasJavaAmqp: boolean; @@ -218,6 +219,10 @@ function createJavaTemplateContext(config: ProjectConfig): JavaTemplateContext { // `openapi-generator` is added to `JavaApiSchema` separately; cast to string // so this handler compiles ahead of (and after) that schema widening. isJavaOpenApiGenerator: isJavaSpringBoot && config.javaApi === "openapi-generator", + // grpc-java runs its own gRPC server (via protoc codegen) alongside the + // Spring Boot web server, so this API layer is gated to Spring Boot exactly + // like `spring-graphql` and `openapi-generator`. + hasJavaGrpc: isJavaSpringBoot && config.javaApi === "grpc", hasJavaLogback: isJavaSpringBoot && config.javaLogging === "logback", // log4j2 is an additive logging backend supported for Spring Boot, // Micronaut, and plain Java. Quarkus keeps its own default logging @@ -372,6 +377,12 @@ function shouldSkipJavaTemplate(templatePath: string, context: JavaTemplateConte ) { return true; } + // gRPC surface: the .proto contract (src/main/proto) is the protoc codegen + // input and the /grpc/ Java sources are the service impl + server lifecycle. + // Emit them only when the gRPC API layer is selected (Spring Boot only). + if (!context.hasJavaGrpc && (templatePath.includes("/proto/") || templatePath.includes("/grpc/"))) { + return true; + } // The OpenAPI spec is the codegen input; emit it only when the OpenAPI // Generator API layer is selected (Spring Boot only). Other frameworks skip // src/main/resources wholesale above, so this guard is what keeps it out of a diff --git a/packages/template-generator/templates/java-base/build.gradle.kts.hbs b/packages/template-generator/templates/java-base/build.gradle.kts.hbs index 4f02d2ff9..f5efdb63b 100644 --- a/packages/template-generator/templates/java-base/build.gradle.kts.hbs +++ b/packages/template-generator/templates/java-base/build.gradle.kts.hbs @@ -1,3 +1,7 @@ +{{#if hasJavaGrpc}} +import com.google.protobuf.gradle.id + +{{/if}} plugins { {{#if isJavaSpringBoot}} java @@ -6,6 +10,9 @@ plugins { {{#if isJavaOpenApiGenerator}} id("org.openapi.generator") version "7.23.0" {{/if}} +{{#if hasJavaGrpc}} + id("com.google.protobuf") version "0.9.4" +{{/if}} {{else if isJavaQuarkus}} java id("io.quarkus") version "3.35.2" @@ -91,6 +98,21 @@ dependencies { // API is enough to compile them (version managed by Spring Boot BOM). implementation("jakarta.validation:jakarta.validation-api") {{/if}} +{{#if hasJavaGrpc}} + // grpc-java runtime. netty-shaded bundles its own Netty so it never + // clashes with the Netty that Spring Boot pulls in. + implementation("io.grpc:grpc-netty-shaded:1.68.1") + implementation("io.grpc:grpc-protobuf:1.68.1") + implementation("io.grpc:grpc-stub:1.68.1") + // Pin protobuf-java to the protoc version so the gencode/runtime versions + // match (the Spring Boot BOM would otherwise force a different one that the + // generated stubs fail their runtime compatibility check against). + implementation("com.google.protobuf:protobuf-java:4.28.2") + // The grpc-java generated stubs are annotated with + // javax.annotation.Generated (source-retention), which is not on the JDK + // since Java 9 — supply it at compile time only. + compileOnly("org.apache.tomcat:annotations-api:6.0.53") +{{/if}} {{#if hasJavaAmqp}} implementation("org.springframework.boot:spring-boot-starter-amqp") {{/if}} @@ -350,6 +372,30 @@ tasks.named("compileJava") { dependsOn("openApiGenerate") } +{{/if}} +{{#if hasJavaGrpc}} +// Run protoc (downloaded by the plugin) + the grpc-java plugin over +// src/main/proto before compiling. The protobuf plugin adds the generated +// message + stub sources to the main source set and makes compileJava depend +// on the generateProto task, so the stubs land on the compile classpath. +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.28.2" + } + plugins { + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:1.68.1" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + id("grpc") + } + } + } +} + {{/if}} tasks.withType { useJUnitPlatform() diff --git a/packages/template-generator/templates/java-base/pom.xml.hbs b/packages/template-generator/templates/java-base/pom.xml.hbs index e443c9b0f..6d046cf3f 100644 --- a/packages/template-generator/templates/java-base/pom.xml.hbs +++ b/packages/template-generator/templates/java-base/pom.xml.hbs @@ -34,6 +34,12 @@ 7.23.0 2.2.52 {{/if}} +{{#if hasJavaGrpc}} + 1.68.1 + 4.28.2 + 0.6.1 + 1.7.1 +{{/if}} {{#if hasJavaSpringdocOpenapi}} 3.0.3 {{/if}} @@ -369,6 +375,36 @@ jakarta.validation-api {{/if}} +{{#if hasJavaGrpc}} + + io.grpc + grpc-netty-shaded + ${grpc.version} + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + + org.apache.tomcat + annotations-api + 6.0.53 + provided + +{{/if}} {{#if hasJavaAmqp}} org.springframework.boot @@ -1018,12 +1054,47 @@ +{{#if hasJavaGrpc}} + + + + kr.motd.maven + os-maven-plugin + ${os-plugin.version} + + +{{/if}} {{#if isJavaSpringBoot}} org.springframework.boot spring-boot-maven-plugin +{{#if hasJavaGrpc}} + + + org.xolstice.maven.plugins + protobuf-maven-plugin + ${protobuf-plugin.version} + + com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier} + grpc-java + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + + + compile + compile-custom + + + + +{{/if}} {{#if isJavaOpenApiGenerator}} org.openapitools diff --git a/packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GreeterService.java.hbs b/packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GreeterService.java.hbs new file mode 100644 index 000000000..db38c31e9 --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GreeterService.java.hbs @@ -0,0 +1,31 @@ +{{#if hasJavaGrpc}} +package {{javaPackageName}}.grpc; + +import io.grpc.stub.StreamObserver; +import org.springframework.stereotype.Component; + +import {{javaPackageName}}.grpc.proto.GreeterGrpc; +import {{javaPackageName}}.grpc.proto.HelloReply; +import {{javaPackageName}}.grpc.proto.HelloRequest; + +/** + * gRPC service implementation for the {@code Greeter} contract defined in + * {@code src/main/proto/greeter.proto}. The {@code GreeterGrpc.*} base class and + * the {@code HelloRequest}/{@code HelloReply} messages are generated by protoc + * (+ the grpc-java plugin) into {@code {{javaPackageName}}.grpc.proto} at build + * time. Registered with the server by {@link GrpcServerLifecycle}. + */ +@Component +public class GreeterService extends GreeterGrpc.GreeterImplBase { + + @Override + public void sayHello(HelloRequest request, StreamObserver responseObserver) { + String name = request.getName().isBlank() ? "world" : request.getName(); + HelloReply reply = HelloReply.newBuilder() + .setMessage("Hello, %s!".formatted(name)) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } +} +{{/if}} diff --git a/packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GrpcServerLifecycle.java.hbs b/packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GrpcServerLifecycle.java.hbs new file mode 100644 index 000000000..99271551b --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/java/__javaPackagePath__/grpc/GrpcServerLifecycle.java.hbs @@ -0,0 +1,71 @@ +{{#if hasJavaGrpc}} +package {{javaPackageName}}.grpc; + +import java.io.IOException; + +import io.grpc.Server; +import io.grpc.ServerBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.SmartLifecycle; +import org.springframework.stereotype.Component; + +/** + * Starts an in-process gRPC {@link Server} alongside the Spring Boot web server. + * + *

Implemented as a {@link SmartLifecycle} bean so the gRPC server boots when + * the application context starts and shuts down gracefully when it stops. The + * embedded web server keeps the JVM alive; this component only owns the gRPC + * listener on {@code grpc.server.port} (default {@code 9090}). + */ +@Component +public class GrpcServerLifecycle implements SmartLifecycle { + + private static final Logger log = LoggerFactory.getLogger(GrpcServerLifecycle.class); + + private final GreeterService greeterService; + private final int port; + private Server server; + private volatile boolean running = false; + + public GrpcServerLifecycle( + GreeterService greeterService, + @Value("${grpc.server.port:9090}") int port + ) { + this.greeterService = greeterService; + this.port = port; + } + + @Override + public void start() { + if (running) { + return; + } + try { + server = ServerBuilder.forPort(port) + .addService(greeterService) + .build() + .start(); + running = true; + log.info("gRPC server started, listening on port {}", port); + } catch (IOException ex) { + throw new IllegalStateException("Failed to start gRPC server on port " + port, ex); + } + } + + @Override + public void stop() { + if (server != null) { + server.shutdown(); + log.info("gRPC server stopped"); + } + running = false; + } + + @Override + public boolean isRunning() { + return running; + } +} +{{/if}} diff --git a/packages/template-generator/templates/java-base/src/main/proto/greeter.proto.hbs b/packages/template-generator/templates/java-base/src/main/proto/greeter.proto.hbs new file mode 100644 index 000000000..811a7206e --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/proto/greeter.proto.hbs @@ -0,0 +1,24 @@ +{{#if hasJavaGrpc}}syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "{{javaPackageName}}.grpc.proto"; +option java_outer_classname = "GreeterProto"; + +package greeter; + +// The greeting service definition. +service Greeter { + // Sends a greeting. + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greeting. +message HelloReply { + string message = 1; +} +{{/if}} diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index d0ad2d91f..75a1b077a 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -1400,6 +1400,7 @@ const EXACT_LABEL_OVERRIDES: Partial Date: Sun, 5 Jul 2026 17:49:09 +0300 Subject: [PATCH 03/14] feat(stack-graph): promote astroIntegration to a graph-owned setting (Phase 4) First Phase-4 field promotion: astroIntegration now rides on the Astro frontend part's settings and round-trips legacy<->graph (legacyProjectConfigToStackParts attaches it; stackPartsToLegacyProjectConfigPartial restores it) with a flat fallback so the generator projection and CLI stack-update persistence stay correct. Establishes the template for promoting the remaining flat-only categories to owned parts. Verified: stack-graph round-trip 49/0 (added guard test), types 107/0, tsc clean, CLI stack-update 56/0, template-snapshots 95 byte-identical. Advances task #20 (Phase-4 consolidation; full flat-storage retirement remains). --- packages/types/src/stack-graph.ts | 30 +++++++++++++- packages/types/test/stack-graph.test.ts | 54 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/types/src/stack-graph.ts b/packages/types/src/stack-graph.ts index 3c0ede3e6..cbfeaaf50 100644 --- a/packages/types/src/stack-graph.ts +++ b/packages/types/src/stack-graph.ts @@ -666,6 +666,7 @@ const GRAPH_PROJECTION_DEFAULT_LEGACY_CATEGORIES = [ "orm", "api", "auth", + "astroIntegration", "rustFrontend", ...Object.values(LEGACY_BACKEND_CATEGORY_BY_ECOSYSTEM), ...Object.values(LEGACY_CAPABILITY_CATEGORIES_BY_ECOSYSTEM).flatMap((categories) => @@ -2571,9 +2572,10 @@ function addLegacyPart( toolId: string | undefined, source: StackPartSource, ownerPartId?: string, + settings?: Record, ) { if (!toolId || toolId === "none") return undefined; - const part = createStackPart({ role, ecosystem, toolId, source, ownerPartId }); + const part = createStackPart({ role, ecosystem, toolId, source, ownerPartId, settings }); parts.push(part); return part; } @@ -2635,6 +2637,16 @@ export function legacyProjectConfigToStackParts( const webFrontend = webFrontends[0]; const nativeFrontend = nativeFrontends[0]; const frontendPart = addLegacyPart(parts, "frontend", "typescript", webFrontend, source); + // The Astro sub-framework choice is a settings-shaped detail carried on the + // Astro frontend part itself (rather than a scoped capability part), so a pure + // graph round-trips the integration without a flat-field side channel. + if ( + frontendPart?.toolId === "astro" && + config.astroIntegration && + config.astroIntegration !== "none" + ) { + frontendPart.settings = { astroIntegration: config.astroIntegration }; + } const mobilePart = addLegacyPart(parts, "mobile", "react-native", nativeFrontend, source); let backendPart: StackPart | undefined; @@ -2896,6 +2908,10 @@ export function stackPartsToLegacyProjectConfigPartial( ...(config.frontend ?? []), part.toolId as ProjectConfig["frontend"][number], ]; + const astroIntegration = part.settings?.astroIntegration; + if (typeof astroIntegration === "string") { + config.astroIntegration = astroIntegration as ProjectConfig["astroIntegration"]; + } } else if (part.role === "frontend" && part.ecosystem === "rust") { config.rustFrontend = part.toolId as ProjectConfig["rustFrontend"]; } else if (part.role === "mobile") { @@ -3098,6 +3114,18 @@ export function stackGraphToLegacyProjectConfigForEcosystem( projected.auth = config.auth; } + // astroIntegration is reset to "none" by the default-category loop above; prefer + // the graph-owned value carried on the Astro frontend part's settings, falling + // back to the flat field so graph-mode specs (which cannot express the setting) + // keep projecting the same value the spread used to carry. + const astroIntegrationSetting = + frontend?.toolId === "astro" ? frontend.settings?.astroIntegration : undefined; + if (typeof astroIntegrationSetting === "string") { + projected.astroIntegration = astroIntegrationSetting as ProjectConfig["astroIntegration"]; + } else if (config.astroIntegration !== undefined) { + projected.astroIntegration = config.astroIntegration; + } + const backendScopedPartRoles = new Set(["database", "orm", "api", "auth"]); for (const part of parts) { if ( diff --git a/packages/types/test/stack-graph.test.ts b/packages/types/test/stack-graph.test.ts index fd248e5b7..3594cca63 100644 --- a/packages/types/test/stack-graph.test.ts +++ b/packages/types/test/stack-graph.test.ts @@ -115,6 +115,7 @@ import { I18N_VALUES, ANALYTICS_VALUES, ANIMATION_VALUES, + ASTRO_INTEGRATION_VALUES, STATE_MANAGEMENT_VALUES, UI_LIBRARY_VALUES, VALIDATION_VALUES, @@ -1117,6 +1118,59 @@ describe("stack graph structural round-trip (phase 0)", () => { } }); + it("carries the Astro integration as a frontend-part setting and round-trips it", () => { + for (const astroIntegration of ASTRO_INTEGRATION_VALUES.filter((value) => value !== "none")) { + const config: Partial = { + ecosystem: "typescript", + frontend: ["astro"], + backend: "none", + database: "none", + orm: "none", + api: "none", + auth: "none", + astroIntegration, + }; + const parts = legacyProjectConfigToStackParts(config); + const frontendPart = parts.find( + (part) => part.role === "frontend" && part.ecosystem === "typescript", + ); + + // legacy -> parts: the choice rides on the Astro frontend part's settings, + // not a flat side channel or a scoped capability part. + expect(frontendPart?.toolId).toBe("astro"); + expect(frontendPart?.settings?.astroIntegration).toBe(astroIntegration); + + // parts -> legacy: the graph restores the same integration with no drift. + const diagnostics = compareLegacyConfigToStackParts(config, parts); + expect(diagnostics).toEqual([]); + + const derived = stackPartsToLegacyProjectConfigPartial(parts); + expect(derived.astroIntegration).toBe(astroIntegration); + expect(validateStackParts(parts).issues).toEqual([]); + } + + // Without a carried setting the projection leaves astroIntegration unset so a + // flat field (e.g. a stack-update whose spec-derived parts drop the setting) + // is preserved instead of being clobbered to "none". + const noIntegrationParts = legacyProjectConfigToStackParts({ + ecosystem: "typescript", + frontend: ["astro"], + backend: "none", + database: "none", + orm: "none", + api: "none", + auth: "none", + }); + expect( + noIntegrationParts.find( + (part) => part.role === "frontend" && part.ecosystem === "typescript", + )?.settings, + ).toBeUndefined(); + expect( + stackPartsToLegacyProjectConfigPartial(noIntegrationParts).astroIntegration, + ).toBeUndefined(); + }); + it("round-trips every deploy, runtime, and db setup value as a scoped graph part", () => { for (const runtime of RUNTIME_VALUES) { const config = { From 37ec92d4adc291d452ad21ec8760aea6fb01a039 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 18:11:37 +0300 Subject: [PATCH 04/14] feat(cli): per-stack skills + 'bfs gen resource' in-project generator (a) The skills addon SKILL.md is now stack-specific: a 'built with: ' fingerprint + conditional 'Add a route/resource', 'Run migrations', 'Add a test' guidance branching on ecosystem/backend/api/orm/database/auth (drizzle-kit vs prisma, trpc vs orpc, vitest/jest/playwright), instead of generic boilerplate. (b) New 'create-better-fullstack gen resource ' (alias gen route): reads bts.jsonc, writes a CRUD sub-router to packages/api/src/routers/.ts (protectedProcedure when better-auth) and registers it via anchor insertion, for TS + trpc/orpc. Idempotent + dry-run; unsupported stacks get an honest message, never a corrupted file. Resource template inlined (no runtime template dep). Verified: gen-resource 6/0, template-snapshots 95/0, CLI regressions 755 pass, oxfmt/oxlint clean; manual smoke: generated SKILL.md is stack-specific and 'gen resource post' + check-types passes across all 5 scaffold packages (trpc and orpc both proven). Closes task #14 (skills fully per-stack; gen covers the primary TS+trpc/orpc stack with graceful unsupported elsewhere). --- apps/cli/src/commands/gen.ts | 357 ++++++++++++++++++ apps/cli/src/run.ts | 26 +- .../test/fixtures/gen-resource/orpc/bts.jsonc | 201 ++++++++++ .../orpc/packages/api/src/routers/index.ts | 11 + .../test/fixtures/gen-resource/trpc/bts.jsonc | 218 +++++++++++ .../trpc/packages/api/src/routers/index.ts | 14 + .../gen-resource/unsupported/bts.jsonc | 38 ++ apps/cli/test/gen-resource.test.ts | 146 +++++++ .../skills/better-fullstack/SKILL.md.hbs | 103 +++++ 9 files changed, 1113 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/commands/gen.ts create mode 100644 apps/cli/test/fixtures/gen-resource/orpc/bts.jsonc create mode 100644 apps/cli/test/fixtures/gen-resource/orpc/packages/api/src/routers/index.ts create mode 100644 apps/cli/test/fixtures/gen-resource/trpc/bts.jsonc create mode 100644 apps/cli/test/fixtures/gen-resource/trpc/packages/api/src/routers/index.ts create mode 100644 apps/cli/test/fixtures/gen-resource/unsupported/bts.jsonc create mode 100644 apps/cli/test/gen-resource.test.ts diff --git a/apps/cli/src/commands/gen.ts b/apps/cli/src/commands/gen.ts new file mode 100644 index 000000000..8be5acdbd --- /dev/null +++ b/apps/cli/src/commands/gen.ts @@ -0,0 +1,357 @@ +import { processTemplateString } from "@better-fullstack/template-generator"; +import { log } from "@clack/prompts"; +import fs from "fs-extra"; +import path from "node:path"; +import pc from "picocolors"; + +import type { ProjectConfig } from "../types"; + +import { readBtsConfig } from "../utils/bts-config"; + +export type GenKind = "resource" | "route"; + +export type GenCommandInput = { + kind: GenKind; + name: string; + dir?: string; + dryRun?: boolean; +}; + +export type GenStatus = "created" | "manual-wiring" | "unsupported"; + +export type GenResult = { + status: GenStatus; + message: string; + resourceFile?: string; + registered?: boolean; +}; + +/** + * Inline resource template (rendered with the project's ProjectConfig via the + * template-generator's Handlebars pipeline). Kept as a string constant so `gen` + * works in a published CLI with no template directory / EMBEDDED_TEMPLATES + * dependency. Branches on `api` (trpc | orpc) and `auth` (better-auth -> + * protectedProcedure). + */ +const RESOURCE_TEMPLATE = `{{#if (eq api "trpc")}} +import { z } from "zod"; + +import { {{#if (isBetterAuth auth)}}protectedProcedure{{else}}publicProcedure{{/if}}, router } from "../index{{importExt}}"; + +export type {{ResourceName}} = { + id: string; + name: string; + createdAt: string; +}; + +const {{resourceName}}Store: {{ResourceName}}[] = []; +let {{resourceName}}NextId = 1; + +const {{resourceName}}Procedure = {{#if (isBetterAuth auth)}}protectedProcedure{{else}}publicProcedure{{/if}}; + +export const {{resourceName}}Router = router({ + list: {{resourceName}}Procedure.query(() => { + return {{resourceName}}Store; + }), + byId: {{resourceName}}Procedure.input(z.object({ id: z.string() })).query(({ input }) => { + return {{resourceName}}Store.find((item) => item.id === input.id) ?? null; + }), + create: {{resourceName}}Procedure + .input(z.object({ name: z.string().min(1) })) + .mutation(({ input }) => { + const item: {{ResourceName}} = { + id: String({{resourceName}}NextId++), + name: input.name, + createdAt: new Date().toISOString(), + }; + {{resourceName}}Store.push(item); + return item; + }), + update: {{resourceName}}Procedure + .input(z.object({ id: z.string(), name: z.string().min(1) })) + .mutation(({ input }) => { + const item = {{resourceName}}Store.find((entry) => entry.id === input.id); + if (!item) return null; + item.name = input.name; + return item; + }), + remove: {{resourceName}}Procedure.input(z.object({ id: z.string() })).mutation(({ input }) => { + const index = {{resourceName}}Store.findIndex((entry) => entry.id === input.id); + if (index === -1) return { success: false }; + {{resourceName}}Store.splice(index, 1); + return { success: true }; + }), +}); +{{else}} +import { z } from "zod"; + +import { {{#if (isBetterAuth auth)}}protectedProcedure{{else}}publicProcedure{{/if}} } from "../index{{importExt}}"; + +export type {{ResourceName}} = { + id: string; + name: string; + createdAt: string; +}; + +const {{resourceName}}Store: {{ResourceName}}[] = []; +let {{resourceName}}NextId = 1; + +const {{resourceName}}Procedure = {{#if (isBetterAuth auth)}}protectedProcedure{{else}}publicProcedure{{/if}}; + +export const {{resourceName}}Router = { + list: {{resourceName}}Procedure.handler(() => { + return {{resourceName}}Store; + }), + byId: {{resourceName}}Procedure + .input(z.object({ id: z.string() })) + .handler(({ input }) => { + return {{resourceName}}Store.find((item) => item.id === input.id) ?? null; + }), + create: {{resourceName}}Procedure + .input(z.object({ name: z.string().min(1) })) + .handler(({ input }) => { + const item: {{ResourceName}} = { + id: String({{resourceName}}NextId++), + name: input.name, + createdAt: new Date().toISOString(), + }; + {{resourceName}}Store.push(item); + return item; + }), + update: {{resourceName}}Procedure + .input(z.object({ id: z.string(), name: z.string().min(1) })) + .handler(({ input }) => { + const item = {{resourceName}}Store.find((entry) => entry.id === input.id); + if (!item) return null; + item.name = input.name; + return item; + }), + remove: {{resourceName}}Procedure + .input(z.object({ id: z.string() })) + .handler(({ input }) => { + const index = {{resourceName}}Store.findIndex((entry) => entry.id === input.id); + if (index === -1) return { success: false }; + {{resourceName}}Store.splice(index, 1); + return { success: true }; + }), +}; +{{/if}} +`; + +/** Candidate roots (relative to the project) that may hold a routers/index.ts. */ +const ROUTER_INDEX_CANDIDATES = [ + "packages/api/src/routers/index.ts", + "apps/server/src/routers/index.ts", +]; + +function splitWords(raw: string): string[] { + return raw + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[^a-zA-Z0-9]+/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function toCamelCase(raw: string): string { + const words = splitWords(raw); + return words + .map((word, index) => + index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ) + .join(""); +} + +function toPascalCase(raw: string): string { + const camel = toCamelCase(raw); + return camel.charAt(0).toUpperCase() + camel.slice(1); +} + +/** + * Scans for an existing routers/index.ts to register the new resource in. + * Checks the well-known locations first, then any `/src/routers/index.ts` + * under apps/* and packages/* (robust across hono/express/elysia split backends + * and the packages/api layout). + */ +async function findRouterIndex(projectDir: string): Promise { + for (const candidate of ROUTER_INDEX_CANDIDATES) { + const full = path.join(projectDir, candidate); + if (await fs.pathExists(full)) return full; + } + + for (const workspace of ["apps", "packages"]) { + const workspaceDir = path.join(projectDir, workspace); + if (!(await fs.pathExists(workspaceDir))) continue; + let entries: string[]; + try { + entries = await fs.readdir(workspaceDir); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(workspaceDir, entry, "src", "routers", "index.ts"); + if (await fs.pathExists(full)) return full; + } + } + + return null; +} + +/** Mirrors the relative-import extension used by the existing routers/index.ts. */ +function detectImportExtension(indexContent: string): string { + return /from\s+["']\.\.\/index\.js["']/.test(indexContent) ? ".js" : ""; +} + +type Injection = { ok: true; content: string } | { ok: false; reason: string }; + +function injectResource( + indexContent: string, + api: "trpc" | "orpc", + resourceName: string, + importExt: string, +): Injection { + const anchor = + api === "trpc" ? "export const appRouter = router({" : "export const appRouter = {"; + + const lines = indexContent.split("\n"); + const anchorIdx = lines.findIndex((line) => line.includes(anchor)); + if (anchorIdx === -1) { + return { ok: false, reason: `Could not find the appRouter anchor (\`${anchor}\`)` }; + } + + const registrationKey = `${resourceName}:`; + const alreadyRegistered = lines.some( + (line, idx) => idx > anchorIdx && line.trim().startsWith(registrationKey), + ); + if (alreadyRegistered) { + return { ok: false, reason: `\`${resourceName}\` is already registered in appRouter` }; + } + + const importLine = `import { ${resourceName}Router } from "./${resourceName}${importExt}";`; + const registrationLine = ` ${resourceName}: ${resourceName}Router,`; + + // Insert the import after the last top-of-file import line (before the anchor). + let lastImportIdx = -1; + for (let i = 0; i < anchorIdx; i += 1) { + const line = lines[i]; + if (line !== undefined && line.trimStart().startsWith("import ")) { + lastImportIdx = i; + } + } + lines.splice(lastImportIdx + 1, 0, importLine); + + // Anchor moved down by one after the import insert; re-find it. + const newAnchorIdx = lines.findIndex((line) => line.includes(anchor)); + lines.splice(newAnchorIdx + 1, 0, registrationLine); + + return { ok: true, content: lines.join("\n") }; +} + +export async function genCommand(input: GenCommandInput): Promise { + const projectDir = path.resolve(input.dir || process.cwd()); + const dryRun = input.dryRun ?? false; + + const btsConfig = await readBtsConfig(projectDir); + if (!btsConfig) { + throw new Error( + `No Better Fullstack project found in ${projectDir}. Make sure bts.jsonc exists.`, + ); + } + + const resourceName = toCamelCase(input.name); + const ResourceName = toPascalCase(input.name); + if (!resourceName || !/^[a-z]/i.test(resourceName)) { + throw new Error( + `Invalid resource name "${input.name}". Use a name that starts with a letter, e.g. "post".`, + ); + } + + const ecosystem = btsConfig.ecosystem; + const api = btsConfig.api; + + // Scope guard: only TypeScript + trpc/orpc is supported for now. Everything + // else prints an honest "not yet supported" message and writes nothing. + if (ecosystem !== "typescript" || (api !== "trpc" && api !== "orpc")) { + const stack = `${ecosystem}${api && api !== "none" ? ` + ${api}` : ""}`; + const message = `\`gen ${input.kind}\` is not yet supported for this stack (${stack}). Currently supported: TypeScript projects with a trpc or orpc API.`; + log.warn(pc.yellow(message)); + return { status: "unsupported", message }; + } + + const routerIndexPath = await findRouterIndex(projectDir); + if (!routerIndexPath) { + const message = `Could not locate a \`routers/index.ts\` in this project. Is this a trpc/orpc project? No files were written.`; + log.warn(pc.yellow(message)); + return { status: "unsupported", message }; + } + + const routersDir = path.dirname(routerIndexPath); + const resourceFile = path.join(routersDir, `${resourceName}.ts`); + const relResourceFile = path.relative(projectDir, resourceFile); + + // Idempotency: never overwrite an existing resource file. + if (await fs.pathExists(resourceFile)) { + throw new Error( + `Resource "${resourceName}" already exists at ${relResourceFile}. Delete it first or choose another name.`, + ); + } + + const indexContent = await fs.readFile(routerIndexPath, "utf-8"); + const importExt = detectImportExtension(indexContent); + + const templateContext = { + ...btsConfig, + resourceName, + ResourceName, + importExt, + } as unknown as ProjectConfig; + + const resourceContent = processTemplateString(RESOURCE_TEMPLATE, templateContext).trimStart(); + + const injection = injectResource(indexContent, api, resourceName, importExt); + + if (dryRun) { + log.info(pc.cyan(`[dry run] Would create ${relResourceFile}`)); + log.message(resourceContent); + if (injection.ok) { + log.info( + pc.cyan( + `[dry run] Would register \`${resourceName}: ${resourceName}Router\` in ${path.relative( + projectDir, + routerIndexPath, + )}`, + ), + ); + } else { + log.warn(pc.yellow(`[dry run] Manual wiring required: ${injection.reason}`)); + } + return { + status: injection.ok ? "created" : "manual-wiring", + message: "dry run", + resourceFile, + registered: injection.ok, + }; + } + + await fs.writeFile(resourceFile, resourceContent, "utf-8"); + + if (!injection.ok) { + const relIndex = path.relative(projectDir, routerIndexPath); + const message = `Created ${relResourceFile}, but could not auto-register it: ${injection.reason}. Wire it manually.`; + log.warn(pc.yellow(message)); + log.message( + [ + `Add these two lines to ${relIndex}:`, + pc.dim(` import { ${resourceName}Router } from "./${resourceName}${importExt}";`), + pc.dim(` ${resourceName}: ${resourceName}Router, // inside appRouter`), + ].join("\n"), + ); + return { status: "manual-wiring", message, resourceFile, registered: false }; + } + + await fs.writeFile(routerIndexPath, injection.content, "utf-8"); + + const relIndex = path.relative(projectDir, routerIndexPath); + const message = `Created ${relResourceFile} and registered \`${resourceName}: ${resourceName}Router\` in ${relIndex}.`; + log.success(pc.green(message)); + return { status: "created", message, resourceFile, registered: true }; +} diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 568c08a30..32fb28574 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -4,10 +4,11 @@ import pc from "picocolors"; import { createCli } from "trpc-cli"; import z from "zod"; +import type { AddResult } from "./helpers/core/add-handler"; + import { historyHandler } from "./commands/history"; import { telemetryHandler } from "./commands/telemetry"; import { CreateCommandInputSchema, CreateCommandOptionsSchema } from "./create-command-input"; -import type { AddResult } from "./helpers/core/add-handler"; import { createProjectHandler } from "./helpers/core/command-handlers"; import { type AddInput, @@ -330,6 +331,29 @@ export const router = os.router({ ecosystem: input.ecosystem, }); }), + gen: os + .meta({ + description: + "Generate in-project code for an existing Better Fullstack project (e.g. `gen resource ` / `gen route ` for a new trpc/orpc API resource router)", + }) + .input( + z.tuple([ + z.enum(["resource", "route"]).describe("What to generate: resource (alias: route)"), + z.string().describe("Name of the resource/route (e.g. post)"), + z.object({ + dir: z.string().optional().describe("Project directory (defaults to current)"), + dryRun: z + .boolean() + .default(false) + .describe("Print the planned changes without writing any files"), + }), + ]), + ) + .handler(async ({ input }) => { + const [kind, name, options] = input; + const { genCommand } = await import("./commands/gen.js"); + await genCommand({ kind, name, dir: options.dir, dryRun: options.dryRun }); + }), mcp: os .meta({ description: diff --git a/apps/cli/test/fixtures/gen-resource/orpc/bts.jsonc b/apps/cli/test/fixtures/gen-resource/orpc/bts.jsonc new file mode 100644 index 000000000..a89b523bc --- /dev/null +++ b/apps/cli/test/fixtures/gen-resource/orpc/bts.jsonc @@ -0,0 +1,201 @@ +// Better Fullstack configuration file +// safe to delete +// stackParts is the source of truth; graphSummary/effectiveStack summarize it for humans and tools. +// Top-level option fields are a derived compatibility cache for older integrations. + +{ + "$schema": "https://better-fullstack-web.vercel.app/schema.json", + "version": "2.1.5", + "createdAt": "2026-07-05T15:02:29.684Z", + "graphSummary": "React + Vite app + typescript hono API + drizzle/SQLite", + "effectiveStack": { + "frontend": "typescript:react-vite", + "backend": "typescript:hono", + "database": "universal:sqlite", + "backend.orm": "typescript:drizzle", + "backend.api": "typescript:orpc", + "frontend.css": "typescript:tailwind", + "backend.runtime": "typescript:bun", + }, + "ecosystem": "typescript", + "database": "sqlite", + "orm": "drizzle", + "backend": "hono", + "runtime": "bun", + "frontend": ["react-vite"], + "addons": [], + "examples": [], + "auth": "none", + "payments": "none", + "email": "none", + "fileUpload": "none", + "effect": "none", + "ai": "none", + "stateManagement": "none", + "validation": "none", + "forms": "none", + "testing": "none", + "packageManager": "bun", + "versionChannel": "stable", + "dbSetup": "none", + "api": "orpc", + "webDeploy": "none", + "serverDeploy": "none", + "cssFramework": "tailwind", + "uiLibrary": "none", + "realtime": "none", + "jobQueue": "none", + "animation": "none", + "logging": "none", + "observability": "none", + "featureFlags": "none", + "analytics": "none", + "mobileNavigation": "none", + "mobileUI": "none", + "mobileStorage": "none", + "mobileTesting": "none", + "mobilePush": "none", + "mobileOTA": "none", + "mobileDeepLinking": "none", + "cms": "none", + "caching": "none", + "rateLimit": "none", + "i18n": "none", + "search": "none", + "vectorDb": "none", + "fileStorage": "none", + "rustWebFramework": "none", + "rustFrontend": "none", + "rustOrm": "none", + "rustApi": "none", + "rustCli": "none", + "rustLibraries": [], + "rustLogging": "none", + "rustErrorHandling": "none", + "rustCaching": "none", + "rustAuth": "none", + "rustRealtime": "none", + "rustMessageQueue": "none", + "rustObservability": "none", + "rustTemplating": "none", + "pythonWebFramework": "none", + "pythonOrm": "none", + "pythonValidation": "none", + "pythonAi": [], + "pythonAuth": "none", + "pythonApi": "none", + "pythonTaskQueue": "none", + "pythonGraphql": "none", + "pythonQuality": "none", + "pythonTesting": [], + "pythonCaching": "none", + "pythonRealtime": "none", + "pythonObservability": "none", + "pythonCli": [], + "goWebFramework": "none", + "goOrm": "none", + "goApi": "none", + "goCli": "none", + "goLogging": "none", + "goAuth": "none", + "goTesting": [], + "goRealtime": "none", + "goMessageQueue": "none", + "goCaching": "none", + "goConfig": "none", + "goObservability": "none", + "javaWebFramework": "none", + "javaBuildTool": "none", + "javaOrm": "none", + "javaAuth": "none", + "javaApi": "none", + "javaLogging": "none", + "javaLibraries": [], + "javaTestingLibraries": [], + "dotnetWebFramework": "none", + "dotnetOrm": "none", + "dotnetAuth": "none", + "dotnetApi": "none", + "dotnetTesting": [], + "dotnetJobQueue": "none", + "dotnetRealtime": "none", + "dotnetObservability": [], + "dotnetValidation": "none", + "dotnetCaching": "none", + "dotnetDeploy": "none", + "elixirWebFramework": "none", + "elixirOrm": "none", + "elixirAuth": "none", + "elixirApi": "none", + "elixirRealtime": "none", + "elixirJobs": "none", + "elixirValidation": "none", + "elixirHttp": "none", + "elixirJson": "none", + "elixirEmail": "none", + "elixirCaching": "none", + "elixirObservability": "none", + "elixirTesting": "none", + "elixirQuality": "none", + "elixirDeploy": "none", + "elixirLibraries": [], + "aiDocs": ["claude-md"], + "stackParts": [ + { + "id": "frontend:typescript:react-vite", + "role": "frontend", + "toolId": "react-vite", + "ecosystem": "typescript", + "source": "legacy", + "targetPath": "apps/web", + }, + { + "id": "backend:typescript:hono", + "role": "backend", + "toolId": "hono", + "ecosystem": "typescript", + "source": "legacy", + "targetPath": "apps/server", + }, + { + "id": "database:universal:sqlite", + "role": "database", + "toolId": "sqlite", + "ecosystem": "universal", + "source": "legacy", + "targetPath": "packages/db", + }, + { + "id": "backend:typescript:hono.orm:typescript:drizzle", + "role": "orm", + "toolId": "drizzle", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.api:typescript:orpc", + "role": "api", + "toolId": "orpc", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "frontend:typescript:react-vite.css:typescript:tailwind", + "role": "css", + "toolId": "tailwind", + "ecosystem": "typescript", + "ownerPartId": "frontend:typescript:react-vite", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.runtime:typescript:bun", + "role": "runtime", + "toolId": "bun", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + ], +} diff --git a/apps/cli/test/fixtures/gen-resource/orpc/packages/api/src/routers/index.ts b/apps/cli/test/fixtures/gen-resource/orpc/packages/api/src/routers/index.ts new file mode 100644 index 000000000..92df518c9 --- /dev/null +++ b/apps/cli/test/fixtures/gen-resource/orpc/packages/api/src/routers/index.ts @@ -0,0 +1,11 @@ +import type { RouterClient } from "@orpc/server"; + +import { publicProcedure } from "../index"; + +export const appRouter = { + healthCheck: publicProcedure.handler(() => { + return "OK"; + }), +}; +export type AppRouter = typeof appRouter; +export type AppRouterClient = RouterClient; diff --git a/apps/cli/test/fixtures/gen-resource/trpc/bts.jsonc b/apps/cli/test/fixtures/gen-resource/trpc/bts.jsonc new file mode 100644 index 000000000..0fc3650e1 --- /dev/null +++ b/apps/cli/test/fixtures/gen-resource/trpc/bts.jsonc @@ -0,0 +1,218 @@ +// Better Fullstack configuration file +// safe to delete +// stackParts is the source of truth; graphSummary/effectiveStack summarize it for humans and tools. +// Top-level option fields are a derived compatibility cache for older integrations. + +{ + "$schema": "https://better-fullstack-web.vercel.app/schema.json", + "version": "2.1.5", + "createdAt": "2026-07-05T14:59:15.759Z", + "graphSummary": "TanStack Router app + typescript hono API + drizzle/SQLite", + "effectiveStack": { + "frontend": "typescript:tanstack-router", + "backend": "typescript:hono", + "database": "universal:sqlite", + "workspaceTooling": "universal:skills", + "backend.orm": "typescript:drizzle", + "backend.api": "typescript:trpc", + "backend.auth": "typescript:better-auth", + "frontend.css": "typescript:tailwind", + "backend.runtime": "typescript:bun", + }, + "ecosystem": "typescript", + "database": "sqlite", + "orm": "drizzle", + "backend": "hono", + "runtime": "bun", + "frontend": ["tanstack-router"], + "addons": ["skills"], + "examples": [], + "auth": "better-auth", + "payments": "none", + "email": "none", + "fileUpload": "none", + "effect": "none", + "ai": "none", + "stateManagement": "none", + "validation": "none", + "forms": "none", + "testing": "vitest", + "packageManager": "bun", + "versionChannel": "stable", + "dbSetup": "none", + "api": "trpc", + "webDeploy": "none", + "serverDeploy": "none", + "cssFramework": "tailwind", + "uiLibrary": "none", + "realtime": "none", + "jobQueue": "none", + "animation": "none", + "logging": "none", + "observability": "none", + "featureFlags": "none", + "analytics": "none", + "mobileNavigation": "none", + "mobileUI": "none", + "mobileStorage": "none", + "mobileTesting": "none", + "mobilePush": "none", + "mobileOTA": "none", + "mobileDeepLinking": "none", + "cms": "none", + "caching": "none", + "rateLimit": "none", + "i18n": "none", + "search": "none", + "vectorDb": "none", + "fileStorage": "none", + "rustWebFramework": "none", + "rustFrontend": "none", + "rustOrm": "none", + "rustApi": "none", + "rustCli": "none", + "rustLibraries": [], + "rustLogging": "none", + "rustErrorHandling": "none", + "rustCaching": "none", + "rustAuth": "none", + "rustRealtime": "none", + "rustMessageQueue": "none", + "rustObservability": "none", + "rustTemplating": "none", + "pythonWebFramework": "none", + "pythonOrm": "none", + "pythonValidation": "none", + "pythonAi": [], + "pythonAuth": "none", + "pythonApi": "none", + "pythonTaskQueue": "none", + "pythonGraphql": "none", + "pythonQuality": "none", + "pythonTesting": [], + "pythonCaching": "none", + "pythonRealtime": "none", + "pythonObservability": "none", + "pythonCli": [], + "goWebFramework": "none", + "goOrm": "none", + "goApi": "none", + "goCli": "none", + "goLogging": "none", + "goAuth": "none", + "goTesting": [], + "goRealtime": "none", + "goMessageQueue": "none", + "goCaching": "none", + "goConfig": "none", + "goObservability": "none", + "javaWebFramework": "none", + "javaBuildTool": "none", + "javaOrm": "none", + "javaAuth": "none", + "javaApi": "none", + "javaLogging": "none", + "javaLibraries": [], + "javaTestingLibraries": [], + "dotnetWebFramework": "none", + "dotnetOrm": "none", + "dotnetAuth": "none", + "dotnetApi": "none", + "dotnetTesting": [], + "dotnetJobQueue": "none", + "dotnetRealtime": "none", + "dotnetObservability": [], + "dotnetValidation": "none", + "dotnetCaching": "none", + "dotnetDeploy": "none", + "elixirWebFramework": "none", + "elixirOrm": "none", + "elixirAuth": "none", + "elixirApi": "none", + "elixirRealtime": "none", + "elixirJobs": "none", + "elixirValidation": "none", + "elixirHttp": "none", + "elixirJson": "none", + "elixirEmail": "none", + "elixirCaching": "none", + "elixirObservability": "none", + "elixirTesting": "none", + "elixirQuality": "none", + "elixirDeploy": "none", + "elixirLibraries": [], + "aiDocs": ["claude-md"], + "stackParts": [ + { + "id": "frontend:typescript:tanstack-router", + "role": "frontend", + "toolId": "tanstack-router", + "ecosystem": "typescript", + "source": "legacy", + "targetPath": "apps/web", + }, + { + "id": "backend:typescript:hono", + "role": "backend", + "toolId": "hono", + "ecosystem": "typescript", + "source": "legacy", + "targetPath": "apps/server", + }, + { + "id": "database:universal:sqlite", + "role": "database", + "toolId": "sqlite", + "ecosystem": "universal", + "source": "legacy", + "targetPath": "packages/db", + }, + { + "id": "workspacetooling:universal:skills", + "role": "workspaceTooling", + "toolId": "skills", + "ecosystem": "universal", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.orm:typescript:drizzle", + "role": "orm", + "toolId": "drizzle", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.api:typescript:trpc", + "role": "api", + "toolId": "trpc", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.auth:typescript:better-auth", + "role": "auth", + "toolId": "better-auth", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "frontend:typescript:tanstack-router.css:typescript:tailwind", + "role": "css", + "toolId": "tailwind", + "ecosystem": "typescript", + "ownerPartId": "frontend:typescript:tanstack-router", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.runtime:typescript:bun", + "role": "runtime", + "toolId": "bun", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + ], +} diff --git a/apps/cli/test/fixtures/gen-resource/trpc/packages/api/src/routers/index.ts b/apps/cli/test/fixtures/gen-resource/trpc/packages/api/src/routers/index.ts new file mode 100644 index 000000000..1de652ba2 --- /dev/null +++ b/apps/cli/test/fixtures/gen-resource/trpc/packages/api/src/routers/index.ts @@ -0,0 +1,14 @@ +import { protectedProcedure, publicProcedure, router } from "../index"; + +export const appRouter = router({ + healthCheck: publicProcedure.query(() => { + return "OK"; + }), + privateData: protectedProcedure.query(({ ctx }) => { + return { + message: "This is private", + user: ctx.session.user, + }; + }), +}); +export type AppRouter = typeof appRouter; diff --git a/apps/cli/test/fixtures/gen-resource/unsupported/bts.jsonc b/apps/cli/test/fixtures/gen-resource/unsupported/bts.jsonc new file mode 100644 index 000000000..0263329a7 --- /dev/null +++ b/apps/cli/test/fixtures/gen-resource/unsupported/bts.jsonc @@ -0,0 +1,38 @@ +// Better Fullstack configuration file +// safe to delete +// stackParts is the source of truth; graphSummary/effectiveStack summarize it for humans and tools. +// Top-level option fields are a derived compatibility cache for older integrations. + +{ + "$schema": "https://better-fullstack-web.vercel.app/schema.json", + "version": "2.1.5", + "createdAt": "2026-07-05T00:00:00.000Z", + "ecosystem": "go", + "database": "none", + "orm": "none", + "backend": "none", + "runtime": "none", + "frontend": ["none"], + "addons": [], + "examples": [], + "auth": "none", + "api": "none", + "packageManager": "bun", + "goWebFramework": "gin", + "goOrm": "none", + "goApi": "none", + "goCli": "none", + "goLogging": "none", + "goAuth": "none", + "goTesting": [], + "stackParts": [ + { + "id": "backend:go:gin", + "role": "backend", + "toolId": "gin", + "ecosystem": "go", + "source": "legacy", + "targetPath": "apps/server", + }, + ], +} diff --git a/apps/cli/test/gen-resource.test.ts b/apps/cli/test/gen-resource.test.ts new file mode 100644 index 000000000..285824e9a --- /dev/null +++ b/apps/cli/test/gen-resource.test.ts @@ -0,0 +1,146 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import fs from "fs-extra"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { genCommand } from "../src/commands/gen"; + +const FIXTURES = join(import.meta.dir, "fixtures", "gen-resource"); +const TEMP_ROOTS: string[] = []; + +async function stageFixture(variant: string): Promise { + const dir = await mkdtemp(join(tmpdir(), `bfs-gen-${variant}-`)); + TEMP_ROOTS.push(dir); + await fs.copy(join(FIXTURES, variant), dir); + return dir; +} + +function routerIndexPath(dir: string): string { + return join(dir, "packages", "api", "src", "routers", "index.ts"); +} + +function resourcePath(dir: string, name: string): string { + return join(dir, "packages", "api", "src", "routers", `${name}.ts`); +} + +afterAll(async () => { + await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("gen resource", () => { + it("scaffolds a protected trpc router and registers it", async () => { + const dir = await stageFixture("trpc"); + + const result = await genCommand({ kind: "resource", name: "post", dir }); + + expect(result.status).toBe("created"); + expect(result.registered).toBe(true); + + const resource = await readFile(resourcePath(dir, "post"), "utf-8"); + // trpc shape + auth-gated procedure + expect(resource).toContain("export const postRouter = router({"); + expect(resource).toContain("const postProcedure = protectedProcedure;"); + expect(resource).toContain('import { protectedProcedure, router } from "../index";'); + expect(resource).not.toContain("publicProcedure"); + // CRUD procedures with trpc query/mutation verbs + expect(resource).toContain("list: postProcedure.query("); + expect(resource).toContain("byId: postProcedure"); + expect(resource).toContain(".mutation("); + expect(resource).toContain("create: postProcedure"); + expect(resource).toContain("update: postProcedure"); + expect(resource).toContain("remove: postProcedure"); + + const index = await readFile(routerIndexPath(dir), "utf-8"); + expect(index).toContain('import { postRouter } from "./post";'); + expect(index).toContain("post: postRouter,"); + // registration lands inside appRouter, right after the anchor + expect(index).toMatch(/export const appRouter = router\(\{\n\s+post: postRouter,/); + }); + + it("scaffolds a public orpc router and registers it", async () => { + const dir = await stageFixture("orpc"); + + const result = await genCommand({ kind: "route", name: "comment", dir }); + + expect(result.status).toBe("created"); + expect(result.registered).toBe(true); + + const resource = await readFile(resourcePath(dir, "comment"), "utf-8"); + // orpc shape (plain object) + public procedure (no auth in this fixture) + expect(resource).toContain("export const commentRouter = {"); + expect(resource).toContain("const commentProcedure = publicProcedure;"); + expect(resource).toContain('import { publicProcedure } from "../index";'); + expect(resource).not.toContain("protectedProcedure"); + // orpc uses .handler, never trpc verbs + expect(resource).toContain(".handler("); + expect(resource).not.toContain(".query("); + expect(resource).not.toContain(".mutation("); + expect(resource).toContain("list: commentProcedure.handler("); + + const index = await readFile(routerIndexPath(dir), "utf-8"); + expect(index).toContain('import { commentRouter } from "./comment";'); + expect(index).toContain("comment: commentRouter,"); + expect(index).toMatch(/export const appRouter = \{\n\s+comment: commentRouter,/); + }); + + it("is idempotent: re-running for an existing resource throws and does not clobber", async () => { + const dir = await stageFixture("trpc"); + + await genCommand({ kind: "resource", name: "post", dir }); + const indexAfterFirst = await readFile(routerIndexPath(dir), "utf-8"); + + await expect(genCommand({ kind: "resource", name: "post", dir })).rejects.toThrow( + /already exists/, + ); + + // The router index is untouched by the failed second run (no duplicate entry). + const indexAfterSecond = await readFile(routerIndexPath(dir), "utf-8"); + expect(indexAfterSecond).toBe(indexAfterFirst); + expect(indexAfterSecond.match(/post: postRouter,/g)?.length).toBe(1); + }); + + it("normalizes a kebab/space resource name into camelCase identifiers", async () => { + const dir = await stageFixture("trpc"); + + const result = await genCommand({ kind: "resource", name: "blog-post", dir }); + expect(result.status).toBe("created"); + + const resource = await readFile(resourcePath(dir, "blogPost"), "utf-8"); + expect(resource).toContain("export const blogPostRouter = router({"); + expect(resource).toContain("export type BlogPost = {"); + + const index = await readFile(routerIndexPath(dir), "utf-8"); + expect(index).toContain('import { blogPostRouter } from "./blogPost";'); + expect(index).toContain("blogPost: blogPostRouter,"); + }); + + it("gracefully refuses an unsupported (non-typescript) stack and writes nothing", async () => { + const dir = await stageFixture("unsupported"); + + const before = await fs.readdir(join(dir, "packages", "api", "src", "routers")).catch(() => []); + + const result = await genCommand({ kind: "resource", name: "post", dir }); + + expect(result.status).toBe("unsupported"); + expect(result.message).toContain("not yet supported"); + + // No resource file was written. + expect(await fs.pathExists(resourcePath(dir, "post"))).toBe(false); + const after = await fs.readdir(join(dir, "packages", "api", "src", "routers")).catch(() => []); + expect(after).toEqual(before); + }); + + it("supports --dry-run without touching the filesystem", async () => { + const dir = await stageFixture("trpc"); + const indexBefore = await readFile(routerIndexPath(dir), "utf-8"); + + const result = await genCommand({ kind: "resource", name: "post", dir, dryRun: true }); + expect(result.status).toBe("created"); + + // Nothing is written in dry-run mode. + expect(await fs.pathExists(resourcePath(dir, "post"))).toBe(false); + const indexAfter = await readFile(routerIndexPath(dir), "utf-8"); + expect(indexAfter).toBe(indexBefore); + }); +}); diff --git a/packages/template-generator/templates/addons/skills/.agents/skills/better-fullstack/SKILL.md.hbs b/packages/template-generator/templates/addons/skills/.agents/skills/better-fullstack/SKILL.md.hbs index 02e10330d..2b796f56a 100644 --- a/packages/template-generator/templates/addons/skills/.agents/skills/better-fullstack/SKILL.md.hbs +++ b/packages/template-generator/templates/addons/skills/.agents/skills/better-fullstack/SKILL.md.hbs @@ -8,6 +8,8 @@ description: Work on this Better Fullstack project, including scaffolded apps, g This project was generated by Better Fullstack. Treat the generated structure as the source of truth when adding stack features, fixing bugs, or extending apps. +**This project was built with:** {{ecosystem}} ecosystem{{#each frontend}}{{#unless (eq this "none")}} · {{this}}{{/unless}}{{/each}}{{#if (and backend (ne backend "none"))}} · {{backend}} backend{{/if}}{{#if (and runtime (ne runtime "none"))}} ({{runtime}}){{/if}}{{#if (and api (ne api "none"))}} · {{api}} API{{/if}}{{#if (and orm (ne orm "none"))}} · {{orm}}{{/if}}{{#if (and database (ne database "none"))}}/{{database}}{{/if}}{{#if (isBetterAuth auth)}} · better-auth{{/if}}{{#if (and cssFramework (ne cssFramework "none"))}} · {{cssFramework}}{{/if}}. The authoritative record of these choices is `bts.jsonc`. + ## Project Rules - Use `{{packageManager}}` for package-manager commands. @@ -24,5 +26,106 @@ the source of truth when adding stack features, fixing bugs, or extending apps. {{packageManager}} run build ``` +## Add a route or resource + +{{#if (or (eq api "trpc") (eq api "orpc"))}} +This project exposes a **{{api}}** API. Routers live in `packages/api/src/routers/`, wired together in `packages/api/src/routers/index.ts`. + +- Fastest path — scaffold a CRUD resource with the generator: + + ```bash + {{#if (eq packageManager "bun")}}bunx{{else if (eq packageManager "pnpm")}}pnpm dlx{{else if (eq packageManager "yarn")}}yarn dlx{{else}}npx{{/if}} create-better-fullstack gen resource + ``` + + It writes `packages/api/src/routers/.ts` (auto-registered in `routers/index.ts`) using {{#if (isBetterAuth auth)}}`protectedProcedure` (auth-gated){{else}}`publicProcedure`{{/if}} for every handler. +- Manual path — create `packages/api/src/routers/.ts` exporting a `Router`, then import it in `routers/index.ts` and add `: Router` to `appRouter`. +{{#if (eq api "trpc")}} +- tRPC procedures chain `.input(z.object({ ... }))` then `.query(...)` (reads) or `.mutation(...)` (writes); `appRouter` is built with `router({ ... })`. +{{else}} +- oRPC procedures chain `.input(z.object({ ... }))` then `.handler(...)`; `appRouter` is a plain object literal of procedures. +{{/if}} +{{else if (and (eq ecosystem "typescript") (eq backend "self"))}} +This project uses a **{{#each frontend}}{{#unless (eq this "none")}}{{this}}{{/unless}}{{/each}}** app with a built-in ({{backend}}) backend — there is no separate `packages/api`. Add server routes/handlers using the framework's own file-based server conventions inside `apps/web` (e.g. server routes, actions, or API route files). +{{else if (and (eq ecosystem "typescript") (and (ne backend "none") (ne backend "self")))}} +This project runs a **{{backend}}** server in `apps/server/`. Add a new route by registering a handler on the app instance in `apps/server/src/` (see the existing route wiring) and export any shared logic from `packages/api` if present. +{{else if (ne goWebFramework "none")}} +This is a Go **{{goWebFramework}}** service. Add handlers in the server package and register their routes with the router/mux, following the existing endpoint wiring. +{{else if (ne pythonWebFramework "none")}} +This is a Python **{{pythonWebFramework}}** service. Add an endpoint in the app/router module and include it in the application, following the existing route definitions. +{{else if (ne rustWebFramework "none")}} +This is a Rust **{{rustWebFramework}}** service. Add a handler function and mount it on the router, mirroring the existing routes. +{{else if (ne javaWebFramework "none")}} +This is a Java **{{javaWebFramework}}** service. Add a controller/handler and map it to a route following the existing endpoints. +{{else if (ne elixirWebFramework "none")}} +This is an Elixir **{{elixirWebFramework}}** service. Add a controller action and a matching entry in the router pipeline. +{{else}} +This stack has no HTTP API layer configured. Add one with the Better Fullstack CLI (e.g. `--api orpc`/`--backend hono`) before scaffolding routes. +{{/if}} + +## Run migrations + +{{#if (eq orm "drizzle")}} +Schema lives in `packages/db/src/schema/`. After editing it: + +```bash +{{packageManager}} run db:generate # drizzle-kit generate (create SQL migration) +{{packageManager}} run db:migrate # drizzle-kit migrate (apply migrations) +``` + +For rapid local iteration you can push the schema directly with `{{packageManager}} run db:push` (drizzle-kit push). +{{else if (eq orm "prisma")}} +Schema lives in `packages/db/prisma/schema/`. After editing it: + +```bash +{{packageManager}} run db:migrate # prisma migrate dev (create + apply migration) +{{packageManager}} run db:generate # prisma generate (regenerate the client) +``` + +For rapid local iteration you can push the schema directly with `{{packageManager}} run db:push` (prisma db push). +{{else if (eq orm "mongoose")}} +MongoDB via Mongoose has no migration step — models are schema-on-write. Edit the models in `packages/db/src/` and the collections adapt on the next write. +{{else if (and (ne orm "none") (eq ecosystem "typescript"))}} +This project uses the **{{orm}}** ORM (schema in `packages/db/src/`). Use the ORM's own migration CLI to generate and apply migrations after editing the entities/models. +{{else if (ne pythonOrm "none")}} +This project uses the Python **{{pythonOrm}}** ORM. Use Alembic (or the ORM's migration tool) to autogenerate and apply migrations after editing the models. +{{else if (ne goOrm "none")}} +This project uses the Go **{{goOrm}}** ORM. Run the ORM's migration/auto-migrate step (or its CLI) after editing the models. +{{else if (ne rustOrm "none")}} +This project uses the Rust **{{rustOrm}}** ORM. Use its migration CLI (e.g. `sea-orm-cli migrate` / `sqlx migrate run`) after editing the entities. +{{else if (ne javaOrm "none")}} +This project uses the Java **{{javaOrm}}** persistence layer. Apply schema changes with the configured migration tool (Flyway/Liquibase) or the ORM's DDL settings. +{{else if (ne elixirOrm "none")}} +This project uses the Elixir **{{elixirOrm}}** ORM. Generate a migration with `mix ecto.gen.migration` and apply it with `mix ecto.migrate`. +{{else}} +No database/ORM is configured for this project, so there are no migrations to run. +{{/if}} + +## Add a test + +{{#if (or (eq testing "vitest") (eq testing "vitest-playwright"))}} +Unit tests use **Vitest**. Add `*.test.ts` files next to the code under test and run: + +```bash +{{packageManager}} run test +``` +{{#if (eq testing "vitest-playwright")}} +End-to-end tests use **Playwright** in `apps/web` — add specs under its `e2e`/`tests` directory and run the app's `test:e2e` script. +{{/if}} +{{else if (eq testing "jest")}} +Unit tests use **Jest**. Add `*.test.ts` files next to the code under test and run `{{packageManager}} run test`. +{{else if (eq testing "playwright")}} +End-to-end tests use **Playwright**. Add specs under the Playwright `tests` directory and run the app's `test:e2e` script. +{{else if (and (ne elixirTesting "none") (ne ecosystem "typescript"))}} +Tests use **{{elixirTesting}}** — add test modules under `test/` and run `mix test`. +{{else if (and (ne goTesting "none") (eq ecosystem "go"))}} +Go tests use the standard `testing` package (plus configured helpers). Add `*_test.go` files next to the code and run `go test ./...`. +{{else if (and (ne pythonTesting "none") (eq ecosystem "python"))}} +Python tests use the configured runner (e.g. pytest). Add `test_*.py` files and run the project's test command. +{{else}} +No test runner was scaffolded for this project. Add one with the Better Fullstack CLI (e.g. `--testing vitest`) before writing tests, or wire a runner manually. +{{/if}} + +## Fallbacks + If a command is missing from the root package, inspect the relevant `apps/*` or `packages/*` package before inventing a new workflow. From e3f3ae1614b70d4d014f8568f0ed6112d6e3d568 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 18:27:16 +0300 Subject: [PATCH 05/14] feat(cli): capability-pack registry (bfs registry add/list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a capability-pack system: a Zod manifest schema (packages/types/src/registry.ts: name/version/files/deps/env/addons) + 'bfs registry add ' installing a pack from a local path or file:// URL into an existing project (renders template files via processTemplateString, merges deps into the right package.json, appends env to .env.example, writes a .better-fullstack/registry.json lockfile + records it in bts.jsonc), plus 'bfs registry list'. --dry-run + clear validation errors on bad manifests. Local/file:// only (no network fetch, no monetization) — the installable foundation for community/private packs. Verified: types registry 7/0, cli registry 7/0, template-snapshots 95/0, tsc + oxfmt + oxlint clean; manual smoke installed a real pack (file+dep+env+lockfile), list/dry-run/invalid-reject all correct. Closes task #19 core (local/file:// packs; remote fetch is an additive extension). --- apps/cli/src/commands/registry.ts | 104 +++++ apps/cli/src/helpers/core/registry-handler.ts | 392 ++++++++++++++++++ apps/cli/src/run.ts | 54 +++ apps/cli/src/utils/registry-bts.ts | 59 +++ .../registry/invalid-pack/registry.json | 9 + .../registry/project/apps/server/.env.example | 1 + .../registry/project/apps/server/package.json | 8 + .../test/fixtures/registry/project/bts.jsonc | 218 ++++++++++ .../fixtures/registry/project/package.json | 6 + .../registry/sample-pack/registry.json | 38 ++ apps/cli/test/registry.test.ts | 169 ++++++++ packages/types/src/index.ts | 1 + packages/types/src/registry.ts | 147 +++++++ packages/types/test/registry.test.ts | 86 ++++ 14 files changed, 1292 insertions(+) create mode 100644 apps/cli/src/commands/registry.ts create mode 100644 apps/cli/src/helpers/core/registry-handler.ts create mode 100644 apps/cli/src/utils/registry-bts.ts create mode 100644 apps/cli/test/fixtures/registry/invalid-pack/registry.json create mode 100644 apps/cli/test/fixtures/registry/project/apps/server/.env.example create mode 100644 apps/cli/test/fixtures/registry/project/apps/server/package.json create mode 100644 apps/cli/test/fixtures/registry/project/bts.jsonc create mode 100644 apps/cli/test/fixtures/registry/project/package.json create mode 100644 apps/cli/test/fixtures/registry/sample-pack/registry.json create mode 100644 apps/cli/test/registry.test.ts create mode 100644 packages/types/src/registry.ts create mode 100644 packages/types/test/registry.test.ts diff --git a/apps/cli/src/commands/registry.ts b/apps/cli/src/commands/registry.ts new file mode 100644 index 000000000..575921b8d --- /dev/null +++ b/apps/cli/src/commands/registry.ts @@ -0,0 +1,104 @@ +import { intro, log, outro } from "@clack/prompts"; +import path from "node:path"; +import pc from "picocolors"; + +import type { RegistryAddResult } from "../helpers/core/registry-handler"; + +import { addPack, listInstalledPacks } from "../helpers/core/registry-handler"; +import { CLIError } from "../utils/errors"; +import { renderTitle } from "../utils/render-title"; + +export type RegistryAction = "add" | "list"; + +export type RegistryCommandInput = { + action: RegistryAction; + source?: string; + projectDir?: string; + json?: boolean; + dryRun?: boolean; +}; + +function formatCount(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function reportAddResult(result: RegistryAddResult): void { + const label = result.dryRun ? "[dry run] Would install" : "Installed"; + log.info(pc.cyan(`${label} ${result.pack.name}@${result.pack.version}`)); + + const fileLabel = result.dryRun ? "would write" : "wrote"; + log.message( + pc.dim( + `Files: ${fileLabel} ${formatCount(result.filesWritten.length, "file")}` + + (result.filesSkipped.length > 0 + ? `, skipped ${formatCount(result.filesSkipped.length, "existing file")}` + : ""), + ), + ); + for (const file of result.filesWritten) log.message(pc.dim(` + ${file}`)); + for (const file of result.filesSkipped) + log.message(pc.dim(` = ${file} (exists, not overwritten)`)); + + if (result.dependencies.length > 0) { + log.message(pc.dim(`Dependencies: ${formatCount(result.dependencies.length, "change")}`)); + for (const dep of result.dependencies) { + log.message(pc.dim(` + ${dep.dir}: ${dep.name}@${dep.version}${dep.dev ? " (dev)" : ""}`)); + } + } + + if (result.envKeys.length > 0 && result.envFile) { + log.message(pc.dim(`Env: ${formatCount(result.envKeys.length, "var")} -> ${result.envFile}`)); + for (const key of result.envKeys) log.message(pc.dim(` + ${key}`)); + } +} + +export async function registryHandler(input: RegistryCommandInput): Promise { + const projectDir = path.resolve(input.projectDir || process.cwd()); + + if (input.action === "list") { + const packs = await listInstalledPacks(projectDir); + if (input.json) { + console.log(JSON.stringify(packs, null, 2)); + return; + } + + renderTitle(); + intro(pc.magenta("Installed capability packs")); + if (packs.length === 0) { + log.info(pc.dim("No capability packs installed. Add one with `registry add `.")); + outro(pc.magenta("Nothing installed.")); + return; + } + for (const pack of packs) { + log.message( + `${pc.bold(pack.name)}@${pack.version} ${pc.dim( + `(${formatCount(pack.files.length, "file")}, from ${pack.source})`, + )}`, + ); + } + outro(pc.magenta(`${formatCount(packs.length, "pack")} installed.`)); + return; + } + + // action === "add" + if (!input.source) { + throw new CLIError( + "registry add requires a (a local path or file:// URL to a capability pack).", + ); + } + + const dryRun = input.dryRun ?? false; + const result = await addPack({ projectDir, source: input.source, dryRun }); + + if (input.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + renderTitle(); + intro(pc.magenta(dryRun ? "Preview capability pack install" : "Install capability pack")); + reportAddResult(result); + outro( + pc.magenta(dryRun ? "Dry run complete. No files were written." : "Capability pack installed."), + ); +} diff --git a/apps/cli/src/helpers/core/registry-handler.ts b/apps/cli/src/helpers/core/registry-handler.ts new file mode 100644 index 000000000..436b987df --- /dev/null +++ b/apps/cli/src/helpers/core/registry-handler.ts @@ -0,0 +1,392 @@ +import type { z } from "zod"; + +import { processTemplateString, VirtualFileSystem } from "@better-fullstack/template-generator"; +import { writeTreeToFilesystem } from "@better-fullstack/template-generator/fs-writer"; +import fs from "fs-extra"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { + CapabilityPackManifest, + InstalledPack, + ProjectConfig, + RegistryLock, +} from "../../types"; + +import { + CapabilityPackManifestSchema, + REGISTRY_LOCK_VERSION, + RegistryLockSchema, +} from "../../types"; +import { readBtsConfig } from "../../utils/bts-config"; +import { CLIError } from "../../utils/errors"; +import { recordPackInBtsConfig } from "../../utils/registry-bts"; + +const LOCK_DIR = ".better-fullstack"; +const LOCK_FILE = "registry.json"; +const MANIFEST_FILE = "registry.json"; +/** Preference order for the .env.example a pack's env vars are appended to. */ +const ENV_EXAMPLE_CANDIDATES = ["apps/server/.env.example", ".env.example"] as const; + +export interface RegistryAddOptions { + projectDir: string; + source: string; + dryRun?: boolean; +} + +export interface RegistryDependencyChange { + dir: string; + name: string; + version: string; + dev: boolean; +} + +export interface RegistryAddResult { + pack: { name: string; version: string }; + source: string; + filesWritten: string[]; + filesSkipped: string[]; + dependencies: RegistryDependencyChange[]; + envKeys: string[]; + envFile?: string; + dryRun: boolean; +} + +interface ResolvedPackSource { + manifestPath: string; + packDir: string; + /** Normalized source string persisted in the lockfile. */ + resolvedSource: string; +} + +/** Formats a ZodError's issues into a single readable message. */ +function formatManifestIssues(error: z.ZodError): string { + return error.issues + .map((issue) => { + const location = issue.path.length > 0 ? issue.path.join(".") : "(root)"; + return ` - ${location}: ${issue.message}`; + }) + .join("\n"); +} + +/** + * Resolves a pack source (local path or file:// URL) to its registry.json. + * The `https` scheme is reserved but intentionally unsupported in the MVP so + * pack installs stay offline and deterministic. + */ +export async function resolvePackSource(source: string): Promise { + if (/^https?:\/\//i.test(source)) { + throw new CLIError( + `Remote pack sources are not yet supported (got '${source}'). Use a local path or a file:// URL.`, + ); + } + + let candidate: string; + if (source.startsWith("file://")) { + candidate = fileURLToPath(source); + } else { + candidate = path.resolve(source); + } + + if (!(await fs.pathExists(candidate))) { + throw new CLIError(`Pack source not found: ${source}`); + } + + const stats = await fs.stat(candidate); + const manifestPath = stats.isDirectory() ? path.join(candidate, MANIFEST_FILE) : candidate; + + if (!(await fs.pathExists(manifestPath))) { + throw new CLIError( + `No ${MANIFEST_FILE} found at ${manifestPath}. A capability pack must ship a ${MANIFEST_FILE} manifest.`, + ); + } + + return { + manifestPath, + packDir: path.dirname(manifestPath), + resolvedSource: manifestPath, + }; +} + +/** Reads and validates a pack manifest, raising a clear CLIError on any failure. */ +export async function loadPackManifest(manifestPath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(manifestPath, "utf-8"); + } catch (error) { + throw new CLIError( + `Failed to read pack manifest ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + let json: unknown; + try { + json = JSON.parse(raw); + } catch (error) { + throw new CLIError( + `Pack manifest ${manifestPath} is not valid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const parsed = CapabilityPackManifestSchema.safeParse(json); + if (!parsed.success) { + throw new CLIError( + `Invalid capability pack manifest (${manifestPath}):\n${formatManifestIssues(parsed.error)}`, + ); + } + + return parsed.data; +} + +function lockPath(projectDir: string): string { + return path.join(projectDir, LOCK_DIR, LOCK_FILE); +} + +/** Reads the per-project registry lockfile, tolerating a missing/empty file. */ +export async function readRegistryLock(projectDir: string): Promise { + const file = lockPath(projectDir); + if (!(await fs.pathExists(file))) { + return { version: REGISTRY_LOCK_VERSION, packs: [] }; + } + try { + const raw = await fs.readFile(file, "utf-8"); + const parsed = RegistryLockSchema.safeParse(JSON.parse(raw)); + if (!parsed.success) { + return { version: REGISTRY_LOCK_VERSION, packs: [] }; + } + return parsed.data; + } catch { + return { version: REGISTRY_LOCK_VERSION, packs: [] }; + } +} + +async function writeRegistryLock(projectDir: string, lock: RegistryLock): Promise { + const file = lockPath(projectDir); + await fs.ensureDir(path.dirname(file)); + await fs.writeFile(file, `${JSON.stringify(lock, null, 2)}\n`, "utf-8"); +} + +/** Lists packs recorded as installed in the per-project lockfile. */ +export async function listInstalledPacks(projectDir: string): Promise { + const lock = await readRegistryLock(projectDir); + return lock.packs; +} + +function parseEnvKeys(content: string): Set { + const keys = new Set(); + for (const line of content.split("\n")) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/); + if (match?.[1]) keys.add(match[1]); + } + return keys; +} + +/** Appends any missing env vars (with optional comment/value) to existing content. */ +function appendEnvVars( + content: string, + vars: CapabilityPackManifest["env"], +): { content: string; keys: string[] } { + const existing = parseEnvKeys(content); + const missing = vars.filter((entry) => !existing.has(entry.key)); + if (missing.length === 0) return { content, keys: [] }; + + const separator = content.trim().length === 0 ? "" : content.endsWith("\n") ? "\n" : "\n\n"; + const block = missing + .map((entry) => { + const comment = entry.description ? `# ${entry.description}\n` : ""; + return `${comment}${entry.key}=${entry.value ?? ""}`; + }) + .join("\n"); + + return { + content: `${content}${separator}${block}\n`, + keys: missing.map((entry) => entry.key), + }; +} + +async function resolveEnvExamplePath(projectDir: string): Promise { + for (const candidate of ENV_EXAMPLE_CANDIDATES) { + if (await fs.pathExists(path.join(projectDir, candidate))) { + return candidate; + } + } + return ".env.example"; +} + +/** + * Merges a pack's dependencies/devDependencies into the target package.json + * files. Pack dependency versions are arbitrary name->version pairs, so they + * are merged directly (they cannot flow through addPackageDependency, which is + * restricted to the AvailableDependencies enum). + */ +async function planDependencyChanges( + projectDir: string, + manifest: CapabilityPackManifest, +): Promise<{ changes: RegistryDependencyChange[]; writes: Map> }> { + const changes: RegistryDependencyChange[] = []; + const writes = new Map>(); + + const groups: Array<{ map: CapabilityPackManifest["dependencies"]; dev: boolean }> = [ + { map: manifest.dependencies, dev: false }, + { map: manifest.devDependencies, dev: true }, + ]; + + for (const { map, dev } of groups) { + if (!map) continue; + for (const [dir, deps] of Object.entries(map)) { + if (Object.keys(deps).length === 0) continue; + const pkgRelPath = path.join(dir === "." ? "" : dir, "package.json"); + const pkgAbsPath = path.join(projectDir, pkgRelPath); + if (!(await fs.pathExists(pkgAbsPath))) { + throw new CLIError( + `Pack targets ${pkgRelPath} which does not exist in this project. Cannot merge dependencies.`, + ); + } + + const pkgJson = + writes.get(pkgAbsPath) ?? ((await fs.readJson(pkgAbsPath)) as Record); + const field = dev ? "devDependencies" : "dependencies"; + const bucket: Record = { + ...(pkgJson[field] as Record | undefined), + }; + for (const [name, version] of Object.entries(deps)) { + bucket[name] = version; + changes.push({ dir, name, version, dev }); + } + pkgJson[field] = bucket; + writes.set(pkgAbsPath, pkgJson); + } + } + + return { changes, writes }; +} + +/** + * Installs a capability pack into an existing Better Fullstack project. + * + * Reuses the same building blocks as the `add` handler: readBtsConfig to assert + * a real project, the template-generator VFS + writeTreeToFilesystem to stage + * and write files, and processTemplateString to render templated files. + */ +export async function addPack(options: RegistryAddOptions): Promise { + const projectDir = path.resolve(options.projectDir); + const dryRun = options.dryRun ?? false; + + const btsConfig = await readBtsConfig(projectDir); + if (!btsConfig) { + throw new CLIError( + `No Better Fullstack project found in ${projectDir}. Make sure bts.jsonc exists.`, + ); + } + + const { manifestPath, resolvedSource } = await resolvePackSource(options.source); + const manifest = await loadPackManifest(manifestPath); + const templateContext = btsConfig as unknown as ProjectConfig; + + // Stage files: decide write-vs-skip up front (respecting the overwrite flag), + // render templated content, and only stage files that should be written. + const vfs = new VirtualFileSystem(); + const filesWritten: string[] = []; + const filesSkipped: string[] = []; + + for (const file of manifest.files) { + const normalized = file.path.replaceAll("\\", "/").replace(/^\.\//, ""); + const targetAbs = path.join(projectDir, normalized); + if (!file.overwrite && (await fs.pathExists(targetAbs))) { + filesSkipped.push(normalized); + continue; + } + const content = file.template + ? processTemplateString(file.content, templateContext) + : file.content; + vfs.writeFile(normalized, content); + filesWritten.push(normalized); + } + + // Plan dependency merges and env additions. + const { changes: dependencies, writes: packageJsonWrites } = await planDependencyChanges( + projectDir, + manifest, + ); + + let envFile: string | undefined; + let envKeys: string[] = []; + let envContent: string | undefined; + if (manifest.env.length > 0) { + envFile = await resolveEnvExamplePath(projectDir); + const envAbs = path.join(projectDir, envFile); + const existing = (await fs.pathExists(envAbs)) ? await fs.readFile(envAbs, "utf-8") : ""; + const merged = appendEnvVars(existing, manifest.env); + envKeys = merged.keys; + if (merged.keys.length > 0) { + envContent = merged.content; + } else { + envFile = undefined; + } + } + + if (dryRun) { + return { + pack: { name: manifest.name, version: manifest.version }, + source: resolvedSource, + filesWritten, + filesSkipped, + dependencies, + envKeys, + envFile, + dryRun: true, + }; + } + + // Write staged pack files. + if (filesWritten.length > 0) { + const tree = { + root: vfs.toTree(manifest.name), + fileCount: vfs.getFileCount(), + directoryCount: vfs.getDirectoryCount(), + config: templateContext, + }; + await writeTreeToFilesystem(tree, projectDir); + } + + // Merge dependencies into their package.json files. + for (const [pkgAbsPath, pkgJson] of packageJsonWrites) { + await fs.writeJson(pkgAbsPath, pkgJson, { spaces: 2 }); + } + + // Append env vars. + if (envFile && envContent !== undefined) { + await fs.writeFile(path.join(projectDir, envFile), envContent, "utf-8"); + } + + // Record the install in the lockfile (dedupe by name for re-install/upgrade). + const lock = await readRegistryLock(projectDir); + const installed: InstalledPack = { + name: manifest.name, + version: manifest.version, + source: resolvedSource, + files: filesWritten, + installedAt: new Date().toISOString(), + }; + lock.version = REGISTRY_LOCK_VERSION; + lock.packs = [...lock.packs.filter((pack) => pack.name !== manifest.name), installed]; + await writeRegistryLock(projectDir, lock); + + // Additively record the pack (and its declared addon metadata) in bts.jsonc. + await recordPackInBtsConfig(projectDir, manifest); + + return { + pack: { name: manifest.name, version: manifest.version }, + source: resolvedSource, + filesWritten, + filesSkipped, + dependencies, + envKeys, + envFile, + dryRun: false, + }; +} diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 32fb28574..07a6be212 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -354,6 +354,44 @@ export const router = os.router({ const { genCommand } = await import("./commands/gen.js"); await genCommand({ kind, name, dir: options.dir, dryRun: options.dryRun }); }), + registry: os + .meta({ + description: + "Manage community/private capability packs for an existing Better Fullstack project (`registry add ` installs a pack from a local path or file:// URL; `registry list` shows installed packs)", + }) + .input( + z.tuple([ + z + .enum(["add", "list"]) + .optional() + .default("list") + .describe("Action to perform: add (install a pack) or list (default)"), + z + .string() + .optional() + .describe("Pack source: a local path or file:// URL (required for `add`)"), + z.object({ + projectDir: z.string().optional().describe("Project directory (defaults to current)"), + json: z.boolean().optional().default(false).describe("Output the result as JSON"), + dryRun: z + .boolean() + .optional() + .default(false) + .describe("Preview the install without writing any files"), + }), + ]), + ) + .handler(async ({ input }) => { + const [action, source, options] = input; + const { registryHandler } = await import("./commands/registry.js"); + await registryHandler({ + action, + source, + projectDir: options.projectDir, + json: options.json, + dryRun: options.dryRun, + }); + }), mcp: os .meta({ description: @@ -536,3 +574,19 @@ export async function check( { skipChecks: options?.skipChecks ?? false, json: options?.json ?? false }, ]); } + +export async function registry( + action: "add" | "list" = "list", + source?: string, + options?: { projectDir?: string; json?: boolean; dryRun?: boolean }, +) { + return caller.registry([ + action, + source, + { + projectDir: options?.projectDir, + json: options?.json ?? false, + dryRun: options?.dryRun ?? false, + }, + ]); +} diff --git a/apps/cli/src/utils/registry-bts.ts b/apps/cli/src/utils/registry-bts.ts new file mode 100644 index 000000000..0975ab864 --- /dev/null +++ b/apps/cli/src/utils/registry-bts.ts @@ -0,0 +1,59 @@ +import fs from "fs-extra"; +import * as JSONC from "jsonc-parser"; +import path from "node:path"; + +import type { CapabilityPackManifest } from "../types"; + +const BTS_CONFIG_FILE = "bts.jsonc"; + +/** + * Additively records an installed capability pack in the project's bts.jsonc + * under a dedicated `capabilityPacks` array (list of "name@version"), plus any + * addon ids the pack declares under `capabilityPackAddons`. + * + * This is intentionally self-contained: it does NOT flow through + * `updateBtsConfig` (whose typed signature is limited to addons/webDeploy/ + * serverDeploy and would rewrite the derived stack graph). It edits the two + * dedicated keys in place with JSONC.modify so comments and formatting survive. + */ +export async function recordPackInBtsConfig( + projectDir: string, + manifest: CapabilityPackManifest, +): Promise { + const configPath = path.join(projectDir, BTS_CONFIG_FILE); + if (!(await fs.pathExists(configPath))) return; + + let content = await fs.readFile(configPath, "utf-8"); + const errors: JSONC.ParseError[] = []; + const parsed = JSONC.parse(content, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as { capabilityPacks?: string[]; capabilityPackAddons?: string[] } | undefined; + if (errors.length > 0 || typeof parsed !== "object" || parsed === null) return; + + const packRef = `${manifest.name}@${manifest.version}`; + const existingPacks = Array.isArray(parsed.capabilityPacks) ? parsed.capabilityPacks : []; + const nextPacks = [ + ...existingPacks.filter((entry) => !entry.startsWith(`${manifest.name}@`)), + packRef, + ]; + + const existingAddons = Array.isArray(parsed.capabilityPackAddons) + ? parsed.capabilityPackAddons + : []; + const nextAddons = [...new Set([...existingAddons, ...(manifest.addons ?? [])])]; + + const formattingOptions = { tabSize: 2, insertSpaces: true, eol: "\n" } as const; + + const packEdit = JSONC.modify(content, ["capabilityPacks"], nextPacks, { formattingOptions }); + content = JSONC.applyEdits(content, packEdit); + + if (nextAddons.length > 0) { + const addonEdit = JSONC.modify(content, ["capabilityPackAddons"], nextAddons, { + formattingOptions, + }); + content = JSONC.applyEdits(content, addonEdit); + } + + await fs.writeFile(configPath, content, "utf-8"); +} diff --git a/apps/cli/test/fixtures/registry/invalid-pack/registry.json b/apps/cli/test/fixtures/registry/invalid-pack/registry.json new file mode 100644 index 000000000..287ebffe8 --- /dev/null +++ b/apps/cli/test/fixtures/registry/invalid-pack/registry.json @@ -0,0 +1,9 @@ +{ + "description": "Missing name and version; file path is not a string.", + "files": [ + { + "path": 123, + "content": "export const broken = true;\n" + } + ] +} diff --git a/apps/cli/test/fixtures/registry/project/apps/server/.env.example b/apps/cli/test/fixtures/registry/project/apps/server/.env.example new file mode 100644 index 000000000..ce8189276 --- /dev/null +++ b/apps/cli/test/fixtures/registry/project/apps/server/.env.example @@ -0,0 +1 @@ +DATABASE_URL= diff --git a/apps/cli/test/fixtures/registry/project/apps/server/package.json b/apps/cli/test/fixtures/registry/project/apps/server/package.json new file mode 100644 index 000000000..6275ae81d --- /dev/null +++ b/apps/cli/test/fixtures/registry/project/apps/server/package.json @@ -0,0 +1,8 @@ +{ + "name": "@registry-fixture/server", + "version": "0.0.0", + "private": true, + "dependencies": { + "hono": "^4.0.0" + } +} diff --git a/apps/cli/test/fixtures/registry/project/bts.jsonc b/apps/cli/test/fixtures/registry/project/bts.jsonc new file mode 100644 index 000000000..0fc3650e1 --- /dev/null +++ b/apps/cli/test/fixtures/registry/project/bts.jsonc @@ -0,0 +1,218 @@ +// Better Fullstack configuration file +// safe to delete +// stackParts is the source of truth; graphSummary/effectiveStack summarize it for humans and tools. +// Top-level option fields are a derived compatibility cache for older integrations. + +{ + "$schema": "https://better-fullstack-web.vercel.app/schema.json", + "version": "2.1.5", + "createdAt": "2026-07-05T14:59:15.759Z", + "graphSummary": "TanStack Router app + typescript hono API + drizzle/SQLite", + "effectiveStack": { + "frontend": "typescript:tanstack-router", + "backend": "typescript:hono", + "database": "universal:sqlite", + "workspaceTooling": "universal:skills", + "backend.orm": "typescript:drizzle", + "backend.api": "typescript:trpc", + "backend.auth": "typescript:better-auth", + "frontend.css": "typescript:tailwind", + "backend.runtime": "typescript:bun", + }, + "ecosystem": "typescript", + "database": "sqlite", + "orm": "drizzle", + "backend": "hono", + "runtime": "bun", + "frontend": ["tanstack-router"], + "addons": ["skills"], + "examples": [], + "auth": "better-auth", + "payments": "none", + "email": "none", + "fileUpload": "none", + "effect": "none", + "ai": "none", + "stateManagement": "none", + "validation": "none", + "forms": "none", + "testing": "vitest", + "packageManager": "bun", + "versionChannel": "stable", + "dbSetup": "none", + "api": "trpc", + "webDeploy": "none", + "serverDeploy": "none", + "cssFramework": "tailwind", + "uiLibrary": "none", + "realtime": "none", + "jobQueue": "none", + "animation": "none", + "logging": "none", + "observability": "none", + "featureFlags": "none", + "analytics": "none", + "mobileNavigation": "none", + "mobileUI": "none", + "mobileStorage": "none", + "mobileTesting": "none", + "mobilePush": "none", + "mobileOTA": "none", + "mobileDeepLinking": "none", + "cms": "none", + "caching": "none", + "rateLimit": "none", + "i18n": "none", + "search": "none", + "vectorDb": "none", + "fileStorage": "none", + "rustWebFramework": "none", + "rustFrontend": "none", + "rustOrm": "none", + "rustApi": "none", + "rustCli": "none", + "rustLibraries": [], + "rustLogging": "none", + "rustErrorHandling": "none", + "rustCaching": "none", + "rustAuth": "none", + "rustRealtime": "none", + "rustMessageQueue": "none", + "rustObservability": "none", + "rustTemplating": "none", + "pythonWebFramework": "none", + "pythonOrm": "none", + "pythonValidation": "none", + "pythonAi": [], + "pythonAuth": "none", + "pythonApi": "none", + "pythonTaskQueue": "none", + "pythonGraphql": "none", + "pythonQuality": "none", + "pythonTesting": [], + "pythonCaching": "none", + "pythonRealtime": "none", + "pythonObservability": "none", + "pythonCli": [], + "goWebFramework": "none", + "goOrm": "none", + "goApi": "none", + "goCli": "none", + "goLogging": "none", + "goAuth": "none", + "goTesting": [], + "goRealtime": "none", + "goMessageQueue": "none", + "goCaching": "none", + "goConfig": "none", + "goObservability": "none", + "javaWebFramework": "none", + "javaBuildTool": "none", + "javaOrm": "none", + "javaAuth": "none", + "javaApi": "none", + "javaLogging": "none", + "javaLibraries": [], + "javaTestingLibraries": [], + "dotnetWebFramework": "none", + "dotnetOrm": "none", + "dotnetAuth": "none", + "dotnetApi": "none", + "dotnetTesting": [], + "dotnetJobQueue": "none", + "dotnetRealtime": "none", + "dotnetObservability": [], + "dotnetValidation": "none", + "dotnetCaching": "none", + "dotnetDeploy": "none", + "elixirWebFramework": "none", + "elixirOrm": "none", + "elixirAuth": "none", + "elixirApi": "none", + "elixirRealtime": "none", + "elixirJobs": "none", + "elixirValidation": "none", + "elixirHttp": "none", + "elixirJson": "none", + "elixirEmail": "none", + "elixirCaching": "none", + "elixirObservability": "none", + "elixirTesting": "none", + "elixirQuality": "none", + "elixirDeploy": "none", + "elixirLibraries": [], + "aiDocs": ["claude-md"], + "stackParts": [ + { + "id": "frontend:typescript:tanstack-router", + "role": "frontend", + "toolId": "tanstack-router", + "ecosystem": "typescript", + "source": "legacy", + "targetPath": "apps/web", + }, + { + "id": "backend:typescript:hono", + "role": "backend", + "toolId": "hono", + "ecosystem": "typescript", + "source": "legacy", + "targetPath": "apps/server", + }, + { + "id": "database:universal:sqlite", + "role": "database", + "toolId": "sqlite", + "ecosystem": "universal", + "source": "legacy", + "targetPath": "packages/db", + }, + { + "id": "workspacetooling:universal:skills", + "role": "workspaceTooling", + "toolId": "skills", + "ecosystem": "universal", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.orm:typescript:drizzle", + "role": "orm", + "toolId": "drizzle", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.api:typescript:trpc", + "role": "api", + "toolId": "trpc", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.auth:typescript:better-auth", + "role": "auth", + "toolId": "better-auth", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + { + "id": "frontend:typescript:tanstack-router.css:typescript:tailwind", + "role": "css", + "toolId": "tailwind", + "ecosystem": "typescript", + "ownerPartId": "frontend:typescript:tanstack-router", + "source": "legacy", + }, + { + "id": "backend:typescript:hono.runtime:typescript:bun", + "role": "runtime", + "toolId": "bun", + "ecosystem": "typescript", + "ownerPartId": "backend:typescript:hono", + "source": "legacy", + }, + ], +} diff --git a/apps/cli/test/fixtures/registry/project/package.json b/apps/cli/test/fixtures/registry/project/package.json new file mode 100644 index 000000000..128a7709e --- /dev/null +++ b/apps/cli/test/fixtures/registry/project/package.json @@ -0,0 +1,6 @@ +{ + "name": "registry-fixture", + "version": "0.0.0", + "private": true, + "workspaces": ["apps/*"] +} diff --git a/apps/cli/test/fixtures/registry/sample-pack/registry.json b/apps/cli/test/fixtures/registry/sample-pack/registry.json new file mode 100644 index 000000000..6113c9cb7 --- /dev/null +++ b/apps/cli/test/fixtures/registry/sample-pack/registry.json @@ -0,0 +1,38 @@ +{ + "name": "@acme/rate-limit", + "version": "1.0.0", + "description": "Tiny sample capability pack: a rate-limit helper.", + "source": "local", + "files": [ + { + "path": "apps/server/src/lib/rate-limit.ts", + "content": "export function rateLimitKey(ip: string): string {\n return `rate-limit:${ip}`;\n}\n" + }, + { + "path": "apps/server/src/lib/rate-limit-meta.ts", + "template": true, + "content": "export const RATE_LIMIT_ECOSYSTEM = \"{{ecosystem}}\";\n" + } + ], + "dependencies": { + "apps/server": { + "@acme/token-bucket": "^1.0.0" + } + }, + "devDependencies": { + "apps/server": { + "@types/acme-token-bucket": "^1.0.0" + } + }, + "env": [ + { + "key": "RATE_LIMIT_MAX", + "value": "100", + "description": "Max requests per window" + } + ], + "addons": ["rate-limit"], + "compatibility": { + "ecosystems": ["typescript"] + } +} diff --git a/apps/cli/test/registry.test.ts b/apps/cli/test/registry.test.ts new file mode 100644 index 000000000..81d991154 --- /dev/null +++ b/apps/cli/test/registry.test.ts @@ -0,0 +1,169 @@ +import { afterAll, afterEach, describe, expect, it } from "bun:test"; +import fs from "fs-extra"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { registryHandler } from "../src/commands/registry"; +import { addPack, listInstalledPacks } from "../src/helpers/core/registry-handler"; + +const FIXTURES = join(import.meta.dir, "fixtures", "registry"); +const SAMPLE_PACK = join(FIXTURES, "sample-pack"); +const INVALID_PACK = join(FIXTURES, "invalid-pack"); +const TEMP_ROOTS: string[] = []; + +async function stageProject(): Promise { + const dir = await mkdtemp(join(tmpdir(), "bfs-registry-")); + TEMP_ROOTS.push(dir); + await fs.copy(join(FIXTURES, "project"), dir); + return dir; +} + +function serverFile(dir: string, rel: string): string { + return join(dir, "apps", "server", rel); +} + +async function readServerPackageJson(dir: string): Promise<{ + dependencies?: Record; + devDependencies?: Record; +}> { + return fs.readJson(serverFile(dir, "package.json")); +} + +afterAll(async () => { + await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("registry add", () => { + it("installs a pack: writes files, merges deps, appends env, records the install", async () => { + const dir = await stageProject(); + + const result = await addPack({ projectDir: dir, source: SAMPLE_PACK }); + + expect(result.dryRun).toBe(false); + expect(result.pack).toEqual({ name: "@acme/rate-limit", version: "1.0.0" }); + + // Files written (plain + templated). + expect(result.filesWritten).toContain("apps/server/src/lib/rate-limit.ts"); + expect(result.filesWritten).toContain("apps/server/src/lib/rate-limit-meta.ts"); + const plain = await fs.readFile(serverFile(dir, "src/lib/rate-limit.ts"), "utf-8"); + expect(plain).toContain("export function rateLimitKey(ip: string)"); + const templated = await fs.readFile(serverFile(dir, "src/lib/rate-limit-meta.ts"), "utf-8"); + // {{ecosystem}} rendered from the project's bts.jsonc (typescript). + expect(templated).toContain('export const RATE_LIMIT_ECOSYSTEM = "typescript";'); + + // Dependencies merged into apps/server/package.json (existing dep preserved). + const pkg = await readServerPackageJson(dir); + expect(pkg.dependencies?.["@acme/token-bucket"]).toBe("^1.0.0"); + expect(pkg.dependencies?.hono).toBe("^4.0.0"); + expect(pkg.devDependencies?.["@types/acme-token-bucket"]).toBe("^1.0.0"); + + // Env var appended to apps/server/.env.example (existing var preserved). + const env = await fs.readFile(serverFile(dir, ".env.example"), "utf-8"); + expect(env).toContain("DATABASE_URL="); + expect(env).toContain("RATE_LIMIT_MAX=100"); + expect(env).toContain("# Max requests per window"); + + // Lockfile records the install. + const lock = await fs.readJson(join(dir, ".better-fullstack", "registry.json")); + expect(lock.packs).toHaveLength(1); + expect(lock.packs[0].name).toBe("@acme/rate-limit"); + expect(lock.packs[0].files).toContain("apps/server/src/lib/rate-limit.ts"); + + // bts.jsonc additively records the pack + declared addon metadata. + const bts = await fs.readFile(join(dir, "bts.jsonc"), "utf-8"); + expect(bts).toContain('"@acme/rate-limit@1.0.0"'); + expect(bts).toContain('"rate-limit"'); + }); + + it("is idempotent for env/files and dedupes the lock on re-install", async () => { + const dir = await stageProject(); + await addPack({ projectDir: dir, source: SAMPLE_PACK }); + + // Re-running skips already-present files and does not duplicate env keys. + const second = await addPack({ projectDir: dir, source: SAMPLE_PACK }); + expect(second.filesWritten).toEqual([]); + expect(second.filesSkipped).toContain("apps/server/src/lib/rate-limit.ts"); + + const env = await fs.readFile(serverFile(dir, ".env.example"), "utf-8"); + expect(env.match(/RATE_LIMIT_MAX=/g)?.length).toBe(1); + + const lock = await fs.readJson(join(dir, ".better-fullstack", "registry.json")); + expect(lock.packs).toHaveLength(1); + }); + + it("rejects an invalid manifest with a clear validation error", async () => { + const dir = await stageProject(); + await expect(addPack({ projectDir: dir, source: INVALID_PACK })).rejects.toThrow( + /Invalid capability pack manifest/, + ); + // Nothing was recorded. + expect(await fs.pathExists(join(dir, ".better-fullstack", "registry.json"))).toBe(false); + }); + + it("--dry-run writes nothing", async () => { + const dir = await stageProject(); + const before = await readServerPackageJson(dir); + + const result = await addPack({ projectDir: dir, source: SAMPLE_PACK, dryRun: true }); + expect(result.dryRun).toBe(true); + expect(result.filesWritten).toContain("apps/server/src/lib/rate-limit.ts"); + expect(result.dependencies.map((dep) => dep.name)).toContain("@acme/token-bucket"); + expect(result.envKeys).toContain("RATE_LIMIT_MAX"); + + // No files, deps, env, or lockfile touched. + expect(await fs.pathExists(serverFile(dir, "src/lib/rate-limit.ts"))).toBe(false); + expect(await readServerPackageJson(dir)).toEqual(before); + const env = await fs.readFile(serverFile(dir, ".env.example"), "utf-8"); + expect(env).not.toContain("RATE_LIMIT_MAX"); + expect(await fs.pathExists(join(dir, ".better-fullstack", "registry.json"))).toBe(false); + }); + + it("errors when the project has no bts.jsonc", async () => { + const dir = await mkdtemp(join(tmpdir(), "bfs-registry-empty-")); + TEMP_ROOTS.push(dir); + await expect(addPack({ projectDir: dir, source: SAMPLE_PACK })).rejects.toThrow( + /No Better Fullstack project found/, + ); + }); +}); + +describe("registry list", () => { + const originalLog = console.log; + + afterEach(() => { + console.log = originalLog; + }); + + it("reflects installed packs and prints JSON via the command handler", async () => { + const dir = await stageProject(); + await addPack({ projectDir: dir, source: SAMPLE_PACK }); + + const packs = await listInstalledPacks(dir); + expect(packs).toHaveLength(1); + expect(packs[0]?.name).toBe("@acme/rate-limit"); + + let captured = ""; + console.log = (...args: unknown[]) => { + captured += args.map(String).join(" "); + }; + await registryHandler({ action: "list", projectDir: dir, json: true }); + console.log = originalLog; + + const parsed = JSON.parse(captured) as Array<{ name: string; version: string }>; + expect(parsed).toHaveLength(1); + expect(parsed[0]?.name).toBe("@acme/rate-limit"); + expect(parsed[0]?.version).toBe("1.0.0"); + }); + + it("prints an empty JSON array when nothing is installed", async () => { + const dir = await stageProject(); + let captured = ""; + console.log = (...args: unknown[]) => { + captured += args.map(String).join(" "); + }; + await registryHandler({ action: "list", projectDir: dir, json: true }); + console.log = originalLog; + expect(JSON.parse(captured)).toEqual([]); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2b25363be..956cbcc70 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -8,3 +8,4 @@ export * from "./local-dev"; export * from "./defaults"; export * from "./stack-translation"; export * from "./stack-graph"; +export * from "./registry"; diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts new file mode 100644 index 000000000..dd4fa5038 --- /dev/null +++ b/packages/types/src/registry.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; + +/** + * Capability-pack registry schemas. + * + * A capability pack is a self-contained bundle a community author (or a private + * team) can drop into an existing Better Fullstack project: a `registry.json` + * manifest that declares the files to write, the dependencies to merge, the env + * vars to append, and some advisory metadata. The MVP installer resolves packs + * from a local path or a `file://` URL only; the `https` source kind is + * reserved but not yet wired. + */ + +/** Where a pack was resolved from. MVP wires `local`/`file` only. */ +export const CapabilityPackSourceKindSchema = z + .enum(["local", "file", "https"]) + .describe("Pack source kind: local path, file:// URL, or (reserved) https URL"); +export type CapabilityPackSourceKind = z.infer; + +/** + * A single file contributed by a pack. `content` is written verbatim unless + * `template` is true, in which case it is rendered through the Better Fullstack + * Handlebars pipeline against the target project's config first. + */ +export const CapabilityPackFileSchema = z + .object({ + path: z + .string() + .min(1) + .describe("Project-relative destination path, e.g. apps/server/src/lib/rate-limit.ts"), + content: z.string().describe("File contents (rendered as a template when template=true)"), + template: z + .boolean() + .optional() + .default(false) + .describe("Render `content` through the template pipeline before writing"), + overwrite: z + .boolean() + .optional() + .default(false) + .describe("Overwrite an existing file at `path` instead of skipping it"), + }) + .describe("A file a capability pack writes into the project"); +export type CapabilityPackFile = z.infer; + +/** An environment variable a pack appends to the project's .env.example. */ +export const CapabilityPackEnvSchema = z + .object({ + key: z + .string() + .min(1) + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Env keys must be valid shell identifiers") + .describe("Environment variable name, e.g. RATE_LIMIT_REDIS_URL"), + value: z + .string() + .optional() + .describe("Default value written to .env.example (defaults to empty)"), + description: z + .string() + .optional() + .describe("Human-readable comment written above the variable"), + }) + .describe("An environment variable declared by a capability pack"); +export type CapabilityPackEnv = z.infer; + +/** + * Dependencies keyed by workspace-relative package.json directory ("." for the + * repo root, "apps/server", "packages/api", ...) each mapping a dependency name + * to a literal version string. + */ +export const CapabilityPackDependencyMapSchema = z + .record(z.string(), z.record(z.string(), z.string())) + .describe('Workspace dir -> { "dep-name": "version" }'); +export type CapabilityPackDependencyMap = z.infer; + +/** Advisory compatibility metadata (not enforced by the MVP installer). */ +export const CapabilityPackCompatibilitySchema = z + .object({ + ecosystems: z + .array(z.string()) + .optional() + .describe("Ecosystems this pack is intended for (advisory)"), + requires: z + .array(z.string()) + .optional() + .describe("Capability ids the pack expects to already be present (advisory)"), + conflicts: z + .array(z.string()) + .optional() + .describe("Capability ids the pack is known to conflict with (advisory)"), + }) + .describe("Advisory compatibility metadata for a capability pack"); +export type CapabilityPackCompatibility = z.infer; + +/** The full `registry.json` manifest for a capability pack. */ +export const CapabilityPackManifestSchema = z + .object({ + name: z + .string() + .min(1) + .regex(/^@?[a-z0-9][a-z0-9._@/-]*$/i, "Pack names must be url/npm-safe") + .describe("Unique pack identifier, e.g. @acme/rate-limit"), + version: z.string().min(1).describe("Pack version (semver recommended)"), + description: z.string().optional().describe("Short human-readable summary"), + source: CapabilityPackSourceKindSchema.optional().describe("Origin kind (informational)"), + files: z + .array(CapabilityPackFileSchema) + .default([]) + .describe("Files the pack writes into the project"), + dependencies: CapabilityPackDependencyMapSchema.optional(), + devDependencies: CapabilityPackDependencyMapSchema.optional(), + env: z + .array(CapabilityPackEnvSchema) + .default([]) + .describe("Env vars appended to the project's .env.example"), + addons: z + .array(z.string()) + .optional() + .describe("Addon ids recorded as metadata (not wired into the addons enum)"), + compatibility: CapabilityPackCompatibilitySchema.optional(), + }) + .describe("A community/private capability pack manifest (registry.json)"); +export type CapabilityPackManifest = z.infer; + +/** A record of one installed pack, persisted in the per-project lockfile. */ +export const InstalledPackSchema = z + .object({ + name: z.string().describe("Pack identifier"), + version: z.string().describe("Installed pack version"), + source: z.string().describe("Resolved source string (local path or file:// URL)"), + files: z.array(z.string()).default([]).describe("Project-relative paths written by the pack"), + installedAt: z.string().describe("ISO timestamp of the install"), + }) + .describe("A capability pack recorded as installed in the project lockfile"); +export type InstalledPack = z.infer; + +/** Current schema version of the per-project registry lockfile. */ +export const REGISTRY_LOCK_VERSION = 1; + +/** The per-project lockfile persisted at `.better-fullstack/registry.json`. */ +export const RegistryLockSchema = z + .object({ + version: z.number().int().default(REGISTRY_LOCK_VERSION).describe("Lockfile schema version"), + packs: z.array(InstalledPackSchema).default([]).describe("Installed capability packs"), + }) + .describe("Per-project registry lockfile (.better-fullstack/registry.json)"); +export type RegistryLock = z.infer; diff --git a/packages/types/test/registry.test.ts b/packages/types/test/registry.test.ts new file mode 100644 index 000000000..3f2c0bd03 --- /dev/null +++ b/packages/types/test/registry.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; + +import { + CapabilityPackManifestSchema, + REGISTRY_LOCK_VERSION, + RegistryLockSchema, +} from "../src/registry"; + +describe("CapabilityPackManifestSchema", () => { + it("parses a valid manifest and applies defaults", () => { + const parsed = CapabilityPackManifestSchema.parse({ + name: "@acme/rate-limit", + version: "1.0.0", + description: "Token-bucket rate limiting", + files: [{ path: "apps/server/src/lib/rate-limit.ts", content: "export const x = 1;\n" }], + dependencies: { "apps/server": { "@acme/limiter": "^1.2.0" } }, + env: [{ key: "RATE_LIMIT_MAX", value: "100", description: "Requests per window" }], + addons: ["rate-limit"], + }); + + // File-level defaults are filled in. + expect(parsed.files[0]?.template).toBe(false); + expect(parsed.files[0]?.overwrite).toBe(false); + expect(parsed.dependencies?.["apps/server"]?.["@acme/limiter"]).toBe("^1.2.0"); + expect(parsed.env[0]?.key).toBe("RATE_LIMIT_MAX"); + }); + + it("defaults files/env to empty arrays when omitted", () => { + const parsed = CapabilityPackManifestSchema.parse({ name: "minimal", version: "0.0.1" }); + expect(parsed.files).toEqual([]); + expect(parsed.env).toEqual([]); + }); + + it("rejects a manifest missing required fields", () => { + const result = CapabilityPackManifestSchema.safeParse({ description: "no name/version" }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((issue) => issue.path.join(".")); + expect(paths).toContain("name"); + expect(paths).toContain("version"); + } + }); + + it("rejects a file entry with a non-string path", () => { + const result = CapabilityPackManifestSchema.safeParse({ + name: "bad", + version: "1.0.0", + files: [{ path: 123, content: "x" }], + }); + expect(result.success).toBe(false); + }); + + it("rejects an env key that is not a valid identifier", () => { + const result = CapabilityPackManifestSchema.safeParse({ + name: "bad-env", + version: "1.0.0", + env: [{ key: "not a key" }], + }); + expect(result.success).toBe(false); + }); +}); + +describe("RegistryLockSchema", () => { + it("defaults to the current version with no packs", () => { + const parsed = RegistryLockSchema.parse({}); + expect(parsed.version).toBe(REGISTRY_LOCK_VERSION); + expect(parsed.packs).toEqual([]); + }); + + it("parses a lockfile with installed packs", () => { + const parsed = RegistryLockSchema.parse({ + version: REGISTRY_LOCK_VERSION, + packs: [ + { + name: "@acme/rate-limit", + version: "1.0.0", + source: "/tmp/sample-pack/registry.json", + files: ["apps/server/src/lib/rate-limit.ts"], + installedAt: new Date().toISOString(), + }, + ], + }); + expect(parsed.packs).toHaveLength(1); + expect(parsed.packs[0]?.name).toBe("@acme/rate-limit"); + }); +}); From fc10a974c7d4d503271beebf2a91b94cd1aac331 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 18:50:10 +0300 Subject: [PATCH 06/14] feat(cli): post-scaffold upgrade engine (bfs update) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records a bts.lock.json baseline (path -> sha256) at create time (best-effort, never throws, additive — snapshots stay byte-identical), then 'bfs update [projectDir]' re-renders with current templates and 3-way classifies each file (disk vs baseline vs render): unchanged / drift (safe-patch) / user-edited (left alone) / conflict / manual. --dry-run plan, --apply patches drift + refreshes the baseline, --check gives CI exit codes, --json for tooling. Reuses generateTree + stack-update helpers; README + package.json/.env routed to manual review (structured merge is a documented follow-up). Verified: scaffold-upgrade 8/0, template-snapshots 95 intact, virtual-generator -regressions 163, stack-update 56 (export widening only), tsc+oxfmt+oxlint clean; manual smoke proved the full up-to-date->edit->drift->patch lifecycle + exit codes. Closes task #17 core (drift detection + guided patch apply; structured package.json /env merge deferred). --- apps/cli/src/commands/update.ts | 254 ++++++++++++++ apps/cli/src/helpers/core/create-project.ts | 7 + apps/cli/src/helpers/core/scaffold-upgrade.ts | 330 ++++++++++++++++++ apps/cli/src/helpers/core/stack-update.ts | 8 +- apps/cli/src/index.ts | 1 + apps/cli/src/run.ts | 65 ++++ apps/cli/src/utils/scaffold-manifest.ts | 150 ++++++++ apps/cli/test/scaffold-upgrade.test.ts | 291 +++++++++++++++ 8 files changed, 1102 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/commands/update.ts create mode 100644 apps/cli/src/helpers/core/scaffold-upgrade.ts create mode 100644 apps/cli/src/utils/scaffold-manifest.ts create mode 100644 apps/cli/test/scaffold-upgrade.test.ts diff --git a/apps/cli/src/commands/update.ts b/apps/cli/src/commands/update.ts new file mode 100644 index 000000000..39875a0c2 --- /dev/null +++ b/apps/cli/src/commands/update.ts @@ -0,0 +1,254 @@ +import { intro, log, outro } from "@clack/prompts"; +import path from "node:path"; +import pc from "picocolors"; + +import { + applyScaffoldUpgrade, + planScaffoldUpgrade, + type UpgradePlan, +} from "../helpers/core/scaffold-upgrade"; +import { readBtsConfig } from "../utils/bts-config"; +import { handleError } from "../utils/errors"; +import { renderTitle } from "../utils/render-title"; +import { recordScaffoldManifest } from "../utils/scaffold-manifest"; + +export type UpdateCommandInput = { + projectDir?: string; + dryRun?: boolean; + apply?: boolean; + check?: boolean; + json?: boolean; + recordBaseline?: boolean; +}; + +function formatCount(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function failUpdate(projectDir: string, error: string, json: boolean): never { + if (json) { + console.log(JSON.stringify({ projectDir, ok: false, error }, null, 2)); + // Exit synchronously: trpc-cli calls process.exit(0) after the handler + // resolves, which would otherwise mask the failure for CI gates. + process.exit(1); + } + handleError(error); +} + +function reportGroup(title: string, marker: string, entries: string[]): void { + if (entries.length === 0) return; + log.message(`${title} (${entries.length}):`); + for (const entry of entries) { + log.message(pc.dim(` ${marker} ${entry}`)); + } +} + +function reportManual(entries: UpgradePlan["manual"]): void { + if (entries.length === 0) return; + log.message(`Needs manual review (${entries.length}):`); + for (const entry of entries) { + log.message(pc.dim(` ! ${entry.path}${entry.reason ? ` — ${entry.reason}` : ""}`)); + } +} + +function reportRemoved(plan: UpgradePlan): void { + const removed = plan.files.filter((file) => file.category === "removed"); + if (removed.length === 0) return; + log.message(`Removed by templates (${removed.length}):`); + for (const entry of removed) { + log.message(pc.dim(` - ${entry.path}${entry.reason ? ` — ${entry.reason}` : ""}`)); + } +} + +function renderPlan(plan: UpgradePlan): void { + log.info(pc.dim(`Path: ${plan.projectDir}`)); + log.info( + pc.dim( + plan.hasBaseline + ? `Baseline: bts.lock.json${ + plan.baselineCreatedAt ? ` (recorded ${plan.baselineCreatedAt})` : "" + }` + : "Baseline: none — run `update --record-baseline` to enable safe auto-patching", + ), + ); + log.message(""); + + reportGroup("Template drift (safe to patch)", "~", plan.drift); + reportGroup("New files from templates", "+", plan.newFiles); + reportGroup("Locally edited (kept as-is)", "*", plan.userEdited); + reportGroup("Conflicts (template + local both changed)", "!", plan.conflicts); + reportManual(plan.manual); + reportRemoved(plan); + + log.message(""); + log.message( + pc.dim( + `${plan.unchanged.length} up to date · ${plan.drift.length} drift · ${plan.newFiles.length} new · ` + + `${plan.userEdited.length} local · ${plan.conflicts.length} conflict · ${plan.manual.length} manual`, + ), + ); +} + +function toJsonPlan(plan: UpgradePlan) { + return { + projectDir: plan.projectDir, + hasBaseline: plan.hasBaseline, + baselineCreatedAt: plan.baselineCreatedAt, + summary: { + unchanged: plan.unchanged.length, + drift: plan.drift.length, + newFiles: plan.newFiles.length, + userEdited: plan.userEdited.length, + conflicts: plan.conflicts.length, + manual: plan.manual.length, + removed: plan.removed.length, + }, + drift: plan.drift, + newFiles: plan.newFiles, + userEdited: plan.userEdited, + conflicts: plan.conflicts, + manual: plan.manual, + removed: plan.removed, + actionable: plan.actionable, + }; +} + +export async function updateCommand(input: UpdateCommandInput): Promise { + const projectDir = path.resolve(input.projectDir || process.cwd()); + const json = input.json ?? false; + const apply = input.apply ?? false; + const check = input.check ?? false; + + const btsConfig = await readBtsConfig(projectDir); + if (!btsConfig) { + const message = `No Better Fullstack project found in ${projectDir}. Make sure bts.jsonc exists.`; + if (json) { + console.log(JSON.stringify({ projectDir, ok: false, error: message }, null, 2)); + // Exit synchronously: trpc-cli calls process.exit(0) after the handler + // resolves, which would otherwise mask the failure for CI gates. + process.exit(1); + } + handleError(message); + } + + if (input.recordBaseline) { + const manifest = await recordScaffoldManifest(projectDir); + if (json) { + console.log( + JSON.stringify( + { + projectDir, + ok: manifest !== null, + recordedBaseline: manifest !== null, + files: manifest ? Object.keys(manifest.hashes).length : 0, + }, + null, + 2, + ), + ); + if (!manifest) process.exit(1); + return; + } + renderTitle(); + intro(pc.magenta("Record scaffold baseline")); + if (!manifest) { + handleError(`Failed to record a baseline for ${projectDir}.`); + } + log.success( + pc.green( + `Recorded bts.lock.json with ${formatCount(Object.keys(manifest.hashes).length, "file")}.`, + ), + ); + outro( + pc.magenta("Baseline recorded. Future `bfs update` runs can now auto-patch template drift."), + ); + return; + } + + let plan: UpgradePlan; + let applied: { patched: string[]; added: string[] } | undefined; + if (apply) { + const result = await applyScaffoldUpgrade(projectDir); + if (!result.success) return failUpdate(projectDir, result.error, json); + plan = result; + applied = result.applied; + } else { + const result = await planScaffoldUpgrade(projectDir); + if (!result.success) return failUpdate(projectDir, result.error, json); + plan = result; + applied = undefined; + } + + if (json) { + console.log( + JSON.stringify( + { + ...toJsonPlan(plan), + ok: true, + mode: apply ? "apply" : check ? "check" : "dry-run", + applied, + }, + null, + 2, + ), + ); + if (check && plan.actionable.length > 0) process.exit(1); + return; + } + + renderTitle(); + intro( + pc.magenta( + apply + ? `Updating ${pc.cyan(path.basename(projectDir))} to current templates` + : `Update plan for ${pc.cyan(path.basename(projectDir))}`, + ), + ); + renderPlan(plan); + log.message(""); + + if (applied) { + const total = applied.patched.length + applied.added.length; + if (total === 0) { + log.success(pc.green("Already up to date. No template-drift patches to apply.")); + } else { + log.success( + pc.green( + `Applied ${formatCount(applied.patched.length, "patch")} and added ${formatCount( + applied.added.length, + "file", + )}.`, + ), + ); + } + const leftover = plan.conflicts.length + plan.manual.length; + if (leftover > 0) { + log.warn( + pc.yellow( + `${formatCount(leftover, "file")} still need manual review (conflicts + post-processed files).`, + ), + ); + } + outro(pc.magenta("Update complete.")); + return; + } + + if (plan.actionable.length === 0) { + log.success(pc.green("Up to date with the current templates.")); + } else { + log.info( + pc.cyan( + `Run \`bfs update --apply\` to patch ${formatCount( + plan.drift.length, + "drift file", + )} and add ${formatCount(plan.newFiles.length, "new file")}.`, + ), + ); + } + outro(pc.magenta(apply ? "Update complete." : "Dry run — no files were written.")); + + // CI gate: exit non-zero when there is actionable drift to apply. + if (check && plan.actionable.length > 0) { + process.exit(1); + } +} diff --git a/apps/cli/src/helpers/core/create-project.ts b/apps/cli/src/helpers/core/create-project.ts index 3652a6621..2eb185757 100644 --- a/apps/cli/src/helpers/core/create-project.ts +++ b/apps/cli/src/helpers/core/create-project.ts @@ -11,6 +11,7 @@ import { isSilent } from "../../utils/context"; import { applyDependencyVersionChannel } from "../../utils/dependency-version-channel"; import { exitWithError } from "../../utils/errors"; import { formatProject } from "../../utils/file-formatter"; +import { recordScaffoldManifest } from "../../utils/scaffold-manifest"; import { setupAddons } from "../addons/addons-setup"; import { setupDatabase } from "../core/db-setup"; import { initializeGit } from "./git"; @@ -80,6 +81,12 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj await formatProject(projectDir); + // Record the scaffold baseline (bts.lock.json) from the final formatted, + // pre-install bytes so `bfs update` can later tell template drift apart from + // user edits. Best-effort: recordScaffoldManifest never throws, so a failure + // here disables update auto-patching without breaking scaffolding. + await recordScaffoldManifest(projectDir); + if (!isSilent()) log.success("Project template successfully scaffolded!"); // Skip npm/pnpm/bun install for Rust/Python/Go/Java projects (they use native toolchains) diff --git a/apps/cli/src/helpers/core/scaffold-upgrade.ts b/apps/cli/src/helpers/core/scaffold-upgrade.ts new file mode 100644 index 000000000..d10fd1fe5 --- /dev/null +++ b/apps/cli/src/helpers/core/scaffold-upgrade.ts @@ -0,0 +1,330 @@ +import type { VirtualFileTree } from "@better-fullstack/template-generator"; + +import { writeSelectedFiles } from "@better-fullstack/template-generator/fs-writer"; +import fs from "fs-extra"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { readBtsConfig } from "../../utils/bts-config"; +import { + hashContent, + readScaffoldManifest, + type ScaffoldManifest, + writeScaffoldManifest, +} from "../../utils/scaffold-manifest"; +import { + configFromBtsConfig, + formatGeneratedTree, + generateTree, + treeToFileMap, +} from "./stack-update"; + +const BINARY_FILE_MARKER = "[Binary file]"; + +/** + * Files whose on-disk bytes are mutated by create-time post-processing + * (package-manager version, dependency version channel, db-setup, addons) or by + * dependency install, so their scaffold baseline is not a pure-template render. + * Never auto-patched — always routed to manual review. A structured merge + * (reusing stack-update's mergePackageJson / mergeEnvExample) is a deferred + * follow-up; the MVP is conservative to avoid clobbering post-processed deps. + */ +function isStructuredMergeFile(relPath: string): boolean { + const name = path.basename(relPath); + return ( + name === "package.json" || + name === ".env" || + name.endsWith(".env.example") || + name === "bun.lock" || + name === "bun.lockb" || + name === "package-lock.json" || + name === "pnpm-lock.yaml" || + name === "yarn.lock" + ); +} + +/** + * Generated docs (README) are re-derived from project mode / stack summary at + * render time, so their bytes legitimately differ between the create-time + * render and a later re-render even when untouched. Mirror stack-update's + * isSkippableGeneratedDoc: never auto-patch them, and never flag them as drift. + */ +function isSkippableDoc(relPath: string): boolean { + return path.basename(relPath).toLowerCase() === "readme.md"; +} + +export type UpgradeCategory = + | "unchanged" + | "drift" + | "user-edited" + | "conflict" + | "manual" + | "new-file" + | "removed"; + +export type UpgradeFileEntry = { + path: string; + category: UpgradeCategory; + reason?: string; +}; + +export type UpgradePlan = { + success: true; + projectDir: string; + hasBaseline: boolean; + baselineCreatedAt?: string; + files: UpgradeFileEntry[]; + unchanged: string[]; + drift: string[]; + userEdited: string[]; + conflicts: string[]; + manual: UpgradeFileEntry[]; + newFiles: string[]; + removed: string[]; + /** Files `--apply` would write: drift patches + brand-new template files. */ + actionable: string[]; +}; + +export type UpgradeResult = UpgradePlan | { success: false; projectDir?: string; error: string }; + +export type UpgradeApplyResult = + | (UpgradePlan & { applied: { patched: string[]; added: string[] } }) + | { success: false; projectDir?: string; error: string }; + +async function inferProjectName(projectDir: string): Promise { + const packageJson = await fs.readJson(path.join(projectDir, "package.json")).catch(() => null); + if (packageJson && typeof packageJson.name === "string" && packageJson.name.trim()) { + return packageJson.name.trim(); + } + return path.basename(projectDir); +} + +/** + * Render the project with the current bundled templates and return a + * deterministic path -> sha256 map of the formatted output. Text files hash + * their formatted content directly; binary files are materialized to a temp + * dir (mirroring how create writes them) and hashed from bytes. + */ +async function computeRenderHashes(tree: VirtualFileTree): Promise> { + const fileMap = treeToFileMap(tree); + const hashes = new Map(); + const binaryPaths: string[] = []; + + for (const [filePath, file] of fileMap) { + if (file.content === BINARY_FILE_MARKER) { + binaryPaths.push(filePath); + } else { + hashes.set(filePath, hashContent(Buffer.from(file.content, "utf-8"))); + } + } + + if (binaryPaths.length > 0) { + const binarySet = new Set(binaryPaths); + const tempDir = await fs.mkdtemp(path.join(tmpdir(), "bfs-update-binary-")); + try { + const written = await writeSelectedFiles(tree, tempDir, (candidate) => + binarySet.has(candidate), + ); + for (const filePath of written) { + const bytes = await fs.readFile(path.join(tempDir, filePath)); + hashes.set(filePath, hashContent(bytes)); + } + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + + return hashes; +} + +async function renderCurrentProject( + projectDir: string, +): Promise<{ tree: VirtualFileTree; renderHashes: Map } | { error: string }> { + const btsConfig = await readBtsConfig(projectDir); + if (!btsConfig) { + return { error: `No bts.jsonc found in ${projectDir}. Is this a Better Fullstack project?` }; + } + const projectName = await inferProjectName(projectDir); + const currentConfig = configFromBtsConfig(btsConfig, projectDir, projectName); + try { + const tree = await generateTree(currentConfig); + await formatGeneratedTree(tree); + const renderHashes = await computeRenderHashes(tree); + return { tree, renderHashes }; + } catch (error) { + return { + error: `Failed to render current templates: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } +} + +function summarize( + projectDir: string, + files: UpgradeFileEntry[], + manifest: ScaffoldManifest | null, +): UpgradePlan { + const byCategory = (category: UpgradeCategory) => + files.filter((file) => file.category === category).map((file) => file.path); + const drift = byCategory("drift"); + const newFiles = byCategory("new-file"); + + return { + success: true, + projectDir, + hasBaseline: manifest !== null, + baselineCreatedAt: manifest?.createdAt, + files, + unchanged: byCategory("unchanged"), + drift, + userEdited: byCategory("user-edited"), + conflicts: byCategory("conflict"), + manual: files.filter((file) => file.category === "manual"), + newFiles, + removed: byCategory("removed"), + actionable: [...drift, ...newFiles].sort(), + }; +} + +/** + * Classify every current-template file against the on-disk project and the + * recorded scaffold baseline. Pure read-only planning — writes nothing. + */ +export async function planScaffoldUpgrade(projectDirInput: string): Promise { + const projectDir = path.resolve(projectDirInput); + const rendered = await renderCurrentProject(projectDir); + if ("error" in rendered) { + return { success: false, projectDir, error: rendered.error }; + } + + const { renderHashes } = rendered; + const manifest = await readScaffoldManifest(projectDir); + const baseline = manifest?.hashes ?? {}; + const hasBaseline = manifest !== null; + + const files: UpgradeFileEntry[] = []; + const renderPaths = [...renderHashes.keys()].sort(); + + for (const filePath of renderPaths) { + const renderHash = renderHashes.get(filePath) as string; + const fullPath = path.join(projectDir, filePath); + + if (!(await fs.pathExists(fullPath))) { + files.push({ path: filePath, category: "new-file" }); + continue; + } + + const diskBytes = await fs.readFile(fullPath).catch(() => undefined); + if (!diskBytes) { + files.push({ path: filePath, category: "manual", reason: "unreadable on disk" }); + continue; + } + + const diskHash = hashContent(diskBytes); + if (diskHash === renderHash) { + files.push({ path: filePath, category: "unchanged" }); + continue; + } + + if (isSkippableDoc(filePath)) { + // Regenerated per project mode — not real template drift, never patched. + continue; + } + + if (isStructuredMergeFile(filePath)) { + files.push({ + path: filePath, + category: "manual", + reason: "post-processed file — merge dependencies/env by hand", + }); + continue; + } + + const baselineHash = baseline[filePath]; + if (baselineHash === undefined) { + files.push({ + path: filePath, + category: "manual", + reason: hasBaseline + ? "no baseline recorded for this file" + : "no scaffold baseline — run `update --record-baseline` first", + }); + continue; + } + + if (diskHash === baselineHash) { + // Disk untouched since scaffold, but the template moved -> safe to patch. + files.push({ path: filePath, category: "drift" }); + continue; + } + + if (renderHash === baselineHash) { + // Template unchanged, but the user edited the file -> keep as-is. + files.push({ path: filePath, category: "user-edited" }); + continue; + } + + // Both the template and the local copy diverged from the baseline. + files.push({ + path: filePath, + category: "conflict", + reason: "both the template and your local copy changed", + }); + } + + const renderPathSet = new Set(renderPaths); + const removed: string[] = []; + for (const baselinePath of Object.keys(baseline)) { + if (renderPathSet.has(baselinePath) || isStructuredMergeFile(baselinePath)) continue; + if (await fs.pathExists(path.join(projectDir, baselinePath))) { + removed.push(baselinePath); + } + } + for (const removedPath of removed.sort()) { + files.push({ + path: removedPath, + category: "removed", + reason: "no longer produced by the current templates (not auto-deleted)", + }); + } + + return summarize(projectDir, files, manifest); +} + +/** + * Apply the safe part of the plan: overwrite template-drift files and write + * brand-new template files, then refresh the baseline for every file that now + * matches the current render. Conflicts, local edits, and post-processed files + * are left untouched (and reported by the caller for manual review). + */ +export async function applyScaffoldUpgrade(projectDirInput: string): Promise { + const plan = await planScaffoldUpgrade(projectDirInput); + if (!plan.success) return plan; + + const { projectDir } = plan; + const rendered = await renderCurrentProject(projectDir); + if ("error" in rendered) { + return { success: false, projectDir, error: rendered.error }; + } + + const { tree, renderHashes } = rendered; + const toWrite = new Set([...plan.drift, ...plan.newFiles]); + if (toWrite.size > 0) { + await writeSelectedFiles(tree, projectDir, (candidate) => toWrite.has(candidate)); + } + + const manifest = await readScaffoldManifest(projectDir); + if (manifest) { + // Every file that now equals the current render becomes the new baseline; + // user-edited / conflict / manual files keep their original baseline so a + // later update can still tell they diverged. + for (const filePath of new Set([...plan.unchanged, ...toWrite])) { + const renderHash = renderHashes.get(filePath); + if (renderHash) manifest.hashes[filePath] = renderHash; + } + await writeScaffoldManifest(projectDir, manifest); + } + + return { ...plan, applied: { patched: [...plan.drift], added: [...plan.newFiles] } }; +} diff --git a/apps/cli/src/helpers/core/stack-update.ts b/apps/cli/src/helpers/core/stack-update.ts index 315944dc7..c0599af20 100644 --- a/apps/cli/src/helpers/core/stack-update.ts +++ b/apps/cli/src/helpers/core/stack-update.ts @@ -163,7 +163,7 @@ async function inferProjectName(projectDir: string): Promise { return path.basename(projectDir); } -function configFromBtsConfig( +export function configFromBtsConfig( config: BetterTStackConfig, projectDir: string, projectName: string, @@ -930,7 +930,7 @@ function applyKnownDependencyExpansions( return { config: next, adjustments }; } -async function generateTree(config: ProjectConfig): Promise { +export async function generateTree(config: ProjectConfig): Promise { const result = await generateVirtualProject({ config, templates: EMBEDDED_TEMPLATES }); if (!result.success || !result.tree) { throw new Error(result.error ?? "Failed to generate virtual project"); @@ -938,7 +938,7 @@ async function generateTree(config: ProjectConfig): Promise { return result.tree; } -async function formatGeneratedTree(tree: VirtualFileTree): Promise { +export async function formatGeneratedTree(tree: VirtualFileTree): Promise { const denoConfigDirs = new Set(); function collectDenoConfigDirs(nodes: VirtualNode[]) { @@ -979,7 +979,7 @@ async function formatGeneratedTree(tree: VirtualFileTree): Promise { await formatNodes(tree.root.children); } -function treeToFileMap(tree: VirtualFileTree): Map { +export function treeToFileMap(tree: VirtualFileTree): Map { const files = new Map(); function walk(nodes: VirtualNode[]) { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index c8f70151c..e0aa77ab3 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -13,6 +13,7 @@ export { telemetry, doctor, check, + update, } from "./run"; import type { ProjectConfig } from "./types"; diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 07a6be212..d7d603bd5 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -392,6 +392,49 @@ export const router = os.router({ dryRun: options.dryRun, }); }), + update: os + .meta({ + description: + "Re-apply the current bundled templates to an existing Better Fullstack project, classifying template drift vs. your local edits from the bts.lock.json scaffold baseline. Default is a dry-run plan; `--apply` writes safe drift patches + new files. Distinct from the maintainer `update-deps` command.", + }) + .input( + z.tuple([ + z + .string() + .optional() + .describe("Project directory to update (defaults to current directory)"), + z.object({ + dryRun: z + .boolean() + .optional() + .default(false) + .describe("Preview the plan without writing (default behavior)"), + apply: z + .boolean() + .optional() + .default(false) + .describe("Write safe template-drift patches and new files, refreshing the baseline"), + check: z + .boolean() + .optional() + .default(false) + .describe("Exit non-zero when actionable template drift exists (CI gate)"), + json: z.boolean().optional().default(false).describe("Output the plan as JSON"), + recordBaseline: z + .boolean() + .optional() + .default(false) + .describe( + "Adopt the current on-disk state as the scaffold baseline (for projects created before the update engine)", + ), + }), + ]), + ) + .handler(async ({ input }) => { + const [projectDir, options] = input; + const { updateCommand } = await import("./commands/update.js"); + await updateCommand({ projectDir, ...options }); + }), mcp: os .meta({ description: @@ -590,3 +633,25 @@ export async function registry( }, ]); } + +export async function update( + projectDir?: string, + options?: { + dryRun?: boolean; + apply?: boolean; + check?: boolean; + json?: boolean; + recordBaseline?: boolean; + }, +) { + return caller.update([ + projectDir, + { + dryRun: options?.dryRun ?? false, + apply: options?.apply ?? false, + check: options?.check ?? false, + json: options?.json ?? false, + recordBaseline: options?.recordBaseline ?? false, + }, + ]); +} diff --git a/apps/cli/src/utils/scaffold-manifest.ts b/apps/cli/src/utils/scaffold-manifest.ts new file mode 100644 index 000000000..746ec07fe --- /dev/null +++ b/apps/cli/src/utils/scaffold-manifest.ts @@ -0,0 +1,150 @@ +import type { Dirent } from "node:fs"; + +import fs from "fs-extra"; +import { createHash } from "node:crypto"; +import path from "node:path"; + +/** + * Scaffold baseline manifest ("bts.lock.json"). + * + * Recorded once at create time (after formatting, before install) as a map of + * every on-disk file path -> sha256 of its bytes. `bfs update` later re-renders + * the project with the current templates and uses this baseline to tell apart + * three cases per file: the template moved but the file was never touched (safe + * to patch), the user edited the file (keep as-is), or both changed (conflict). + * Without a recorded baseline that distinction is impossible. + */ + +export const SCAFFOLD_MANIFEST_FILE = "bts.lock.json"; +const MANIFEST_VERSION = "1"; + +/** Directories never worth hashing (dependencies / VCS metadata). */ +const EXCLUDED_DIR_NAMES = new Set(["node_modules", ".git"]); + +/** + * Files whose on-disk bytes are NOT a pure-template render — the manifest + * itself, the config file (regenerated on update), and package-manager / + * toolchain lockfiles that install mutates. Excluding them keeps the baseline + * focused on template-comparable content so `bfs update` never mistakes an + * install artifact for template drift. + */ +const EXCLUDED_FILE_NAMES = new Set([ + SCAFFOLD_MANIFEST_FILE, + "bts.jsonc", + "bun.lock", + "bun.lockb", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "Cargo.lock", + "uv.lock", + "poetry.lock", + "go.sum", + "mix.lock", +]); + +export type ScaffoldManifest = { + version: string; + createdAt: string; + hashes: Record; +}; + +export function hashContent(content: Buffer | string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function toPosixRelative(rootDir: string, fullPath: string): string { + return path.relative(rootDir, fullPath).split(path.sep).join("/"); +} + +async function walkFiles(rootDir: string): Promise { + const results: string[] = []; + + async function walk(dir: string): Promise { + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (EXCLUDED_DIR_NAMES.has(entry.name)) continue; + await walk(fullPath); + } else if (entry.isFile()) { + if (EXCLUDED_FILE_NAMES.has(entry.name)) continue; + results.push(fullPath); + } + } + } + + await walk(rootDir); + return results; +} + +/** Walk the project on disk and return a deterministic path -> sha256 map. */ +export async function computeScaffoldHashes(projectDir: string): Promise> { + const files = await walkFiles(projectDir); + const entries = await Promise.all( + files.map(async (fullPath) => { + const bytes = await fs.readFile(fullPath); + return [toPosixRelative(projectDir, fullPath), hashContent(bytes)] as const; + }), + ); + return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b))); +} + +export async function writeScaffoldManifest( + projectDir: string, + manifest: ScaffoldManifest, +): Promise { + const sorted: ScaffoldManifest = { + version: manifest.version, + createdAt: manifest.createdAt, + hashes: Object.fromEntries( + Object.entries(manifest.hashes).sort(([a], [b]) => a.localeCompare(b)), + ), + }; + const manifestPath = path.join(projectDir, SCAFFOLD_MANIFEST_FILE); + await fs.writeFile(manifestPath, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); +} + +/** + * Record the scaffold baseline manifest for a freshly created project. + * + * Best-effort by design: any failure returns null instead of throwing, so a + * problem here can only disable `bfs update`'s auto-patching — it must never + * break the create path. + */ +export async function recordScaffoldManifest( + projectDir: string, + metadata: { createdAt?: string } = {}, +): Promise { + try { + const manifest: ScaffoldManifest = { + version: MANIFEST_VERSION, + createdAt: metadata.createdAt ?? new Date().toISOString(), + hashes: await computeScaffoldHashes(projectDir), + }; + await writeScaffoldManifest(projectDir, manifest); + return manifest; + } catch { + return null; + } +} + +export async function readScaffoldManifest(projectDir: string): Promise { + try { + const manifestPath = path.join(projectDir, SCAFFOLD_MANIFEST_FILE); + if (!(await fs.pathExists(manifestPath))) return null; + const raw = await fs.readFile(manifestPath, "utf-8"); + const parsed = JSON.parse(raw) as ScaffoldManifest; + if (!parsed || typeof parsed !== "object" || typeof parsed.hashes !== "object") { + return null; + } + return parsed; + } catch { + return null; + } +} diff --git a/apps/cli/test/scaffold-upgrade.test.ts b/apps/cli/test/scaffold-upgrade.test.ts new file mode 100644 index 000000000..6d13f5ad4 --- /dev/null +++ b/apps/cli/test/scaffold-upgrade.test.ts @@ -0,0 +1,291 @@ +import { EMBEDDED_TEMPLATES, generateVirtualProject } from "@better-fullstack/template-generator"; +import { writeTreeToFilesystem } from "@better-fullstack/template-generator/fs-writer"; +import { createCliDefaultProjectConfigBase, type ProjectConfig } from "@better-fullstack/types"; +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { applyScaffoldUpgrade, planScaffoldUpgrade } from "../src/helpers/core/scaffold-upgrade"; +import { buildBtsConfigForPersistence, writeBtsConfig } from "../src/utils/bts-config"; +import { formatProject } from "../src/utils/file-formatter"; +import { + hashContent, + readScaffoldManifest, + recordScaffoldManifest, + SCAFFOLD_MANIFEST_FILE, + writeScaffoldManifest, +} from "../src/utils/scaffold-manifest"; + +const TEMP_ROOTS: string[] = []; + +afterAll(async () => { + await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function makeTempDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), "bfs-upgrade-")); + TEMP_ROOTS.push(dir); + return dir; +} + +function makeConfig(projectDir: string, overrides: Partial = {}): ProjectConfig { + return { + ...createCliDefaultProjectConfigBase(), + projectName: "upgrade-app", + projectDir, + relativePath: ".", + git: false, + install: false, + frontend: ["tanstack-router"], + backend: "hono", + runtime: "bun", + api: "trpc", + database: "sqlite", + orm: "drizzle", + auth: "none", + ...overrides, + } as ProjectConfig; +} + +/** + * Scaffold a project to disk the same way `createProject` does at the point the + * baseline is recorded: write the generated tree, persist bts.jsonc, format on + * disk, then record the scaffold manifest. (Skips install + post-processing, + * which are irrelevant to template-drift classification.) + */ +async function scaffoldWithBaseline(projectDir: string, config: ProjectConfig): Promise { + const persistedConfig = buildBtsConfigForPersistence(config); + const normalizedConfig = { + ...config, + ...persistedConfig, + projectName: config.projectName, + projectDir, + relativePath: ".", + git: false, + install: false, + } as ProjectConfig; + + const result = await generateVirtualProject({ + config: normalizedConfig, + templates: EMBEDDED_TEMPLATES, + }); + if (!result.success || !result.tree) { + throw new Error(result.error ?? "Failed to generate fixture project"); + } + await writeTreeToFilesystem(result.tree, projectDir); + await writeBtsConfig(normalizedConfig, { + version: persistedConfig.version, + createdAt: persistedConfig.createdAt, + }); + await formatProject(projectDir); + await recordScaffoldManifest(projectDir); +} + +function assertSuccess( + result: T, +): asserts result is Extract { + expect(result.success).toBe(true); +} + +/** Pick a plain source file that is safe to treat as pure template content. */ +function pickSourceFile(paths: string[], exclude: string[] = []): string { + const excluded = new Set(exclude); + const candidate = paths.find( + (candidatePath) => + !excluded.has(candidatePath) && + /\.(ts|tsx)$/.test(candidatePath) && + !candidatePath.endsWith(".d.ts") && + !candidatePath.endsWith("package.json"), + ); + if (!candidate) throw new Error("No suitable source file found in the generated project"); + return candidate; +} + +describe("scaffold-upgrade engine", () => { + it("records a baseline manifest at scaffold time with sensible hashes", async () => { + const dir = await makeTempDir(); + await scaffoldWithBaseline(dir, makeConfig(dir)); + + const manifest = await readScaffoldManifest(dir); + expect(manifest).not.toBeNull(); + const hashes = manifest!.hashes; + expect(Object.keys(hashes).length).toBeGreaterThan(10); + // Excluded from its own walk + the config file is not template-comparable. + expect(hashes[SCAFFOLD_MANIFEST_FILE]).toBeUndefined(); + expect(hashes["bts.jsonc"]).toBeUndefined(); + // Every value is a sha256 hex digest. + for (const value of Object.values(hashes)) { + expect(value).toMatch(/^[0-9a-f]{64}$/); + } + }); + + it("reports no drift on an untouched fresh project", async () => { + const dir = await makeTempDir(); + await scaffoldWithBaseline(dir, makeConfig(dir)); + + const plan = await planScaffoldUpgrade(dir); + assertSuccess(plan); + + expect(plan.hasBaseline).toBe(true); + expect(plan.unchanged.length).toBeGreaterThan(10); + expect(plan.drift).toEqual([]); + expect(plan.newFiles).toEqual([]); + expect(plan.userEdited).toEqual([]); + expect(plan.conflicts).toEqual([]); + expect(plan.manual).toEqual([]); + expect(plan.actionable).toEqual([]); + }); + + it("classifies template drift and patches it on apply", async () => { + const dir = await makeTempDir(); + await scaffoldWithBaseline(dir, makeConfig(dir)); + + const baselinePlan = await planScaffoldUpgrade(dir); + assertSuccess(baselinePlan); + const target = pickSourceFile(baselinePlan.unchanged); + const targetPath = join(dir, target); + const originalRender = await readFile(targetPath, "utf-8"); + + // Simulate a template that has since moved: the on-disk file differs from + // the current render, but still matches the recorded baseline (user never + // touched it). Rewrite disk + baseline together so disk == baseline. + const drifted = `// simulated old template output\n${originalRender}`; + await writeFile(targetPath, drifted, "utf-8"); + const manifest = await readScaffoldManifest(dir); + expect(manifest).not.toBeNull(); + manifest!.hashes[target] = hashContent(Buffer.from(drifted, "utf-8")); + await writeScaffoldManifest(dir, manifest!); + + const plan = await planScaffoldUpgrade(dir); + assertSuccess(plan); + expect(plan.drift).toContain(target); + expect(plan.actionable).toContain(target); + expect(plan.userEdited).not.toContain(target); + expect(plan.conflicts).not.toContain(target); + + const applied = await applyScaffoldUpgrade(dir); + assertSuccess(applied); + expect(applied.applied.patched).toContain(target); + + // Disk was patched back to the current render, and the baseline refreshed. + expect(await readFile(targetPath, "utf-8")).toBe(originalRender); + const refreshed = await readScaffoldManifest(dir); + expect(refreshed!.hashes[target]).toBe(hashContent(Buffer.from(originalRender, "utf-8"))); + + // A re-plan now sees the file as up to date. + const rePlan = await planScaffoldUpgrade(dir); + assertSuccess(rePlan); + expect(rePlan.drift).not.toContain(target); + expect(rePlan.unchanged).toContain(target); + }); + + it("classifies a user edit and leaves it untouched on apply", async () => { + const dir = await makeTempDir(); + await scaffoldWithBaseline(dir, makeConfig(dir)); + + const baselinePlan = await planScaffoldUpgrade(dir); + assertSuccess(baselinePlan); + const target = pickSourceFile(baselinePlan.unchanged); + const targetPath = join(dir, target); + + // User edited the file: disk != baseline, while the template (render) still + // matches the baseline. The baseline is left as-is. + const edited = `// my local customization\nexport const CUSTOM = true;\n`; + await writeFile(targetPath, edited, "utf-8"); + + const plan = await planScaffoldUpgrade(dir); + assertSuccess(plan); + expect(plan.userEdited).toContain(target); + expect(plan.drift).not.toContain(target); + expect(plan.actionable).not.toContain(target); + + const applied = await applyScaffoldUpgrade(dir); + assertSuccess(applied); + expect(applied.applied.patched).not.toContain(target); + // Left exactly as the user wrote it. + expect(await readFile(targetPath, "utf-8")).toBe(edited); + }); + + it("flags a conflict when both the template and the local copy changed", async () => { + const dir = await makeTempDir(); + await scaffoldWithBaseline(dir, makeConfig(dir)); + + const baselinePlan = await planScaffoldUpgrade(dir); + assertSuccess(baselinePlan); + const target = pickSourceFile(baselinePlan.unchanged); + const targetPath = join(dir, target); + + // disk != baseline AND render != baseline: point the baseline at a third + // value so neither the disk nor the render matches it. + await writeFile(targetPath, `// local change\n`, "utf-8"); + const manifest = await readScaffoldManifest(dir); + manifest!.hashes[target] = hashContent("something-else-entirely"); + await writeScaffoldManifest(dir, manifest!); + + const plan = await planScaffoldUpgrade(dir); + assertSuccess(plan); + expect(plan.conflicts).toContain(target); + expect(plan.drift).not.toContain(target); + expect(plan.actionable).not.toContain(target); + + // Apply never overwrites a conflict. + const applied = await applyScaffoldUpgrade(dir); + assertSuccess(applied); + expect(applied.applied.patched).not.toContain(target); + expect(await readFile(targetPath, "utf-8")).toBe(`// local change\n`); + }); + + it("always routes an edited package.json to manual review", async () => { + const dir = await makeTempDir(); + await scaffoldWithBaseline(dir, makeConfig(dir)); + + const pkgPath = join(dir, "package.json"); + const pkg = JSON.parse(await readFile(pkgPath, "utf-8")); + pkg.dependencies = { ...pkg.dependencies, "left-pad": "^1.3.0" }; + await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8"); + + const plan = await planScaffoldUpgrade(dir); + assertSuccess(plan); + const manualPaths = plan.manual.map((entry) => entry.path); + expect(manualPaths).toContain("package.json"); + expect(plan.drift).not.toContain("package.json"); + expect(plan.actionable).not.toContain("package.json"); + + // Never auto-written. + const applied = await applyScaffoldUpgrade(dir); + assertSuccess(applied); + expect(applied.applied.patched).not.toContain("package.json"); + expect(await readFile(pkgPath, "utf-8")).toContain("left-pad"); + }); + + it("never treats a generated README as drift, even when it differs from the render", async () => { + const dir = await makeTempDir(); + await scaffoldWithBaseline(dir, makeConfig(dir)); + + const readmePath = join(dir, "README.md"); + const custom = `# custom\nThis README diverges from the current template render.\n`; + await writeFile(readmePath, custom, "utf-8"); + // Make disk == baseline so a normal file would classify as safe drift. + const manifest = await readScaffoldManifest(dir); + manifest!.hashes["README.md"] = hashContent(Buffer.from(custom, "utf-8")); + await writeScaffoldManifest(dir, manifest!); + + const plan = await planScaffoldUpgrade(dir); + assertSuccess(plan); + expect(plan.drift).not.toContain("README.md"); + expect(plan.actionable).not.toContain("README.md"); + expect(plan.files.some((file) => file.path === "README.md")).toBe(false); + + const applied = await applyScaffoldUpgrade(dir); + assertSuccess(applied); + // README is a skippable doc: apply must leave the user's copy intact. + expect(await readFile(readmePath, "utf-8")).toBe(custom); + }); + + it("reports a missing project as an error", async () => { + const dir = await makeTempDir(); + const plan = await planScaffoldUpgrade(dir); + expect(plan.success).toBe(false); + }); +}); From fd5cca7f3db7a8981817a035c698ff7081e2ad48 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 19:13:40 +0300 Subject: [PATCH 07/14] feat(mcp/cli): architecture-change acknowledgment gate + migration checklist Phase-3 additive gate on the stack-update pipeline: RISKY_ARCHITECTURE_KEYS (database/orm/auth/api/backend/runtime). When a plan REPLACES an existing non-none value for one of these (sqlite->postgres, drizzle->prisma, better-auth ->none, bun->workers, ...), the plan is flagged architecture-changing, carries per-swap migrationSteps, and applyStackUpdate refuses unless acknowledged (bfs_apply_stack_update acknowledgeArchitectureChange:true / CLI --acknowledge-architecture-change), distinct from the edited-file blockers. With ack it proceeds and appends a MIGRATION.md checklist (data is NOT auto-migrated). Non-architecture updates are completely unchanged (backward-compatible). Verified: stack-update 61/0 (56 kept + 5 new), tsc + oxlint clean, mcp-graph/ scaffold-upgrade/add-history/registry/gen-resource suites green; manual smoke: sqlite->postgres surfaces the checklist + ack requirement, refuse-without-ack and apply-with-ack both correct. Closes task #18 core (risky-swap gate + migration checklist; automatic data migration intentionally out of scope). --- apps/cli/src/helpers/core/add-handler.ts | 18 +++ apps/cli/src/helpers/core/stack-update.ts | 147 ++++++++++++++++++- apps/cli/src/mcp.ts | 19 ++- apps/cli/src/run.ts | 6 + apps/cli/test/stack-update.test.ts | 171 +++++++++++++++++++++- packages/types/src/schemas.ts | 1 + 6 files changed, 355 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/helpers/core/add-handler.ts b/apps/cli/src/helpers/core/add-handler.ts index 0497ec13f..ce4cf3774 100644 --- a/apps/cli/src/helpers/core/add-handler.ts +++ b/apps/cli/src/helpers/core/add-handler.ts @@ -110,6 +110,24 @@ function logStackUpdateSummary(plan: StackUpdatePlan, dryRun: boolean) { log.info(pc.dim(`Adjusted: ${adjustment}`)); } + if (plan.architectureChanges.length > 0) { + const swaps = plan.architectureChanges + .map((change) => `${change.key}: ${change.from} -> ${change.to}`) + .join(", "); + log.warn(pc.yellow(`Architecture change: ${swaps}`)); + log.info(pc.dim("Data and schema are NOT migrated automatically. Migration checklist:")); + for (const step of plan.migrationSteps) { + log.info(pc.dim(` - ${step}`)); + } + if (dryRun && plan.requiresArchitectureAck) { + log.warn( + pc.yellow( + "Re-run with --acknowledge-architecture-change to apply this architecture change.", + ), + ); + } + } + for (const blocker of plan.manualReviewBlockers) { log.warn(pc.yellow(`Manual review: ${blocker}`)); } diff --git a/apps/cli/src/helpers/core/stack-update.ts b/apps/cli/src/helpers/core/stack-update.ts index c0599af20..1d288bf2d 100644 --- a/apps/cli/src/helpers/core/stack-update.ts +++ b/apps/cli/src/helpers/core/stack-update.ts @@ -47,6 +47,12 @@ type StackUpdateOperation = summary: string[]; }; +export type ArchitectureChange = { + key: string; + from: string; + to: string; +}; + export type StackUpdatePlan = { success: true; projectDir: string; @@ -59,6 +65,9 @@ export type StackUpdatePlan = { scriptChanges: Record; envChanges: Record; manualReviewBlockers: string[]; + architectureChanges: ArchitectureChange[]; + migrationSteps: string[]; + requiresArchitectureAck: boolean; operations: StackUpdateOperation[]; installCommand: string; compatibilityAdjustments: string[]; @@ -114,7 +123,27 @@ export const SUPPORTED_STACK_UPDATE_KEYS = Object.keys(CreateCommandOptionsSchem .sort(); const SUPPORTED_STACK_UPDATE_KEY_SET = new Set(SUPPORTED_STACK_UPDATE_KEYS); -const IGNORED_REQUEST_KEYS = new Set(["projectDir", "projectName", "install", "git"]); +const IGNORED_REQUEST_KEYS = new Set([ + "projectDir", + "projectName", + "install", + "git", + "acknowledgeArchitectureChange", +]); + +// Architecture-defining stack choices. Replacing a non-"none" value for one of +// these (a genuine swap, e.g. sqlite->postgres, drizzle->prisma, bun->workers, +// better-auth->none) requires an explicit acknowledgment because data/schema +// are NOT migrated automatically. Adding a brand-new choice (none->X) stays +// frictionless and is intentionally NOT gated. +const RISKY_ARCHITECTURE_KEYS: Array = [ + "database", + "orm", + "auth", + "api", + "backend", + "runtime", +]; const PACKAGE_JSON_SECTIONS = ["dependencies", "devDependencies", "peerDependencies", "scripts"]; const BINARY_FILE_MARKER = "[Binary file]"; @@ -371,6 +400,99 @@ function asString(value: unknown, fallback = "none"): string { return typeof value === "string" ? value : fallback; } +function computeArchitectureChanges( + currentConfig: ProjectConfig, + proposedConfig: ProjectConfig, +): ArchitectureChange[] { + const changes: ArchitectureChange[] = []; + for (const key of RISKY_ARCHITECTURE_KEYS) { + const from = asString(currentConfig[key]); + const to = asString(proposedConfig[key]); + // Gate only genuine REPLACEMENTS of an existing choice; additive none->X flows stay frictionless. + if (from !== "none" && from !== to) { + changes.push({ key: key as string, from, to }); + } + } + return changes; +} + +function buildMigrationSteps(changes: ArchitectureChange[]): string[] { + const steps: string[] = []; + for (const { key, from, to } of changes) { + const label = `${key} (${from} -> ${to})`; + switch (key) { + case "database": + steps.push( + `${label}: Back up all existing data from the ${from} database before making changes.`, + `${label}: Provision a ${to} database and update DATABASE_URL in .env and .env.example.`, + `${label}: Regenerate the schema for ${to} and create + run an initial migration.`, + `${label}: Export rows from ${from} and import them into ${to} (data is NOT migrated automatically).`, + ); + break; + case "orm": + steps.push( + `${label}: Re-author the database schema/models using ${to} conventions.`, + `${label}: Regenerate the ${to} client and create an initial ${to} migration.`, + `${label}: Port existing ${from} queries and migration history to ${to}, then remove ${from} artifacts.`, + ); + break; + case "auth": + steps.push( + `${label}: Migrate existing user/account records into the ${to} schema.`, + `${label}: Invalidate current sessions and update auth secrets/env vars for ${to}.`, + `${label}: Update sign-in/sign-up flows and protected routes to use ${to}.`, + ); + break; + case "api": + steps.push( + `${label}: Port server routers/handlers from ${from} to ${to}.`, + `${label}: Update client call sites and generated types to the ${to} client.`, + ); + break; + case "backend": + steps.push( + `${label}: Port the server entrypoint, routes, and middleware from ${from} to ${to}.`, + `${label}: Reconcile runtime and deploy configuration for the ${to} server.`, + ); + break; + case "runtime": + steps.push( + `${label}: Update the runtime toolchain, scripts, and deploy target for ${to}.`, + `${label}: Verify runtime-specific APIs and environment bindings behave correctly on ${to}.`, + ); + break; + default: + steps.push(`${label}: Review and migrate affected code manually.`); + } + } + return steps; +} + +async function writeMigrationChecklist(projectDir: string, plan: StackUpdatePlan): Promise { + if (plan.architectureChanges.length === 0 || plan.migrationSteps.length === 0) return; + const migrationPath = path.join(projectDir, "MIGRATION.md"); + const timestamp = new Date().toISOString(); + const swaps = plan.architectureChanges + .map((change) => `\`${change.key}\`: \`${change.from}\` -> \`${change.to}\``) + .join(", "); + const section = [ + `## Architecture change - ${timestamp}`, + "", + `Swapped: ${swaps}`, + "", + "Data and schema are NOT migrated automatically. Complete these steps manually:", + "", + ...plan.migrationSteps.map((step) => `- [ ] ${step}`), + ].join("\n"); + + if (await fs.pathExists(migrationPath)) { + const existing = (await fs.readFile(migrationPath, "utf-8")).trimEnd(); + await fs.writeFile(migrationPath, `${existing}\n\n${section}\n`, "utf-8"); + } else { + await fs.writeFile(migrationPath, `# Migration checklist\n\n${section}\n`, "utf-8"); + } +} + function asStringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } @@ -1557,6 +1679,8 @@ export async function planStackUpdate( }); const graphPreview = getGraphPreview(persistedProposedConfig); + const architectureChanges = computeArchitectureChanges(currentConfig, proposedConfig); + const migrationSteps = buildMigrationSteps(architectureChanges); return { success: true, projectDir, @@ -1569,6 +1693,9 @@ export async function planStackUpdate( scriptChanges, envChanges, manualReviewBlockers, + architectureChanges, + migrationSteps, + requiresArchitectureAck: architectureChanges.length > 0, operations, installCommand: getInstallCommand(normalizedProposedConfig), compatibilityAdjustments, @@ -1590,6 +1717,22 @@ export async function applyStackUpdate( }; } + const acknowledgeArchitectureChange = input.acknowledgeArchitectureChange === true; + if (plan.requiresArchitectureAck && !acknowledgeArchitectureChange) { + const swaps = plan.architectureChanges + .map((change) => `${change.key}: ${change.from} -> ${change.to}`) + .join("; "); + const checklist = plan.migrationSteps.map((step) => ` - ${step}`).join("\n"); + return { + success: false, + projectDir: plan.projectDir, + error: + `This architecture change requires acknowledgment before it can be applied. ` + + `It replaces existing architecture-defining choices (${swaps}); data and schema are NOT migrated automatically. ` + + `Re-run with acknowledgeArchitectureChange: true (MCP) or --acknowledge-architecture-change (CLI) after reviewing the migration checklist:\n${checklist}`, + }; + } + const projectName = await inferProjectName(plan.projectDir); const proposedConfig = configFromBtsConfig(plan.proposedConfig, plan.projectDir, projectName); const proposedTree = await generateTree(proposedConfig); @@ -1621,5 +1764,7 @@ export async function applyStackUpdate( createdAt: plan.proposedConfig.createdAt, }); + await writeMigrationChecklist(plan.projectDir, plan); + return plan; } diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 17c76b761..1d06cc0fb 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -1007,6 +1007,11 @@ const stackUpdateOutputSchema = { scriptChanges: z.record(z.string(), z.array(z.string())).optional(), envChanges: z.record(z.string(), z.array(z.string())).optional(), manualReviewBlockers: z.array(z.string()).optional(), + architectureChanges: z + .array(z.object({ key: z.string(), from: z.string(), to: z.string() })) + .optional(), + migrationSteps: z.array(z.string()).optional(), + requiresArchitectureAck: z.boolean().optional(), compatibilityAdjustments: z.array(z.string()).optional(), compatibilityWarnings: z.array(z.string()).optional(), installCommand: z.string().optional(), @@ -1445,6 +1450,12 @@ export const MCP_PLAN_CREATE_SCHEMA = { export const MCP_STACK_UPDATE_SCHEMA = { ...MCP_PLAN_CREATE_SCHEMA, projectDir: z.string().describe("Absolute path to the existing Better-Fullstack project"), + acknowledgeArchitectureChange: z + .boolean() + .optional() + .describe( + "Acknowledge that this update replaces an existing database/orm/auth/api/backend/runtime choice. Required to apply architecture-changing updates; data and schema are NOT migrated automatically.", + ), }; export async function startMcpServer() { @@ -1972,7 +1983,13 @@ export async function startMcpServer() { message: plan.manualReviewBlockers.length > 0 ? "Plan created, but manual review is required before applying." - : `Plan created. If approved, call bfs_apply_stack_update, then run: ${plan.installCommand}`, + : plan.requiresArchitectureAck + ? `Plan created. This is an architecture change (${plan.architectureChanges + .map((change) => `${change.key}: ${change.from} -> ${change.to}`) + .join( + "; ", + )}); data and schema are NOT migrated automatically. Review migrationSteps, then call bfs_apply_stack_update with acknowledgeArchitectureChange: true, then run: ${plan.installCommand}` + : `Plan created. If approved, call bfs_apply_stack_update, then run: ${plan.installCommand}`, }; return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index d7d603bd5..2fc60860d 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -197,6 +197,12 @@ const AddCommandInputSchema = CreateCommandOptionsSchema.omit({ manualDb: true, }).extend({ projectDir: z.string().optional().describe("Project directory (defaults to current)"), + acknowledgeArchitectureChange: z + .boolean() + .optional() + .describe( + "Acknowledge that this update replaces an existing database/orm/auth/api/backend/runtime choice (data/schema migration required)", + ), }); const ProjectCheckInputSchema = z.tuple([ diff --git a/apps/cli/test/stack-update.test.ts b/apps/cli/test/stack-update.test.ts index 83e381b22..5d79454e3 100644 --- a/apps/cli/test/stack-update.test.ts +++ b/apps/cli/test/stack-update.test.ts @@ -81,6 +81,13 @@ async function expectFileNotContains(path: string, expected: string): Promise { + return readFile(target, "utf-8").then( + () => true, + () => false, + ); +} + afterAll(async () => { await Promise.all(TEMP_ROOTS.map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -263,7 +270,10 @@ describe("stack update planner", () => { .filter((key) => !CREATE_ONLY_KEYS.has(key)) .sort(); const mcpUpdateKeys = Object.keys(MCP_STACK_UPDATE_SCHEMA) - .filter((key) => key !== "projectDir" && key !== "projectName") + .filter( + (key) => + key !== "projectDir" && key !== "projectName" && key !== "acknowledgeArchitectureChange", + ) .sort(); expect(mcpUpdateKeys).toEqual(expectedStackKeys); @@ -775,7 +785,10 @@ describe("stack update planner", () => { expect(plan.stackPartSpecs).toContain("backend.database:typescript:postgres"); expect(plan.stackPartSpecs).not.toContain("database:universal:postgres"); - const result = await applyStackUpdate(projectDir, { database: "postgres" }); + const result = await applyStackUpdate(projectDir, { + database: "postgres", + acknowledgeArchitectureChange: true, + }); expect(result.success).toBe(true); const btsConfig = await readJsonc(join(projectDir, "bts.jsonc")); @@ -1633,7 +1646,10 @@ describe("stack update planner", () => { expect(plan.compatibilityAdjustments).toContain(testCase.expectedAdjustment); expect(plan.manualReviewBlockers).toEqual([]); - const result = await applyStackUpdate(projectDir, { serverDeploy: testCase.serverDeploy }); + const result = await applyStackUpdate(projectDir, { + serverDeploy: testCase.serverDeploy, + acknowledgeArchitectureChange: true, + }); expect(result.success).toBe(true); const btsConfig = await readJsonc(join(projectDir, "bts.jsonc")); @@ -1867,7 +1883,10 @@ describe("stack update planner", () => { ); expect(plan.manualReviewBlockers).toEqual([]); - const result = await applyStackUpdate(projectDir, { examples: ["chat-sdk"] }); + const result = await applyStackUpdate(projectDir, { + examples: ["chat-sdk"], + acknowledgeArchitectureChange: true, + }); expect(result.success).toBe(true); const btsConfig = await readJsonc(join(projectDir, "bts.jsonc")); @@ -1940,7 +1959,10 @@ describe("stack update planner", () => { ); expect(plan.manualReviewBlockers).toEqual([]); - const result = await applyStackUpdate(projectDir, { dbSetup: "d1" }); + const result = await applyStackUpdate(projectDir, { + dbSetup: "d1", + acknowledgeArchitectureChange: true, + }); expect(result.success).toBe(true); const btsConfig = await readJsonc(join(projectDir, "bts.jsonc")); @@ -3414,4 +3436,143 @@ describe("stack update planner", () => { if (result.success) return; expect(result.error).toContain("Manual review required"); }); + + const ARCH_BASE_CONFIG: Partial = { + frontend: ["react-vite"], + backend: "hono", + runtime: "bun", + database: "sqlite", + orm: "drizzle", + api: "none", + auth: "better-auth", + }; + + it("flags database, orm, and auth swaps as architecture changes carrying migration steps", async () => { + const root = await makeTempRoot("bfs-stack-update-arch-flags-"); + const projectDir = join(root, "app"); + await scaffoldGeneratedProject(makeConfig(projectDir, ARCH_BASE_CONFIG)); + + const plan = await planStackUpdate(projectDir, { + database: "postgres", + orm: "prisma", + auth: "none", + }); + expect(plan.success).toBe(true); + if (!plan.success) return; + + expect(plan.requiresArchitectureAck).toBe(true); + expect(plan.architectureChanges).toContainEqual({ + key: "database", + from: "sqlite", + to: "postgres", + }); + expect(plan.architectureChanges).toContainEqual({ + key: "orm", + from: "drizzle", + to: "prisma", + }); + expect(plan.architectureChanges).toContainEqual({ + key: "auth", + from: "better-auth", + to: "none", + }); + expect(plan.migrationSteps.length).toBeGreaterThan(0); + expect( + plan.migrationSteps.some((step) => step.startsWith("database (sqlite -> postgres)")), + ).toBe(true); + expect(plan.migrationSteps.some((step) => step.startsWith("orm (drizzle -> prisma)"))).toBe( + true, + ); + expect(plan.migrationSteps.some((step) => step.startsWith("auth (better-auth -> none)"))).toBe( + true, + ); + }); + + it("refuses to apply an architecture change without acknowledgment and leaves the project untouched", async () => { + const root = await makeTempRoot("bfs-stack-update-arch-refuse-"); + const projectDir = join(root, "app"); + await scaffoldGeneratedProject(makeConfig(projectDir, ARCH_BASE_CONFIG)); + + const before = await readJsonc(join(projectDir, "bts.jsonc")); + + const result = await applyStackUpdate(projectDir, { database: "postgres" }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toContain("architecture change requires acknowledgment"); + expect(result.error).toContain("database: sqlite -> postgres"); + expect(result.error).toContain("acknowledgeArchitectureChange: true"); + + const after = await readJsonc(join(projectDir, "bts.jsonc")); + expect(after.database).toBe("sqlite"); + expect(after).toEqual(before); + expect(await pathExists(join(projectDir, "MIGRATION.md"))).toBe(false); + }); + + it("applies an architecture change with acknowledgment and appends a MIGRATION.md checklist", async () => { + const root = await makeTempRoot("bfs-stack-update-arch-apply-"); + const projectDir = join(root, "app"); + await scaffoldGeneratedProject(makeConfig(projectDir, ARCH_BASE_CONFIG)); + + const result = await applyStackUpdate(projectDir, { + database: "postgres", + acknowledgeArchitectureChange: true, + }); + expect(result.success).toBe(true); + if (!result.success) return; + + const btsConfig = await readJsonc(join(projectDir, "bts.jsonc")); + expect(btsConfig.database).toBe("postgres"); + + const migrationPath = join(projectDir, "MIGRATION.md"); + const migration = await readFile(migrationPath, "utf-8"); + expect(migration).toContain("# Migration checklist"); + expect(migration).toContain("database (sqlite -> postgres)"); + expect(migration).toContain("Data and schema are NOT migrated automatically"); + + // A second architecture change appends (does not overwrite) a new checklist section. + const second = await applyStackUpdate(projectDir, { + orm: "prisma", + acknowledgeArchitectureChange: true, + }); + expect(second.success).toBe(true); + const migrationAfter = await readFile(migrationPath, "utf-8"); + expect(migrationAfter).toContain("database (sqlite -> postgres)"); + expect(migrationAfter).toContain("orm (drizzle -> prisma)"); + expect(migrationAfter.match(/## Architecture change/g)?.length).toBe(2); + }); + + it("does not gate additive addon updates", async () => { + const root = await makeTempRoot("bfs-stack-update-arch-addon-"); + const projectDir = join(root, "app"); + await scaffoldGeneratedProject(makeConfig(projectDir, ARCH_BASE_CONFIG)); + + const plan = await planStackUpdate(projectDir, { addons: ["ultracite"] }); + expect(plan.success).toBe(true); + if (!plan.success) return; + + expect(plan.requiresArchitectureAck).toBe(false); + expect(plan.architectureChanges).toEqual([]); + expect(plan.migrationSteps).toEqual([]); + + const result = await applyStackUpdate(projectDir, { addons: ["ultracite"] }); + expect(result.success).toBe(true); + expect(await pathExists(join(projectDir, "MIGRATION.md"))).toBe(false); + }); + + it("does not gate additive (none -> X) stack additions", async () => { + const root = await makeTempRoot("bfs-stack-update-arch-add-none-"); + const projectDir = join(root, "app"); + await scaffoldGeneratedProject(makeConfig(projectDir, TYPESCRIPT_SERVICE_BASE_CONFIG)); + + const plan = await planStackUpdate(projectDir, { auth: "better-auth" }); + expect(plan.success).toBe(true); + if (!plan.success) return; + + expect(plan.requiresArchitectureAck).toBe(false); + expect(plan.architectureChanges).toEqual([]); + + const result = await applyStackUpdate(projectDir, { auth: "better-auth" }); + expect(result.success).toBe(true); + expect(await pathExists(join(projectDir, "MIGRATION.md"))).toBe(false); + }); }); diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index 98f75b6c4..0b315fa94 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -1045,6 +1045,7 @@ export const AddInputSchema = CreateInputSchema.omit({ manualDb: true, }).extend({ projectDir: z.string().optional(), + acknowledgeArchitectureChange: z.boolean().optional(), }); export const CLIInputSchema = CreateInputSchema.extend({ From 06a5a9f25b80f54a1daeed134030567e863e57ba Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 20:22:55 +0300 Subject: [PATCH 08/14] feat(cli): single-app (non-monorepo) workspace mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New workspaceShape: monorepo | single-app option (default monorepo — every existing combo scaffolds BYTE-IDENTICAL). When single-app is selected AND the stack is a qualifying thin-self app (backend self + one Next.js or TanStack Start web frontend, no sibling capability package), a post-process collapses apps/web + packages/config + packages/env into a flat root app; every non-qualifying stack normalizes back to monorepo so we never emit a broken flat layout. self-nuxt is deferred (different alias convention) and normalizes to monorepo. Post-hoc flatten (generate monorepo, then collapse) guarantees monorepo byte-identity + adds a tree-validation safety net. workspaceShape is create-only. Verified: FULL turbo test 3512/0 (cli 3510, web 271, template-generator 140, types 114), snapshots 95/95 byte-identical, lint 0 errors, tsc clean across all packages. Manual smoke: self-next AND self-tanstack-start single-app scaffolds are genuinely flat and pass bun install + build + tsc; non-qualifying single-app requests fall back to full monorepo. Closes task #16 (single-app for self-next + self-tanstack-start; self-nuxt normalizes to monorepo). --- apps/cli/src/create-command-input.ts | 4 + apps/cli/src/helpers/core/stack-update.ts | 4 + apps/cli/src/index.ts | 1 + apps/cli/src/mcp.ts | 1 + apps/cli/src/prompts/config-prompts.ts | 6 + apps/cli/src/prompts/workspace-shape.ts | 51 +++ apps/cli/src/run.ts | 2 + .../utils/generate-reproducible-command.ts | 3 + apps/cli/test/stack-update.test.ts | 1 + apps/web/src/lib/constant.ts | 18 ++ docs/next-updates-roadmap.md | 7 +- .../template-generator/src/core/virtual-fs.ts | 36 +++ packages/template-generator/src/generator.ts | 15 +- .../src/post-process/flatten-single-app.ts | 306 ++++++++++++++++++ .../src/post-process/index.ts | 5 + .../post-process/flatten-single-app.test.ts | 274 ++++++++++++++++ packages/types/src/compatibility.ts | 78 +++++ packages/types/src/defaults.ts | 1 + packages/types/src/option-metadata.ts | 5 + packages/types/src/schemas.ts | 8 + packages/types/src/stack-translation.ts | 5 + packages/types/src/types.ts | 2 + packages/types/test/workspace-shape.test.ts | 113 +++++++ 23 files changed, 944 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/prompts/workspace-shape.ts create mode 100644 packages/template-generator/src/post-process/flatten-single-app.ts create mode 100644 packages/template-generator/test/post-process/flatten-single-app.test.ts create mode 100644 packages/types/test/workspace-shape.test.ts diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts index c41f9a515..4c6fbe61b 100644 --- a/apps/cli/src/create-command-input.ts +++ b/apps/cli/src/create-command-input.ts @@ -85,6 +85,7 @@ import { ObservabilitySchema, ORMSchema, PackageManagerSchema, + WorkspaceShapeSchema, PaymentsSchema, ProjectNameSchema, PythonAiSchema, @@ -226,6 +227,9 @@ export const CreateCommandOptionsSchema = z.object({ examples: z.array(ExamplesSchema).optional(), git: z.boolean().optional(), packageManager: PackageManagerSchema.optional(), + workspaceShape: WorkspaceShapeSchema.optional().describe( + "Workspace layout: monorepo (default) or single-app (flat root app; only for a thin self app)", + ), install: z.boolean().optional(), versionChannel: VersionChannelSchema.optional().describe( "Dependency version channel (stable, latest, beta)", diff --git a/apps/cli/src/helpers/core/stack-update.ts b/apps/cli/src/helpers/core/stack-update.ts index 1d288bf2d..464a22850 100644 --- a/apps/cli/src/helpers/core/stack-update.ts +++ b/apps/cli/src/helpers/core/stack-update.ts @@ -116,6 +116,9 @@ const NON_STACK_UPDATE_CREATE_KEYS = new Set([ "renderTitle", "disableAnalytics", "manualDb", + // Workspace shape is a create-time structural choice; converting an existing + // project between monorepo and single-app is out of scope for stack updates. + "workspaceShape", ]); export const SUPPORTED_STACK_UPDATE_KEYS = Object.keys(CreateCommandOptionsSchema.shape) @@ -735,6 +738,7 @@ function buildCompatibilityInputFromConfig(config: ProjectConfig): Compatibility documentation, appPlatforms, packageManager: asString(config.packageManager, "bun"), + workspaceShape: asString(config.workspaceShape, "monorepo"), versionChannel: asString(config.versionChannel, "stable"), examples: asStringArray(config.examples), aiSdk: asString(config.ai), diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e0aa77ab3..09110defc 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -87,6 +87,7 @@ export async function createVirtual( effect: options.effect || "none", git: options.git ?? false, packageManager: options.packageManager || "bun", + workspaceShape: options.workspaceShape || "monorepo", versionChannel: options.versionChannel || "stable", install: false, dbSetup: options.dbSetup || "none", diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 1d06cc0fb..cf8514ec9 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -486,6 +486,7 @@ const MCP_COMPATIBILITY_DEFAULTS = { mobilePush: "none", mobileOTA: "none", packageManager: "bun", + workspaceShape: "monorepo", versionChannel: "stable", examples: [], aiSdk: "none", diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 6cd10b161..81518b173 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -81,6 +81,7 @@ import type { Observability, ORM, PackageManager, + WorkspaceShape, Payments, ProjectConfig, RateLimit, @@ -226,6 +227,7 @@ import { navigableGroup } from "./navigable-group"; import { getObservabilityChoice } from "./observability"; import { getORMChoice } from "./orm"; import { getPackageManagerChoice } from "./package-manager"; +import { getWorkspaceShapeChoice } from "./workspace-shape"; import { getPaymentsChoice } from "./payments"; import { getRateLimitChoice } from "./rate-limit"; import { @@ -406,6 +408,7 @@ type PromptGroupResults = { // Keep at end aiDocs: AiDocs[]; git: boolean; + workspaceShape: WorkspaceShape; packageManager: PackageManager; install: boolean; }; @@ -1101,6 +1104,8 @@ export async function gatherConfig( // Keep at end aiDocs: () => getAiDocsChoice(flags.aiDocs), git: () => getGitChoice(flags.git), + workspaceShape: ({ results }) => + getWorkspaceShapeChoice(flags.workspaceShape, results.backend, results.frontend), packageManager: ({ results }) => { // Skip package manager prompt for non-JS ecosystems. if ( @@ -1143,6 +1148,7 @@ export async function gatherConfig( examples: result.examples, git: result.git, packageManager: result.packageManager, + workspaceShape: result.workspaceShape, install: result.install, dbSetup: result.dbSetup, api: result.api, diff --git a/apps/cli/src/prompts/workspace-shape.ts b/apps/cli/src/prompts/workspace-shape.ts new file mode 100644 index 000000000..9d9ea70a6 --- /dev/null +++ b/apps/cli/src/prompts/workspace-shape.ts @@ -0,0 +1,51 @@ +import type { Backend, Frontend, WorkspaceShape } from "../types"; + +import { exitCancelled } from "../utils/errors"; +import { canPromptInteractively } from "../utils/prompt-environment"; +import { isCancel, navigableSelect } from "./navigable"; + +const SINGLE_APP_WEB_FRONTENDS = new Set(["next", "tanstack-start"]); + +/** + * The flat single-app layout only makes sense for a "thin self" app: backend + * `self` with exactly one Next.js or TanStack Start web frontend. For anything + * else we never prompt and resolve to `monorepo` (the compatibility engine and + * generator also normalize non-qualifying stacks back to monorepo). + */ +function stackCanUseSingleApp(backend: Backend | undefined, frontend: Frontend[]): boolean { + if (backend !== "self") return false; + const webFrontends = frontend.filter((f) => f && f !== "none"); + if (webFrontends.length !== 1) return false; + return SINGLE_APP_WEB_FRONTENDS.has(webFrontends[0] as Frontend); +} + +export async function getWorkspaceShapeChoice( + workspaceShape: WorkspaceShape | undefined, + backend: Backend | undefined, + frontend: Frontend[] | undefined, +): Promise { + if (!stackCanUseSingleApp(backend, frontend ?? [])) return "monorepo"; + if (workspaceShape !== undefined) return workspaceShape; + if (!canPromptInteractively()) return "monorepo"; + + const response = await navigableSelect({ + message: "Choose workspace layout", + options: [ + { + value: "monorepo", + label: "Monorepo", + hint: "apps/* + packages/* workspaces (recommended)", + }, + { + value: "single-app", + label: "Single app", + hint: "Flat app at the repo root (no workspaces)", + }, + ], + initialValue: "monorepo", + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 2fc60860d..68cffd3fc 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -195,6 +195,8 @@ const AddCommandInputSchema = CreateCommandOptionsSchema.omit({ renderTitle: true, disableAnalytics: true, manualDb: true, + // Workspace shape is a create-time structural choice, not a stack update. + workspaceShape: true, }).extend({ projectDir: z.string().optional().describe("Project directory (defaults to current)"), acknowledgeArchitectureChange: z diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 95043d5e3..d4369551b 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -35,6 +35,9 @@ function appendCommonFlags(flags: string[], config: ProjectConfig) { flags.push(config.git ? "--git" : "--no-git"); flags.push(`--package-manager ${config.packageManager}`); + if (config.workspaceShape && config.workspaceShape !== "monorepo") { + flags.push(`--workspace-shape ${config.workspaceShape}`); + } if (config.versionChannel !== "stable") { flags.push(`--version-channel ${config.versionChannel}`); } diff --git a/apps/cli/test/stack-update.test.ts b/apps/cli/test/stack-update.test.ts index 5d79454e3..41e7b8b8f 100644 --- a/apps/cli/test/stack-update.test.ts +++ b/apps/cli/test/stack-update.test.ts @@ -107,6 +107,7 @@ const CREATE_ONLY_KEYS = new Set([ "renderTitle", "disableAnalytics", "manualDb", + "workspaceShape", ]); const NON_TS_BASE_CONFIG: Partial = { diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 021e2539d..51581b8a5 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -2199,6 +2199,24 @@ export const TECH_OPTIONS: Record< color: "from-sky-500 to-sky-700", }, ], + workspaceShape: [ + { + id: "monorepo", + name: "Monorepo", + description: "apps/* + packages/* workspaces", + icon: "", + color: "from-slate-500 to-slate-700", + default: true, + }, + { + id: "single-app", + name: "Single App", + description: "Flat root app (no workspaces); thin self apps only", + icon: "", + color: "from-teal-500 to-teal-700", + isNew: true, + }, + ], versionChannel: [ { id: "stable", diff --git a/docs/next-updates-roadmap.md b/docs/next-updates-roadmap.md index 607f51438..aef827b27 100644 --- a/docs/next-updates-roadmap.md +++ b/docs/next-updates-roadmap.md @@ -121,7 +121,12 @@ These are still open after the docs refresh: - Data/storage: Supabase Storage, InstantDB, raw SQL/no-ORM mode. - i18n/browser/desktop: Intlayer, Plasmo, Electrobun. - API/runtime: Effect HTTP. -- Workspace shape: non-monorepo / single-app mode. +- Workspace shape: non-monorepo / single-app mode — MVP landed. `--workspace-shape single-app` + (default `monorepo`) collapses a qualifying "thin self" stack (backend `self` with a single + Next.js or TanStack Start web frontend and no sibling-package capability) into a flat root app; + every other stack normalizes back to `monorepo`. Follow-ups: self-nuxt flat support (needs a + `~/env` alias convention), and a broad flatten that inlines `packages/db`/`packages/auth`/ + `packages/api` and non-self backends. - Mobile depth: Capacitor, TanStack Query mobile offline defaults, Legend State/offline data. - Ecosystem depth: Go stdlib and Bun ORM, Rust Loco/Poem and Kafka, Java Micronaut/gRPC/log4j2, Python search category, Elixir validation/deploy-depth passes. diff --git a/packages/template-generator/src/core/virtual-fs.ts b/packages/template-generator/src/core/virtual-fs.ts index faba1f5ff..e29b4bee6 100644 --- a/packages/template-generator/src/core/virtual-fs.ts +++ b/packages/template-generator/src/core/virtual-fs.ts @@ -51,6 +51,42 @@ export class VirtualFileSystem { return this._files.delete(path); } + /** + * Recursively removes a directory and every file/subdirectory beneath it, + * including the directory entry itself (so it no longer appears in the tree). + */ + removeDir(dirPath: string): void { + const path = this.normalizePath(dirPath); + if (path === "/") return; + const prefix = `${path}/`; + + const filesToDelete: string[] = []; + for (const filePath of this._files.keys()) { + if (filePath === path || filePath.startsWith(prefix)) { + filesToDelete.push(filePath); + } + } + for (const filePath of filesToDelete) { + this._files.delete(filePath); + this._sourcePathMap.delete(filePath); + } + + const dirsToDelete: string[] = []; + for (const dir of this._dirs) { + if (dir === path || dir.startsWith(prefix)) { + dirsToDelete.push(dir); + } + } + for (const dir of dirsToDelete) { + this._dirs.delete(dir); + } + } + + /** Returns the original template source path for a binary file, if tracked. */ + getSourcePath(filePath: string): string | undefined { + return this._sourcePathMap.get(this.normalizePath(filePath)); + } + listDir(dirPath: string): string[] { const dir = this.normalizePath(dirPath) || "/"; const names = new Set(); diff --git a/packages/template-generator/src/generator.ts b/packages/template-generator/src/generator.ts index dcc58806a..ee73ecab4 100644 --- a/packages/template-generator/src/generator.ts +++ b/packages/template-generator/src/generator.ts @@ -8,7 +8,13 @@ import { import type { GeneratorOptions, GeneratorResult, VirtualFileTree } from "./types"; import { VirtualFileSystem } from "./core/virtual-fs"; -import { processCatalogs, processPackageConfigs, updateDbPackageJson } from "./post-process"; +import { + flattenSingleApp, + processCatalogs, + processPackageConfigs, + qualifiesForSingleApp, + updateDbPackageJson, +} from "./post-process"; import { processDatabaseDeps, processDependencies, @@ -320,6 +326,13 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise await processParaglidePlugins(vfs, config); await processPwaPlugins(vfs, config); processCatalogs(vfs, config); + + // Collapse the monorepo layout into a flat single-app repo when the stack + // qualifies (thin self app). Runs last so it operates on the complete, + // valid monorepo tree; a no-op for the default monorepo shape. + if (qualifiesForSingleApp(config)) { + flattenSingleApp(vfs, config); + } } if ( diff --git a/packages/template-generator/src/post-process/flatten-single-app.ts b/packages/template-generator/src/post-process/flatten-single-app.ts new file mode 100644 index 000000000..d6a95cc0c --- /dev/null +++ b/packages/template-generator/src/post-process/flatten-single-app.ts @@ -0,0 +1,306 @@ +/** + * Single-app (flat) workspace post-processor. + * + * Collapses the default monorepo layout (`apps/web` + `packages/config` + + * `packages/env`) into a single flat app at the repository root. This only + * applies to a qualifying "thin self" stack (see `qualifiesForSingleApp`): + * backend `self` with exactly one Next.js or TanStack Start web frontend and no + * capability that would emit a sibling workspace package. For anything else the + * generator keeps the monorepo layout untouched. + * + * The transform runs AFTER the normal monorepo generation (including catalog + * setup), so it operates on a complete, valid monorepo tree. If the tree + * contains any unexpected workspace directory it bails without mutating, + * leaving a valid monorepo — never a broken flat layout. + */ + +import type { ProjectConfig } from "@better-fullstack/types"; + +import yaml from "yaml"; + +import type { VirtualFileSystem } from "../core/virtual-fs"; + +/** Web frameworks whose flat layout is supported (both expose `@/*` -> ./src). */ +const SINGLE_APP_WEB_FRONTENDS = new Set(["next", "tanstack-start"]); + +/** Only these directories may exist under apps/ and packages/ to be flattenable. */ +const ALLOWED_APP_DIRS = new Set(["web"]); +const ALLOWED_PACKAGE_DIRS = new Set(["config", "env"]); + +const SOURCE_FILE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/; + +type PackageJson = { + name?: string; + version?: string; + private?: boolean; + type?: string; + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + exports?: Record; + packageManager?: string; + workspaces?: string[] | { packages?: string[]; catalog?: Record }; + overrides?: Record; + resolutions?: Record; + pnpm?: Record; + [key: string]: unknown; +}; + +export interface WorkspaceLayout { + isFlat: boolean; + webAppDir: string; +} + +/** + * A single-app (flat) layout is only safe for a "thin self" stack that emits no + * sibling workspace package (database/orm, better-auth server, trpc/orpc + * packages/api, payments, email, etc.) and no separate native/server app. Graph + * (`--part`) configs always stay monorepo. + */ +export function qualifiesForSingleApp(config: ProjectConfig): boolean { + if (config.workspaceShape !== "single-app") return false; + if (config.stackParts && config.stackParts.length > 0) return false; + if (config.ecosystem !== "typescript") return false; + if (config.backend !== "self") return false; + + const webFrontends = (config.frontend ?? []).filter((f) => f && f !== "none"); + if (webFrontends.length !== 1) return false; + if (!SINGLE_APP_WEB_FRONTENDS.has(webFrontends[0] as string)) return false; + + const siblingPackageScalars = [ + config.api, + config.database, + config.orm, + config.auth, + config.payments, + config.email, + config.fileUpload, + config.realtime, + config.jobQueue, + config.caching, + config.rateLimit, + config.i18n, + config.search, + config.vectorDb, + config.fileStorage, + config.cms, + config.ai, + config.analytics, + config.featureFlags, + config.observability, + config.logging, + config.webDeploy, + config.serverDeploy, + ]; + if (siblingPackageScalars.some((value) => value && value !== "none")) return false; + + const nonNoneExamples = (config.examples ?? []).filter((e) => e && e !== "none"); + if (nonNoneExamples.length > 0) return false; + + return true; +} + +export function resolveWorkspaceLayout(config: ProjectConfig): WorkspaceLayout { + const isFlat = qualifiesForSingleApp(config); + return { isFlat, webAppDir: isFlat ? "" : "apps/web" }; +} + +/** + * Collapse the monorepo tree into a flat single-app repo. Returns true when the + * tree was flattened, false when it was left untouched (safety bail-out). + */ +export function flattenSingleApp(vfs: VirtualFileSystem, config: ProjectConfig): boolean { + // Safety net: only flatten a tree that contains exactly the expected thin-self + // workspaces. Anything else means a capability slipped through the qualifying + // predicate — leave the complete, valid monorepo rather than emit a broken + // flat layout. + if (vfs.directoryExists("apps")) { + const appDirs = vfs.listDir("apps"); + if (!appDirs.every((dir) => ALLOWED_APP_DIRS.has(dir))) return false; + } + if (vfs.directoryExists("packages")) { + const packageDirs = vfs.listDir("packages"); + if (!packageDirs.every((dir) => ALLOWED_PACKAGE_DIRS.has(dir))) return false; + } + if (!vfs.fileExists("apps/web/package.json")) return false; + + const webPkg = vfs.readJson("apps/web/package.json"); + if (!webPkg) return false; + const rootPkg = vfs.readJson("package.json") ?? {}; + const envPkg = vfs.readJson("packages/env/package.json") ?? undefined; + const catalog = readCatalog(vfs, rootPkg); + + // Only inline the env modules the app actually imports. This avoids dragging + // an unused env/server.ts (which references `process`) into a frontend whose + // tsconfig excludes node types (e.g. TanStack Start uses `vite/client`). + const importedEnvFiles = collectImportedEnvFiles(vfs, config.projectName); + + const flatPkg = buildFlatPackageJson( + config, + rootPkg, + webPkg, + importedEnvFiles.size > 0 ? envPkg : undefined, + catalog, + ); + + // Inline the shared env package into the app's own src/env directory. + inlineEnv(vfs, importedEnvFiles); + + // Relocate the web app to the repository root. + moveDirContents(vfs, "apps/web", ""); + + // Rewrite `@{proj}/env/*` imports to the local path alias now that env lives in src/env. + rewriteEnvImports(vfs, config.projectName); + + // Write the merged flat root package.json (overwrites the moved web package.json). + vfs.writeJson("package.json", flatPkg); + + // Drop the now-empty workspace scaffolding. + vfs.removeDir("apps"); + vfs.removeDir("packages"); + for (const file of ["turbo.json", "pnpm-workspace.yaml", "nx.json"]) { + if (vfs.fileExists(file)) vfs.deleteFile(file); + } + + return true; +} + +function readCatalog(vfs: VirtualFileSystem, rootPkg: PackageJson): Record { + const workspaces = rootPkg.workspaces; + if (workspaces && !Array.isArray(workspaces) && workspaces.catalog) { + return { ...workspaces.catalog }; + } + + const yamlContent = vfs.readFile("pnpm-workspace.yaml"); + if (yamlContent) { + try { + const parsed = yaml.parse(yamlContent) as { catalog?: Record } | undefined; + if (parsed?.catalog) return { ...parsed.catalog }; + } catch { + // Ignore malformed yaml; fall through to empty catalog. + } + } + + return {}; +} + +function resolveDeps( + deps: Record | undefined, + catalog: Record, + projectScope: string, +): Record { + const resolved: Record = {}; + for (const [name, version] of Object.entries(deps ?? {})) { + // Drop the inlined workspace packages (@{proj}/env, @{proj}/config, ...). + if (name.startsWith(projectScope)) continue; + if (version.startsWith("workspace:")) continue; + if (version === "catalog:" || version.startsWith("catalog:")) { + resolved[name] = catalog[name] ?? version; + continue; + } + resolved[name] = version; + } + return resolved; +} + +function buildFlatPackageJson( + config: ProjectConfig, + rootPkg: PackageJson, + webPkg: PackageJson, + envPkg: PackageJson | undefined, + catalog: Record, +): PackageJson { + const projectScope = `@${config.projectName}/`; + + const dependencies = { + ...resolveDeps(webPkg.dependencies, catalog, projectScope), + ...resolveDeps(envPkg?.dependencies, catalog, projectScope), + }; + const devDependencies = resolveDeps(webPkg.devDependencies, catalog, projectScope); + + const flatPkg: PackageJson = { + name: config.projectName, + version: webPkg.version ?? "0.1.0", + private: true, + type: "module", + scripts: { ...webPkg.scripts }, + }; + + if (Object.keys(dependencies).length > 0) flatPkg.dependencies = dependencies; + if (Object.keys(devDependencies).length > 0) flatPkg.devDependencies = devDependencies; + + if (rootPkg.packageManager) flatPkg.packageManager = rootPkg.packageManager; + if (rootPkg.overrides) flatPkg.overrides = rootPkg.overrides; + if (rootPkg.resolutions) flatPkg.resolutions = rootPkg.resolutions; + if (rootPkg.pnpm) flatPkg.pnpm = rootPkg.pnpm; + + return flatPkg; +} + +/** + * Scans the web app's source for `@{proj}/env/` imports and returns the set + * of env source files that are actually referenced (e.g. `web.ts`, `server.ts`). + */ +function collectImportedEnvFiles(vfs: VirtualFileSystem, projectName: string): Set { + const importedFiles = new Set(); + const pattern = new RegExp(`@${escapeRegExp(projectName)}/env/([A-Za-z0-9_-]+)`, "g"); + + for (const filePath of vfs.getAllFiles()) { + if (!filePath.startsWith("apps/web/")) continue; + if (!SOURCE_FILE_PATTERN.test(filePath)) continue; + const content = vfs.readFile(filePath); + if (!content) continue; + for (const match of content.matchAll(pattern)) { + const subpath = match[1]; + if (subpath && vfs.fileExists(`packages/env/src/${subpath}.ts`)) { + importedFiles.add(`${subpath}.ts`); + } + } + } + + return importedFiles; +} + +function inlineEnv(vfs: VirtualFileSystem, filesToInline: Set): void { + for (const relativePath of filesToInline) { + const content = vfs.readFile(`packages/env/src/${relativePath}`); + if (content !== undefined) { + vfs.writeFile(`src/env/${relativePath}`, content); + } + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function moveDirContents(vfs: VirtualFileSystem, fromDir: string, toDir: string): void { + const prefix = `${fromDir}/`; + for (const filePath of vfs.getAllFiles()) { + if (!filePath.startsWith(prefix)) continue; + const relativePath = filePath.slice(prefix.length); + const destPath = toDir ? `${toDir}/${relativePath}` : relativePath; + const content = vfs.readFile(filePath); + if (content === undefined) continue; + vfs.writeFile(destPath, content, vfs.getSourcePath(filePath)); + } +} + +function rewriteEnvImports(vfs: VirtualFileSystem, projectName: string): void { + const needle = `@${projectName}/env/`; + const nextConfigFiles = new Set(["next.config.ts", "next.config.mjs", "next.config.js"]); + + for (const filePath of vfs.getAllFiles()) { + if (!SOURCE_FILE_PATTERN.test(filePath)) continue; + const content = vfs.readFile(filePath); + if (!content || !content.includes(needle)) continue; + + let updated = content.replaceAll(needle, "@/env/"); + // next.config.* is evaluated by Next before the app's path-alias resolution + // kicks in, so it needs a relative import instead of the `@/` alias. + if (nextConfigFiles.has(filePath)) { + updated = updated.replaceAll("@/env/", "./src/env/"); + } + vfs.writeFile(filePath, updated); + } +} diff --git a/packages/template-generator/src/post-process/index.ts b/packages/template-generator/src/post-process/index.ts index a72ba6908..16a603be4 100644 --- a/packages/template-generator/src/post-process/index.ts +++ b/packages/template-generator/src/post-process/index.ts @@ -1,2 +1,7 @@ export { processCatalogs } from "./catalogs"; export { processPackageConfigs, updateDbPackageJson } from "./package-configs"; +export { + flattenSingleApp, + qualifiesForSingleApp, + resolveWorkspaceLayout, +} from "./flatten-single-app"; diff --git a/packages/template-generator/test/post-process/flatten-single-app.test.ts b/packages/template-generator/test/post-process/flatten-single-app.test.ts new file mode 100644 index 000000000..2a86f42d1 --- /dev/null +++ b/packages/template-generator/test/post-process/flatten-single-app.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "bun:test"; + +import { + flattenSingleApp, + qualifiesForSingleApp, +} from "../../src/post-process/flatten-single-app"; +import { VirtualFileSystem } from "../../src/core/virtual-fs"; +import { makeConfig } from "../_fixtures/config-factory"; + +/** + * Builds a minimal but realistic thin-self-app monorepo tree (Next.js + self, + * everything else "none") the way the generator would emit it right before the + * flatten post-process runs: apps/web + packages/config + packages/env, a root + * package.json carrying the bun catalog, and workspace-protocol deps. + */ +function seedThinSelfMonorepo(projectName = "flatapp"): VirtualFileSystem { + const vfs = new VirtualFileSystem(); + + vfs.writeJson("package.json", { + name: projectName, + private: true, + type: "module", + workspaces: { + packages: ["apps/*", "packages/*"], + catalog: { + dotenv: "^17.4.2", + zod: "^4.4.3", + "@types/node": "^26.0.1", + }, + }, + scripts: { dev: "bun run --filter '*' dev" }, + packageManager: "bun@1.3.5", + dependencies: { dotenv: "catalog:", zod: "catalog:", [`@${projectName}/env`]: "workspace:*" }, + devDependencies: { + typescript: "^6.0.3", + "@types/node": "catalog:", + [`@${projectName}/config`]: "workspace:*", + }, + }); + + vfs.writeFile("bunfig.toml", '[install]\nlinker = "isolated"\n'); + vfs.writeFile("tsconfig.json", `{\n "extends": "@${projectName}/config/tsconfig.base.json",\n}\n`); + vfs.writeFile("README.md", "# flatapp\n"); + + vfs.writeJson("apps/web/package.json", { + name: "web", + version: "0.1.0", + private: true, + scripts: { "check-types": "tsc --noEmit", dev: "next dev --port 3001", build: "next build" }, + dependencies: { + next: "^16.2.9", + react: "^19.2.7", + dotenv: "catalog:", + zod: "catalog:", + [`@${projectName}/env`]: "workspace:*", + }, + devDependencies: { + typescript: "^5", + "@types/node": "catalog:", + [`@${projectName}/config`]: "workspace:*", + }, + }); + vfs.writeFile( + "apps/web/next.config.ts", + `import "@${projectName}/env/web";\nimport type { NextConfig } from "next";\n\nexport default {} satisfies NextConfig;\n`, + ); + vfs.writeFile("apps/web/next-env.d.ts", '/// \n'); + vfs.writeFile( + "apps/web/src/app/page.tsx", + `import { env } from "@${projectName}/env/web";\n\nexport default function Home() {\n return

{String(env)}
;\n}\n`, + ); + vfs.writeFile( + "apps/web/tsconfig.json", + '{\n "compilerOptions": { "paths": { "@/*": ["./src/*"] } },\n "include": ["./**/*.ts", "./**/*.tsx"]\n}\n', + ); + vfs.writeFile("apps/web/.gitignore", "/node_modules\n/.next/\n"); + + vfs.writeJson("packages/config/package.json", { name: `@${projectName}/config`, private: true }); + vfs.writeFile("packages/config/tsconfig.base.json", '{\n "compilerOptions": { "strict": true }\n}\n'); + + vfs.writeJson("packages/env/package.json", { + name: `@${projectName}/env`, + version: "0.0.0", + private: true, + type: "module", + exports: { "./server": "./src/server.ts", "./web": "./src/web.ts" }, + dependencies: { + dotenv: "catalog:", + zod: "catalog:", + "@t3-oss/env-nextjs": "^0.13.11", + "@t3-oss/env-core": "^0.13.11", + }, + devDependencies: { + typescript: "^6.0.3", + "@types/node": "catalog:", + [`@${projectName}/config`]: "workspace:*", + }, + }); + vfs.writeFile( + "packages/env/src/web.ts", + 'import { createEnv } from "@t3-oss/env-nextjs";\n\nexport const env = createEnv({ client: {}, runtimeEnv: {}, emptyStringAsUndefined: true });\n', + ); + vfs.writeFile( + "packages/env/src/server.ts", + 'import "dotenv/config";\nimport { createEnv } from "@t3-oss/env-core";\n\nexport const env = createEnv({ server: {}, runtimeEnv: process.env, emptyStringAsUndefined: true });\n', + ); + vfs.writeFile("packages/env/tsconfig.json", `{\n "extends": "@${projectName}/config/tsconfig.base.json",\n}\n`); + + return vfs; +} + +const SINGLE_APP_NEXT = { + projectName: "flatapp", + workspaceShape: "single-app" as const, + backend: "self" as const, + frontend: ["next" as const], + api: "none" as const, + database: "none" as const, + orm: "none" as const, + auth: "none" as const, + cssFramework: "tailwind" as const, + uiLibrary: "none" as const, + addons: [], + examples: [], +}; + +describe("qualifiesForSingleApp", () => { + it("qualifies a thin self-next app with everything else 'none'", () => { + expect(qualifiesForSingleApp(makeConfig(SINGLE_APP_NEXT))).toBe(true); + }); + + it("qualifies a thin self tanstack-start app", () => { + expect( + qualifiesForSingleApp( + makeConfig({ ...SINGLE_APP_NEXT, frontend: ["tanstack-start"] }), + ), + ).toBe(true); + }); + + it("does NOT qualify when workspaceShape is monorepo (the default)", () => { + expect( + qualifiesForSingleApp(makeConfig({ ...SINGLE_APP_NEXT, workspaceShape: "monorepo" })), + ).toBe(false); + }); + + it("does NOT qualify a non-self backend", () => { + expect( + qualifiesForSingleApp( + makeConfig({ ...SINGLE_APP_NEXT, backend: "hono", frontend: ["tanstack-router"] }), + ), + ).toBe(false); + }); + + it("does NOT qualify when a sibling package capability is present (auth/db/api)", () => { + expect( + qualifiesForSingleApp(makeConfig({ ...SINGLE_APP_NEXT, auth: "better-auth" })), + ).toBe(false); + expect( + qualifiesForSingleApp(makeConfig({ ...SINGLE_APP_NEXT, database: "postgres", orm: "drizzle" })), + ).toBe(false); + expect(qualifiesForSingleApp(makeConfig({ ...SINGLE_APP_NEXT, api: "trpc" }))).toBe(false); + }); + + it("does NOT qualify self-nuxt (deferred; different alias convention)", () => { + expect( + qualifiesForSingleApp(makeConfig({ ...SINGLE_APP_NEXT, frontend: ["nuxt"] })), + ).toBe(false); + }); + + it("does NOT qualify when a native frontend is added", () => { + expect( + qualifiesForSingleApp( + makeConfig({ ...SINGLE_APP_NEXT, frontend: ["next", "native-bare"] }), + ), + ).toBe(false); + }); +}); + +describe("flattenSingleApp", () => { + it("collapses the monorepo into a flat root app", () => { + const vfs = seedThinSelfMonorepo("flatapp"); + const flattened = flattenSingleApp(vfs, makeConfig(SINGLE_APP_NEXT)); + + expect(flattened).toBe(true); + + const files = vfs.getAllFiles(); + expect(files.some((f) => f.startsWith("apps/"))).toBe(false); + expect(files.some((f) => f.startsWith("packages/"))).toBe(false); + expect(vfs.directoryExists("apps")).toBe(false); + expect(vfs.directoryExists("packages")).toBe(false); + + // Web app is now at the root. + expect(vfs.fileExists("next.config.ts")).toBe(true); + expect(vfs.fileExists("src/app/page.tsx")).toBe(true); + expect(vfs.fileExists("tsconfig.json")).toBe(true); + + // Only the imported env module is inlined into src/env (web is imported by + // next.config + page; server is not, so it stays out of the flat app). + expect(vfs.fileExists("src/env/web.ts")).toBe(true); + expect(vfs.fileExists("src/env/server.ts")).toBe(false); + }); + + it("rewrites env imports to the local path alias", () => { + const vfs = seedThinSelfMonorepo("flatapp"); + flattenSingleApp(vfs, makeConfig(SINGLE_APP_NEXT)); + + // next.config.* uses a relative import (evaluated before path-alias resolution). + const nextConfig = vfs.readFile("next.config.ts") ?? ""; + expect(nextConfig).toContain('import "./src/env/web"'); + expect(nextConfig).not.toContain("@flatapp/env"); + + // App source uses the @/ alias. + const page = vfs.readFile("src/app/page.tsx") ?? ""; + expect(page).toContain('from "@/env/web"'); + expect(page).not.toContain("@flatapp/env"); + }); + + it("produces a flat root package.json without workspaces or workspace deps", () => { + const vfs = seedThinSelfMonorepo("flatapp"); + flattenSingleApp(vfs, makeConfig(SINGLE_APP_NEXT)); + + const pkg = vfs.readJson<{ + name: string; + workspaces?: unknown; + dependencies?: Record; + devDependencies?: Record; + }>("package.json"); + + expect(pkg?.name).toBe("flatapp"); + expect(pkg?.workspaces).toBeUndefined(); + + const deps = pkg?.dependencies ?? {}; + const devDeps = pkg?.devDependencies ?? {}; + + // Workspace packages are dropped (inlined), not left dangling. + expect(deps["@flatapp/env"]).toBeUndefined(); + expect(devDeps["@flatapp/config"]).toBeUndefined(); + + // The env package's runtime deps are merged in. + expect(deps["@t3-oss/env-nextjs"]).toBe("^0.13.11"); + + // catalog: references are resolved to concrete versions. + expect(deps.dotenv).toBe("^17.4.2"); + expect(deps.zod).toBe("^4.4.3"); + expect(devDeps["@types/node"]).toBe("^26.0.1"); + for (const version of [...Object.values(deps), ...Object.values(devDeps)]) { + expect(version.startsWith("catalog:")).toBe(false); + expect(version.startsWith("workspace:")).toBe(false); + } + }); + + it("removes workspace tooling files", () => { + const vfs = seedThinSelfMonorepo("flatapp"); + vfs.writeJson("turbo.json", { tasks: {} }); + vfs.writeFile("pnpm-workspace.yaml", "packages:\n - apps/*\n"); + + flattenSingleApp(vfs, makeConfig(SINGLE_APP_NEXT)); + + expect(vfs.fileExists("turbo.json")).toBe(false); + expect(vfs.fileExists("pnpm-workspace.yaml")).toBe(false); + }); + + it("bails (leaves the monorepo) when an unexpected workspace package exists", () => { + const vfs = seedThinSelfMonorepo("flatapp"); + vfs.writeJson("packages/db/package.json", { name: "@flatapp/db" }); + + const flattened = flattenSingleApp(vfs, makeConfig(SINGLE_APP_NEXT)); + + expect(flattened).toBe(false); + // The monorepo is left intact. + expect(vfs.fileExists("apps/web/package.json")).toBe(true); + expect(vfs.fileExists("packages/db/package.json")).toBe(true); + }); +}); diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index fe89a5a1d..67b84d24d 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -127,6 +127,7 @@ export type CompatibilityInput = { documentation: string[]; appPlatforms: string[]; packageManager: string; + workspaceShape: string; versionChannel: string; examples: string[]; aiSdk: string; @@ -304,6 +305,70 @@ export type CompatibilityAnalysisResult = { * This follows the CLI approach: when you make a selection, dependent items adjust automatically. * The flow is: frontend -> backend -> runtime -> database -> orm -> api -> auth -> etc. */ +/** + * Backends that can collapse into a flat single-app layout (no separate + * apps/server or packages/* workspace). These are the "thin self" fullstack + * frameworks whose web app already owns the server via route handlers AND that + * expose a `@/*` -> ./src path alias so the inlined env module resolves cleanly. + * Nuxt is intentionally excluded from the MVP (different alias convention). + */ +const SINGLE_APP_SELF_BACKENDS = new Set(["self-next", "self-tanstack-start"]); +const SINGLE_APP_WEB_FRONTEND_BY_BACKEND: Record = { + "self-next": "next", + "self-tanstack-start": "tanstack-start", +}; + +/** + * A single-app (flat) layout is only safe for a "thin self" stack that emits no + * sibling workspace package (database/orm, better-auth server, trpc/orpc + * packages/api, payments, email, etc.) and no separate native/server app. For + * anything else, `single-app` is normalized back to `monorepo` so we never emit + * a broken flat layout. appPlatforms (turborepo/nx) are ignored here — the + * generator drops the workspace tooling when it flattens. + */ +export function stackQualifiesForSingleApp(stack: CompatibilityInput): boolean { + if (!SINGLE_APP_SELF_BACKENDS.has(stack.backend)) return false; + + const nativeFrontends = (stack.nativeFrontend ?? []).filter((f) => f && f !== "none"); + if (nativeFrontends.length > 0) return false; + + const webFrontends = (stack.webFrontend ?? []).filter((f) => f && f !== "none"); + if (webFrontends.length !== 1) return false; + if (webFrontends[0] !== SINGLE_APP_WEB_FRONTEND_BY_BACKEND[stack.backend]) return false; + + const siblingPackageScalars = [ + stack.api, + stack.database, + stack.orm, + stack.auth, + stack.payments, + stack.email, + stack.fileUpload, + stack.realtime, + stack.jobQueue, + stack.caching, + stack.rateLimit, + stack.i18n, + stack.search, + stack.vectorDb, + stack.fileStorage, + stack.cms, + stack.aiSdk, + stack.analytics, + stack.featureFlags, + stack.observability, + stack.logging, + stack.webDeploy, + stack.serverDeploy, + ]; + if (siblingPackageScalars.some((value) => value && value !== "none")) return false; + + const nonNoneExamples = (stack.examples ?? []).filter((e) => e && e !== "none"); + if (nonNoneExamples.length > 0) return false; + + return true; +} + export const analyzeStackCompatibility = ( stack: CompatibilityInput, ): CompatibilityAnalysisResult => { @@ -1662,6 +1727,19 @@ export const analyzeStackCompatibility = ( }); } + // Workspace shape: single-app (flat) only applies to a qualifying thin self + // app; normalize back to monorepo for anything else so we never emit a broken + // flat layout. + if (nextStack.workspaceShape === "single-app" && !stackQualifiesForSingleApp(nextStack)) { + nextStack.workspaceShape = "monorepo"; + changed = true; + changes.push({ + category: "workspaceShape", + message: + "Workspace shape set to 'Monorepo' (single-app only supports a thin self app: Next.js or TanStack Start fullstack with no separate database/auth/api/server packages)", + }); + } + return { adjustedStack: changed ? nextStack : null, notes, diff --git a/packages/types/src/defaults.ts b/packages/types/src/defaults.ts index 1b6f35584..29a056345 100644 --- a/packages/types/src/defaults.ts +++ b/packages/types/src/defaults.ts @@ -23,6 +23,7 @@ export function createCliDefaultProjectConfigBase( effect: "none", git: true, packageManager, + workspaceShape: "monorepo", versionChannel: "stable", install: true, dbSetup: "none", diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index 75a1b077a..a31afa3ad 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -78,6 +78,7 @@ import { OBSERVABILITY_VALUES, ORM_VALUES, PACKAGE_MANAGER_VALUES, + WORKSPACE_SHAPE_VALUES, PAYMENTS_VALUES, VERSION_CHANNEL_VALUES, PYTHON_AI_VALUES, @@ -176,6 +177,7 @@ export type OptionCategory = | "documentation" | "appPlatforms" | "packageManager" + | "workspaceShape" | "versionChannel" | "examples" | "ai" @@ -325,6 +327,7 @@ export const TYPESCRIPT_CATEGORY_ORDER = [ "codeQuality", "documentation", "appPlatforms", + "workspaceShape", "packageManager", "examples", "aiDocs", @@ -774,6 +777,7 @@ const CATEGORY_VALUE_IDS: Record = { documentation: DOCUMENTATION_VALUES, appPlatforms: APP_PLATFORM_VALUES, packageManager: PACKAGE_MANAGER_VALUES, + workspaceShape: WORKSPACE_SHAPE_VALUES, versionChannel: VERSION_CHANNEL_VALUES, examples: EXAMPLE_VALUES, ai: AI_VALUES, @@ -1691,6 +1695,7 @@ export const OPTION_CATEGORY_METADATA: Record; export type Addons = z.infer; export type Examples = z.infer; export type PackageManager = z.infer; +export type WorkspaceShape = z.infer; export type VersionChannel = z.infer; export type DatabaseSetup = z.infer; export type API = z.infer; diff --git a/packages/types/test/workspace-shape.test.ts b/packages/types/test/workspace-shape.test.ts new file mode 100644 index 000000000..0c9457db8 --- /dev/null +++ b/packages/types/test/workspace-shape.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; + +import { + analyzeStackCompatibility, + stackQualifiesForSingleApp, + type CompatibilityInput, +} from "../src/compatibility"; +import { DEFAULT_STACK_SELECTION } from "../src/stack-translation"; + +function makeStack(overrides: Partial): CompatibilityInput { + return { + ...DEFAULT_STACK_SELECTION, + ...overrides, + } as unknown as CompatibilityInput; +} + +const THIN_SELF_NEXT: Partial = { + workspaceShape: "single-app", + backend: "self-next", + webFrontend: ["next"], + nativeFrontend: ["none"], + api: "none", + database: "none", + orm: "none", + auth: "none", + payments: "none", + email: "none", + runtime: "none", + serverDeploy: "none", + webDeploy: "none", + testing: "none", + examples: [], + appPlatforms: [], +}; + +describe("stackQualifiesForSingleApp", () => { + it("qualifies a thin self-next stack", () => { + expect(stackQualifiesForSingleApp(makeStack(THIN_SELF_NEXT))).toBe(true); + }); + + it("qualifies a thin self-tanstack-start stack", () => { + expect( + stackQualifiesForSingleApp( + makeStack({ + ...THIN_SELF_NEXT, + backend: "self-tanstack-start", + webFrontend: ["tanstack-start"], + }), + ), + ).toBe(true); + }); + + it("does not qualify a self-nuxt stack (deferred)", () => { + expect( + stackQualifiesForSingleApp( + makeStack({ ...THIN_SELF_NEXT, backend: "self-nuxt", webFrontend: ["nuxt"] }), + ), + ).toBe(false); + }); + + it("does not qualify when a sibling-package capability is present", () => { + expect(stackQualifiesForSingleApp(makeStack({ ...THIN_SELF_NEXT, auth: "better-auth" }))).toBe( + false, + ); + expect( + stackQualifiesForSingleApp( + makeStack({ ...THIN_SELF_NEXT, database: "postgres", orm: "drizzle" }), + ), + ).toBe(false); + expect(stackQualifiesForSingleApp(makeStack({ ...THIN_SELF_NEXT, api: "trpc" }))).toBe(false); + }); + + it("does not qualify when a native frontend is present", () => { + expect( + stackQualifiesForSingleApp( + makeStack({ ...THIN_SELF_NEXT, nativeFrontend: ["native-bare"] }), + ), + ).toBe(false); + }); +}); + +describe("analyzeStackCompatibility workspace shape normalization", () => { + it("downgrades single-app -> monorepo for a non-qualifying stack", () => { + const result = analyzeStackCompatibility( + makeStack({ + workspaceShape: "single-app", + backend: "hono", + webFrontend: ["tanstack-router"], + database: "sqlite", + orm: "drizzle", + auth: "better-auth", + api: "trpc", + }), + ); + + expect(result.adjustedStack?.workspaceShape).toBe("monorepo"); + expect(result.changes.some((change) => change.category === "workspaceShape")).toBe(true); + }); + + it("preserves single-app for a qualifying thin self app", () => { + const result = analyzeStackCompatibility(makeStack(THIN_SELF_NEXT)); + + // The stack may be adjusted for unrelated reasons, but if it is, workspaceShape stays single-app. + const shape = result.adjustedStack?.workspaceShape ?? "single-app"; + expect(shape).toBe("single-app"); + expect(result.changes.some((change) => change.category === "workspaceShape")).toBe(false); + }); + + it("leaves monorepo untouched (the default shape)", () => { + const result = analyzeStackCompatibility(makeStack({ workspaceShape: "monorepo" })); + expect(result.changes.some((change) => change.category === "workspaceShape")).toBe(false); + }); +}); From ba973872a0c73f17bd517f3efae0630e2c5542ab Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 20:59:08 +0300 Subject: [PATCH 09/14] fix(smoke): pass --payments for native presets + exempt workspaceShape tech-links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI fixes for the phase-2 features: 1. testing/lib/generate-combos/render.ts: reactNativeFlags now emits --payments (defaults none). RevenueCat made payments native-applicable, so without an explicit --payments the CLI prompts and non-interactive native scaffolds hang (Strict Smoke Core + plain Smoke native combos failed 'scaffold [template]'). Pre-existing harness gap on Development; surfaces on any PR off current Dev. 2. apps/web/scripts/validate-tech-resource-links.ts: workspaceShape added to SHARED_DOCS_CATEGORIES — it's a project-structure mode (monorepo|single-app), not an external tech with docs/GitHub links (same as the shadcn style modes). Fixes Lint + Release Guard (validate:tech-links). Verified: validate:tech-links exit 0; pr-core smoke 12/0 (native-uniwind-trpc now scaffolds with --payments none). --- apps/web/scripts/validate-tech-resource-links.ts | 3 +++ testing/lib/generate-combos/render.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/web/scripts/validate-tech-resource-links.ts b/apps/web/scripts/validate-tech-resource-links.ts index c38b6a2fd..861d6d4af 100644 --- a/apps/web/scripts/validate-tech-resource-links.ts +++ b/apps/web/scripts/validate-tech-resource-links.ts @@ -18,6 +18,9 @@ const SHARED_DOCS_CATEGORIES = new Set([ "shadcnRadius", "shadcnStyle", "versionChannel", + // Project-structure mode (monorepo | single-app), not an external tech — + // no per-value docs/GitHub link, same treatment as the shadcn style modes. + "workspaceShape", ]); const LIVE_MODE = process.argv.includes("--live"); diff --git a/testing/lib/generate-combos/render.ts b/testing/lib/generate-combos/render.ts index 16fcc9e07..314f27055 100644 --- a/testing/lib/generate-combos/render.ts +++ b/testing/lib/generate-combos/render.ts @@ -183,6 +183,9 @@ export function buildCommand(name: string, config: ProjectConfig): string { const reactNativeFlags: Array<[string, string | readonly string[]]> = [ ["frontend", withExplicitNone(config.frontend)], ["auth", config.auth], + // Payments (RevenueCat) is native-applicable, so it must be passed explicitly + // or the CLI prompts for it and non-interactive scaffolds hang. + ["payments", config.payments], ["mobile-navigation", withExplicitScalar(config.mobileNavigation)], ["mobile-ui", withExplicitScalar(config.mobileUI)], ["mobile-storage", withExplicitScalar(config.mobileStorage)], From f68c41ce2963b47a552d05909573cacd2c66bd01 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 22:47:52 +0300 Subject: [PATCH 10/14] feat(stack-graph): promote testing to a graph-owned part (Phase 4 complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Phase-4 category promotion: an audit found nearly all candidate categories (css/ui/forms/animation/i18n/analytics/logging/validation/realtime/ examples + mobile + non-TS) were already graph-promoted and round-tripping. The one remaining real library category, TypeScript top-level 'testing', is now a backend-owned shared-single part (registered via defineTools + the backend-single map), round-tripping legacy<->graph and graph-authoritative in both projections (stale flat testing overridden by the graph part; resets to none with no part). msw/storybook stay frontend-owned addons in the testing role — no scope collision. Remaining flat-only fields (shadcn* detail settings, elixirJson) are settings- shaped and explicitly deferred by the single-source-of-truth design doc (18+ file consumer surface, correlated conditional settings) — documented as follow-on; flat-field removal from bts.jsonc is intentionally out of scope (derived cache). Verified: stack-graph round-trip 50/0, compatibility 26/0, tsc clean, FULL turbo test 3510/0 with 95 snapshots byte-identical. Closes task #20 (Phase-4 category promotion). --- .../single-source-of-truth-stack-graph.md | 8 ++-- .../stack-graph-phase-0-library-inventory.md | 7 +-- packages/types/src/stack-graph.ts | 8 ++++ packages/types/test/stack-graph.test.ts | 44 ++++++++++++++++++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/docs/plans/planned/single-source-of-truth-stack-graph.md b/docs/plans/planned/single-source-of-truth-stack-graph.md index 3295bb753..52b32b8d0 100644 --- a/docs/plans/planned/single-source-of-truth-stack-graph.md +++ b/docs/plans/planned/single-source-of-truth-stack-graph.md @@ -1,8 +1,8 @@ # Single Source of Truth for the Stack Graph > **Active design doc — keep this updated as decisions land.** -> Status: **Phase 3 compatibility consolidation shipped; Phase 4 storage/settings cleanup remains** -> Last updated: 2026-07-02 +> Status: **Phase 3 compatibility consolidation shipped; Phase 4 library promotion complete except settings-shaped `shadcn*`/`elixirJson`; settings-cluster + full flat-storage retirement remain** +> Last updated: 2026-07-05 ## Active State (read this first) @@ -77,8 +77,8 @@ There are **two categories** of configuration data and they behave differently: ## Remaining Implementation Sketch - [x] **Phase 3 — Consolidate compatibility:** promoted library, ecosystem, addon/example, deploy, database setup, mobile, Java/.NET/Elixir, and shared backend-service disabled reasons now route through graph candidate checks. Graph-complete bindings are authoritative, so clean graph results no longer fall through to duplicated flat library branches. Global backend locks and intentionally setting-shaped checks remain flat. -- [ ] **Phase 4 — Storage cleanup:** current graph-authoritative cache guards now cover saved config, URL/import paths, MCP previews, stack-update/generated-project updates, reproducible commands, config-file replay, and full history replay. Keep extending those guards as additional categories are promoted, and finish retiring flat-authoritative storage paths. -- [ ] Keep deferred single-option/settings-shaped fields, such as `elixirJson` and Astro/shadcn detail settings, flat until their final graph-settings shape is settled. +- [ ] **Phase 4 — Storage cleanup:** current graph-authoritative cache guards now cover saved config, URL/import paths, MCP previews, stack-update/generated-project updates, reproducible commands, config-file replay, and full history replay. Keep extending those guards as additional categories are promoted, and finish retiring flat-authoritative storage paths. **Field promotions done:** `astroIntegration` (frontend-part setting) and `testing` (backend-owned single, graph-authoritative in both `stackGraphToLegacyProjectConfigForEcosystem` and `normalizeGraphConfigForPersistence`). With `testing` landed, every flat `ProjectConfig` field except project metadata and the settings-shaped `shadcn*`/`elixirJson` cluster now round-trips through the graph and is graph-authoritative in the projection when `stackParts` is present. +- [ ] **Deferred as a larger migration — settings-shaped fields:** `elixirJson` (single-option) and the seven `shadcn*` detail settings. `shadcn*` differs from the already-promoted `astroIntegration` setting: it is seven correlated fields on a *conditional* `ui` part, spans an 18+ file consumer surface, and its enum values are not `"none"`-defaulted (so the reset-then-rederive projection pattern used for every promoted category does not apply cleanly). Keep both flat until their final graph-settings shape is settled. ## Reference Map (files) diff --git a/docs/plans/planned/stack-graph-phase-0-library-inventory.md b/docs/plans/planned/stack-graph-phase-0-library-inventory.md index 8eae9ce70..c34f74780 100644 --- a/docs/plans/planned/stack-graph-phase-0-library-inventory.md +++ b/docs/plans/planned/stack-graph-phase-0-library-inventory.md @@ -41,7 +41,7 @@ Roles marked **NEW** do not exist in `StackPartRoleSchema` (`packages/types/src/ | `i18n` | 2 | `i18n` | frontend | single | `:2400-2411` (next-intl ↔ next) | | `analytics` | 2 | `analytics` | frontend | single | none found | | `validation` | 7 | `validation` | backend (shared; see §5 Q3) | single | none found (TS); api-layer coupling implicit in templates | -| `testing` | 5 | `testing` | frontend (playwright/cypress) — see §5 Q3 | single | none found | +| `testing` | 5 | `testing` | **backend** (shared; owner=backend when present, else flat — see §5 Q3) | single | none found — **promoted** (backend-owned so the framework never shares a scope with the frontend-owned msw/storybook testing addons) | | `logging` | 3 | `logging` | backend | single | none found | | `observability` | 3 | `observability` | backend | single | `:1935-1949` (non-TS → sentry only; java needs build tool) | | `email` | 8 | `email` | backend | single | `:1910-1930` (non-TS → resend only; convex/none backend excluded) | @@ -105,7 +105,7 @@ New roles required (recommendation): `dataFetching`, `examples`, `buildTool`, `c 1. **Workspace-level tools don't fit the owner model.** `validateStackParts` requires every non-primary part to have an owner (`MISSING_OWNER_PART`, `stack-graph.ts:936-944`), but turborepo/biome/husky/starlight configure the repo, not a part. **Recommend:** allow ownerless parts for a whitelisted set of workspace roles (`codeQuality`, `documentation`, plus workspace tooling) rather than inventing a synthetic root part. 2. **Multi-select scopes.** `DUPLICATE_ROLE_SCOPE` forbids two selected parts per `(owner, role)`, but `addons`, `examples`, `rustLibraries`, `javaLibraries`, `javaTestingLibraries`, `pythonAi`, `aiDocs` are arrays. **Recommend:** per-role `allowMultiple` flag in the registry, enforced in `validateStackParts`. -3. **Shared-owner libraries** (`validation`, `testing`, `effect` span web+server in TS). **Recommend:** owner = backend when present, else frontend (deterministic solo collapse); multi mode may add one part per owner later without schema change. Effect backend is the special case: selecting `backend=effect` must default/lock the backend-owned Effect services to `effect-full` and validation to `effect-schema`, while still allowing compatible choices such as TanStack Form or frontend libraries. +3. **Shared-owner libraries** (`validation`, `testing`, `effect` span web+server in TS). **Recommend:** owner = backend when present, else flat-only (deterministic solo collapse); multi mode may add one part per owner later without schema change. **Settled/implemented:** `validation`, `effect`, and `testing` all import as backend-owned singles (`LEGACY_TYPESCRIPT_BACKEND_SINGLE_CATEGORIES`) and stay flat-only without a TypeScript backend. `testing` is deliberately backend-owned so the framework part never shares a `(owner, role)` scope with the frontend-owned msw/storybook `testing` addons (which would otherwise trip `DUPLICATE_ROLE_SCOPE`); the flat↔graph round-trip discriminates framework vs addon tools by toolId via `getAddonStackPartBinding`. Effect backend is the special case: selecting `backend=effect` must default/lock the backend-owned Effect services to `effect-full` and validation to `effect-schema`, while still allowing compatible choices such as TanStack Form or frontend libraries. 4. **GraphQL role collision** (§1). **Settled:** `pythonGraphql` now imports/exports as backend-owned `graphql`, so Python API and GraphQL selections can coexist without a duplicate `api` role. 5. **`dbSetup`:** owned part (role `dbSetup`, owner database) rather than a setting — it has 9 options and its own compatibility matrix. 6. **Cross-owner compatibility context.** Library rules need richer context than `getStackPartCompatibilityIssue` currently passes: cms→frontend toolId, addons→frontend+backend+runtime, email/search/caching→`javaBuildTool` sibling, cssFramework→`ui` sibling. `primaryToolIdsByRole` + `siblingToolIdsByRole` (`stack-graph.ts:76-84`) already cover most; java rules additionally need sibling lookup by the new `buildTool` role — no new mechanism, just registration order. @@ -114,7 +114,8 @@ New roles required (recommendation): `dataFetching`, `examples`, `buildTool`, `c Keep this file as the library-role reference while finishing the graph authority cleanup. -- [ ] Keep `elixirJson` flat until the final settings/graph shape is settled. +- [x] `testing` (TypeScript test runner: vitest/playwright/vitest-playwright/jest/cypress) is promoted as a backend-owned graph single, round-tripping legacy↔graph and graph-authoritative in both the generator projection and persistence normalization. It is the last non-settings-shaped TypeScript library category; every remaining flat-only field is now either intentionally flat (project metadata) or settings-shaped (`shadcn*`, `elixirJson`). +- [ ] **Remaining flat-only, settings-shaped (deferred as a larger migration):** the seven `shadcn*` detail settings and single-option `elixirJson`. Unlike `astroIntegration` (one field on the always-present frontend part), `shadcn*` is seven correlated fields on a conditional `ui` part with a wide consumer surface (generator template-processor, `css-ui-deps`, reproducible-command, bts-config persistence, prompts, web builder — 18+ files) and enum values that are not `"none"`-defaulted, so the graph-projection reset semantics differ from the promoted categories. Keep both flat until their final graph-settings shape is settled. - [x] Phase 3 compatibility consolidation is complete for promoted graph-owned roles: disabled reasons for promoted library, ecosystem, addon/example, deploy, database setup, mobile, Java/.NET/Elixir, and shared backend-service categories route through graph candidate checks and graph-complete bindings are authoritative. - [ ] Continue treating global backend locks and settings-shaped constraints as flat until their final graph-settings shape is settled. - [ ] Keep graph-derived cache behavior covered for CLI config writes, add/deploy updates, MCP responses, reproducible command generation, URL/import paths, config-file replay, and history replay as more categories move into graph ownership. diff --git a/packages/types/src/stack-graph.ts b/packages/types/src/stack-graph.ts index cbfeaaf50..5a03ec9a9 100644 --- a/packages/types/src/stack-graph.ts +++ b/packages/types/src/stack-graph.ts @@ -123,6 +123,7 @@ import { ELIXIR_QUALITY_VALUES, StackPartRoleSchema, STATE_MANAGEMENT_VALUES, + TESTING_VALUES, UI_LIBRARY_VALUES, VALIDATION_VALUES, WEB_DEPLOY_VALUES, @@ -313,8 +314,12 @@ const LEGACY_TYPESCRIPT_BACKEND_SINGLE_CATEGORIES = { cms: "cms", // Shared web+server categories collapse onto the backend owner (inventory §5 decision 3); // without a TypeScript backend they stay flat-only, like the rest of this map. + // `testing` (vitest/playwright/jest/cypress) is the shared test-runner choice; owning it + // on the backend keeps it in a different scope from the frontend-owned msw/storybook + // testing addons, so the two never collide in a single `(owner, role)` scope. validation: "validation", effect: "effect", + testing: "testing", } as const satisfies Partial>; const LEGACY_TYPESCRIPT_BACKEND_INFRA_CATEGORIES = { @@ -799,6 +804,9 @@ export const STACK_TOOL_DEFINITIONS: readonly ToolDefinition[] = [ ...defineTools(CMS_VALUES, "cms", "typescript", "cms"), ...defineTools(VALIDATION_VALUES, "validation", "typescript", "validation"), ...defineTools(EFFECT_VALUES, "effect", "typescript", "effect"), + // TypeScript test-runner framework (distinct toolIds from the msw/storybook testing + // addons registered above), backend-owned so it never shares a scope with them. + ...defineTools(TESTING_VALUES, "testing", "typescript", "testing"), ...defineTools(AUTH_VALUES, "auth", "react-native", "auth"), ...defineTools(MOBILE_NAVIGATION_VALUES, "navigation", "react-native", "mobileNavigation"), ...defineTools(MOBILE_UI_VALUES, "ui", "react-native", "mobileUI"), diff --git a/packages/types/test/stack-graph.test.ts b/packages/types/test/stack-graph.test.ts index 3594cca63..91d2ce972 100644 --- a/packages/types/test/stack-graph.test.ts +++ b/packages/types/test/stack-graph.test.ts @@ -117,6 +117,7 @@ import { ANIMATION_VALUES, ASTRO_INTEGRATION_VALUES, STATE_MANAGEMENT_VALUES, + TESTING_VALUES, UI_LIBRARY_VALUES, VALIDATION_VALUES, WEB_DEPLOY_VALUES, @@ -1030,6 +1031,7 @@ describe("stack graph structural round-trip (phase 0)", () => { cms: CMS_VALUES, validation: VALIDATION_VALUES, effect: EFFECT_VALUES, + testing: TESTING_VALUES, } as const; for (const [field, values] of Object.entries(cases)) { @@ -1055,7 +1057,7 @@ describe("stack graph structural round-trip (phase 0)", () => { } }); - it("keeps validation and effect flat-only without a TypeScript backend", () => { + it("keeps validation, effect, and testing flat-only without a TypeScript backend", () => { const config: Partial = { ecosystem: "typescript", frontend: ["tanstack-router"], @@ -1066,12 +1068,50 @@ describe("stack graph structural round-trip (phase 0)", () => { auth: "none", validation: "valibot", effect: "effect", + testing: "playwright", }; const parts = legacyProjectConfigToStackParts(config); - expect(parts.some((part) => part.role === "validation" || part.role === "effect")).toBe(false); + expect( + parts.some( + (part) => part.role === "validation" || part.role === "effect" || part.role === "testing", + ), + ).toBe(false); expectNoDrift(config); }); + it("owns the TypeScript test runner on the backend without colliding with testing addons", () => { + // The framework testing part (backend-owned) and the msw/storybook testing addons + // (frontend-owned) share the `testing` role but live in different owner scopes, so + // both round-trip cleanly and validateStackParts raises no DUPLICATE_ROLE_SCOPE issue. + const config: Partial = { + ...TS_BASE, + testing: "vitest", + addons: ["msw", "storybook"], + }; + const parts = legacyProjectConfigToStackParts(config); + const backend = parts.find( + (part) => part.role === "backend" && part.ecosystem === "typescript", + ); + const frontend = parts.find( + (part) => part.role === "frontend" && part.ecosystem === "typescript", + ); + const frameworkPart = parts.find( + (part) => part.role === "testing" && part.toolId === "vitest", + ); + const addonTestingParts = parts.filter( + (part) => part.role === "testing" && part.toolId !== "vitest", + ); + + expect(frameworkPart?.ownerPartId).toBe(backend?.id); + expect(addonTestingParts.map((part) => part.toolId).sort()).toEqual(["msw", "storybook"]); + expect(addonTestingParts.every((part) => part.ownerPartId === frontend?.id)).toBe(true); + expect(validateStackParts(parts).issues).toEqual([]); + + const derived = expectNoDrift(config); + expect(derived.testing).toBe("vitest"); + expect([...(derived.addons ?? [])].sort()).toEqual(["msw", "storybook"]); + }); + it("round-trips every frontend-owned TypeScript single value as a scoped graph part", () => { const cases = { cssFramework: { role: "css", values: CSS_FRAMEWORK_VALUES }, From cc3923ff9c49d06a3c81a6df311399f714b91731 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 5 Jul 2026 23:18:24 +0300 Subject: [PATCH 11/14] docs: schema-sourced CLI-flag tables + stack/provider guides (task #9) - CLI reference: new packages/types/src/cli-flags.ts registry + generator (apps/web/scripts/generate-cli-flags-data.ts -> cli-flags-data.ts, wired into prebuild) + component; create.mdx's hand-written tables replaced with schema-sourced tables (now include .NET/saas/fets/nitro/encore/convex the old tables had drifted from). - 4 stack guides (frontend frameworks, backend frameworks, database+ORM pairing, auth provider comparison) under docs/stack-guides/. - 2 provider setup guides (env-var-per-provider, database provisioning for Turso/ Neon/Supabase/PlanetScale/Atlas/Upstash/D1/...) under docs/provider-setup/. - Sidebar + localized folder names wired; all 6 pages added to the 8 locale i18n bundles with translated title/description + English-fallback bodies (the site's established graceful-degradation; full 8-locale body translation needs a translation service and is tracked as ongoing localized-content drift). Verified: docs-content-contract 8/0, docs-navigation+seo 7/0, tsc clean, FULL turbo test 3510/0, vite build succeeds. Closes task #9 (remaining guide + CLI-flag-generation items). --- apps/web/content/docs/cli/create.mdx | 152 +- apps/web/content/docs/meta.json | 4 + .../provider-setup/database-provisioning.mdx | 49 + .../provider-setup/environment-variables.mdx | 45 + .../web/content/docs/provider-setup/meta.json | 5 + .../docs/stack-guides/auth-providers.mdx | 38 + .../docs/stack-guides/backend-frameworks.mdx | 40 + .../stack-guides/database-orm-pairing.mdx | 43 + .../docs/stack-guides/frontend-frameworks.mdx | 35 + apps/web/content/docs/stack-guides/meta.json | 10 + apps/web/content/i18n/de.json | 42 + apps/web/content/i18n/es.json | 42 + apps/web/content/i18n/fr.json | 42 + apps/web/content/i18n/ja.json | 42 + apps/web/content/i18n/ko.json | 42 + apps/web/content/i18n/uk.json | 42 + apps/web/content/i18n/zh-Hant.json | 42 + apps/web/content/i18n/zh.json | 42 + apps/web/package.json | 3 +- apps/web/scripts/generate-cli-flags-data.ts | 64 + .../components/docs/mdx/cli-flags-table.tsx | 73 + apps/web/src/components/docs/mdx/index.tsx | 2 + apps/web/src/lib/docs/cli-flags-data.ts | 2122 +++++++++++++++++ apps/web/src/lib/docs/source.ts | 20 + .../plans/planned/documentation-follow-ups.md | 14 +- packages/types/src/cli-flags.ts | 510 ++++ packages/types/src/index.ts | 1 + 27 files changed, 3423 insertions(+), 143 deletions(-) create mode 100644 apps/web/content/docs/provider-setup/database-provisioning.mdx create mode 100644 apps/web/content/docs/provider-setup/environment-variables.mdx create mode 100644 apps/web/content/docs/provider-setup/meta.json create mode 100644 apps/web/content/docs/stack-guides/auth-providers.mdx create mode 100644 apps/web/content/docs/stack-guides/backend-frameworks.mdx create mode 100644 apps/web/content/docs/stack-guides/database-orm-pairing.mdx create mode 100644 apps/web/content/docs/stack-guides/frontend-frameworks.mdx create mode 100644 apps/web/content/docs/stack-guides/meta.json create mode 100644 apps/web/scripts/generate-cli-flags-data.ts create mode 100644 apps/web/src/components/docs/mdx/cli-flags-table.tsx create mode 100644 apps/web/src/lib/docs/cli-flags-data.ts create mode 100644 packages/types/src/cli-flags.ts diff --git a/apps/web/content/docs/cli/create.mdx b/apps/web/content/docs/cli/create.mdx index be2dc7f9f..9c8e6549d 100644 --- a/apps/web/content/docs/cli/create.mdx +++ b/apps/web/content/docs/cli/create.mdx @@ -33,179 +33,61 @@ For npm, Better Fullstack flags go after `--`. Generated npm commands may use `n ## Common flags -| Flag | Values or behavior | -| --- | --- | -| `--ecosystem` | `typescript` `react-native` `rust` `python` `go` `java` `elixir`. Defaults to `typescript`. | -| `--template` | `t3` `mern` `pern` `uniwind` `none`. | -| `--part` | Adds a multi-ecosystem stack part such as `frontend:typescript:next` or `backend.orm:go:gorm`. Repeat for each part. | -| `--addons` | `pwa` `tauri` `starlight` `biome` `lefthook` `husky` `ruler` `mcp` `skills` `turborepo` `nx` `fumadocs` `ultracite` `oxlint` `opentui` `wxt` `msw` `storybook` `swr` `tanstack-query` `tanstack-table` `tanstack-virtual` `tanstack-db` `tanstack-pacer` `backend-utils` `devcontainer` `docker-compose` `github-actions` `none`. | -| `--examples` | `ai` `chat-sdk` `tanstack-showcase` `none`. | -| `--ai-docs` | `claude-md` `agents-md` `cursorrules` `none`. | -| `--package-manager` | `npm` `pnpm` `bun` `yarn`. | -| `--version-channel` | `stable` `latest` `beta`. | -| `--web-deploy` | `cloudflare` `fly` `railway` `render` `netlify` `docker` `sst` `vercel` `none`. | -| `--server-deploy` | `cloudflare` `fly` `railway` `render` `netlify` `docker` `sst` `vercel` `none`. | -| `--directory-conflict` | Strategy for an existing target directory. | -| `--dry-run` | Preview generated files without writing them. | -| `--verify` | Run generated checks after scaffolding where supported. | -| `--yes` | Accept defaults. Incompatible with core stack flags. | -| `--yolo` | Skip safety confirmations where supported. | -| `--install` / `--no-install` | Install or skip dependency installation. | -| `--git` / `--no-git` | Initialize or skip Git. | -| `--disable-analytics` | Opt out of anonymous CLI analytics. | -| `--verbose` | Print detailed scaffold output. | +The tables below are generated from the same shared schemas the CLI, Stack Builder, and MCP server use, so accepted values and defaults stay in sync with the generator. See the [Options Reference](/docs/reference/options/) for per-ecosystem option details. + + ## TypeScript flags Use these with `--ecosystem typescript`. -| Flag | Values | -| --- | --- | -| `--frontend` | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `vinext` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none`. | -| `--backend` | `hono` `express` `fastify` `elysia` `fets` `effect` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none`. | -| `--runtime` | `bun` `node` `workers` `none`. | -| `--database` | `sqlite` `postgres` `mysql` `mongodb` `edgedb` `redis` `none`. | -| `--orm` | `drizzle` `prisma` `mongoose` `typeorm` `kysely` `mikroorm` `sequelize` `none`. | -| `--db-setup` | `turso` `neon` `prisma-postgres` `planetscale` `mongodb-atlas` `supabase` `upstash` `d1` `docker` `none`. | -| `--manual-db` | Skip provider-specific database setup prompts when applicable. | -| `--auth` | `better-auth` `better-auth-organizations` `go-better-auth` `clerk` `nextauth` `stack-auth` `supabase-auth` `auth0` `workos` `kinde` `none`. | -| `--api` | `trpc` `orpc` `ts-rest` `garph` `graphql-yoga` `apollo-server` `openapi` `none`. | -| `--astro-integration` | `react` `vue` `svelte` `solid` `none`. | + ### Services -| Flag | Values | -| --- | --- | -| `--ai` | `vercel-ai` `mastra` `voltagent` `langgraph` `openai-agents` `google-adk` `modelfusion` `langchain` `llamaindex` `tanstack-ai` `ai-cli` `none`. | -| `--payments` | `polar` `stripe` `lemon-squeezy` `paddle` `dodo` `none`. | -| `--email` | `react-email` `resend` `nodemailer` `postmark` `sendgrid` `aws-ses` `mailgun` `plunk` `none`. | -| `--realtime` | `socket-io` `partykit` `ably` `pusher` `liveblocks` `yjs` `none`. | -| `--job-queue` | `bullmq` `trigger-dev` `inngest` `temporal` `none`. | -| `--cms` | `payload` `sanity` `strapi` `tinacms` `directus` `keystatic` `none`. | -| `--caching` | `upstash-redis` `none`. | -| `--search` | `meilisearch` `typesense` `elasticsearch` `opensearch` `algolia` `none`. | -| `--file-storage` | `s3` `r2` `cloudinary` `none`. | -| `--file-upload` | `uploadthing` `filepond` `uppy` `none`. | -| `--analytics` | `plausible` `umami` `none`. | -| `--feature-flags` | `growthbook` `posthog` `launchdarkly` `flagsmith` `unleash` `none`. | + ### UI and app behavior -| Flag | Values | -| --- | --- | -| `--css-framework` | `tailwind` `scss` `less` `postcss-only` `none`. | -| `--ui-library` | `shadcn-ui` `shadcn-svelte` `daisyui` `radix-ui` `headless-ui` `park-ui` `chakra-ui` `nextui` `mantine` `mui` `antd` `base-ui` `ark-ui` `react-aria` `none`. | -| `--state-management` | `zustand` `jotai` `nanostores` `redux-toolkit` `mobx` `xstate` `valtio` `tanstack-store` `legend-state` `none`. | -| `--forms` | `tanstack-form` `react-hook-form` `formik` `final-form` `conform` `modular-forms` `none`. | -| `--validation` | `zod` `valibot` `arktype` `typebox` `typia` `runtypes` `effect-schema` `none`. | -| `--testing` | `vitest` `playwright` `vitest-playwright` `jest` `cypress` `none`. | -| `--logging` | `pino` `winston` `evlog` `none`. | -| `--observability` | `opentelemetry` `sentry` `grafana` `datadog` `axiom` `betterstack` `none`. | -| `--animation` | `framer-motion` `gsap` `react-spring` `auto-animate` `lottie` `none`. | -| `--i18n` | `paraglide` `i18next` `next-intl` `none`. | -| `--effect` | `effect` `effect-full` `none`. | + ### shadcn/ui flags -| Flag | Values | -| --- | --- | -| `--shadcn-base` | `radix` `base`. | -| `--shadcn-style` | `vega` `nova` `maia` `lyra` `mira`. | -| `--shadcn-icon-library` | `lucide` `tabler` `hugeicons` `phosphor` `remixicon`. | -| `--shadcn-color-theme` | `neutral` `stone` `zinc` `gray` `amber` `blue` `cyan` `emerald` `fuchsia` `green` `indigo` `lime` `orange` `pink` `purple` `red` `rose` `sky` `teal` `violet` `yellow`. | -| `--shadcn-base-color` | `neutral` `stone` `zinc` `gray`. | -| `--shadcn-font` | `inter` `geist` `noto-sans` `nunito-sans` `figtree` `roboto` `raleway` `dm-sans` `public-sans` `outfit` `jetbrains-mono` `geist-mono`. | -| `--shadcn-radius` | `default` `none` `small` `medium` `large`. | + ## React Native flags Use these with `--ecosystem react-native`. -| Flag | Values | -| --- | --- | -| `--frontend` | `native-bare` `native-uniwind` `native-unistyles`. | -| `--mobile-navigation` | `expo-router` `react-navigation` `none`. | -| `--mobile-ui` | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none`. | -| `--mobile-storage` | `mmkv` `none`. | -| `--mobile-testing` | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none`. | -| `--mobile-push` | `expo-notifications` `none`. | -| `--mobile-ota` | `expo-updates` `none`. | -| `--mobile-deep-linking` | `expo-linking` `none`. | + ## Language ecosystem flags ### Rust -| Flag | Values | -| --- | --- | -| `--rust-web-framework` | `axum` `actix-web` `rocket` `none`. | -| `--rust-frontend` | `leptos` `dioxus` `none`. | -| `--rust-orm` | `sea-orm` `sqlx` `diesel` `none`. | -| `--rust-api` | `tonic` `async-graphql` `none`. | -| `--rust-cli` | `clap` `ratatui` `none`. | -| `--rust-libraries` | `serde` `uuid` `chrono` `reqwest` `config` `dashmap` `parking-lot` `secrecy` `tokio-util` `utoipa` `validator` `jsonwebtoken` `argon2` `tokio-test` `mockall` `proptest` `insta` `none`. | -| `--rust-logging` | `tracing` `env-logger` `none`. | -| `--rust-error-handling` | `anyhow-thiserror` `eyre` `none`. | -| `--rust-caching` | `moka` `redis` `none`. | -| `--rust-auth` | `oauth2` `none`. | + ### Python -| Flag | Values | -| --- | --- | -| `--python-web-framework` | `fastapi` `django` `flask` `litestar` `none`. | -| `--python-orm` | `sqlalchemy` `sqlmodel` `tortoise-orm` `none`. | -| `--python-validation` | `pydantic` `none`. | -| `--python-ai` | `langchain` `llamaindex` `openai-sdk` `anthropic-sdk` `langgraph` `crewai` `haystack` `pydantic-ai` `google-adk` `smolagents` `none`. | -| `--python-auth` | `authlib` `jwt` `none`. | -| `--python-api` | `django-rest-framework` `django-ninja` `none`. | -| `--python-task-queue` | `celery` `rq` `dramatiq` `huey` `none`. | -| `--python-graphql` | `strawberry` `ariadne` `none`. | -| `--python-quality` | `ruff` `mypy` `pyright` `none`. | + ### Go -| Flag | Values | -| --- | --- | -| `--go-web-framework` | `gin` `echo` `fiber` `chi` `none`. | -| `--go-orm` | `gorm` `sqlc` `ent` `none`. | -| `--go-api` | `grpc-go` `none`. | -| `--go-cli` | `cobra` `bubbletea` `urfave-cli` `none`. | -| `--go-logging` | `zap` `zerolog` `slog` `logrus` `none`. | -| `--go-auth` | `casbin` `jwt` `none`. | + GoBetterAuth is selected with the global `--auth go-better-auth` value. Go-native helpers use `--go-auth`. ### Java -| Flag | Values | -| --- | --- | -| `--java-web-framework` | `spring-boot` `quarkus` `none`. | -| `--java-build-tool` | `maven` `gradle` `none`. | -| `--java-orm` | `spring-data-jpa` `none`. | -| `--java-auth` | `spring-security` `none`. | -| `--java-libraries` | `spring-actuator` `spring-validation` `flyway` `liquibase` `springdoc-openapi` `lombok` `mapstruct` `caffeine` `resilience4j` `spring-webflux` `spring-batch` `spring-kafka` `spring-mail` `spring-devtools` `micrometer-prometheus` `thymeleaf` `none`. | -| `--java-testing-libraries` | `junit5` `mockito` `testcontainers` `assertj` `rest-assured` `wiremock` `awaitility` `archunit` `jqwik` `none`. | + + +### .NET + + ### Elixir -| Flag | Values | -| --- | --- | -| `--elixir-web-framework` | `phoenix` `phoenix-live-view` `none`. | -| `--elixir-orm` | `ecto` `ecto-sql` `none`. | -| `--elixir-auth` | `phx-gen-auth` `ueberauth` `guardian` `none`. | -| `--elixir-api` | `rest` `absinthe` `none`. | -| `--elixir-realtime` | `channels` `presence` `pubsub` `live-view-streams` `none`. | -| `--elixir-jobs` | `oban` `quantum` `none`. | -| `--elixir-validation` | `ecto-changesets` `nimble-options` `none`. | -| `--elixir-http` | `req` `finch` `none`. | -| `--elixir-json` | `jason` `none`. | -| `--elixir-email` | `swoosh` `none`. | -| `--elixir-caching` | `cachex` `nebulex` `none`. | -| `--elixir-observability` | `telemetry` `opentelemetry` `prom_ex` `none`. | -| `--elixir-testing` | `ex_unit` `mox` `bypass` `wallaby` `none`. | -| `--elixir-quality` | `credo` `dialyxir` `sobelow` `none`. | -| `--elixir-deploy` | `docker` `fly` `gigalixir` `mix-release` `none`. | + ## Examples diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json index b0e564d78..96f11edc8 100644 --- a/apps/web/content/docs/meta.json +++ b/apps/web/content/docs/meta.json @@ -6,6 +6,10 @@ "getting-started", "---Sections---", "sections", + "---Stack Guides---", + "stack-guides", + "---Provider Setup---", + "provider-setup", "---CLI---", "cli", "---Ecosystems---", diff --git a/apps/web/content/docs/provider-setup/database-provisioning.mdx b/apps/web/content/docs/provider-setup/database-provisioning.mdx new file mode 100644 index 000000000..7fab2caf7 --- /dev/null +++ b/apps/web/content/docs/provider-setup/database-provisioning.mdx @@ -0,0 +1,49 @@ +--- +title: Database Provisioning +description: Provision hosted databases for Better Fullstack — Turso, Neon, Supabase, PlanetScale, and more. +--- + +The `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded. + +## Provider quick reference + +| `--db-setup` | Engine | You provide | +| --- | --- | --- | +| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. | +| `neon` | Postgres | Serverless `DATABASE_URL`. | +| `supabase` | Postgres | Connection string; pairs with Supabase Auth. | +| `planetscale` | MySQL | Connection string (with SSL). | +| `mongodb-atlas` | MongoDB | Atlas SRV connection string. | +| `upstash` | Redis | REST URL and token. | +| `prisma-postgres` | Postgres | Prisma Postgres connection string. | +| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. | +| `docker` | Local | A `docker-compose` database for local development. | + +## Provisioning steps + +Most hosted providers follow the same flow: + +1. Create the database in the provider dashboard or CLI. +2. Copy the connection string (and any auth token). +3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys). +4. Run your ORM's migration/generate command to create the schema. + +Provider notes: + +- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token. +- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard. +- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL. +- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI. +- **Upstash** — create a Redis database and copy the REST URL and token for serverless access. +- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`. +- **Docker** — no external account needed; bring the local database up with `docker compose up -d`. + +## Local development first + +If you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy. + +## Next steps + +- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide. +- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide. +- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview. diff --git a/apps/web/content/docs/provider-setup/environment-variables.mdx b/apps/web/content/docs/provider-setup/environment-variables.mdx new file mode 100644 index 000000000..77cf67662 --- /dev/null +++ b/apps/web/content/docs/provider-setup/environment-variables.mdx @@ -0,0 +1,45 @@ +--- +title: Environment Variables +description: How generated projects load environment variables and the keys each provider integration expects. +--- + +Better Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app. + +## How env loading works + +- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config. +- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values. +- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix. + +```bash +cp .env.example .env +# then edit .env with your real credentials +``` + +## Keys by category + +The exact keys depend on your selections. Common ones include: + +| Category | Typical keys | +| --- | --- | +| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). | +| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. | +| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. | +| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. | +| Email | Provider API key, e.g. `RESEND_API_KEY`. | +| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. | +| File storage | `S3_*` / `R2_*` bucket, key, and secret values. | +| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. | + +## Good practices + +- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it. +- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`). +- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production. +- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message. + +## Next steps + +- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide. +- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/). +- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page. diff --git a/apps/web/content/docs/provider-setup/meta.json b/apps/web/content/docs/provider-setup/meta.json new file mode 100644 index 000000000..ac00057cc --- /dev/null +++ b/apps/web/content/docs/provider-setup/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Provider Setup", + "defaultOpen": false, + "pages": ["environment-variables", "database-provisioning"] +} diff --git a/apps/web/content/docs/stack-guides/auth-providers.mdx b/apps/web/content/docs/stack-guides/auth-providers.mdx new file mode 100644 index 000000000..ca84d5e6f --- /dev/null +++ b/apps/web/content/docs/stack-guides/auth-providers.mdx @@ -0,0 +1,38 @@ +--- +title: Auth Provider Comparison +description: Compare Better Fullstack auth providers and the stack requirements each one brings. +--- + +The `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements. + +## TypeScript auth providers + +| Provider | Hosting | Notes | +| --- | --- | --- | +| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. | +| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. | +| `clerk` | Hosted | Drop-in components and hosted user management. | +| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. | +| `stack-auth` | Hosted | Hosted auth with a generous free tier. | +| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. | +| `auth0` | Hosted IdP | Enterprise identity provider. | +| `workos` | Hosted IdP | AuthKit and enterprise SSO. | +| `kinde` | Hosted IdP | Hosted auth and feature flags. | + +Other ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`. + +## Stack requirements + +- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/). +- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section. +- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/). + +## Choosing a provider + +Pick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO. + +## Next steps + +- See every value on the [TypeScript options](/docs/reference/options/typescript/) page. +- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview. +- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide. diff --git a/apps/web/content/docs/stack-guides/backend-frameworks.mdx b/apps/web/content/docs/stack-guides/backend-frameworks.mdx new file mode 100644 index 000000000..c230e2513 --- /dev/null +++ b/apps/web/content/docs/stack-guides/backend-frameworks.mdx @@ -0,0 +1,40 @@ +--- +title: Backend Frameworks +description: Compare Better Fullstack backend frameworks across runtimes, APIs, and fullstack modes to pick the right server. +--- + +The TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing. + +## Backend options + +| Backend | Character | Notes | +| --- | --- | --- | +| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. | +| `express` | The classic Node framework. | Largest ecosystem, Node runtime. | +| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. | +| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. | +| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. | +| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. | +| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. | +| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. | +| `nitro` | Universal server toolkit. | Deploys to many targets. | +| `encore` | Backend framework with built-in infra. | Encore.ts workflow. | +| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. | +| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. | + +## Runtime and API interactions + +- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`. +- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them. +- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface. +- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`. + +## Choosing a backend + +Start with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server. + +## Next steps + +- See all values on the [TypeScript options](/docs/reference/options/typescript/) page. +- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide. +- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide. diff --git a/apps/web/content/docs/stack-guides/database-orm-pairing.mdx b/apps/web/content/docs/stack-guides/database-orm-pairing.mdx new file mode 100644 index 000000000..4753dd7b6 --- /dev/null +++ b/apps/web/content/docs/stack-guides/database-orm-pairing.mdx @@ -0,0 +1,43 @@ +--- +title: Database and ORM Pairing +description: Which ORM pairs with each database in Better Fullstack, plus provider and auth-adapter notes. +--- + +The `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative. + +## Databases + +`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value. + +## ORM support by engine + +| ORM | SQLite | Postgres | MySQL | MongoDB | +| --- | --- | --- | --- | --- | +| `drizzle` | Yes | Yes | Yes | No | +| `prisma` | Yes | Yes | Yes | Yes | +| `kysely` | Yes | Yes | Yes | No | +| `mikroorm` | Yes | Yes | Yes | No | +| `typeorm` | Yes | Yes | Yes | No | +| `sequelize` | Yes | Yes | Yes | No | +| `mongoose` | No | No | No | Yes | + +MongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`. + +## Auth adapter caveats + +Some ORMs lack a Better Auth adapter: + +- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM. +- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM. + +`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture. + +## Hosted provider setup + +`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one. + +## Next steps + +- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page. +- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview. +- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide. diff --git a/apps/web/content/docs/stack-guides/frontend-frameworks.mdx b/apps/web/content/docs/stack-guides/frontend-frameworks.mdx new file mode 100644 index 000000000..9372e75c5 --- /dev/null +++ b/apps/web/content/docs/stack-guides/frontend-frameworks.mdx @@ -0,0 +1,35 @@ +--- +title: Frontend Frameworks +description: Compare the TypeScript web frontends Better Fullstack scaffolds and how each shapes API, runtime, and deployment choices. +--- + +Better Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination. + +## Framework families + +| Family | Frameworks | Choose it when | +| --- | --- | --- | +| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. | +| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. | +| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. | +| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. | +| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. | +| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. | + +## How the frontend narrows the stack + +- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik. +- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes. +- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`. +- **Astro.** Astro needs an island framework: pass `--astro-integration `. +- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge. + +## Picking a default + +If you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit. + +## Next steps + +- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page. +- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide. +- Build a stack visually in the [Stack Builder](/new). diff --git a/apps/web/content/docs/stack-guides/meta.json b/apps/web/content/docs/stack-guides/meta.json new file mode 100644 index 000000000..afc5b7de3 --- /dev/null +++ b/apps/web/content/docs/stack-guides/meta.json @@ -0,0 +1,10 @@ +{ + "title": "Stack Guides", + "defaultOpen": false, + "pages": [ + "frontend-frameworks", + "backend-frameworks", + "database-orm-pairing", + "auth-providers" + ] +} diff --git a/apps/web/content/i18n/de.json b/apps/web/content/i18n/de.json index d0ada1420..32899d2a0 100644 --- a/apps/web/content/i18n/de.json +++ b/apps/web/content/i18n/de.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack ist ein Projektgenerator für typisierte Produktstapel. Wählen Sie Frontend, Backend, Datenschicht, Dienste, Tools und Zielökosystem aus; Der CLI schreibt die passenden Dateien und zeichnet den Stapel in `bts.jsonc` auf.\n\nVerwenden Sie das interaktive CLI, wenn Sie eine geführte Einrichtung wünschen, das [Stack Builder](https://better-fullstack.dev/new), wenn Sie Optionen visuell vergleichen möchten, und explizite Flags, wenn Sie einen reproduzierbaren Befehl für CI, Dokumente oder einen anderen Entwickler benötigen.\n\n## Anforderungen\n\nDer CLI läuft auf Node.js 20 oder neuer.\n\n```bash\nnode --version\n```\n\nGenerierte Projekte benötigen möglicherweise auch ihre Ökosystem-Toolchain: Expo-Tooling für React Native, Cargo für Rust, `uv` für Python-, Go-, Java 21- oder Elixir/Erlang für Phoenix-Projekte.\n\n## Erstellen Sie Ihr erstes Projekt\n\nFühren Sie den Assistenten aus und beantworten Sie die Eingabeaufforderungen:\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\nFür npm stehen Better Fullstack-Flags nach dem Trennzeichen `--`. Die gerenderten Paketmanager-Registerkarten zeigen die passenden pnpm-, Bun- und Yarn-Befehle, bei denen das Trennzeichen nicht erforderlich ist.\n\n## Was die Standardeinstellungen bewirken\n\nDurch das Akzeptieren der Standardeinstellungen wird ein TypeScript-Projekt erstellt mit:\n\n- TanStack Router Frontend und Hono Backend.\n- Bun Laufzeit, SQLite, Drizzle, Better Auth und tRPC.\n- Tailwind CSS, shadcn/ui, Vitest und Turborepo.\n- Der zum Starten der CLI verwendete Paketmanager, stabile Abhängigkeitsversionen, Git, aktivierte Installation und `CLAUDE.md`.\n\nSie können diesen Stapel behalten, mit einer Vorlage beginnen oder das Ökosystem komplett wechseln.\n\n## Möglichkeiten, einen Stapel auszuwählen\n\n| Weg | Verwenden Sie es wann |\n| --- | --- |\n| CLI-Assistent | Sie benötigen Eingabeaufforderungen und sinnvolle Standardeinstellungen. |\n| Stack Builder | Sie möchten vor dem Gerüstbau kompatible Kombinationen erkunden. |\n| Explizite Flags | Sie möchten eine wiederholbare Ausgabe in Skripten, Dokumenten oder Problemberichten. |\n| Agentenfähigkeit | Sie möchten, dass ein Codierungsagent den CLI-Workflow verwendet, ohne Startdateien handschriftlich zu schreiben. |\n| MCP Server | Sie möchten, dass ein AI-Codierungsagent das Schema überprüft, die Kompatibilität validiert und einen sicheren Probelauf durchführt. |\n\n## Generierter Projektkontext\n\nJedes generierte Projekt beinhaltet:\n\n- `bts.jsonc` mit dem ausgewählten Stapel.\n- Ein reproduzierbarer Befehl, der vom CLI gedruckt wird.\n- Framework-, Paket- und Konfigurationsdateien für das ausgewählte Ökosystem.\n- Optionale AI-Anweisungsdateien wie `CLAUDE.md`, `Agents.md` oder `.cursorrules`.\n\n## Nächste\n\n- [Installation](/docs/getting-started/installation/) erklärt Starter und Toolchains.\n- [Erstes Projekt](/docs/getting-started/first-project/) führt das Gerüst, den Probelauf, die Installation und die Inspektion durch.\n- [CLI Create](/docs/cli/create/) listet Flags und Skriptbeispiele auf.\n- [Ökosysteme](/docs/ecosystems/) vergleicht TypeScript, React Native, Rust, Python, Go, Java und Elixir.\n- [Multi-Ökosystem](/docs/ecosystems/multi-ecosystem/) zeigt, wie man Web-, Backend-, Datenbank- und mobile Teile in einem Projekt kombiniert.\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "Datenbank-Bereitstellung", + "description": "Stelle verwaltete Datenbanken für Better Fullstack bereit: Turso, Neon, Supabase, PlanetScale und mehr." + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "Umgebungsvariablen", + "description": "Wie generierte Projekte Umgebungsvariablen laden und welche Schlüssel jede Anbieter-Integration erwartet." + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "Kompatibilitätsmatrix", @@ -293,6 +307,34 @@ "description": "Wählen Sie Styling, Komponentenbibliotheken, Formulare, Validierung, State, Animation und i18n." }, "body": "\nUI- und Erfahrungsoptionen prägen den generierten Frontend-Code, Designprimitive und clientseitige Anwendungsmuster.\n\n## Styling- und UI-Bibliotheken\n\nZu den CSS-Optionen gehören Tailwind, SCSS, Less, PostCSS-only oder keine. Zu den UI-Bibliotheken gehören shadcn/ui, shadcn-svelte, daisyUI, Radix UI, Headless UI, Park UI, Chakra UI, NextUI, Mantine, MUI, Ant Design, Base UI, Ark UI, React Aria und keine.\n\nshadcn/ui verfügt über zusätzliche Voreinstellungen für Grundelemente, visuellen Stil, Symbole, Farbthema, neutrale Grundfarbe, Schriftart und Radius.\n\n## Formulare, Validierung und Status\n\nZu den Frontend-Anwendungsoptionen gehören:\n\n- Status: Zustand, Jotai, Nanostores, Redux Toolkit, MobX, XState, Valtio, TanStack Store, Legend State oder keiner.\n- Formulare: TanStack Form, React Hook Form, Formik, Final Form, Conform, Modular Forms oder keine.\n- Validierung: Zod, Valibot, ArkType, TypeBox, Typia, Runtypes, Effect Schema oder keine.\n\n## Erlebnisfunktionen\n\nAnimation, i18n und an das Frontend angrenzende App-Plattform-Add-ons leben hier konzeptionell, auch wenn ihre genauen Flags in unterschiedlichen Optionstabellen erscheinen.\n\nReact Native verfügt über eigene mobile UI-Optionen: Tamagui, Gluestack UI, Uniwind, Unistyles oder keine.\n\n## Referenzen\n\n- [TypeScript Optionen](/docs/reference/options/typescript/)\n- [React Native Optionen](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "Vergleich der Auth-Anbieter", + "description": "Vergleiche die Auth-Anbieter von Better Fullstack und die Stack-Anforderungen, die jeder mitbringt." + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "Backend-Frameworks", + "description": "Vergleiche die Backend-Frameworks von Better Fullstack nach Runtime, API und Fullstack-Modus, um den richtigen Server zu wählen." + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "Datenbank- und ORM-Kombination", + "description": "Welches ORM in Better Fullstack zu welcher Datenbank passt, plus Hinweise zu Anbietern und Auth-Adaptern." + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "Frontend-Frameworks", + "description": "Vergleiche die TypeScript-Web-Frontends, die Better Fullstack erzeugt, und wie jedes API, Runtime und Deployment beeinflusst." + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/content/i18n/es.json b/apps/web/content/i18n/es.json index 7d393c8a2..f30fe9429 100644 --- a/apps/web/content/i18n/es.json +++ b/apps/web/content/i18n/es.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack es un generador de proyectos para stacks de producto tipados. Elige frontend, backend, capa de datos, servicios, tooling y ecosistema destino; la CLI escribe los archivos correspondientes y registra el stack en `bts.jsonc`.\n\nUsa la CLI interactiva cuando quieras una configuración guiada, el [Stack Builder](https://better-fullstack.dev/new) cuando quieras comparar opciones visualmente, y flags explícitos cuando necesites un comando reproducible para CI, docs u otro desarrollador.\n\n## Requisitos\n\nLa CLI corre sobre Node.js 20 o superior.\n\n```bash\nnode --version\n```\n\nLos proyectos generados también pueden necesitar el toolchain de su ecosistema: tooling de Expo para React Native, Cargo para Rust, `uv` para Python, Go, Java 21, o Elixir/Erlang para proyectos Phoenix.\n\n## Crea tu primer proyecto\n\nEjecuta el asistente y responde los prompts:\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\nCon npm, los flags de Better Fullstack van después del separador `--`. Las tabs de package manager muestran los comandos equivalentes para pnpm, Bun y Yarn, donde ese separador no hace falta.\n\n## Qué crean los defaults\n\nAceptar los defaults crea un proyecto TypeScript con:\n\n- Frontend TanStack Router y backend Hono.\n- Runtime Bun, SQLite, Drizzle, Better Auth y tRPC.\n- Tailwind CSS, shadcn/ui, Vitest y Turborepo.\n- El package manager usado para lanzar la CLI, versiones estables de dependencias, Git, instalacion activada y `CLAUDE.md`.\n\nPuedes quedarte con ese stack, partir desde una plantilla o cambiar de ecosistema por completo.\n\n## Formas de elegir un stack\n\n| Ruta | Usala cuando |\n| --- | --- |\n| Asistente CLI | Quieres prompts y defaults sensatos. |\n| Stack Builder | Quieres explorar combinaciones compatibles antes de generar. |\n| Flags explícitos | Quieres output repetible en scripts, docs o reportes de issues. |\n| Agent Skill | Quieres que un agente de código use el flujo CLI sin escribir starters a mano. |\n| Servidor MCP | Quieres que un agente de IA inspeccione el schema, valide compatibilidad y haga dry-runs de forma segura. |\n\n## Contexto del proyecto generado\n\nCada proyecto generado incluye:\n\n- `bts.jsonc` con el stack seleccionado.\n- Un comando reproducible impreso por la CLI.\n- Archivos de framework, paquetes y configuración para el ecosistema elegido.\n- Archivos opcionales de instrucciones para IA como `CLAUDE.md`, `Agents.md` o `.cursorrules`.\n\n## Siguiente\n\n- [Instalación](/docs/getting-started/installation/) explica launchers y toolchains.\n- [Primer proyecto](/docs/getting-started/first-project/) recorre scaffold, dry-run, instalación e inspección.\n- [CLI Create](/docs/cli/create/) lista flags y ejemplos scriptados.\n- [Ecosistemas](/docs/ecosystems/) compara TypeScript, React Native, Rust, Python, Go, Java y Elixir.\n- [Multi-Ecosystem](/docs/ecosystems/multi-ecosystem/) muestra cómo combinar partes web, backend, database y mobile en un solo proyecto.\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "Aprovisionamiento de base de datos", + "description": "Aprovisiona bases de datos gestionadas para Better Fullstack: Turso, Neon, Supabase, PlanetScale y más." + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "Variables de entorno", + "description": "Cómo los proyectos generados cargan variables de entorno y qué claves espera cada integración de proveedor." + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "Matriz de compatibilidad", @@ -293,6 +307,34 @@ "description": "Elige styling, librerías de componentes, formularios, validación, estado, animación e i18n." }, "body": "\nLas opciones de UI y experiencia dan forma al código frontend generado, los primitivos de diseno y los patrones de aplicación del lado cliente.\n\n## Styling y librerias UI\n\nLas opciones CSS incluyen Tailwind, SCSS, Less, solo PostCSS o none. Las librerias UI incluyen shadcn/ui, shadcn-svelte, daisyUI, Radix UI, Headless UI, Park UI, Chakra UI, NextUI, Mantine, MUI, Ant Design, Base UI, Ark UI, React Aria y none.\n\nshadcn/ui tiene presets adicionales para primitivos base, estilo visual, iconos, tema de color, color base neutral, fuente y radio.\n\n## Formularios, validación y estado\n\nLas opciones de aplicación frontend incluyen:\n\n- Estado: Zustand, Jotai, Nanostores, Redux Toolkit, MobX, XState, Valtio, TanStack Store, Legend State o none.\n- Formularios: TanStack Form, React Hook Form, Formik, Final Form, Conform, Modular Forms o none.\n- Validacion: Zod, Valibot, ArkType, TypeBox, Typia, Runtypes, Effect Schema o none.\n\n## Funciones de experiencia\n\nAnimacion, i18n y addons app-platform cercanos al frontend viven aqui conceptualmente, incluso cuando sus flags exactos aparecen en otras tablas de opciones.\n\nReact Native tiene sus propias opciones mobile UI: Tamagui, Gluestack UI, Uniwind, Unistyles o none.\n\n## Referencias\n\n- [Opciones de TypeScript](/docs/reference/options/typescript/)\n- [Opciones de React Native](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "Comparación de proveedores de auth", + "description": "Compara los proveedores de auth de Better Fullstack y los requisitos de stack que impone cada uno." + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "Frameworks de backend", + "description": "Compara los frameworks de backend de Better Fullstack según runtime, API y modos fullstack para elegir el servidor adecuado." + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "Emparejamiento de base de datos y ORM", + "description": "Qué ORM se empareja con cada base de datos en Better Fullstack, más notas sobre proveedores y adaptadores de auth." + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "Frameworks de frontend", + "description": "Compara los frontends web de TypeScript que genera Better Fullstack y cómo cada uno condiciona la API, el runtime y el despliegue." + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/content/i18n/fr.json b/apps/web/content/i18n/fr.json index f35f9ed52..d56f44858 100644 --- a/apps/web/content/i18n/fr.json +++ b/apps/web/content/i18n/fr.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack est un générateur de projets pour les piles de produits typés. Choisissez le frontend, le backend, la couche de données, les services, les outils et l'écosystème cible ; le CLI écrit les fichiers correspondants et enregistre la pile dans `bts.jsonc`.\n\nUtilisez le CLI interactif lorsque vous souhaitez une configuration guidée, le [Stack Builder](https://better-fullstack.dev/new) lorsque vous souhaitez comparer les options visuellement et des indicateurs explicites lorsque vous avez besoin d'une commande reproductible pour CI, Docs ou un autre développeur.\n\n## Exigences\n\nLe CLI s'exécute sur Node.js 20 ou version ultérieure.\n\n```bash\nnode --version\n```\n\nLes projets générés peuvent également avoir besoin de leur chaîne d'outils d'écosystème : les outils Expo pour React Native, Cargo pour Rust, `uv` pour Python, Go, Java 21 ou Elixir/Erlang pour les projets Phoenix.\n\n## Créez votre premier projet\n\nExécutez l'assistant et répondez aux invites :\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\nPour npm, les indicateurs Better Fullstack suivent le séparateur `--`. Les onglets rendus du gestionnaire de packages affichent les commandes pnpm, Bun et Yarn correspondantes pour lesquelles le séparateur n'est pas nécessaire.\n\n## Ce que créent les valeurs par défaut\n\nL'acceptation des valeurs par défaut crée un projet TypeScript avec :\n\n- Frontend TanStack Router et backend Hono.\n- Exécution Bun, SQLite, Drizzle, Better Auth et tRPC.\n- Tailwind CSS, shadcn/ui, Vitest et Turborepo.\n- Le gestionnaire de packages utilisé pour lancer CLI, les versions de dépendances stables, Git, l'installation activée et `CLAUDE.md`.\n\nVous pouvez conserver cette pile, partir d’un modèle ou changer complètement d’écosystème.\n\n## Façons de choisir une pile\n\n| Chemin | Utilisez-le quand |\n| --- | --- |\n| Assistant CLI | Vous voulez des invites et des valeurs par défaut raisonnables. |\n| Stack Builder | Vous souhaitez explorer des combinaisons compatibles avant l’échafaudage. |\n| Indicateurs explicites | Vous souhaitez une sortie reproductible dans des scripts, des documents ou des rapports de problèmes. |\n| Compétence d'agent | Vous souhaitez qu’un agent de codage utilise le flux de travail CLI sans écrire manuellement les fichiers de démarrage. |\n| Serveur MCP | Vous souhaitez qu'un agent de codage AI inspecte le schéma, valide la compatibilité et effectue un essai à sec en toute sécurité. |\n\n## Contexte de projet généré\n\nChaque projet généré comprend :\n\n- `bts.jsonc` avec la pile sélectionnée.\n- Une commande reproductible imprimée par le CLI.\n- Fichiers de framework, de package et de configuration pour l'écosystème sélectionné.\n- Fichiers d'instructions AI facultatifs tels que `CLAUDE.md`, `Agents.md` ou `.cursorrules`.\n\n## Suivant\n\n- [Installation](/docs/getting-started/installation/) explique les lanceurs et les chaînes d'outils.\n- [Premier projet](/docs/getting-started/first-project/) parcourt l'échafaudage, l'essai à sec, l'installation et l'inspection.\n- [CLI Create](/docs/cli/create/) répertorie les indicateurs et les exemples de script.\n- [Écosystèmes](/docs/ecosystems/) compare TypeScript, React Native, Rust, Python, Go, Java et Elixir.\n- [Multi-Ecosystem](/docs/ecosystems/multi-ecosystem/) montre comment combiner les parties Web, backend, base de données et mobiles dans un seul projet.\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "Provisionnement de base de données", + "description": "Provisionnez des bases de données gérées pour Better Fullstack : Turso, Neon, Supabase, PlanetScale et plus." + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "Variables d'environnement", + "description": "Comment les projets générés chargent les variables d'environnement et les clés attendues par chaque intégration de fournisseur." + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "Matrice de compatibilité", @@ -293,6 +307,34 @@ "description": "Choisissez le style, les bibliothèques de composants, les formulaires, la validation, l'état, l'animation et i18n." }, "body": "\nLes choix d'interface utilisateur et d'expérience façonnent le code frontal généré, les primitives de conception et les modèles d'application côté client.\n\n## Bibliothèques de style et d'interface utilisateur\n\nLes choix CSS incluent Tailwind, SCSS, Less, PostCSS uniquement ou aucun. Les bibliothèques d'interface utilisateur incluent shadcn/ui, shadcn-svelte, daisyUI, Radix UI, Headless UI, Park UI, Chakra UI, NextUI, Mantine, MUI, Ant Design, Base UI, Ark UI, React Aria et aucune.\n\nshadcn/ui dispose de préréglages supplémentaires pour les primitives de base, le style visuel, les icônes, le thème de couleur, la couleur de base neutre, la police et le rayon.\n\n## Formulaires, validation et état\n\nLes choix d'applications frontales incluent :\n\n- État : Zustand, Jotai, Nanostores, Redux Toolkit, MobX, XState, Valtio, TanStack Store, Legend State ou aucun.\n- Formulaires : TanStack Form, React Hook Form, Formik, Final Form, Conform, Modular Forms ou aucun.\n- Validation : Zod, Valibot, ArkType, TypeBox, Typia, Runtypes, Effect Schema ou aucune.\n\n## Fonctionnalités d'expérience\n\nLes modules complémentaires d'animation, d'i18n et de plate-forme d'application adjacente au frontend vivent ici conceptuellement, même lorsque leurs indicateurs exacts apparaissent dans différentes tables d'options.\n\nReact Native a ses propres choix d'interface utilisateur mobile : Tamagui, Gluestack UI, Uniwind, Unistyles ou aucun.\n\n## Références\n\n- [Options TypeScript](/docs/reference/options/typescript/)\n- [Options React Native](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "Comparaison des fournisseurs d'auth", + "description": "Comparez les fournisseurs d'auth de Better Fullstack et les exigences de stack propres à chacun." + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "Frameworks backend", + "description": "Comparez les frameworks backend de Better Fullstack selon le runtime, l'API et les modes fullstack pour choisir le bon serveur." + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "Association base de données et ORM", + "description": "Quel ORM s'associe à chaque base de données dans Better Fullstack, avec des notes sur les fournisseurs et adaptateurs d'auth." + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "Frameworks frontend", + "description": "Comparez les frontends web TypeScript générés par Better Fullstack et l'impact de chacun sur l'API, le runtime et le déploiement." + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/content/i18n/ja.json b/apps/web/content/i18n/ja.json index e9c07e943..50852d1e4 100644 --- a/apps/web/content/i18n/ja.json +++ b/apps/web/content/i18n/ja.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack は、型付き製品スタック用のプロジェクト ジェネレーターです。フロントエンド、バックエンド、データ層、サービス、ツール、ターゲット エコシステムを選択します。 CLI は、一致するファイルを書き込み、スタックを `bts.jsonc` に記録します。\n\nガイド付きセットアップが必要な場合は対話型の CLI を使用し、オプションを視覚的に比較したい場合は [Stack Builder](https://better-fullstack.dev/new) を使用し、CI、ドキュメント、または別の開発者向けに再現可能なコマンドが必要な場合は明示的なフラグを使用します。\n\n## 要件\n\nCLI は Node.js 20 以降で実行されます。\n\n```bash\nnode --version\n```\n\n生成されたプロジェクトにはエコシステム ツールチェーンも必要になる場合があります: React Native の場合は Expo ツール、Rust の場合は Cargo、Python、Go、Java 21 の場合は `uv`、Phoenix プロジェクトの場合は Elixir/Erlang。\n\n## 最初のプロジェクトを作成する\n\nウィザードを実行し、プロンプトに答えます。\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\nnpm の場合、Better Fullstack フラグは `--` 区切り文字の後に続きます。レンダリングされたパッケージ マネージャー タブには、区切り文字が必要ない、一致する pnpm、Bun、および Yarn コマンドが表示されます。\n\n## デフォルトによって作成されるもの\n\nデフォルトを受け入れると、以下を含む TypeScript プロジェクトが作成されます。\n\n- TanStack Router フロントエンドと Hono バックエンド。\n- Bun ランタイム、SQLite、Drizzle、Better Auth、および tRPC。\n- Tailwind CSS、shadcn/ui、Vitest、Turborepo。\n- CLI の起動に使用されるパッケージ マネージャー、安定した依存関係バージョン、Git、有効なインストール、および `CLAUDE.md`。\n\nそのスタックを維持したり、テンプレートから開始したり、エコシステムを完全に切り替えたりすることができます。\n\n## スタックの選択方法\n\n| パス | こんなときに使います |\n| --- | --- |\n| CLI ウィザード | プロンプトと適切なデフォルトが必要です。 |\n| Stack Builder | スキャフォールディングを行う前に、互換性のある組み合わせを検討したいと考えています。 |\n| 明示的なフラグ | スクリプト、ドキュメント、または問題レポートで繰り返し可能な出力が必要です。 |\n| エージェントスキル | コーディング エージェントがスターター ファイルを手書きせずに CLI ワークフローを使用できるようにしたいと考えています。 |\n| MCP サーバー | AI コーディング エージェントにスキーマの検査、互換性の検証、安全なドライランを実行してもらいたいと考えています。 |\n\n## 生成されたプロジェクトコンテキスト\n\n生成されたすべてのプロジェクトには以下が含まれます。\n\n- 選択したスタックを含む `bts.jsonc`。\n- CLI によって出力される再現可能なコマンド。\n- 選択したエコシステムのフレームワーク、パッケージ、および構成ファイル。\n- オプションの AI 命令ファイル (`CLAUDE.md`、`Agents.md`、`.cursorrules` など)。\n\n## 次\n\n- [インストール](/docs/getting-started/installation/) では、ランチャーとツールチェーンについて説明します。\n- [最初のプロジェクト](/docs/getting-started/first-project/) は、足場、予行演習、設置、検査を順を追って説明します。\n- [CLI Create](/docs/cli/create/) には、フラグとスクリプト化された例がリストされています。\n- [エコシステム](/docs/ecosystems/) は、TypeScript、React Native、Rust、Python、Go、Java、および Elixir を比較します。\n- [マルチエコシステム](/docs/ecosystems/multi-ecosystem/) は、Web、バックエンド、データベース、モバイルの各部分を 1 つのプロジェクトに組み合わせる方法を示します。\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "データベースのプロビジョニング", + "description": "Better Fullstack 向けにマネージドデータベースをプロビジョニングします:Turso、Neon、Supabase、PlanetScale など。" + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "環境変数", + "description": "生成されたプロジェクトが環境変数を読み込む方法と、各プロバイダー連携が必要とするキー。" + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "互換性マトリックス", @@ -293,6 +307,34 @@ "description": "スタイル、コンポーネント ライブラリ、フォーム、検証、状態、アニメーション、および i18n を選択します。" }, "body": "\nUI とエクスペリエンスの選択により、生成されるフロントエンド コード、デザイン プリミティブ、およびクライアント側のアプリケーション パターンが形成されます。\n\n## スタイルおよび UI ライブラリ\n\nCSS の選択肢には、Tailwind、SCSS、Less、PostCSS-only、または none が含まれます。 UI ライブラリには、shadcn/ui、shadcn-svelte、daisyUI、Radix UI、Headless UI、Park UI、Chakra UI、NextUI、Mantine、MUI、Ant Design、Base UI、Ark UI、React Aria、および none が含まれます。\n\nshadcn/ui には、ベース プリミティブ、ビジュアル スタイル、アイコン、カラー テーマ、ニュートラルなベース カラー、フォント、および半径の追加のプリセットがあります。\n\n## フォーム、検証、状態\n\nフロントエンド アプリケーションの選択肢は次のとおりです。\n\n- 状態: Zustand、Jotai、Nanostores、Redux Toolkit、MobX、XState、Valtio、TanStack Store、Legend State、またはなし。\n- フォーム: TanStack Form、React Hook Form、Formik、Final Form、Conform、Modular Forms、またはなし。\n- 検証: Zod、Valibot、ArkType、TypeBox、Typia、Runtypes、Effect Schema、またはなし。\n\n## 体験機能\n\nアニメーション、i18n、およびフロントエンドに隣接するアプリ プラットフォーム アドオンは、それらの正確なフラグが別のオプション テーブルに表示されている場合でも、概念的にはここに存在します。\n\nReact Native には独自のモバイル UI の選択肢があります: Tamagui、Gluestack UI、Uniwind、Unistyles、またはなし。\n\n## 参考文献\n\n- [TypeScript オプション](/docs/reference/options/typescript/)\n- [React Native オプション](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "認証プロバイダーの比較", + "description": "Better Fullstack の認証プロバイダーと、それぞれが求めるスタック要件を比較します。" + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "バックエンドフレームワーク", + "description": "ランタイム・API・フルスタックモードの観点で Better Fullstack のバックエンドフレームワークを比較し、最適なサーバーを選びます。" + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "データベースと ORM の組み合わせ", + "description": "Better Fullstack で各データベースに対応する ORM と、プロバイダーや認証アダプターに関する注意点。" + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "フロントエンドフレームワーク", + "description": "Better Fullstack が生成する TypeScript の Web フロントエンドと、それぞれが API・ランタイム・デプロイの選択に与える影響を比較します。" + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/content/i18n/ko.json b/apps/web/content/i18n/ko.json index 84af6d15a..b73d118a9 100644 --- a/apps/web/content/i18n/ko.json +++ b/apps/web/content/i18n/ko.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack은 유형화된 제품 스택을 위한 프로젝트 생성기입니다. 프런트엔드, 백엔드, 데이터 계층, 서비스, 도구, 대상 생태계를 선택하세요. CLI는 일치하는 파일을 쓰고 `bts.jsonc`에 스택을 기록합니다.\n\n안내식 설정이 필요한 경우 대화형 CLI을 사용하고, 옵션을 시각적으로 비교하려는 경우 [Stack Builder](https://better-fullstack.dev/new)를 사용하고, CI, 문서 또는 다른 개발자를 위한 재현 가능한 명령이 필요한 경우 명시적 플래그를 사용합니다.\n\n## 요구사항\n\nCLI은 Node.js 20 이상에서 실행됩니다.\n\n```bash\nnode --version\n```\n\n생성된 프로젝트에는 React Native용 Expo 도구, Rust용 Cargo, Python, Go, Java 21용 `uv` 또는 Phoenix 프로젝트용 Elixir/Erlang과 같은 생태계 툴체인이 필요할 수도 있습니다.\n\n## 첫 번째 프로젝트 만들기\n\n마법사를 실행하고 프롬프트에 응답합니다.\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\nnpm의 경우 Better Fullstack 플래그는 `--` 구분 기호 뒤에 옵니다. 렌더링된 패키지 관리자 탭에는 구분 기호가 필요하지 않은 일치하는 pnpm, Bun 및 Yarn 명령이 표시됩니다.\n\n## 기본값이 만드는 것\n\n기본값을 수락하면 다음을 사용하여 TypeScript 프로젝트가 생성됩니다.\n\n- TanStack Router 프런트엔드 및 Hono 백엔드.\n- Bun 런타임, SQLite, Drizzle, Better Auth 및 tRPC.\n- Tailwind CSS, shadcn/ui, Vitest 및 Turborepo.\n- CLI를 시작하는 데 사용되는 패키지 관리자, 안정적인 종속성 버전, Git, 설치 활성화 및 `CLAUDE.md`.\n\n해당 스택을 유지하거나, 템플릿에서 시작하거나, 생태계를 완전히 전환할 수 있습니다.\n\n## 스택을 선택하는 방법\n\n| 길 | 다음과 같은 경우에 사용하세요. |\n| --- | --- |\n| CLI 마법사 | 프롬프트와 합리적인 기본값을 원합니다. |\n| Stack Builder | 비계를 설치하기 전에 호환 가능한 조합을 탐색하고 싶습니다. |\n| 명시적 플래그 | 스크립트, 문서 또는 문제 보고서에서 반복 가능한 출력을 원합니다. |\n| 에이전트 스킬 | 코딩 에이전트가 시작 파일을 직접 작성하지 않고도 CLI 워크플로를 사용하기를 원합니다. |\n| MCP 서버 | AI 코딩 에이전트가 스키마를 검사하고, 호환성을 검증하고, 안전하게 테스트 실행하기를 원합니다. |\n\n## 생성된 프로젝트 컨텍스트\n\n생성된 모든 프로젝트에는 다음이 포함됩니다.\n\n- 선택한 스택이 포함된 `bts.jsonc`.\n- CLI에 의해 인쇄되는 재현 가능한 명령입니다.\n- 선택한 생태계에 대한 프레임워크, 패키지 및 구성 파일입니다.\n- `CLAUDE.md`, `Agents.md` 또는 `.cursorrules`와 같은 선택적 AI 명령 파일입니다.\n\n## 다음\n\n- [설치](/docs/getting-started/installation/)에서는 런처와 툴체인을 설명합니다.\n- [첫 번째 프로젝트](/docs/getting-started/first-project/)는 비계, 테스트 실행, 설치, 점검 과정을 안내합니다.\n- [CLI 만들기](/docs/cli/create/)에는 플래그와 스크립트된 예가 나열되어 있습니다.\n- [생태계](/docs/ecosystems/)는 TypeScript, React Native, Rust, Python, Go, Java 및 Elixir를 비교합니다.\n- [Multi-Ecosystem](/docs/ecosystems/multi-ecosystem/)은 웹, 백엔드, 데이터베이스, 모바일 부분을 하나의 프로젝트에 결합하는 방법을 보여줍니다.\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "데이터베이스 프로비저닝", + "description": "Better Fullstack용 관리형 데이터베이스를 프로비저닝합니다: Turso, Neon, Supabase, PlanetScale 등." + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "환경 변수", + "description": "생성된 프로젝트가 환경 변수를 로드하는 방식과 각 공급자 통합에 필요한 키." + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "호환성 매트릭스", @@ -293,6 +307,34 @@ "description": "스타일, 구성 요소 라이브러리, 양식, 유효성 검사, 상태, 애니메이션 및 i18n을 선택하세요." }, "body": "\nUI 및 경험 선택은 생성된 프런트엔드 코드, 디자인 기본 요소 및 클라이언트 측 애플리케이션 패턴을 형성합니다.\n\n## 스타일링 및 UI 라이브러리\n\nCSS 선택에는 Tailwind, SCSS, Less, PostCSS 전용 또는 없음이 포함됩니다. UI 라이브러리에는 shadcn/ui, shadcn-svelte, daisyUI, Radix UI, Headless UI, Park UI, Chakra UI, NextUI, Mantine, MUI, Ant Design, Base UI, Ark UI, React Aria 및 없음이 포함됩니다.\n\nshadcn/ui에는 기본 기본 요소, 시각적 스타일, 아이콘, 색상 테마, 중립 기본 색상, 글꼴 및 반경에 대한 추가 사전 설정이 있습니다.\n\n## 양식, 유효성 검사 및 상태\n\n프런트엔드 애플리케이션 선택 사항은 다음과 같습니다.\n\n- 상태: Zustand, Jotai, Nanostores, Redux Toolkit, MobX, XState, Valtio, TanStack Store, Legend State 또는 없음.\n- 양식: TanStack Form, React Hook Form, Formik, Final Form, Conform, Modular Forms 또는 없음.\n- 검증: Zod, Valibot, ArkType, TypeBox, Typia, Runtypes, Effect Schema 또는 없음.\n\n## 체험 기능\n\n애니메이션, i18n 및 프런트엔드 인접 앱 플랫폼 애드온은 정확한 플래그가 다른 옵션 테이블에 표시되는 경우에도 개념적으로 여기에 있습니다.\n\nReact Native에는 Tamagui, Gluestack UI, Uniwind, Unistyles 또는 없음 등 자체 모바일 UI 선택 사항이 있습니다.\n\n## 참고자료\n\n- [TypeScript 옵션](/docs/reference/options/typescript/)\n- [React Native 옵션](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "인증 공급자 비교", + "description": "Better Fullstack 인증 공급자와 각 공급자가 요구하는 스택 요건을 비교합니다." + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "백엔드 프레임워크", + "description": "런타임, API, 풀스택 모드 기준으로 Better Fullstack 백엔드 프레임워크를 비교해 적합한 서버를 선택하세요." + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "데이터베이스와 ORM 조합", + "description": "Better Fullstack에서 각 데이터베이스에 맞는 ORM과 공급자·인증 어댑터 관련 참고 사항." + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "프런트엔드 프레임워크", + "description": "Better Fullstack가 생성하는 TypeScript 웹 프런트엔드와 각 프런트엔드가 API·런타임·배포 선택에 미치는 영향을 비교합니다." + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/content/i18n/uk.json b/apps/web/content/i18n/uk.json index 9478d3b14..e98d48e3d 100644 --- a/apps/web/content/i18n/uk.json +++ b/apps/web/content/i18n/uk.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack — це генератор проєктів для типізованих стеків продуктів. Виберіть фронтенд, бекенд, рівень даних, служби, інструменти та цільову екосистему; CLI записує відповідні файли та записує стек у `bts.jsonc`.\n\nВикористовуйте інтерактивний CLI, коли вам потрібна керована настройка, [Stack Builder](https://better-fullstack.dev/new), коли ви хочете візуально порівняти параметри, і явні позначки, коли вам потрібна відтворювана команда для CI, документів або іншого розробника.\n\n## Вимоги\n\nCLI працює на Node.js 20 або новішої версії.\n\n```bash\nnode --version\n```\n\nСтворені проєкти також можуть потребувати свого екосистемного інструментарію: інструменти Expo для React Native, Cargo для Rust, `uv` для Python, Go, Java 21 або Elixir/Erlang для Phoenix проєкти.\n\n## Створіть свій перший проєкт\n\nЗапустіть майстер і дайте відповіді на prompt:\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\nДля npm прапорці Better Fullstack йдуть після розділювача `--`. Відображені вкладки менеджера пакунків показують відповідні команди pnpm, Bun та Yarn, де роздільник не потрібен.\n\n## Що створюють значення default\n\nПрийняття типових значень створює проєкт TypeScript з:\n\n- TanStack Router фронтенд і Hono сервер.\n- Bun виконання, SQLite, Drizzle, Better Auth та tRPC.\n- Tailwind CSS, shadcn/ui, Vitest та Turborepo.\n- Менеджер пакетів використовувався для запуску CLI, стабільних версій залежностей, Git, увімкнено встановлення та `CLAUDE.md`.\n\nВи можете зберегти цей стек, почати з шаблону або повністю змінити екосистему.\n\n## Способи вибору стека\n\n| шлях | Використовуйте його, коли |\n| --- | --- |\n| CLI майстер | Вам потрібні prompt та розумні параметри default. |\n| Stack Builder | Ви хочете вивчити сумісні комбінації перед скафолдингом. |\n| Явні прапори | Вам потрібен повторюваний вихід у сценаріях, документах або звітах про проблеми. |\n| Agent skills | Ви хочете, щоб coding agent використовував робочий процес CLI без рукописного написання початкових файлів. |\n| MCP сервер | Ви хочете, щоб AI coding agent перевірив схему, підтвердив сумісність і безпечно виконав пробний запуск. |\n\n## Згенерований контекст проєкту\n\nКожен створений проєкт містить:\n\n- `bts.jsonc` з вибраним стеком.\n- Відтворювана команда, надрукована CLI.\n- Фреймворк, пакет і файли конфігурації для вибраної екосистеми.\n- Додаткові файли інструкцій AI, наприклад `CLAUDE.md`, `Agents.md` або `.cursorrules`.\n\n## Далі\n\n- [Встановлення](/docs/getting-started/installation/) пояснює застосунки запуску та набори інструментів.\n- [Перший проєкт](/docs/getting-started/first-project/) проходить скафолдинг, суху роботу, установку та перевірку.\n- [CLI Створити](/docs/cli/create/) перераховує позначки та приклади сценаріїв.\n- [Ecosystems](/docs/ecosystems/) порівнює TypeScript, React Native, Rust, Python, Go, Java та Elixir.\n- [Мультиекосистема](/docs/ecosystems/multi-ecosystem/) показує, як об’єднати веб, серверну частину, базу даних і мобільні частини в одному проєкті.\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "Провізіонування бази даних", + "description": "Провізіонуйте керовані бази даних для Better Fullstack: Turso, Neon, Supabase, PlanetScale тощо." + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "Змінні середовища", + "description": "Як згенеровані проєкти завантажують змінні середовища та які ключі очікує кожна інтеграція провайдера." + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "Матриця сумісності", @@ -293,6 +307,34 @@ "description": "Виберіть стилізацію, бібліотеки компонентів, форми, валідацію, стан, анімацію та i18n." }, "body": "\nВибір UI та досвіду формує згенерований фронтендний код, примітиви дизайну та шаблони програм на стороні клієнта.\n\n## Стилізація та бібліотеки UI\n\nВибір CSS включає Tailwind, SCSS, Less, PostCSS-only або жодного. Бібліотеки UI включають shadcn/ui, shadcn-svelte, daisyUI, Radix UI, Headless UI, Park UI, Chakra UI, NextUI, Mantine, MUI, Ant Design, Base UI, Ark UI, React Aria та жодної.\n\nshadcn/ui має додаткові пресети для базових примітивів, візуального стилю, піктограм, колірної теми, нейтрального базового кольору, шрифту та радіуса.\n\n## Форми, перевірка та стан\n\nВаріанти фронтенд-застосунків включають:\n\n- Стан: Zustand, Jotai, Nanostores, Redux Toolkit, MobX, XState, Valtio, TanStack Store, Legend State або жодного.\n- Форми: TanStack Form, React Hook Form, Formik, Final Form, Conform, Modular Forms або жодної.\n- Перевірка: Zod, Valibot, ArkType, TypeBox, Typia, Runtypes, Effect Schema або жодної.\n\n## Особливості досвіду\n\nАнімація, i18n і суміжні з зовнішнім фронтендом аддони платформи застосунки живуть тут концептуально, навіть якщо їхні точні прапорці з’являються в різних таблицях параметрів.\n\nReact Native має власні варіанти мобільного UI: Tamagui, Gluestack UI, Uniwind, Unistyles або жодного.\n\n## Список літератури\n\n- [TypeScript параметри](/docs/reference/options/typescript/)\n- [React Native параметри](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "Порівняння провайдерів автентифікації", + "description": "Порівняйте провайдерів автентифікації Better Fullstack і вимоги до стеку, які висуває кожен." + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "Бекенд-фреймворки", + "description": "Порівняйте бекенд-фреймворки Better Fullstack за середовищем виконання, API та фулстек-режимами, щоб обрати потрібний сервер." + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "Поєднання бази даних та ORM", + "description": "Який ORM поєднується з кожною базою даних у Better Fullstack, а також нотатки про провайдерів та адаптери автентифікації." + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "Фронтенд-фреймворки", + "description": "Порівняйте веб-фронтенди TypeScript, які створює Better Fullstack, і те, як кожен впливає на вибір API, середовища виконання та розгортання." + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/content/i18n/zh-Hant.json b/apps/web/content/i18n/zh-Hant.json index 8e8408946..46865c1db 100644 --- a/apps/web/content/i18n/zh-Hant.json +++ b/apps/web/content/i18n/zh-Hant.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack 是針對類型化產品 stack 的專案產生器。選擇 frontend、backend、資料層、服務、tooling 和目標生態;CLI 會寫入符合的文件,並且把 stack 記錄到 `bts.jsonc`。\n\n需要引導式設定時使用互動式 CLI;想視覺化比較選項時使用 [Stack Builder](https://better-fullstack.dev/new);需要給 CI、文件或其他 developer 留下可重複指令時使用明確 flags。\n\n## 要求\n\nCLI 運行在 Node.js 20 或更高版本。\n\n```bash\nnode --version\n```\n\n產生的專案也可能需要對應生態的 toolchain:React Native 需要 Expo tooling,Rust 需要 Cargo,Python 需要 `uv`,Go 需要 Go,Java 需要 Java 21,Phoenix 專案需要 Elixir/Erlang。\n\n## 建立第一個專案\n\n運行嚮導並回答 prompts:\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\n使用 npm 時,Better Fullstack flags 要放在 `--` 分隔符號之後。 package-manager tabs 會顯示對應的 pnpm、Bun 和 Yarn 指令,這些指令不需要 npm 專屬的分隔符號。\n\n## 預設配置會創建什麼\n\n接受 defaults 會建立一個 TypeScript 項目,包含:\n\n- TanStack Router frontend 和 Hono backend。\n- Bun runtime、SQLite、Drizzle、Better Auth 和 tRPC。\n- Tailwind CSS、shadcn/ui、Vitest 和 Turborepo。\n- 啟動 CLI 時使用的 package manager、穩定依賴版本、Git、啟用安裝,以及 `CLAUDE.md`。\n\n你可以保留這個 stack,從模板開始,或完全切換到其他生態。\n\n## 選擇 stack 的方式\n\n| 路徑 | 適合場景 |\n| --- | --- |\n| CLI 精靈 | 你想要 prompts 和合理 defaults。 |\n| Stack Builder | 你想在生成前探索相容的組合。 |\n| 顯式 flags | 你想要在 scripts、docs 或 issue reports 中得到可重複 output。 |\n| Agent Skill | 你想讓 coding agent 使用 CLI workflow,而不是手寫 starter 檔案。 |\n| MCP server | 你想讓 AI coding agent 檢查 schema、驗證相容性,並且安全地 dry-run。 |\n\n## 生成項目的上下文\n\n每個生成項目都會包含:\n\n- 記錄所選 stack 的 `bts.jsonc`。\n- CLI 列印的可複現指令。\n- 面向所選生態的 framework、package 和 config 檔案。\n- 可選的 AI 指令文件,例如 `CLAUDE.md`、`Agents.md` 或 `.cursorrules`。\n\n## 下一步\n\n- [安裝](/docs/getting-started/installation/) 解釋 launchers 和 toolchains。\n- [第一個項目](/docs/getting-started/first-project/) 逐步介紹 scaffold、dry-run、安裝和檢查。\n- [CLI Create](/docs/cli/create/) 列出 flags 和腳本化範例。\n- [生態系](/docs/ecosystems/) 比較 TypeScript、React Native、Rust、Python、Go、Java 和 Elixir。\n- [Multi-Ecosystem](/docs/ecosystems/multi-ecosystem/) 展示如何在一個專案中組合 web、backend、database 和 mobile parts。\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "資料庫佈建", + "description": "為 Better Fullstack 佈建託管資料庫:Turso、Neon、Supabase、PlanetScale 等。" + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "環境變數", + "description": "產生的專案如何載入環境變數,以及每個服務商整合所需的鍵。" + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "相容性矩陣", @@ -293,6 +307,34 @@ "description": "選擇 styling、元件庫、表單、驗證、狀態、動畫和 i18n。" }, "body": "\nUI 和體驗選擇會塑造產生的前端程式碼、設計 primitives 和客戶端應用模式。\n\n## Styling 和 UI 庫\n\nCSS 選擇包括 Tailwind、SCSS、Less、PostCSS-only 或 none。 UI 函式庫包括 shadcn/ui、shadcn-svelte、daisyUI、Radix UI、Headless UI、Park UI、Chakra UI、NextUI、Mantine、MUI、Ant Design、Base UI、Ark UI、React Aria 和 none。\n\nshadcn/ui 還有額外 presets,用於基礎 primitives、視覺風格、圖示、顏色主題、中性色、字體和圓角。\n\n## 表單、驗證和狀態\n\n前端應用選擇包括:\n\n- 狀態:Zustand、Jotai、Nanostores、Redux Toolkit、MobX、XState、Valtio、TanStack Store、Legend State 或 none。\n- 表單:TanStack Form、React Hook Form、Formik、Final Form、Conform、Modular Forms 或 none。\n- 驗證:Zod、Valibot、ArkType、TypeBox、Typia、Runtypes、Effect Schema 或 none。\n\n## 體驗功能\n\n動畫、i18n 和前端相鄰的 app-platform addons 在概念上屬於這裡,即使它們的精確 flags 出現在不同選項表中。\n\nReact Native 有自己的行動裝置 UI 選擇:Tamagui、Gluestack UI、Uniwind、Unistyles 或 none。\n\n## 參考\n\n- [TypeScript 選項](/docs/reference/options/typescript/)\n- [React Native 選項](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "認證服務商比較", + "description": "比較 Better Fullstack 的認證服務商,以及每種服務商對技術棧的要求。" + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "後端框架", + "description": "從執行環境、API 與全端模式比較 Better Fullstack 的後端框架,選出合適的伺服器。" + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "資料庫與 ORM 搭配", + "description": "在 Better Fullstack 中每種資料庫可搭配的 ORM,以及服務商與認證轉接器的說明。" + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "前端框架", + "description": "比較 Better Fullstack 產生的 TypeScript Web 前端,以及每種前端如何影響 API、執行環境與部署選擇。" + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/content/i18n/zh.json b/apps/web/content/i18n/zh.json index a4d52a5eb..db43347bd 100644 --- a/apps/web/content/i18n/zh.json +++ b/apps/web/content/i18n/zh.json @@ -161,6 +161,20 @@ }, "body": "\nBetter Fullstack 是面向类型化产品 stack 的项目生成器。选择 frontend、backend、数据层、服务、tooling 和目标生态;CLI 会写入匹配的文件,并把 stack 记录到 `bts.jsonc`。\n\n需要引导式配置时使用交互式 CLI;想可视化比较选项时使用 [Stack Builder](https://better-fullstack.dev/new);需要给 CI、文档或其他 developer 留下可复现命令时使用显式 flags。\n\n## 要求\n\nCLI 运行在 Node.js 20 或更高版本。\n\n```bash\nnode --version\n```\n\n生成的项目也可能需要对应生态的 toolchain:React Native 需要 Expo tooling,Rust 需要 Cargo,Python 需要 `uv`,Go 需要 Go,Java 需要 Java 21,Phoenix 项目需要 Elixir/Erlang。\n\n## 创建第一个项目\n\n运行向导并回答 prompts:\n\n```npm\nnpm create better-fullstack@latest my-app\n```\n\n使用 npm 时,Better Fullstack flags 要放在 `--` 分隔符之后。package-manager tabs 会展示对应的 pnpm、Bun 和 Yarn 命令,这些命令不需要 npm 专属的分隔符。\n\n## 默认配置会创建什么\n\n接受 defaults 会创建一个 TypeScript 项目,包含:\n\n- TanStack Router frontend 和 Hono backend。\n- Bun runtime、SQLite、Drizzle、Better Auth 和 tRPC。\n- Tailwind CSS、shadcn/ui、Vitest 和 Turborepo。\n- 启动 CLI 时使用的 package manager、稳定依赖版本、Git、启用安装,以及 `CLAUDE.md`。\n\n你可以保留这个 stack,从模板开始,或完全切换到其他生态。\n\n## 选择 stack 的方式\n\n| 路径 | 适合场景 |\n| --- | --- |\n| CLI 向导 | 你想要 prompts 和合理 defaults。 |\n| Stack Builder | 你想在生成前探索兼容组合。 |\n| 显式 flags | 你想在 scripts、docs 或 issue reports 中得到可重复 output。 |\n| Agent Skill | 你想让 coding agent 使用 CLI workflow,而不是手写 starter 文件。 |\n| MCP server | 你想让 AI coding agent 检查 schema、验证兼容性,并安全地 dry-run。 |\n\n## 生成项目的上下文\n\n每个生成项目都会包含:\n\n- 记录所选 stack 的 `bts.jsonc`。\n- CLI 打印的可复现命令。\n- 面向所选生态的 framework、package 和 config 文件。\n- 可选的 AI 指令文件,例如 `CLAUDE.md`、`Agents.md` 或 `.cursorrules`。\n\n## 下一步\n\n- [安装](/docs/getting-started/installation/) 解释 launchers 和 toolchains。\n- [第一个项目](/docs/getting-started/first-project/) 逐步介绍 scaffold、dry-run、安装和检查。\n- [CLI Create](/docs/cli/create/) 列出 flags 和脚本化示例。\n- [生态系统](/docs/ecosystems/) 对比 TypeScript、React Native、Rust、Python、Go、Java 和 Elixir。\n- [Multi-Ecosystem](/docs/ecosystems/multi-ecosystem/) 展示如何在一个项目里组合 web、backend、database 和 mobile parts。\n" }, + "provider-setup/database-provisioning.mdx": { + "frontmatter": { + "title": "数据库预配置", + "description": "为 Better Fullstack 预配置托管数据库:Turso、Neon、Supabase、PlanetScale 等。" + }, + "body": "\nThe `--db-setup` flag adds provider-specific configuration for a hosted database. You still create the database in the provider's dashboard or CLI, then paste the connection details into your `.env`. See [Environment Variables](/docs/provider-setup/environment-variables/) for how keys are loaded.\n\n## Provider quick reference\n\n| `--db-setup` | Engine | You provide |\n| --- | --- | --- |\n| `turso` | SQLite (libSQL) | Database URL and `TURSO_AUTH_TOKEN`. |\n| `neon` | Postgres | Serverless `DATABASE_URL`. |\n| `supabase` | Postgres | Connection string; pairs with Supabase Auth. |\n| `planetscale` | MySQL | Connection string (with SSL). |\n| `mongodb-atlas` | MongoDB | Atlas SRV connection string. |\n| `upstash` | Redis | REST URL and token. |\n| `prisma-postgres` | Postgres | Prisma Postgres connection string. |\n| `d1` | SQLite (Cloudflare) | D1 binding configured in Wrangler. |\n| `docker` | Local | A `docker-compose` database for local development. |\n\n## Provisioning steps\n\nMost hosted providers follow the same flow:\n\n1. Create the database in the provider dashboard or CLI.\n2. Copy the connection string (and any auth token).\n3. Paste it into `.env` as `DATABASE_URL` (plus provider-specific keys).\n4. Run your ORM's migration/generate command to create the schema.\n\nProvider notes:\n\n- **Turso** — create a database with the Turso CLI, then run `turso db show` to get the URL and `turso db tokens create` for the token.\n- **Neon / Supabase / Prisma Postgres** — copy the pooled Postgres connection string from the dashboard.\n- **PlanetScale** — create a branch and copy its connection string; PlanetScale requires SSL.\n- **MongoDB Atlas** — create a cluster and a database user, then copy the SRV URI.\n- **Upstash** — create a Redis database and copy the REST URL and token for serverless access.\n- **Cloudflare D1** — create the database with Wrangler and bind it in `wrangler.toml`.\n- **Docker** — no external account needed; bring the local database up with `docker compose up -d`.\n\n## Local development first\n\nIf you just want to build, `--db-setup none` with a local SQLite file (or `--db-setup docker`) gets you running without any hosted account. Switch to a hosted provider when you deploy.\n\n## Next steps\n\n- Pick the right ORM in the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n- Configure secrets with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n" + }, + "provider-setup/environment-variables.mdx": { + "frontmatter": { + "title": "环境变量", + "description": "生成的项目如何加载环境变量,以及每个服务商集成所需的键。" + }, + "body": "\nBetter Fullstack generates a `.env.example` (and, where applicable, a validated env module) for the integrations in your stack. After scaffolding, copy the example to `.env` and fill in real values before running the app.\n\n## How env loading works\n\n- Each generated app reads environment variables at startup. Server frameworks load `.env` automatically or through their standard config.\n- The scaffold writes a `.env.example` listing every key your selected providers need, with placeholder values.\n- Client-exposed values use the framework's public prefix (for example `VITE_`, `NEXT_PUBLIC_`, or `PUBLIC_`). Never put secrets behind a public prefix.\n\n```bash\ncp .env.example .env\n# then edit .env with your real credentials\n```\n\n## Keys by category\n\nThe exact keys depend on your selections. Common ones include:\n\n| Category | Typical keys |\n| --- | --- |\n| Database | `DATABASE_URL` (plus provider tokens like `TURSO_AUTH_TOKEN`). |\n| Auth (Better Auth) | `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`. |\n| Auth (hosted) | Provider client id/secret, e.g. `CLERK_SECRET_KEY`, `AUTH0_CLIENT_ID`. |\n| Payments | Provider secret + webhook secret, e.g. `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. |\n| Email | Provider API key, e.g. `RESEND_API_KEY`. |\n| Caching | `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` or `REDIS_URL`. |\n| File storage | `S3_*` / `R2_*` bucket, key, and secret values. |\n| Observability / analytics | Provider DSN or token, e.g. `SENTRY_DSN`. |\n\n## Good practices\n\n- Keep `.env` out of version control — the scaffold's `.gitignore` already excludes it.\n- Generate strong secrets for keys like `BETTER_AUTH_SECRET` (for example `openssl rand -base64 32`).\n- Set the same keys in your hosting provider's dashboard for production; do not ship `.env` to production.\n- If your stack includes env validation, missing or malformed keys fail fast at startup with a clear message.\n\n## Next steps\n\n- Create hosted databases in the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n- Match keys to providers via the [Auth Provider Comparison](/docs/stack-guides/auth-providers/).\n- Review provider-sensitive options on the [TypeScript options](/docs/reference/options/typescript/) page.\n" + }, "reference/compatibility.mdx": { "frontmatter": { "title": "兼容性矩阵", @@ -293,6 +307,34 @@ "description": "选择 styling、组件库、表单、验证、状态、动画和 i18n。" }, "body": "\nUI 和体验选择会塑造生成的前端代码、设计 primitives 和客户端应用模式。\n\n## Styling 和 UI 库\n\nCSS 选择包括 Tailwind、SCSS、Less、PostCSS-only 或 none。UI 库包括 shadcn/ui、shadcn-svelte、daisyUI、Radix UI、Headless UI、Park UI、Chakra UI、NextUI、Mantine、MUI、Ant Design、Base UI、Ark UI、React Aria 和 none。\n\nshadcn/ui 还有额外 presets,用于基础 primitives、视觉风格、图标、颜色主题、中性色、字体和圆角。\n\n## 表单、验证和状态\n\n前端应用选择包括:\n\n- 状态:Zustand、Jotai、Nanostores、Redux Toolkit、MobX、XState、Valtio、TanStack Store、Legend State 或 none。\n- 表单:TanStack Form、React Hook Form、Formik、Final Form、Conform、Modular Forms 或 none。\n- 验证:Zod、Valibot、ArkType、TypeBox、Typia、Runtypes、Effect Schema 或 none。\n\n## 体验功能\n\n动画、i18n 和前端相邻的 app-platform addons 在概念上属于这里,即使它们的精确 flags 出现在不同选项表中。\n\nReact Native 有自己的移动端 UI 选择:Tamagui、Gluestack UI、Uniwind、Unistyles 或 none。\n\n## 参考\n\n- [TypeScript 选项](/docs/reference/options/typescript/)\n- [React Native 选项](/docs/reference/options/react-native/)\n" + }, + "stack-guides/auth-providers.mdx": { + "frontmatter": { + "title": "认证服务商对比", + "description": "对比 Better Fullstack 的认证服务商,以及每种服务商对技术栈的要求。" + }, + "body": "\nThe `--auth` flag chooses how generated apps handle identity. Providers range from self-hosted (you own the tables) to fully hosted identity platforms. This guide compares them; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for exact requirements.\n\n## TypeScript auth providers\n\n| Provider | Hosting | Notes |\n| --- | --- | --- |\n| `better-auth` | Self-hosted | Owns tables in your database; needs a Better Auth-compatible ORM. |\n| `better-auth-organizations` | Self-hosted | Better Auth plus the organizations/teams plugin. |\n| `clerk` | Hosted | Drop-in components and hosted user management. |\n| `nextauth` | Self-hosted | Auth.js; strongest fit for Next.js. |\n| `stack-auth` | Hosted | Hosted auth with a generous free tier. |\n| `supabase-auth` | Hosted | Pairs naturally with Supabase Postgres. |\n| `auth0` | Hosted IdP | Enterprise identity provider. |\n| `workos` | Hosted IdP | AuthKit and enterprise SSO. |\n| `kinde` | Hosted IdP | Hosted auth and feature flags. |\n\nOther ecosystems expose native helpers: Go uses `--auth go-better-auth` or `--go-auth`; Python uses `--python-auth`; Rust uses `--rust-auth`; Java uses `--java-auth`; Elixir uses `--elixir-auth`.\n\n## Stack requirements\n\n- **Better Auth needs an adapter.** It works with `drizzle`, `prisma`, `kysely`, and `mikroorm`, but not with `typeorm` or `sequelize`. See [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/).\n- **Payments can require Better Auth.** For example, Polar requires Better Auth and a web frontend. Review the [Auth and Payments](/docs/sections/auth-and-payments/) section.\n- **Hosted providers need keys.** Clerk, Auth0, WorkOS, Kinde, Stack Auth, and Supabase Auth read credentials from environment variables — see [Environment Variables](/docs/provider-setup/environment-variables/).\n\n## Choosing a provider\n\nPick `better-auth` when you want to own the data and already use a supported ORM, `clerk` or `stack-auth` for the fastest hosted setup, `supabase-auth` when you are on Supabase, and `auth0`/`workos`/`kinde` when you need enterprise SSO.\n\n## Next steps\n\n- See every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Auth and Payments](/docs/sections/auth-and-payments/) section overview.\n- Configure credentials with the [Environment Variables](/docs/provider-setup/environment-variables/) guide.\n" + }, + "stack-guides/backend-frameworks.mdx": { + "frontmatter": { + "title": "后端框架", + "description": "从运行时、API 和全栈模式对比 Better Fullstack 的后端框架,选择合适的服务器。" + }, + "body": "\nThe TypeScript `--backend` flag selects the server framework that hosts your API, auth, and integrations. This guide groups the options by how they run; see the [Compatibility Matrix](/docs/reference/compatibility/) for any exact pairing.\n\n## Backend options\n\n| Backend | Character | Notes |\n| --- | --- | --- |\n| `hono` | Small, fast, runs on Bun, Node, and Workers. | The scaffolded default; broadest runtime support. |\n| `express` | The classic Node framework. | Largest ecosystem, Node runtime. |\n| `fastify` | High-throughput Node framework with schemas. | Good for JSON APIs. |\n| `elysia` | Bun-first framework with end-to-end types. | Best on the Bun runtime. |\n| `fets` | Fetch-API framework with OpenAPI. | Standards-based handlers. |\n| `effect` | Effect-based HTTP platform. | Pairs with the `--effect` capability flags. |\n| `nestjs` | Opinionated, decorator-driven framework. | Requires `--runtime node`. |\n| `adonisjs` | Full MVC framework with its own tooling. | Requires `--runtime node`. |\n| `nitro` | Universal server toolkit. | Deploys to many targets. |\n| `encore` | Backend framework with built-in infra. | Encore.ts workflow. |\n| `convex` | Reactive backend-as-a-service. | Replaces backend-owned database/API choices. |\n| `self` | The frontend serves its own API. | Requires `--runtime none` and a fullstack frontend. |\n\n## Runtime and API interactions\n\n- **Runtime.** Most backends run on `bun` or `node`; `hono` also targets `workers`. `nestjs` and `adonisjs` require `--runtime node`. `self` requires `--runtime none`.\n- **API layer.** tRPC is React-oriented. oRPC works across frontends. GraphQL (`garph`, `graphql-yoga`, `apollo-server`) and `openapi` are available where templates support them.\n- **Convex.** Selecting `convex` auto-adjusts the stack because it owns the database and API surface.\n- **Effect.** Choose `effect` as the backend, or layer Effect onto another backend with `--effect effect` / `--effect effect-full`.\n\n## Choosing a backend\n\nStart with `hono` for portability, `elysia` if you are all-in on Bun, `nestjs`/`adonisjs` for a structured Node app, or `convex` when you want managed realtime data. For fullstack frameworks like Next.js or TanStack Start, `--backend self` avoids running a second server.\n\n## Next steps\n\n- See all values on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pick a client from the [Frontend Frameworks](/docs/stack-guides/frontend-frameworks/) guide.\n- Wire data with the [Database and ORM Pairing](/docs/stack-guides/database-orm-pairing/) guide.\n" + }, + "stack-guides/database-orm-pairing.mdx": { + "frontmatter": { + "title": "数据库与 ORM 搭配", + "description": "在 Better Fullstack 中每种数据库可搭配的 ORM,以及服务商和认证适配器的说明。" + }, + "body": "\nThe `--database` and `--orm` flags are chosen together. Not every ORM supports every engine, and a few ORMs affect which auth providers you can use. Treat the table below as guidance and the [Compatibility Matrix](/docs/reference/compatibility/) as authoritative.\n\n## Databases\n\n`--database` accepts `sqlite`, `postgres`, `mysql`, `mongodb`, `edgedb`, `redis`, or `none`. Relational engines cover most apps; `mongodb` is document-based; `redis` is key-value.\n\n## ORM support by engine\n\n| ORM | SQLite | Postgres | MySQL | MongoDB |\n| --- | --- | --- | --- | --- |\n| `drizzle` | Yes | Yes | Yes | No |\n| `prisma` | Yes | Yes | Yes | Yes |\n| `kysely` | Yes | Yes | Yes | No |\n| `mikroorm` | Yes | Yes | Yes | No |\n| `typeorm` | Yes | Yes | Yes | No |\n| `sequelize` | Yes | Yes | Yes | No |\n| `mongoose` | No | No | No | Yes |\n\nMongoDB pairs with `prisma` or `mongoose`. The relational ORMs pair with `sqlite`, `postgres`, and `mysql`.\n\n## Auth adapter caveats\n\nSome ORMs lack a Better Auth adapter:\n\n- `typeorm` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n- `sequelize` + `better-auth` is unsupported — use `--auth none` or a different ORM.\n\n`drizzle`, `prisma`, `kysely`, and `mikroorm` all work with Better Auth. See the [Auth Providers](/docs/stack-guides/auth-providers/) guide for the full picture.\n\n## Hosted provider setup\n\n`--db-setup` layers provider configuration onto your database choice — Turso, Neon, Prisma Postgres, PlanetScale, MongoDB Atlas, Supabase, Upstash, Cloudflare D1, or Docker. Follow the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide to create and connect each one.\n\n## Next steps\n\n- Browse every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Read the [Data and Storage](/docs/sections/data-and-storage/) section overview.\n- Provision a hosted database with the [Database Provisioning](/docs/provider-setup/database-provisioning/) guide.\n" + }, + "stack-guides/frontend-frameworks.mdx": { + "frontmatter": { + "title": "前端框架", + "description": "对比 Better Fullstack 生成的 TypeScript Web 前端,以及每种前端如何影响 API、运行时和部署选择。" + }, + "body": "\nBetter Fullstack scaffolds fifteen TypeScript web frontends. They fall into a few families, and your choice narrows the compatible backend, API, runtime, and deployment options. This guide helps you pick one; the [Compatibility Matrix](/docs/reference/compatibility/) is authoritative for any specific combination.\n\n## Framework families\n\n| Family | Frameworks | Choose it when |\n| --- | --- | --- |\n| React SPA | `tanstack-router`, `react-router`, `react-vite` | You want a client-rendered React app with a separate backend. |\n| React fullstack | `tanstack-start`, `next`, `vinext`, `redwood` | You want SSR/RSC and can run the frontend as its own server. |\n| Vue / Nuxt | `nuxt` | You prefer Vue with an integrated server. |\n| Svelte / Solid | `svelte` (SvelteKit), `solid`, `solid-start` | You want a compiler-first framework with fine-grained reactivity. |\n| Content / edge | `astro`, `qwik`, `fresh` | You lead with content or want minimal client JavaScript. |\n| Angular | `angular` | You want Angular's batteries-included framework and built-in HttpClient. |\n\n## How the frontend narrows the stack\n\n- **API layer.** tRPC (`--api trpc`) only works with React-based frontends. Use `--api orpc` (or another value) for Svelte, Solid, Vue, and Qwik.\n- **Fullstack `backend self`.** `--backend self` pairs with `next`, `vinext`, `tanstack-start`, `astro`, `nuxt`, `svelte`, and `solid-start`, and requires `--runtime none` — the frontend serves its own API routes.\n- **Qwik and Angular.** Qwik ships its own server (`--backend none --api none`). Angular has a built-in HttpClient, so it uses `--api none`.\n- **Astro.** Astro needs an island framework: pass `--astro-integration `.\n- **Workers runtime.** `--runtime workers` support depends on the frontend/backend pair. Check the matrix before committing to the edge.\n\n## Picking a default\n\nIf you are unsure, `tanstack-router` with a `hono` backend is the scaffolded default and works across runtimes. For a batteries-included fullstack app, `next` or `tanstack-start` with `--backend self` keeps everything in one deployable unit.\n\n## Next steps\n\n- Review every value on the [TypeScript options](/docs/reference/options/typescript/) page.\n- Pair a server from the [Backend Frameworks](/docs/stack-guides/backend-frameworks/) guide.\n- Build a stack visually in the [Stack Builder](/new).\n" } }, "guides": { diff --git a/apps/web/package.json b/apps/web/package.json index da8ba13c5..a418f2472 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "sideEffects": false, "scripts": { "i18n:compile": "bun run scripts/compile-paraglide.ts", - "prebuild": "bun run i18n:compile && cd ../../packages/types && bun run build && cd ../template-generator && bun run build", + "docs:cli-flags": "bun run scripts/generate-cli-flags-data.ts", + "prebuild": "bun run i18n:compile && cd ../../packages/types && bun run build && cd ../template-generator && bun run build && cd ../../apps/web && bun run docs:cli-flags", "build": "vite build", "dev": "bun run i18n:compile && vite dev --port 3333", "start": "node .output/server/index.mjs", diff --git a/apps/web/scripts/generate-cli-flags-data.ts b/apps/web/scripts/generate-cli-flags-data.ts new file mode 100644 index 000000000..35d8be845 --- /dev/null +++ b/apps/web/scripts/generate-cli-flags-data.ts @@ -0,0 +1,64 @@ +/** + * Generates `src/lib/docs/cli-flags-data.ts` from the shared CLI flag registry + * in `@better-fullstack/types`. The docs CLI reference renders `` + * from that module so the flag/default tables never drift from the real + * generator surface. Run via `bun run docs:cli-flags` (wired into `prebuild`). + */ +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { getCliFlagReference } from "@better-fullstack/types"; + +const WEB_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const OUTPUT_PATH = join(WEB_ROOT, "src/lib/docs/cli-flags-data.ts"); + +const groups = getCliFlagReference(); + +const header = `// AUTO-GENERATED by scripts/generate-cli-flags-data.ts — DO NOT EDIT. +// Source of truth: @better-fullstack/types (CLI_FLAG_GROUP_DEFINITIONS). +// Regenerate with: bun run docs:cli-flags +`; + +const body = `export type CliFlagRow = { + flag: string; + summary: string; + values: string[]; + valueHint: string | null; + defaultValue: string | null; + multiple: boolean; +}; + +export type CliFlagGroup = { + id: string; + title: string; + description: string; + flags: CliFlagRow[]; +}; + +export const CLI_FLAG_GROUPS: CliFlagGroup[] = ${JSON.stringify( + groups.map((group) => ({ + id: group.id, + title: group.title, + description: group.description, + flags: group.flags.map((flag) => ({ + flag: flag.flag, + summary: flag.summary, + values: flag.values, + valueHint: flag.valueHint, + defaultValue: flag.defaultValue, + multiple: flag.multiple, + })), + })), + null, + 2, +)}; + +export const CLI_FLAG_GROUPS_BY_ID: Record = Object.fromEntries( + CLI_FLAG_GROUPS.map((group) => [group.id, group]), +); +`; + +writeFileSync(OUTPUT_PATH, `${header}\n${body}`); +// eslint-disable-next-line no-console +console.log(`Generated ${groups.length} CLI flag groups -> ${OUTPUT_PATH}`); diff --git a/apps/web/src/components/docs/mdx/cli-flags-table.tsx b/apps/web/src/components/docs/mdx/cli-flags-table.tsx new file mode 100644 index 000000000..1d39eeb3f --- /dev/null +++ b/apps/web/src/components/docs/mdx/cli-flags-table.tsx @@ -0,0 +1,73 @@ +import { CLI_FLAG_GROUPS_BY_ID } from "@/lib/docs/cli-flags-data"; + +/** + * Renders a CLI flag reference table for a single flag group. Data is generated + * from the shared schemas in `@better-fullstack/types` (see + * `scripts/generate-cli-flags-data.ts`), so accepted values and defaults stay + * in sync with the real generator surface instead of being hand-maintained. + */ +export function CliFlagTable({ group }: { group: string }) { + const data = CLI_FLAG_GROUPS_BY_ID[group]; + + if (!data) { + return null; + } + + return ( +
+
+ + + + + + + + + + {data.flags.map((flag) => ( + + + + + + ))} + +
FlagAccepted valuesDefault
+ + {flag.flag} + + {flag.multiple ? ( + + multiple + + ) : null} +
{flag.summary}
+
+ {flag.values.length > 0 ? ( +
    + {flag.values.map((value) => ( +
  • + {value} +
  • + ))} +
+ ) : ( + {flag.valueHint} + )} +
+ {flag.defaultValue ? ( + + {flag.defaultValue} + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/docs/mdx/index.tsx b/apps/web/src/components/docs/mdx/index.tsx index b4dadfc15..0a0aad088 100644 --- a/apps/web/src/components/docs/mdx/index.tsx +++ b/apps/web/src/components/docs/mdx/index.tsx @@ -2,6 +2,7 @@ import type { ComponentType } from "react"; import { BenchBarChart } from "./bench-bar-chart"; import { Callout } from "./callout"; +import { CliFlagTable } from "./cli-flags-table"; import { CodeBlock } from "./code-block"; import { CompatibilityMatrix } from "./compatibility-matrix"; import { GuideCompatibilityNote } from "./guide-compatibility-note"; @@ -30,6 +31,7 @@ export const mdxComponents: Record> = { BenchBarChart, Callout, CategoryCount, + CliFlagTable, CompatibilityMatrix, GuideCompatibilityNote, GuideStackSnapshot, diff --git a/apps/web/src/lib/docs/cli-flags-data.ts b/apps/web/src/lib/docs/cli-flags-data.ts new file mode 100644 index 000000000..a9cb8fdae --- /dev/null +++ b/apps/web/src/lib/docs/cli-flags-data.ts @@ -0,0 +1,2122 @@ +// AUTO-GENERATED by scripts/generate-cli-flags-data.ts — DO NOT EDIT. +// Source of truth: @better-fullstack/types (CLI_FLAG_GROUP_DEFINITIONS). +// Regenerate with: bun run docs:cli-flags + +export type CliFlagRow = { + flag: string; + summary: string; + values: string[]; + valueHint: string | null; + defaultValue: string | null; + multiple: boolean; +}; + +export type CliFlagGroup = { + id: string; + title: string; + description: string; + flags: CliFlagRow[]; +}; + +export const CLI_FLAG_GROUPS: CliFlagGroup[] = [ + { + "id": "common", + "title": "Common flags", + "description": "Flags that apply to every ecosystem and control workflow behavior.", + "flags": [ + { + "flag": "--ecosystem", + "summary": "Language/runtime ecosystem to scaffold.", + "values": [ + "typescript", + "react-native", + "rust", + "python", + "go", + "java", + "elixir", + "dotnet" + ], + "valueHint": null, + "defaultValue": "typescript", + "multiple": false + }, + { + "flag": "--template", + "summary": "Start from a curated stack preset instead of individual flags.", + "values": [ + "mern", + "pern", + "t3", + "saas", + "uniwind", + "none" + ], + "valueHint": null, + "defaultValue": null, + "multiple": false + }, + { + "flag": "--part", + "summary": "Add a multi-ecosystem stack part. Repeat once per part.", + "values": [], + "valueHint": "role:ecosystem:tool (e.g. frontend:typescript:next)", + "defaultValue": null, + "multiple": false + }, + { + "flag": "--addons", + "summary": "Repo tooling and platform addons.", + "values": [ + "turborepo", + "nx", + "pwa", + "tauri", + "wxt", + "opentui", + "mcp", + "skills", + "msw", + "storybook", + "swr", + "tanstack-query", + "tanstack-table", + "tanstack-virtual", + "tanstack-db", + "tanstack-pacer", + "backend-utils", + "devcontainer", + "docker-compose", + "github-actions", + "biome", + "oxlint", + "ultracite", + "lefthook", + "husky", + "ruler", + "starlight", + "fumadocs", + "none" + ], + "valueHint": null, + "defaultValue": "turborepo", + "multiple": true + }, + { + "flag": "--examples", + "summary": "Optional example features to include.", + "values": [ + "ai", + "chat-sdk", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + }, + { + "flag": "--ai-docs", + "summary": "Agent instruction files to generate.", + "values": [ + "claude-md", + "agents-md", + "cursorrules", + "none" + ], + "valueHint": null, + "defaultValue": "claude-md agents-md", + "multiple": true + }, + { + "flag": "--package-manager", + "summary": "Package manager for the generated workspace.", + "values": [ + "npm", + "pnpm", + "bun", + "yarn" + ], + "valueHint": null, + "defaultValue": "bun", + "multiple": false + }, + { + "flag": "--workspace-shape", + "summary": "Monorepo or single-app workspace layout.", + "values": [ + "monorepo", + "single-app" + ], + "valueHint": null, + "defaultValue": "monorepo", + "multiple": false + }, + { + "flag": "--version-channel", + "summary": "Dependency version channel.", + "values": [ + "stable", + "latest", + "beta" + ], + "valueHint": null, + "defaultValue": "stable", + "multiple": false + }, + { + "flag": "--web-deploy", + "summary": "Deployment target config for the web app.", + "values": [ + "cloudflare", + "fly", + "railway", + "render", + "netlify", + "docker", + "sst", + "vercel", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--server-deploy", + "summary": "Deployment target config for the server.", + "values": [ + "cloudflare", + "fly", + "railway", + "render", + "netlify", + "docker", + "sst", + "vercel", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--directory-conflict", + "summary": "Strategy when the target directory already exists.", + "values": [ + "merge", + "overwrite", + "increment", + "error" + ], + "valueHint": null, + "defaultValue": null, + "multiple": false + }, + { + "flag": "--install", + "summary": "Install dependencies after scaffolding.", + "values": [], + "valueHint": "--install / --no-install", + "defaultValue": "--install", + "multiple": false + }, + { + "flag": "--git", + "summary": "Initialize a Git repository.", + "values": [], + "valueHint": "--git / --no-git", + "defaultValue": "--git", + "multiple": false + }, + { + "flag": "--dry-run", + "summary": "Preview generated files without writing them.", + "values": [], + "valueHint": "boolean flag", + "defaultValue": null, + "multiple": false + }, + { + "flag": "--verify", + "summary": "Run generated checks after scaffolding where supported.", + "values": [], + "valueHint": "boolean flag", + "defaultValue": null, + "multiple": false + }, + { + "flag": "--yes", + "summary": "Accept defaults. Conflicts with core stack flags.", + "values": [], + "valueHint": "boolean flag", + "defaultValue": null, + "multiple": false + }, + { + "flag": "--yolo", + "summary": "Skip safety confirmations where supported.", + "values": [], + "valueHint": "boolean flag", + "defaultValue": null, + "multiple": false + }, + { + "flag": "--verbose", + "summary": "Print detailed scaffold output.", + "values": [], + "valueHint": "boolean flag", + "defaultValue": null, + "multiple": false + }, + { + "flag": "--disable-analytics", + "summary": "Opt out of anonymous CLI analytics.", + "values": [], + "valueHint": "boolean flag", + "defaultValue": null, + "multiple": false + } + ] + }, + { + "id": "typescript-stack", + "title": "TypeScript stack", + "description": "Core stack flags for `--ecosystem typescript`.", + "flags": [ + { + "flag": "--frontend", + "summary": "Web frontend framework(s).", + "values": [ + "tanstack-router", + "react-router", + "react-vite", + "tanstack-start", + "next", + "vinext", + "nuxt", + "svelte", + "solid", + "solid-start", + "astro", + "qwik", + "angular", + "redwood", + "fresh", + "none" + ], + "valueHint": null, + "defaultValue": "tanstack-router", + "multiple": true + }, + { + "flag": "--backend", + "summary": "Backend framework. `self` pairs with fullstack frontends.", + "values": [ + "hono", + "express", + "fastify", + "elysia", + "fets", + "effect", + "nestjs", + "adonisjs", + "nitro", + "encore", + "convex", + "self", + "none" + ], + "valueHint": null, + "defaultValue": "hono", + "multiple": false + }, + { + "flag": "--runtime", + "summary": "Server runtime.", + "values": [ + "bun", + "node", + "workers", + "none" + ], + "valueHint": null, + "defaultValue": "bun", + "multiple": false + }, + { + "flag": "--database", + "summary": "Database engine.", + "values": [ + "none", + "sqlite", + "postgres", + "mysql", + "mongodb", + "edgedb", + "redis" + ], + "valueHint": null, + "defaultValue": "sqlite", + "multiple": false + }, + { + "flag": "--orm", + "summary": "ORM / query layer.", + "values": [ + "drizzle", + "prisma", + "mongoose", + "typeorm", + "kysely", + "mikroorm", + "sequelize", + "none" + ], + "valueHint": null, + "defaultValue": "drizzle", + "multiple": false + }, + { + "flag": "--db-setup", + "summary": "Hosted database provider setup.", + "values": [ + "turso", + "neon", + "prisma-postgres", + "planetscale", + "mongodb-atlas", + "supabase", + "upstash", + "d1", + "docker", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--auth", + "summary": "Authentication provider.", + "values": [ + "better-auth", + "better-auth-organizations", + "go-better-auth", + "clerk", + "nextauth", + "stack-auth", + "supabase-auth", + "auth0", + "workos", + "kinde", + "none" + ], + "valueHint": null, + "defaultValue": "better-auth", + "multiple": false + }, + { + "flag": "--api", + "summary": "API layer. tRPC is React-oriented.", + "values": [ + "trpc", + "orpc", + "ts-rest", + "garph", + "graphql-yoga", + "apollo-server", + "openapi", + "none" + ], + "valueHint": null, + "defaultValue": "trpc", + "multiple": false + }, + { + "flag": "--astro-integration", + "summary": "Astro UI framework integration (Astro frontends).", + "values": [ + "react", + "vue", + "svelte", + "solid", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--manual-db", + "summary": "Skip provider-specific database setup prompts.", + "values": [], + "valueHint": "boolean flag", + "defaultValue": null, + "multiple": false + } + ] + }, + { + "id": "typescript-services", + "title": "TypeScript services", + "description": "Optional service integrations for TypeScript stacks.", + "flags": [ + { + "flag": "--ai", + "summary": "AI SDK / agent framework.", + "values": [ + "vercel-ai", + "mastra", + "voltagent", + "langgraph", + "openai-agents", + "google-adk", + "modelfusion", + "langchain", + "llamaindex", + "tanstack-ai", + "ai-cli", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--payments", + "summary": "Payments provider.", + "values": [ + "revenuecat", + "polar", + "stripe", + "lemon-squeezy", + "paddle", + "dodo", + "creem", + "autumn", + "commet", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--email", + "summary": "Email provider.", + "values": [ + "react-email", + "resend", + "nodemailer", + "postmark", + "sendgrid", + "aws-ses", + "mailgun", + "plunk", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--realtime", + "summary": "Realtime transport.", + "values": [ + "socket-io", + "partykit", + "ably", + "pusher", + "liveblocks", + "yjs", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--job-queue", + "summary": "Background job / queue system.", + "values": [ + "bullmq", + "trigger-dev", + "inngest", + "temporal", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--cms", + "summary": "Content management system.", + "values": [ + "payload", + "sanity", + "strapi", + "tinacms", + "directus", + "keystatic", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--caching", + "summary": "Cache layer.", + "values": [ + "upstash-redis", + "redis", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--search", + "summary": "Search engine.", + "values": [ + "meilisearch", + "typesense", + "elasticsearch", + "opensearch", + "algolia", + "bleve", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--file-storage", + "summary": "Object storage provider.", + "values": [ + "s3", + "r2", + "cloudinary", + "supabase-storage", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--file-upload", + "summary": "File upload helper.", + "values": [ + "uploadthing", + "filepond", + "uppy", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--analytics", + "summary": "Web analytics provider.", + "values": [ + "plausible", + "umami", + "posthog", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--feature-flags", + "summary": "Feature flag platform.", + "values": [ + "growthbook", + "posthog", + "launchdarkly", + "flagsmith", + "unleash", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--vector-db", + "summary": "Vector database.", + "values": [ + "pgvector", + "qdrant", + "chroma", + "pinecone", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rate-limit", + "summary": "Rate limiting helper.", + "values": [ + "arcjet", + "upstash-ratelimit", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--i18n", + "summary": "Internationalization library.", + "values": [ + "paraglide", + "i18next", + "next-intl", + "intlayer", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--effect", + "summary": "Effect capability level.", + "values": [ + "effect", + "effect-full", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--logging", + "summary": "Logging library.", + "values": [ + "pino", + "winston", + "evlog", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--observability", + "summary": "Observability provider.", + "values": [ + "opentelemetry", + "sentry", + "grafana", + "datadog", + "axiom", + "betterstack", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + } + ] + }, + { + "id": "typescript-ui", + "title": "TypeScript UI and app behavior", + "description": "Frontend styling, state, forms, validation, and testing flags.", + "flags": [ + { + "flag": "--css-framework", + "summary": "CSS framework.", + "values": [ + "tailwind", + "scss", + "less", + "postcss-only", + "none" + ], + "valueHint": null, + "defaultValue": "tailwind", + "multiple": false + }, + { + "flag": "--ui-library", + "summary": "Component library.", + "values": [ + "shadcn-ui", + "shadcn-svelte", + "daisyui", + "radix-ui", + "headless-ui", + "park-ui", + "chakra-ui", + "nextui", + "mantine", + "mui", + "antd", + "base-ui", + "ark-ui", + "react-aria", + "none" + ], + "valueHint": null, + "defaultValue": "shadcn-ui", + "multiple": false + }, + { + "flag": "--state-management", + "summary": "Client state manager.", + "values": [ + "zustand", + "jotai", + "nanostores", + "redux-toolkit", + "mobx", + "xstate", + "valtio", + "tanstack-store", + "legend-state", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--forms", + "summary": "Form library.", + "values": [ + "tanstack-form", + "react-hook-form", + "formik", + "final-form", + "conform", + "modular-forms", + "none" + ], + "valueHint": null, + "defaultValue": "react-hook-form", + "multiple": false + }, + { + "flag": "--validation", + "summary": "Schema validation library.", + "values": [ + "zod", + "valibot", + "arktype", + "typebox", + "typia", + "runtypes", + "effect-schema", + "none" + ], + "valueHint": null, + "defaultValue": "zod", + "multiple": false + }, + { + "flag": "--testing", + "summary": "Testing setup.", + "values": [ + "vitest", + "playwright", + "vitest-playwright", + "jest", + "cypress", + "none" + ], + "valueHint": null, + "defaultValue": "vitest", + "multiple": false + }, + { + "flag": "--animation", + "summary": "Animation library.", + "values": [ + "framer-motion", + "gsap", + "react-spring", + "auto-animate", + "lottie", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + } + ] + }, + { + "id": "shadcn", + "title": "shadcn/ui flags", + "description": "Only apply when `--ui-library shadcn-ui` is selected.", + "flags": [ + { + "flag": "--shadcn-base", + "summary": "shadcn primitive base.", + "values": [ + "radix", + "base" + ], + "valueHint": null, + "defaultValue": "radix", + "multiple": false + }, + { + "flag": "--shadcn-style", + "summary": "shadcn style preset.", + "values": [ + "vega", + "nova", + "maia", + "lyra", + "mira", + "luma", + "sera" + ], + "valueHint": null, + "defaultValue": "nova", + "multiple": false + }, + { + "flag": "--shadcn-icon-library", + "summary": "Icon library.", + "values": [ + "lucide", + "tabler", + "hugeicons", + "phosphor", + "remixicon", + "heroicons", + "react-icons" + ], + "valueHint": null, + "defaultValue": "lucide", + "multiple": false + }, + { + "flag": "--shadcn-color-theme", + "summary": "Color theme.", + "values": [ + "neutral", + "stone", + "zinc", + "gray", + "amber", + "blue", + "cyan", + "emerald", + "fuchsia", + "green", + "indigo", + "lime", + "orange", + "pink", + "purple", + "red", + "rose", + "sky", + "teal", + "violet", + "yellow" + ], + "valueHint": null, + "defaultValue": "neutral", + "multiple": false + }, + { + "flag": "--shadcn-base-color", + "summary": "Base neutral color.", + "values": [ + "neutral", + "stone", + "zinc", + "gray" + ], + "valueHint": null, + "defaultValue": "neutral", + "multiple": false + }, + { + "flag": "--shadcn-font", + "summary": "Default font.", + "values": [ + "inter", + "geist", + "noto-sans", + "nunito-sans", + "figtree", + "roboto", + "raleway", + "dm-sans", + "public-sans", + "outfit", + "jetbrains-mono", + "geist-mono" + ], + "valueHint": null, + "defaultValue": "inter", + "multiple": false + }, + { + "flag": "--shadcn-radius", + "summary": "Corner radius scale.", + "values": [ + "default", + "none", + "small", + "medium", + "large" + ], + "valueHint": null, + "defaultValue": "default", + "multiple": false + } + ] + }, + { + "id": "react-native", + "title": "React Native flags", + "description": "Flags for `--ecosystem react-native`.", + "flags": [ + { + "flag": "--frontend", + "summary": "Expo native frontend styling.", + "values": [ + "native-bare", + "native-uniwind", + "native-unistyles", + "none" + ], + "valueHint": null, + "defaultValue": "native-bare", + "multiple": true + }, + { + "flag": "--mobile-navigation", + "summary": "Navigation library.", + "values": [ + "expo-router", + "react-navigation", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--mobile-ui", + "summary": "Mobile UI kit.", + "values": [ + "tamagui", + "gluestack-ui", + "uniwind", + "unistyles", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--mobile-storage", + "summary": "On-device storage.", + "values": [ + "mmkv", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--mobile-testing", + "summary": "Mobile testing setup.", + "values": [ + "maestro", + "react-native-testing-library", + "maestro-react-native-testing-library", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--mobile-push", + "summary": "Push notifications.", + "values": [ + "expo-notifications", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--mobile-ota", + "summary": "Over-the-air updates.", + "values": [ + "expo-updates", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--mobile-deep-linking", + "summary": "Deep linking.", + "values": [ + "expo-linking", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + } + ] + }, + { + "id": "rust", + "title": "Rust flags", + "description": "Flags for `--ecosystem rust`.", + "flags": [ + { + "flag": "--rust-web-framework", + "summary": "Rust web framework.", + "values": [ + "axum", + "actix-web", + "rocket", + "poem", + "loco", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-frontend", + "summary": "WASM frontend.", + "values": [ + "leptos", + "dioxus", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-orm", + "summary": "Rust ORM / database.", + "values": [ + "sea-orm", + "sqlx", + "diesel", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-api", + "summary": "Rust API layer.", + "values": [ + "tonic", + "async-graphql", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-cli", + "summary": "Rust CLI tooling.", + "values": [ + "clap", + "ratatui", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-libraries", + "summary": "Rust core libraries.", + "values": [ + "serde", + "uuid", + "chrono", + "reqwest", + "config", + "dashmap", + "parking-lot", + "secrecy", + "tokio-util", + "utoipa", + "validator", + "jsonwebtoken", + "argon2", + "tokio-test", + "mockall", + "proptest", + "insta", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + }, + { + "flag": "--rust-logging", + "summary": "Rust logging.", + "values": [ + "tracing", + "env-logger", + "none" + ], + "valueHint": null, + "defaultValue": "tracing", + "multiple": false + }, + { + "flag": "--rust-error-handling", + "summary": "Rust error handling.", + "values": [ + "anyhow-thiserror", + "eyre", + "none" + ], + "valueHint": null, + "defaultValue": "anyhow-thiserror", + "multiple": false + }, + { + "flag": "--rust-caching", + "summary": "Rust caching.", + "values": [ + "moka", + "redis", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-auth", + "summary": "Rust auth.", + "values": [ + "oauth2", + "torii", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-realtime", + "summary": "Rust realtime.", + "values": [ + "tokio-tungstenite", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-message-queue", + "summary": "Rust message queue.", + "values": [ + "lapin", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-observability", + "summary": "Rust observability.", + "values": [ + "opentelemetry", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--rust-templating", + "summary": "Rust templating.", + "values": [ + "askama", + "tera", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + } + ] + }, + { + "id": "python", + "title": "Python flags", + "description": "Flags for `--ecosystem python`.", + "flags": [ + { + "flag": "--python-web-framework", + "summary": "Python web framework.", + "values": [ + "fastapi", + "django", + "flask", + "litestar", + "starlette", + "none" + ], + "valueHint": null, + "defaultValue": "fastapi", + "multiple": false + }, + { + "flag": "--python-orm", + "summary": "Python ORM / database.", + "values": [ + "sqlalchemy", + "sqlmodel", + "tortoise-orm", + "peewee", + "none" + ], + "valueHint": null, + "defaultValue": "sqlalchemy", + "multiple": false + }, + { + "flag": "--python-validation", + "summary": "Validation library.", + "values": [ + "pydantic", + "none" + ], + "valueHint": null, + "defaultValue": "pydantic", + "multiple": false + }, + { + "flag": "--python-ai", + "summary": "Python AI / ML libraries.", + "values": [ + "langchain", + "llamaindex", + "openai-sdk", + "anthropic-sdk", + "langgraph", + "crewai", + "haystack", + "pydantic-ai", + "google-adk", + "smolagents", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + }, + { + "flag": "--python-auth", + "summary": "Python auth.", + "values": [ + "authlib", + "jwt", + "fastapi-users", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--python-api", + "summary": "Python API framework.", + "values": [ + "django-rest-framework", + "django-ninja", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--python-task-queue", + "summary": "Python task queue.", + "values": [ + "celery", + "rq", + "dramatiq", + "huey", + "taskiq", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--python-graphql", + "summary": "Python GraphQL.", + "values": [ + "strawberry", + "ariadne", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--python-quality", + "summary": "Python code quality tool.", + "values": [ + "ruff", + "mypy", + "pyright", + "none" + ], + "valueHint": null, + "defaultValue": "ruff", + "multiple": false + }, + { + "flag": "--python-testing", + "summary": "Python testing libraries.", + "values": [ + "pytest", + "hypothesis", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + }, + { + "flag": "--python-caching", + "summary": "Python caching.", + "values": [ + "redis", + "aiocache", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--python-realtime", + "summary": "Python realtime.", + "values": [ + "python-socketio", + "websockets", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--python-observability", + "summary": "Python observability.", + "values": [ + "opentelemetry", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--python-cli", + "summary": "Python CLI tooling.", + "values": [ + "typer", + "click", + "rich", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + } + ] + }, + { + "id": "go", + "title": "Go flags", + "description": "Flags for `--ecosystem go`. Global `--auth` also applies.", + "flags": [ + { + "flag": "--go-web-framework", + "summary": "Go web framework.", + "values": [ + "gin", + "echo", + "fiber", + "chi", + "stdlib", + "none" + ], + "valueHint": null, + "defaultValue": "gin", + "multiple": false + }, + { + "flag": "--go-orm", + "summary": "Go ORM / database.", + "values": [ + "gorm", + "sqlc", + "ent", + "bun", + "none" + ], + "valueHint": null, + "defaultValue": "gorm", + "multiple": false + }, + { + "flag": "--go-api", + "summary": "Go API layer.", + "values": [ + "grpc-go", + "gqlgen", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--go-cli", + "summary": "Go CLI tooling.", + "values": [ + "cobra", + "bubbletea", + "urfave-cli", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--go-logging", + "summary": "Go logging.", + "values": [ + "zap", + "zerolog", + "slog", + "logrus", + "none" + ], + "valueHint": null, + "defaultValue": "zap", + "multiple": false + }, + { + "flag": "--go-auth", + "summary": "Go-native auth helpers.", + "values": [ + "casbin", + "jwt", + "goth", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--go-testing", + "summary": "Go testing libraries.", + "values": [ + "testify", + "gomock", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + }, + { + "flag": "--go-realtime", + "summary": "Go realtime.", + "values": [ + "gorilla-websocket", + "centrifuge", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--go-message-queue", + "summary": "Go message queue.", + "values": [ + "nats", + "watermill", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--go-caching", + "summary": "Go caching.", + "values": [ + "redis", + "ristretto", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--go-config", + "summary": "Go config loader.", + "values": [ + "viper", + "koanf", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--go-observability", + "summary": "Go observability.", + "values": [ + "opentelemetry", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + } + ] + }, + { + "id": "java", + "title": "Java flags", + "description": "Flags for `--ecosystem java`.", + "flags": [ + { + "flag": "--java-web-framework", + "summary": "Java web framework.", + "values": [ + "spring-boot", + "quarkus", + "micronaut", + "none" + ], + "valueHint": null, + "defaultValue": "spring-boot", + "multiple": false + }, + { + "flag": "--java-build-tool", + "summary": "Build tool.", + "values": [ + "maven", + "gradle", + "none" + ], + "valueHint": null, + "defaultValue": "maven", + "multiple": false + }, + { + "flag": "--java-orm", + "summary": "Java ORM / database.", + "values": [ + "spring-data-jpa", + "jooq", + "mybatis", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--java-auth", + "summary": "Java auth.", + "values": [ + "spring-security", + "keycloak", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--java-api", + "summary": "Java API layer.", + "values": [ + "spring-graphql", + "openapi-generator", + "grpc", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--java-logging", + "summary": "Java logging.", + "values": [ + "logback", + "log4j2", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--java-libraries", + "summary": "Java libraries.", + "values": [ + "spring-actuator", + "spring-validation", + "flyway", + "liquibase", + "springdoc-openapi", + "lombok", + "mapstruct", + "caffeine", + "resilience4j", + "spring-webflux", + "spring-batch", + "spring-kafka", + "spring-mail", + "spring-devtools", + "micrometer-prometheus", + "thymeleaf", + "spring-amqp", + "opentelemetry-java", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + }, + { + "flag": "--java-testing-libraries", + "summary": "Java testing libraries.", + "values": [ + "junit5", + "mockito", + "testcontainers", + "assertj", + "rest-assured", + "wiremock", + "awaitility", + "archunit", + "jqwik", + "none" + ], + "valueHint": null, + "defaultValue": "junit5", + "multiple": true + } + ] + }, + { + "id": "dotnet", + "title": ".NET flags", + "description": "Flags for `--ecosystem dotnet`.", + "flags": [ + { + "flag": "--dotnet-web-framework", + "summary": ".NET web framework.", + "values": [ + "aspnet-minimal", + "aspnet-mvc", + "aspnet-blazor", + "none" + ], + "valueHint": null, + "defaultValue": "aspnet-minimal", + "multiple": false + }, + { + "flag": "--dotnet-orm", + "summary": ".NET data access.", + "values": [ + "ef-core", + "dapper", + "linq2db", + "none" + ], + "valueHint": null, + "defaultValue": "ef-core", + "multiple": false + }, + { + "flag": "--dotnet-auth", + "summary": ".NET auth.", + "values": [ + "aspnet-identity", + "duende-identityserver", + "auth0-aspnet", + "none" + ], + "valueHint": null, + "defaultValue": "aspnet-identity", + "multiple": false + }, + { + "flag": "--dotnet-api", + "summary": ".NET API style.", + "values": [ + "minimal-api", + "graphql-hotchocolate", + "grpc-dotnet", + "none" + ], + "valueHint": null, + "defaultValue": "minimal-api", + "multiple": false + }, + { + "flag": "--dotnet-testing", + "summary": ".NET testing libraries.", + "values": [ + "xunit", + "nunit", + "moq", + "testcontainers-dotnet", + "none" + ], + "valueHint": null, + "defaultValue": "xunit", + "multiple": true + }, + { + "flag": "--dotnet-job-queue", + "summary": ".NET background jobs.", + "values": [ + "hangfire", + "quartz-net", + "hosted-services", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--dotnet-realtime", + "summary": ".NET realtime.", + "values": [ + "signalr", + "none" + ], + "valueHint": null, + "defaultValue": "signalr", + "multiple": false + }, + { + "flag": "--dotnet-observability", + "summary": ".NET observability.", + "values": [ + "opentelemetry-dotnet", + "serilog", + "nlog", + "health-checks", + "none" + ], + "valueHint": null, + "defaultValue": "serilog", + "multiple": true + }, + { + "flag": "--dotnet-validation", + "summary": ".NET validation.", + "values": [ + "fluentvalidation", + "data-annotations", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--dotnet-caching", + "summary": ".NET caching.", + "values": [ + "redis", + "memory-cache", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--dotnet-deploy", + "summary": ".NET deploy target.", + "values": [ + "docker", + "azure", + "aws", + "none" + ], + "valueHint": null, + "defaultValue": "docker", + "multiple": false + } + ] + }, + { + "id": "elixir", + "title": "Elixir flags", + "description": "Flags for `--ecosystem elixir`.", + "flags": [ + { + "flag": "--elixir-web-framework", + "summary": "Elixir web framework.", + "values": [ + "phoenix", + "phoenix-live-view", + "none" + ], + "valueHint": null, + "defaultValue": "phoenix", + "multiple": false + }, + { + "flag": "--elixir-orm", + "summary": "Elixir ORM / database.", + "values": [ + "ecto", + "ecto-sql", + "none" + ], + "valueHint": null, + "defaultValue": "ecto-sql", + "multiple": false + }, + { + "flag": "--elixir-auth", + "summary": "Elixir auth.", + "values": [ + "phx-gen-auth", + "ueberauth", + "guardian", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--elixir-api", + "summary": "Elixir API layer.", + "values": [ + "rest", + "absinthe", + "grpc", + "none" + ], + "valueHint": null, + "defaultValue": "rest", + "multiple": false + }, + { + "flag": "--elixir-realtime", + "summary": "Elixir realtime.", + "values": [ + "channels", + "presence", + "pubsub", + "live-view-streams", + "none" + ], + "valueHint": null, + "defaultValue": "channels", + "multiple": false + }, + { + "flag": "--elixir-jobs", + "summary": "Elixir jobs.", + "values": [ + "oban", + "quantum", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--elixir-validation", + "summary": "Elixir validation.", + "values": [ + "ecto-changesets", + "nimble-options", + "none" + ], + "valueHint": null, + "defaultValue": "ecto-changesets", + "multiple": false + }, + { + "flag": "--elixir-http", + "summary": "Elixir HTTP client.", + "values": [ + "req", + "finch", + "none" + ], + "valueHint": null, + "defaultValue": "req", + "multiple": false + }, + { + "flag": "--elixir-json", + "summary": "Elixir JSON library.", + "values": [ + "jason", + "none" + ], + "valueHint": null, + "defaultValue": "jason", + "multiple": false + }, + { + "flag": "--elixir-email", + "summary": "Elixir email.", + "values": [ + "swoosh", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--elixir-caching", + "summary": "Elixir caching.", + "values": [ + "cachex", + "nebulex", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--elixir-observability", + "summary": "Elixir observability.", + "values": [ + "telemetry", + "opentelemetry", + "prom_ex", + "none" + ], + "valueHint": null, + "defaultValue": "telemetry", + "multiple": false + }, + { + "flag": "--elixir-testing", + "summary": "Elixir testing.", + "values": [ + "ex_unit", + "mox", + "bypass", + "wallaby", + "none" + ], + "valueHint": null, + "defaultValue": "ex_unit", + "multiple": false + }, + { + "flag": "--elixir-quality", + "summary": "Elixir code quality.", + "values": [ + "credo", + "dialyxir", + "sobelow", + "none" + ], + "valueHint": null, + "defaultValue": "credo", + "multiple": false + }, + { + "flag": "--elixir-deploy", + "summary": "Elixir deploy target.", + "values": [ + "docker", + "fly", + "gigalixir", + "mix-release", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": false + }, + { + "flag": "--elixir-libraries", + "summary": "Elixir libraries.", + "values": [ + "broadway", + "nx", + "none" + ], + "valueHint": null, + "defaultValue": "none", + "multiple": true + } + ] + } +]; + +export const CLI_FLAG_GROUPS_BY_ID: Record = Object.fromEntries( + CLI_FLAG_GROUPS.map((group) => [group.id, group]), +); diff --git a/apps/web/src/lib/docs/source.ts b/apps/web/src/lib/docs/source.ts index 8446b41fb..b02d58ca4 100644 --- a/apps/web/src/lib/docs/source.ts +++ b/apps/web/src/lib/docs/source.ts @@ -162,6 +162,26 @@ const DOC_FOLDER_TITLE_TRANSLATIONS: Record` in `content/docs/cli/create.mdx` ## Stack Guides -- [ ] Add deeper frontend framework guides -- [ ] Add backend framework guides -- [ ] Add database + ORM pairing guide -- [ ] Add auth provider comparison +- [x] Add deeper frontend framework guides — `content/docs/stack-guides/frontend-frameworks.mdx` +- [x] Add backend framework guides — `content/docs/stack-guides/backend-frameworks.mdx` +- [x] Add database + ORM pairing guide — `content/docs/stack-guides/database-orm-pairing.mdx` +- [x] Add auth provider comparison — `content/docs/stack-guides/auth-providers.mdx` ## Section Guides -- [ ] Add environment-variable setup per provider -- [ ] Add database provisioning guides for Turso, Neon, Supabase, PlanetScale, and other supported providers +- [x] Add environment-variable setup per provider — `content/docs/provider-setup/environment-variables.mdx` +- [x] Add database provisioning guides for Turso, Neon, Supabase, PlanetScale, and other supported providers — `content/docs/provider-setup/database-provisioning.mdx` ## Next Documentation Updates diff --git a/packages/types/src/cli-flags.ts b/packages/types/src/cli-flags.ts new file mode 100644 index 000000000..05435d480 --- /dev/null +++ b/packages/types/src/cli-flags.ts @@ -0,0 +1,510 @@ +import { createCliDefaultProjectConfigBase } from "./defaults"; +import { + getCategoryCliValues, + type OptionCategory, + type OptionCategoryEcosystem, +} from "./option-metadata"; +import { DIRECTORY_CONFLICT_VALUES, ECOSYSTEM_VALUES, TEMPLATE_VALUES } from "./schemas"; + +type CliDefaultConfig = ReturnType; +type CliConfigKey = keyof CliDefaultConfig; + +/** + * How a CLI flag's accepted values are derived. Every source resolves against + * the shared option metadata / schema value lists in this package so the CLI + * reference table never drifts from the real generator surface. + */ +export type CliFlagValueSource = + | { readonly kind: "category"; readonly category: OptionCategory } + | { readonly kind: "categories"; readonly categories: readonly OptionCategory[] } + | { readonly kind: "literal"; readonly values: readonly string[] } + | { readonly kind: "boolean" } + | { readonly kind: "freeform"; readonly hint: string }; + +export type CliFlagDefinition = { + /** Canonical flag name without the leading dashes, e.g. `frontend`. */ + readonly flag: string; + readonly summary: string; + readonly source: CliFlagValueSource; + /** Project-config field the default value is read from, when applicable. */ + readonly configKey?: CliConfigKey; + /** Overrides the config-derived default when the config default is ecosystem-dependent. */ + readonly defaultLiteral?: string; + /** Whether the flag accepts multiple space-separated values. */ + readonly multiple?: boolean; +}; + +export type CliFlagGroupDefinition = { + readonly id: string; + readonly title: string; + readonly description: string; + readonly ecosystem?: OptionCategoryEcosystem; + readonly flags: readonly CliFlagDefinition[]; +}; + +export type ResolvedCliFlag = { + /** Flag rendered with the leading dashes, e.g. `--frontend`. */ + readonly flag: string; + readonly summary: string; + readonly values: readonly string[]; + readonly valueHint: string | null; + readonly defaultValue: string | null; + readonly multiple: boolean; +}; + +export type ResolvedCliFlagGroup = { + readonly id: string; + readonly title: string; + readonly description: string; + readonly ecosystem?: OptionCategoryEcosystem; + readonly flags: readonly ResolvedCliFlag[]; +}; + +export const CLI_FLAG_GROUP_DEFINITIONS: readonly CliFlagGroupDefinition[] = [ + { + id: "common", + title: "Common flags", + description: "Flags that apply to every ecosystem and control workflow behavior.", + flags: [ + { + flag: "ecosystem", + summary: "Language/runtime ecosystem to scaffold.", + source: { kind: "literal", values: ECOSYSTEM_VALUES }, + configKey: "ecosystem", + }, + { + flag: "template", + summary: "Start from a curated stack preset instead of individual flags.", + source: { kind: "literal", values: TEMPLATE_VALUES }, + }, + { + flag: "part", + summary: "Add a multi-ecosystem stack part. Repeat once per part.", + source: { kind: "freeform", hint: "role:ecosystem:tool (e.g. frontend:typescript:next)" }, + }, + { + flag: "addons", + summary: "Repo tooling and platform addons.", + source: { kind: "categories", categories: ["appPlatforms", "codeQuality", "documentation"] }, + configKey: "addons", + multiple: true, + }, + { + flag: "examples", + summary: "Optional example features to include.", + source: { kind: "category", category: "examples" }, + configKey: "examples", + multiple: true, + }, + { + flag: "ai-docs", + summary: "Agent instruction files to generate.", + source: { kind: "category", category: "aiDocs" }, + configKey: "aiDocs", + multiple: true, + }, + { + flag: "package-manager", + summary: "Package manager for the generated workspace.", + source: { kind: "category", category: "packageManager" }, + configKey: "packageManager", + }, + { + flag: "workspace-shape", + summary: "Monorepo or single-app workspace layout.", + source: { kind: "category", category: "workspaceShape" }, + configKey: "workspaceShape", + }, + { + flag: "version-channel", + summary: "Dependency version channel.", + source: { kind: "category", category: "versionChannel" }, + configKey: "versionChannel", + }, + { + flag: "web-deploy", + summary: "Deployment target config for the web app.", + source: { kind: "category", category: "webDeploy" }, + configKey: "webDeploy", + }, + { + flag: "server-deploy", + summary: "Deployment target config for the server.", + source: { kind: "category", category: "serverDeploy" }, + configKey: "serverDeploy", + }, + { + flag: "directory-conflict", + summary: "Strategy when the target directory already exists.", + source: { kind: "literal", values: DIRECTORY_CONFLICT_VALUES }, + }, + { + flag: "install", + summary: "Install dependencies after scaffolding.", + source: { kind: "boolean" }, + configKey: "install", + }, + { + flag: "git", + summary: "Initialize a Git repository.", + source: { kind: "boolean" }, + configKey: "git", + }, + { + flag: "dry-run", + summary: "Preview generated files without writing them.", + source: { kind: "freeform", hint: "boolean flag" }, + }, + { + flag: "verify", + summary: "Run generated checks after scaffolding where supported.", + source: { kind: "freeform", hint: "boolean flag" }, + }, + { + flag: "yes", + summary: "Accept defaults. Conflicts with core stack flags.", + source: { kind: "freeform", hint: "boolean flag" }, + }, + { + flag: "yolo", + summary: "Skip safety confirmations where supported.", + source: { kind: "freeform", hint: "boolean flag" }, + }, + { + flag: "verbose", + summary: "Print detailed scaffold output.", + source: { kind: "freeform", hint: "boolean flag" }, + }, + { + flag: "disable-analytics", + summary: "Opt out of anonymous CLI analytics.", + source: { kind: "freeform", hint: "boolean flag" }, + }, + ], + }, + { + id: "typescript-stack", + title: "TypeScript stack", + description: "Core stack flags for `--ecosystem typescript`.", + ecosystem: "typescript", + flags: [ + { + flag: "frontend", + summary: "Web frontend framework(s).", + source: { kind: "category", category: "webFrontend" }, + configKey: "frontend", + multiple: true, + }, + { + flag: "backend", + summary: "Backend framework. `self` pairs with fullstack frontends.", + source: { kind: "category", category: "backend" }, + configKey: "backend", + }, + { + flag: "runtime", + summary: "Server runtime.", + source: { kind: "category", category: "runtime" }, + configKey: "runtime", + }, + { + flag: "database", + summary: "Database engine.", + source: { kind: "category", category: "database" }, + configKey: "database", + }, + { + flag: "orm", + summary: "ORM / query layer.", + source: { kind: "category", category: "orm" }, + configKey: "orm", + }, + { + flag: "db-setup", + summary: "Hosted database provider setup.", + source: { kind: "category", category: "dbSetup" }, + configKey: "dbSetup", + }, + { + flag: "auth", + summary: "Authentication provider.", + source: { kind: "category", category: "auth" }, + configKey: "auth", + }, + { + flag: "api", + summary: "API layer. tRPC is React-oriented.", + source: { kind: "category", category: "api" }, + configKey: "api", + }, + { + flag: "astro-integration", + summary: "Astro UI framework integration (Astro frontends).", + source: { kind: "category", category: "astroIntegration" }, + configKey: "astroIntegration", + }, + { + flag: "manual-db", + summary: "Skip provider-specific database setup prompts.", + source: { kind: "freeform", hint: "boolean flag" }, + }, + ], + }, + { + id: "typescript-services", + title: "TypeScript services", + description: "Optional service integrations for TypeScript stacks.", + ecosystem: "typescript", + flags: [ + { flag: "ai", summary: "AI SDK / agent framework.", source: { kind: "category", category: "ai" }, configKey: "ai" }, + { flag: "payments", summary: "Payments provider.", source: { kind: "category", category: "payments" }, configKey: "payments" }, + { flag: "email", summary: "Email provider.", source: { kind: "category", category: "email" }, configKey: "email" }, + { flag: "realtime", summary: "Realtime transport.", source: { kind: "category", category: "realtime" }, configKey: "realtime" }, + { flag: "job-queue", summary: "Background job / queue system.", source: { kind: "category", category: "jobQueue" }, configKey: "jobQueue" }, + { flag: "cms", summary: "Content management system.", source: { kind: "category", category: "cms" }, configKey: "cms" }, + { flag: "caching", summary: "Cache layer.", source: { kind: "category", category: "caching" }, configKey: "caching" }, + { flag: "search", summary: "Search engine.", source: { kind: "category", category: "search" }, configKey: "search" }, + { flag: "file-storage", summary: "Object storage provider.", source: { kind: "category", category: "fileStorage" }, configKey: "fileStorage" }, + { flag: "file-upload", summary: "File upload helper.", source: { kind: "category", category: "fileUpload" }, configKey: "fileUpload" }, + { flag: "analytics", summary: "Web analytics provider.", source: { kind: "category", category: "analytics" }, configKey: "analytics" }, + { flag: "feature-flags", summary: "Feature flag platform.", source: { kind: "category", category: "featureFlags" }, configKey: "featureFlags" }, + { flag: "vector-db", summary: "Vector database.", source: { kind: "category", category: "vectorDb" }, configKey: "vectorDb" }, + { flag: "rate-limit", summary: "Rate limiting helper.", source: { kind: "category", category: "rateLimit" }, configKey: "rateLimit" }, + { flag: "i18n", summary: "Internationalization library.", source: { kind: "category", category: "i18n" }, configKey: "i18n" }, + { flag: "effect", summary: "Effect capability level.", source: { kind: "category", category: "effect" }, configKey: "effect" }, + { flag: "logging", summary: "Logging library.", source: { kind: "category", category: "logging" }, configKey: "logging" }, + { flag: "observability", summary: "Observability provider.", source: { kind: "category", category: "observability" }, configKey: "observability" }, + ], + }, + { + id: "typescript-ui", + title: "TypeScript UI and app behavior", + description: "Frontend styling, state, forms, validation, and testing flags.", + ecosystem: "typescript", + flags: [ + { flag: "css-framework", summary: "CSS framework.", source: { kind: "category", category: "cssFramework" }, configKey: "cssFramework" }, + { flag: "ui-library", summary: "Component library.", source: { kind: "category", category: "uiLibrary" }, configKey: "uiLibrary" }, + { flag: "state-management", summary: "Client state manager.", source: { kind: "category", category: "stateManagement" }, configKey: "stateManagement" }, + { flag: "forms", summary: "Form library.", source: { kind: "category", category: "forms" }, configKey: "forms" }, + { flag: "validation", summary: "Schema validation library.", source: { kind: "category", category: "validation" }, configKey: "validation" }, + { flag: "testing", summary: "Testing setup.", source: { kind: "category", category: "testing" }, configKey: "testing" }, + { flag: "animation", summary: "Animation library.", source: { kind: "category", category: "animation" }, configKey: "animation" }, + ], + }, + { + id: "shadcn", + title: "shadcn/ui flags", + description: "Only apply when `--ui-library shadcn-ui` is selected.", + ecosystem: "typescript", + flags: [ + { flag: "shadcn-base", summary: "shadcn primitive base.", source: { kind: "category", category: "shadcnBase" }, configKey: "shadcnBase" }, + { flag: "shadcn-style", summary: "shadcn style preset.", source: { kind: "category", category: "shadcnStyle" }, configKey: "shadcnStyle" }, + { flag: "shadcn-icon-library", summary: "Icon library.", source: { kind: "category", category: "shadcnIconLibrary" }, configKey: "shadcnIconLibrary" }, + { flag: "shadcn-color-theme", summary: "Color theme.", source: { kind: "category", category: "shadcnColorTheme" }, configKey: "shadcnColorTheme" }, + { flag: "shadcn-base-color", summary: "Base neutral color.", source: { kind: "category", category: "shadcnBaseColor" }, configKey: "shadcnBaseColor" }, + { flag: "shadcn-font", summary: "Default font.", source: { kind: "category", category: "shadcnFont" }, configKey: "shadcnFont" }, + { flag: "shadcn-radius", summary: "Corner radius scale.", source: { kind: "category", category: "shadcnRadius" }, configKey: "shadcnRadius" }, + ], + }, + { + id: "react-native", + title: "React Native flags", + description: "Flags for `--ecosystem react-native`.", + ecosystem: "react-native", + flags: [ + { + flag: "frontend", + summary: "Expo native frontend styling.", + source: { kind: "category", category: "nativeFrontend" }, + defaultLiteral: "native-bare", + multiple: true, + }, + { flag: "mobile-navigation", summary: "Navigation library.", source: { kind: "category", category: "mobileNavigation" }, configKey: "mobileNavigation" }, + { flag: "mobile-ui", summary: "Mobile UI kit.", source: { kind: "category", category: "mobileUI" }, configKey: "mobileUI" }, + { flag: "mobile-storage", summary: "On-device storage.", source: { kind: "category", category: "mobileStorage" }, configKey: "mobileStorage" }, + { flag: "mobile-testing", summary: "Mobile testing setup.", source: { kind: "category", category: "mobileTesting" }, configKey: "mobileTesting" }, + { flag: "mobile-push", summary: "Push notifications.", source: { kind: "category", category: "mobilePush" }, configKey: "mobilePush" }, + { flag: "mobile-ota", summary: "Over-the-air updates.", source: { kind: "category", category: "mobileOTA" }, configKey: "mobileOTA" }, + { flag: "mobile-deep-linking", summary: "Deep linking.", source: { kind: "category", category: "mobileDeepLinking" }, configKey: "mobileDeepLinking" }, + ], + }, + { + id: "rust", + title: "Rust flags", + description: "Flags for `--ecosystem rust`.", + ecosystem: "rust", + flags: [ + { flag: "rust-web-framework", summary: "Rust web framework.", source: { kind: "category", category: "rustWebFramework" }, configKey: "rustWebFramework" }, + { flag: "rust-frontend", summary: "WASM frontend.", source: { kind: "category", category: "rustFrontend" }, configKey: "rustFrontend" }, + { flag: "rust-orm", summary: "Rust ORM / database.", source: { kind: "category", category: "rustOrm" }, configKey: "rustOrm" }, + { flag: "rust-api", summary: "Rust API layer.", source: { kind: "category", category: "rustApi" }, configKey: "rustApi" }, + { flag: "rust-cli", summary: "Rust CLI tooling.", source: { kind: "category", category: "rustCli" }, configKey: "rustCli" }, + { flag: "rust-libraries", summary: "Rust core libraries.", source: { kind: "category", category: "rustLibraries" }, configKey: "rustLibraries", multiple: true }, + { flag: "rust-logging", summary: "Rust logging.", source: { kind: "category", category: "rustLogging" }, configKey: "rustLogging" }, + { flag: "rust-error-handling", summary: "Rust error handling.", source: { kind: "category", category: "rustErrorHandling" }, configKey: "rustErrorHandling" }, + { flag: "rust-caching", summary: "Rust caching.", source: { kind: "category", category: "rustCaching" }, configKey: "rustCaching" }, + { flag: "rust-auth", summary: "Rust auth.", source: { kind: "category", category: "rustAuth" }, configKey: "rustAuth" }, + { flag: "rust-realtime", summary: "Rust realtime.", source: { kind: "category", category: "rustRealtime" }, configKey: "rustRealtime" }, + { flag: "rust-message-queue", summary: "Rust message queue.", source: { kind: "category", category: "rustMessageQueue" }, configKey: "rustMessageQueue" }, + { flag: "rust-observability", summary: "Rust observability.", source: { kind: "category", category: "rustObservability" }, configKey: "rustObservability" }, + { flag: "rust-templating", summary: "Rust templating.", source: { kind: "category", category: "rustTemplating" }, configKey: "rustTemplating" }, + ], + }, + { + id: "python", + title: "Python flags", + description: "Flags for `--ecosystem python`.", + ecosystem: "python", + flags: [ + { flag: "python-web-framework", summary: "Python web framework.", source: { kind: "category", category: "pythonWebFramework" }, configKey: "pythonWebFramework" }, + { flag: "python-orm", summary: "Python ORM / database.", source: { kind: "category", category: "pythonOrm" }, configKey: "pythonOrm" }, + { flag: "python-validation", summary: "Validation library.", source: { kind: "category", category: "pythonValidation" }, configKey: "pythonValidation" }, + { flag: "python-ai", summary: "Python AI / ML libraries.", source: { kind: "category", category: "pythonAi" }, configKey: "pythonAi", multiple: true }, + { flag: "python-auth", summary: "Python auth.", source: { kind: "category", category: "pythonAuth" }, configKey: "pythonAuth" }, + { flag: "python-api", summary: "Python API framework.", source: { kind: "category", category: "pythonApi" }, configKey: "pythonApi" }, + { flag: "python-task-queue", summary: "Python task queue.", source: { kind: "category", category: "pythonTaskQueue" }, configKey: "pythonTaskQueue" }, + { flag: "python-graphql", summary: "Python GraphQL.", source: { kind: "category", category: "pythonGraphql" }, configKey: "pythonGraphql" }, + { flag: "python-quality", summary: "Python code quality tool.", source: { kind: "category", category: "pythonQuality" }, configKey: "pythonQuality" }, + { flag: "python-testing", summary: "Python testing libraries.", source: { kind: "category", category: "pythonTesting" }, configKey: "pythonTesting", multiple: true }, + { flag: "python-caching", summary: "Python caching.", source: { kind: "category", category: "pythonCaching" }, configKey: "pythonCaching" }, + { flag: "python-realtime", summary: "Python realtime.", source: { kind: "category", category: "pythonRealtime" }, configKey: "pythonRealtime" }, + { flag: "python-observability", summary: "Python observability.", source: { kind: "category", category: "pythonObservability" }, configKey: "pythonObservability" }, + { flag: "python-cli", summary: "Python CLI tooling.", source: { kind: "category", category: "pythonCli" }, configKey: "pythonCli", multiple: true }, + ], + }, + { + id: "go", + title: "Go flags", + description: "Flags for `--ecosystem go`. Global `--auth` also applies.", + ecosystem: "go", + flags: [ + { flag: "go-web-framework", summary: "Go web framework.", source: { kind: "category", category: "goWebFramework" }, configKey: "goWebFramework" }, + { flag: "go-orm", summary: "Go ORM / database.", source: { kind: "category", category: "goOrm" }, configKey: "goOrm" }, + { flag: "go-api", summary: "Go API layer.", source: { kind: "category", category: "goApi" }, configKey: "goApi" }, + { flag: "go-cli", summary: "Go CLI tooling.", source: { kind: "category", category: "goCli" }, configKey: "goCli" }, + { flag: "go-logging", summary: "Go logging.", source: { kind: "category", category: "goLogging" }, configKey: "goLogging" }, + { flag: "go-auth", summary: "Go-native auth helpers.", source: { kind: "category", category: "goAuth" }, configKey: "goAuth" }, + { flag: "go-testing", summary: "Go testing libraries.", source: { kind: "category", category: "goTesting" }, configKey: "goTesting", multiple: true }, + { flag: "go-realtime", summary: "Go realtime.", source: { kind: "category", category: "goRealtime" }, configKey: "goRealtime" }, + { flag: "go-message-queue", summary: "Go message queue.", source: { kind: "category", category: "goMessageQueue" }, configKey: "goMessageQueue" }, + { flag: "go-caching", summary: "Go caching.", source: { kind: "category", category: "goCaching" }, configKey: "goCaching" }, + { flag: "go-config", summary: "Go config loader.", source: { kind: "category", category: "goConfig" }, configKey: "goConfig" }, + { flag: "go-observability", summary: "Go observability.", source: { kind: "category", category: "goObservability" }, configKey: "goObservability" }, + ], + }, + { + id: "java", + title: "Java flags", + description: "Flags for `--ecosystem java`.", + ecosystem: "java", + flags: [ + { flag: "java-web-framework", summary: "Java web framework.", source: { kind: "category", category: "javaWebFramework" }, configKey: "javaWebFramework" }, + { flag: "java-build-tool", summary: "Build tool.", source: { kind: "category", category: "javaBuildTool" }, configKey: "javaBuildTool" }, + { flag: "java-orm", summary: "Java ORM / database.", source: { kind: "category", category: "javaOrm" }, configKey: "javaOrm" }, + { flag: "java-auth", summary: "Java auth.", source: { kind: "category", category: "javaAuth" }, configKey: "javaAuth" }, + { flag: "java-api", summary: "Java API layer.", source: { kind: "category", category: "javaApi" }, configKey: "javaApi" }, + { flag: "java-logging", summary: "Java logging.", source: { kind: "category", category: "javaLogging" }, configKey: "javaLogging" }, + { flag: "java-libraries", summary: "Java libraries.", source: { kind: "category", category: "javaLibraries" }, configKey: "javaLibraries", multiple: true }, + { flag: "java-testing-libraries", summary: "Java testing libraries.", source: { kind: "category", category: "javaTestingLibraries" }, configKey: "javaTestingLibraries", multiple: true }, + ], + }, + { + id: "dotnet", + title: ".NET flags", + description: "Flags for `--ecosystem dotnet`.", + ecosystem: "dotnet", + flags: [ + { flag: "dotnet-web-framework", summary: ".NET web framework.", source: { kind: "category", category: "dotnetWebFramework" }, configKey: "dotnetWebFramework" }, + { flag: "dotnet-orm", summary: ".NET data access.", source: { kind: "category", category: "dotnetOrm" }, configKey: "dotnetOrm" }, + { flag: "dotnet-auth", summary: ".NET auth.", source: { kind: "category", category: "dotnetAuth" }, configKey: "dotnetAuth" }, + { flag: "dotnet-api", summary: ".NET API style.", source: { kind: "category", category: "dotnetApi" }, configKey: "dotnetApi" }, + { flag: "dotnet-testing", summary: ".NET testing libraries.", source: { kind: "category", category: "dotnetTesting" }, configKey: "dotnetTesting", multiple: true }, + { flag: "dotnet-job-queue", summary: ".NET background jobs.", source: { kind: "category", category: "dotnetJobQueue" }, configKey: "dotnetJobQueue" }, + { flag: "dotnet-realtime", summary: ".NET realtime.", source: { kind: "category", category: "dotnetRealtime" }, configKey: "dotnetRealtime" }, + { flag: "dotnet-observability", summary: ".NET observability.", source: { kind: "category", category: "dotnetObservability" }, configKey: "dotnetObservability", multiple: true }, + { flag: "dotnet-validation", summary: ".NET validation.", source: { kind: "category", category: "dotnetValidation" }, configKey: "dotnetValidation" }, + { flag: "dotnet-caching", summary: ".NET caching.", source: { kind: "category", category: "dotnetCaching" }, configKey: "dotnetCaching" }, + { flag: "dotnet-deploy", summary: ".NET deploy target.", source: { kind: "category", category: "dotnetDeploy" }, configKey: "dotnetDeploy" }, + ], + }, + { + id: "elixir", + title: "Elixir flags", + description: "Flags for `--ecosystem elixir`.", + ecosystem: "elixir", + flags: [ + { flag: "elixir-web-framework", summary: "Elixir web framework.", source: { kind: "category", category: "elixirWebFramework" }, configKey: "elixirWebFramework" }, + { flag: "elixir-orm", summary: "Elixir ORM / database.", source: { kind: "category", category: "elixirOrm" }, configKey: "elixirOrm" }, + { flag: "elixir-auth", summary: "Elixir auth.", source: { kind: "category", category: "elixirAuth" }, configKey: "elixirAuth" }, + { flag: "elixir-api", summary: "Elixir API layer.", source: { kind: "category", category: "elixirApi" }, configKey: "elixirApi" }, + { flag: "elixir-realtime", summary: "Elixir realtime.", source: { kind: "category", category: "elixirRealtime" }, configKey: "elixirRealtime" }, + { flag: "elixir-jobs", summary: "Elixir jobs.", source: { kind: "category", category: "elixirJobs" }, configKey: "elixirJobs" }, + { flag: "elixir-validation", summary: "Elixir validation.", source: { kind: "category", category: "elixirValidation" }, configKey: "elixirValidation" }, + { flag: "elixir-http", summary: "Elixir HTTP client.", source: { kind: "category", category: "elixirHttp" }, configKey: "elixirHttp" }, + { flag: "elixir-json", summary: "Elixir JSON library.", source: { kind: "category", category: "elixirJson" }, configKey: "elixirJson" }, + { flag: "elixir-email", summary: "Elixir email.", source: { kind: "category", category: "elixirEmail" }, configKey: "elixirEmail" }, + { flag: "elixir-caching", summary: "Elixir caching.", source: { kind: "category", category: "elixirCaching" }, configKey: "elixirCaching" }, + { flag: "elixir-observability", summary: "Elixir observability.", source: { kind: "category", category: "elixirObservability" }, configKey: "elixirObservability" }, + { flag: "elixir-testing", summary: "Elixir testing.", source: { kind: "category", category: "elixirTesting" }, configKey: "elixirTesting" }, + { flag: "elixir-quality", summary: "Elixir code quality.", source: { kind: "category", category: "elixirQuality" }, configKey: "elixirQuality" }, + { flag: "elixir-deploy", summary: "Elixir deploy target.", source: { kind: "category", category: "elixirDeploy" }, configKey: "elixirDeploy" }, + { flag: "elixir-libraries", summary: "Elixir libraries.", source: { kind: "category", category: "elixirLibraries" }, configKey: "elixirLibraries", multiple: true }, + ], + }, +]; + +function resolveValues(def: CliFlagDefinition): { values: string[]; valueHint: string | null } { + const source = def.source; + switch (source.kind) { + case "boolean": + return { values: [], valueHint: `--${def.flag} / --no-${def.flag}` }; + case "freeform": + return { values: [], valueHint: source.hint }; + case "literal": + return { values: [...source.values], valueHint: null }; + case "category": + case "categories": { + const categories = source.kind === "category" ? [source.category] : source.categories; + let values = [...new Set(categories.flatMap((category) => getCategoryCliValues(category)))]; + if (def.multiple && !values.includes("none")) values = [...values, "none"]; + return { values, valueHint: null }; + } + } +} + +function formatDefault(def: CliFlagDefinition): string | null { + if (def.defaultLiteral !== undefined) return def.defaultLiteral; + if (!def.configKey) return null; + + const value = createCliDefaultProjectConfigBase()[def.configKey]; + if (Array.isArray(value)) return value.length > 0 ? value.join(" ") : "none"; + if (typeof value === "boolean") return value ? `--${def.flag}` : `--no-${def.flag}`; + if (value === undefined || value === null) return null; + return String(value); +} + +/** + * Resolve the declarative flag registry into fully-populated rows (accepted + * values + defaults) sourced from the shared option metadata and CLI defaults. + * The docs CLI-flag data module and any tooling should call this rather than + * hand-maintaining flag tables. + */ +export function getCliFlagReference(): ResolvedCliFlagGroup[] { + return CLI_FLAG_GROUP_DEFINITIONS.map((group) => ({ + id: group.id, + title: group.title, + description: group.description, + ecosystem: group.ecosystem, + flags: group.flags.map((def) => { + const { values, valueHint } = resolveValues(def); + return { + flag: `--${def.flag}`, + summary: def.summary, + values, + valueHint, + defaultValue: formatDefault(def), + multiple: def.multiple ?? false, + } satisfies ResolvedCliFlag; + }), + })); +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 956cbcc70..b5a2e9169 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,6 +4,7 @@ export * from "./types"; export * from "./capabilities"; export * from "./compatibility"; export * from "./option-metadata"; +export * from "./cli-flags"; export * from "./local-dev"; export * from "./defaults"; export * from "./stack-translation"; From 1d9fb71d047d43e56b62d1c0ab36967b3d078fa5 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 6 Jul 2026 00:20:24 +0300 Subject: [PATCH 12/14] feat(java): add Kotlin language variant (javaLanguage: java|kotlin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New javaLanguage option (default java — every existing java combo stays BYTE-IDENTICAL). kotlin is a gated Spring-Boot-only variant emitting a compiling Kotlin project: build.gradle.kts gains kotlin(jvm)+plugin.spring(+plugin.jpa)+ jvmToolchain, pom.xml gains kotlin-maven-plugin, and 15 idiomatic .kt templates (Application, controllers, domain data classes, repository/service, security/ keycloak/otel config, cache, tests) replace the Java sources when kotlin. The java-base handler routes java vs kotlin sources + drops lombok/mapstruct (data classes replace them). Non-Spring-Boot kotlin requests (micronaut/quarkus/jooq/ mybatis/grpc/...) normalize to Java in BOTH compatibility.ts and the generator so a broken Kotlin project is never emitted. Verified: Kotlin Gradle ./gradlew compileKotlin compileTestKotlin BUILD SUCCESSFUL (JPA+security+graphql+caffeine+otel+keycloak variants), Kotlin Maven test-compile exit 0, java default byte-identical (template-snapshots 95/0, java compileJava BUILD SUCCESSFUL), java-ecosystem contract tests 27/0(cli)+24/0(web), FULL turbo test 3513/0, oxlint clean. Completes task #6 (Java gRPC + Kotlin). Kotlin scope: Spring Boot + common options (jpa/security/keycloak/graphql/junit5/mockito/assertj/caffeine/otel); other frameworks/options normalize to Java honestly. --- apps/cli/src/create-command-input.ts | 2 + apps/cli/src/helpers/core/stack-update.ts | 1 + apps/cli/src/index.ts | 1 + apps/cli/src/mcp.ts | 3 + apps/cli/src/prompts/config-prompts.ts | 23 ++++ apps/cli/src/prompts/java-ecosystem.ts | 35 ++++++ .../utils/generate-reproducible-command.ts | 5 + apps/web/src/lib/constant.ts | 19 ++++ apps/web/src/lib/tech-icons.ts | 1 + apps/web/src/lib/tech-resource-links.ts | 4 + .../src/template-handlers/java-base.ts | 104 ++++++++++++++++++ .../templates/java-base/build.gradle.kts.hbs | 21 ++++ .../templates/java-base/pom.xml.hbs | 49 +++++++++ .../__javaPackagePath__/Application.kt.hbs | 17 +++ .../cache/CachedTimeService.kt.hbs | 12 ++ .../config/OtelConfig.kt.hbs | 41 +++++++ .../config/ResourceServerConfig.kt.hbs | 31 ++++++ .../config/SecurityConfig.kt.hbs | 46 ++++++++ .../controller/CacheController.kt.hbs | 13 +++ .../controller/GraphqlController.kt.hbs | 13 +++ .../controller/HealthController.kt.hbs | 18 +++ .../controller/UserController.kt.hbs | 63 +++++++++++ .../__javaPackagePath__/domain/AppUser.kt.hbs | 51 +++++++++ .../repository/AppUserRepository.kt.hbs | 10 ++ .../service/AppUserService.kt.hbs | 21 ++++ .../ApplicationTests.kt.hbs | 18 +++ .../MockitoSmokeTest.kt.hbs | 17 +++ .../service/AppUserServiceTest.kt.hbs | 57 ++++++++++ packages/types/src/cli-flags.ts | 1 + packages/types/src/compatibility.ts | 43 ++++++++ packages/types/src/option-metadata.ts | 10 ++ packages/types/src/schemas.ts | 8 ++ packages/types/src/stack-translation.ts | 9 ++ packages/types/src/types.ts | 2 + 34 files changed, 769 insertions(+) create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/Application.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/cache/CachedTimeService.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/OtelConfig.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/ResourceServerConfig.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/SecurityConfig.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/CacheController.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/GraphqlController.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/HealthController.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/UserController.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/domain/AppUser.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/repository/AppUserRepository.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/service/AppUserService.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/ApplicationTests.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/MockitoSmokeTest.kt.hbs create mode 100644 packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/service/AppUserServiceTest.kt.hbs diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts index 4c6fbe61b..f5d86d1d6 100644 --- a/apps/cli/src/create-command-input.ts +++ b/apps/cli/src/create-command-input.ts @@ -73,6 +73,7 @@ import { GoWebFrameworkSchema, JavaAuthSchema, JavaApiSchema, + JavaLanguageSchema, JavaLoggingSchema, JavaBuildToolSchema, JavaLibrariesSchema, @@ -339,6 +340,7 @@ export const CreateCommandOptionsSchema = z.object({ javaWebFramework: JavaWebFrameworkSchema.optional().describe( "Java web framework (spring-boot, quarkus, none)", ), + javaLanguage: JavaLanguageSchema.optional().describe("JVM language (java, kotlin)"), javaBuildTool: JavaBuildToolSchema.optional().describe("Java build tool (maven, gradle, none)"), javaOrm: JavaOrmSchema.optional().describe("Java ORM/database (spring-data-jpa)"), javaAuth: JavaAuthSchema.optional().describe("Java auth (spring-security)"), diff --git a/apps/cli/src/helpers/core/stack-update.ts b/apps/cli/src/helpers/core/stack-update.ts index 464a22850..4a5608091 100644 --- a/apps/cli/src/helpers/core/stack-update.ts +++ b/apps/cli/src/helpers/core/stack-update.ts @@ -789,6 +789,7 @@ function buildCompatibilityInputFromConfig(config: ProjectConfig): Compatibility goCaching: asString(config.goCaching), goConfig: asString(config.goConfig), goObservability: asString(config.goObservability), + javaLanguage: asString(config.javaLanguage, "java"), javaWebFramework: asString(config.javaWebFramework), javaBuildTool: asString(config.javaBuildTool), javaOrm: asString(config.javaOrm), diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 09110defc..f292f8795 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -176,6 +176,7 @@ export async function createVirtual( goObservability: options.goObservability || "none", javaWebFramework: options.javaWebFramework || (options.ecosystem === "java" ? "spring-boot" : "none"), + javaLanguage: options.javaLanguage || "java", javaBuildTool: options.javaBuildTool || (options.ecosystem === "java" ? "maven" : "none"), javaOrm: options.javaOrm || "none", javaAuth: options.javaAuth || "none", diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index cf8514ec9..4871b8970 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -75,6 +75,7 @@ import { GoWebFrameworkSchema, JavaAuthSchema, JavaApiSchema, + JavaLanguageSchema, JavaLoggingSchema, JavaBuildToolSchema, JavaLibrariesSchema, @@ -537,6 +538,7 @@ const MCP_COMPATIBILITY_DEFAULTS = { goCaching: "none", goConfig: "none", goObservability: "none", + javaLanguage: "java", javaWebFramework: "spring-boot", javaBuildTool: "maven", javaOrm: "none", @@ -1341,6 +1343,7 @@ const crossEcosystemInputSchema = { goCaching: GoCachingSchema.optional().describe("Go caching library"), goConfig: GoConfigSchema.optional().describe("Go config management"), goObservability: GoObservabilitySchema.optional().describe("Go observability"), + javaLanguage: JavaLanguageSchema.optional().describe("JVM language (java, kotlin)"), javaWebFramework: JavaWebFrameworkSchema.optional().describe("Java web framework"), javaBuildTool: JavaBuildToolSchema.optional().describe("Java build tool"), javaOrm: JavaOrmSchema.optional().describe("Java ORM"), diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 81518b173..c97de5634 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -63,6 +63,7 @@ import type { GoWebFramework, JavaAuth, JavaApi, + JavaLanguage, JavaLogging, JavaBuildTool, JavaLibraries, @@ -201,6 +202,7 @@ import { getinstallChoice } from "./install"; import { getJavaAuthChoice, getJavaApiChoice, + getJavaLanguageChoice, getJavaLoggingChoice, getJavaBuildToolChoice, getJavaLibrariesChoice, @@ -369,6 +371,7 @@ type PromptGroupResults = { goObservability: GoObservability; // Java ecosystem javaWebFramework: JavaWebFramework; + javaLanguage: JavaLanguage; javaBuildTool: JavaBuildTool; javaOrm: JavaOrm; javaAuth: JavaAuth; @@ -936,6 +939,25 @@ export async function gatherConfig( if (results.ecosystem !== "java") return Promise.resolve("none" as JavaWebFramework); return getJavaWebFrameworkChoice(flags.javaWebFramework); }, + javaLanguage: ({ results }) => { + if (results.ecosystem !== "java") return Promise.resolve("java" as JavaLanguage); + // Kotlin is only wired for the Spring Boot scaffold; keep Java otherwise. + if (results.javaWebFramework !== "spring-boot") { + return Promise.resolve("java" as JavaLanguage); + } + // Honor an explicit --java-language flag (resolves without prompting). + if (flags.javaLanguage !== undefined) { + return getJavaLanguageChoice(flags.javaLanguage); + } + // Flag-driven run (framework was passed as a flag) without --java-language: + // default to Java without prompting so existing non-interactive Java + // scaffolds stay byte-identical and never hang on a new prompt. The JVM + // language prompt only appears in the fully interactive flow. + if (flags.javaWebFramework !== undefined) { + return Promise.resolve("java" as JavaLanguage); + } + return getJavaLanguageChoice(flags.javaLanguage); + }, javaBuildTool: ({ results }) => { if (results.ecosystem !== "java") return Promise.resolve("none" as JavaBuildTool); return getJavaBuildToolChoice(flags.javaBuildTool); @@ -1229,6 +1251,7 @@ export async function gatherConfig( goObservability: result.goObservability, // Java ecosystem options javaWebFramework: result.javaWebFramework, + javaLanguage: result.javaLanguage, javaBuildTool: result.javaBuildTool, javaOrm: result.javaOrm, javaAuth: result.javaAuth, diff --git a/apps/cli/src/prompts/java-ecosystem.ts b/apps/cli/src/prompts/java-ecosystem.ts index 0774d2eca..b0bd257f3 100644 --- a/apps/cli/src/prompts/java-ecosystem.ts +++ b/apps/cli/src/prompts/java-ecosystem.ts @@ -1,6 +1,7 @@ import type { JavaAuth, JavaApi, + JavaLanguage, JavaLogging, JavaBuildTool, JavaLibraries, @@ -40,6 +41,19 @@ const JAVA_WEB_FRAMEWORK_PROMPT_OPTIONS: PromptOption[] = [ }, ]; +const JAVA_LANGUAGE_PROMPT_OPTIONS: PromptOption[] = [ + { + value: "java", + label: "Java", + hint: "Classic Java sources (default)", + }, + { + value: "kotlin", + label: "Kotlin", + hint: "Idiomatic Kotlin sources (Spring Boot only)", + }, +]; + const JAVA_BUILD_TOOL_PROMPT_OPTIONS: PromptOption[] = [ { value: "maven", @@ -275,6 +289,27 @@ export async function getJavaWebFrameworkChoice(javaWebFramework?: JavaWebFramew return response; } +export function resolveJavaLanguagePrompt(javaLanguage?: JavaLanguage) { + return createStaticSinglePromptResolution(JAVA_LANGUAGE_PROMPT_OPTIONS, "java", javaLanguage); +} + +export async function getJavaLanguageChoice(javaLanguage?: JavaLanguage) { + const resolution = resolveJavaLanguagePrompt(javaLanguage); + if (!resolution.shouldPrompt) { + return resolution.autoValue ?? "java"; + } + + const response = await navigableSelect({ + message: "Select JVM language", + options: resolution.options, + initialValue: resolution.initialValue as JavaLanguage, + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} + export function resolveJavaBuildToolPrompt(javaBuildTool?: JavaBuildTool) { return createStaticSinglePromptResolution(JAVA_BUILD_TOOL_PROMPT_OPTIONS, "maven", javaBuildTool); } diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index d4369551b..4dfde0a64 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -1020,6 +1020,11 @@ function getJavaFlags(config: ProjectConfig) { const flags = ["--ecosystem java"]; flags.push(`--java-web-framework ${config.javaWebFramework}`); + // Emit the language flag only for the non-default (Kotlin) variant so the + // reproducible command for every existing Java project stays byte-identical. + if (config.javaLanguage === "kotlin") { + flags.push(`--java-language ${config.javaLanguage}`); + } flags.push(`--java-build-tool ${config.javaBuildTool}`); flags.push(`--java-orm ${config.javaOrm}`); flags.push(`--java-auth ${config.javaAuth}`); diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 51581b8a5..120bce0ea 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -4697,6 +4697,25 @@ export const TECH_OPTIONS: Record< default: false, }, ], + javaLanguage: [ + { + id: "java", + name: "Java", + description: "Classic Java sources", + icon: "/icon/java.svg", + color: "from-orange-500 to-red-600", + default: true, + }, + { + id: "kotlin", + name: "Kotlin", + description: "Idiomatic Kotlin sources (Spring Boot only)", + icon: "https://cdn.simpleicons.org/kotlin/7F52FF", + color: "from-purple-500 to-indigo-600", + default: false, + isNew: true, + }, + ], javaBuildTool: [ { id: "maven", diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index d4be3ce41..76d4df8d3 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -478,6 +478,7 @@ export const ICON_REGISTRY: Record = { logrus: { type: "si", slug: "go", hex: "00ADD8" }, // ─── Java ────────────────────────────────────────────────────────────────── + kotlin: { type: "si", slug: "kotlin", hex: "7F52FF" }, "spring-boot": { type: "si", slug: "springboot", hex: "6DB33F" }, quarkus: { type: "si", slug: "quarkus", hex: "4695EB" }, micronaut: { type: "local", src: "/icon/micronaut.svg" }, diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index 6ff9852ac..fecbbad5e 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -469,6 +469,10 @@ const BASE_LINKS: LinkMap = { githubUrl: "https://github.com/pqina/filepond", }, uppy: { docsUrl: "https://uppy.io/docs/", githubUrl: "https://github.com/transloadit/uppy" }, + kotlin: { + docsUrl: "https://kotlinlang.org/docs/home.html", + githubUrl: "https://github.com/JetBrains/kotlin", + }, "spring-boot": { docsUrl: "https://docs.spring.io/spring-boot/", githubUrl: "https://github.com/spring-projects/spring-boot", diff --git a/packages/template-generator/src/template-handlers/java-base.ts b/packages/template-generator/src/template-handlers/java-base.ts index 433711dbb..9d1bf702b 100644 --- a/packages/template-generator/src/template-handlers/java-base.ts +++ b/packages/template-generator/src/template-handlers/java-base.ts @@ -14,6 +14,7 @@ type JavaTemplateContext = ProjectConfig & { isJavaMaven: boolean; isJavaGradle: boolean; isJavaSpringBoot: boolean; + isJavaKotlin: boolean; isJavaQuarkus: boolean; isJavaMicronaut: boolean; isJavaPlainJava: boolean; @@ -162,6 +163,33 @@ function createJavaTemplateContext(config: ProjectConfig): JavaTemplateContext { // `micronaut` is added to `JavaWebFrameworkSchema` separately; cast so this // handler compiles ahead of (and after) that schema widening landing. const isJavaMicronaut = config.javaWebFramework === "micronaut" && hasJavaBuildTool; + // Kotlin is only wired for the Spring Boot scaffold and its common option + // surface. Everything else falls back to the (byte-identical) Java path so the + // generator never emits a half-built Kotlin project. This mirrors the + // normalization in compatibility.ts and is repeated here so direct + // `createVirtual`/MCP callers (which bypass compatibility) stay safe. + const kotlinUnsupportedTestingLibraries = new Set([ + "testcontainers", + "rest-assured", + "wiremock", + "awaitility", + "archunit", + "jqwik", + ]); + const isJavaKotlin = + config.javaLanguage === "kotlin" && + isJavaSpringBoot && + config.javaOrm !== "jooq" && + config.javaOrm !== "mybatis" && + config.javaApi !== "grpc" && + config.javaApi !== "openapi-generator" && + config.email !== "resend" && + config.search !== "meilisearch" && + config.caching !== "upstash-redis" && + config.observability !== "sentry" && + !(config.javaTestingLibraries || []).some((library) => + kotlinUnsupportedTestingLibraries.has(library), + ); const hasJavaJpa = isJavaSpringBoot && config.javaOrm === "spring-data-jpa"; const rawLibraries = isJavaSpringBoot ? (config.javaLibraries || []).filter((library) => library !== "none") @@ -181,6 +209,13 @@ function createJavaTemplateContext(config: ProjectConfig): JavaTemplateContext { if (library === "liquibase" && rawLibrarySet.has("flyway")) { continue; } + // Lombok and MapStruct are Java annotation-processor tooling. In Kotlin they + // are redundant (data classes replace Lombok) and not wired (MapStruct would + // need kapt), so drop them from the effective library set — the Kotlin + // scaffold uses idiomatic Kotlin instead of the DTO/mapper example. + if (isJavaKotlin && (library === "lombok" || library === "mapstruct")) { + continue; + } javaLibraries.push(library); } const testingLibraries = hasJavaBuildTool @@ -206,6 +241,7 @@ function createJavaTemplateContext(config: ProjectConfig): JavaTemplateContext { isJavaMaven: config.javaBuildTool === "maven", isJavaGradle: config.javaBuildTool === "gradle", isJavaSpringBoot, + isJavaKotlin, isJavaQuarkus, isJavaMicronaut, isJavaPlainJava: !isJavaSpringBoot && !isJavaQuarkus && !isJavaMicronaut, @@ -263,9 +299,77 @@ function createJavaTemplateContext(config: ProjectConfig): JavaTemplateContext { }; } +// Kotlin sources live under src/main/kotlin / src/test/kotlin and are emitted +// only for the Kotlin variant. They mirror the Spring Boot Java surface for the +// supported option set (JPA/none ORM, Spring Security/Keycloak/none auth, Spring +// GraphQL/none API, and the source-bearing libraries caffeine/otel). +function shouldSkipKotlinSource(templatePath: string, context: JavaTemplateContext): boolean { + if (!context.hasJavaJpa) { + if ( + templatePath.includes("/domain/") || + templatePath.includes("/repository/") || + templatePath.endsWith("/service/AppUserService.kt.hbs") || + templatePath.endsWith("/controller/UserController.kt.hbs") || + templatePath.endsWith("/service/AppUserServiceTest.kt.hbs") + ) { + return true; + } + } + if (!context.hasJavaSecurity && templatePath.endsWith("/config/SecurityConfig.kt.hbs")) { + return true; + } + if (!context.hasJavaKeycloak && templatePath.endsWith("/config/ResourceServerConfig.kt.hbs")) { + return true; + } + if (!context.hasJavaOtel && templatePath.endsWith("/config/OtelConfig.kt.hbs")) { + return true; + } + if (!context.hasJavaGraphql && templatePath.endsWith("/controller/GraphqlController.kt.hbs")) { + return true; + } + if ( + !context.hasJavaCaffeine && + (templatePath.endsWith("/controller/CacheController.kt.hbs") || + templatePath.endsWith("/cache/CachedTimeService.kt.hbs")) + ) { + return true; + } + if (!context.hasJavaTests && templatePath.includes("/src/test/kotlin/")) { + return true; + } + if ( + !context.hasJavaMockito && + (templatePath.endsWith("/MockitoSmokeTest.kt.hbs") || + templatePath.endsWith("/service/AppUserServiceTest.kt.hbs")) + ) { + return true; + } + if ( + !context.hasJavaTestcontainers && + templatePath.endsWith("/ApplicationContainerTests.kt.hbs") + ) { + return true; + } + return false; +} + function shouldSkipJavaTemplate(templatePath: string, context: JavaTemplateContext): boolean { const isEmailServiceTemplate = templatePath.endsWith("/service/EmailService.java.hbs"); + const isKotlinSourceTemplate = + templatePath.includes("/src/main/kotlin/") || templatePath.includes("/src/test/kotlin/"); + const isJavaSourceTemplate = + templatePath.includes("/src/main/java/") || templatePath.includes("/src/test/java/"); + + if (context.isJavaKotlin) { + // Kotlin project: never emit the Java sources; gate the Kotlin sources. + if (isJavaSourceTemplate) return true; + if (isKotlinSourceTemplate) return shouldSkipKotlinSource(templatePath, context); + } else if (isKotlinSourceTemplate) { + // Java (default) project: never emit the Kotlin sources. + return true; + } + if ( (!context.isJavaMaven && (templatePath === "java-base/pom.xml.hbs" || diff --git a/packages/template-generator/templates/java-base/build.gradle.kts.hbs b/packages/template-generator/templates/java-base/build.gradle.kts.hbs index f5efdb63b..01be76270 100644 --- a/packages/template-generator/templates/java-base/build.gradle.kts.hbs +++ b/packages/template-generator/templates/java-base/build.gradle.kts.hbs @@ -4,7 +4,15 @@ import com.google.protobuf.gradle.id {{/if}} plugins { {{#if isJavaSpringBoot}} +{{#if isJavaKotlin}} + kotlin("jvm") version "2.2.20" + kotlin("plugin.spring") version "2.2.20" +{{#if hasJavaJpa}} + kotlin("plugin.jpa") version "2.2.20" +{{/if}} +{{else}} java +{{/if}} id("org.springframework.boot") version "4.0.6" id("io.spring.dependency-management") version "1.1.7" {{#if isJavaOpenApiGenerator}} @@ -46,6 +54,15 @@ java { } } +{{#if isJavaKotlin}} +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +{{/if}} repositories { mavenCentral() } @@ -185,6 +202,10 @@ dependencies { {{else}} implementation("org.springframework.boot:spring-boot-starter-webmvc") {{/if}} +{{#if isJavaKotlin}} + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") +{{/if}} {{#if hasJavaJpa}} runtimeOnly("com.h2database:h2") {{/if}} diff --git a/packages/template-generator/templates/java-base/pom.xml.hbs b/packages/template-generator/templates/java-base/pom.xml.hbs index 6d046cf3f..f49e70b4f 100644 --- a/packages/template-generator/templates/java-base/pom.xml.hbs +++ b/packages/template-generator/templates/java-base/pom.xml.hbs @@ -643,6 +643,16 @@ spring-boot-starter-log4j2
{{/if}} +{{#if isJavaKotlin}} + + org.jetbrains.kotlin + kotlin-reflect + + + com.fasterxml.jackson.module + jackson-module-kotlin + +{{/if}} {{#if hasJavaJpa}} com.h2database @@ -1054,6 +1064,10 @@ +{{#if isJavaKotlin}} + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin +{{/if}} {{#if hasJavaGrpc}} + + org.jetbrains.kotlin + kotlin-maven-plugin + + + -Xjsr305=strict + + + spring +{{#if hasJavaJpa}} + jpa +{{/if}} + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + +{{#if hasJavaJpa}} + + org.jetbrains.kotlin + kotlin-maven-noarg + ${kotlin.version} + +{{/if}} + + +{{/if}} org.springframework.boot spring-boot-maven-plugin diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/Application.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/Application.kt.hbs new file mode 100644 index 000000000..411fad41c --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/Application.kt.hbs @@ -0,0 +1,17 @@ +package {{javaPackageName}} + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +{{#if hasJavaCaffeine}} +import org.springframework.cache.annotation.EnableCaching +{{/if}} + +@SpringBootApplication +{{#if hasJavaCaffeine}} +@EnableCaching +{{/if}} +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/cache/CachedTimeService.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/cache/CachedTimeService.kt.hbs new file mode 100644 index 000000000..065dedbde --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/cache/CachedTimeService.kt.hbs @@ -0,0 +1,12 @@ +package {{javaPackageName}}.cache + +import java.time.Instant +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service + +@Service +open class CachedTimeService { + + @Cacheable("generated-timestamps") + open fun generatedAt(): Instant = Instant.now() +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/OtelConfig.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/OtelConfig.kt.hbs new file mode 100644 index 000000000..4d5c5cd63 --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/OtelConfig.kt.hbs @@ -0,0 +1,41 @@ +package {{javaPackageName}}.config + +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * OpenTelemetry tracing with OTLP export. + * + * Set OTEL_EXPORTER_OTLP_ENDPOINT (defaults to http://localhost:4317). + */ +@Configuration +class OtelConfig { + + @Bean + fun openTelemetry(): OpenTelemetry { + val resource = + Resource.getDefault().merge( + Resource.create( + Attributes.of(AttributeKey.stringKey("service.name"), "{{javaArtifactId}}"), + ), + ) + + val tracerProvider = + SdkTracerProvider.builder() + .addSpanProcessor( + BatchSpanProcessor.builder(OtlpGrpcSpanExporter.builder().build()).build(), + ) + .setResource(resource) + .build() + + return OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build() + } +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/ResourceServerConfig.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/ResourceServerConfig.kt.hbs new file mode 100644 index 000000000..21a5a4705 --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/ResourceServerConfig.kt.hbs @@ -0,0 +1,31 @@ +package {{javaPackageName}}.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.Customizer +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +/** + * Validates Keycloak-issued JWTs as an OAuth2 resource server. + * + * Point KEYCLOAK_ISSUER_URI at your realm, e.g. + * http://localhost:8081/realms/my-realm. The issuer metadata is fetched lazily + * on the first request, so the application starts without a running Keycloak. + */ +@Configuration +@EnableWebSecurity +class ResourceServerConfig { + + @Bean + fun resourceServerFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .authorizeHttpRequests { + it.requestMatchers("/", "/health", "/actuator/**").permitAll() + .anyRequest().authenticated() + } + .oauth2ResourceServer { oauth2 -> oauth2.jwt(Customizer.withDefaults()) } + return http.build() + } +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/SecurityConfig.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/SecurityConfig.kt.hbs new file mode 100644 index 000000000..07c32e02b --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/config/SecurityConfig.kt.hbs @@ -0,0 +1,46 @@ +package {{javaPackageName}}.config + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.Customizer +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain + +@Configuration +class SecurityConfig { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .authorizeHttpRequests { + it.requestMatchers("/health").permitAll() + .anyRequest().authenticated() + } + .httpBasic(Customizer.withDefaults()) + return http.build() + } + + @Bean + fun userDetailsService( + @Value("\${APP_BASIC_USERNAME:admin}") username: String, + @Value("\${APP_BASIC_PASSWORD:change-me}") password: String, + passwordEncoder: PasswordEncoder, + ): UserDetailsService { + val user = + User.withUsername(username) + .password(passwordEncoder.encode(password)) + .roles("USER") + .build() + return InMemoryUserDetailsManager(user) + } + + @Bean + fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/CacheController.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/CacheController.kt.hbs new file mode 100644 index 000000000..6eae0ae3b --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/CacheController.kt.hbs @@ -0,0 +1,13 @@ +package {{javaPackageName}}.controller + +import java.time.Instant +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import {{javaPackageName}}.cache.CachedTimeService + +@RestController +class CacheController(private val cachedTimeService: CachedTimeService) { + + @GetMapping("/cache/time") + fun cachedTime(): Map = mapOf("generatedAt" to cachedTimeService.generatedAt()) +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/GraphqlController.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/GraphqlController.kt.hbs new file mode 100644 index 000000000..4396b759e --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/GraphqlController.kt.hbs @@ -0,0 +1,13 @@ +package {{javaPackageName}}.controller + +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.stereotype.Controller + +/** Resolvers for the schema in src/main/resources/graphql/schema.graphqls. */ +@Controller +class GraphqlController { + + @QueryMapping + fun greeting(@Argument name: String?): String = "Hello, ${name ?: "world"}!" +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/HealthController.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/HealthController.kt.hbs new file mode 100644 index 000000000..05716140d --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/HealthController.kt.hbs @@ -0,0 +1,18 @@ +package {{javaPackageName}}.controller + +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@CrossOrigin(origins = ["*"]) +class HealthController { + + @GetMapping("/health") + fun health(): Map = + mapOf( + "status" to "ok", + "application" to "{{projectName}}", + "framework" to "spring-boot", + ) +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/UserController.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/UserController.kt.hbs new file mode 100644 index 000000000..48a66298f --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/controller/UserController.kt.hbs @@ -0,0 +1,63 @@ +package {{javaPackageName}}.controller + +import java.time.Instant +{{#if hasJavaValidation}} +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +{{/if}} +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +{{#unless hasJavaValidation}} +import org.springframework.web.server.ResponseStatusException +{{/unless}} +import {{javaPackageName}}.domain.AppUser +import {{javaPackageName}}.service.AppUserService + +@RestController +@RequestMapping("/users") +class UserController(private val userService: AppUserService) { + + @GetMapping + fun listUsers(): List = userService.findAll().map { UserResponse.from(it) } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) +{{#if hasJavaValidation}} + fun createUser(@Valid @RequestBody request: CreateUserRequest): UserResponse { +{{else}} + fun createUser(@RequestBody request: CreateUserRequest): UserResponse { + if (request.email.isNullOrBlank()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "email is required") + } +{{/if}} + val createdUser = userService.create(request.email, request.displayName) + return UserResponse.from(createdUser) + } + +{{#if hasJavaValidation}} + data class CreateUserRequest( + @field:NotBlank @field:Email val email: String?, + @field:NotBlank val displayName: String?, + ) +{{else}} + data class CreateUserRequest(val email: String?, val displayName: String?) +{{/if}} + + data class UserResponse( + val id: Long?, + val email: String, + val displayName: String, + val createdAt: Instant?, + ) { + companion object { + fun from(user: AppUser): UserResponse = + UserResponse(user.id, user.email, user.displayName, user.createdAt) + } + } +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/domain/AppUser.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/domain/AppUser.kt.hbs new file mode 100644 index 000000000..976c4abe1 --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/domain/AppUser.kt.hbs @@ -0,0 +1,51 @@ +package {{javaPackageName}}.domain + +import java.time.Instant +{{#if hasJavaValidation}} +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +{{/if}} +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.PrePersist +import jakarta.persistence.Table + +@Entity +@Table(name = "app_users") +class AppUser() { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @Column(nullable = false, unique = true) +{{#if hasJavaValidation}} + @field:Email + @field:NotBlank +{{/if}} + var email: String = "" + + @Column(nullable = false) +{{#if hasJavaValidation}} + @field:NotBlank +{{/if}} + var displayName: String = "" + + @Column(nullable = false, updatable = false) + var createdAt: Instant? = null + + constructor(email: String, displayName: String) : this() { + this.email = email + this.displayName = displayName + } + + @PrePersist + fun onCreate() { + if (createdAt == null) { + createdAt = Instant.now() + } + } +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/repository/AppUserRepository.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/repository/AppUserRepository.kt.hbs new file mode 100644 index 000000000..7c7050414 --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/repository/AppUserRepository.kt.hbs @@ -0,0 +1,10 @@ +package {{javaPackageName}}.repository + +import java.util.Optional +import org.springframework.data.jpa.repository.JpaRepository +import {{javaPackageName}}.domain.AppUser + +interface AppUserRepository : JpaRepository { + + fun findByEmail(email: String): Optional +} diff --git a/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/service/AppUserService.kt.hbs b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/service/AppUserService.kt.hbs new file mode 100644 index 000000000..dc891dc5c --- /dev/null +++ b/packages/template-generator/templates/java-base/src/main/kotlin/__javaPackagePath__/service/AppUserService.kt.hbs @@ -0,0 +1,21 @@ +package {{javaPackageName}}.service + +import org.springframework.stereotype.Service +import {{javaPackageName}}.domain.AppUser +import {{javaPackageName}}.repository.AppUserRepository + +@Service +class AppUserService(private val repository: AppUserRepository) { + + fun findAll(): List = repository.findAll() + + fun create(email: String?, displayName: String?): AppUser { + val normalizedEmail = email?.trim()?.lowercase() ?: "" + require(normalizedEmail.isNotBlank()) { "email must not be blank" } + + val normalizedDisplayName = + if (displayName.isNullOrBlank()) normalizedEmail else displayName.trim() + + return repository.save(AppUser(normalizedEmail, normalizedDisplayName)) + } +} diff --git a/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/ApplicationTests.kt.hbs b/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/ApplicationTests.kt.hbs new file mode 100644 index 000000000..99b999e6b --- /dev/null +++ b/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/ApplicationTests.kt.hbs @@ -0,0 +1,18 @@ +package {{javaPackageName}} + +{{#if hasJavaAssertj}} +import org.assertj.core.api.Assertions.assertThat +{{/if}} +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ApplicationTests { + + @Test + fun contextLoads() { +{{#if hasJavaAssertj}} + assertThat(Application::class.java).isNotNull() +{{/if}} + } +} diff --git a/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/MockitoSmokeTest.kt.hbs b/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/MockitoSmokeTest.kt.hbs new file mode 100644 index 000000000..21a4bf5d9 --- /dev/null +++ b/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/MockitoSmokeTest.kt.hbs @@ -0,0 +1,17 @@ +package {{javaPackageName}} + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify + +class MockitoSmokeTest { + + @Test + fun verifiesMockInteractions() { + val task = mock(Runnable::class.java) + + task.run() + + verify(task).run() + } +} diff --git a/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/service/AppUserServiceTest.kt.hbs b/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/service/AppUserServiceTest.kt.hbs new file mode 100644 index 000000000..5c4ac90b6 --- /dev/null +++ b/packages/template-generator/templates/java-base/src/test/kotlin/__javaPackagePath__/service/AppUserServiceTest.kt.hbs @@ -0,0 +1,57 @@ +package {{javaPackageName}}.service + +{{#if hasJavaAssertj}} +import org.assertj.core.api.Assertions.assertThat +{{else}} +import org.junit.jupiter.api.Assertions.assertEquals +{{/if}} +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.any +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import {{javaPackageName}}.domain.AppUser +import {{javaPackageName}}.repository.AppUserRepository + +@ExtendWith(MockitoExtension::class) +class AppUserServiceTest { + + @Mock + lateinit var repository: AppUserRepository + + @InjectMocks + lateinit var service: AppUserService + + @Test + fun createPersistsANormalizedUser() { + `when`(repository.save(any(AppUser::class.java))).thenAnswer { it.getArgument(0) } + + val createdUser = service.create("Ada@example.com ", "Ada Lovelace") + + verify(repository).save(any(AppUser::class.java)) +{{#if hasJavaAssertj}} + assertThat(createdUser.email).isEqualTo("ada@example.com") + assertThat(createdUser.displayName).isEqualTo("Ada Lovelace") +{{else}} + assertEquals("ada@example.com", createdUser.email) + assertEquals("Ada Lovelace", createdUser.displayName) +{{/if}} + } + + @Test + fun findAllDelegatesToRepository() { + `when`(repository.findAll()).thenReturn(listOf(AppUser("grace@example.com", "Grace Hopper"))) + + val users = service.findAll() + +{{#if hasJavaAssertj}} + assertThat(users).hasSize(1) +{{else}} + assertEquals(1, users.size) +{{/if}} + verify(repository).findAll() + } +} diff --git a/packages/types/src/cli-flags.ts b/packages/types/src/cli-flags.ts index 05435d480..d6200cc8d 100644 --- a/packages/types/src/cli-flags.ts +++ b/packages/types/src/cli-flags.ts @@ -399,6 +399,7 @@ export const CLI_FLAG_GROUP_DEFINITIONS: readonly CliFlagGroupDefinition[] = [ ecosystem: "java", flags: [ { flag: "java-web-framework", summary: "Java web framework.", source: { kind: "category", category: "javaWebFramework" }, configKey: "javaWebFramework" }, + { flag: "java-language", summary: "JVM language (java or kotlin).", source: { kind: "category", category: "javaLanguage" }, configKey: "javaLanguage" }, { flag: "java-build-tool", summary: "Build tool.", source: { kind: "category", category: "javaBuildTool" }, configKey: "javaBuildTool" }, { flag: "java-orm", summary: "Java ORM / database.", source: { kind: "category", category: "javaOrm" }, configKey: "javaOrm" }, { flag: "java-auth", summary: "Java auth.", source: { kind: "category", category: "javaAuth" }, configKey: "javaAuth" }, diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index 67b84d24d..1fcea3063 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -178,6 +178,7 @@ export type CompatibilityInput = { goCaching: string; goConfig: string; goObservability: string; + javaLanguage: string; javaWebFramework: string; javaBuildTool: string; javaOrm: string; @@ -1578,6 +1579,48 @@ export const analyzeStackCompatibility = ( }); } } + + // Kotlin is only wired for the Spring Boot scaffold (with Maven or Gradle) + // and its common option surface. For every other Java combination we + // normalize the language back to `java` so the generator never emits a + // half-built Kotlin project. The uncovered surface is: non-Spring-Boot + // frameworks, source-only scaffolds, jOOQ/MyBatis ORMs, the gRPC/OpenAPI + // API layers (protoc/codegen paths), and the Java-only third-party service + // integrations (Resend email, Meilisearch, Upstash Redis, Sentry). + if (nextStack.javaLanguage === "kotlin") { + const kotlinUnsupportedTestingLibraries = new Set([ + "testcontainers", + "rest-assured", + "wiremock", + "awaitility", + "archunit", + "jqwik", + ]); + const kotlinSupported = + nextStack.javaWebFramework === "spring-boot" && + nextStack.javaBuildTool !== "none" && + nextStack.javaOrm !== "jooq" && + nextStack.javaOrm !== "mybatis" && + nextStack.javaApi !== "grpc" && + nextStack.javaApi !== "openapi-generator" && + nextStack.email !== "resend" && + nextStack.search !== "meilisearch" && + nextStack.caching !== "upstash-redis" && + nextStack.observability !== "sentry" && + !nextStack.javaTestingLibraries.some((library) => + kotlinUnsupportedTestingLibraries.has(library), + ); + + if (!kotlinSupported) { + nextStack.javaLanguage = "java"; + changed = true; + changes.push({ + category: "javaLanguage", + message: + "Java language set to 'Java' (Kotlin currently targets the Spring Boot scaffold with Spring Data JPA/none, Spring GraphQL/none, and no jOOQ/MyBatis/gRPC/OpenAPI or Java-only service integrations)", + }); + } + } } if (nextStack.i18n === "intlayer") { diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index a31afa3ad..5300f9896 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -67,6 +67,7 @@ import { GO_OBSERVABILITY_VALUES, JAVA_AUTH_VALUES, JAVA_API_VALUES, + JAVA_LANGUAGE_VALUES, JAVA_LOGGING_VALUES, JAVA_BUILD_TOOL_VALUES, JAVA_LIBRARIES_VALUES, @@ -232,6 +233,7 @@ export type OptionCategory = | "goCaching" | "goConfig" | "goObservability" + | "javaLanguage" | "javaWebFramework" | "javaBuildTool" | "javaOrm" @@ -426,6 +428,7 @@ export const GO_CATEGORY_ORDER = [ export const JAVA_CATEGORY_ORDER = [ "javaWebFramework", + "javaLanguage", "javaBuildTool", "javaOrm", "javaAuth", @@ -568,6 +571,7 @@ export function getCategoryDisplayName(categoryKey: string): string { goCaching: "Go Caching", goConfig: "Go Config", goObservability: "Go Observability", + javaLanguage: "Java Language", javaWebFramework: "Java Web Framework", javaBuildTool: "Java Build Tool", javaOrm: "Java ORM / Database", @@ -832,6 +836,7 @@ const CATEGORY_VALUE_IDS: Record = { goCaching: GO_CACHING_VALUES, goConfig: GO_CONFIG_VALUES, goObservability: GO_OBSERVABILITY_VALUES, + javaLanguage: JAVA_LANGUAGE_VALUES, javaWebFramework: JAVA_WEB_FRAMEWORK_VALUES, javaBuildTool: JAVA_BUILD_TOOL_VALUES, javaOrm: JAVA_ORM_VALUES, @@ -1384,6 +1389,10 @@ const EXACT_LABEL_OVERRIDES: Partial; export type GoCaching = z.infer; export type GoConfig = z.infer; export type GoObservability = z.infer; +export type JavaLanguage = z.infer; export type JavaWebFramework = z.infer; export type JavaBuildTool = z.infer; export type JavaOrm = z.infer; From 66e58e0527fee5daccd78c71c14242f668d1b025 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 6 Jul 2026 00:21:22 +0300 Subject: [PATCH 13/14] fix(web): add javaLanguage:java resource link (kotlin already had one) javaLanguage TECH_OPTIONS added 'java' + 'kotlin'; kotlin had a link but java did not, failing validate:tech-links (Lint + Release Guard). Add the java language docs/GitHub links. validate:tech-links exit 0. --- apps/web/src/lib/tech-resource-links.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index fecbbad5e..50f873f2e 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -469,6 +469,10 @@ const BASE_LINKS: LinkMap = { githubUrl: "https://github.com/pqina/filepond", }, uppy: { docsUrl: "https://uppy.io/docs/", githubUrl: "https://github.com/transloadit/uppy" }, + java: { + docsUrl: "https://dev.java/learn/", + githubUrl: "https://github.com/openjdk/jdk", + }, kotlin: { docsUrl: "https://kotlinlang.org/docs/home.html", githubUrl: "https://github.com/JetBrains/kotlin", From b2d92357a9c1b1f172c1bfd0852ddd89d2647429 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 6 Jul 2026 01:07:21 +0300 Subject: [PATCH 14/14] =?UTF-8?q?fix(cli):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20pack=20path=20traversal=20+=20persist=20workspaceSh?= =?UTF-8?q?ape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves 3 Codex P1 review comments on #299: - registry: reject capability-pack file paths and dependency target dirs that escape the project directory (../ traversal or absolute paths) — a local/ community pack could otherwise write outside the project or mutate sibling package.json manifests. New assertPathInsideProject guard on both the file- write loop and the dependency-map loop. + 2 security regression tests (traversal file path + dep dir both rejected; nothing written outside). - bts-config: persist workspaceShape=single-app in bts.jsonc (via buildBtsConfigForPersistence) and read it back (configFromBtsConfig), so bfs update / stack-update don't reconstruct a flat single-app project as a monorepo and re-render apps/*+packages/* over it. monorepo (default) is omitted to keep bts.jsonc byte-identical. + round-trip test. Verified: registry+bts 110/0 (incl. new tests), template-snapshots 95 byte- identical, full turbo test 3516/0. --- apps/cli/src/helpers/core/registry-handler.ts | 17 +++++++++++++++++ apps/cli/src/utils/bts-config.ts | 6 ++++++ apps/cli/test/bts-config.test.ts | 9 +++++++++ .../registry/traversal-dep-pack/registry.json | 8 ++++++++ .../registry/traversal-file-pack/registry.json | 7 +++++++ apps/cli/test/registry.test.ts | 17 +++++++++++++++++ 6 files changed, 64 insertions(+) create mode 100644 apps/cli/test/fixtures/registry/traversal-dep-pack/registry.json create mode 100644 apps/cli/test/fixtures/registry/traversal-file-pack/registry.json diff --git a/apps/cli/src/helpers/core/registry-handler.ts b/apps/cli/src/helpers/core/registry-handler.ts index 436b987df..3bf8a3868 100644 --- a/apps/cli/src/helpers/core/registry-handler.ts +++ b/apps/cli/src/helpers/core/registry-handler.ts @@ -217,6 +217,21 @@ async function resolveEnvExamplePath(projectDir: string): Promise { return ".env.example"; } +/** + * Rejects a capability pack whose resolved target path escapes the project + * directory (path traversal via `..` segments or an absolute path in the + * manifest). Applied to both file writes and dependency-map target dirs so a + * pack can never write outside / mutate sibling projects. + */ +function assertPathInsideProject(projectDir: string, targetAbs: string, label: string): void { + const rel = path.relative(projectDir, targetAbs); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) { + throw new CLIError( + `Capability pack ${label} escapes the project directory and was rejected: ${targetAbs}`, + ); + } +} + /** * Merges a pack's dependencies/devDependencies into the target package.json * files. Pack dependency versions are arbitrary name->version pairs, so they @@ -241,6 +256,7 @@ async function planDependencyChanges( if (Object.keys(deps).length === 0) continue; const pkgRelPath = path.join(dir === "." ? "" : dir, "package.json"); const pkgAbsPath = path.join(projectDir, pkgRelPath); + assertPathInsideProject(projectDir, pkgAbsPath, `dependency target "${dir}"`); if (!(await fs.pathExists(pkgAbsPath))) { throw new CLIError( `Pack targets ${pkgRelPath} which does not exist in this project. Cannot merge dependencies.`, @@ -296,6 +312,7 @@ export async function addPack(options: RegistryAddOptions): Promise monorepo). + workspaceShape: + persistedConfig.workspaceShape === "single-app" ? "single-app" : undefined, dbSetup: persistedConfig.dbSetup, api: persistedConfig.api, webDeploy: persistedConfig.webDeploy, @@ -518,6 +523,7 @@ export async function writeBtsConfig( testing: btsConfig.testing, packageManager: btsConfig.packageManager, versionChannel: btsConfig.versionChannel, + workspaceShape: btsConfig.workspaceShape, dbSetup: btsConfig.dbSetup, api: btsConfig.api, webDeploy: btsConfig.webDeploy, diff --git a/apps/cli/test/bts-config.test.ts b/apps/cli/test/bts-config.test.ts index 18d92da99..315710a47 100644 --- a/apps/cli/test/bts-config.test.ts +++ b/apps/cli/test/bts-config.test.ts @@ -87,6 +87,15 @@ describe("bts.jsonc graph persistence", () => { expect(readBack?.pythonWebFramework).toBe("none"); }); + it("persists workspaceShape so a single-app project isn't re-rendered as a monorepo", async () => { + const config = await makeProjectConfig({ workspaceShape: "single-app" }); + await writeBtsConfig(config); + const readBack = await readBtsConfig(config.projectDir); + // Without this, bfs update / stack-update would reconstruct workspaceShape as + // the monorepo default and re-render the flat app with apps/*+packages/* files. + expect(readBack?.workspaceShape).toBe("single-app"); + }); + it("lets stackParts win over stale top-level cache fields", async () => { const stackParts = parseStackPartSpecs([ "frontend:typescript:next", diff --git a/apps/cli/test/fixtures/registry/traversal-dep-pack/registry.json b/apps/cli/test/fixtures/registry/traversal-dep-pack/registry.json new file mode 100644 index 000000000..9b2a5e369 --- /dev/null +++ b/apps/cli/test/fixtures/registry/traversal-dep-pack/registry.json @@ -0,0 +1,8 @@ +{ + "name": "@evil/traversal-dep", + "version": "1.0.0", + "description": "Malicious pack: a dependency target dir that escapes via ..", + "files": [], + "dependencies": { "..": { "evil-dep": "1.0.0" } }, + "env": [] +} diff --git a/apps/cli/test/fixtures/registry/traversal-file-pack/registry.json b/apps/cli/test/fixtures/registry/traversal-file-pack/registry.json new file mode 100644 index 000000000..8b6a2dcd5 --- /dev/null +++ b/apps/cli/test/fixtures/registry/traversal-file-pack/registry.json @@ -0,0 +1,7 @@ +{ + "name": "@evil/traversal-file", + "version": "1.0.0", + "description": "Malicious pack: a file path that escapes the project via ../", + "files": [{ "path": "../outside.txt", "content": "pwned\n" }], + "env": [] +} diff --git a/apps/cli/test/registry.test.ts b/apps/cli/test/registry.test.ts index 81d991154..4d78abd05 100644 --- a/apps/cli/test/registry.test.ts +++ b/apps/cli/test/registry.test.ts @@ -10,6 +10,8 @@ import { addPack, listInstalledPacks } from "../src/helpers/core/registry-handle const FIXTURES = join(import.meta.dir, "fixtures", "registry"); const SAMPLE_PACK = join(FIXTURES, "sample-pack"); const INVALID_PACK = join(FIXTURES, "invalid-pack"); +const TRAVERSAL_FILE_PACK = join(FIXTURES, "traversal-file-pack"); +const TRAVERSAL_DEP_PACK = join(FIXTURES, "traversal-dep-pack"); const TEMP_ROOTS: string[] = []; async function stageProject(): Promise { @@ -101,6 +103,21 @@ describe("registry add", () => { expect(await fs.pathExists(join(dir, ".better-fullstack", "registry.json"))).toBe(false); }); + it("rejects a pack whose file path escapes the project dir (path traversal)", async () => { + const dir = await stageProject(); + await expect(addPack({ projectDir: dir, source: TRAVERSAL_FILE_PACK })).rejects.toThrow( + /escapes the project directory/, + ); + expect(await fs.pathExists(join(dir, "..", "outside.txt"))).toBe(false); + }); + + it("rejects a pack whose dependency target dir escapes the project dir", async () => { + const dir = await stageProject(); + await expect(addPack({ projectDir: dir, source: TRAVERSAL_DEP_PACK })).rejects.toThrow( + /escapes the project directory/, + ); + }); + it("--dry-run writes nothing", async () => { const dir = await stageProject(); const before = await readServerPackageJson(dir);