Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,7 @@ testing/.validate-r4/
testing/.verify-*/
# LLM benchmark reports stay local
testing/llm-benchmarks/

# Erlang/Elixir crash dumps (never commit)
erl_crash.dump
*.dump
25 changes: 25 additions & 0 deletions apps/cli/src/helpers/core/post-installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,31 @@ function getPaymentSetupInstructions(
);
}

if (payments === "creem") {
return (
`${pc.bold("Creem Setup:")}\n` +
`${pc.cyan("•")} Get your API key from ${pc.underline("https://docs.creem.io")} (Developers > API Keys)\n` +
`${pc.cyan("•")} Set ${pc.white("CREEM_API_KEY")}, ${pc.white("CREEM_WEBHOOK_SECRET")}, and ${pc.white("CREEM_ENVIRONMENT")} (test|prod) in ${pc.white(envPath)}`
);
}

if (payments === "autumn") {
return (
`${pc.bold("Autumn Setup:")}\n` +
`${pc.cyan("•")} Sign up at ${pc.underline("https://useautumn.com")} and connect Stripe\n` +
`${pc.cyan("•")} Set ${pc.white("AUTUMN_SECRET_KEY")} in ${pc.white(envPath)} and mount handleAutumnRequest at ${pc.white("/api/autumn/*")}\n` +
`${pc.cyan("•")} Define plans/features with ${pc.white("npx atmn@latest")} (Autumn is webhookless)`
);
}

if (payments === "commet") {
return (
`${pc.bold("Commet Setup:")}\n` +
`${pc.cyan("•")} Get your API key from ${pc.underline("https://commet.co")} (Settings > API Keys)\n` +
`${pc.cyan("•")} Set ${pc.white("COMMET_API_KEY")} and ${pc.white("COMMET_WEBHOOK_SECRET")} in ${pc.white(envPath)}`
);
}

return "";
}

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,7 +1086,7 @@ function matchNearestPreset(input: Record<string, unknown>): Template | null {
return best && best.score >= 3 ? best.id : null;
}

function recommendStackFromBrief(
export function recommendStackFromBrief(
brief: string,
ecosystemHint?: ProjectConfig["ecosystem"],
): { input: Record<string, unknown>; rationale: string[]; matchedPreset: Template | null } {
Expand Down
16 changes: 14 additions & 2 deletions apps/cli/src/prompts/caching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@ const CACHING_PROMPT_OPTIONS = [
label: "Upstash Redis",
hint: "Serverless Redis with REST API for edge and serverless",
},
{
value: "redis" as const,
label: "Redis",
hint: "Self-hosted Redis via ioredis (TCP) — full command surface",
},
{
value: "none" as const,
label: "None",
hint: "Skip caching layer setup",
},
];

// Non-TypeScript ecosystems have their own native caching fields
// (goCaching/rustCaching/pythonCaching); the shared self-hosted redis client
// is TypeScript-only, so drop it from their options.
const NON_TS_CACHING_PROMPT_OPTIONS = CACHING_PROMPT_OPTIONS.filter(
(option) => option.value !== "redis",
);

type CachingPromptContext = {
caching?: Caching;
backend?: Backend;
Expand All @@ -40,13 +52,13 @@ export function resolveCachingPrompt(
? {
shouldPrompt: false,
mode: "single",
options: CACHING_PROMPT_OPTIONS,
options: NON_TS_CACHING_PROMPT_OPTIONS,
autoValue: context.caching,
}
: {
shouldPrompt: true,
mode: "single",
options: CACHING_PROMPT_OPTIONS,
options: NON_TS_CACHING_PROMPT_OPTIONS,
initialValue: "none",
};
}
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/prompts/file-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export async function getFileStorageChoice(fileStorage?: FileStorage, backend?:
label: "Cloudinary",
hint: "Image and media storage with transformations",
},
{
value: "supabase-storage" as const,
label: "Supabase Storage",
hint: "S3-compatible object storage with signed URLs and RLS",
},
{
value: "none" as const,
label: "None",
Expand Down
10 changes: 10 additions & 0 deletions apps/cli/src/prompts/go-ecosystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ const GO_WEB_FRAMEWORK_PROMPT_OPTIONS: PromptOption<GoWebFramework>[] = [
label: "Chi",
hint: "Lightweight, zero-dependency router built on net/http",
},
{
value: "stdlib",
label: "net/http",
hint: "Go 1.22+ standard-library routing (ServeMux), zero dependencies",
},
{
value: "none",
label: "None",
Expand All @@ -65,6 +70,11 @@ const GO_ORM_PROMPT_OPTIONS: PromptOption<GoOrm>[] = [
label: "Ent",
hint: "Code-first ORM by Meta with graph traversal API, 15k+ stars",
},
{
value: "bun",
label: "Bun",
hint: "SQL-first Go ORM by uptrace with a lightweight query builder",
},
{
value: "none",
label: "None",
Expand Down
15 changes: 15 additions & 0 deletions apps/cli/src/prompts/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export async function getI18nChoice(i18n?: I18n, frontend?: Frontend[]) {

const hasNext = frontend?.includes("next") ?? false;
const hasParaglideFrontend = frontend?.some((f) => PARAGLIDE_FRONTENDS.has(f)) ?? false;
const hasIntlayerFrontend =
frontend?.some((f) =>
["next", "vinext", "tanstack-router", "tanstack-start", "react-router", "react-vite"].includes(
f,
),
) ?? false;

const options = [
...(hasParaglideFrontend
Expand All @@ -47,6 +53,15 @@ export async function getI18nChoice(i18n?: I18n, frontend?: Frontend[]) {
},
]
: []),
...(hasIntlayerFrontend
? [
{
value: "intlayer" as const,
label: "Intlayer",
hint: "Component-level, type-safe i18n for React, Next.js, and Vite",
},
]
: []),
{
value: "none" as const,
label: "None",
Expand Down
15 changes: 15 additions & 0 deletions apps/cli/src/prompts/java-ecosystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ const JAVA_WEB_FRAMEWORK_PROMPT_OPTIONS: PromptOption<JavaWebFramework>[] = [
label: "Quarkus",
hint: "Cloud-native Java framework optimized for fast startup and lower memory use",
},
{
value: "micronaut",
label: "Micronaut",
hint: "Modern JVM framework with compile-time DI/AOP and fast startup, low memory",
},
{
value: "none",
label: "None",
Expand Down Expand Up @@ -391,6 +396,11 @@ const JAVA_API_PROMPT_OPTIONS: PromptOption<JavaApi>[] = [
label: "Spring for GraphQL",
hint: "GraphQL schema + annotated controllers on Spring Boot",
},
{
value: "openapi-generator",
label: "OpenAPI Generator",
hint: "Generate Spring API interfaces + models from an OpenAPI spec at build time",
},
{
value: "none",
label: "None",
Expand All @@ -404,6 +414,11 @@ const JAVA_LOGGING_PROMPT_OPTIONS: PromptOption<JavaLogging>[] = [
label: "Logback",
hint: "Spring Boot default logger with explicit logback-spring.xml",
},
{
value: "log4j2",
label: "Log4j2",
hint: "High-performance Apache logging with async loggers (Spring Boot, Micronaut, plain Java)",
},
{
value: "none",
label: "None",
Expand Down
15 changes: 15 additions & 0 deletions apps/cli/src/prompts/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ export function resolvePaymentsPrompt(
label: "Dodo Payments",
hint: "Simple payment infrastructure for developers.",
},
{
value: "creem" as Payments,
label: "Creem",
hint: "Cheapest merchant-of-record payments & billing, with a Better Auth plugin.",
},
{
value: "autumn" as Payments,
label: "Autumn",
hint: "Usage-based pricing & billing for SaaS and AI apps.",
},
{
value: "commet" as Payments,
label: "Commet",
hint: "All-in-one plan-first billing for SaaS and AI products.",
},
{
value: "none" as Payments,
label: "None",
Expand Down
10 changes: 10 additions & 0 deletions apps/cli/src/prompts/rust-ecosystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ const RUST_WEB_FRAMEWORK_PROMPT_OPTIONS: PromptOption<RustWebFramework>[] = [
label: "Rocket",
hint: "Convention-over-configuration web framework, 25k+ stars",
},
{
value: "poem",
label: "Poem",
hint: "Full-featured, fast web framework built on Tokio and hyper",
},
{
value: "loco",
label: "Loco",
hint: "The Rails of Rust: opinionated full-stack framework (runs DB-free too)",
},
{
value: "none",
label: "None",
Expand Down
37 changes: 34 additions & 3 deletions apps/cli/src/prompts/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ const NON_TYPESCRIPT_SEARCH_PROMPT_OPTIONS = SEARCH_PROMPT_OPTIONS.filter(
(option) => option.value === "meilisearch" || option.value === "none",
);

// Python supports Meilisearch and Elasticsearch (both have first-class Python clients).
const PYTHON_SEARCH_PROMPT_OPTIONS = SEARCH_PROMPT_OPTIONS.filter(
(option) =>
option.value === "meilisearch" ||
option.value === "elasticsearch" ||
option.value === "none",
);

// Go additionally supports Bleve, an embedded (server-less) full-text engine.
const GO_SEARCH_PROMPT_OPTIONS = [
{
value: "meilisearch" as const,
label: "Meilisearch",
hint: "Lightning-fast search engine with typo tolerance",
},
{
value: "bleve" as const,
label: "Bleve",
hint: "Embedded, server-less full-text search for Go (no external service)",
},
{
value: "none" as const,
label: "None",
hint: "Skip search engine setup",
},
];

type SearchPromptContext = {
search?: Search;
backend?: Backend;
Expand All @@ -60,9 +87,13 @@ export function resolveSearchPrompt(
}

const options =
context.ecosystem && context.ecosystem !== "typescript"
? NON_TYPESCRIPT_SEARCH_PROMPT_OPTIONS
: SEARCH_PROMPT_OPTIONS;
context.ecosystem === "go"
? GO_SEARCH_PROMPT_OPTIONS
: context.ecosystem === "python"
? PYTHON_SEARCH_PROMPT_OPTIONS
: context.ecosystem && context.ecosystem !== "typescript"
? NON_TYPESCRIPT_SEARCH_PROMPT_OPTIONS
: SEARCH_PROMPT_OPTIONS;

if (
(!context.ecosystem || context.ecosystem === "typescript") &&
Expand Down
38 changes: 38 additions & 0 deletions apps/cli/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,44 @@ export const router = os.router({
const { doctorCommand } = await import("./commands/doctor.js");
await doctorCommand({ projectDir, ...options });
}),
recommend: os
.meta({
description:
"Recommend a stack from a natural-language brief (prompt-to-stack): prints the suggested config, the rationale, and a ready-to-run create command",
})
.input(
z.object({
brief: z
.string()
.min(1)
.describe('Natural-language description, e.g. "a SaaS with Postgres, auth and payments"'),
ecosystem: z
.string()
.optional()
.describe("Force an ecosystem (typescript, react-native, rust, go, python, java, ...)"),
json: z.boolean().default(false).describe("Output the recommendation as JSON"),
}),
)
.handler(async ({ input }) => {
const { recommendStackFromBrief } = await import("./mcp.js");
const result = recommendStackFromBrief(
input.brief,
input.ecosystem as Parameters<typeof recommendStackFromBrief>[1],
);

if (input.json) {
log.message(JSON.stringify(result, null, 2));
return;
}

log.message("Recommended stack:");
for (const line of result.rationale) log.message(` • ${line}`);
log.message(`\nConfig: ${JSON.stringify(result.input)}`);
if (result.matchedPreset) {
log.message(`Nearest preset: ${result.matchedPreset}`);
}
log.message("\nReview, then scaffold with: create-better-fullstack create <name> [flags]");
}),
});

const caller = createRouterClient(router, { context: {} });
Expand Down
9 changes: 8 additions & 1 deletion apps/cli/src/utils/config-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,14 @@ function validateRateLimitConstraints(config: Partial<ProjectConfig>) {
function validateSearchConstraints(config: Partial<ProjectConfig>) {
if (!config.search || config.search === "none") return;
const ecosystem = config.ecosystem ?? "typescript";
if (ecosystem !== "typescript" && config.search !== "meilisearch") {
// Non-TypeScript ecosystems default to Meilisearch, but a few have native
// options: Go adds Bleve (embedded) and Python adds Elasticsearch.
const nonTsSearchAllowlist: Record<string, string[]> = {
go: ["meilisearch", "bleve"],
python: ["meilisearch", "elasticsearch"],
};
const allowedSearch = nonTsSearchAllowlist[ecosystem] ?? ["meilisearch"];
if (ecosystem !== "typescript" && !allowedSearch.includes(config.search)) {
incompatibilityError({
message: "Only Meilisearch search is available for non-TypeScript ecosystems.",
provided: { ecosystem, search: config.search },
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/src/utils/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ const TEMPLATE_PRESETS: Record<Template, CreateInput | null> = {
webDeploy: "none",
serverDeploy: "none",
},
saas: {
database: "postgres",
orm: "drizzle",
backend: "self",
runtime: "none",
frontend: ["next"],
api: "trpc",
auth: "better-auth-organizations",
payments: "creem",
addons: ["turborepo"],
examples: ["none"],
dbSetup: "none",
webDeploy: "none",
serverDeploy: "none",
},
uniwind: {
database: "none",
orm: "none",
Expand Down Expand Up @@ -82,6 +97,7 @@ export function getTemplateDescription(template: Template) {
mern: "MongoDB + Express + React + Node.js - Classic MERN stack",
pern: "PostgreSQL + Express + React + Node.js - Popular PERN stack",
t3: "T3 Stack - Next.js + tRPC + Prisma + PostgreSQL + Better Auth",
saas: "SaaS Starter - Next.js + tRPC + Drizzle + PostgreSQL + Better Auth Organizations + Creem billing",
uniwind: "Expo + Uniwind native app with no backend services",
none: "No template - Full customization",
};
Expand Down
Loading
Loading