From 725376d560fff4d731cb38cf36c88af722d21c3a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:26:05 +0300 Subject: [PATCH 01/33] Add first-class mobile scaffolding --- apps/cli/src/create-command-input.ts | 14 + apps/cli/src/helpers/core/command-handlers.ts | 7 + apps/cli/src/index.ts | 7 + apps/cli/src/mcp.ts | 57 +- apps/cli/src/prompts/config-prompts.ts | 83 ++ apps/cli/src/prompts/mobile.ts | 158 ++++ .../src/prompts/prompt-resolver-registry.ts | 53 ++ apps/cli/src/utils/bts-config.ts | 14 + apps/cli/src/utils/display-config.ts | 46 ++ .../utils/generate-reproducible-command.ts | 7 + .../template-snapshots.test.ts.snap | 710 +++++++++++++++++- apps/cli/test/mobile.test.ts | 94 +++ apps/cli/test/template-snapshots.test.ts | 25 + apps/web/content/docs/cli/create.mdx | 7 + .../content/docs/ecosystems/typescript.mdx | 5 + .../docs/reference/options/typescript.mdx | 9 + apps/web/content/docs/roadmap.mdx | 2 +- apps/web/src/lib/constant.ts | 172 +++++ apps/web/src/lib/stack-utils.ts | 7 + apps/web/src/lib/tech-resource-links.ts | 52 ++ .../src/processors/env-vars.ts | 29 +- .../native/bare/app/(drawer)/index.tsx.hbs | 2 + .../unistyles/app/(drawer)/index.tsx.hbs | 2 + .../native/uniwind/app/(drawer)/index.tsx.hbs | 2 + .../native/base/app/(auth)/_layout.tsx.hbs | 2 + .../native/base/app/(auth)/sign-in.tsx.hbs | 2 + .../native/base/app/(auth)/sign-up.tsx.hbs | 2 + .../frontend/native/bare/app.json.hbs | 119 +-- .../bare/app/(drawer)/(tabs)/_layout.tsx.hbs | 3 +- .../bare/app/(drawer)/(tabs)/index.tsx.hbs | 3 +- .../bare/app/(drawer)/(tabs)/two.tsx.hbs | 3 +- .../native/bare/app/(drawer)/_layout.tsx.hbs | 3 +- .../native/bare/app/(drawer)/index.tsx.hbs | 2 + .../native/bare/app/+not-found.tsx.hbs | 3 +- .../frontend/native/bare/app/_layout.tsx.hbs | 35 +- .../frontend/native/bare/app/modal.tsx.hbs | 3 +- .../frontend/native/bare/package.json.hbs | 38 +- .../native/base/.maestro/home.yaml.hbs | 6 + .../frontend/native/base/App.tsx.hbs | 97 +++ .../__tests__/mobile-ui-provider.test.tsx.hbs | 33 + .../components/mobile-ui-provider.tsx.hbs | 20 + .../frontend/native/base/index.js.hbs | 7 + .../frontend/native/base/jest.config.js.hbs | 10 + .../native/base/lib/deep-linking.ts.hbs | 23 + .../native/base/lib/mobile-storage.ts.hbs | 15 + .../native/base/lib/notifications.ts.hbs | 52 ++ .../frontend/native/base/lib/updates.ts.hbs | 30 + .../base/navigation/native-navigation.tsx.hbs | 145 ++++ .../frontend/native/unistyles/app.json.hbs | 30 +- .../app/(drawer)/(tabs)/_layout.tsx.hbs | 2 + .../app/(drawer)/(tabs)/index.tsx.hbs | 2 + .../unistyles/app/(drawer)/(tabs)/two.tsx.hbs | 2 + .../unistyles/app/(drawer)/_layout.tsx.hbs | 2 + .../unistyles/app/(drawer)/index.tsx.hbs | 2 + .../native/unistyles/app/+not-found.tsx.hbs | 2 + .../native/unistyles/app/_layout.tsx.hbs | 10 + .../native/unistyles/app/modal.tsx.hbs | 2 + .../frontend/native/unistyles/index.js.hbs | 9 +- .../native/unistyles/package.json.hbs | 34 +- .../frontend/native/uniwind/app.json.hbs | 65 +- .../app/(drawer)/(tabs)/_layout.tsx.hbs | 2 + .../uniwind/app/(drawer)/(tabs)/index.tsx.hbs | 2 + .../uniwind/app/(drawer)/(tabs)/two.tsx.hbs | 2 + .../uniwind/app/(drawer)/_layout.tsx.hbs | 2 + .../native/uniwind/app/(drawer)/index.tsx.hbs | 2 + .../native/uniwind/app/+not-found.tsx.hbs | 2 + .../native/uniwind/app/_layout.tsx.hbs | 16 + .../frontend/native/uniwind/app/modal.tsx.hbs | 2 + .../frontend/native/uniwind/package.json.hbs | 32 +- .../templates/packages/env/src/native.ts.hbs | 6 + packages/types/src/compatibility.ts | 133 ++++ packages/types/src/defaults.ts | 7 + packages/types/src/option-metadata.ts | 55 ++ packages/types/src/schemas.ts | 56 ++ packages/types/src/stack-translation.ts | 35 + packages/types/src/types.ts | 14 + 76 files changed, 2664 insertions(+), 86 deletions(-) create mode 100644 apps/cli/src/prompts/mobile.ts create mode 100644 apps/cli/test/mobile.test.ts create mode 100644 packages/template-generator/templates/frontend/native/base/.maestro/home.yaml.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/App.tsx.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/index.js.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/jest.config.js.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/lib/mobile-storage.ts.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/lib/notifications.ts.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/lib/updates.ts.hbs create mode 100644 packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts index cde519651..ba6349dda 100644 --- a/apps/cli/src/create-command-input.ts +++ b/apps/cli/src/create-command-input.ts @@ -24,6 +24,13 @@ import { FileStorageSchema, FileUploadSchema, FormsSchema, + MobileDeepLinkingSchema, + MobileNavigationSchema, + MobileOTASchema, + MobilePushSchema, + MobileStorageSchema, + MobileTestingSchema, + MobileUISchema, FrontendSchema, GoApiSchema, GoAuthSchema, @@ -123,6 +130,13 @@ export const CreateCommandOptionsSchema = z.object({ i18n: I18nSchema.optional().describe("Internationalization (i18n) library"), search: SearchSchema.optional().describe("Search engine solution"), fileStorage: FileStorageSchema.optional().describe("File storage solution (S3, R2)"), + mobileNavigation: MobileNavigationSchema.optional().describe("Mobile navigation (expo-router, react-navigation)"), + mobileUI: MobileUISchema.optional().describe("Mobile UI (tamagui, gluestack-ui, uniwind, unistyles)"), + mobileStorage: MobileStorageSchema.optional().describe("Mobile storage (mmkv)"), + mobileTesting: MobileTestingSchema.optional().describe("Mobile testing (maestro, react-native-testing-library)"), + mobilePush: MobilePushSchema.optional().describe("Mobile push notifications (expo-notifications)"), + mobileOTA: MobileOTASchema.optional().describe("Mobile OTA updates (expo-updates)"), + mobileDeepLinking: MobileDeepLinkingSchema.optional().describe("Mobile deep linking (expo-linking)"), frontend: z.array(FrontendSchema).optional(), astroIntegration: AstroIntegrationSchema.optional().describe( "Astro UI framework integration (react, vue, svelte, solid)", diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index a40bc0398..ace3e9896 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -170,6 +170,13 @@ export async function createProjectHandler( featureFlags: "none", analytics: "none", fileStorage: "none", + mobileNavigation: "none", + mobileUI: "none", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "none", pythonWebFramework: "none", pythonOrm: "none", pythonValidation: "none", diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index cd7f02615..dad812b53 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -462,6 +462,13 @@ export async function createVirtual( observability: options.observability || "none", featureFlags: options.featureFlags || "none", analytics: options.analytics || "none", + mobileNavigation: options.mobileNavigation || "expo-router", + mobileUI: options.mobileUI || "none", + mobileStorage: options.mobileStorage || "none", + mobileTesting: options.mobileTesting || "none", + mobilePush: options.mobilePush || "none", + mobileOTA: options.mobileOTA || "none", + mobileDeepLinking: options.mobileDeepLinking || "expo-linking", cms: options.cms || "none", caching: options.caching || "none", i18n: options.i18n || "none", diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 4128d97ed..cb97bd6bc 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -25,6 +25,13 @@ import { FileUploadSchema, FormsSchema, FrontendSchema, + MobileDeepLinkingSchema, + MobileNavigationSchema, + MobileOTASchema, + MobilePushSchema, + MobileStorageSchema, + MobileTestingSchema, + MobileUISchema, GoApiSchema, GoCliSchema, GoAuthSchema, @@ -207,6 +214,13 @@ const SCHEMA_MAP: Record = { i18n: I18nSchema, search: SearchSchema, fileStorage: FileStorageSchema, + mobileNavigation: MobileNavigationSchema, + mobileUI: MobileUISchema, + mobileStorage: MobileStorageSchema, + mobileTesting: MobileTestingSchema, + mobilePush: MobilePushSchema, + mobileOTA: MobileOTASchema, + mobileDeepLinking: MobileDeepLinkingSchema, addons: AddonsSchema, examples: ExamplesSchema, packageManager: PackageManagerSchema, @@ -253,7 +267,9 @@ const ECOSYSTEM_CATEGORIES: Record = { "email", "fileUpload", "effect", "ai", "stateManagement", "forms", "validation", "testing", "cssFramework", "uiLibrary", "realtime", "jobQueue", "animation", "logging", "observability", "featureFlags", "analytics", "cms", "caching", - "i18n", "search", "fileStorage", "astroIntegration", + "i18n", "search", "fileStorage", "astroIntegration", "mobileNavigation", + "mobileUI", "mobileStorage", "mobileTesting", "mobilePush", "mobileOTA", + "mobileDeepLinking", ], rust: ["rustWebFramework", "rustFrontend", "rustOrm", "rustApi", "rustCli", "rustLibraries", "rustLogging", "rustErrorHandling", "rustCaching", "rustAuth", "email", "observability", "caching", "search"], python: ["pythonWebFramework", "pythonOrm", "pythonValidation", "pythonAi", "pythonAuth", "pythonApi", "pythonTaskQueue", "pythonGraphql", "pythonQuality", "email", "observability", "caching", "search"], @@ -362,7 +378,7 @@ function buildProjectConfig( payments: (input.payments as ProjectConfig["payments"]) ?? "none", email: (input.email as ProjectConfig["email"]) ?? "none", fileUpload: (input.fileUpload as ProjectConfig["fileUpload"]) ?? "none", - effect: "none", + effect: (input.effect as ProjectConfig["effect"]) ?? "none", ai: (input.ai as ProjectConfig["ai"]) ?? "none", stateManagement: (input.stateManagement as ProjectConfig["stateManagement"]) ?? "none", forms: (input.forms as ProjectConfig["forms"]) ?? "none", @@ -383,7 +399,15 @@ function buildProjectConfig( logging: (input.logging as ProjectConfig["logging"]) ?? "none", observability: (input.observability as ProjectConfig["observability"]) ?? "none", featureFlags: (input.featureFlags as ProjectConfig["featureFlags"]) ?? "none", - analytics: "none", + analytics: (input.analytics as ProjectConfig["analytics"]) ?? "none", + mobileNavigation: (input.mobileNavigation as ProjectConfig["mobileNavigation"]) ?? "expo-router", + mobileUI: (input.mobileUI as ProjectConfig["mobileUI"]) ?? "none", + mobileStorage: (input.mobileStorage as ProjectConfig["mobileStorage"]) ?? "none", + mobileTesting: (input.mobileTesting as ProjectConfig["mobileTesting"]) ?? "none", + mobilePush: (input.mobilePush as ProjectConfig["mobilePush"]) ?? "none", + mobileOTA: (input.mobileOTA as ProjectConfig["mobileOTA"]) ?? "none", + mobileDeepLinking: + (input.mobileDeepLinking as ProjectConfig["mobileDeepLinking"]) ?? "expo-linking", cms: (input.cms as ProjectConfig["cms"]) ?? "none", caching: (input.caching as ProjectConfig["caching"]) ?? "none", i18n: (input.i18n as ProjectConfig["i18n"]) ?? "none", @@ -450,6 +474,8 @@ function sanitizePath(input: string): string { function buildCompatibilityInput(input: Record): CompatibilityInput { const frontend = input.frontend as string[] | undefined; + const webFrontend = (frontend ?? []).filter((item) => !item.startsWith("native-")); + const nativeFrontend = (frontend ?? []).filter((item) => item.startsWith("native-")); const addons = (input.addons as string[] | undefined) ?? []; const codeQuality = addons.filter((a) => @@ -464,8 +490,8 @@ function buildCompatibilityInput(input: Record): CompatibilityI return { ecosystem: (input.ecosystem as CompatibilityInput["ecosystem"]) ?? "typescript", projectName: (input.projectName as string) ?? null, - webFrontend: frontend ?? [], - nativeFrontend: [], + webFrontend, + nativeFrontend, astroIntegration: (input.astroIntegration as string) ?? "none", runtime: (input.runtime as string) ?? "bun", backend: (input.backend as string) ?? "hono", @@ -502,6 +528,13 @@ function buildCompatibilityInput(input: Record): CompatibilityI cms: (input.cms as string) ?? "none", search: (input.search as string) ?? "none", fileStorage: (input.fileStorage as string) ?? "none", + mobileNavigation: (input.mobileNavigation as string) ?? "expo-router", + mobileUI: (input.mobileUI as string) ?? "none", + mobileStorage: (input.mobileStorage as string) ?? "none", + mobileTesting: (input.mobileTesting as string) ?? "none", + mobilePush: (input.mobilePush as string) ?? "none", + mobileOTA: (input.mobileOTA as string) ?? "none", + mobileDeepLinking: (input.mobileDeepLinking as string) ?? "expo-linking", codeQuality, documentation, appPlatforms, @@ -742,6 +775,13 @@ export async function startMcpServer() { i18n: I18nSchema.optional().describe("Internationalization library"), search: SearchSchema.optional().describe("Search engine"), fileStorage: FileStorageSchema.optional().describe("File storage"), + mobileNavigation: MobileNavigationSchema.optional().describe("Mobile navigation"), + mobileUI: MobileUISchema.optional().describe("Mobile UI"), + mobileStorage: MobileStorageSchema.optional().describe("Mobile storage"), + mobileTesting: MobileTestingSchema.optional().describe("Mobile testing"), + mobilePush: MobilePushSchema.optional().describe("Mobile push notifications"), + mobileOTA: MobileOTASchema.optional().describe("Mobile OTA updates"), + mobileDeepLinking: MobileDeepLinkingSchema.optional().describe("Mobile deep linking"), dbSetup: DatabaseSetupSchema.optional().describe("Database hosting provider"), webDeploy: WebDeploySchema.optional().describe("Web deployment target"), serverDeploy: ServerDeploySchema.optional().describe("Server deployment target"), @@ -839,6 +879,13 @@ export async function startMcpServer() { i18n: I18nSchema.optional().describe("Internationalization (i18n) library"), cms: CMSSchema.optional().describe("CMS"), fileStorage: FileStorageSchema.optional().describe("File storage"), + mobileNavigation: MobileNavigationSchema.optional().describe("Mobile navigation"), + mobileUI: MobileUISchema.optional().describe("Mobile UI"), + mobileStorage: MobileStorageSchema.optional().describe("Mobile storage"), + mobileTesting: MobileTestingSchema.optional().describe("Mobile testing"), + mobilePush: MobilePushSchema.optional().describe("Mobile push notifications"), + mobileOTA: MobileOTASchema.optional().describe("Mobile OTA updates"), + mobileDeepLinking: MobileDeepLinkingSchema.optional().describe("Mobile deep linking"), fileUpload: FileUploadSchema.optional().describe("File upload"), webDeploy: WebDeploySchema.optional().describe("Web deployment target"), serverDeploy: ServerDeploySchema.optional().describe("Server deployment target"), diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 2680c8e78..043ee12bc 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -36,6 +36,13 @@ import type { JavaWebFramework, JobQueue, Logging, + MobileDeepLinking, + MobileNavigation, + MobileOTA, + MobilePush, + MobileStorage, + MobileTesting, + MobileUI, Observability, ORM, PackageManager, @@ -117,6 +124,15 @@ import { } from "./java-ecosystem"; import { getJobQueueChoice } from "./job-queue"; import { getLoggingChoice } from "./logging"; +import { + getMobileDeepLinkingChoice, + getMobileNavigationChoice, + getMobileOTAChoice, + getMobilePushChoice, + getMobileStorageChoice, + getMobileTestingChoice, + getMobileUIChoice, +} from "./mobile"; import { navigableGroup } from "./navigable-group"; import { getObservabilityChoice } from "./observability"; import { getORMChoice } from "./orm"; @@ -197,6 +213,13 @@ type PromptGroupResults = { i18n: I18n; search: Search; fileStorage: FileStorage; + mobileNavigation: MobileNavigation; + mobileUI: MobileUI; + mobileStorage: MobileStorage; + mobileTesting: MobileTesting; + mobilePush: MobilePush; + mobileOTA: MobileOTA; + mobileDeepLinking: MobileDeepLinking; // Rust ecosystem rustWebFramework: RustWebFramework; rustFrontend: RustFrontend; @@ -475,6 +498,59 @@ export async function gatherConfig( if (results.ecosystem !== "typescript") return Promise.resolve("none" as FileStorage); return getFileStorageChoice(flags.fileStorage, results.backend); }, + mobileNavigation: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileNavigation); + if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { + return Promise.resolve("none" as MobileNavigation); + } + return getMobileNavigationChoice(flags.mobileNavigation); + }, + mobileUI: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileUI); + if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { + return Promise.resolve("none" as MobileUI); + } + if (results.frontend.includes("native-uniwind")) return Promise.resolve("uniwind" as MobileUI); + if (results.frontend.includes("native-unistyles")) { + return Promise.resolve("unistyles" as MobileUI); + } + return getMobileUIChoice(flags.mobileUI); + }, + mobileStorage: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileStorage); + if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { + return Promise.resolve("none" as MobileStorage); + } + return getMobileStorageChoice(flags.mobileStorage); + }, + mobileTesting: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileTesting); + if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { + return Promise.resolve("none" as MobileTesting); + } + return getMobileTestingChoice(flags.mobileTesting); + }, + mobilePush: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobilePush); + if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { + return Promise.resolve("none" as MobilePush); + } + return getMobilePushChoice(flags.mobilePush); + }, + mobileOTA: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileOTA); + if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { + return Promise.resolve("none" as MobileOTA); + } + return getMobileOTAChoice(flags.mobileOTA); + }, + mobileDeepLinking: ({ results }) => { + if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileDeepLinking); + if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { + return Promise.resolve("none" as MobileDeepLinking); + } + return getMobileDeepLinkingChoice(flags.mobileDeepLinking); + }, // Rust ecosystem prompts (skip if TypeScript or Python) rustWebFramework: ({ results }) => { if (results.ecosystem !== "rust") return Promise.resolve("none" as RustWebFramework); @@ -685,6 +761,13 @@ export async function gatherConfig( i18n: result.i18n, search: result.search, fileStorage: result.fileStorage, + mobileNavigation: result.mobileNavigation, + mobileUI: result.mobileUI, + mobileStorage: result.mobileStorage, + mobileTesting: result.mobileTesting, + mobilePush: result.mobilePush, + mobileOTA: result.mobileOTA, + mobileDeepLinking: result.mobileDeepLinking, // Ecosystem ecosystem: result.ecosystem, // Rust ecosystem options diff --git a/apps/cli/src/prompts/mobile.ts b/apps/cli/src/prompts/mobile.ts new file mode 100644 index 000000000..f797e903a --- /dev/null +++ b/apps/cli/src/prompts/mobile.ts @@ -0,0 +1,158 @@ +import type { + MobileDeepLinking, + MobileNavigation, + MobileOTA, + MobilePush, + MobileStorage, + MobileTesting, + MobileUI, +} from "../types"; + +import { + createStaticSinglePromptResolution, + type PromptOption, +} from "./prompt-contract"; +import { exitCancelled } from "../utils/errors"; +import { isCancel, navigableSelect } from "./navigable"; + +const MOBILE_NAVIGATION_OPTIONS: PromptOption[] = [ + { value: "expo-router", label: "Expo Router", hint: "File-based routing for Expo apps" }, + { value: "react-navigation", label: "React Navigation", hint: "Code-defined native stacks and tabs" }, + { value: "none", label: "None", hint: "Skip navigation setup" }, +]; + +const MOBILE_UI_OPTIONS: PromptOption[] = [ + { value: "none", label: "None", hint: "Use React Native primitives" }, + { value: "tamagui", label: "Tamagui", hint: "Universal themed UI primitives" }, + { value: "gluestack-ui", label: "Gluestack UI", hint: "Accessible cross-platform components" }, + { value: "uniwind", label: "Uniwind", hint: "Tailwind-style React Native styling" }, + { value: "unistyles", label: "Unistyles", hint: "Type-safe React Native stylesheets" }, +]; + +const MOBILE_STORAGE_OPTIONS: PromptOption[] = [ + { value: "none", label: "None", hint: "Skip device storage helpers" }, + { value: "mmkv", label: "MMKV", hint: "Fast encrypted key-value storage" }, +]; + +const MOBILE_TESTING_OPTIONS: PromptOption[] = [ + { value: "none", label: "None", hint: "Skip mobile testing setup" }, + { value: "maestro", label: "Maestro", hint: "Mobile E2E flow files" }, + { + value: "react-native-testing-library", + label: "React Native Testing Library", + hint: "Unit tests for native components", + }, + { + value: "maestro-react-native-testing-library", + label: "Maestro + RN Testing Library", + hint: "Mobile E2E flows and unit tests", + }, +]; + +const MOBILE_PUSH_OPTIONS: PromptOption[] = [ + { value: "none", label: "None", hint: "Skip push notification setup" }, + { value: "expo-notifications", label: "Expo Notifications", hint: "Expo push token helper" }, +]; + +const MOBILE_OTA_OPTIONS: PromptOption[] = [ + { value: "none", label: "None", hint: "Skip OTA update setup" }, + { value: "expo-updates", label: "Expo Updates", hint: "Runtime version and update helper" }, +]; + +const MOBILE_DEEP_LINKING_OPTIONS: PromptOption[] = [ + { value: "expo-linking", label: "Expo Linking", hint: "Scheme config and redirect URI helpers" }, + { value: "none", label: "None", hint: "Skip deep link helpers" }, +]; + +async function promptMobileOption( + options: PromptOption[], + defaultValue: T, + selected: T | undefined, + message: string, +) { + const resolution = createStaticSinglePromptResolution(options, defaultValue, selected); + if (!resolution.shouldPrompt) return resolution.autoValue ?? defaultValue; + + const response = await navigableSelect({ + message, + options: resolution.options, + initialValue: resolution.initialValue as T, + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + return response; +} + +export function resolveMobileNavigationPrompt(mobileNavigation?: MobileNavigation) { + return createStaticSinglePromptResolution( + MOBILE_NAVIGATION_OPTIONS, + "expo-router", + mobileNavigation, + ); +} + +export function resolveMobileUIPrompt(mobileUI?: MobileUI) { + return createStaticSinglePromptResolution(MOBILE_UI_OPTIONS, "none", mobileUI); +} + +export function resolveMobileStoragePrompt(mobileStorage?: MobileStorage) { + return createStaticSinglePromptResolution(MOBILE_STORAGE_OPTIONS, "none", mobileStorage); +} + +export function resolveMobileTestingPrompt(mobileTesting?: MobileTesting) { + return createStaticSinglePromptResolution(MOBILE_TESTING_OPTIONS, "none", mobileTesting); +} + +export function resolveMobilePushPrompt(mobilePush?: MobilePush) { + return createStaticSinglePromptResolution(MOBILE_PUSH_OPTIONS, "none", mobilePush); +} + +export function resolveMobileOTAPrompt(mobileOTA?: MobileOTA) { + return createStaticSinglePromptResolution(MOBILE_OTA_OPTIONS, "none", mobileOTA); +} + +export function resolveMobileDeepLinkingPrompt(mobileDeepLinking?: MobileDeepLinking) { + return createStaticSinglePromptResolution( + MOBILE_DEEP_LINKING_OPTIONS, + "expo-linking", + mobileDeepLinking, + ); +} + +export function getMobileNavigationChoice(mobileNavigation?: MobileNavigation) { + return promptMobileOption( + MOBILE_NAVIGATION_OPTIONS, + "expo-router", + mobileNavigation, + "Select mobile navigation", + ); +} + +export function getMobileUIChoice(mobileUI?: MobileUI) { + return promptMobileOption(MOBILE_UI_OPTIONS, "none", mobileUI, "Select mobile UI"); +} + +export function getMobileStorageChoice(mobileStorage?: MobileStorage) { + return promptMobileOption(MOBILE_STORAGE_OPTIONS, "none", mobileStorage, "Select mobile storage"); +} + +export function getMobileTestingChoice(mobileTesting?: MobileTesting) { + return promptMobileOption(MOBILE_TESTING_OPTIONS, "none", mobileTesting, "Select mobile testing"); +} + +export function getMobilePushChoice(mobilePush?: MobilePush) { + return promptMobileOption(MOBILE_PUSH_OPTIONS, "none", mobilePush, "Select mobile push"); +} + +export function getMobileOTAChoice(mobileOTA?: MobileOTA) { + return promptMobileOption(MOBILE_OTA_OPTIONS, "none", mobileOTA, "Select mobile OTA updates"); +} + +export function getMobileDeepLinkingChoice(mobileDeepLinking?: MobileDeepLinking) { + return promptMobileOption( + MOBILE_DEEP_LINKING_OPTIONS, + "expo-linking", + mobileDeepLinking, + "Select mobile deep linking", + ); +} diff --git a/apps/cli/src/prompts/prompt-resolver-registry.ts b/apps/cli/src/prompts/prompt-resolver-registry.ts index 1e6a97b6c..26c9ac4f0 100644 --- a/apps/cli/src/prompts/prompt-resolver-registry.ts +++ b/apps/cli/src/prompts/prompt-resolver-registry.ts @@ -14,6 +14,13 @@ import { FILE_UPLOAD_VALUES, FORMS_VALUES, FRONTEND_VALUES, + MOBILE_DEEP_LINKING_VALUES, + MOBILE_NAVIGATION_VALUES, + MOBILE_OTA_VALUES, + MOBILE_PUSH_VALUES, + MOBILE_STORAGE_VALUES, + MOBILE_TESTING_VALUES, + MOBILE_UI_VALUES, GO_API_VALUES, GO_AUTH_VALUES, GO_CLI_VALUES, @@ -90,6 +97,15 @@ import { } from "./java-ecosystem"; import { resolveJobQueuePrompt } from "./job-queue"; import { resolveLoggingPrompt } from "./logging"; +import { + resolveMobileDeepLinkingPrompt, + resolveMobileNavigationPrompt, + resolveMobileOTAPrompt, + resolveMobilePushPrompt, + resolveMobileStoragePrompt, + resolveMobileTestingPrompt, + resolveMobileUIPrompt, +} from "./mobile"; import { resolveObservabilityPrompt } from "./observability"; import { resolveORMPrompt } from "./orm"; import { resolvePaymentsPrompt } from "./payments"; @@ -266,6 +282,43 @@ export const PROMPT_RESOLVER_REGISTRY: ResolverRegistry = { resolve: ({ value }: { value?: string } = {}) => resolveTestingPrompt(value as any), coverageContexts: [{}], }, + mobileNavigation: { + schemaValues: MOBILE_NAVIGATION_VALUES, + resolve: ({ value }: { value?: string } = {}) => + resolveMobileNavigationPrompt(value as any), + coverageContexts: [{}], + }, + mobileUI: { + schemaValues: MOBILE_UI_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveMobileUIPrompt(value as any), + coverageContexts: [{}], + }, + mobileStorage: { + schemaValues: MOBILE_STORAGE_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveMobileStoragePrompt(value as any), + coverageContexts: [{}], + }, + mobileTesting: { + schemaValues: MOBILE_TESTING_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveMobileTestingPrompt(value as any), + coverageContexts: [{}], + }, + mobilePush: { + schemaValues: MOBILE_PUSH_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveMobilePushPrompt(value as any), + coverageContexts: [{}], + }, + mobileOTA: { + schemaValues: MOBILE_OTA_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveMobileOTAPrompt(value as any), + coverageContexts: [{}], + }, + mobileDeepLinking: { + schemaValues: MOBILE_DEEP_LINKING_VALUES, + resolve: ({ value }: { value?: string } = {}) => + resolveMobileDeepLinkingPrompt(value as any), + coverageContexts: [{}], + }, uiLibrary: { schemaValues: UI_LIBRARY_VALUES, resolve: resolveUILibraryPrompt, diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index d736ab7b4..89a66cf6a 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -45,6 +45,13 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { observability: projectConfig.observability, featureFlags: projectConfig.featureFlags, analytics: projectConfig.analytics, + mobileNavigation: projectConfig.mobileNavigation, + mobileUI: projectConfig.mobileUI, + mobileStorage: projectConfig.mobileStorage, + mobileTesting: projectConfig.mobileTesting, + mobilePush: projectConfig.mobilePush, + mobileOTA: projectConfig.mobileOTA, + mobileDeepLinking: projectConfig.mobileDeepLinking, cms: projectConfig.cms, caching: projectConfig.caching, i18n: projectConfig.i18n, @@ -121,6 +128,13 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { observability: btsConfig.observability, featureFlags: btsConfig.featureFlags, analytics: btsConfig.analytics, + mobileNavigation: btsConfig.mobileNavigation, + mobileUI: btsConfig.mobileUI, + mobileStorage: btsConfig.mobileStorage, + mobileTesting: btsConfig.mobileTesting, + mobilePush: btsConfig.mobilePush, + mobileOTA: btsConfig.mobileOTA, + mobileDeepLinking: btsConfig.mobileDeepLinking, cms: btsConfig.cms, caching: btsConfig.caching, i18n: btsConfig.i18n, diff --git a/apps/cli/src/utils/display-config.ts b/apps/cli/src/utils/display-config.ts index 98a3d081f..32cc0cf99 100644 --- a/apps/cli/src/utils/display-config.ts +++ b/apps/cli/src/utils/display-config.ts @@ -104,10 +104,50 @@ export function displayConfig(config: Partial) { configDisplay.push(`${pc.blue("Observability:")} ${String(config.observability)}`); } + if (config.featureFlags !== undefined) { + configDisplay.push(`${pc.blue("Feature Flags:")} ${String(config.featureFlags)}`); + } + + if (config.analytics !== undefined) { + configDisplay.push(`${pc.blue("Analytics:")} ${String(config.analytics)}`); + } + + if (config.mobileNavigation !== undefined) { + configDisplay.push(`${pc.blue("Mobile Navigation:")} ${String(config.mobileNavigation)}`); + } + + if (config.mobileUI !== undefined) { + configDisplay.push(`${pc.blue("Mobile UI:")} ${String(config.mobileUI)}`); + } + + if (config.mobileStorage !== undefined) { + configDisplay.push(`${pc.blue("Mobile Storage:")} ${String(config.mobileStorage)}`); + } + + if (config.mobileTesting !== undefined) { + configDisplay.push(`${pc.blue("Mobile Testing:")} ${String(config.mobileTesting)}`); + } + + if (config.mobilePush !== undefined) { + configDisplay.push(`${pc.blue("Mobile Push:")} ${String(config.mobilePush)}`); + } + + if (config.mobileOTA !== undefined) { + configDisplay.push(`${pc.blue("Mobile OTA:")} ${String(config.mobileOTA)}`); + } + + if (config.mobileDeepLinking !== undefined) { + configDisplay.push(`${pc.blue("Mobile Deep Linking:")} ${String(config.mobileDeepLinking)}`); + } + if (config.caching !== undefined) { configDisplay.push(`${pc.blue("Caching:")} ${String(config.caching)}`); } + if (config.i18n !== undefined) { + configDisplay.push(`${pc.blue("i18n:")} ${String(config.i18n)}`); + } + if (config.cms !== undefined) { configDisplay.push(`${pc.blue("CMS:")} ${String(config.cms)}`); } @@ -133,6 +173,12 @@ export function displayConfig(config: Partial) { configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`); } + if (config.aiDocs !== undefined) { + const aiDocs = Array.isArray(config.aiDocs) ? config.aiDocs : [config.aiDocs]; + const aiDocsText = aiDocs.length > 0 && aiDocs[0] !== undefined ? aiDocs.join(", ") : "none"; + configDisplay.push(`${pc.blue("AI Docs:")} ${aiDocsText}`); + } + if (config.git !== undefined) { const gitText = typeof config.git === "boolean" ? (config.git ? "Yes" : "No") : String(config.git); diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 41ec6b122..8096badb0 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -97,6 +97,13 @@ function getTypeScriptFlags(config: ProjectConfig) { flags.push(`--cms ${config.cms}`); flags.push(`--search ${config.search}`); flags.push(`--file-storage ${config.fileStorage}`); + flags.push(`--mobile-navigation ${config.mobileNavigation}`); + flags.push(`--mobile-ui ${config.mobileUI}`); + flags.push(`--mobile-storage ${config.mobileStorage}`); + flags.push(`--mobile-testing ${config.mobileTesting}`); + flags.push(`--mobile-push ${config.mobilePush}`); + flags.push(`--mobile-ota ${config.mobileOTA}`); + flags.push(`--mobile-deep-linking ${config.mobileDeepLinking}`); if (config.addons && config.addons.length > 0) { flags.push(`--addons ${config.addons.join(" ")}`); diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 7d896281f..b94256e03 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -786,6 +786,7 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-reac "CLAUDE.md", "README.md", "apps/native/.env", + "apps/native/.env.example", "apps/native/app.json", "apps/native/app/(drawer)/(tabs)/_layout.tsx", "apps/native/app/(drawer)/(tabs)/index.tsx", @@ -797,9 +798,11 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-reac "apps/native/app/modal.tsx", "apps/native/components/container.tsx", "apps/native/components/header-button.tsx", + "apps/native/components/mobile-ui-provider.tsx", "apps/native/components/tabbar-icon.tsx", "apps/native/lib/android-navigation-bar.tsx", "apps/native/lib/constants.ts", + "apps/native/lib/deep-linking.ts", "apps/native/lib/use-color-scheme.ts", "apps/native/metro.config.js", "apps/native/package.json", @@ -833,6 +836,56 @@ exports[`Template Snapshots File Structure Snapshots file structure: native-reac ] `; +exports[`Template Snapshots File Structure Snapshots file structure: native-mobile-integrations 1`] = ` +[ + "CLAUDE.md", + "README.md", + "apps/native/.env", + "apps/native/.env.example", + "apps/native/.maestro/home.yaml", + "apps/native/App.tsx", + "apps/native/__tests__/mobile-ui-provider.test.tsx", + "apps/native/app.json", + "apps/native/components/container.tsx", + "apps/native/components/header-button.tsx", + "apps/native/components/mobile-ui-provider.tsx", + "apps/native/components/tabbar-icon.tsx", + "apps/native/index.js", + "apps/native/jest.config.js", + "apps/native/lib/android-navigation-bar.tsx", + "apps/native/lib/constants.ts", + "apps/native/lib/deep-linking.ts", + "apps/native/lib/mobile-storage.ts", + "apps/native/lib/notifications.ts", + "apps/native/lib/updates.ts", + "apps/native/lib/use-color-scheme.ts", + "apps/native/metro.config.js", + "apps/native/navigation/native-navigation.tsx", + "apps/native/package.json", + "apps/native/tsconfig.json", + "apps/native/utils/orpc.ts", + "apps/server/.env", + "apps/server/package.json", + "apps/server/src/index.ts", + "apps/server/tsconfig.json", + "apps/server/tsdown.config.ts", + "package.json", + "packages/api/package.json", + "packages/api/src/context.ts", + "packages/api/src/index.ts", + "packages/api/src/routers/index.ts", + "packages/api/tsconfig.json", + "packages/config/package.json", + "packages/config/tsconfig.base.json", + "packages/env/package.json", + "packages/env/src/native.ts", + "packages/env/src/server.ts", + "packages/env/src/web.ts", + "packages/env/tsconfig.json", + "tsconfig.json", +] +`; + exports[`Template Snapshots File Structure Snapshots file structure: java-spring-boot-jpa-security 1`] = ` [ ".env.example", @@ -11234,12 +11287,16 @@ export default defineConfig({ exports[`Template Snapshots Key File Content Snapshots key files: native-react-native 1`] = ` { - "fileCount": 62, + "fileCount": 65, "files": [ { "content": "[exists]", "path": "apps/native/.env", }, + { + "content": "EXPO_PUBLIC_SERVER_URL=http://localhost:3000", + "path": "apps/native/.env.example", + }, { "content": "[exists]", "path": "apps/native/app.json", @@ -11300,7 +11357,6 @@ const styles = StyleSheet.create({ fontSize: 16, }, }); - " , "path": "apps/native/app/(drawer)/(tabs)/index.tsx", @@ -11446,6 +11502,10 @@ fontWeight: "bold", "content": "[exists]", "path": "apps/native/components/header-button.tsx", }, + { + "content": "[exists]", + "path": "apps/native/components/mobile-ui-provider.tsx", + }, { "content": "[exists]", "path": "apps/native/components/tabbar-icon.tsx", @@ -11458,6 +11518,10 @@ fontWeight: "bold", "content": "[exists]", "path": "apps/native/lib/constants.ts", }, + { + "content": "[exists]", + "path": "apps/native/lib/deep-linking.ts", + }, { "content": "[exists]", "path": "apps/native/lib/use-color-scheme.ts", @@ -11484,6 +11548,7 @@ fontWeight: "bold", "@react-navigation/bottom-tabs": "^7.16.1", "@react-navigation/drawer": "^7.10.2", "@react-navigation/native": "^7.2.4", + "@react-navigation/native-stack": "^7.8.1", "@tanstack/react-form": "^1.32.0", "@tanstack/react-query": "^5.100.10", "expo": "^55.0.24", @@ -12021,6 +12086,647 @@ export const db = drizzle({ client, schema }); } `; +exports[`Template Snapshots Key File Content Snapshots key files: native-mobile-integrations 1`] = ` +{ + "fileCount": 59, + "files": [ + { + "content": "[exists]", + "path": "apps/native/__tests__/mobile-ui-provider.test.tsx", + }, + { + "content": "[exists]", + "path": "apps/native/.env", + }, + { + "content": +"EXPO_PUBLIC_SERVER_URL=http://localhost:3000 +# EAS project ID for Expo push notification tokens +EXPO_PUBLIC_EAS_PROJECT_ID=your-eas-project-id" +, + "path": "apps/native/.env.example", + }, + { + "content": "[exists]", + "path": "apps/native/.maestro/home.yaml", + }, + { + "content": "[exists]", + "path": "apps/native/app.json", + }, + { + "content": +" +import { QueryClientProvider } from "@tanstack/react-query"; +import { NavigationContainer } from "@react-navigation/native"; +import { StatusBar } from "expo-status-bar"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native"; +import { MobileUIProvider } from "@/components/mobile-ui-provider"; +import { linking } from "@/lib/deep-linking"; +import { queryClient } from "@/utils/orpc"; +import { RootNavigator } from "@/navigation/native-navigation"; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + + +function AppShell() { + return ( + + + + + + + + + + + ); +} + +export default function App() { + return ( + + + + ); +} +" +, + "path": "apps/native/App.tsx", + }, + { + "content": "[exists]", + "path": "apps/native/components/container.tsx", + }, + { + "content": "[exists]", + "path": "apps/native/components/header-button.tsx", + }, + { + "content": "[exists]", + "path": "apps/native/components/mobile-ui-provider.tsx", + }, + { + "content": "[exists]", + "path": "apps/native/components/tabbar-icon.tsx", + }, + { + "content": +"import { registerRootComponent } from "expo"; + +import App from "./App"; + +registerRootComponent(App); +" +, + "path": "apps/native/index.js", + }, + { + "content": "[exists]", + "path": "apps/native/jest.config.js", + }, + { + "content": "[exists]", + "path": "apps/native/lib/android-navigation-bar.tsx", + }, + { + "content": "[exists]", + "path": "apps/native/lib/constants.ts", + }, + { + "content": "[exists]", + "path": "apps/native/lib/deep-linking.ts", + }, + { + "content": "[exists]", + "path": "apps/native/lib/mobile-storage.ts", + }, + { + "content": "[exists]", + "path": "apps/native/lib/notifications.ts", + }, + { + "content": "[exists]", + "path": "apps/native/lib/updates.ts", + }, + { + "content": "[exists]", + "path": "apps/native/lib/use-color-scheme.ts", + }, + { + "content": "[exists]", + "path": "apps/native/metro.config.js", + }, + { + "content": "[exists]", + "path": "apps/native/navigation/native-navigation.tsx", + }, + { + "content": +"{ + "name": "native", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "dev": "expo start --clear", + "android": "expo run:android", + "ios": "expo run:ios", + "prebuild": "expo prebuild", + "web": "expo start --web", + "test": "jest" + }, + "dependencies": { + "@expo/vector-icons": "^15.1.1", + "@react-navigation/bottom-tabs": "^7.16.1", + "@react-navigation/drawer": "^7.10.2", + "@react-navigation/native": "^7.2.4", + "@react-navigation/native-stack": "^7.8.1", + "@tanstack/react-form": "^1.32.0", + "@gluestack-ui/themed": "^1.1.73", + "@tanstack/react-query": "^5.100.10", + "expo": "^55.0.24", + "expo-constants": "^55.0.16", + "expo-device": "^8.0.9", + "expo-crypto": "^55.0.15", + "expo-linking": "^55.0.15", + "expo-navigation-bar": "^55.0.13", + "expo-network": "^55.0.14", + "expo-notifications": "^56.0.12", + "expo-secure-store": "^55.0.14", + "expo-splash-screen": "^55.0.21", + "expo-status-bar": "^55.0.6", + "expo-system-ui": "^55.0.18", + "expo-updates": "^56.0.15", + "expo-web-browser": "^55.0.16", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-native": "^0.85.3", + "react-native-mmkv": "^4.1.0", + "react-native-gesture-handler": "^2.31.2", + "react-native-reanimated": "^4.3.1", + "react-native-safe-area-context": "^5.7.0", + "react-native-screens": "^4.25.0", + "react-native-web": "^0.21.2", + "react-native-worklets": "^0.8.3", + "dotenv": "catalog:", + "zod": "catalog:", + "@snapshot-native-mobile-integrations/env": "workspace:*", + "@snapshot-native-mobile-integrations/api": "workspace:*", + "@orpc/tanstack-query": "^1.14.3", + "@orpc/client": "catalog:" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@types/react": "^19.2.14", + "@types/jest": "^29.5.14", + "@testing-library/react-native": "^13.3.3", + "@react-native/jest-preset": "^0.85.3", + "jest": "^29.7.0", + "jest-expo": "^55.0.18", + "react-test-renderer": "^19.2.6", + "typescript": "catalog:", + "@snapshot-native-mobile-integrations/config": "workspace:*" + }, + "private": true +} +" +, + "path": "apps/native/package.json", + }, + { + "content": +"{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +} + +" +, + "path": "apps/native/tsconfig.json", + }, + { + "content": +"import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; +import { createTanstackQueryUtils } from "@orpc/tanstack-query"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; +import type { AppRouterClient } from "@snapshot-native-mobile-integrations/api/routers/index"; +import { env } from "@snapshot-native-mobile-integrations/env/native"; + +export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + console.log(error) + }, + }), +}); + +export const link = new RPCLink({ + url: \`\${env.EXPO_PUBLIC_SERVER_URL}/rpc\`, +}); + +export const client: AppRouterClient = createORPCClient(link); + +export const orpc = createTanstackQueryUtils(client); +" +, + "path": "apps/native/utils/orpc.ts", + }, + { + "content": "[exists]", + "path": "apps/server/.env", + }, + { + "content": +"{ + "name": "server", + "main": "src/index.ts", + "type": "module", + "scripts": { + "build": "tsdown", + "check-types": "tsc -b", + "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server", + "dev": "bun run --hot src/index.ts", + "start": "bun run dist/index.js" + }, + "dependencies": { + "dotenv": "catalog:", + "zod": "catalog:", + "@snapshot-native-mobile-integrations/env": "workspace:*", + "@snapshot-native-mobile-integrations/api": "workspace:*", + "hono": "catalog:", + "@orpc/server": "catalog:", + "@orpc/openapi": "catalog:", + "@orpc/zod": "catalog:" + }, + "devDependencies": { + "typescript": "catalog:", + "tsdown": "^0.22.0", + "@snapshot-native-mobile-integrations/config": "workspace:*", + "@types/bun": "catalog:" + } +} +" +, + "path": "apps/server/package.json", + }, + { + "content": +"import { env } from "@snapshot-native-mobile-integrations/env/server"; +import { OpenAPIHandler } from "@orpc/openapi/fetch"; +import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; +import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; +import { RPCHandler } from "@orpc/server/fetch"; +import { onError } from "@orpc/server"; +import { createContext } from "@snapshot-native-mobile-integrations/api/context"; +import { appRouter } from "@snapshot-native-mobile-integrations/api/routers/index"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; + +const app = new Hono(); + +app.use(logger()); +app.use( + "/*", + cors({ + origin: env.CORS_ORIGIN, + allowMethods: ["GET", "POST", "OPTIONS"], + }) +); + + +export const apiHandler = new OpenAPIHandler(appRouter, { + plugins: [ + new OpenAPIReferencePlugin({ + schemaConverters: [new ZodToJsonSchemaConverter()], + }), + ], + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +export const rpcHandler = new RPCHandler(appRouter, { + interceptors: [ + onError((error) => { + console.error(error); + }), + ], +}); + +app.use("/*", async (c, next) => { + const context = await createContext({ context: c }); + + const rpcResult = await rpcHandler.handle(c.req.raw, { + prefix: "/rpc", + context: context, + }); + + if (rpcResult.matched) { + return c.newResponse(rpcResult.response.body, rpcResult.response); + } + + const apiResult = await apiHandler.handle(c.req.raw, { + prefix: "/api-reference", + context: context, + }); + + if (apiResult.matched) { + return c.newResponse(apiResult.response.body, apiResult.response); + } + + await next(); +}); + + + + +app.get("/", (c) => { + return c.text("OK"); +}); + +export default app; +" +, + "path": "apps/server/src/index.ts", + }, + { + "content": +"{ + "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "paths": { + "@/*": ["./src/*"] + }, + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + } +} +" +, + "path": "apps/server/tsconfig.json", + }, + { + "content": "[exists]", + "path": "apps/server/tsdown.config.ts", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": +"{ + "name": "snapshot-native-mobile-integrations", + "private": true, + "type": "module", + "workspaces": { + "packages": [ + "apps/*", + "packages/*" + ], + "catalog": { + "dotenv": "^17.4.2", + "zod": "^4.4.3", + "typescript": "^6.0.3", + "@types/bun": "^1.3.14", + "hono": "^4.12.19", + "@orpc/server": "^1.14.3", + "@orpc/openapi": "^1.14.3", + "@orpc/zod": "^1.14.3", + "@orpc/client": "^1.14.3" + } + }, + "scripts": { + "dev": "bun run --filter '*' dev", + "build": "bun run --filter '*' build", + "check-types": "bun run --if-present --filter '*' check-types", + "dev:native": "bun run --filter native dev", + "dev:web": "bun run --filter web dev", + "dev:server": "bun run --filter server dev" + }, + "packageManager": "bun@1.3.5", + "dependencies": { + "dotenv": "catalog:", + "zod": "catalog:", + "@snapshot-native-mobile-integrations/env": "workspace:*" + }, + "devDependencies": { + "typescript": "catalog:", + "@types/bun": "catalog:", + "@snapshot-native-mobile-integrations/config": "workspace:*" + } +} +" +, + "path": "package.json", + }, + { + "content": +"{ + "name": "@snapshot-native-mobile-integrations/api", + "exports": { + ".": { + "default": "./src/index.ts" + }, + "./*": { + "default": "./src/*.ts" + } + }, + "type": "module", + "scripts": {}, + "devDependencies": { + "typescript": "catalog:", + "@snapshot-native-mobile-integrations/config": "workspace:*" + }, + "dependencies": { + "dotenv": "catalog:", + "zod": "catalog:", + "@snapshot-native-mobile-integrations/env": "workspace:*", + "@orpc/server": "catalog:", + "@orpc/client": "catalog:", + "@orpc/openapi": "catalog:", + "@orpc/zod": "catalog:", + "hono": "catalog:" + } +} +" +, + "path": "packages/api/package.json", + }, + { + "content": "[exists]", + "path": "packages/api/src/context.ts", + }, + { + "content": +"import { os } from "@orpc/server"; +import type { Context } from "./context"; + +export const o = os.$context(); + +export const publicProcedure = o; + +" +, + "path": "packages/api/src/index.ts", + }, + { + "content": +"import { publicProcedure } from "../index"; +import type { RouterClient } from "@orpc/server"; + +export const appRouter = { + healthCheck: publicProcedure.handler(() => { + return "OK"; + }), +}; +export type AppRouter = typeof appRouter; +export type AppRouterClient = RouterClient; +" +, + "path": "packages/api/src/routers/index.ts", + }, + { + "content": +"{ + "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "composite": true + } +} +" +, + "path": "packages/api/tsconfig.json", + }, + { + "content": +"{ + "name": "@snapshot-native-mobile-integrations/config", + "version": "0.0.0", + "private": true +} +" +, + "path": "packages/config/package.json", + }, + { + "content": +"{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "verbatimModuleSyntax": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": [ + "bun" + + ] + } +} +" +, + "path": "packages/config/tsconfig.base.json", + }, + { + "content": +"{ + "name": "@snapshot-native-mobile-integrations/env", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./server": "./src/server.ts", + "./native": "./src/native.ts" + }, + "dependencies": { + "dotenv": "catalog:", + "zod": "catalog:", + "@t3-oss/env-core": "^0.13.11" + }, + "devDependencies": { + "typescript": "catalog:", + "@types/bun": "catalog:", + "@snapshot-native-mobile-integrations/config": "workspace:*" + } +} +" +, + "path": "packages/env/package.json", + }, + { + "content": "[exists]", + "path": "packages/env/src/native.ts", + }, + { + "content": "[exists]", + "path": "packages/env/src/server.ts", + }, + { + "content": "[exists]", + "path": "packages/env/src/web.ts", + }, + { + "content": +"{ + "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json", +} +" +, + "path": "packages/env/tsconfig.json", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": +"{ + "extends": "@snapshot-native-mobile-integrations/config/tsconfig.base.json", +} +" +, + "path": "tsconfig.json", + }, + ], +} +`; + exports[`Template Snapshots Key File Content Snapshots key files: java-spring-boot-jpa-security 1`] = ` { "fileCount": 22, diff --git a/apps/cli/test/mobile.test.ts b/apps/cli/test/mobile.test.ts new file mode 100644 index 000000000..d6ac8699a --- /dev/null +++ b/apps/cli/test/mobile.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; + +import { createVirtual } from "../src/index"; +import type { VirtualDirectory, VirtualFile, VirtualNode } from "../src/index"; + +function findFile(node: VirtualNode, path: string): VirtualFile | undefined { + if (node.type === "file") { + return node.path.replace(/^\/+/, "") === path ? node : undefined; + } + + for (const child of (node as VirtualDirectory).children) { + const found = findFile(child, path); + if (found) return found; + } +} + +function getFile(root: VirtualNode, path: string) { + const file = findFile(root, path); + expect(file, `${path} should be generated`).toBeDefined(); + return file!.content; +} + +describe("mobile native scaffolding", () => { + test("generates React Navigation with production mobile integrations", async () => { + const result = await createVirtual({ + projectName: "mobile-rn", + frontend: ["native-bare"], + backend: "hono", + runtime: "bun", + database: "none", + orm: "none", + api: "orpc", + auth: "none", + cssFramework: "none", + uiLibrary: "none", + mobileNavigation: "react-navigation", + mobileUI: "gluestack-ui", + mobileStorage: "mmkv", + mobileTesting: "maestro-react-native-testing-library", + mobilePush: "expo-notifications", + mobileOTA: "expo-updates", + mobileDeepLinking: "expo-linking", + }); + + expect(result.success).toBe(true); + const root = result.tree!.root; + const pkg = JSON.parse(getFile(root, "apps/native/package.json")); + const appConfig = JSON.parse(getFile(root, "apps/native/app.json")); + + expect(pkg.main).toBe("index.js"); + expect(pkg.dependencies).toMatchObject({ + "@react-navigation/native-stack": "^7.8.1", + "@gluestack-ui/themed": "^1.1.73", + "react-native-mmkv": "^4.1.0", + "expo-notifications": "^56.0.12", + "expo-updates": "^56.0.15", + }); + expect(pkg.dependencies["expo-router"]).toBeUndefined(); + expect(pkg.scripts.test).toBe("jest"); + + expect(appConfig.expo.plugins).not.toContain("expo-router"); + expect(appConfig.expo.updates.url).toBe("https://u.expo.dev/your-eas-project-id"); + expect(appConfig.expo.extra.eas.projectId).toBe("your-eas-project-id"); + expect(getFile(root, "apps/native/App.tsx")).toContain("NavigationContainer"); + expect(getFile(root, "apps/native/navigation/native-navigation.tsx")).toContain("mobileStorage"); + expect(getFile(root, "apps/native/lib/notifications.ts")).toContain("getExpoPushTokenAsync"); + expect(getFile(root, "apps/native/lib/updates.ts")).toContain("checkForUpdateAsync"); + expect(getFile(root, "apps/native/.maestro/home.yaml")).toContain("launchApp"); + expect(getFile(root, "apps/native/__tests__/mobile-ui-provider.test.tsx")).toContain( + "@testing-library/react-native", + ); + }); + + test("keeps Expo Router as the default native navigation", async () => { + const result = await createVirtual({ + projectName: "mobile-router", + frontend: ["native-bare"], + backend: "hono", + api: "trpc", + database: "sqlite", + orm: "drizzle", + auth: "none", + }); + + expect(result.success).toBe(true); + const root = result.tree!.root; + const pkg = JSON.parse(getFile(root, "apps/native/package.json")); + const appConfig = JSON.parse(getFile(root, "apps/native/app.json")); + + expect(pkg.main).toBe("expo-router/entry"); + expect(pkg.dependencies["expo-router"]).toBe("^55.0.14"); + expect(appConfig.expo.plugins).toContain("expo-router"); + }); +}); diff --git a/apps/cli/test/template-snapshots.test.ts b/apps/cli/test/template-snapshots.test.ts index ddcb87c5e..616177da7 100644 --- a/apps/cli/test/template-snapshots.test.ts +++ b/apps/cli/test/template-snapshots.test.ts @@ -208,6 +208,24 @@ const SNAPSHOT_CONFIGS: Array<{ auth: "none", }, }, + { + name: "native-mobile-integrations", + config: { + frontend: ["native-bare"], + backend: "hono", + api: "orpc", + database: "none", + orm: "none", + auth: "none", + mobileNavigation: "react-navigation", + mobileUI: "gluestack-ui", + mobileStorage: "mmkv", + mobileTesting: "maestro-react-native-testing-library", + mobilePush: "expo-notifications", + mobileOTA: "expo-updates", + mobileDeepLinking: "expo-linking", + }, + }, { name: "java-spring-boot-jpa-security", config: { @@ -273,6 +291,13 @@ const DEFAULT_CONFIG: Partial = { cms: "none", ai: "none", jobQueue: "none", + mobileNavigation: "expo-router", + mobileUI: "none", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "expo-linking", }; describe("Template Snapshots", () => { diff --git a/apps/web/content/docs/cli/create.mdx b/apps/web/content/docs/cli/create.mdx index d660cd871..8daad81b4 100644 --- a/apps/web/content/docs/cli/create.mdx +++ b/apps/web/content/docs/cli/create.mdx @@ -40,6 +40,13 @@ With `npm create`, pass Better Fullstack flags after `--`. Generated reproducibl | `--auth` | `better-auth` `clerk` `nextauth` `stack-auth` `supabase-auth` `auth0` `go-better-auth` `none` | | `--api` | `orpc` `trpc` `ts-rest` `graphql-yoga` `garph` `none` | | `--astro-integration` | `react` `vue` `svelte` `solid` `none` | +| `--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` | ### Features and services diff --git a/apps/web/content/docs/ecosystems/typescript.mdx b/apps/web/content/docs/ecosystems/typescript.mdx index 2648fef89..6583ee716 100644 --- a/apps/web/content/docs/ecosystems/typescript.mdx +++ b/apps/web/content/docs/ecosystems/typescript.mdx @@ -38,6 +38,10 @@ npm create better-fullstack@latest my-app -- \ | --- | --- | | Web frontend | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` | | Native frontend | `native-bare` `native-uniwind` `native-unistyles` `none` | +| Mobile navigation | `expo-router` `react-navigation` `none` | +| Mobile UI | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` | +| Mobile storage/testing | `mmkv`, `maestro`, `react-native-testing-library` | +| Mobile services | `expo-notifications` `expo-updates` `expo-linking` | | Backend | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` | | Runtime | `node` `bun` `workers` `none` | | Database | `sqlite` `postgres` `mysql` `mongodb` `edgedb` `redis` `none` | @@ -73,5 +77,6 @@ npm create better-fullstack@latest my-app -- \ - `self` backend is for fullstack frameworks and pairs with `runtime none`. - Qwik, Angular, Redwood, and Fresh have narrower API/backend support than the full option table. - Workers runtime support is backend-dependent. +- Mobile options require a native Expo frontend. React Navigation emits an `App.tsx` entrypoint, Expo Router emits file routes, and push/OTA/deep-link choices add Expo config plus helper files. - UI libraries often depend on frontend family and CSS framework. For example, shadcn/ui is React-oriented and Tailwind-based. - Some providers have extra constraints, such as Polar requiring Better Auth and a web frontend. diff --git a/apps/web/content/docs/reference/options/typescript.mdx b/apps/web/content/docs/reference/options/typescript.mdx index 36c8fd648..20c9292b5 100644 --- a/apps/web/content/docs/reference/options/typescript.mdx +++ b/apps/web/content/docs/reference/options/typescript.mdx @@ -17,6 +17,13 @@ The tables list valid option values, not every valid combination. tRPC is React- | --- | --- | --- | | Web frontend | multiple | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` | | Native frontend | multiple | `native-bare` `native-uniwind` `native-unistyles` `none` | +| Mobile navigation | single | `expo-router` `react-navigation` `none` | +| Mobile UI | single | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` | +| Mobile storage | single | `mmkv` `none` | +| Mobile testing | single | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none` | +| Mobile push | single | `expo-notifications` `none` | +| Mobile OTA | single | `expo-updates` `none` | +| Mobile deep linking | single | `expo-linking` `none` | | Astro integration | single | `react` `vue` `svelte` `solid` `none` | | Backend | single | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` | | Runtime | single | `bun` `node` `workers` `none` | @@ -28,6 +35,8 @@ The tables list valid option values, not every valid combination. tRPC is React- `dbSetup: upstash` is a Redis setup path. GoBetterAuth is documented on the [Go Options](/docs/reference/options/go/) page because it targets Go projects. +Mobile options only apply when a native Expo frontend is selected. Expo Router and React Navigation are mutually exclusive navigation setups. Tamagui and Gluestack UI target `native-bare`; `native-uniwind` and `native-unistyles` keep their aligned styling systems. Push, OTA, and deep linking emit Expo config plus helper code rather than package-only installs. + ## Services And Integrations | Category | Selection | Values | diff --git a/apps/web/content/docs/roadmap.mdx b/apps/web/content/docs/roadmap.mdx index de6e49349..4e0adc393 100644 --- a/apps/web/content/docs/roadmap.mdx +++ b/apps/web/content/docs/roadmap.mdx @@ -10,6 +10,7 @@ This roadmap is a curated view of the active Better Fullstack backlog. It is not | Focus | Why it matters | | --- | --- | | REST / OpenAPI API option | REST remains the most common API style, and OpenAPI output makes generated projects easier to document and integrate. | +| Mobile-first Expo scaffolds | Native apps now need production-minded navigation, UI, storage, testing, push, OTA, and deep-link setup rather than thin starter shells. | | Deployment trust follow-ups | The public docs now cover deployment targets; provider env examples and database provisioning guides are the next trust layer. | ## Next @@ -28,7 +29,6 @@ This roadmap is a curated view of the active Better Fullstack backlog. It is not | --- | --- | | Cross-ecosystem stacks research | Rust, Go, Python, or Java backends with TypeScript frontends would be powerful, but this needs architecture work first. | | Non-monorepo / single-app mode | Simpler generated layouts are valuable for small projects, but require careful template path design. | -| React Native and mobile expansion | Navigation, UI, push notifications, and OTA updates would make native scaffolds more complete. | | Elixir ecosystem | Phoenix, LiveView, and BEAM strengths would be a unique differentiator. | | .NET ecosystem | ASP.NET Core, EF Core, Dapper, and SignalR would broaden enterprise coverage. | diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 815231bbc..715a8d119 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -243,6 +243,171 @@ export const TECH_OPTIONS: Record< default: false, }, ], + mobileNavigation: [ + { + id: "expo-router", + name: "Expo Router", + description: "File-based routing with typed routes for Expo apps", + icon: "https://cdn.simpleicons.org/expo", + color: "from-slate-500 to-zinc-700", + default: true, + className: "invert-0 dark:invert", + }, + { + id: "react-navigation", + name: "React Navigation", + description: "Production native stacks, tabs, and linking configuration", + icon: "https://cdn.simpleicons.org/react/61DAFB", + color: "from-cyan-500 to-blue-700", + }, + { + id: "none", + name: "No Mobile Navigation", + description: "Skip mobile navigation setup", + icon: "", + color: "from-gray-400 to-gray-600", + }, + ], + mobileUI: [ + { + id: "tamagui", + name: "Tamagui", + description: "Universal themed UI primitives for React Native", + icon: "", + color: "from-emerald-400 to-teal-700", + }, + { + id: "gluestack-ui", + name: "Gluestack UI", + description: "Accessible cross-platform component primitives", + icon: "", + color: "from-orange-400 to-red-600", + }, + { + id: "uniwind", + name: "Uniwind", + description: "Tailwind-aligned styling for React Native", + icon: "https://cdn.simpleicons.org/tailwindcss/06B6D4", + color: "from-cyan-400 to-sky-700", + }, + { + id: "unistyles", + name: "Unistyles", + description: "Type-safe stylesheet system for React Native", + icon: "", + color: "from-pink-400 to-rose-700", + }, + { + id: "none", + name: "No Mobile UI", + description: "Use React Native primitives", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], + mobileStorage: [ + { + id: "mmkv", + name: "MMKV", + description: "Fast local key-value storage for mobile data", + icon: "", + color: "from-yellow-400 to-amber-700", + }, + { + id: "none", + name: "No Mobile Storage", + description: "Skip device storage helpers", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], + mobileTesting: [ + { + id: "maestro", + name: "Maestro", + description: "Mobile E2E flow files for iOS and Android", + icon: "", + color: "from-violet-400 to-fuchsia-700", + }, + { + id: "react-native-testing-library", + name: "RN Testing Library", + description: "Native component tests with Jest Expo", + icon: "https://cdn.simpleicons.org/testinglibrary/E33332", + color: "from-red-400 to-rose-700", + }, + { + id: "maestro-react-native-testing-library", + name: "Maestro + RN Testing Library", + description: "Mobile E2E flows plus native component tests", + icon: "https://cdn.simpleicons.org/testinglibrary/E33332", + color: "from-violet-500 to-red-600", + }, + { + id: "none", + name: "No Mobile Testing", + description: "Skip mobile testing setup", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], + mobilePush: [ + { + id: "expo-notifications", + name: "Expo Notifications", + description: "Push token registration and notification handler", + icon: "https://cdn.simpleicons.org/expo", + color: "from-blue-400 to-indigo-700", + className: "invert-0 dark:invert", + }, + { + id: "none", + name: "No Mobile Push", + description: "Skip push notification setup", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], + mobileOTA: [ + { + id: "expo-updates", + name: "Expo Updates", + description: "Runtime version and update check helper", + icon: "https://cdn.simpleicons.org/expo", + color: "from-green-400 to-emerald-700", + className: "invert-0 dark:invert", + }, + { + id: "none", + name: "No Mobile OTA", + description: "Skip OTA update setup", + icon: "", + color: "from-gray-400 to-gray-600", + default: true, + }, + ], + mobileDeepLinking: [ + { + id: "expo-linking", + name: "Expo Linking", + description: "Scheme config and auth redirect URI examples", + icon: "https://cdn.simpleicons.org/expo", + color: "from-sky-400 to-blue-700", + className: "invert-0 dark:invert", + default: true, + }, + { + id: "none", + name: "No Deep Linking", + description: "Skip mobile deep link helpers", + icon: "", + color: "from-gray-400 to-gray-600", + }, + ], astroIntegration: [ { id: "react", @@ -3957,6 +4122,13 @@ export const ECOSYSTEM_CATEGORIES: Record = { typescript: [ "webFrontend", "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", "astroIntegration", "cssFramework", "uiLibrary", diff --git a/apps/web/src/lib/stack-utils.ts b/apps/web/src/lib/stack-utils.ts index 0b26d7e67..912c1c49b 100644 --- a/apps/web/src/lib/stack-utils.ts +++ b/apps/web/src/lib/stack-utils.ts @@ -11,6 +11,13 @@ import { createStackSearchParams } from "@/lib/stack-url-state.shared"; const TYPESCRIPT_CATEGORY_ORDER: Array = [ "webFrontend", "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", "astroIntegration", "cssFramework", "uiLibrary", diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index 702b14b60..dcb23f30b 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -120,6 +120,58 @@ const BASE_LINKS: LinkMap = { docsUrl: "https://www.unistyl.es/v3/start/introduction", githubUrl: "https://github.com/jpudysz/react-native-unistyles", }, + "expo-router": { + docsUrl: "https://docs.expo.dev/router/introduction/", + githubUrl: "https://github.com/expo/expo", + }, + "react-navigation": { + docsUrl: "https://reactnavigation.org/docs/getting-started", + githubUrl: "https://github.com/react-navigation/react-navigation", + }, + tamagui: { + docsUrl: "https://tamagui.dev/docs/intro/introduction", + githubUrl: "https://github.com/tamagui/tamagui", + }, + "gluestack-ui": { + docsUrl: "https://gluestack.io/ui/docs/home/overview/introduction", + githubUrl: "https://github.com/gluestack/gluestack-ui", + }, + uniwind: { + docsUrl: "https://www.uniwind.dev/", + githubUrl: "https://github.com/uniwind/uniwind", + }, + unistyles: { + docsUrl: "https://www.unistyl.es/v3/start/introduction", + githubUrl: "https://github.com/jpudysz/react-native-unistyles", + }, + mmkv: { + docsUrl: "https://github.com/mrousavy/react-native-mmkv", + githubUrl: "https://github.com/mrousavy/react-native-mmkv", + }, + maestro: { + docsUrl: "https://docs.maestro.dev/", + githubUrl: "https://github.com/mobile-dev-inc/Maestro", + }, + "react-native-testing-library": { + docsUrl: "https://callstack.github.io/react-native-testing-library/", + githubUrl: "https://github.com/callstack/react-native-testing-library", + }, + "maestro-react-native-testing-library": { + docsUrl: "https://callstack.github.io/react-native-testing-library/", + githubUrl: "https://github.com/callstack/react-native-testing-library", + }, + "expo-notifications": { + docsUrl: "https://docs.expo.dev/versions/latest/sdk/notifications/", + githubUrl: "https://github.com/expo/expo", + }, + "expo-updates": { + docsUrl: "https://docs.expo.dev/versions/latest/sdk/updates/", + githubUrl: "https://github.com/expo/expo", + }, + "expo-linking": { + docsUrl: "https://docs.expo.dev/versions/latest/sdk/linking/", + githubUrl: "https://github.com/expo/expo", + }, sqlite: { docsUrl: "https://www.sqlite.org/docs.html", githubUrl: "https://github.com/sqlite/sqlite", diff --git a/packages/template-generator/src/processors/env-vars.ts b/packages/template-generator/src/processors/env-vars.ts index 2b58f4b7c..328dca5b2 100644 --- a/packages/template-generator/src/processors/env-vars.ts +++ b/packages/template-generator/src/processors/env-vars.ts @@ -512,6 +512,8 @@ function buildNativeVars( frontend: string[], backend: ProjectConfig["backend"], auth: ProjectConfig["auth"], + mobilePush: ProjectConfig["mobilePush"], + mobileDeepLinking: ProjectConfig["mobileDeepLinking"], ): EnvVariable[] { let envVarName = "EXPO_PUBLIC_SERVER_URL"; let serverUrl = "http://localhost:3000"; @@ -550,6 +552,24 @@ function buildNativeVars( }); } + if (auth !== "none" && mobileDeepLinking === "expo-linking") { + vars.push({ + key: "EXPO_PUBLIC_AUTH_REDIRECT_PATH", + value: "auth/callback", + condition: true, + comment: "Mobile auth callback path used with expo-linking", + }); + } + + if (mobilePush === "expo-notifications") { + vars.push({ + key: "EXPO_PUBLIC_EAS_PROJECT_ID", + value: "your-eas-project-id", + condition: true, + comment: "EAS project ID for Expo push notification tokens", + }); + } + return vars; } @@ -1672,8 +1692,15 @@ export function processEnvVariables(vfs: VirtualFileSystem, config: ProjectConfi const nativeDir = "apps/native"; if (vfs.directoryExists(nativeDir)) { const envPath = `${nativeDir}/.env`; - const nativeVars = buildNativeVars(frontend, backend, auth); + const nativeVars = buildNativeVars( + frontend, + backend, + auth, + config.mobilePush, + config.mobileDeepLinking, + ); writeEnvFile(vfs, envPath, nativeVars); + writeEnvFile(vfs, `${nativeDir}/.env.example`, nativeVars); } } diff --git a/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs index 0689c7867..dad39a24a 100644 --- a/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/native/bare/app/(drawer)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from "react-native"; import { Container } from "@/components/container"; import { useColorScheme } from "@/lib/use-color-scheme"; @@ -184,3 +185,4 @@ privateDataText: { fontSize: 14, }, }); +{{/if}} diff --git a/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs index 184ee1219..ba9205554 100644 --- a/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/native/unistyles/app/(drawer)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { authClient } from "@/lib/auth-client"; import { ScrollView, Text, TouchableOpacity, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -185,3 +186,4 @@ const styles = StyleSheet.create((theme) => ({ padding: 16, }, })); +{{/if}} diff --git a/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs index 449d0e831..772202c7f 100644 --- a/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/native/uniwind/app/(drawer)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Text, View, Pressable } from "react-native"; import { Container } from "@/components/container"; import { authClient } from "@/lib/auth-client"; @@ -121,3 +122,4 @@ return ( ); } +{{/if}} diff --git a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs index a36f42a09..64adcab4e 100644 --- a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs +++ b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Redirect, Stack } from "expo-router"; import { useAuth } from "@clerk/clerk-expo"; @@ -10,3 +11,4 @@ export default function AuthRoutesLayout() { return ; } +{{/if}} diff --git a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs index 6f4b4e122..c7ee5dd1f 100644 --- a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs +++ b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-in.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { useSignIn } from "@clerk/clerk-expo"; import { Link, useRouter } from "expo-router"; import { Text, TextInput, TouchableOpacity, View } from "react-native"; @@ -65,3 +66,4 @@ export default function Page() { ); } +{{/if}} diff --git a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs index d50f341d6..491bd5141 100644 --- a/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs +++ b/packages/template-generator/templates/auth/clerk/convex/native/base/app/(auth)/sign-up.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import * as React from "react"; import { Text, TextInput, TouchableOpacity, View } from "react-native"; import { useSignUp } from "@clerk/clerk-expo"; @@ -108,3 +109,4 @@ export default function SignUpScreen() { ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app.json.hbs b/packages/template-generator/templates/frontend/native/bare/app.json.hbs index 21d0859d2..051d37925 100644 --- a/packages/template-generator/templates/frontend/native/bare/app.json.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app.json.hbs @@ -1,50 +1,73 @@ { - "expo": { - "name": "{{projectName}}", - "slug": "{{projectName}}", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "mybettertapp", - "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "ios": { - "supportsTablet": true - }, - "android": { - "adaptiveIcon": { - "backgroundColor": "#E6F4FE", - "foregroundImage": "./assets/images/android-icon-foreground.png", - "backgroundImage": "./assets/images/android-icon-background.png", - "monochromeImage": "./assets/images/android-icon-monochrome.png" - }, - "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false, - "package": "com.anonymous.mybettertapp" - }, - "web": { - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "image": "./assets/images/splash-icon.png", - "imageWidth": 200, - "resizeMode": "contain", - "backgroundColor": "#ffffff", - "dark": { - "backgroundColor": "#000000" - } - } - ] - ], - "experiments": { - "typedRoutes": true, - "reactCompiler": true - } - } + "expo": { + "name": "{{projectName}}", + "slug": "{{projectName}}", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "{{projectName}}", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.betterfullstack.app" + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false, + "package": "com.betterfullstack.app" + }, + "web": { + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + {{#if (eq mobileOTA "expo-updates")}} + "updates": { + "url": "https://u.expo.dev/your-eas-project-id" + }, + "runtimeVersion": { + "policy": "appVersion" + }, + "extra": { + "eas": { + "projectId": "your-eas-project-id" + } + }, + {{/if}} + "plugins": [ + {{#if (eq mobileNavigation "expo-router")}} + "expo-router", + {{/if}} + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#000000" + } + } + ]{{#if (eq mobilePush "expo-notifications")}}, + [ + "expo-notifications", + { + "icon": "./assets/images/icon.png", + "color": "#111827" + } + ] + {{/if}} + ], + "experiments": { + "typedRoutes": true, + "reactCompiler": true + } + } } - diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs index 627082b33..ef879c79e 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { TabBarIcon } from "@/components/tabbar-icon"; import { useColorScheme } from "@/lib/use-color-scheme"; import { Tabs } from "expo-router"; @@ -38,4 +39,4 @@ export default function TabLayout() { ); } - +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs index 1d55bf21c..752a09470 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { ScrollView, Text, View, StyleSheet } from "react-native"; import { useColorScheme } from "@/lib/use-color-scheme"; @@ -40,4 +41,4 @@ const styles = StyleSheet.create({ fontSize: 16, }, }); - +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs index 0ffa6a31c..e1f80f0a9 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/(tabs)/two.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { ScrollView, Text, View, StyleSheet } from "react-native"; import { useColorScheme } from "@/lib/use-color-scheme"; @@ -40,4 +41,4 @@ const styles = StyleSheet.create({ fontSize: 16, }, }); - +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs index df33a92cf..df2891353 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import { Link } from "expo-router"; import { Drawer } from "expo-router/drawer"; @@ -75,4 +76,4 @@ const DrawerLayout = () => { }; export default DrawerLayout; - +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs index 994d0a047..7a328322e 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/(drawer)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from "react-native"; import { Container } from "@/components/container"; import { useColorScheme } from "@/lib/use-color-scheme"; @@ -232,3 +233,4 @@ marginBottom: 8, fontWeight: "bold", }, }); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs index 100e1dd59..7525680b5 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/+not-found.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { Link, Stack } from "expo-router"; import { Text, View, StyleSheet } from "react-native"; @@ -62,4 +63,4 @@ const styles = StyleSheet.create({ padding: 12, }, }); - +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs index 38e01a6a5..33bcae8ec 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} {{#if (includes examples "ai")}} import "@/polyfills"; {{/if}} @@ -43,6 +44,13 @@ import React, { useRef } from "react"; import { useColorScheme } from "@/lib/use-color-scheme"; import { Platform, StyleSheet } from "react-native"; import { setAndroidNavigationBar } from "@/lib/android-navigation-bar"; +import { MobileUIProvider } from "@/components/mobile-ui-provider"; +{{#if (eq mobilePush "expo-notifications")}} +import { registerForPushNotificationsAsync } from "@/lib/notifications"; +{{/if}} +{{#if (eq mobileOTA "expo-updates")}} +import { useUpdateCheck } from "@/lib/updates"; +{{/if}} const LIGHT_THEME: Theme = { ...DefaultTheme, @@ -75,6 +83,14 @@ const styles = StyleSheet.create({ }); export default function RootLayout() { + {{#if (eq mobileOTA "expo-updates")}} + useUpdateCheck(); + {{/if}} + React.useEffect(() => { + {{#if (eq mobilePush "expo-notifications")}} + registerForPushNotificationsAsync().catch(console.warn); + {{/if}} + }, []); const hasMounted = useRef(false); const { colorScheme, isDarkColorScheme } = useColorScheme(); const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false); @@ -101,11 +117,13 @@ export default function RootLayout() { + + @@ -115,10 +133,12 @@ export default function RootLayout() { - + + + @@ -127,10 +147,12 @@ export default function RootLayout() { - + + + @@ -141,10 +163,12 @@ export default function RootLayout() { - + + + @@ -152,10 +176,12 @@ export default function RootLayout() { - + + + {{/unless}} @@ -163,3 +189,4 @@ export default function RootLayout() { ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs b/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs index e568296c8..8be2990de 100644 --- a/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/bare/app/modal.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { Text, View, StyleSheet } from "react-native"; import { useColorScheme } from "@/lib/use-color-scheme"; @@ -31,4 +32,4 @@ const styles = StyleSheet.create({ fontWeight: "bold", }, }); - +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/bare/package.json.hbs b/packages/template-generator/templates/frontend/native/bare/package.json.hbs index 767e7ecfe..74e8e72e6 100644 --- a/packages/template-generator/templates/frontend/native/bare/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/bare/package.json.hbs @@ -1,20 +1,30 @@ { "name": "native", "version": "1.0.0", - "main": "expo-router/entry", + "main": "{{#if (eq mobileNavigation 'react-navigation')}}index.js{{else}}expo-router/entry{{/if}}", "scripts": { "dev": "expo start --clear", "android": "expo run:android", "ios": "expo run:ios", "prebuild": "expo prebuild", - "web": "expo start --web" + "web": "expo start --web"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, + "test": "jest" + {{/if}} }, "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-navigation/bottom-tabs": "^7.16.1", "@react-navigation/drawer": "^7.10.2", "@react-navigation/native": "^7.2.4", + "@react-navigation/native-stack": "^7.8.1", "@tanstack/react-form": "^1.32.0", + {{#if (eq mobileUI "tamagui")}} + "@tamagui/config": "^2.0.0-rc.42", + "tamagui": "^2.0.0-rc.42", + {{/if}} + {{#if (eq mobileUI "gluestack-ui")}} + "@gluestack-ui/themed": "^1.1.73", + {{/if}} "@tanstack/react-query": "^5.100.10", {{#if (includes examples "ai")}} "@stardazed/streams-text-encoding": "^1.0.2", @@ -22,19 +32,33 @@ {{/if}} "expo": "^55.0.24", "expo-constants": "^55.0.16", + {{#if (eq mobilePush "expo-notifications")}} + "expo-device": "^8.0.9", + {{/if}} "expo-crypto": "^55.0.15", "expo-linking": "^55.0.15", "expo-navigation-bar": "^55.0.13", "expo-network": "^55.0.14", + {{#if (eq mobilePush "expo-notifications")}} + "expo-notifications": "^56.0.12", + {{/if}} + {{#if (eq mobileNavigation "expo-router")}} "expo-router": "^55.0.14", + {{/if}} "expo-secure-store": "^55.0.14", "expo-splash-screen": "^55.0.21", "expo-status-bar": "^55.0.6", "expo-system-ui": "^55.0.18", + {{#if (eq mobileOTA "expo-updates")}} + "expo-updates": "^56.0.15", + {{/if}} "expo-web-browser": "^55.0.16", "react": "^19.2.6", "react-dom": "^19.2.6", "react-native": "^0.85.3", + {{#if (eq mobileStorage "mmkv")}} + "react-native-mmkv": "^4.1.0", + {{/if}} "react-native-gesture-handler": "^2.31.2", "react-native-reanimated": "^4.3.1", "react-native-safe-area-context": "^5.7.0", @@ -44,8 +68,14 @@ }, "devDependencies": { "@babel/core": "^7.29.0", - "@types/react": "^19.2.14" + "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, + "@types/jest": "^29.5.14", + "@testing-library/react-native": "^13.3.3", + "@react-native/jest-preset": "^0.85.3", + "jest": "^29.7.0", + "jest-expo": "^55.0.18", + "react-test-renderer": "^19.2.6" + {{/if}} }, "private": true } - diff --git a/packages/template-generator/templates/frontend/native/base/.maestro/home.yaml.hbs b/packages/template-generator/templates/frontend/native/base/.maestro/home.yaml.hbs new file mode 100644 index 000000000..8bb7458d7 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/.maestro/home.yaml.hbs @@ -0,0 +1,6 @@ +{{#if (or (eq mobileTesting "maestro") (eq mobileTesting "maestro-react-native-testing-library"))}} +appId: com.betterfullstack.app +--- +- launchApp +- assertVisible: "Better Fullstack Mobile" +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/App.tsx.hbs b/packages/template-generator/templates/frontend/native/base/App.tsx.hbs new file mode 100644 index 000000000..e43b8e1b1 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/App.tsx.hbs @@ -0,0 +1,97 @@ +{{#if (eq mobileNavigation "react-navigation")}} +{{#if (includes examples "ai")}} +import "@/polyfills"; +{{/if}} + +{{#if (eq backend "convex")}} + {{#if (eq auth "better-auth")}} +import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react"; +import { ConvexReactClient } from "convex/react"; +import { authClient } from "@/lib/auth-client"; +import { env } from "@{{projectName}}/env/native"; + {{else}} +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { env } from "@{{projectName}}/env/native"; + {{/if}} + {{#if (eq auth "clerk")}} +import { ClerkProvider, useAuth } from "@clerk/clerk-expo"; +import { tokenCache } from "@clerk/clerk-expo/token-cache"; +import { ConvexProviderWithClerk } from "convex/react-clerk"; + {{/if}} +{{else}} + {{#unless (eq api "none")}} +import { QueryClientProvider } from "@tanstack/react-query"; + {{/unless}} +{{/if}} +import { NavigationContainer } from "@react-navigation/native"; +import { StatusBar } from "expo-status-bar"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native"; +import { MobileUIProvider } from "@/components/mobile-ui-provider"; +import { linking } from "@/lib/deep-linking"; +{{#if (eq api "trpc")}} +import { queryClient } from "@/utils/trpc"; +{{/if}} +{{#if (eq api "orpc")}} +import { queryClient } from "@/utils/orpc"; +{{/if}} +import { RootNavigator } from "@/navigation/native-navigation"; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + +{{#if (eq backend "convex")}} +const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, { + unsavedChangesWarning: false, +}); +{{/if}} + +function AppShell() { + return ( + + + + + + + + + + + ); +} + +export default function App() { + return ( + {{#if (eq backend "convex")}} + {{#if (eq auth "clerk")}} + + + + + + {{else if (eq auth "better-auth")}} + + + + {{else}} + + + + {{/if}} + {{else}} + {{#unless (eq api "none")}} + + + + {{else}} + + {{/unless}} + {{/if}} + ); +} +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs b/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs new file mode 100644 index 000000000..91e2489e2 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/__tests__/mobile-ui-provider.test.tsx.hbs @@ -0,0 +1,33 @@ +{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}} +/// + +import type { ReactNode } from "react"; +import { render } from "@testing-library/react-native"; +import { Text } from "react-native"; + +{{#if (eq mobileUI "gluestack-ui")}} +jest.mock("@gluestack-ui/themed", () => ({ + GluestackUIProvider: ({ children }: { children: ReactNode }) => children, +})); + +{{/if}} +{{#if (eq mobileUI "tamagui")}} +jest.mock("@tamagui/config/v3", () => ({ config: {} })); +jest.mock("tamagui", () => ({ + createTamagui: (config: unknown) => config, + TamaguiProvider: ({ children }: { children: ReactNode }) => children, +})); + +{{/if}} +import { MobileUIProvider } from "@/components/mobile-ui-provider"; + +test("renders children inside the mobile provider", () => { + const screen = render( + + Mobile ready + , + ); + + expect(screen.getByText("Mobile ready")).toBeTruthy(); +}); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs b/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs new file mode 100644 index 000000000..f5c9a83a3 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs @@ -0,0 +1,20 @@ +import type { PropsWithChildren } from "react"; +{{#if (eq mobileUI "tamagui")}} +import { TamaguiProvider, createTamagui } from "tamagui"; +import { config } from "@tamagui/config/v3"; + +const tamaguiConfig = createTamagui(config); +{{/if}} +{{#if (eq mobileUI "gluestack-ui")}} +import { GluestackUIProvider } from "@gluestack-ui/themed"; +{{/if}} + +export function MobileUIProvider({ children }: PropsWithChildren) { + {{#if (eq mobileUI "tamagui")}} + return {children}; + {{else if (eq mobileUI "gluestack-ui")}} + return {children}; + {{else}} + return <>{children}; + {{/if}} +} diff --git a/packages/template-generator/templates/frontend/native/base/index.js.hbs b/packages/template-generator/templates/frontend/native/base/index.js.hbs new file mode 100644 index 000000000..dd659cd34 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/index.js.hbs @@ -0,0 +1,7 @@ +{{#if (eq mobileNavigation "react-navigation")}} +import { registerRootComponent } from "expo"; + +import App from "./App"; + +registerRootComponent(App); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/jest.config.js.hbs b/packages/template-generator/templates/frontend/native/base/jest.config.js.hbs new file mode 100644 index 000000000..53a3b2796 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/jest.config.js.hbs @@ -0,0 +1,10 @@ +{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}} +module.exports = { + preset: "jest-expo", + transformIgnorePatterns: [ + "/node_modules/(?!(.bun|.pnpm|react-native|@react-native|@react-native-community|expo|@expo|@expo-google-fonts|react-navigation|@react-navigation|@sentry/react-native|native-base|@gluestack-ui|react-native-mmkv))", + "/node_modules/react-native-reanimated/plugin/", + "/node_modules/@react-native/babel-preset/", + ], +}; +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs new file mode 100644 index 000000000..4aba6fbcf --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs @@ -0,0 +1,23 @@ +import * as Linking from "expo-linking"; + +export const appScheme = "{{projectName}}"; + +export const linking = { + prefixes: [Linking.createURL("/"), `${appScheme}://`], + config: { + screens: { + Main: "", + Modal: "modal", + Home: "", + Settings: "settings", + }, + }, +}; + +export function getAuthRedirectUri(path = "auth/callback") { + return Linking.createURL(path); +} + +export function getDeepLinkUrl(path = "") { + return Linking.createURL(path); +} diff --git a/packages/template-generator/templates/frontend/native/base/lib/mobile-storage.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/mobile-storage.ts.hbs new file mode 100644 index 000000000..00811681f --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/lib/mobile-storage.ts.hbs @@ -0,0 +1,15 @@ +{{#if (eq mobileStorage "mmkv")}} +import { createMMKV } from "react-native-mmkv"; + +export const mobileStorage = createMMKV({ + id: "{{projectName}}.storage", +}); + +export function getStoredString(key: string) { + return mobileStorage.getString(key) ?? null; +} + +export function setStoredString(key: string, value: string) { + mobileStorage.set(key, value); +} +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/lib/notifications.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/notifications.ts.hbs new file mode 100644 index 000000000..a1b3a0486 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/lib/notifications.ts.hbs @@ -0,0 +1,52 @@ +{{#if (eq mobilePush "expo-notifications")}} +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +export async function registerForPushNotificationsAsync() { + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("default", { + name: "default", + importance: Notifications.AndroidImportance.MAX, + }); + } + + if (!Device.isDevice) { + return null; + } + + const existing = (await Notifications.getPermissionsAsync()) as { + granted?: boolean; + status?: string; + }; + const existingGranted = existing.granted ?? existing.status === "granted"; + const requested = existingGranted + ? existing + : ((await Notifications.requestPermissionsAsync()) as { + granted?: boolean; + status?: string; + }); + const finalStatus = + (requested.granted ?? requested.status === "granted") ? "granted" : "denied"; + + if (finalStatus !== "granted") { + return null; + } + + const projectId = Constants.expoConfig?.extra?.eas?.projectId; + const token = await Notifications.getExpoPushTokenAsync( + projectId ? { projectId } : undefined, + ); + return token.data; +} +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/lib/updates.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/updates.ts.hbs new file mode 100644 index 000000000..148e188b1 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/lib/updates.ts.hbs @@ -0,0 +1,30 @@ +{{#if (eq mobileOTA "expo-updates")}} +import * as Updates from "expo-updates"; +import { useEffect, useState } from "react"; + +export function useUpdateCheck() { + const [isChecking, setIsChecking] = useState(false); + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); + + useEffect(() => { + let isMounted = true; + setIsChecking(true); + Updates.checkForUpdateAsync() + .then((result) => { + if (isMounted) setIsUpdateAvailable(result.isAvailable); + }) + .catch(() => { + if (isMounted) setIsUpdateAvailable(false); + }) + .finally(() => { + if (isMounted) setIsChecking(false); + }); + + return () => { + isMounted = false; + }; + }, []); + + return { isChecking, isUpdateAvailable }; +} +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs b/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs new file mode 100644 index 000000000..597f138c5 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs @@ -0,0 +1,145 @@ +{{#if (eq mobileNavigation "react-navigation")}} +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { Text, View, Pressable, StyleSheet } from "react-native"; +{{#if (eq auth "clerk")}} +import { useUser } from "@clerk/clerk-expo"; +{{/if}} +{{#if (eq mobilePush "expo-notifications")}} +import { registerForPushNotificationsAsync } from "@/lib/notifications"; +{{/if}} +{{#if (eq mobileOTA "expo-updates")}} +import { useUpdateCheck } from "@/lib/updates"; +{{/if}} +{{#if (eq mobileStorage "mmkv")}} +import { mobileStorage } from "@/lib/mobile-storage"; +{{/if}} +import { getAuthRedirectUri } from "@/lib/deep-linking"; +import { useEffect } from "react"; + +type RootStackParamList = { + Main: undefined; + Modal: undefined; +}; + +type TabParamList = { + Home: undefined; + Settings: undefined; +}; + +const Stack = createNativeStackNavigator(); +const Tab = createBottomTabNavigator(); + +function HomeScreen() { + {{#if (eq auth "clerk")}} + const { user } = useUser(); + {{/if}} + {{#if (eq mobileOTA "expo-updates")}} + const { isChecking, isUpdateAvailable } = useUpdateCheck(); + {{/if}} + + useEffect(() => { + {{#if (eq mobilePush "expo-notifications")}} + registerForPushNotificationsAsync().catch(console.warn); + {{/if}} + {{#if (eq mobileStorage "mmkv")}} + mobileStorage.set("lastOpenedAt", new Date().toISOString()); + {{/if}} + }, []); + + return ( + + Better Fullstack Mobile + Production-minded Expo starter + {{#if (eq auth "clerk")}} + {user ? `Signed in as ${user.primaryEmailAddress?.emailAddress}` : "Auth routes are ready for Clerk."} + {{/if}} + {{#if (eq auth "better-auth")}} + Use {getAuthRedirectUri()} as the Better Auth mobile callback URL. + {{/if}} + {{#if (eq mobileOTA "expo-updates")}} + {isChecking ? "Checking for updates..." : isUpdateAvailable ? "An update is ready." : "App is up to date."} + {{/if}} + + ); +} + +function SettingsScreen() { + return ( + + Mobile integrations + Deep link redirect URI: {getAuthRedirectUri()} + {{#if (eq mobileStorage "mmkv")}} + MMKV is configured in lib/mobile-storage.ts. + {{/if}} + + ); +} + +function ModalScreen({ navigation }: { navigation: { goBack: () => void } }) { + return ( + + Modal + navigation.goBack()} style={styles.button}> + Close + + + ); +} + +function Tabs() { + return ( + + + + + ); +} + +export function RootNavigator() { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + justifyContent: "center", + gap: 12, + padding: 24, + backgroundColor: "#f8fafc", + }, + eyebrow: { + fontSize: 13, + fontWeight: "700", + letterSpacing: 0, + textTransform: "uppercase", + color: "#2563eb", + }, + title: { + fontSize: 28, + fontWeight: "800", + color: "#0f172a", + }, + body: { + fontSize: 16, + lineHeight: 24, + color: "#475569", + }, + button: { + alignSelf: "flex-start", + borderRadius: 8, + backgroundColor: "#111827", + paddingHorizontal: 16, + paddingVertical: 10, + }, + buttonText: { + color: "#ffffff", + fontWeight: "700", + }, +}); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs index f5ab3dfed..051d37925 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app.json.hbs @@ -5,11 +5,12 @@ "version": "1.0.0", "orientation": "portrait", "icon": "./assets/images/icon.png", - "scheme": "mybettertapp", + "scheme": "{{projectName}}", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.betterfullstack.app" }, "android": { "adaptiveIcon": { @@ -20,14 +21,29 @@ }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, - "package": "com.anonymous.mybettertapp" + "package": "com.betterfullstack.app" }, "web": { "output": "static", "favicon": "./assets/images/favicon.png" }, + {{#if (eq mobileOTA "expo-updates")}} + "updates": { + "url": "https://u.expo.dev/your-eas-project-id" + }, + "runtimeVersion": { + "policy": "appVersion" + }, + "extra": { + "eas": { + "projectId": "your-eas-project-id" + } + }, + {{/if}} "plugins": [ + {{#if (eq mobileNavigation "expo-router")}} "expo-router", + {{/if}} [ "expo-splash-screen", { @@ -39,7 +55,15 @@ "backgroundColor": "#000000" } } + ]{{#if (eq mobilePush "expo-notifications")}}, + [ + "expo-notifications", + { + "icon": "./assets/images/icon.png", + "color": "#111827" + } ] + {{/if}} ], "experiments": { "typedRoutes": true, diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs index 760a1c02f..6c1f49f36 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Tabs } from "expo-router"; import { useUnistyles } from "react-native-unistyles"; @@ -37,3 +38,4 @@ export default function TabLayout() { ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs index e430dbc11..758c89776 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { ScrollView, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -35,3 +36,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, }, })); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs index 2d1a92231..076c25352 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/(tabs)/two.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { ScrollView, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -35,3 +36,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, }, })); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs index 9fff74661..dc56e3520 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import { Link } from "expo-router"; import { Drawer } from "expo-router/drawer"; @@ -73,3 +74,4 @@ const DrawerLayout = () => { }; export default DrawerLayout; +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs index 52e7d08d6..44bfa8227 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/(drawer)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { ScrollView, Text, View, TouchableOpacity } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { Container } from "@/components/container"; @@ -331,3 +332,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, }, })); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs index ab80f8c26..0a62154a0 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/+not-found.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Link, Stack } from "expo-router"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -63,3 +64,4 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: "500", }, })); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs index 821d526ce..44a5a8e0a 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} {{#if (includes examples "ai")}} import "@/polyfills"; {{/if}} @@ -43,6 +44,14 @@ const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, { {{/if}} export default function RootLayout() { + {{#if (eq mobileOTA "expo-updates")}} + useUpdateCheck(); + {{/if}} + React.useEffect(() => { + {{#if (eq mobilePush "expo-notifications")}} + registerForPushNotificationsAsync().catch(console.warn); + {{/if}} + }, []); const { theme } = useUnistyles(); return ( @@ -167,3 +176,4 @@ export default function RootLayout() { {{/if}} ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs index 18941b674..60e7a665d 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/modal.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -31,3 +32,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, })); +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs b/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs index 1df324146..cadf09302 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/index.js.hbs @@ -1,2 +1,9 @@ -import 'expo-router/entry'; import './unistyles'; +{{#if (eq mobileNavigation "react-navigation")}} +import { registerRootComponent } from 'expo'; +import App from './App'; + +registerRootComponent(App); +{{else}} +import 'expo-router/entry'; +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs index 6ceef5997..752e422a2 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs @@ -2,38 +2,59 @@ "name": "native", "version": "1.0.0", "private": true, - "main": "index.js", + "main": "{{#if (eq mobileNavigation 'react-navigation')}}index.js{{else}}expo-router/entry{{/if}}", "scripts": { "dev": "expo start --clear", "android": "expo run:android", "ios": "expo run:ios", - "web": "expo start --web" + "web": "expo start --web"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, + "test": "jest" + {{/if}} }, "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-navigation/bottom-tabs": "^7.16.1", "@react-navigation/drawer": "^7.10.2", "@react-navigation/native": "^7.2.4", + "@react-navigation/native-stack": "^7.8.1", {{#if (includes examples "ai")}} "@stardazed/streams-text-encoding": "^1.0.2", "@ungap/structured-clone": "^1.3.1", {{/if}} "@tanstack/react-form": "^1.32.0", + {{#if (eq mobileUI "tamagui")}} + "@tamagui/config": "^2.0.0-rc.42", + "tamagui": "^2.0.0-rc.42", + {{/if}} + {{#if (eq mobileUI "gluestack-ui")}} + "@gluestack-ui/themed": "^1.1.73", + {{/if}} "expo": "^55.0.24", "expo-constants": "^55.0.16", + {{#if (eq mobilePush "expo-notifications")}} + "expo-device": "^8.0.9", + {{/if}} "expo-crypto": "^55.0.15", "expo-linking": "^55.0.15", + {{#if (eq mobileNavigation "expo-router")}} "expo-router": "^55.0.14", + {{/if}} "expo-secure-store": "^55.0.14", "expo-splash-screen": "^55.0.21", "expo-status-bar": "^55.0.6", "expo-system-ui": "^55.0.18", + {{#if (eq mobileOTA "expo-updates")}} + "expo-updates": "^56.0.15", + {{/if}} "expo-dev-client": "^55.0.34", "expo-web-browser": "^55.0.16", "react": "^19.2.6", "react-dom": "^19.2.6", "react-native": "^0.85.3", "react-native-edge-to-edge": "^1.8.1", + {{#if (eq mobileStorage "mmkv")}} + "react-native-mmkv": "^4.1.0", + {{/if}} "react-native-gesture-handler": "^2.31.2", "react-native-nitro-modules": "^0.35.6", "react-native-reanimated": "^4.3.1", @@ -46,6 +67,13 @@ "devDependencies": { "ajv": "^8.20.0", "@babel/core": "^7.29.0", - "@types/react": "^19.2.14" + "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, + "@types/jest": "^29.5.14", + "@testing-library/react-native": "^13.3.3", + "@react-native/jest-preset": "^0.85.3", + "jest": "^29.7.0", + "jest-expo": "^55.0.18", + "react-test-renderer": "^19.2.6" + {{/if}} } } diff --git a/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs index a54d40243..1cdc0cd27 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app.json.hbs @@ -1,15 +1,70 @@ { "expo": { + "name": "{{projectName}}", + "slug": "{{projectName}}", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", "scheme": "{{projectName}}", "userInterfaceStyle": "automatic", - "orientation": "default", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.betterfullstack.app" + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./assets/images/android-icon-foreground.png", + "backgroundImage": "./assets/images/android-icon-background.png", + "monochromeImage": "./assets/images/android-icon-monochrome.png" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false, + "package": "com.betterfullstack.app" + }, "web": { - "bundler": "metro" + "output": "static", + "favicon": "./assets/images/favicon.png" }, - "name": "{{projectName}}", - "slug": "{{projectName}}", + {{#if (eq mobileOTA "expo-updates")}} + "updates": { + "url": "https://u.expo.dev/your-eas-project-id" + }, + "runtimeVersion": { + "policy": "appVersion" + }, + "extra": { + "eas": { + "projectId": "your-eas-project-id" + } + }, + {{/if}} "plugins": [ - "expo-font" + {{#if (eq mobileNavigation "expo-router")}} + "expo-router", + {{/if}} + "expo-font", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#000000" + } + } + ]{{#if (eq mobilePush "expo-notifications")}}, + [ + "expo-notifications", + { + "icon": "./assets/images/icon.png", + "color": "#111827" + } + ] + {{/if}} ], "experiments": { "typedRoutes": true, diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs index 3c6918435..6e299e03d 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Tabs } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import { useThemeColor } from "heroui-native"; @@ -44,3 +45,4 @@ export default function TabLayout() { ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs index e092affe3..4c54ad110 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { Text, View } from "react-native"; import { Card } from "heroui-native"; @@ -13,3 +14,4 @@ export default function Home() { ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs index 01bc323f1..4ba4748f3 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/(tabs)/two.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Container } from "@/components/container"; import { Text, View } from "react-native"; import { Card } from "heroui-native"; @@ -13,3 +14,4 @@ export default function TabTwo() { ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs index 8a8f947a8..4cf78054f 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import React, { useCallback } from "react"; import { Ionicons, MaterialIcons } from "@expo/vector-icons"; import { Link } from "expo-router"; @@ -75,3 +76,4 @@ function DrawerLayout() { } export default DrawerLayout; +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs index 5e64642da..22c0b5182 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Text, View } from "react-native"; import { Container } from "@/components/container"; {{#if (eq api "orpc")}} @@ -189,3 +190,4 @@ return ( ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs index ed78975f2..d07b8d4b1 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/+not-found.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Link, Stack } from "expo-router"; import { Button, Surface } from "heroui-native"; import { Text, View } from "react-native"; @@ -25,3 +26,4 @@ export default function NotFoundScreen() { ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs index 096cdf17f..6d4e1e744 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} {{#if (includes examples "ai")}} import "@/polyfills"; {{/if}} @@ -30,6 +31,12 @@ import { Stack } from "expo-router"; import { HeroUINativeProvider } from "heroui-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; +{{#if (eq mobilePush "expo-notifications")}} +import { registerForPushNotificationsAsync } from "@/lib/notifications"; +{{/if}} +{{#if (eq mobileOTA "expo-updates")}} +import { useUpdateCheck } from "@/lib/updates"; +{{/if}} import { AppThemeProvider } from "@/contexts/app-theme-context"; {{#if (eq api "trpc")}} @@ -62,6 +69,14 @@ function StackLayout() { } export default function Layout() { + {{#if (eq mobileOTA "expo-updates")}} + useUpdateCheck(); + {{/if}} + React.useEffect(() => { + {{#if (eq mobilePush "expo-notifications")}} + registerForPushNotificationsAsync().catch(console.warn); + {{/if}} + }, []); return ( {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} @@ -130,3 +145,4 @@ export default function Layout() { {{/if}} ); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs index 868c280cb..40ccfb7d4 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/modal.tsx.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; import { Button, Surface, useThemeColor } from "heroui-native"; @@ -35,3 +36,4 @@ function Modal() { } export default Modal; +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs index 16f8915a7..98222b6f5 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs @@ -2,14 +2,16 @@ "name": "native", "version": "1.0.0", "private": true, - "main": "expo-router/entry", + "main": "{{#if (eq mobileNavigation 'react-navigation')}}index.js{{else}}expo-router/entry{{/if}}", "scripts": { "start": "expo start", "dev": "expo start --clear", "android": "expo run:android", "ios": "expo run:ios", "prebuild": "expo prebuild", - "web": "expo start --web" + "web": "expo start --web"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, + "test": "jest" + {{/if}} }, "dependencies": { "@expo/metro-runtime": "^55.0.11", @@ -17,23 +19,40 @@ "@gorhom/bottom-sheet": "^5", "@react-navigation/drawer": "^7.10.2", "@react-navigation/elements": "^2.9.18", + "@react-navigation/bottom-tabs": "^7.16.1", + "@react-navigation/native": "^7.2.4", + "@react-navigation/native-stack": "^7.8.1", {{#if (includes examples "ai")}} "@stardazed/streams-text-encoding": "^1.0.2", "@ungap/structured-clone": "^1.3.1", {{/if}} "expo": "^55.0.24", "expo-constants": "^55.0.16", + {{#if (eq mobilePush "expo-notifications")}} + "expo-device": "^8.0.9", + {{/if}} "expo-font": "^55.0.7", "expo-haptics": "^55.0.14", "expo-linking": "^55.0.15", "expo-network": "^55.0.14", + {{#if (eq mobilePush "expo-notifications")}} + "expo-notifications": "^56.0.12", + {{/if}} + {{#if (eq mobileNavigation "expo-router")}} "expo-router": "^55.0.14", + {{/if}} "expo-secure-store": "^55.0.14", "expo-status-bar": "^55.0.6", + {{#if (eq mobileOTA "expo-updates")}} + "expo-updates": "^56.0.15", + {{/if}} "heroui-native": "^1.0.3", "react": "^19.2.6", "react-dom": "^19.2.6", "react-native": "^0.85.3", + {{#if (eq mobileStorage "mmkv")}} + "react-native-mmkv": "^4.1.0", + {{/if}} "react-native-gesture-handler": "^2.31.2", "react-native-keyboard-controller": "^1.21.7", "react-native-reanimated": "^4.3.1", @@ -49,6 +68,13 @@ }, "devDependencies": { "@types/node": "^25.8.0", - "@types/react": "^19.2.14" + "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, + "@types/jest": "^29.5.14", + "@testing-library/react-native": "^13.3.3", + "@react-native/jest-preset": "^0.85.3", + "jest": "^29.7.0", + "jest-expo": "^55.0.18", + "react-test-renderer": "^19.2.6" + {{/if}} } } diff --git a/packages/template-generator/templates/packages/env/src/native.ts.hbs b/packages/template-generator/templates/packages/env/src/native.ts.hbs index a1414e1f1..6eb673e24 100644 --- a/packages/template-generator/templates/packages/env/src/native.ts.hbs +++ b/packages/template-generator/templates/packages/env/src/native.ts.hbs @@ -14,6 +14,12 @@ export const env = createEnv({ {{/if}} {{else}} EXPO_PUBLIC_SERVER_URL: z.url(), +{{/if}} +{{#if (and (ne auth "none") (eq mobileDeepLinking "expo-linking"))}} + EXPO_PUBLIC_AUTH_REDIRECT_PATH: z.string().default("auth/callback"), +{{/if}} +{{#if (eq mobilePush "expo-notifications")}} + EXPO_PUBLIC_EAS_PROJECT_ID: z.string().optional(), {{/if}} }, runtimeEnv: process.env, diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index d5810e3bb..5ccfe538e 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -51,6 +51,13 @@ export type CompatibilityCategory = | "cms" | "featureFlags" | "analytics" + | "mobileNavigation" + | "mobileUI" + | "mobileStorage" + | "mobileTesting" + | "mobilePush" + | "mobileOTA" + | "mobileDeepLinking" | "codeQuality" | "documentation" | "appPlatforms" @@ -149,6 +156,13 @@ export type CompatibilityInput = { i18n: string; search: string; fileStorage: string; + mobileNavigation: string; + mobileUI: string; + mobileStorage: string; + mobileTesting: string; + mobilePush: string; + mobileOTA: string; + mobileDeepLinking: string; codeQuality: string[]; documentation: string[]; appPlatforms: string[]; @@ -230,6 +244,13 @@ const TYPESCRIPT_CATEGORY_ORDER: CompatibilityCategory[] = [ "i18n", "search", "fileStorage", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", "animation", "cms", "codeQuality", @@ -404,6 +425,13 @@ export const getCategoryDisplayName = (categoryKey: string): string => { // Custom display names for TypeScript categories const tsCategoryNames: Record = { i18n: "Internationalization (i18n)", + mobileNavigation: "Mobile Navigation", + mobileUI: "Mobile UI", + mobileStorage: "Mobile Storage", + mobileTesting: "Mobile Testing", + mobilePush: "Mobile Push", + mobileOTA: "Mobile OTA", + mobileDeepLinking: "Mobile Deep Linking", }; if (tsCategoryNames[categoryKey]) { @@ -1072,6 +1100,67 @@ export const analyzeStackCompatibility = ( } } + const hasNativeFrontend = nextStack.nativeFrontend.some((f) => f !== "none"); + + if (!hasNativeFrontend) { + const nativeOnlyCategories = [ + ["mobileNavigation", "none", "Mobile navigation set to 'None' (no native frontend)"], + ["mobileUI", "none", "Mobile UI set to 'None' (no native frontend)"], + ["mobileStorage", "none", "Mobile storage set to 'None' (no native frontend)"], + ["mobileTesting", "none", "Mobile testing set to 'None' (no native frontend)"], + ["mobilePush", "none", "Mobile push set to 'None' (no native frontend)"], + ["mobileOTA", "none", "Mobile OTA set to 'None' (no native frontend)"], + ["mobileDeepLinking", "none", "Mobile deep linking set to 'None' (no native frontend)"], + ] as const; + + for (const [category, value, message] of nativeOnlyCategories) { + if (nextStack[category] !== value) { + nextStack[category] = value; + changed = true; + changes.push({ category, message }); + } + } + } else { + if (nextStack.mobileNavigation === "none") { + nextStack.mobileNavigation = "expo-router"; + changed = true; + changes.push({ + category: "mobileNavigation", + message: "Mobile navigation set to 'Expo Router' (native frontend selected)", + }); + } + + if (nextStack.mobileDeepLinking === "none" && nextStack.auth !== "none") { + nextStack.mobileDeepLinking = "expo-linking"; + changed = true; + changes.push({ + category: "mobileDeepLinking", + message: "Mobile deep linking set to 'Expo Linking' (required for mobile auth redirects)", + }); + } + + if (nextStack.nativeFrontend.includes("native-uniwind") && nextStack.mobileUI !== "uniwind") { + nextStack.mobileUI = "uniwind"; + changed = true; + changes.push({ + category: "mobileUI", + message: "Mobile UI set to 'Uniwind' (required by Expo + Uniwind)", + }); + } + + if ( + nextStack.nativeFrontend.includes("native-unistyles") && + nextStack.mobileUI !== "unistyles" + ) { + nextStack.mobileUI = "unistyles"; + changed = true; + changes.push({ + category: "mobileUI", + message: "Mobile UI set to 'Unistyles' (required by Expo + Unistyles)", + }); + } + } + // UI libraries requiring Tailwind - auto-adjust CSS framework or clear UI library const requiresTailwind = ["shadcn-ui", "daisyui", "nextui"].includes(nextStack.uiLibrary); if (requiresTailwind && nextStack.cssFramework !== "tailwind") { @@ -2115,6 +2204,50 @@ export const getDisabledReason = ( } } + const mobileCategories = new Set([ + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + ]); + + if (mobileCategories.has(category)) { + const hasNativeFrontend = currentStack.nativeFrontend.some((f) => f !== "none"); + if (!hasNativeFrontend && optionId !== "none") { + return `${getCategoryDisplayName(category)} requires a native Expo frontend`; + } + + if (category === "mobileNavigation" && optionId === "react-navigation") { + return null; + } + + if (category === "mobileUI") { + if (optionId === "uniwind" && !currentStack.nativeFrontend.includes("native-uniwind")) { + return "Uniwind mobile UI requires Expo + Uniwind frontend"; + } + if (optionId === "unistyles" && !currentStack.nativeFrontend.includes("native-unistyles")) { + return "Unistyles mobile UI requires Expo + Unistyles frontend"; + } + if ( + ["tamagui", "gluestack-ui"].includes(optionId) && + currentStack.nativeFrontend.some((f) => ["native-uniwind", "native-unistyles"].includes(f)) + ) { + return "Tamagui and Gluestack UI require Expo + Bare to avoid conflicting styling setup"; + } + } + + if ( + (category === "mobilePush" && optionId === "expo-notifications") || + (category === "mobileOTA" && optionId === "expo-updates") || + (category === "mobileDeepLinking" && optionId === "expo-linking") + ) { + return null; + } + } + // ============================================ // UI LIBRARY CONSTRAINTS // ============================================ diff --git a/packages/types/src/defaults.ts b/packages/types/src/defaults.ts index 66c89ccbe..38644a1e9 100644 --- a/packages/types/src/defaults.ts +++ b/packages/types/src/defaults.ts @@ -51,6 +51,13 @@ export function createCliDefaultProjectConfigBase( observability: "none", featureFlags: "none", analytics: "none", + mobileNavigation: "none", + mobileUI: "none", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "none", cms: "none", caching: "none", i18n: "none", diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index cf536474e..b1b700348 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -19,6 +19,13 @@ import { FILE_STORAGE_VALUES, FILE_UPLOAD_VALUES, FORMS_VALUES, + MOBILE_DEEP_LINKING_VALUES, + MOBILE_NAVIGATION_VALUES, + MOBILE_OTA_VALUES, + MOBILE_PUSH_VALUES, + MOBILE_STORAGE_VALUES, + MOBILE_TESTING_VALUES, + MOBILE_UI_VALUES, GO_API_VALUES, GO_CLI_VALUES, GO_AUTH_VALUES, @@ -110,6 +117,13 @@ export type OptionCategory = | "cms" | "featureFlags" | "analytics" + | "mobileNavigation" + | "mobileUI" + | "mobileStorage" + | "mobileTesting" + | "mobilePush" + | "mobileOTA" + | "mobileDeepLinking" | "codeQuality" | "documentation" | "appPlatforms" @@ -302,6 +316,13 @@ const CATEGORY_VALUE_IDS: Record = { cms: CMS_VALUES, featureFlags: FEATURE_FLAGS_VALUES, analytics: ANALYTICS_VALUES, + mobileNavigation: MOBILE_NAVIGATION_VALUES, + mobileUI: MOBILE_UI_VALUES, + mobileStorage: MOBILE_STORAGE_VALUES, + mobileTesting: MOBILE_TESTING_VALUES, + mobilePush: MOBILE_PUSH_VALUES, + mobileOTA: MOBILE_OTA_VALUES, + mobileDeepLinking: MOBILE_DEEP_LINKING_VALUES, codeQuality: CODE_QUALITY_VALUES, documentation: DOCUMENTATION_VALUES, appPlatforms: APP_PLATFORM_VALUES, @@ -480,6 +501,33 @@ const EXACT_LABEL_OVERRIDES: Partial; export type Observability = z.infer; export type FeatureFlags = z.infer; export type Analytics = z.infer; +export type MobileNavigation = z.infer; +export type MobileUI = z.infer; +export type MobileStorage = z.infer; +export type MobileTesting = z.infer; +export type MobilePush = z.infer; +export type MobileOTA = z.infer; +export type MobileDeepLinking = z.infer; export type CMS = z.infer; export type Caching = z.infer; export type I18n = z.infer; From 95ba8d61c1cc83ce0d0ed97c2c1db0a2fcf6e99b Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:28:10 +0300 Subject: [PATCH 02/33] Add Elixir Phoenix ecosystem --- apps/cli/src/create-command-input.ts | 50 +- apps/cli/src/helpers/core/command-handlers.ts | 15 + apps/cli/src/helpers/core/create-project.ts | 5 + .../src/helpers/core/install-dependencies.ts | 25 + .../cli/src/helpers/core/post-installation.ts | 52 +++ apps/cli/src/index.ts | 45 ++ apps/cli/src/mcp.ts | 121 ++++- apps/cli/src/prompts/config-prompts.ts | 128 ++++- apps/cli/src/prompts/elixir-ecosystem.ts | 195 ++++++++ apps/cli/src/prompts/install.ts | 17 + .../src/prompts/prompt-resolver-registry.ts | 107 +++++ apps/cli/src/utils/bts-config.ts | 30 ++ apps/cli/src/utils/config-validation.ts | 166 +++++++ .../utils/generate-reproducible-command.ts | 27 ++ .../template-snapshots.test.ts.snap | 438 ++++++++++++++++++ apps/cli/test/template-snapshots.test.ts | 81 ++++ apps/web/content/docs/ecosystems/elixir.mdx | 68 +++ apps/web/content/docs/ecosystems/meta.json | 2 +- .../docs/mdx/compatibility-matrix.tsx | 27 ++ .../src/components/home/features-section.tsx | 13 +- .../stack-builder/saved-stacks-panel.tsx | 8 + .../stack-builder/stack-builder.tsx | 3 + apps/web/src/lib/combinations-count.ts | 41 +- apps/web/src/lib/constant.ts | 124 +++++ apps/web/src/lib/llms.ts | 1 + apps/web/src/lib/project-stats.generated.ts | 4 +- apps/web/src/lib/stack-utils.ts | 23 + apps/web/src/lib/tech-icons.ts | 34 ++ apps/web/src/lib/tech-resource-links.ts | 117 +++++ .../src/core/template-processor.ts | 22 + packages/template-generator/src/generator.ts | 4 + .../src/processors/readme-generator.ts | 43 ++ .../src/template-handlers/elixir-base.ts | 70 +++ .../src/template-handlers/index.ts | 1 + .../templates/elixir-base/.env.example.hbs | 9 + .../templates/elixir-base/Dockerfile.hbs | 20 + .../templates/elixir-base/README.md.hbs | 28 ++ .../templates/elixir-base/_gitignore | 9 + .../elixir-base/config/config.exs.hbs | 48 ++ .../templates/elixir-base/config/dev.exs.hbs | 24 + .../elixir-base/config/runtime.exs.hbs | 25 + .../templates/elixir-base/config/test.exs.hbs | 18 + .../elixir-base/lib/__elixirAppName__.ex.hbs | 5 + .../lib/__elixirAppName__/accounts.ex.hbs | 10 + .../__elixirAppName__/accounts/user.ex.hbs | 19 + .../lib/__elixirAppName__/application.ex.hbs | 38 ++ .../lib/__elixirAppName__/catalog.ex.hbs | 16 + .../lib/__elixirAppName__/catalog/item.ex.hbs | 18 + .../lib/__elixirAppName__/http_client.ex.hbs | 17 + .../lib/__elixirAppName__/mailer.ex.hbs | 3 + .../lib/__elixirAppName__/repo.ex.hbs | 5 + .../lib/__elixirAppName__/scheduler.ex.hbs | 3 + .../workers/sample_worker.ex.hbs | 10 + .../lib/__elixirAppName___web.ex.hbs | 72 +++ .../channels/presence.ex.hbs | 5 + .../channels/room_channel.ex.hbs | 12 + .../channels/user_socket.ex.hbs | 11 + .../components/layouts.ex.hbs | 5 + .../components/layouts/root.html.heex.hbs | 12 + .../controllers/error_html.ex.hbs | 7 + .../controllers/error_json.ex.hbs | 5 + .../controllers/health_controller.ex.hbs | 7 + .../controllers/item_controller.ex.hbs | 25 + .../controllers/page_controller.ex.hbs | 12 + .../lib/__elixirAppName___web/endpoint.ex.hbs | 41 ++ .../graphql/resolvers/catalog.ex.hbs | 7 + .../graphql/schema.ex.hbs | 17 + .../live/item_live/index.ex.hbs | 47 ++ .../lib/__elixirAppName___web/router.ex.hbs | 47 ++ .../__elixirAppName___web/telemetry.ex.hbs | 18 + .../templates/elixir-base/mix.exs.hbs | 119 +++++ .../20260101000000_create_items.exs.hbs | 12 + .../20260101000001_create_users.exs.hbs | 14 + .../20260101000002_add_oban_jobs.exs.hbs | 11 + .../elixir-base/priv/repo/seeds.exs.hbs | 3 + .../health_controller_test.exs.hbs | 8 + .../elixir-base/test/support/conn_case.ex.hbs | 17 + .../elixir-base/test/test_helper.exs.hbs | 4 + .../test/_fixtures/config-factory.ts | 15 + packages/types/src/compatibility.ts | 267 ++++++++++- packages/types/src/defaults.ts | 15 + packages/types/src/json-schema.ts | 90 ++++ packages/types/src/option-metadata.ts | 129 +++++- packages/types/src/schemas.ts | 122 ++++- packages/types/src/stack-translation.ts | 123 +++++ packages/types/src/types.ts | 30 ++ testing/lib/generate-combos/options.ts | 77 +++ testing/lib/generate-combos/render.ts | 32 ++ testing/lib/generate-combos/types.ts | 18 +- testing/lib/presets.ts | 15 + 90 files changed, 3881 insertions(+), 22 deletions(-) create mode 100644 apps/cli/src/prompts/elixir-ecosystem.ts create mode 100644 apps/web/content/docs/ecosystems/elixir.mdx create mode 100644 packages/template-generator/src/template-handlers/elixir-base.ts create mode 100644 packages/template-generator/templates/elixir-base/.env.example.hbs create mode 100644 packages/template-generator/templates/elixir-base/Dockerfile.hbs create mode 100644 packages/template-generator/templates/elixir-base/README.md.hbs create mode 100644 packages/template-generator/templates/elixir-base/_gitignore create mode 100644 packages/template-generator/templates/elixir-base/config/config.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/config/dev.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/config/runtime.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/config/test.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog/item.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/http_client.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/mailer.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/repo.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/scheduler.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName__/workers/sample_worker.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/presence.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/room_channel.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/user_socket.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts/root.html.heex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_html.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_json.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/health_controller.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/item_controller.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/page_controller.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/endpoint.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/resolvers/catalog.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/schema.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/telemetry.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/mix.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000000_create_items.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000001_create_users.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000002_add_oban_jobs.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/priv/repo/seeds.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/test/__elixirAppName___web/controllers/health_controller_test.exs.hbs create mode 100644 packages/template-generator/templates/elixir-base/test/support/conn_case.ex.hbs create mode 100644 packages/template-generator/templates/elixir-base/test/test_helper.exs.hbs diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts index cde519651..bf6981fc9 100644 --- a/apps/cli/src/create-command-input.ts +++ b/apps/cli/src/create-command-input.ts @@ -17,6 +17,21 @@ import { DatabaseSetupSchema, DirectoryConflictSchema, EcosystemSchema, + ElixirApiSchema, + ElixirAuthSchema, + ElixirCachingSchema, + ElixirDeploySchema, + ElixirEmailSchema, + ElixirHttpSchema, + ElixirJobsSchema, + ElixirJsonSchema, + ElixirObservabilitySchema, + ElixirOrmSchema, + ElixirQualitySchema, + ElixirRealtimeSchema, + ElixirTestingSchema, + ElixirValidationSchema, + ElixirWebFrameworkSchema, EffectSchema, EmailSchema, ExamplesSchema, @@ -98,7 +113,7 @@ export const CreateCommandOptionsSchema = z.object({ .optional() .default(false) .describe("Preview generated file tree without writing to disk"), - ecosystem: EcosystemSchema.optional().describe("Language ecosystem (typescript, rust, python, go, or java)"), + ecosystem: EcosystemSchema.optional().describe("Language ecosystem (typescript, rust, python, go, java, or elixir)"), database: DatabaseSchema.optional(), orm: ORMSchema.optional(), auth: AuthSchema.optional(), @@ -219,6 +234,39 @@ export const CreateCommandOptionsSchema = z.object({ .array(JavaTestingLibrariesSchema) .optional() .describe("Java testing libraries"), + elixirWebFramework: ElixirWebFrameworkSchema.optional().describe( + "Elixir web framework (phoenix, phoenix-live-view, none)", + ), + elixirOrm: ElixirOrmSchema.optional().describe("Elixir ORM/database (ecto, ecto-sql, none)"), + elixirAuth: ElixirAuthSchema.optional().describe( + "Elixir auth (phx-gen-auth, ueberauth, guardian, none)", + ), + elixirApi: ElixirApiSchema.optional().describe("Elixir API layer (rest, absinthe, none)"), + elixirRealtime: ElixirRealtimeSchema.optional().describe( + "Elixir realtime (channels, presence, pubsub, live-view-streams, none)", + ), + elixirJobs: ElixirJobsSchema.optional().describe("Elixir jobs (oban, quantum, none)"), + elixirValidation: ElixirValidationSchema.optional().describe( + "Elixir validation (ecto-changesets, nimble-options, none)", + ), + elixirHttp: ElixirHttpSchema.optional().describe("Elixir HTTP client (req, finch, none)"), + elixirJson: ElixirJsonSchema.optional().describe("Elixir JSON library (jason, none)"), + elixirEmail: ElixirEmailSchema.optional().describe("Elixir email library (swoosh, none)"), + elixirCaching: ElixirCachingSchema.optional().describe( + "Elixir caching (cachex, nebulex, none)", + ), + elixirObservability: ElixirObservabilitySchema.optional().describe( + "Elixir observability (telemetry, opentelemetry, prom_ex, none)", + ), + elixirTesting: ElixirTestingSchema.optional().describe( + "Elixir testing (ex_unit, mox, bypass, wallaby, none)", + ), + elixirQuality: ElixirQualitySchema.optional().describe( + "Elixir code quality (credo, dialyxir, sobelow, none)", + ), + elixirDeploy: ElixirDeploySchema.optional().describe( + "Elixir deploy target (docker, fly, gigalixir, mix-release, none)", + ), aiDocs: z .array(AiDocsSchema) .optional() diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index a40bc0398..cc43361c5 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -191,6 +191,21 @@ export async function createProjectHandler( javaAuth: "none", javaLibraries: [], javaTestingLibraries: [], + 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", aiDocs: [], } satisfies ProjectConfig, reproducibleCommand: "", diff --git a/apps/cli/src/helpers/core/create-project.ts b/apps/cli/src/helpers/core/create-project.ts index aca78c577..766f6c855 100644 --- a/apps/cli/src/helpers/core/create-project.ts +++ b/apps/cli/src/helpers/core/create-project.ts @@ -23,6 +23,7 @@ import { runMavenTests, runUvSync, runGoModTidy, + runMixCompile, } from "./install-dependencies"; import { displayPostInstallInstructions } from "./post-installation"; @@ -102,6 +103,10 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj } } + if (options.install && options.ecosystem === "elixir") { + await runMixCompile({ projectDir }); + } + await initializeGit(projectDir, options.git); if (!isSilent()) { diff --git a/apps/cli/src/helpers/core/install-dependencies.ts b/apps/cli/src/helpers/core/install-dependencies.ts index 7dc90898b..4e92474cb 100644 --- a/apps/cli/src/helpers/core/install-dependencies.ts +++ b/apps/cli/src/helpers/core/install-dependencies.ts @@ -163,3 +163,28 @@ export async function runGradleTests({ projectDir }: { projectDir: string }) { } } } + +export async function runMixCompile({ projectDir }: { projectDir: string }) { + const s = spinner(); + + try { + s.start("Running mix deps.get and mix compile..."); + + await $({ + cwd: projectDir, + stderr: "inherit", + })`mix deps.get`; + + await $({ + cwd: projectDir, + stderr: "inherit", + })`mix compile`; + + s.stop("Elixir dependencies installed and project compiled"); + } catch (error) { + s.stop(pc.red("mix compile failed")); + if (error instanceof Error) { + consola.error(pc.red(`Mix error: ${error.message}`)); + } + } +} diff --git a/apps/cli/src/helpers/core/post-installation.ts b/apps/cli/src/helpers/core/post-installation.ts index aa671c163..931d5c256 100644 --- a/apps/cli/src/helpers/core/post-installation.ts +++ b/apps/cli/src/helpers/core/post-installation.ts @@ -60,6 +60,11 @@ export async function displayPostInstallInstructions( return; } + if (ecosystem === "elixir") { + displayElixirInstructions(config); + return; + } + const isConvex = backend === "convex"; const isBackendSelf = backend === "self"; const runCmd = @@ -1257,3 +1262,50 @@ function displayPythonInstructions(config: ProjectConfig & { depsInstalled: bool consola.box(output); } + +function displayElixirInstructions(config: ProjectConfig & { depsInstalled: boolean }) { + const { + relativePath, + depsInstalled, + elixirWebFramework, + elixirOrm, + elixirApi, + elixirRealtime, + elixirJobs, + elixirAuth, + elixirDeploy, + } = config; + + let output = `${pc.bold("Project created successfully!")}\n\n`; + + output += `${pc.bold("Next steps:")}\n`; + output += `${pc.cyan("1.")} cd ${relativePath}\n`; + if (!depsInstalled) { + output += `${pc.cyan("2.")} mix deps.get\n`; + output += `${pc.cyan("3.")} mix ecto.setup\n`; + output += `${pc.cyan("4.")} mix phx.server\n`; + } else { + output += `${pc.cyan("2.")} mix ecto.setup\n`; + output += `${pc.cyan("3.")} mix phx.server\n`; + } + + output += `\n${pc.bold("Selected Elixir stack:")}\n`; + output += `${pc.cyan("•")} Web: ${elixirWebFramework}\n`; + output += `${pc.cyan("•")} Database: ${elixirOrm}\n`; + output += `${pc.cyan("•")} API: ${elixirApi}\n`; + output += `${pc.cyan("•")} Realtime: ${elixirRealtime}\n`; + output += `${pc.cyan("•")} Jobs: ${elixirJobs}\n`; + output += `${pc.cyan("•")} Auth: ${elixirAuth}\n`; + output += `${pc.cyan("•")} Deploy: ${elixirDeploy}\n`; + + output += `\n${pc.bold("Common Mix commands:")}\n`; + output += `${pc.cyan("•")} Run: mix phx.server\n`; + output += `${pc.cyan("•")} Test: mix test\n`; + output += `${pc.cyan("•")} Format: mix format\n`; + output += `${pc.cyan("•")} Compile: mix compile\n`; + + output += `\n${pc.bold("Your Phoenix app will be available at:")}\n`; + output += `${pc.cyan("•")} Web: http://localhost:4000\n`; + + consola.box(output); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index cd7f02615..93de78f66 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -144,6 +144,21 @@ import { type JavaAuth, JavaTestingLibrariesSchema, type JavaTestingLibraries, + type ElixirWebFramework, + type ElixirOrm, + type ElixirAuth, + type ElixirApi, + type ElixirRealtime, + type ElixirJobs, + type ElixirValidation, + type ElixirHttp, + type ElixirJson, + type ElixirEmail, + type ElixirCaching, + type ElixirObservability, + type ElixirTesting, + type ElixirQuality, + type ElixirDeploy, OPTION_CATEGORY_METADATA, AiDocsSchema, type AiDocs, @@ -501,6 +516,21 @@ export async function createVirtual( javaAuth: options.javaAuth || "none", javaLibraries: options.javaLibraries || [], javaTestingLibraries: options.javaTestingLibraries || (options.ecosystem === "java" ? ["junit5"] : []), + elixirWebFramework: options.elixirWebFramework || (options.ecosystem === "elixir" ? "phoenix" : "none"), + elixirOrm: options.elixirOrm || (options.ecosystem === "elixir" ? "ecto-sql" : "none"), + elixirAuth: options.elixirAuth || "none", + elixirApi: options.elixirApi || (options.ecosystem === "elixir" ? "rest" : "none"), + elixirRealtime: options.elixirRealtime || (options.ecosystem === "elixir" ? "channels" : "none"), + elixirJobs: options.elixirJobs || "none", + elixirValidation: options.elixirValidation || (options.ecosystem === "elixir" ? "ecto-changesets" : "none"), + elixirHttp: options.elixirHttp || (options.ecosystem === "elixir" ? "req" : "none"), + elixirJson: options.elixirJson || (options.ecosystem === "elixir" ? "jason" : "none"), + elixirEmail: options.elixirEmail || "none", + elixirCaching: options.elixirCaching || "none", + elixirObservability: options.elixirObservability || (options.ecosystem === "elixir" ? "telemetry" : "none"), + elixirTesting: options.elixirTesting || (options.ecosystem === "elixir" ? "ex_unit" : "none"), + elixirQuality: options.elixirQuality || (options.ecosystem === "elixir" ? "credo" : "none"), + elixirDeploy: options.elixirDeploy || "none", // AI documentation files aiDocs: options.aiDocs || ["claude-md"], }; @@ -579,6 +609,21 @@ export type { JavaOrm, JavaAuth, JavaTestingLibraries, + ElixirWebFramework, + ElixirOrm, + ElixirAuth, + ElixirApi, + ElixirRealtime, + ElixirJobs, + ElixirValidation, + ElixirHttp, + ElixirJson, + ElixirEmail, + ElixirCaching, + ElixirObservability, + ElixirTesting, + ElixirQuality, + ElixirDeploy, AiDocs, AddResult, }; diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 4128d97ed..7e3184bdc 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -17,6 +17,21 @@ import { DatabaseSchema, DatabaseSetupSchema, EcosystemSchema, + ElixirApiSchema, + ElixirAuthSchema, + ElixirCachingSchema, + ElixirDeploySchema, + ElixirEmailSchema, + ElixirHttpSchema, + ElixirJobsSchema, + ElixirJsonSchema, + ElixirObservabilitySchema, + ElixirOrmSchema, + ElixirQualitySchema, + ElixirRealtimeSchema, + ElixirTestingSchema, + ElixirValidationSchema, + ElixirWebFrameworkSchema, EffectSchema, EmailSchema, ExamplesSchema, @@ -86,7 +101,7 @@ const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce( 0, ); -const INSTRUCTIONS = `Better-Fullstack scaffolds fullstack projects across TypeScript, Rust, Go, Python, and Java ecosystems with ${OPTION_ENTRY_COUNT} configurable options. +const INSTRUCTIONS = `Better-Fullstack scaffolds fullstack projects across TypeScript, Rust, Go, Python, Java, and Elixir ecosystems with ${OPTION_ENTRY_COUNT} configurable options. RECOMMENDED WORKFLOW: 1. Call bfs_get_guidance to understand field semantics, required fields, and workflow rules. @@ -104,7 +119,7 @@ CRITICAL RULES: - Array fields: "frontend", "addons", "examples", "aiDocs", "rustLibraries", "pythonAi", "javaLibraries", and "javaTestingLibraries". Most other option fields are strings. - "none" means "skip this feature entirely", not "use the default". - Always specify "ecosystem" first — it determines which other fields are relevant. -- TypeScript-specific fields (frontend, backend, orm, etc.) are IGNORED for rust/python/go/java ecosystems. +- TypeScript-specific fields (frontend, backend, orm, etc.) are IGNORED for rust/python/go/java/elixir ecosystems. - The compatibility engine auto-adjusts invalid combinations — always call bfs_check_compatibility first to see adjustments.`; function getGuidance() { @@ -126,6 +141,8 @@ function getGuidance() { go: "Backend/CLI: web framework (gin/echo), ORM (gorm/sqlc), gRPC, CLI tools, logging.", java: "Backend/API: Spring Boot with Maven or Gradle Wrapper, optional Spring Data JPA, Spring Security, app libraries, and Java testing libraries.", + elixir: + "Phoenix: Phoenix or Phoenix LiveView with Ecto SQL, PostgreSQL-ready config, REST or Absinthe, Channels/Presence, Oban, and Mix releases/Docker.", }, fieldRules: { projectName: @@ -244,7 +261,22 @@ const SCHEMA_MAP: Record = { javaOrm: JavaOrmSchema, javaAuth: JavaAuthSchema, javaLibraries: JavaLibrariesSchema, - javaTestingLibraries: JavaTestingLibrariesSchema, + javaTestingLibraries: JavaTestingLibrariesSchema, + elixirWebFramework: ElixirWebFrameworkSchema, + elixirOrm: ElixirOrmSchema, + elixirAuth: ElixirAuthSchema, + elixirApi: ElixirApiSchema, + elixirRealtime: ElixirRealtimeSchema, + elixirJobs: ElixirJobsSchema, + elixirValidation: ElixirValidationSchema, + elixirHttp: ElixirHttpSchema, + elixirJson: ElixirJsonSchema, + elixirEmail: ElixirEmailSchema, + elixirCaching: ElixirCachingSchema, + elixirObservability: ElixirObservabilitySchema, + elixirTesting: ElixirTestingSchema, + elixirQuality: ElixirQualitySchema, + elixirDeploy: ElixirDeploySchema, }; const ECOSYSTEM_CATEGORIES: Record = { @@ -270,6 +302,23 @@ const ECOSYSTEM_CATEGORIES: Record = { "caching", "search", ], + elixir: [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + ], shared: ["ecosystem", "packageManager", "addons", "examples", "webDeploy", "serverDeploy", "dbSetup"], }; @@ -308,6 +357,7 @@ function getInstallCommand( case "rust": return `cd ${projectName} && cargo build`; case "python": return `cd ${projectName} && uv sync`; case "go": return `cd ${projectName} && go mod tidy`; + case "elixir": return `cd ${projectName} && mix deps.get && mix compile && mix test`; case "java": if (javaWebFramework === "quarkus") { return javaBuildTool === "gradle" @@ -433,6 +483,24 @@ function buildProjectConfig( javaLibraries: (input.javaLibraries as ProjectConfig["javaLibraries"]) ?? [], javaTestingLibraries: (input.javaTestingLibraries as ProjectConfig["javaTestingLibraries"]) ?? ["junit5"], + elixirWebFramework: + (input.elixirWebFramework as ProjectConfig["elixirWebFramework"]) ?? "phoenix", + elixirOrm: (input.elixirOrm as ProjectConfig["elixirOrm"]) ?? "ecto-sql", + elixirAuth: (input.elixirAuth as ProjectConfig["elixirAuth"]) ?? "none", + elixirApi: (input.elixirApi as ProjectConfig["elixirApi"]) ?? "rest", + elixirRealtime: (input.elixirRealtime as ProjectConfig["elixirRealtime"]) ?? "channels", + elixirJobs: (input.elixirJobs as ProjectConfig["elixirJobs"]) ?? "none", + elixirValidation: + (input.elixirValidation as ProjectConfig["elixirValidation"]) ?? "ecto-changesets", + elixirHttp: (input.elixirHttp as ProjectConfig["elixirHttp"]) ?? "req", + elixirJson: (input.elixirJson as ProjectConfig["elixirJson"]) ?? "jason", + elixirEmail: (input.elixirEmail as ProjectConfig["elixirEmail"]) ?? "none", + elixirCaching: (input.elixirCaching as ProjectConfig["elixirCaching"]) ?? "none", + elixirObservability: + (input.elixirObservability as ProjectConfig["elixirObservability"]) ?? "telemetry", + elixirTesting: (input.elixirTesting as ProjectConfig["elixirTesting"]) ?? "ex_unit", + elixirQuality: (input.elixirQuality as ProjectConfig["elixirQuality"]) ?? "credo", + elixirDeploy: (input.elixirDeploy as ProjectConfig["elixirDeploy"]) ?? "none", }; } @@ -547,6 +615,21 @@ function buildCompatibilityInput(input: Record): CompatibilityI javaAuth: (input.javaAuth as string) ?? "none", javaLibraries: (input.javaLibraries as string[]) ?? [], javaTestingLibraries: (input.javaTestingLibraries as string[]) ?? ["junit5"], + elixirWebFramework: (input.elixirWebFramework as string) ?? "phoenix", + elixirOrm: (input.elixirOrm as string) ?? "ecto-sql", + elixirAuth: (input.elixirAuth as string) ?? "none", + elixirApi: (input.elixirApi as string) ?? "rest", + elixirRealtime: (input.elixirRealtime as string) ?? "channels", + elixirJobs: (input.elixirJobs as string) ?? "none", + elixirValidation: (input.elixirValidation as string) ?? "ecto-changesets", + elixirHttp: (input.elixirHttp as string) ?? "req", + elixirJson: (input.elixirJson as string) ?? "jason", + elixirEmail: (input.elixirEmail as string) ?? "none", + elixirCaching: (input.elixirCaching as string) ?? "none", + elixirObservability: (input.elixirObservability as string) ?? "telemetry", + elixirTesting: (input.elixirTesting as string) ?? "ex_unit", + elixirQuality: (input.elixirQuality as string) ?? "credo", + elixirDeploy: (input.elixirDeploy as string) ?? "none", }; } @@ -607,7 +690,7 @@ const COMPATIBILITY_RULES_MD = `# Better-Fullstack Compatibility Rules - Java Sentry requires Maven or Gradle so the generated project can manage the SDK dependency. ## Ecosystem Isolation -- Rust, Python, Go, and Java ecosystems are independent — TypeScript fields are ignored. +- Rust, Python, Go, Java, and Elixir ecosystems are independent — TypeScript fields are ignored. - Each ecosystem generates a standalone project with its own build system. `; @@ -788,6 +871,21 @@ export async function startMcpServer() { .array(JavaTestingLibrariesSchema) .optional() .describe("Java testing libraries"), + elixirWebFramework: ElixirWebFrameworkSchema.optional().describe("Elixir web framework"), + elixirOrm: ElixirOrmSchema.optional().describe("Elixir persistence layer"), + elixirAuth: ElixirAuthSchema.optional().describe("Elixir authentication"), + elixirApi: ElixirApiSchema.optional().describe("Elixir API layer"), + elixirRealtime: ElixirRealtimeSchema.optional().describe("Elixir realtime feature"), + elixirJobs: ElixirJobsSchema.optional().describe("Elixir jobs and scheduling"), + elixirValidation: ElixirValidationSchema.optional().describe("Elixir validation/data"), + elixirHttp: ElixirHttpSchema.optional().describe("Elixir HTTP client"), + elixirJson: ElixirJsonSchema.optional().describe("Elixir JSON library"), + elixirEmail: ElixirEmailSchema.optional().describe("Elixir email library"), + elixirCaching: ElixirCachingSchema.optional().describe("Elixir caching library"), + elixirObservability: ElixirObservabilitySchema.optional().describe("Elixir observability"), + elixirTesting: ElixirTestingSchema.optional().describe("Elixir testing library"), + elixirQuality: ElixirQualitySchema.optional().describe("Elixir code quality/security"), + elixirDeploy: ElixirDeploySchema.optional().describe("Elixir deployment target"), }), async (input: Record) => { try { @@ -880,6 +978,21 @@ export async function startMcpServer() { .array(JavaTestingLibrariesSchema) .optional() .describe("Java testing libraries"), + elixirWebFramework: ElixirWebFrameworkSchema.optional().describe("Elixir web framework"), + elixirOrm: ElixirOrmSchema.optional().describe("Elixir persistence layer"), + elixirAuth: ElixirAuthSchema.optional().describe("Elixir authentication"), + elixirApi: ElixirApiSchema.optional().describe("Elixir API layer"), + elixirRealtime: ElixirRealtimeSchema.optional().describe("Elixir realtime feature"), + elixirJobs: ElixirJobsSchema.optional().describe("Elixir jobs and scheduling"), + elixirValidation: ElixirValidationSchema.optional().describe("Elixir validation/data"), + elixirHttp: ElixirHttpSchema.optional().describe("Elixir HTTP client"), + elixirJson: ElixirJsonSchema.optional().describe("Elixir JSON library"), + elixirEmail: ElixirEmailSchema.optional().describe("Elixir email library"), + elixirCaching: ElixirCachingSchema.optional().describe("Elixir caching library"), + elixirObservability: ElixirObservabilitySchema.optional().describe("Elixir observability"), + elixirTesting: ElixirTestingSchema.optional().describe("Elixir testing library"), + elixirQuality: ElixirQualitySchema.optional().describe("Elixir code quality/security"), + elixirDeploy: ElixirDeploySchema.optional().describe("Elixir deployment target"), }; registerTool( diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 2680c8e78..3c333be6a 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -11,6 +11,21 @@ import type { Caching, CMS, CSSFramework, + ElixirApi, + ElixirAuth, + ElixirCaching, + ElixirDeploy, + ElixirEmail, + ElixirHttp, + ElixirJobs, + ElixirJson, + ElixirObservability, + ElixirOrm, + ElixirQuality, + ElixirRealtime, + ElixirTesting, + ElixirValidation, + ElixirWebFramework, I18n, Database, DatabaseSetup, @@ -88,6 +103,23 @@ import { getCMSChoice } from "./cms"; import { getCSSFrameworkChoice } from "./css-framework"; import { getDatabaseChoice } from "./database"; import { getDBSetupChoice } from "./database-setup"; +import { + getElixirApiChoice, + getElixirAuthChoice, + getElixirCachingChoice, + getElixirDeployChoice, + getElixirEmailChoice, + getElixirHttpChoice, + getElixirJobsChoice, + getElixirJsonChoice, + getElixirObservabilityChoice, + getElixirOrmChoice, + getElixirQualityChoice, + getElixirRealtimeChoice, + getElixirTestingChoice, + getElixirValidationChoice, + getElixirWebFrameworkChoice, +} from "./elixir-ecosystem"; import { getEcosystemChoice } from "./ecosystem"; import { getEffectChoice } from "./effect"; import { getEmailChoice } from "./email"; @@ -232,6 +264,22 @@ type PromptGroupResults = { javaAuth: JavaAuth; javaLibraries: JavaLibraries[]; javaTestingLibraries: JavaTestingLibraries[]; + // Elixir ecosystem + elixirWebFramework: ElixirWebFramework; + elixirOrm: ElixirOrm; + elixirAuth: ElixirAuth; + elixirApi: ElixirApi; + elixirRealtime: ElixirRealtime; + elixirJobs: ElixirJobs; + elixirValidation: ElixirValidation; + elixirHttp: ElixirHttp; + elixirJson: ElixirJson; + elixirEmail: ElixirEmail; + elixirCaching: ElixirCaching; + elixirObservability: ElixirObservability; + elixirTesting: ElixirTesting; + elixirQuality: ElixirQuality; + elixirDeploy: ElixirDeploy; // Keep at end aiDocs: AiDocs[]; git: boolean; @@ -618,6 +666,67 @@ export async function gatherConfig( } return getJavaTestingLibrariesChoice(flags.javaTestingLibraries); }, + // Elixir ecosystem prompts (skip if not Elixir) + elixirWebFramework: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirWebFramework); + return getElixirWebFrameworkChoice(flags.elixirWebFramework); + }, + elixirOrm: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirOrm); + return getElixirOrmChoice(flags.elixirOrm); + }, + elixirAuth: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirAuth); + return getElixirAuthChoice(flags.elixirAuth); + }, + elixirApi: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirApi); + return getElixirApiChoice(flags.elixirApi); + }, + elixirRealtime: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirRealtime); + return getElixirRealtimeChoice(flags.elixirRealtime); + }, + elixirJobs: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirJobs); + return getElixirJobsChoice(flags.elixirJobs); + }, + elixirValidation: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirValidation); + return getElixirValidationChoice(flags.elixirValidation); + }, + elixirHttp: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirHttp); + return getElixirHttpChoice(flags.elixirHttp); + }, + elixirJson: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirJson); + return getElixirJsonChoice(flags.elixirJson); + }, + elixirEmail: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirEmail); + return getElixirEmailChoice(flags.elixirEmail); + }, + elixirCaching: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirCaching); + return getElixirCachingChoice(flags.elixirCaching); + }, + elixirObservability: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirObservability); + return getElixirObservabilityChoice(flags.elixirObservability); + }, + elixirTesting: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirTesting); + return getElixirTestingChoice(flags.elixirTesting); + }, + elixirQuality: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirQuality); + return getElixirQualityChoice(flags.elixirQuality); + }, + elixirDeploy: ({ results }) => { + if (results.ecosystem !== "elixir") return Promise.resolve("none" as ElixirDeploy); + return getElixirDeployChoice(flags.elixirDeploy); + }, // Keep at end aiDocs: () => getAiDocsChoice(flags.aiDocs), git: () => getGitChoice(flags.git), @@ -627,7 +736,8 @@ export async function gatherConfig( results.ecosystem === "rust" || results.ecosystem === "python" || results.ecosystem === "go" || - results.ecosystem === "java" + results.ecosystem === "java" || + results.ecosystem === "elixir" ) return Promise.resolve(flags.packageManager ?? getUserPkgManager()); return getPackageManagerChoice(flags.packageManager); @@ -722,6 +832,22 @@ export async function gatherConfig( javaAuth: result.javaAuth, javaLibraries: result.javaLibraries, javaTestingLibraries: result.javaTestingLibraries, + // Elixir ecosystem options + elixirWebFramework: result.elixirWebFramework, + elixirOrm: result.elixirOrm, + elixirAuth: result.elixirAuth, + elixirApi: result.elixirApi, + elixirRealtime: result.elixirRealtime, + elixirJobs: result.elixirJobs, + elixirValidation: result.elixirValidation, + elixirHttp: result.elixirHttp, + elixirJson: result.elixirJson, + elixirEmail: result.elixirEmail, + elixirCaching: result.elixirCaching, + elixirObservability: result.elixirObservability, + elixirTesting: result.elixirTesting, + elixirQuality: result.elixirQuality, + elixirDeploy: result.elixirDeploy, // AI documentation files aiDocs: result.aiDocs, }; diff --git a/apps/cli/src/prompts/elixir-ecosystem.ts b/apps/cli/src/prompts/elixir-ecosystem.ts new file mode 100644 index 000000000..9cd81ee35 --- /dev/null +++ b/apps/cli/src/prompts/elixir-ecosystem.ts @@ -0,0 +1,195 @@ +import type { + ElixirApi, + ElixirAuth, + ElixirCaching, + ElixirDeploy, + ElixirEmail, + ElixirHttp, + ElixirJobs, + ElixirJson, + ElixirObservability, + ElixirOrm, + ElixirQuality, + ElixirRealtime, + ElixirTesting, + ElixirValidation, + ElixirWebFramework, +} from "../types"; + +import { exitCancelled } from "../utils/errors"; +import { createStaticSinglePromptResolution, type PromptOption } from "./prompt-contract"; +import { isCancel, navigableSelect } from "./navigable"; + +function makeChoice( + message: string, + options: PromptOption[], + defaultValue: T, + value?: T, +) { + const resolution = createStaticSinglePromptResolution(options, defaultValue, value); + if (!resolution.shouldPrompt) return Promise.resolve(resolution.autoValue ?? defaultValue); + + return navigableSelect({ + message, + options: resolution.options, + initialValue: resolution.initialValue as T, + }).then((response) => (isCancel(response) ? exitCancelled("Operation cancelled") : response)); +} + +const WEB_FRAMEWORK_OPTIONS: PromptOption[] = [ + { value: "phoenix", label: "Phoenix", hint: "Conventional Phoenix web application" }, + { value: "phoenix-live-view", label: "Phoenix LiveView", hint: "Server-rendered realtime UI" }, + { value: "none", label: "None", hint: "No Elixir web framework" }, +]; + +const ORM_OPTIONS: PromptOption[] = [ + { value: "ecto-sql", label: "Ecto SQL", hint: "Ecto plus SQL adapters and migrations" }, + { value: "ecto", label: "Ecto", hint: "Ecto schemas and changesets without SQL repo wiring" }, + { value: "none", label: "None", hint: "No database layer" }, +]; + +const AUTH_OPTIONS: PromptOption[] = [ + { value: "phx-gen-auth", label: "phx.gen.auth", hint: "Phoenix account/session scaffold" }, + { value: "ueberauth", label: "Ueberauth", hint: "OAuth strategy foundation" }, + { value: "guardian", label: "Guardian", hint: "JWT authentication foundation" }, + { value: "none", label: "None", hint: "No auth layer" }, +]; + +const API_OPTIONS: PromptOption[] = [ + { value: "rest", label: "Phoenix REST", hint: "Controllers and JSON endpoints" }, + { value: "absinthe", label: "Absinthe GraphQL", hint: "GraphQL schema and resolvers" }, + { value: "none", label: "None", hint: "No API layer" }, +]; + +const REALTIME_OPTIONS: PromptOption[] = [ + { value: "channels", label: "Phoenix Channels", hint: "WebSocket channel endpoint" }, + { value: "presence", label: "Phoenix Presence", hint: "Presence tracking over PubSub" }, + { value: "pubsub", label: "Phoenix PubSub", hint: "PubSub foundation only" }, + { value: "live-view-streams", label: "LiveView Streams", hint: "Realtime LiveView stream demo" }, + { value: "none", label: "None", hint: "No realtime feature" }, +]; + +const JOB_OPTIONS: PromptOption[] = [ + { value: "oban", label: "Oban", hint: "PostgreSQL-backed jobs and workers" }, + { value: "quantum", label: "Quantum", hint: "Cron-like scheduler" }, + { value: "none", label: "None", hint: "No jobs layer" }, +]; + +const VALIDATION_OPTIONS: PromptOption[] = [ + { value: "ecto-changesets", label: "Ecto Changesets", hint: "Data validation with Ecto" }, + { value: "nimble-options", label: "NimbleOptions", hint: "Declarative option validation" }, + { value: "none", label: "None", hint: "No extra validation helper" }, +]; + +const HTTP_OPTIONS: PromptOption[] = [ + { value: "req", label: "Req", hint: "High-level HTTP client" }, + { value: "finch", label: "Finch", hint: "Pooled HTTP client" }, + { value: "none", label: "None", hint: "No HTTP client" }, +]; + +const JSON_OPTIONS: PromptOption[] = [ + { value: "jason", label: "Jason", hint: "Phoenix default JSON library" }, + { value: "none", label: "None", hint: "No JSON library" }, +]; + +const EMAIL_OPTIONS: PromptOption[] = [ + { value: "swoosh", label: "Swoosh", hint: "Phoenix email library" }, + { value: "none", label: "None", hint: "No email library" }, +]; + +const CACHING_OPTIONS: PromptOption[] = [ + { value: "cachex", label: "Cachex", hint: "In-memory cache" }, + { value: "nebulex", label: "Nebulex", hint: "Cache abstraction" }, + { value: "none", label: "None", hint: "No cache layer" }, +]; + +const OBSERVABILITY_OPTIONS: PromptOption[] = [ + { value: "telemetry", label: "Telemetry", hint: "Phoenix telemetry metrics" }, + { value: "opentelemetry", label: "OpenTelemetry", hint: "Distributed tracing foundation" }, + { value: "prom_ex", label: "PromEx", hint: "Prometheus metrics for Phoenix" }, + { value: "none", label: "None", hint: "No observability add-on" }, +]; + +const TESTING_OPTIONS: PromptOption[] = [ + { value: "ex_unit", label: "ExUnit", hint: "Standard Elixir tests" }, + { value: "mox", label: "Mox", hint: "Concurrent-safe mocks" }, + { value: "bypass", label: "Bypass", hint: "External HTTP service fakes" }, + { value: "wallaby", label: "Wallaby", hint: "Browser acceptance testing" }, + { value: "none", label: "None", hint: "No extra test library" }, +]; + +const QUALITY_OPTIONS: PromptOption[] = [ + { value: "credo", label: "Credo", hint: "Static code analysis" }, + { value: "dialyxir", label: "Dialyxir", hint: "Dialyzer integration" }, + { value: "sobelow", label: "Sobelow", hint: "Phoenix security analysis" }, + { value: "none", label: "None", hint: "No code quality tool" }, +]; + +const DEPLOY_OPTIONS: PromptOption[] = [ + { value: "docker", label: "Docker", hint: "Dockerfile for Phoenix releases" }, + { value: "fly", label: "Fly.io", hint: "Fly.io release config" }, + { value: "gigalixir", label: "Gigalixir", hint: "Gigalixir Procfile and notes" }, + { value: "mix-release", label: "Mix Release", hint: "Release-ready runtime config" }, + { value: "none", label: "None", hint: "No deploy files" }, +]; + +export const resolveElixirWebFrameworkPrompt = (value?: ElixirWebFramework) => + createStaticSinglePromptResolution(WEB_FRAMEWORK_OPTIONS, "phoenix", value); +export const resolveElixirOrmPrompt = (value?: ElixirOrm) => + createStaticSinglePromptResolution(ORM_OPTIONS, "ecto-sql", value); +export const resolveElixirAuthPrompt = (value?: ElixirAuth) => + createStaticSinglePromptResolution(AUTH_OPTIONS, "none", value); +export const resolveElixirApiPrompt = (value?: ElixirApi) => + createStaticSinglePromptResolution(API_OPTIONS, "rest", value); +export const resolveElixirRealtimePrompt = (value?: ElixirRealtime) => + createStaticSinglePromptResolution(REALTIME_OPTIONS, "channels", value); +export const resolveElixirJobsPrompt = (value?: ElixirJobs) => + createStaticSinglePromptResolution(JOB_OPTIONS, "none", value); +export const resolveElixirValidationPrompt = (value?: ElixirValidation) => + createStaticSinglePromptResolution(VALIDATION_OPTIONS, "ecto-changesets", value); +export const resolveElixirHttpPrompt = (value?: ElixirHttp) => + createStaticSinglePromptResolution(HTTP_OPTIONS, "req", value); +export const resolveElixirJsonPrompt = (value?: ElixirJson) => + createStaticSinglePromptResolution(JSON_OPTIONS, "jason", value); +export const resolveElixirEmailPrompt = (value?: ElixirEmail) => + createStaticSinglePromptResolution(EMAIL_OPTIONS, "none", value); +export const resolveElixirCachingPrompt = (value?: ElixirCaching) => + createStaticSinglePromptResolution(CACHING_OPTIONS, "none", value); +export const resolveElixirObservabilityPrompt = (value?: ElixirObservability) => + createStaticSinglePromptResolution(OBSERVABILITY_OPTIONS, "telemetry", value); +export const resolveElixirTestingPrompt = (value?: ElixirTesting) => + createStaticSinglePromptResolution(TESTING_OPTIONS, "ex_unit", value); +export const resolveElixirQualityPrompt = (value?: ElixirQuality) => + createStaticSinglePromptResolution(QUALITY_OPTIONS, "credo", value); +export const resolveElixirDeployPrompt = (value?: ElixirDeploy) => + createStaticSinglePromptResolution(DEPLOY_OPTIONS, "none", value); +export const getElixirWebFrameworkChoice = (value?: ElixirWebFramework) => + makeChoice("Select Elixir web framework", WEB_FRAMEWORK_OPTIONS, "phoenix", value); +export const getElixirOrmChoice = (value?: ElixirOrm) => + makeChoice("Select Elixir database layer", ORM_OPTIONS, "ecto-sql", value); +export const getElixirAuthChoice = (value?: ElixirAuth) => + makeChoice("Select Elixir auth", AUTH_OPTIONS, "none", value); +export const getElixirApiChoice = (value?: ElixirApi) => + makeChoice("Select Elixir API layer", API_OPTIONS, "rest", value); +export const getElixirRealtimeChoice = (value?: ElixirRealtime) => + makeChoice("Select Elixir realtime feature", REALTIME_OPTIONS, "channels", value); +export const getElixirJobsChoice = (value?: ElixirJobs) => + makeChoice("Select Elixir jobs layer", JOB_OPTIONS, "none", value); +export const getElixirValidationChoice = (value?: ElixirValidation) => + makeChoice("Select Elixir validation", VALIDATION_OPTIONS, "ecto-changesets", value); +export const getElixirHttpChoice = (value?: ElixirHttp) => + makeChoice("Select Elixir HTTP client", HTTP_OPTIONS, "req", value); +export const getElixirJsonChoice = (value?: ElixirJson) => + makeChoice("Select Elixir JSON library", JSON_OPTIONS, "jason", value); +export const getElixirEmailChoice = (value?: ElixirEmail) => + makeChoice("Select Elixir email library", EMAIL_OPTIONS, "none", value); +export const getElixirCachingChoice = (value?: ElixirCaching) => + makeChoice("Select Elixir caching", CACHING_OPTIONS, "none", value); +export const getElixirObservabilityChoice = (value?: ElixirObservability) => + makeChoice("Select Elixir observability", OBSERVABILITY_OPTIONS, "telemetry", value); +export const getElixirTestingChoice = (value?: ElixirTesting) => + makeChoice("Select Elixir testing", TESTING_OPTIONS, "ex_unit", value); +export const getElixirQualityChoice = (value?: ElixirQuality) => + makeChoice("Select Elixir code quality", QUALITY_OPTIONS, "credo", value); +export const getElixirDeployChoice = (value?: ElixirDeploy) => + makeChoice("Select Elixir deploy target", DEPLOY_OPTIONS, "none", value); diff --git a/apps/cli/src/prompts/install.ts b/apps/cli/src/prompts/install.ts index 0433b7b20..8d47ef034 100644 --- a/apps/cli/src/prompts/install.ts +++ b/apps/cli/src/prompts/install.ts @@ -95,6 +95,23 @@ export async function getinstallChoice( return response; } + if (ecosystem === "elixir") { + const mixInstalled = await commandExists("mix"); + if (!mixInstalled) { + log.warn("Mix is not installed. Please install Elixir from https://elixir-lang.org/install.html"); + return false; + } + + const response = await navigableConfirm({ + message: "Run mix deps.get and mix compile?", + initialValue: DEFAULT_CONFIG.install, + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; + } + // For TypeScript: existing behavior const response = await navigableConfirm({ message: "Install dependencies?", diff --git a/apps/cli/src/prompts/prompt-resolver-registry.ts b/apps/cli/src/prompts/prompt-resolver-registry.ts index 1e6a97b6c..a3b632dac 100644 --- a/apps/cli/src/prompts/prompt-resolver-registry.ts +++ b/apps/cli/src/prompts/prompt-resolver-registry.ts @@ -10,6 +10,21 @@ import { CSS_FRAMEWORK_VALUES, DATABASE_SETUP_VALUES, DATABASE_VALUES, + ELIXIR_API_VALUES, + ELIXIR_AUTH_VALUES, + ELIXIR_CACHING_VALUES, + ELIXIR_DEPLOY_VALUES, + ELIXIR_EMAIL_VALUES, + ELIXIR_HTTP_VALUES, + ELIXIR_JOBS_VALUES, + ELIXIR_JSON_VALUES, + ELIXIR_OBSERVABILITY_VALUES, + ELIXIR_ORM_VALUES, + ELIXIR_QUALITY_VALUES, + ELIXIR_REALTIME_VALUES, + ELIXIR_TESTING_VALUES, + ELIXIR_VALIDATION_VALUES, + ELIXIR_WEB_FRAMEWORK_VALUES, EMAIL_VALUES, FILE_UPLOAD_VALUES, FORMS_VALUES, @@ -68,6 +83,23 @@ import { resolveCMSPrompt } from "./cms"; import { resolveCSSFrameworkPrompt } from "./css-framework"; import { resolveDatabasePrompt } from "./database"; import { resolveDBSetupPrompt } from "./database-setup"; +import { + resolveElixirApiPrompt, + resolveElixirAuthPrompt, + resolveElixirCachingPrompt, + resolveElixirDeployPrompt, + resolveElixirEmailPrompt, + resolveElixirHttpPrompt, + resolveElixirJobsPrompt, + resolveElixirJsonPrompt, + resolveElixirObservabilityPrompt, + resolveElixirOrmPrompt, + resolveElixirQualityPrompt, + resolveElixirRealtimePrompt, + resolveElixirTestingPrompt, + resolveElixirValidationPrompt, + resolveElixirWebFrameworkPrompt, +} from "./elixir-ecosystem"; import { resolveEmailPrompt } from "./email"; import { resolveFileUploadPrompt } from "./file-upload"; import { resolveFrontendPrompt } from "./frontend"; @@ -442,4 +474,79 @@ export const PROMPT_RESOLVER_REGISTRY: ResolverRegistry = { resolveJavaTestingLibrariesPrompt(value as any), coverageContexts: [{}, { value: ["none"] }], }, + elixirWebFramework: { + schemaValues: ELIXIR_WEB_FRAMEWORK_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirWebFrameworkPrompt(value as any), + coverageContexts: [{}], + }, + elixirOrm: { + schemaValues: ELIXIR_ORM_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirOrmPrompt(value as any), + coverageContexts: [{}], + }, + elixirAuth: { + schemaValues: ELIXIR_AUTH_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirAuthPrompt(value as any), + coverageContexts: [{}], + }, + elixirApi: { + schemaValues: ELIXIR_API_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirApiPrompt(value as any), + coverageContexts: [{}], + }, + elixirRealtime: { + schemaValues: ELIXIR_REALTIME_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirRealtimePrompt(value as any), + coverageContexts: [{}], + }, + elixirJobs: { + schemaValues: ELIXIR_JOBS_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirJobsPrompt(value as any), + coverageContexts: [{}], + }, + elixirValidation: { + schemaValues: ELIXIR_VALIDATION_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirValidationPrompt(value as any), + coverageContexts: [{}], + }, + elixirHttp: { + schemaValues: ELIXIR_HTTP_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirHttpPrompt(value as any), + coverageContexts: [{}], + }, + elixirJson: { + schemaValues: ELIXIR_JSON_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirJsonPrompt(value as any), + coverageContexts: [{}], + }, + elixirEmail: { + schemaValues: ELIXIR_EMAIL_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirEmailPrompt(value as any), + coverageContexts: [{}], + }, + elixirCaching: { + schemaValues: ELIXIR_CACHING_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirCachingPrompt(value as any), + coverageContexts: [{}], + }, + elixirObservability: { + schemaValues: ELIXIR_OBSERVABILITY_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirObservabilityPrompt(value as any), + coverageContexts: [{}], + }, + elixirTesting: { + schemaValues: ELIXIR_TESTING_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirTestingPrompt(value as any), + coverageContexts: [{}], + }, + elixirQuality: { + schemaValues: ELIXIR_QUALITY_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirQualityPrompt(value as any), + coverageContexts: [{}], + }, + elixirDeploy: { + schemaValues: ELIXIR_DEPLOY_VALUES, + resolve: ({ value }: { value?: string } = {}) => resolveElixirDeployPrompt(value as any), + coverageContexts: [{}], + }, }; diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index d736ab7b4..f9f983c59 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -81,6 +81,21 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { javaAuth: projectConfig.javaAuth, javaLibraries: projectConfig.javaLibraries, javaTestingLibraries: projectConfig.javaTestingLibraries, + elixirWebFramework: projectConfig.elixirWebFramework, + elixirOrm: projectConfig.elixirOrm, + elixirAuth: projectConfig.elixirAuth, + elixirApi: projectConfig.elixirApi, + elixirRealtime: projectConfig.elixirRealtime, + elixirJobs: projectConfig.elixirJobs, + elixirValidation: projectConfig.elixirValidation, + elixirHttp: projectConfig.elixirHttp, + elixirJson: projectConfig.elixirJson, + elixirEmail: projectConfig.elixirEmail, + elixirCaching: projectConfig.elixirCaching, + elixirObservability: projectConfig.elixirObservability, + elixirTesting: projectConfig.elixirTesting, + elixirQuality: projectConfig.elixirQuality, + elixirDeploy: projectConfig.elixirDeploy, aiDocs: projectConfig.aiDocs, }; @@ -157,6 +172,21 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { javaAuth: btsConfig.javaAuth, javaLibraries: btsConfig.javaLibraries, javaTestingLibraries: btsConfig.javaTestingLibraries, + elixirWebFramework: btsConfig.elixirWebFramework, + elixirOrm: btsConfig.elixirOrm, + elixirAuth: btsConfig.elixirAuth, + elixirApi: btsConfig.elixirApi, + elixirRealtime: btsConfig.elixirRealtime, + elixirJobs: btsConfig.elixirJobs, + elixirValidation: btsConfig.elixirValidation, + elixirHttp: btsConfig.elixirHttp, + elixirJson: btsConfig.elixirJson, + elixirEmail: btsConfig.elixirEmail, + elixirCaching: btsConfig.elixirCaching, + elixirObservability: btsConfig.elixirObservability, + elixirTesting: btsConfig.elixirTesting, + elixirQuality: btsConfig.elixirQuality, + elixirDeploy: btsConfig.elixirDeploy, aiDocs: btsConfig.aiDocs, }; diff --git a/apps/cli/src/utils/config-validation.ts b/apps/cli/src/utils/config-validation.ts index 751b7e9ac..3b0332f7a 100644 --- a/apps/cli/src/utils/config-validation.ts +++ b/apps/cli/src/utils/config-validation.ts @@ -638,6 +638,170 @@ export function validateJavaConstraints( } } +export function validateElixirConstraints(config: Partial) { + if (config.ecosystem !== "elixir") return; + + const hasPhoenix = config.elixirWebFramework !== "none"; + const hasEcto = config.elixirOrm !== "none"; + + if (!hasPhoenix) { + incompatibilityError({ + message: "The generated Elixir scaffold currently targets Phoenix projects.", + provided: { "elixir-web-framework": config.elixirWebFramework ?? "none" }, + suggestions: [ + "Use --elixir-web-framework phoenix", + "Use --elixir-web-framework phoenix-live-view", + ], + }); + } + + const unsupportedSelections = [ + { + flag: "elixir-orm", + value: config.elixirOrm, + unsupported: ["ecto"], + message: "Plain Ecto without SQL Repo wiring is not generated yet.", + suggestions: ["Use --elixir-orm ecto-sql", "Use --elixir-orm none"], + }, + { + flag: "elixir-auth", + value: config.elixirAuth, + unsupported: ["ueberauth", "guardian"], + message: "Only phx.gen.auth currently generates Phoenix auth files.", + suggestions: ["Use --elixir-auth phx-gen-auth", "Use --elixir-auth none"], + }, + { + flag: "elixir-validation", + value: config.elixirValidation, + unsupported: ["nimble-options"], + message: "NimbleOptions is not generated yet.", + suggestions: ["Use --elixir-validation ecto-changesets", "Use --elixir-validation none"], + }, + { + flag: "elixir-json", + value: config.elixirJson, + unsupported: ["none"], + message: "Phoenix JSON scaffolds require Jason.", + suggestions: ["Use --elixir-json jason"], + }, + { + flag: "elixir-caching", + value: config.elixirCaching, + unsupported: ["nebulex"], + message: "Nebulex cache modules are not generated yet.", + suggestions: ["Use --elixir-caching cachex", "Use --elixir-caching none"], + }, + { + flag: "elixir-observability", + value: config.elixirObservability, + unsupported: ["opentelemetry", "prom_ex"], + message: "OpenTelemetry and PromEx setup are not generated yet.", + suggestions: ["Use --elixir-observability telemetry", "Use --elixir-observability none"], + }, + { + flag: "elixir-testing", + value: config.elixirTesting, + unsupported: ["mox", "bypass", "wallaby", "none"], + message: "Generated Phoenix projects currently include ExUnit tests only.", + suggestions: ["Use --elixir-testing ex_unit"], + }, + { + flag: "elixir-deploy", + value: config.elixirDeploy, + unsupported: ["fly", "gigalixir"], + message: "Fly.io and Gigalixir config files are not generated yet.", + suggestions: ["Use --elixir-deploy docker", "Use --elixir-deploy mix-release"], + }, + ]; + + for (const selection of unsupportedSelections) { + if (selection.value && selection.unsupported.includes(selection.value)) { + incompatibilityError({ + message: selection.message, + provided: { [selection.flag]: selection.value }, + suggestions: selection.suggestions, + }); + } + } + + if (!hasPhoenix) { + const hasPhoenixFeature = [ + config.elixirOrm, + config.elixirAuth, + config.elixirApi, + config.elixirRealtime, + config.elixirJobs, + config.elixirValidation, + config.elixirHttp, + config.elixirJson, + config.elixirEmail, + config.elixirCaching, + config.elixirObservability, + config.elixirTesting, + config.elixirQuality, + config.elixirDeploy, + ].some((value) => value !== undefined && value !== "none"); + + if (hasPhoenixFeature) { + incompatibilityError({ + message: "Elixir feature options require a Phoenix project.", + provided: { "elixir-web-framework": config.elixirWebFramework ?? "none" }, + suggestions: [ + "Use --elixir-web-framework phoenix", + "Use --elixir-web-framework phoenix-live-view", + ], + }); + } + } + + if (config.elixirAuth === "phx-gen-auth" && !hasEcto) { + incompatibilityError({ + message: "phx.gen.auth requires Ecto in the generated Phoenix scaffold.", + provided: { + "elixir-auth": "phx-gen-auth", + "elixir-orm": config.elixirOrm ?? "none", + }, + suggestions: ["Use --elixir-orm ecto-sql", "Use --elixir-auth none"], + }); + } + + if (config.elixirJobs === "oban" && config.elixirOrm !== "ecto-sql") { + incompatibilityError({ + message: "Oban requires Ecto SQL with PostgreSQL in the generated Phoenix scaffold.", + provided: { + "elixir-jobs": "oban", + "elixir-orm": config.elixirOrm ?? "none", + }, + suggestions: ["Use --elixir-orm ecto-sql", "Use --elixir-jobs none"], + }); + } + + if ( + config.elixirRealtime === "live-view-streams" && + config.elixirWebFramework !== "phoenix-live-view" + ) { + incompatibilityError({ + message: "LiveView Streams require Phoenix LiveView.", + provided: { + "elixir-realtime": "live-view-streams", + "elixir-web-framework": config.elixirWebFramework ?? "none", + }, + suggestions: ["Use --elixir-web-framework phoenix-live-view", "Use --elixir-realtime channels"], + }); + } + + if (config.elixirApi === "absinthe" && !hasEcto) { + incompatibilityError({ + message: "Absinthe GraphQL requires Ecto in the current generated Phoenix scaffold.", + provided: { + "elixir-api": "absinthe", + "elixir-orm": config.elixirOrm ?? "none", + }, + suggestions: ["Use --elixir-orm ecto-sql", "Use --elixir-api rest"], + }); + } +} + export function validateEmailConstraints(config: Partial) { if (!config.email || config.email === "none") return; if (config.ecosystem !== "typescript" && config.email !== "resend") { @@ -811,6 +975,7 @@ export function validateFullConfig( validateCachingConstraints(config); validateSearchConstraints(config); validateJavaConstraints(config, providedFlags); + validateElixirConstraints(config); validateServerDeployRequiresBackend(config.serverDeploy, config.backend); @@ -905,6 +1070,7 @@ export function validateConfigForProgrammaticUse(config: Partial) validateCachingConstraints(config); validateSearchConstraints(config); validateJavaConstraints(config); + validateElixirConstraints(config); validatePaymentsCompatibility(config.payments, config.auth, config.backend, config.frontend); diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 41ec6b122..35bed6735 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -191,6 +191,30 @@ function getJavaFlags(config: ProjectConfig) { return flags; } +function getElixirFlags(config: ProjectConfig) { + const flags = ["--ecosystem elixir"]; + + flags.push(`--elixir-web-framework ${config.elixirWebFramework}`); + flags.push(`--elixir-orm ${config.elixirOrm}`); + flags.push(`--elixir-auth ${config.elixirAuth}`); + flags.push(`--elixir-api ${config.elixirApi}`); + flags.push(`--elixir-realtime ${config.elixirRealtime}`); + flags.push(`--elixir-jobs ${config.elixirJobs}`); + flags.push(`--elixir-validation ${config.elixirValidation}`); + flags.push(`--elixir-http ${config.elixirHttp}`); + flags.push(`--elixir-json ${config.elixirJson}`); + flags.push(`--elixir-email ${config.elixirEmail}`); + flags.push(`--elixir-caching ${config.elixirCaching}`); + flags.push(`--elixir-observability ${config.elixirObservability}`); + flags.push(`--elixir-testing ${config.elixirTesting}`); + flags.push(`--elixir-quality ${config.elixirQuality}`); + flags.push(`--elixir-deploy ${config.elixirDeploy}`); + + appendCommonFlags(flags, config); + + return flags; +} + export function generateReproducibleCommand(config: ProjectConfig) { let flags: string[]; @@ -207,6 +231,9 @@ export function generateReproducibleCommand(config: ProjectConfig) { case "java": flags = getJavaFlags(config); break; + case "elixir": + flags = getElixirFlags(config); + break; case "typescript": default: flags = getTypeScriptFlags(config); diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 7d896281f..1b8b77116 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -15533,3 +15533,441 @@ CORS_ORIGIN=http://localhost:3001" ], } `; + +exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots file structure: phoenix-ecto-rest 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "config/config.exs", + "config/dev.exs", + "config/runtime.exs", + "config/test.exs", + "lib/snapshot_elixir_phoenix_ecto_rest.ex", + "lib/snapshot_elixir_phoenix_ecto_rest/application.ex", + "lib/snapshot_elixir_phoenix_ecto_rest/catalog.ex", + "lib/snapshot_elixir_phoenix_ecto_rest/catalog/item.ex", + "lib/snapshot_elixir_phoenix_ecto_rest/http_client.ex", + "lib/snapshot_elixir_phoenix_ecto_rest/repo.ex", + "lib/snapshot_elixir_phoenix_ecto_rest/scheduler.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/room_channel.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/user_socket.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/components/layouts.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/components/layouts/root.html.heex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/error_html.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/error_json.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/health_controller.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/item_controller.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/page_controller.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/endpoint.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/router.ex", + "lib/snapshot_elixir_phoenix_ecto_rest_web/telemetry.ex", + "mix.exs", + "priv/repo/migrations/20260101000000_create_items.exs", + "priv/repo/migrations/20260101000001_create_users.exs", + "priv/repo/seeds.exs", + "test/snapshot_elixir_phoenix_ecto_rest_web/controllers/health_controller_test.exs", + "test/support/conn_case.ex", + "test/test_helper.exs", +] +`; + +exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots file structure: phoenix-liveview-full 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "Dockerfile", + "README.md", + "config/config.exs", + "config/dev.exs", + "config/runtime.exs", + "config/test.exs", + "lib/snapshot_elixir_phoenix_liveview_full.ex", + "lib/snapshot_elixir_phoenix_liveview_full/accounts.ex", + "lib/snapshot_elixir_phoenix_liveview_full/accounts/user.ex", + "lib/snapshot_elixir_phoenix_liveview_full/application.ex", + "lib/snapshot_elixir_phoenix_liveview_full/catalog.ex", + "lib/snapshot_elixir_phoenix_liveview_full/catalog/item.ex", + "lib/snapshot_elixir_phoenix_liveview_full/http_client.ex", + "lib/snapshot_elixir_phoenix_liveview_full/mailer.ex", + "lib/snapshot_elixir_phoenix_liveview_full/repo.ex", + "lib/snapshot_elixir_phoenix_liveview_full/scheduler.ex", + "lib/snapshot_elixir_phoenix_liveview_full/workers/sample_worker.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/channels/presence.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/channels/room_channel.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/channels/user_socket.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/components/layouts.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/components/layouts/root.html.heex", + "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/error_html.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/error_json.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/health_controller.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/item_controller.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/page_controller.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/endpoint.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/resolvers/catalog.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/schema.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/live/item_live/index.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/router.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/telemetry.ex", + "mix.exs", + "priv/repo/migrations/20260101000000_create_items.exs", + "priv/repo/migrations/20260101000001_create_users.exs", + "priv/repo/migrations/20260101000002_add_oban_jobs.exs", + "priv/repo/seeds.exs", + "test/snapshot_elixir_phoenix_liveview_full_web/controllers/health_controller_test.exs", + "test/support/conn_case.ex", + "test/test_helper.exs", +] +`; + +exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-ecto-rest 1`] = ` +{ + "fileCount": 35, + "files": [ + { + "content": +"PHX_HOST=localhost +PORT=4000 +SECRET_KEY_BASE=replace-with-mix-phx-gen-secret +DATABASE_URL=ecto://postgres:postgres@localhost/snapshot_elixir_phoenix_ecto_rest_prod +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=localhost +POSTGRES_DB=snapshot_elixir_phoenix_ecto_rest_dev +POSTGRES_TEST_DB=snapshot_elixir_phoenix_ecto_rest_test +CORS_ORIGIN=http://localhost:3001" +, + "path": ".env.example", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": "[exists]", + "path": "config/config.exs", + }, + { + "content": "[exists]", + "path": "config/dev.exs", + }, + { + "content": "[exists]", + "path": "config/runtime.exs", + }, + { + "content": "[exists]", + "path": "config/test.exs", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/room_channel.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/user_socket.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/components/layouts.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/components/layouts/root.html.heex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/error_html.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/error_json.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/health_controller.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/item_controller.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/page_controller.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/endpoint.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/router.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/telemetry.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/application.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/catalog.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/catalog/item.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/http_client.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/repo.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/scheduler.ex", + }, + { + "content": "[exists]", + "path": "mix.exs", + }, + { + "content": "[exists]", + "path": "priv/repo/migrations/20260101000000_create_items.exs", + }, + { + "content": "[exists]", + "path": "priv/repo/migrations/20260101000001_create_users.exs", + }, + { + "content": "[exists]", + "path": "priv/repo/seeds.exs", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": "[exists]", + "path": "test/snapshot_elixir_phoenix_ecto_rest_web/controllers/health_controller_test.exs", + }, + { + "content": "[exists]", + "path": "test/support/conn_case.ex", + }, + { + "content": "[exists]", + "path": "test/test_helper.exs", + }, + ], +} +`; + +exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-liveview-full 1`] = ` +{ + "fileCount": 45, + "files": [ + { + "content": +"PHX_HOST=localhost +PORT=4000 +SECRET_KEY_BASE=replace-with-mix-phx-gen-secret +DATABASE_URL=ecto://postgres:postgres@localhost/snapshot_elixir_phoenix_liveview_full_prod +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=localhost +POSTGRES_DB=snapshot_elixir_phoenix_liveview_full_dev +POSTGRES_TEST_DB=snapshot_elixir_phoenix_liveview_full_test +CORS_ORIGIN=http://localhost:3001" +, + "path": ".env.example", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": "[exists]", + "path": "config/config.exs", + }, + { + "content": "[exists]", + "path": "config/dev.exs", + }, + { + "content": "[exists]", + "path": "config/runtime.exs", + }, + { + "content": "[exists]", + "path": "config/test.exs", + }, + { + "content": "[exists]", + "path": "Dockerfile", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/channels/presence.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/channels/room_channel.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/channels/user_socket.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/components/layouts.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/components/layouts/root.html.heex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/error_html.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/error_json.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/health_controller.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/item_controller.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/page_controller.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/endpoint.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/resolvers/catalog.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/schema.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/live/item_live/index.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/router.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/telemetry.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/accounts.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/accounts/user.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/application.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/catalog.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/catalog/item.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/http_client.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/mailer.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/repo.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/scheduler.ex", + }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/workers/sample_worker.ex", + }, + { + "content": "[exists]", + "path": "mix.exs", + }, + { + "content": "[exists]", + "path": "priv/repo/migrations/20260101000000_create_items.exs", + }, + { + "content": "[exists]", + "path": "priv/repo/migrations/20260101000001_create_users.exs", + }, + { + "content": "[exists]", + "path": "priv/repo/migrations/20260101000002_add_oban_jobs.exs", + }, + { + "content": "[exists]", + "path": "priv/repo/seeds.exs", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": "[exists]", + "path": "test/snapshot_elixir_phoenix_liveview_full_web/controllers/health_controller_test.exs", + }, + { + "content": "[exists]", + "path": "test/support/conn_case.ex", + }, + { + "content": "[exists]", + "path": "test/test_helper.exs", + }, + ], +} +`; diff --git a/apps/cli/test/template-snapshots.test.ts b/apps/cli/test/template-snapshots.test.ts index ddcb87c5e..a8d61eea7 100644 --- a/apps/cli/test/template-snapshots.test.ts +++ b/apps/cli/test/template-snapshots.test.ts @@ -689,3 +689,84 @@ describe("Template Snapshots - Python Ecosystem", () => { } }); }); + +describe("Template Snapshots - Elixir Ecosystem", () => { + const ELIXIR_CONFIGS = [ + { + name: "phoenix-ecto-rest", + config: { + ecosystem: "elixir" as const, + elixirWebFramework: "phoenix" as const, + elixirOrm: "ecto-sql" as const, + elixirAuth: "none" as const, + elixirApi: "rest" as const, + elixirRealtime: "channels" as const, + elixirJobs: "none" as const, + elixirValidation: "ecto-changesets" as const, + elixirHttp: "req" as const, + elixirJson: "jason" as const, + elixirEmail: "none" as const, + elixirCaching: "none" as const, + elixirObservability: "telemetry" as const, + elixirTesting: "ex_unit" as const, + elixirQuality: "credo" as const, + elixirDeploy: "none" as const, + }, + }, + { + name: "phoenix-liveview-full", + config: { + ecosystem: "elixir" as const, + elixirWebFramework: "phoenix-live-view" as const, + elixirOrm: "ecto-sql" as const, + elixirAuth: "phx-gen-auth" as const, + elixirApi: "absinthe" as const, + elixirRealtime: "presence" as const, + elixirJobs: "oban" as const, + elixirValidation: "ecto-changesets" as const, + elixirHttp: "req" as const, + elixirJson: "jason" as const, + elixirEmail: "swoosh" as const, + elixirCaching: "cachex" as const, + elixirObservability: "telemetry" as const, + elixirTesting: "ex_unit" as const, + elixirQuality: "sobelow" as const, + elixirDeploy: "docker" as const, + }, + }, + ]; + + describe("Elixir File Structure Snapshots", () => { + for (const { name, config } of ELIXIR_CONFIGS) { + it(`file structure: ${name}`, async () => { + const result = await createVirtual({ + projectName: `snapshot-elixir-${name}`, + ...config, + }); + + expect(result.success).toBe(true); + expect(result.tree).toBeDefined(); + + const fileList = treeToFileList(result.tree!); + expect(fileList).toMatchSnapshot(); + }); + } + }); + + describe("Elixir Key File Content Snapshots", () => { + for (const { name, config } of ELIXIR_CONFIGS) { + it(`key files: ${name}`, async () => { + const result = await createVirtual({ + projectName: `snapshot-elixir-${name}`, + ...config, + }); + + expect(result.success).toBe(true); + expect(result.tree).toBeDefined(); + + const snapshot = treeToSnapshot(result.tree!); + expect(snapshot).toMatchSnapshot(); + }); + } + }); +}); diff --git a/apps/web/content/docs/ecosystems/elixir.mdx b/apps/web/content/docs/ecosystems/elixir.mdx new file mode 100644 index 000000000..bc0446ee2 --- /dev/null +++ b/apps/web/content/docs/ecosystems/elixir.mdx @@ -0,0 +1,68 @@ +--- +title: Elixir +description: Phoenix scaffolds with Ecto SQL, PostgreSQL-ready config, REST or Absinthe, realtime, jobs, tests, and deployment files. +--- + +Elixir projects generate standalone Phoenix applications. The scaffold follows Mix and Phoenix conventions instead of the TypeScript monorepo layout used by the default ecosystem. + +## Prerequisites + +- Elixir and Erlang/OTP compatible with Phoenix 1.7. +- PostgreSQL when using `ecto-sql`, `phx-gen-auth`, or `oban`. +- Git if you want repository initialization. + +## Scripted example + +```bash +bun create better-fullstack@latest my-phoenix-app -- \ + --ecosystem elixir \ + --elixir-web-framework phoenix-live-view \ + --elixir-orm ecto-sql \ + --elixir-auth phx-gen-auth \ + --elixir-api absinthe \ + --elixir-realtime presence \ + --elixir-jobs oban \ + --elixir-http req \ + --elixir-json jason \ + --elixir-observability telemetry \ + --elixir-testing ex_unit \ + --elixir-quality credo \ + --elixir-deploy docker \ + --ai-docs none \ + --no-install \ + --no-git +``` + +## Elixir categories + +| Category | Values | +| --- | --- | +| Web framework | `phoenix` `phoenix-live-view` | +| Persistence | `ecto-sql` `none` | +| Auth | `phx-gen-auth` `none` | +| API | `rest` `absinthe` `none` | +| Realtime | `channels` `presence` `pubsub` `live-view-streams` `none` | +| Jobs | `oban` `quantum` `none` | +| HTTP | `req` `finch` `none` | +| JSON | `jason` | +| Email | `swoosh` `none` | +| Caching | `cachex` `none` | +| Observability | `telemetry` `none` | +| Testing | `ex_unit` | +| Quality | `credo` `dialyxir` `sobelow` `none` | +| Deploy | `docker` `mix-release` `none` | + +## Compatibility notes + +- Elixir options only apply with `--ecosystem elixir`. +- Phoenix and Phoenix LiveView are standalone Elixir web frameworks; TypeScript frontend and backend options are ignored. +- `phx-gen-auth` and Oban require Ecto SQL because the generated code needs a Repo and migrations. +- Oban targets PostgreSQL-backed Ecto SQL projects. +- LiveView streams require `phoenix-live-view`. +- Dep-only choices that do not yet generate real Phoenix code, such as Ueberauth, Guardian, Nebulex, PromEx, OpenTelemetry, Wallaby, Fly.io, and Gigalixir, are rejected by the CLI and disabled in the builder until matching templates exist. + +## Generated behavior + +- The project includes `mix.exs`, Phoenix config, router, endpoint, controllers, tests, `.env.example`, and setup notes. +- Ecto SQL selections include a Repo, migrations, schema/context code, and PostgreSQL config. +- Optional selections add working Phoenix files for LiveView, Channels/Presence, Oban or Quantum jobs, Absinthe schema/resolvers, Req/Finch HTTP clients, Swoosh mailer, Cachex, Dockerfile, and release config. diff --git a/apps/web/content/docs/ecosystems/meta.json b/apps/web/content/docs/ecosystems/meta.json index fa4c2a151..dd187c792 100644 --- a/apps/web/content/docs/ecosystems/meta.json +++ b/apps/web/content/docs/ecosystems/meta.json @@ -1,5 +1,5 @@ { "title": "Ecosystems", "defaultOpen": true, - "pages": ["index", "typescript", "rust", "python", "go", "java"] + "pages": ["index", "typescript", "rust", "python", "go", "java", "elixir"] } diff --git a/apps/web/src/components/docs/mdx/compatibility-matrix.tsx b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx index 7094f62bc..aa5a09f7d 100644 --- a/apps/web/src/components/docs/mdx/compatibility-matrix.tsx +++ b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx @@ -27,6 +27,7 @@ const ECOSYSTEMS: Array<{ id: Ecosystem; label: string }> = [ { id: "python", label: "Python" }, { id: "go", label: "Go" }, { id: "java", label: "Java" }, + { id: "elixir", label: "Elixir" }, ]; const TYPESCRIPT_CATEGORIES: SelectCategory[] = [ @@ -133,6 +134,25 @@ const ECOSYSTEM_CATEGORIES: Record = { "packageManager", "versionChannel", ], + elixir: [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + "packageManager", + "versionChannel", + ], }; const BASELINE_CONTROLS: Record = { @@ -176,6 +196,13 @@ const BASELINE_CONTROLS: Record = { { category: "javaOrm", label: "ORM" }, { category: "javaAuth", label: "Auth" }, ], + elixir: [ + { category: "elixirWebFramework", label: "Framework" }, + { category: "elixirOrm", label: "Persistence" }, + { category: "elixirAuth", label: "Auth" }, + { category: "elixirApi", label: "API" }, + { category: "elixirRealtime", label: "Realtime" }, + ], }; const MULTI_STACK_KEYS = new Set([ diff --git a/apps/web/src/components/home/features-section.tsx b/apps/web/src/components/home/features-section.tsx index 22c07c007..3ee4ded28 100644 --- a/apps/web/src/components/home/features-section.tsx +++ b/apps/web/src/components/home/features-section.tsx @@ -34,17 +34,18 @@ const LAYERS: ReadonlyArray = [ "pythonWebFramework", "goWebFramework", "javaWebFramework", + "elixirWebFramework", ], word: "BACKEND FRAMEWORKS", }, { type: "categories", - categories: ["orm", "rustOrm", "pythonOrm", "goOrm", "javaOrm"], + categories: ["orm", "rustOrm", "pythonOrm", "goOrm", "javaOrm", "elixirOrm"], word: "DATABASE ORMs", }, { type: "categories", - categories: ["auth", "rustAuth", "pythonAuth", "goAuth", "javaAuth"], + categories: ["auth", "rustAuth", "pythonAuth", "goAuth", "javaAuth", "elixirAuth"], word: "AUTH PROVIDERS", }, { @@ -77,7 +78,7 @@ export default function FeaturesSection() {

- ✦ five ecosystems + ✦ six ecosystems

Everything.

- TypeScript, Rust, Python, Go, Java — one CLI scaffolds production-ready - apps across all five. Pick your ecosystem, pick your stack. + TypeScript, Rust, Python, Go, Java, Elixir — one CLI scaffolds production-ready + apps across all six. Pick your ecosystem, pick your stack.

@@ -291,7 +292,7 @@ function TotalBlock() {

- options across 5 ecosystems · ts · rust · go · python · java + options across 6 ecosystems · ts · rust · go · python · java · elixir

diff --git a/apps/web/src/components/stack-builder/saved-stacks-panel.tsx b/apps/web/src/components/stack-builder/saved-stacks-panel.tsx index ab7052504..0fb95a507 100644 --- a/apps/web/src/components/stack-builder/saved-stacks-panel.tsx +++ b/apps/web/src/components/stack-builder/saved-stacks-panel.tsx @@ -71,6 +71,13 @@ const RELEVANT_KEYS_BY_ECOSYSTEM: Record = { "email", "observability", "caching", "search", "aiDocs", "git", "install", "yolo", ], + elixir: [ + "ecosystem", "projectName", + "elixirWebFramework", "elixirOrm", "elixirAuth", "elixirApi", "elixirRealtime", + "elixirJobs", "elixirValidation", "elixirHttp", "elixirJson", "elixirEmail", + "elixirCaching", "elixirObservability", "elixirTesting", "elixirQuality", "elixirDeploy", + "aiDocs", "git", "install", "yolo", + ], }; /** Subset of keys used for the card highlight badges. */ @@ -80,6 +87,7 @@ const HIGHLIGHT_KEYS_BY_ECOSYSTEM: Record python: ["pythonWebFramework", "pythonOrm", "pythonAi", "pythonApi", "pythonTaskQueue"], go: ["goWebFramework", "goOrm", "goApi", "goCli"], java: ["javaWebFramework", "javaBuildTool", "javaOrm", "javaAuth", "javaLibraries", "javaTestingLibraries"], + elixir: ["elixirWebFramework", "elixirOrm", "elixirAuth", "elixirApi", "elixirRealtime", "elixirJobs"], }; interface SavedStacksPanelProps { diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx index fa12ed9ea..3d0969da9 100644 --- a/apps/web/src/components/stack-builder/stack-builder.tsx +++ b/apps/web/src/components/stack-builder/stack-builder.tsx @@ -54,6 +54,7 @@ import { usesVirtualNoneSelection } from "@/lib/stack-contract"; import { useStackState } from "@/lib/stack-url-state"; import { CATEGORY_ORDER, + ELIXIR_CATEGORY_ORDER, generateStackCommand, generateStackSharingUrl, GO_CATEGORY_ORDER, @@ -549,6 +550,8 @@ const StackBuilder = () => { return GO_CATEGORY_ORDER; case "java": return JAVA_CATEGORY_ORDER; + case "elixir": + return ELIXIR_CATEGORY_ORDER; default: return TYPESCRIPT_CATEGORY_ORDER; } diff --git a/apps/web/src/lib/combinations-count.ts b/apps/web/src/lib/combinations-count.ts index ccaafd993..d278c6599 100644 --- a/apps/web/src/lib/combinations-count.ts +++ b/apps/web/src/lib/combinations-count.ts @@ -14,6 +14,21 @@ import { DATABASE_SETUP_VALUES, DATABASE_VALUES, EFFECT_VALUES, + ELIXIR_API_VALUES, + ELIXIR_AUTH_VALUES, + ELIXIR_CACHING_VALUES, + ELIXIR_DEPLOY_VALUES, + ELIXIR_EMAIL_VALUES, + ELIXIR_HTTP_VALUES, + ELIXIR_JOBS_VALUES, + ELIXIR_JSON_VALUES, + ELIXIR_OBSERVABILITY_VALUES, + ELIXIR_ORM_VALUES, + ELIXIR_QUALITY_VALUES, + ELIXIR_REALTIME_VALUES, + ELIXIR_TESTING_VALUES, + ELIXIR_VALIDATION_VALUES, + ELIXIR_WEB_FRAMEWORK_VALUES, EMAIL_VALUES, EXAMPLES_VALUES, FEATURE_FLAGS_VALUES, @@ -164,6 +179,27 @@ const goSingleSelectCounts = [ 2, // install ] as const; +const elixirSingleSelectCounts = [ + ELIXIR_WEB_FRAMEWORK_VALUES.length, + ELIXIR_ORM_VALUES.length, + ELIXIR_AUTH_VALUES.length, + ELIXIR_API_VALUES.length, + ELIXIR_REALTIME_VALUES.length, + ELIXIR_JOBS_VALUES.length, + ELIXIR_VALIDATION_VALUES.length, + ELIXIR_HTTP_VALUES.length, + ELIXIR_JSON_VALUES.length, + ELIXIR_EMAIL_VALUES.length, + ELIXIR_CACHING_VALUES.length, + ELIXIR_OBSERVABILITY_VALUES.length, + ELIXIR_TESTING_VALUES.length, + ELIXIR_QUALITY_VALUES.length, + ELIXIR_DEPLOY_VALUES.length, + PACKAGE_MANAGER_VALUES.length, + 2, // git + 2, // install +] as const; + const typescriptCombinations = multiplyCounts(typescriptSingleSelectCounts) * powerSetSize(FRONTEND_VALUES) * @@ -178,7 +214,10 @@ const pythonCombinations = multiplyCounts(pythonSingleSelectCounts) * powerSetSi const goCombinations = multiplyCounts(goSingleSelectCounts) * powerSetSize(AI_DOCS_VALUES); -const totalCombinations = typescriptCombinations + rustCombinations + pythonCombinations + goCombinations; +const elixirCombinations = multiplyCounts(elixirSingleSelectCounts) * powerSetSize(AI_DOCS_VALUES); + +const totalCombinations = + typescriptCombinations + rustCombinations + pythonCombinations + goCombinations + elixirCombinations; const yoloCombinations = totalCombinations * 2n; const yearsAtOneMillisecondPerCombination = Number(totalCombinations) / MILLISECONDS_PER_YEAR; diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 815231bbc..9f16b389f 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -3587,6 +3587,102 @@ export const TECH_OPTIONS: Record< default: true, }, ], + // Elixir ecosystem options + elixirWebFramework: [ + { + id: "phoenix", + name: "Phoenix", + description: "Productive Elixir web framework", + icon: "https://cdn.simpleicons.org/phoenixframework/FD4F00", + color: "from-orange-500 to-fuchsia-600", + default: true, + }, + { + id: "phoenix-live-view", + name: "Phoenix LiveView", + description: "Realtime server-rendered UI with Phoenix", + icon: "https://cdn.simpleicons.org/phoenixframework/FD4F00", + color: "from-fuchsia-500 to-rose-600", + }, + { id: "none", name: "No Web Framework", description: "Skip Phoenix", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirOrm: [ + { id: "ecto-sql", name: "Ecto SQL", description: "Ecto with SQL adapters and migrations", icon: "", color: "from-violet-500 to-indigo-600", default: true }, + { id: "ecto", name: "Ecto", description: "Schemas and changesets without SQL repo wiring", icon: "", color: "from-purple-500 to-violet-600" }, + { id: "none", name: "No ORM", description: "Skip database layer", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirAuth: [ + { id: "phx-gen-auth", name: "phx.gen.auth", description: "Phoenix account and session scaffold", icon: "", color: "from-emerald-500 to-teal-600" }, + { id: "ueberauth", name: "Ueberauth", description: "OAuth strategy foundation", icon: "", color: "from-blue-500 to-cyan-600" }, + { id: "guardian", name: "Guardian", description: "JWT authentication foundation", icon: "", color: "from-amber-500 to-orange-600" }, + { id: "none", name: "No Auth", description: "Skip auth", icon: "", color: "from-gray-400 to-gray-600", default: true }, + ], + elixirApi: [ + { id: "rest", name: "Phoenix REST", description: "JSON controllers and resources", icon: "", color: "from-sky-500 to-cyan-600", default: true }, + { id: "absinthe", name: "Absinthe GraphQL", description: "GraphQL schema and resolvers", icon: "https://cdn.simpleicons.org/graphql/E10098", color: "from-pink-500 to-violet-600" }, + { id: "none", name: "No API", description: "Skip API routes", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirRealtime: [ + { id: "channels", name: "Phoenix Channels", description: "WebSocket channel endpoint", icon: "", color: "from-cyan-500 to-blue-600", default: true }, + { id: "presence", name: "Phoenix Presence", description: "Presence tracking over PubSub", icon: "", color: "from-teal-500 to-emerald-600" }, + { id: "pubsub", name: "Phoenix PubSub", description: "PubSub foundation", icon: "", color: "from-blue-500 to-indigo-600" }, + { id: "live-view-streams", name: "LiveView Streams", description: "Realtime LiveView stream UI", icon: "", color: "from-fuchsia-500 to-pink-600" }, + { id: "none", name: "No Realtime", description: "Skip realtime feature", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirJobs: [ + { id: "oban", name: "Oban", description: "PostgreSQL-backed jobs and workers", icon: "", color: "from-indigo-500 to-violet-600" }, + { id: "quantum", name: "Quantum", description: "Cron-like scheduler", icon: "", color: "from-amber-500 to-yellow-600" }, + { id: "none", name: "No Jobs", description: "Skip jobs layer", icon: "", color: "from-gray-400 to-gray-600", default: true }, + ], + elixirValidation: [ + { id: "ecto-changesets", name: "Ecto Changesets", description: "Data validation with Ecto", icon: "", color: "from-purple-500 to-indigo-600", default: true }, + { id: "nimble-options", name: "NimbleOptions", description: "Declarative option validation", icon: "", color: "from-lime-500 to-emerald-600" }, + { id: "none", name: "No Validation", description: "Skip validation helper", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirHttp: [ + { id: "req", name: "Req", description: "High-level HTTP client", icon: "", color: "from-green-500 to-teal-600", default: true }, + { id: "finch", name: "Finch", description: "Pooled HTTP client", icon: "", color: "from-sky-500 to-blue-600" }, + { id: "none", name: "No HTTP Client", description: "Skip HTTP client", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirJson: [ + { id: "jason", name: "Jason", description: "Phoenix JSON library", icon: "", color: "from-yellow-500 to-orange-600", default: true }, + { id: "none", name: "No JSON", description: "Skip JSON library", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirEmail: [ + { id: "swoosh", name: "Swoosh", description: "Phoenix email library", icon: "", color: "from-rose-500 to-pink-600" }, + { id: "none", name: "No Email", description: "Skip email", icon: "", color: "from-gray-400 to-gray-600", default: true }, + ], + elixirCaching: [ + { id: "cachex", name: "Cachex", description: "In-memory cache", icon: "", color: "from-teal-500 to-cyan-600" }, + { id: "nebulex", name: "Nebulex", description: "Cache abstraction", icon: "", color: "from-blue-500 to-violet-600" }, + { id: "none", name: "No Cache", description: "Skip cache layer", icon: "", color: "from-gray-400 to-gray-600", default: true }, + ], + elixirObservability: [ + { id: "telemetry", name: "Telemetry", description: "Phoenix telemetry metrics", icon: "", color: "from-cyan-500 to-blue-600", default: true }, + { id: "opentelemetry", name: "OpenTelemetry", description: "Distributed tracing", icon: "https://cdn.simpleicons.org/opentelemetry/000000", color: "from-orange-500 to-red-600" }, + { id: "prom_ex", name: "PromEx", description: "Prometheus metrics for Phoenix", icon: "https://cdn.simpleicons.org/prometheus/E6522C", color: "from-orange-500 to-amber-600" }, + { id: "none", name: "No Observability", description: "Skip observability", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirTesting: [ + { id: "ex_unit", name: "ExUnit", description: "Standard Elixir tests", icon: "", color: "from-green-500 to-emerald-600", default: true }, + { id: "mox", name: "Mox", description: "Concurrent-safe mocks", icon: "", color: "from-violet-500 to-purple-600" }, + { id: "bypass", name: "Bypass", description: "HTTP service fakes", icon: "", color: "from-sky-500 to-cyan-600" }, + { id: "wallaby", name: "Wallaby", description: "Browser acceptance tests", icon: "", color: "from-pink-500 to-rose-600" }, + { id: "none", name: "No Testing", description: "Skip test library", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirQuality: [ + { id: "credo", name: "Credo", description: "Static code analysis", icon: "", color: "from-amber-500 to-yellow-600", default: true }, + { id: "dialyxir", name: "Dialyxir", description: "Dialyzer integration", icon: "", color: "from-indigo-500 to-blue-600" }, + { id: "sobelow", name: "Sobelow", description: "Phoenix security analysis", icon: "", color: "from-red-500 to-rose-600" }, + { id: "none", name: "No Quality Tool", description: "Skip code quality", icon: "", color: "from-gray-400 to-gray-600" }, + ], + elixirDeploy: [ + { id: "docker", name: "Docker", description: "Dockerfile for Phoenix releases", icon: "https://cdn.simpleicons.org/docker/2496ED", color: "from-blue-500 to-cyan-600" }, + { id: "fly", name: "Fly.io", description: "Fly.io-ready release structure", icon: "", color: "from-violet-500 to-indigo-600" }, + { id: "gigalixir", name: "Gigalixir", description: "Gigalixir release notes", icon: "", color: "from-purple-500 to-fuchsia-600" }, + { id: "mix-release", name: "Mix Release", description: "Release-ready runtime config", icon: "", color: "from-slate-500 to-zinc-600" }, + { id: "none", name: "No Deploy Files", description: "Skip deploy files", icon: "", color: "from-gray-400 to-gray-600", default: true }, + ], // Java ecosystem options javaWebFramework: [ { @@ -3950,6 +4046,13 @@ export const ECOSYSTEMS: { icon: "/icon/java.svg", color: "from-red-500 to-orange-600", }, + { + id: "elixir", + name: "Elixir", + description: "Phoenix full-stack ecosystem", + icon: "https://cdn.simpleicons.org/elixir/4B275F", + color: "from-purple-500 to-fuchsia-600", + }, ]; // Categories available for each ecosystem @@ -4058,6 +4161,26 @@ export const ECOSYSTEM_CATEGORIES: Record = { "git", "install", ], + elixir: [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + "aiDocs", + "git", + "install", + ], }; export const PRESET_CATEGORIES = [ @@ -4076,6 +4199,7 @@ export const PRESET_CATEGORIES = [ { id: "python", name: "Python", icon: "fastapi", ecosystem: "python" }, { id: "go", name: "Go", icon: "gin", ecosystem: "go" }, { id: "java", name: "Java", icon: "java", ecosystem: "java" }, + { id: "elixir", name: "Elixir", icon: "phoenix", ecosystem: "elixir" }, ] as const; export type PresetCategory = (typeof PRESET_CATEGORIES)[number]["id"]; diff --git a/apps/web/src/lib/llms.ts b/apps/web/src/lib/llms.ts index cb3266052..b5a4df855 100644 --- a/apps/web/src/lib/llms.ts +++ b/apps/web/src/lib/llms.ts @@ -38,6 +38,7 @@ export function generateLlmsTxt({ "/docs/ecosystems/python", "/docs/ecosystems/go", "/docs/ecosystems/java", + "/docs/ecosystems/elixir", ].includes(page.url), ); diff --git a/apps/web/src/lib/project-stats.generated.ts b/apps/web/src/lib/project-stats.generated.ts index bedcb8b06..d2bf76601 100644 --- a/apps/web/src/lib/project-stats.generated.ts +++ b/apps/web/src/lib/project-stats.generated.ts @@ -6,5 +6,5 @@ export const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce 0, ); export const CATEGORY_COUNT = Object.keys(OPTION_CATEGORY_METADATA).length; -export const ECOSYSTEM_COUNT = 5; -export const ECOSYSTEM_NAMES = ["TypeScript", "Rust", "Python", "Go", "Java"] as const; +export const ECOSYSTEM_COUNT = 6; +export const ECOSYSTEM_NAMES = ["TypeScript", "Rust", "Python", "Go", "Java", "Elixir"] as const; diff --git a/apps/web/src/lib/stack-utils.ts b/apps/web/src/lib/stack-utils.ts index 0b26d7e67..87a3f7fb6 100644 --- a/apps/web/src/lib/stack-utils.ts +++ b/apps/web/src/lib/stack-utils.ts @@ -138,6 +138,27 @@ const JAVA_CATEGORY_ORDER: Array = [ "install", ]; +const ELIXIR_CATEGORY_ORDER: Array = [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + "aiDocs", + "git", + "install", +]; + // Combined category order for backwards compatibility const CATEGORY_ORDER = [ ...new Set([ @@ -146,6 +167,7 @@ const CATEGORY_ORDER = [ ...PYTHON_CATEGORY_ORDER, ...GO_CATEGORY_ORDER, ...JAVA_CATEGORY_ORDER, + ...ELIXIR_CATEGORY_ORDER, ]), ] as Array; @@ -203,4 +225,5 @@ export { PYTHON_CATEGORY_ORDER, GO_CATEGORY_ORDER, JAVA_CATEGORY_ORDER, + ELIXIR_CATEGORY_ORDER, }; diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index cd00e2f68..d60e7529c 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -57,6 +57,40 @@ export const ICON_REGISTRY: Record = { python: { type: "si", slug: "python", hex: "3776AB" }, go: { type: "si", slug: "go", hex: "00ADD8" }, java: { type: "local", src: "/icon/java.svg" }, + elixir: { type: "si", slug: "elixir", hex: "4B275F" }, + phoenix: { type: "si", slug: "phoenixframework", hex: "FD4F00" }, + "phoenix-live-view": { type: "si", slug: "phoenixframework", hex: "FD4F00" }, + "ecto-sql": { type: "si", slug: "postgresql", hex: "4169E1" }, + ecto: { type: "si", slug: "elixir", hex: "4B275F" }, + "phx-gen-auth": { type: "si", slug: "phoenixframework", hex: "FD4F00" }, + ueberauth: { type: "si", slug: "oauth", hex: "000000" }, + guardian: { type: "si", slug: "jsonwebtokens", hex: "000000" }, + absinthe: { type: "si", slug: "graphql", hex: "E10098" }, + channels: { type: "si", slug: "phoenixframework", hex: "FD4F00" }, + presence: { type: "si", slug: "phoenixframework", hex: "FD4F00" }, + pubsub: { type: "si", slug: "phoenixframework", hex: "FD4F00" }, + "live-view-streams": { type: "si", slug: "phoenixframework", hex: "FD4F00" }, + oban: { type: "si", slug: "postgresql", hex: "4169E1" }, + quantum: { type: "si", slug: "elixir", hex: "4B275F" }, + "ecto-changesets": { type: "si", slug: "elixir", hex: "4B275F" }, + "nimble-options": { type: "si", slug: "elixir", hex: "4B275F" }, + req: { type: "si", slug: "elixir", hex: "4B275F" }, + finch: { type: "si", slug: "elixir", hex: "4B275F" }, + jason: { type: "si", slug: "elixir", hex: "4B275F" }, + swoosh: { type: "si", slug: "elixir", hex: "4B275F" }, + cachex: { type: "si", slug: "elixir", hex: "4B275F" }, + nebulex: { type: "si", slug: "elixir", hex: "4B275F" }, + telemetry: { type: "si", slug: "elixir", hex: "4B275F" }, + prom_ex: { type: "si", slug: "prometheus", hex: "E6522C" }, + ex_unit: { type: "si", slug: "elixir", hex: "4B275F" }, + mox: { type: "si", slug: "elixir", hex: "4B275F" }, + bypass: { type: "si", slug: "elixir", hex: "4B275F" }, + wallaby: { type: "si", slug: "elixir", hex: "4B275F" }, + credo: { type: "si", slug: "elixir", hex: "4B275F" }, + dialyxir: { type: "si", slug: "elixir", hex: "4B275F" }, + sobelow: { type: "si", slug: "elixir", hex: "4B275F" }, + "mix-release": { type: "si", slug: "elixir", hex: "4B275F" }, + gigalixir: { type: "si", slug: "elixir", hex: "4B275F" }, // ─── API ─────────────────────────────────────────────────────────────────── trpc: { type: "si", slug: "trpc", hex: "398CCB" }, diff --git a/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index 702b14b60..0757a67e9 100644 --- a/apps/web/src/lib/tech-resource-links.ts +++ b/apps/web/src/lib/tech-resource-links.ts @@ -1125,6 +1125,123 @@ const CATEGORY_LINKS: LinkMap = { githubUrl: "https://github.com/react-icons/react-icons", }, + // ─── Elixir / Phoenix ───────────────────────────────────────────────────── + "elixirWebFramework:phoenix": { + docsUrl: "https://hexdocs.pm/phoenix/", + githubUrl: "https://github.com/phoenixframework/phoenix", + }, + "elixirWebFramework:phoenix-live-view": { + docsUrl: "https://hexdocs.pm/phoenix_live_view/", + githubUrl: "https://github.com/phoenixframework/phoenix_live_view", + }, + "elixirOrm:ecto-sql": { + docsUrl: "https://hexdocs.pm/ecto_sql/", + githubUrl: "https://github.com/elixir-ecto/ecto_sql", + }, + "elixirOrm:ecto": { + docsUrl: "https://hexdocs.pm/ecto/", + githubUrl: "https://github.com/elixir-ecto/ecto", + }, + "elixirAuth:phx-gen-auth": { docsUrl: "https://hexdocs.pm/phoenix/mix_phx_gen_auth.html" }, + "elixirAuth:ueberauth": { + docsUrl: "https://hexdocs.pm/ueberauth/", + githubUrl: "https://github.com/ueberauth/ueberauth", + }, + "elixirAuth:guardian": { + docsUrl: "https://hexdocs.pm/guardian/", + githubUrl: "https://github.com/ueberauth/guardian", + }, + "elixirApi:rest": { docsUrl: "https://hexdocs.pm/phoenix/controllers.html" }, + "elixirApi:absinthe": { + docsUrl: "https://hexdocs.pm/absinthe/", + githubUrl: "https://github.com/absinthe-graphql/absinthe", + }, + "elixirRealtime:channels": { docsUrl: "https://hexdocs.pm/phoenix/channels.html" }, + "elixirRealtime:presence": { docsUrl: "https://hexdocs.pm/phoenix/Phoenix.Presence.html" }, + "elixirRealtime:pubsub": { + docsUrl: "https://hexdocs.pm/phoenix_pubsub/", + githubUrl: "https://github.com/phoenixframework/phoenix_pubsub", + }, + "elixirRealtime:live-view-streams": { + docsUrl: "https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#stream/4", + githubUrl: "https://github.com/phoenixframework/phoenix_live_view", + }, + "elixirJobs:oban": { + docsUrl: "https://hexdocs.pm/oban/", + githubUrl: "https://github.com/sorentwo/oban", + }, + "elixirJobs:quantum": { + docsUrl: "https://hexdocs.pm/quantum/", + githubUrl: "https://github.com/quantum-elixir/quantum-core", + }, + "elixirValidation:ecto-changesets": { + docsUrl: "https://hexdocs.pm/ecto/Ecto.Changeset.html", + githubUrl: "https://github.com/elixir-ecto/ecto", + }, + "elixirValidation:nimble-options": { + docsUrl: "https://hexdocs.pm/nimble_options/", + githubUrl: "https://github.com/dashbitco/nimble_options", + }, + "elixirHttp:req": { + docsUrl: "https://hexdocs.pm/req/", + githubUrl: "https://github.com/wojtekmach/req", + }, + "elixirHttp:finch": { + docsUrl: "https://hexdocs.pm/finch/", + githubUrl: "https://github.com/sneako/finch", + }, + "elixirJson:jason": { + docsUrl: "https://hexdocs.pm/jason/", + githubUrl: "https://github.com/michalmuskala/jason", + }, + "elixirEmail:swoosh": { + docsUrl: "https://hexdocs.pm/swoosh/", + githubUrl: "https://github.com/swoosh/swoosh", + }, + "elixirCaching:cachex": { + docsUrl: "https://hexdocs.pm/cachex/", + githubUrl: "https://github.com/whitfin/cachex", + }, + "elixirCaching:nebulex": { + docsUrl: "https://hexdocs.pm/nebulex/", + githubUrl: "https://github.com/cabol/nebulex", + }, + "elixirObservability:telemetry": { + docsUrl: "https://hexdocs.pm/telemetry/", + githubUrl: "https://github.com/beam-telemetry/telemetry", + }, + "elixirObservability:prom_ex": { + docsUrl: "https://hexdocs.pm/prom_ex/", + githubUrl: "https://github.com/akoutmos/prom_ex", + }, + "elixirTesting:ex_unit": { docsUrl: "https://hexdocs.pm/ex_unit/" }, + "elixirTesting:mox": { + docsUrl: "https://hexdocs.pm/mox/", + githubUrl: "https://github.com/dashbitco/mox", + }, + "elixirTesting:bypass": { + docsUrl: "https://hexdocs.pm/bypass/", + githubUrl: "https://github.com/PSPDFKit-labs/bypass", + }, + "elixirTesting:wallaby": { + docsUrl: "https://hexdocs.pm/wallaby/", + githubUrl: "https://github.com/elixir-wallaby/wallaby", + }, + "elixirQuality:credo": { + docsUrl: "https://hexdocs.pm/credo/", + githubUrl: "https://github.com/rrrene/credo", + }, + "elixirQuality:dialyxir": { + docsUrl: "https://hexdocs.pm/dialyxir/", + githubUrl: "https://github.com/jeremyjh/dialyxir", + }, + "elixirQuality:sobelow": { + docsUrl: "https://hexdocs.pm/sobelow/", + githubUrl: "https://github.com/nccgroup/sobelow", + }, + "elixirDeploy:gigalixir": { docsUrl: "https://gigalixir.readthedocs.io/" }, + "elixirDeploy:mix-release": { docsUrl: "https://hexdocs.pm/mix/Mix.Tasks.Release.html" }, + // ─── shadcn Color Themes──────────────────── "shadcnColorTheme:neutral": { docsUrl: "https://ui.shadcn.com/themes" }, diff --git a/packages/template-generator/src/core/template-processor.ts b/packages/template-generator/src/core/template-processor.ts index 0b0b7b802..0bf1a0f2b 100644 --- a/packages/template-generator/src/core/template-processor.ts +++ b/packages/template-generator/src/core/template-processor.ts @@ -73,6 +73,28 @@ Handlebars.registerHelper("projectNameWithClosingBrace", function (this: Project return `${this.projectName ?? ""}}`; }); +Handlebars.registerHelper("elixirAppName", function (this: ProjectConfig) { + return String(this.projectName ?? "my_app") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") || "my_app"; +}); + +Handlebars.registerHelper("elixirModuleName", function (this: ProjectConfig) { + const appName = String(this.projectName ?? "my_app") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") || "my_app"; + + return appName + .split("_") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +}); + /** Returns the CSS font-family string for the chosen shadcn font. */ Handlebars.registerHelper("shadcnFontFamily", function (this: ProjectConfig) { const font = this.shadcnFont ?? "inter"; diff --git a/packages/template-generator/src/generator.ts b/packages/template-generator/src/generator.ts index 9f383d8f2..8864a9390 100644 --- a/packages/template-generator/src/generator.ts +++ b/packages/template-generator/src/generator.ts @@ -18,6 +18,7 @@ import { processPythonBaseTemplate, processGoBaseTemplate, processJavaBaseTemplate, + processElixirBaseTemplate, processFrontendTemplates, processBackendTemplates, processDbTemplates, @@ -71,6 +72,9 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise } else if (config.ecosystem === "java") { // Java ecosystem - use Maven project structure await processJavaBaseTemplate(vfs, templates, config); + } else if (config.ecosystem === "elixir") { + // Elixir ecosystem - use Mix and Phoenix project structure + await processElixirBaseTemplate(vfs, templates, config); } else { // TypeScript ecosystem - use package.json and TypeScript project structure await processBaseTemplate(vfs, templates, config); diff --git a/packages/template-generator/src/processors/readme-generator.ts b/packages/template-generator/src/processors/readme-generator.ts index bdeb63087..f759d91e9 100644 --- a/packages/template-generator/src/processors/readme-generator.ts +++ b/packages/template-generator/src/processors/readme-generator.ts @@ -74,12 +74,55 @@ export function processReadme(vfs: VirtualFileSystem, config: ProjectConfig): vo content = generateGoReadmeContent(config); } else if (config.ecosystem === "java") { content = generateJavaReadmeContent(config); + } else if (config.ecosystem === "elixir") { + content = generateElixirReadmeContent(config); } else { content = generateReadmeContent(config); } vfs.writeFile("README.md", content); } +function generateElixirReadmeContent(config: ProjectConfig): string { + const features = [ + config.elixirWebFramework === "phoenix-live-view" ? "Phoenix LiveView" : "Phoenix", + config.elixirOrm !== "none" ? `${config.elixirOrm} with PostgreSQL` : null, + config.elixirApi !== "none" ? `API: ${config.elixirApi}` : null, + config.elixirRealtime !== "none" ? `Realtime: ${config.elixirRealtime}` : null, + config.elixirJobs !== "none" ? `Jobs: ${config.elixirJobs}` : null, + config.elixirAuth !== "none" ? `Auth: ${config.elixirAuth}` : null, + config.elixirDeploy !== "none" ? `Deploy: ${config.elixirDeploy}` : null, + ].filter(Boolean) as string[]; + + return `# ${config.projectName} + +This project was created with [Better Fullstack](https://github.com/Marve10s/Better-Fullstack) for the Elixir/Phoenix ecosystem. + +## Stack + +${features.map((feature) => `- ${feature}`).join("\n")} + +## Getting Started + +Make sure Elixir, Erlang/OTP, and PostgreSQL are installed. + +\`\`\`sh +mix deps.get +mix ecto.setup +mix phx.server +\`\`\` + +Open http://localhost:4000. + +## Tests + +\`\`\`sh +mix test +\`\`\` + +Copy \`.env.example\` values into your environment before production release builds. +`; +} + function sanitizeJavaPackageSuffix(projectName: string): string { const alphanumericOnly = projectName .trim() diff --git a/packages/template-generator/src/template-handlers/elixir-base.ts b/packages/template-generator/src/template-handlers/elixir-base.ts new file mode 100644 index 000000000..401a63c0e --- /dev/null +++ b/packages/template-generator/src/template-handlers/elixir-base.ts @@ -0,0 +1,70 @@ +import type { ProjectConfig } from "@better-fullstack/types"; + +import type { VirtualFileSystem } from "../core/virtual-fs"; +import type { TemplateData } from "./utils"; + +import { isBinaryFile, processTemplateString, transformFilename } from "../core/template-processor"; + +function getElixirAppName(config: ProjectConfig) { + return String(config.projectName ?? "my_app") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") || "my_app"; +} + +export async function processElixirBaseTemplate( + vfs: VirtualFileSystem, + templates: TemplateData, + config: ProjectConfig, +): Promise { + if (config.ecosystem !== "elixir") return; + + const prefix = "elixir-base/"; + const hasLiveView = config.elixirWebFramework === "phoenix-live-view"; + const hasEcto = config.elixirOrm !== "none"; + const hasAuth = config.elixirAuth === "phx-gen-auth" && hasEcto; + const hasChannels = config.elixirRealtime === "channels" || config.elixirRealtime === "presence"; + const hasPresence = config.elixirRealtime === "presence"; + const hasOban = config.elixirJobs === "oban"; + const hasAbsinthe = config.elixirApi === "absinthe" && hasEcto; + const hasEmail = config.elixirEmail === "swoosh"; + const hasDocker = ["docker", "fly", "gigalixir", "mix-release"].includes(config.elixirDeploy); + const hasHttpClient = config.elixirHttp !== "none"; + + for (const [templatePath, content] of templates) { + if (!templatePath.startsWith(prefix)) continue; + if (!hasLiveView && templatePath.includes("/live/")) continue; + if (!hasEcto && (templatePath.includes("/repo.ex") || templatePath.includes("/migrations/"))) continue; + if (!hasEcto && (templatePath.includes("/catalog") || templatePath.includes("/item_controller"))) continue; + if (!hasAuth && templatePath.includes("/accounts")) continue; + if (!hasChannels && templatePath.includes("/channels/room_channel")) continue; + if (!hasPresence && templatePath.includes("/channels/presence")) continue; + if (!hasOban && templatePath.includes("/workers/")) continue; + if (!hasOban && templatePath.includes("add_oban_jobs")) continue; + if (!hasAbsinthe && templatePath.includes("/graphql/")) continue; + if (!hasEmail && templatePath.includes("/mailer.ex")) continue; + if (!hasDocker && templatePath.includes("Dockerfile")) continue; + if (!hasHttpClient && templatePath.includes("/http_client.ex")) continue; + + const relativePath = templatePath.slice(prefix.length); + const outputPath = transformFilename(relativePath).replace( + /__elixirAppName__/g, + getElixirAppName(config), + ); + + let processedContent: string; + if (isBinaryFile(templatePath)) { + processedContent = "[Binary file]"; + } else if (templatePath.endsWith(".hbs")) { + processedContent = processTemplateString(content, config); + } else { + processedContent = content; + } + + if (processedContent.trim() === "") continue; + + const sourcePath = isBinaryFile(templatePath) ? templatePath : undefined; + vfs.writeFile(outputPath, processedContent, sourcePath); + } +} diff --git a/packages/template-generator/src/template-handlers/index.ts b/packages/template-generator/src/template-handlers/index.ts index fe614b4e0..6a43e9b5a 100644 --- a/packages/template-generator/src/template-handlers/index.ts +++ b/packages/template-generator/src/template-handlers/index.ts @@ -4,6 +4,7 @@ export { processRustBaseTemplate } from "./rust-base"; export { processPythonBaseTemplate } from "./python-base"; export { processGoBaseTemplate } from "./go-base"; export { processJavaBaseTemplate } from "./java-base"; +export { processElixirBaseTemplate } from "./elixir-base"; export { processFrontendTemplates } from "./frontend"; export { processBackendTemplates } from "./backend"; export { processDbTemplates } from "./database"; diff --git a/packages/template-generator/templates/elixir-base/.env.example.hbs b/packages/template-generator/templates/elixir-base/.env.example.hbs new file mode 100644 index 000000000..671ce5fd9 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/.env.example.hbs @@ -0,0 +1,9 @@ +PHX_HOST=localhost +PORT=4000 +SECRET_KEY_BASE=replace-with-mix-phx-gen-secret +DATABASE_URL=ecto://postgres:postgres@localhost/{{elixirAppName}}_prod +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=localhost +POSTGRES_DB={{elixirAppName}}_dev +POSTGRES_TEST_DB={{elixirAppName}}_test diff --git a/packages/template-generator/templates/elixir-base/Dockerfile.hbs b/packages/template-generator/templates/elixir-base/Dockerfile.hbs new file mode 100644 index 000000000..37b181b1c --- /dev/null +++ b/packages/template-generator/templates/elixir-base/Dockerfile.hbs @@ -0,0 +1,20 @@ +FROM hexpm/elixir:1.16.3-erlang-26.2.5-debian-bookworm-20240423-slim AS build + +RUN apt-get update -y && apt-get install -y build-essential git && rm -rf /var/lib/apt/lists/* +WORKDIR /app +ENV MIX_ENV=prod + +RUN mix local.hex --force && mix local.rebar --force +COPY mix.exs mix.lock* ./ +RUN mix deps.get --only $MIX_ENV +COPY config config +COPY lib lib +COPY priv priv +RUN mix release + +FROM debian:bookworm-20240423-slim AS app +RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +ENV MIX_ENV=prod +COPY --from=build /app/_build/prod/rel/{{elixirAppName}} ./ +CMD ["/app/bin/{{elixirAppName}}", "start"] diff --git a/packages/template-generator/templates/elixir-base/README.md.hbs b/packages/template-generator/templates/elixir-base/README.md.hbs new file mode 100644 index 000000000..7af984dc0 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/README.md.hbs @@ -0,0 +1,28 @@ +# {{projectName}} + +Phoenix project generated by Better Fullstack. + +## Stack + +- Web: {{elixirWebFramework}} +- Database: {{elixirOrm}} with PostgreSQL when Ecto SQL is selected +- API: {{elixirApi}} +- Realtime: {{elixirRealtime}} +- Jobs: {{elixirJobs}} +- Auth: {{elixirAuth}} + +## Setup + +```sh +mix deps.get +mix ecto.setup +mix phx.server +``` + +Open http://localhost:4000. + +For tests: + +```sh +mix test +``` diff --git a/packages/template-generator/templates/elixir-base/_gitignore b/packages/template-generator/templates/elixir-base/_gitignore new file mode 100644 index 000000000..9606b515d --- /dev/null +++ b/packages/template-generator/templates/elixir-base/_gitignore @@ -0,0 +1,9 @@ +/_build/ +/cover/ +/deps/ +/doc/ +/.fetch +/erl_crash.dump +*.ez +/.elixir_ls/ +.env diff --git a/packages/template-generator/templates/elixir-base/config/config.exs.hbs b/packages/template-generator/templates/elixir-base/config/config.exs.hbs new file mode 100644 index 000000000..0a863924e --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/config.exs.hbs @@ -0,0 +1,48 @@ +import Config + +{{#if (ne elixirOrm "none")}} +config :{{elixirAppName}}, + ecto_repos: [{{elixirModuleName}}.Repo], + generators: [timestamp_type: :utc_datetime] +{{else}} +config :{{elixirAppName}}, + generators: [timestamp_type: :utc_datetime] +{{/if}} + +config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, + url: [host: "localhost"], + render_errors: [ + formats: [html: {{elixirModuleName}}Web.ErrorHTML, json: {{elixirModuleName}}Web.ErrorJSON], + layout: false + ], + pubsub_server: {{elixirModuleName}}.PubSub, + live_view: [signing_salt: "change-me"] + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :phoenix, :json_library, Jason + +{{#if (eq elixirJobs "oban")}} +config :{{elixirAppName}}, Oban, + repo: {{elixirModuleName}}.Repo, + queues: [default: 10], + plugins: [Oban.Plugins.Pruner] +{{/if}} + +{{#if (eq elixirJobs "quantum")}} +config :{{elixirAppName}}, {{elixirModuleName}}.Scheduler, + jobs: [ + heartbeat: [ + schedule: "@hourly", + task: fn -> :ok end + ] + ] +{{/if}} + +{{#if (eq elixirEmail "swoosh")}} +config :{{elixirAppName}}, {{elixirModuleName}}.Mailer, adapter: Swoosh.Adapters.Local +{{/if}} + +import_config "#{config_env()}.exs" diff --git a/packages/template-generator/templates/elixir-base/config/dev.exs.hbs b/packages/template-generator/templates/elixir-base/config/dev.exs.hbs new file mode 100644 index 000000000..0bd8e2728 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/dev.exs.hbs @@ -0,0 +1,24 @@ +import Config + +config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, + http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "dev-secret-key-base-replace-before-production", + watchers: [] + +{{#if (ne elixirOrm "none")}} +config :{{elixirAppName}}, {{elixirModuleName}}.Repo, + username: System.get_env("POSTGRES_USER", "postgres"), + password: System.get_env("POSTGRES_PASSWORD", "postgres"), + hostname: System.get_env("POSTGRES_HOST", "localhost"), + database: System.get_env("POSTGRES_DB", "{{elixirAppName}}_dev"), + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 +{{/if}} + +config :logger, :console, format: "[$level] $message\n" +config :phoenix, :stacktrace_depth, 20 +config :phoenix, :plug_init_mode, :runtime diff --git a/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs b/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs new file mode 100644 index 000000000..229ef0685 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs @@ -0,0 +1,25 @@ +import Config + +if config_env() == :prod do + database_url = System.get_env("DATABASE_URL") + + if database_url do + config :{{elixirAppName}}, {{elixirModuleName}}.Repo, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + ssl: System.get_env("ECTO_SSL") == "true" + end + + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise "SECRET_KEY_BASE is missing. Generate one with: mix phx.gen.secret" + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ip: {0, 0, 0, 0}, port: port], + secret_key_base: secret_key_base, + server: true +end diff --git a/packages/template-generator/templates/elixir-base/config/test.exs.hbs b/packages/template-generator/templates/elixir-base/config/test.exs.hbs new file mode 100644 index 000000000..f935935e5 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/test.exs.hbs @@ -0,0 +1,18 @@ +import Config + +config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "test-secret-key-base-replace-before-production", + server: false + +{{#if (ne elixirOrm "none")}} +config :{{elixirAppName}}, {{elixirModuleName}}.Repo, + username: System.get_env("POSTGRES_USER", "postgres"), + password: System.get_env("POSTGRES_PASSWORD", "postgres"), + hostname: System.get_env("POSTGRES_HOST", "localhost"), + database: System.get_env("POSTGRES_TEST_DB", "{{elixirAppName}}_test#{System.get_env("MIX_TEST_PARTITION")}"), + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 10 +{{/if}} + +config :logger, level: :warning diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs new file mode 100644 index 000000000..c22e5f3b4 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs @@ -0,0 +1,5 @@ +defmodule {{elixirModuleName}} do + @moduledoc """ + Domain entrypoint for the {{projectName}} Phoenix application. + """ +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs new file mode 100644 index 000000000..414a928d5 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs @@ -0,0 +1,10 @@ +defmodule {{elixirModuleName}}.Accounts do + alias {{elixirModuleName}}.Accounts.User + alias {{elixirModuleName}}.Repo + + def create_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs new file mode 100644 index 000000000..5f28b0582 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs @@ -0,0 +1,19 @@ +defmodule {{elixirModuleName}}.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :email, :string + field :hashed_password, :string + + timestamps(type: :utc_datetime) + end + + def registration_changeset(user, attrs) do + user + |> cast(attrs, [:email, :hashed_password]) + |> validate_required([:email, :hashed_password]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/) + |> unique_constraint(:email) + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs new file mode 100644 index 000000000..cec5462cb --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs @@ -0,0 +1,38 @@ +defmodule {{elixirModuleName}}.Application do + use Application + + @impl true + def start(_type, _args) do + children = [ +{{#if (ne elixirOrm "none")}} + {{elixirModuleName}}.Repo, +{{/if}} + {Phoenix.PubSub, name: {{elixirModuleName}}.PubSub}, +{{#if (eq elixirHttp "finch")}} + {Finch, name: {{elixirModuleName}}.Finch}, +{{/if}} +{{#if (eq elixirEmail "swoosh")}} + {Finch, name: Swoosh.Finch}, +{{/if}} +{{#if (eq elixirCaching "cachex")}} + {Cachex, name: :{{elixirAppName}}_cache}, +{{/if}} +{{#if (eq elixirJobs "oban")}} + {Oban, Application.fetch_env!(:{{elixirAppName}}, Oban)}, +{{/if}} +{{#if (eq elixirJobs "quantum")}} + {{elixirModuleName}}.Scheduler, +{{/if}} + {{elixirModuleName}}Web.Endpoint + ] + + opts = [strategy: :one_for_one, name: {{elixirModuleName}}.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + {{elixirModuleName}}Web.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog.ex.hbs new file mode 100644 index 000000000..579a0fffb --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog.ex.hbs @@ -0,0 +1,16 @@ +defmodule {{elixirModuleName}}.Catalog do + alias {{elixirModuleName}}.Catalog.Item + alias {{elixirModuleName}}.Repo + + def list_items do + Repo.all(Item) + end + + def get_item!(id), do: Repo.get!(Item, id) + + def create_item(attrs) do + %Item{} + |> Item.changeset(attrs) + |> Repo.insert() + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog/item.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog/item.ex.hbs new file mode 100644 index 000000000..e1c4f3c0c --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/catalog/item.ex.hbs @@ -0,0 +1,18 @@ +defmodule {{elixirModuleName}}.Catalog.Item do + use Ecto.Schema + import Ecto.Changeset + + schema "items" do + field :name, :string + field :description, :string + + timestamps(type: :utc_datetime) + end + + def changeset(item, attrs) do + item + |> cast(attrs, [:name, :description]) + |> validate_required([:name]) + |> validate_length(:name, min: 2, max: 120) + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/http_client.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/http_client.ex.hbs new file mode 100644 index 000000000..9f1a309e6 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/http_client.ex.hbs @@ -0,0 +1,17 @@ +defmodule {{elixirModuleName}}.HttpClient do + @moduledoc """ + Small outbound HTTP boundary used by generated code and tests. + """ + +{{#if (eq elixirHttp "req")}} + def get!(url, opts \\ []) do + Req.get!(url, opts) + end +{{/if}} +{{#if (eq elixirHttp "finch")}} + def get!(url, headers \\ []) do + Finch.build(:get, url, headers) + |> Finch.request!({{elixirModuleName}}.Finch) + end +{{/if}} +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/mailer.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/mailer.ex.hbs new file mode 100644 index 000000000..f80a5602e --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/mailer.ex.hbs @@ -0,0 +1,3 @@ +defmodule {{elixirModuleName}}.Mailer do + use Swoosh.Mailer, otp_app: :{{elixirAppName}} +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/repo.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/repo.ex.hbs new file mode 100644 index 000000000..dfa1c6ee6 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/repo.ex.hbs @@ -0,0 +1,5 @@ +defmodule {{elixirModuleName}}.Repo do + use Ecto.Repo, + otp_app: :{{elixirAppName}}, + adapter: Ecto.Adapters.Postgres +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/scheduler.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/scheduler.ex.hbs new file mode 100644 index 000000000..06f50240b --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/scheduler.ex.hbs @@ -0,0 +1,3 @@ +defmodule {{elixirModuleName}}.Scheduler do + use Quantum, otp_app: :{{elixirAppName}} +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/workers/sample_worker.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/workers/sample_worker.ex.hbs new file mode 100644 index 000000000..99c7e7654 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/workers/sample_worker.ex.hbs @@ -0,0 +1,10 @@ +defmodule {{elixirModuleName}}.Workers.SampleWorker do + use Oban.Worker, queue: :default, max_attempts: 3 + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + require Logger + Logger.info("Processed Oban job with args=#{inspect(args)}") + :ok + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web.ex.hbs new file mode 100644 index 000000000..6be1cff5e --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web.ex.hbs @@ -0,0 +1,72 @@ +defmodule {{elixirModuleName}}Web do + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + import Plug.Conn + unquote(verified_routes()) + end + end + + def html do + quote do + use Phoenix.Component + import Phoenix.Controller, only: [get_csrf_token: 0, view_module: 1, view_template: 1] + unquote(verified_routes()) + end + end + +{{#if (eq elixirWebFramework "phoenix-live-view")}} + def live_view do + quote do + use Phoenix.LiveView + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + unquote(html_helpers()) + end + end +{{/if}} + + def channel do + quote do + use Phoenix.Channel + end + end + + def router do + quote do + use Phoenix.Router, helpers: false + import Plug.Conn + import Phoenix.Controller +{{#if (eq elixirWebFramework "phoenix-live-view")}} + import Phoenix.LiveView.Router +{{/if}} + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: {{elixirModuleName}}Web.Endpoint, + router: {{elixirModuleName}}Web.Router, + statics: {{elixirModuleName}}Web.static_paths() + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/presence.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/presence.ex.hbs new file mode 100644 index 000000000..4a789b1b0 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/presence.ex.hbs @@ -0,0 +1,5 @@ +defmodule {{elixirModuleName}}Web.Presence do + use Phoenix.Presence, + otp_app: :{{elixirAppName}}, + pubsub_server: {{elixirModuleName}}.PubSub +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/room_channel.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/room_channel.ex.hbs new file mode 100644 index 000000000..82482148a --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/room_channel.ex.hbs @@ -0,0 +1,12 @@ +defmodule {{elixirModuleName}}Web.RoomChannel do + use {{elixirModuleName}}Web, :channel + + @impl true + def join("room:lobby", _payload, socket), do: {:ok, socket} + def join("room:" <> _room_id, _payload, socket), do: {:ok, socket} + + @impl true + def handle_in("ping", payload, socket) do + {:reply, {:ok, Map.put(payload, "pong", true)}, socket} + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/user_socket.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/user_socket.ex.hbs new file mode 100644 index 000000000..cd9f5dbb1 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/channels/user_socket.ex.hbs @@ -0,0 +1,11 @@ +defmodule {{elixirModuleName}}Web.UserSocket do + use Phoenix.Socket + + channel "room:*", {{elixirModuleName}}Web.RoomChannel + + @impl true + def connect(_params, socket, _connect_info), do: {:ok, socket} + + @impl true + def id(_socket), do: nil +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts.ex.hbs new file mode 100644 index 000000000..4dd238628 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts.ex.hbs @@ -0,0 +1,5 @@ +defmodule {{elixirModuleName}}Web.Layouts do + use {{elixirModuleName}}Web, :html + + embed_templates "layouts/*" +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts/root.html.heex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts/root.html.heex.hbs new file mode 100644 index 000000000..7e887bb8f --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/components/layouts/root.html.heex.hbs @@ -0,0 +1,12 @@ + + + + + + + {{projectName}} + + + {@inner_content} + + diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_html.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_html.ex.hbs new file mode 100644 index 000000000..5b85d1f21 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_html.ex.hbs @@ -0,0 +1,7 @@ +defmodule {{elixirModuleName}}Web.ErrorHTML do + use {{elixirModuleName}}Web, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_json.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_json.ex.hbs new file mode 100644 index 000000000..d04eeb583 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/error_json.ex.hbs @@ -0,0 +1,5 @@ +defmodule {{elixirModuleName}}Web.ErrorJSON do + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/health_controller.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/health_controller.ex.hbs new file mode 100644 index 000000000..cc9a3311e --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/health_controller.ex.hbs @@ -0,0 +1,7 @@ +defmodule {{elixirModuleName}}Web.HealthController do + use {{elixirModuleName}}Web, :controller + + def show(conn, _params) do + json(conn, %{status: "ok", app: "{{projectName}}"}) + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/item_controller.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/item_controller.ex.hbs new file mode 100644 index 000000000..3fa1e405b --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/item_controller.ex.hbs @@ -0,0 +1,25 @@ +defmodule {{elixirModuleName}}Web.ItemController do + use {{elixirModuleName}}Web, :controller + + alias {{elixirModuleName}}.Catalog + + def index(conn, _params) do + json(conn, %{data: Enum.map(Catalog.list_items(), &item_json/1)}) + end + + def show(conn, %{"id" => id}) do + json(conn, %{data: item_json(Catalog.get_item!(id))}) + end + + def create(conn, %{"item" => attrs}) do + with {:ok, item} <- Catalog.create_item(attrs) do + conn + |> put_status(:created) + |> json(%{data: item_json(item)}) + end + end + + defp item_json(item) do + %{id: item.id, name: item.name, description: item.description} + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/page_controller.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/page_controller.ex.hbs new file mode 100644 index 000000000..a1ffb1eeb --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/page_controller.ex.hbs @@ -0,0 +1,12 @@ +defmodule {{elixirModuleName}}Web.PageController do + use {{elixirModuleName}}Web, :controller + + def home(conn, _params) do + html(conn, """ +
+

{{projectName}}

+

Phoenix is running. Visit /api/health for JSON output.

+
+ """) + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/endpoint.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/endpoint.ex.hbs new file mode 100644 index 000000000..18ac3f5f4 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/endpoint.ex.hbs @@ -0,0 +1,41 @@ +defmodule {{elixirModuleName}}Web.Endpoint do + use Phoenix.Endpoint, otp_app: :{{elixirAppName}} + + @session_options [ + store: :cookie, + key: "_{{elixirAppName}}_key", + signing_salt: "change-me" + ] + +{{#if (or (eq elixirRealtime "channels") (eq elixirRealtime "presence"))}} + socket "/socket", {{elixirModuleName}}Web.UserSocket, + websocket: true, + longpoll: false +{{/if}} + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]] + + plug Plug.Static, + at: "/", + from: :{{elixirAppName}}, + gzip: false, + only: {{elixirModuleName}}Web.static_paths() + + if code_reloading? do + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Jason + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug {{elixirModuleName}}Web.Router +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/resolvers/catalog.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/resolvers/catalog.ex.hbs new file mode 100644 index 000000000..5894e19c4 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/resolvers/catalog.ex.hbs @@ -0,0 +1,7 @@ +defmodule {{elixirModuleName}}Web.GraphQL.Resolvers.Catalog do + alias {{elixirModuleName}}.Catalog + + def items(_parent, _args, _resolution) do + {:ok, Catalog.list_items()} + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/schema.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/schema.ex.hbs new file mode 100644 index 000000000..30a412607 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/graphql/schema.ex.hbs @@ -0,0 +1,17 @@ +defmodule {{elixirModuleName}}Web.GraphQL.Schema do + use Absinthe.Schema + + alias {{elixirModuleName}}Web.GraphQL.Resolvers + + object :item do + field :id, non_null(:id) + field :name, non_null(:string) + field :description, :string + end + + query do + field :items, non_null(list_of(non_null(:item))) do + resolve(&Resolvers.Catalog.items/3) + end + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs new file mode 100644 index 000000000..012f31332 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs @@ -0,0 +1,47 @@ +defmodule {{elixirModuleName}}Web.ItemLive.Index do + use {{elixirModuleName}}Web, :live_view + + alias {{elixirModuleName}}.Catalog + + @impl true + def mount(_params, _session, socket) do + socket = + socket + |> assign(:form, to_form(%{"name" => "", "description" => ""})) + |> stream(:items, Catalog.list_items()) + + {:ok, socket} + end + + @impl true + def handle_event("create", %{"name" => name, "description" => description}, socket) do + case Catalog.create_item(%{"name" => name, "description" => description}) do + {:ok, item} -> + {:noreply, stream_insert(socket, :items, item)} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end + + @impl true + def render(assigns) do + ~H""" +
+

Items

+
+ + + +
+
    + <%= for {id, item} <- @streams.items do %> +
  • + {item.name} {item.description} +
  • + <% end %> +
+
+ """ + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs new file mode 100644 index 000000000..1fe249444 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs @@ -0,0 +1,47 @@ +defmodule {{elixirModuleName}}Web.Router do + use {{elixirModuleName}}Web, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: { {{elixirModuleName}}Web.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", {{elixirModuleName}}Web do + pipe_through :browser + + get "/", PageController, :home +{{#if (eq elixirWebFramework "phoenix-live-view")}} + live "/items", ItemLive.Index, :index +{{/if}} + end + + scope "/api", {{elixirModuleName}}Web do + pipe_through :api + + get "/health", HealthController, :show +{{#if (and (eq elixirApi "rest") (ne elixirOrm "none"))}} + resources "/items", ItemController, only: [:index, :show, :create] +{{/if}} + end + +{{#if (eq elixirApi "absinthe")}} + forward "/graphql", Absinthe.Plug, schema: {{elixirModuleName}}Web.GraphQL.Schema +{{/if}} + + if Application.compile_env(:{{elixirAppName}}, :dev_routes) do + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through :browser + live_dashboard "/dashboard", metrics: {{elixirModuleName}}Web.Telemetry + end + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/telemetry.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/telemetry.ex.hbs new file mode 100644 index 000000000..1a5906375 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/telemetry.ex.hbs @@ -0,0 +1,18 @@ +defmodule {{elixirModuleName}}Web.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + + @impl true + def init(_arg) do + Supervisor.init([], strategy: :one_for_one) + end + + def metrics do + [ + summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}), + summary("phoenix.router_dispatch.stop.duration", unit: {:native, :millisecond}) + ] + end +end diff --git a/packages/template-generator/templates/elixir-base/mix.exs.hbs b/packages/template-generator/templates/elixir-base/mix.exs.hbs new file mode 100644 index 000000000..856b92c10 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/mix.exs.hbs @@ -0,0 +1,119 @@ +defmodule {{elixirModuleName}}.MixProject do + use Mix.Project + + def project do + [ + app: :{{elixirAppName}}, + version: "0.1.0", + elixir: "~> 1.16", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + releases: [{{elixirAppName}}: [include_executables_for: [:unix]]] + ] + end + + def application do + [ + mod: { {{elixirModuleName}}.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:phoenix, "~> 1.7.21"}, + {:phoenix_html, "~> 4.2"}, + {:phoenix_live_reload, "~> 1.5", only: :dev}, + {:phoenix_live_dashboard, "~> 0.8"}, +{{#if (eq elixirWebFramework "phoenix-live-view")}} + {:phoenix_live_view, "~> 1.0"}, +{{/if}} +{{#if (ne elixirOrm "none")}} + {:ecto_sql, "~> 3.12"}, + {:postgrex, ">= 0.0.0"}, +{{/if}} +{{#if (eq elixirAuth "ueberauth")}} + {:ueberauth, "~> 0.10"}, +{{/if}} +{{#if (eq elixirAuth "guardian")}} + {:guardian, "~> 2.3"}, +{{/if}} +{{#if (eq elixirJobs "oban")}} + {:oban, "~> 2.19"}, +{{/if}} +{{#if (eq elixirJobs "quantum")}} + {:quantum, "~> 3.5"}, +{{/if}} +{{#if (eq elixirApi "absinthe")}} + {:absinthe, "~> 1.7"}, + {:absinthe_plug, "~> 1.5"}, +{{/if}} +{{#if (eq elixirValidation "nimble-options")}} + {:nimble_options, "~> 1.1"}, +{{/if}} +{{#if (eq elixirHttp "req")}} + {:req, "~> 0.5"}, +{{/if}} +{{#if (eq elixirHttp "finch")}} + {:finch, "~> 0.19"}, +{{/if}} + {:jason, "~> 1.4"}, +{{#if (eq elixirEmail "swoosh")}} + {:swoosh, "~> 1.17"}, + {:finch, "~> 0.19"}, +{{/if}} +{{#if (eq elixirCaching "cachex")}} + {:cachex, "~> 4.0"}, +{{/if}} +{{#if (eq elixirCaching "nebulex")}} + {:nebulex, "~> 2.6"}, +{{/if}} +{{#if (eq elixirObservability "opentelemetry")}} + {:opentelemetry_api, "~> 1.4"}, + {:opentelemetry, "~> 1.5"}, + {:opentelemetry_phoenix, "~> 1.2"}, +{{/if}} +{{#if (eq elixirObservability "prom_ex")}} + {:prom_ex, "~> 1.11"}, +{{/if}} +{{#if (eq elixirTesting "mox")}} + {:mox, "~> 1.2", only: :test}, +{{/if}} +{{#if (eq elixirTesting "bypass")}} + {:bypass, "~> 2.1", only: :test}, +{{/if}} +{{#if (eq elixirTesting "wallaby")}} + {:wallaby, "~> 0.30", only: :test}, +{{/if}} +{{#if (eq elixirQuality "credo")}} + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, +{{/if}} +{{#if (eq elixirQuality "dialyxir")}} + {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, +{{/if}} +{{#if (eq elixirQuality "sobelow")}} + {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, +{{/if}} + {:plug_cowboy, "~> 2.7"} + ] + end + + defp aliases do + [ +{{#if (ne elixirOrm "none")}} + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] +{{else}} + setup: ["deps.get"], + test: ["test"] +{{/if}} + ] + end +end diff --git a/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000000_create_items.exs.hbs b/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000000_create_items.exs.hbs new file mode 100644 index 000000000..ce083c446 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000000_create_items.exs.hbs @@ -0,0 +1,12 @@ +defmodule {{elixirModuleName}}.Repo.Migrations.CreateItems do + use Ecto.Migration + + def change do + create table(:items) do + add :name, :string, null: false + add :description, :text + + timestamps(type: :utc_datetime) + end + end +end diff --git a/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000001_create_users.exs.hbs b/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000001_create_users.exs.hbs new file mode 100644 index 000000000..09442128d --- /dev/null +++ b/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000001_create_users.exs.hbs @@ -0,0 +1,14 @@ +defmodule {{elixirModuleName}}.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users) do + add :email, :string, null: false + add :hashed_password, :string, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:users, [:email]) + end +end diff --git a/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000002_add_oban_jobs.exs.hbs b/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000002_add_oban_jobs.exs.hbs new file mode 100644 index 000000000..95e36c36c --- /dev/null +++ b/packages/template-generator/templates/elixir-base/priv/repo/migrations/20260101000002_add_oban_jobs.exs.hbs @@ -0,0 +1,11 @@ +defmodule {{elixirModuleName}}.Repo.Migrations.AddObanJobs do + use Ecto.Migration + + def up do + Oban.Migration.up(version: 12) + end + + def down do + Oban.Migration.down(version: 1) + end +end diff --git a/packages/template-generator/templates/elixir-base/priv/repo/seeds.exs.hbs b/packages/template-generator/templates/elixir-base/priv/repo/seeds.exs.hbs new file mode 100644 index 000000000..263d21eb2 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/priv/repo/seeds.exs.hbs @@ -0,0 +1,3 @@ +alias {{elixirModuleName}}.Catalog + +Catalog.create_item(%{name: "Phoenix", description: "Generated by Better Fullstack"}) diff --git a/packages/template-generator/templates/elixir-base/test/__elixirAppName___web/controllers/health_controller_test.exs.hbs b/packages/template-generator/templates/elixir-base/test/__elixirAppName___web/controllers/health_controller_test.exs.hbs new file mode 100644 index 000000000..2aefbf920 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/test/__elixirAppName___web/controllers/health_controller_test.exs.hbs @@ -0,0 +1,8 @@ +defmodule {{elixirModuleName}}Web.HealthControllerTest do + use {{elixirModuleName}}Web.ConnCase, async: true + + test "GET /api/health", %{conn: conn} do + conn = get(conn, ~p"/api/health") + assert %{"status" => "ok"} = json_response(conn, 200) + end +end diff --git a/packages/template-generator/templates/elixir-base/test/support/conn_case.ex.hbs b/packages/template-generator/templates/elixir-base/test/support/conn_case.ex.hbs new file mode 100644 index 000000000..2db3d47c4 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/test/support/conn_case.ex.hbs @@ -0,0 +1,17 @@ +defmodule {{elixirModuleName}}Web.ConnCase do + use ExUnit.CaseTemplate + + using do + quote do + @endpoint {{elixirModuleName}}Web.Endpoint + use {{elixirModuleName}}Web, :verified_routes + import Plug.Conn + import Phoenix.ConnTest + import {{elixirModuleName}}Web.ConnCase + end + end + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/packages/template-generator/templates/elixir-base/test/test_helper.exs.hbs b/packages/template-generator/templates/elixir-base/test/test_helper.exs.hbs new file mode 100644 index 000000000..bffb8127c --- /dev/null +++ b/packages/template-generator/templates/elixir-base/test/test_helper.exs.hbs @@ -0,0 +1,4 @@ +ExUnit.start() +{{#if (ne elixirOrm "none")}} +Ecto.Adapters.SQL.Sandbox.mode({{elixirModuleName}}.Repo, :manual) +{{/if}} diff --git a/packages/template-generator/test/_fixtures/config-factory.ts b/packages/template-generator/test/_fixtures/config-factory.ts index 7eff2139d..35e56af3c 100644 --- a/packages/template-generator/test/_fixtures/config-factory.ts +++ b/packages/template-generator/test/_fixtures/config-factory.ts @@ -83,6 +83,21 @@ const DEFAULT_CONFIG = { javaAuth: "none", javaLibraries: [], javaTestingLibraries: [], + 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", aiDocs: [], }; diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index d5810e3bb..eaa1ebb6c 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -88,7 +88,22 @@ export type CompatibilityCategory = | "javaOrm" | "javaAuth" | "javaLibraries" - | "javaTestingLibraries"; + | "javaTestingLibraries" + | "elixirWebFramework" + | "elixirOrm" + | "elixirAuth" + | "elixirApi" + | "elixirRealtime" + | "elixirJobs" + | "elixirValidation" + | "elixirHttp" + | "elixirJson" + | "elixirEmail" + | "elixirCaching" + | "elixirObservability" + | "elixirTesting" + | "elixirQuality" + | "elixirDeploy"; export type CompatibilityIssue = { code: string; @@ -109,7 +124,7 @@ export type CompatibilityAdjustment = { }; export type CompatibilityInput = { - ecosystem: "typescript" | "rust" | "python" | "go" | "java"; + ecosystem: "typescript" | "rust" | "python" | "go" | "java" | "elixir"; projectName: string | null; webFrontend: string[]; nativeFrontend: string[]; @@ -194,6 +209,21 @@ export type CompatibilityInput = { javaAuth: string; javaLibraries: string[]; javaTestingLibraries: string[]; + elixirWebFramework: string; + elixirOrm: string; + elixirAuth: string; + elixirApi: string; + elixirRealtime: string; + elixirJobs: string; + elixirValidation: string; + elixirHttp: string; + elixirJson: string; + elixirEmail: string; + elixirCaching: string; + elixirObservability: string; + elixirTesting: string; + elixirQuality: string; + elixirDeploy: string; }; const TYPESCRIPT_CATEGORY_ORDER: CompatibilityCategory[] = [ @@ -272,6 +302,21 @@ const CATEGORY_ORDER: CompatibilityCategory[] = [ "javaAuth", "javaLibraries", "javaTestingLibraries", + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", ]; const DEFAULT_RUNTIME = "bun"; @@ -385,6 +430,24 @@ export const getCategoryDisplayName = (categoryKey: string): string => { javaTestingLibraries: "Java Testing Libraries", }; + const elixirCategoryNames: Record = { + elixirWebFramework: "Elixir Web Framework", + elixirOrm: "Elixir ORM / Database", + elixirAuth: "Elixir Auth", + elixirApi: "Elixir API Layer", + elixirRealtime: "Elixir Realtime", + elixirJobs: "Elixir Jobs", + elixirValidation: "Elixir Validation", + elixirHttp: "Elixir HTTP Client", + elixirJson: "Elixir JSON", + elixirEmail: "Elixir Email", + elixirCaching: "Elixir Caching", + elixirObservability: "Elixir Observability", + elixirTesting: "Elixir Testing", + elixirQuality: "Elixir Code Quality", + elixirDeploy: "Elixir Deploy", + }; + if (rustCategoryNames[categoryKey]) { return rustCategoryNames[categoryKey]; } @@ -401,6 +464,10 @@ export const getCategoryDisplayName = (categoryKey: string): string => { return javaCategoryNames[categoryKey]; } + if (elixirCategoryNames[categoryKey]) { + return elixirCategoryNames[categoryKey]; + } + // Custom display names for TypeScript categories const tsCategoryNames: Record = { i18n: "Internationalization (i18n)", @@ -1382,6 +1449,87 @@ export const analyzeStackCompatibility = ( } } + // ============================================ + // ELIXIR ECOSYSTEM CONSTRAINTS + // ============================================ + + if (nextStack.ecosystem === "elixir") { + if (nextStack.elixirWebFramework === "none") { + const dependentKeys: Array = [ + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + ]; + + for (const key of dependentKeys) { + if (nextStack[key] !== "none") { + nextStack[key] = "none" as never; + changed = true; + changes.push({ + category: "elixirWebFramework", + message: `${getCategoryDisplayName(key)} set to 'None' (no Phoenix project selected)`, + }); + } + } + } + + if (nextStack.elixirWebFramework === "phoenix-live-view" && nextStack.elixirApi === "none") { + nextStack.elixirRealtime = "live-view-streams"; + changed = true; + changes.push({ + category: "elixirRealtime", + message: "Elixir realtime set to 'LiveView Streams' (Phoenix LiveView selected)", + }); + } + + if (nextStack.elixirOrm === "none") { + if (nextStack.elixirJobs === "oban") { + nextStack.elixirJobs = "none"; + changed = true; + changes.push({ + category: "elixirOrm", + message: "Elixir jobs set to 'None' (Oban requires Ecto SQL with PostgreSQL)", + }); + } + if (nextStack.elixirAuth === "phx-gen-auth") { + nextStack.elixirAuth = "none"; + changed = true; + changes.push({ + category: "elixirOrm", + message: "Elixir auth set to 'None' (phx.gen.auth requires Ecto)", + }); + } + if (nextStack.elixirApi === "absinthe") { + nextStack.elixirApi = "rest"; + changed = true; + changes.push({ + category: "elixirOrm", + message: "Elixir API set to 'REST' (the generated Absinthe resolver needs Ecto)", + }); + } + } + + if (nextStack.elixirJobs === "oban" && nextStack.elixirOrm !== "ecto-sql") { + nextStack.elixirJobs = "none"; + changed = true; + changes.push({ + category: "elixirJobs", + message: "Elixir jobs set to 'None' (Oban requires Ecto SQL with PostgreSQL)", + }); + } + } + // ============================================ // DEPLOYMENT CONSTRAINTS // ============================================ @@ -2341,6 +2489,106 @@ export const getDisabledReason = ( } } + // ============================================ + // ELIXIR ECOSYSTEM RULES + // ============================================ + const elixirCategories = new Set([ + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + ]); + + if (elixirCategories.has(category) && optionId !== "none") { + if (currentStack.ecosystem !== "elixir") { + return "Elixir options only apply when the Elixir ecosystem is selected"; + } + if (currentStack.elixirWebFramework === "none") { + return "Elixir options require a Phoenix project"; + } + } + + if (category === "elixirWebFramework" && optionId !== "none" && currentStack.ecosystem !== "elixir") { + return "Phoenix is available only in the Elixir ecosystem"; + } + + if ( + category === "elixirWebFramework" && + optionId === "none" && + currentStack.ecosystem === "elixir" + ) { + return "The generated Elixir scaffold currently targets Phoenix projects"; + } + + const elixirNotYetGenerated: Partial>> = { + elixirOrm: { + ecto: "Use Ecto SQL for generated Repo, migrations, schemas, and PostgreSQL wiring", + }, + elixirAuth: { + ueberauth: "Ueberauth is not generated yet; use phx.gen.auth or no auth", + guardian: "Guardian JWT wiring is not generated yet; use phx.gen.auth or no auth", + }, + elixirValidation: { + "nimble-options": "NimbleOptions is not generated yet; use Ecto Changesets or no extra validation", + }, + elixirJson: { + none: "Phoenix JSON scaffolds require Jason", + }, + elixirCaching: { + nebulex: "Nebulex cache modules are not generated yet; use Cachex or no cache", + }, + elixirObservability: { + opentelemetry: "OpenTelemetry setup is not generated yet; use Phoenix telemetry or no extra observability", + prom_ex: "PromEx setup is not generated yet; use Phoenix telemetry or no extra observability", + }, + elixirTesting: { + mox: "Mox-specific test boundaries are not generated yet; use ExUnit", + bypass: "Bypass-specific HTTP tests are not generated yet; use ExUnit", + wallaby: "Wallaby browser tests are not generated yet; use ExUnit", + none: "Generated Phoenix projects include ExUnit tests", + }, + elixirDeploy: { + fly: "Fly.io config is not generated yet; use Docker or mix releases", + gigalixir: "Gigalixir config is not generated yet; use Docker or mix releases", + }, + }; + + const unsupportedElixirReason = elixirNotYetGenerated[category]?.[optionId]; + if (currentStack.ecosystem === "elixir" && unsupportedElixirReason) { + return unsupportedElixirReason; + } + + if (category === "elixirAuth") { + if (optionId === "phx-gen-auth" && currentStack.elixirOrm === "none") { + return "phx.gen.auth requires Ecto"; + } + if ((optionId === "ueberauth" || optionId === "guardian") && currentStack.elixirWebFramework === "none") { + return "Elixir auth libraries require Phoenix"; + } + } + + if (category === "elixirJobs" && optionId === "oban" && currentStack.elixirOrm !== "ecto-sql") { + return "Oban requires Ecto SQL with PostgreSQL in the current Phoenix scaffold"; + } + + if (category === "elixirApi" && optionId === "absinthe" && currentStack.elixirOrm === "none") { + return "Absinthe GraphQL requires Ecto in the current Phoenix scaffold"; + } + + if (category === "elixirRealtime" && optionId === "live-view-streams" && currentStack.elixirWebFramework !== "phoenix-live-view") { + return "LiveView Streams require Phoenix LiveView"; + } + return null; }; @@ -2957,6 +3205,21 @@ export function evaluateCompatibility(input: CompatibilityInput): CompatibilityE ["javaBuildTool", input.javaBuildTool], ["javaOrm", input.javaOrm], ["javaAuth", input.javaAuth], + ["elixirWebFramework", input.elixirWebFramework], + ["elixirOrm", input.elixirOrm], + ["elixirAuth", input.elixirAuth], + ["elixirApi", input.elixirApi], + ["elixirRealtime", input.elixirRealtime], + ["elixirJobs", input.elixirJobs], + ["elixirValidation", input.elixirValidation], + ["elixirHttp", input.elixirHttp], + ["elixirJson", input.elixirJson], + ["elixirEmail", input.elixirEmail], + ["elixirCaching", input.elixirCaching], + ["elixirObservability", input.elixirObservability], + ["elixirTesting", input.elixirTesting], + ["elixirQuality", input.elixirQuality], + ["elixirDeploy", input.elixirDeploy], ]; for (const [category, optionId] of scalarChecks) { diff --git a/packages/types/src/defaults.ts b/packages/types/src/defaults.ts index 66c89ccbe..4c0d22215 100644 --- a/packages/types/src/defaults.ts +++ b/packages/types/src/defaults.ts @@ -87,6 +87,21 @@ export function createCliDefaultProjectConfigBase( javaAuth: "none", javaLibraries: [], javaTestingLibraries: ["junit5"], + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + elixirAuth: "none", + elixirApi: "rest", + elixirRealtime: "channels", + elixirJobs: "none", + elixirValidation: "ecto-changesets", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "none", + elixirCaching: "none", + elixirObservability: "telemetry", + elixirTesting: "ex_unit", + elixirQuality: "credo", + elixirDeploy: "none", aiDocs: ["claude-md"], }; } diff --git a/packages/types/src/json-schema.ts b/packages/types/src/json-schema.ts index a32f72781..f79b03eb1 100644 --- a/packages/types/src/json-schema.ts +++ b/packages/types/src/json-schema.ts @@ -21,6 +21,21 @@ import { ProjectConfigSchema, BetterTStackConfigSchema, InitResultSchema, + ElixirApiSchema, + ElixirAuthSchema, + ElixirCachingSchema, + ElixirDeploySchema, + ElixirEmailSchema, + ElixirHttpSchema, + ElixirJobsSchema, + ElixirJsonSchema, + ElixirObservabilitySchema, + ElixirOrmSchema, + ElixirQualitySchema, + ElixirRealtimeSchema, + ElixirTestingSchema, + ElixirValidationSchema, + ElixirWebFrameworkSchema, JavaAuthSchema, JavaBuildToolSchema, JavaLibrariesSchema, @@ -134,6 +149,66 @@ export function getJavaTestingLibrariesJsonSchema() { return z.toJSONSchema(JavaTestingLibrariesSchema); } +export function getElixirWebFrameworkJsonSchema() { + return z.toJSONSchema(ElixirWebFrameworkSchema); +} + +export function getElixirOrmJsonSchema() { + return z.toJSONSchema(ElixirOrmSchema); +} + +export function getElixirAuthJsonSchema() { + return z.toJSONSchema(ElixirAuthSchema); +} + +export function getElixirApiJsonSchema() { + return z.toJSONSchema(ElixirApiSchema); +} + +export function getElixirRealtimeJsonSchema() { + return z.toJSONSchema(ElixirRealtimeSchema); +} + +export function getElixirJobsJsonSchema() { + return z.toJSONSchema(ElixirJobsSchema); +} + +export function getElixirValidationJsonSchema() { + return z.toJSONSchema(ElixirValidationSchema); +} + +export function getElixirHttpJsonSchema() { + return z.toJSONSchema(ElixirHttpSchema); +} + +export function getElixirJsonJsonSchema() { + return z.toJSONSchema(ElixirJsonSchema); +} + +export function getElixirEmailJsonSchema() { + return z.toJSONSchema(ElixirEmailSchema); +} + +export function getElixirCachingJsonSchema() { + return z.toJSONSchema(ElixirCachingSchema); +} + +export function getElixirObservabilityJsonSchema() { + return z.toJSONSchema(ElixirObservabilitySchema); +} + +export function getElixirTestingJsonSchema() { + return z.toJSONSchema(ElixirTestingSchema); +} + +export function getElixirQualityJsonSchema() { + return z.toJSONSchema(ElixirQualitySchema); +} + +export function getElixirDeployJsonSchema() { + return z.toJSONSchema(ElixirDeploySchema); +} + // Get all JSON schemas as a single object export function getAllJsonSchemas() { return { @@ -159,6 +234,21 @@ export function getAllJsonSchemas() { javaAuth: getJavaAuthJsonSchema(), javaLibraries: getJavaLibrariesJsonSchema(), javaTestingLibraries: getJavaTestingLibrariesJsonSchema(), + elixirWebFramework: getElixirWebFrameworkJsonSchema(), + elixirOrm: getElixirOrmJsonSchema(), + elixirAuth: getElixirAuthJsonSchema(), + elixirApi: getElixirApiJsonSchema(), + elixirRealtime: getElixirRealtimeJsonSchema(), + elixirJobs: getElixirJobsJsonSchema(), + elixirValidation: getElixirValidationJsonSchema(), + elixirHttp: getElixirHttpJsonSchema(), + elixirJson: getElixirJsonJsonSchema(), + elixirEmail: getElixirEmailJsonSchema(), + elixirCaching: getElixirCachingJsonSchema(), + elixirObservability: getElixirObservabilityJsonSchema(), + elixirTesting: getElixirTestingJsonSchema(), + elixirQuality: getElixirQualityJsonSchema(), + elixirDeploy: getElixirDeployJsonSchema(), createInput: getCreateInputJsonSchema(), projectConfig: getProjectConfigJsonSchema(), betterTStackConfig: getBetterTStackConfigJsonSchema(), diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index cf536474e..bbf27ec33 100644 --- a/packages/types/src/option-metadata.ts +++ b/packages/types/src/option-metadata.ts @@ -8,6 +8,21 @@ import { ASTRO_INTEGRATION_VALUES, AUTH_VALUES, CACHING_VALUES, + ELIXIR_API_VALUES, + ELIXIR_AUTH_VALUES, + ELIXIR_CACHING_VALUES, + ELIXIR_DEPLOY_VALUES, + ELIXIR_EMAIL_VALUES, + ELIXIR_HTTP_VALUES, + ELIXIR_JOBS_VALUES, + ELIXIR_JSON_VALUES, + ELIXIR_OBSERVABILITY_VALUES, + ELIXIR_ORM_VALUES, + ELIXIR_QUALITY_VALUES, + ELIXIR_REALTIME_VALUES, + ELIXIR_TESTING_VALUES, + ELIXIR_VALIDATION_VALUES, + ELIXIR_WEB_FRAMEWORK_VALUES, I18N_VALUES, CMS_VALUES, CSS_FRAMEWORK_VALUES, @@ -158,7 +173,22 @@ export type OptionCategory = | "javaOrm" | "javaAuth" | "javaLibraries" - | "javaTestingLibraries"; + | "javaTestingLibraries" + | "elixirWebFramework" + | "elixirOrm" + | "elixirAuth" + | "elixirApi" + | "elixirRealtime" + | "elixirJobs" + | "elixirValidation" + | "elixirHttp" + | "elixirJson" + | "elixirEmail" + | "elixirCaching" + | "elixirObservability" + | "elixirTesting" + | "elixirQuality" + | "elixirDeploy"; export type OptionSelectionMode = "single" | "multiple"; @@ -351,6 +381,21 @@ const CATEGORY_VALUE_IDS: Record = { javaAuth: JAVA_AUTH_VALUES, javaLibraries: JAVA_LIBRARIES_VALUES, javaTestingLibraries: JAVA_TESTING_LIBRARIES_VALUES, + elixirWebFramework: ELIXIR_WEB_FRAMEWORK_VALUES, + elixirOrm: ELIXIR_ORM_VALUES, + elixirAuth: ELIXIR_AUTH_VALUES, + elixirApi: ELIXIR_API_VALUES, + elixirRealtime: ELIXIR_REALTIME_VALUES, + elixirJobs: ELIXIR_JOBS_VALUES, + elixirValidation: ELIXIR_VALIDATION_VALUES, + elixirHttp: ELIXIR_HTTP_VALUES, + elixirJson: ELIXIR_JSON_VALUES, + elixirEmail: ELIXIR_EMAIL_VALUES, + elixirCaching: ELIXIR_CACHING_VALUES, + elixirObservability: ELIXIR_OBSERVABILITY_VALUES, + elixirTesting: ELIXIR_TESTING_VALUES, + elixirQuality: ELIXIR_QUALITY_VALUES, + elixirDeploy: ELIXIR_DEPLOY_VALUES, }; const EXACT_LABEL_OVERRIDES: Partial>>> = { @@ -755,6 +800,73 @@ const EXACT_LABEL_OVERRIDES: Partial>>> = @@ -920,6 +1032,21 @@ export const OPTION_CATEGORY_METADATA: Record; export const STACK_SELECTION_KEYS = Object.keys( @@ -561,6 +606,21 @@ const CLI_SCALAR_CONFIG_FIELDS = [ ["javaBuildTool", "javaBuildTool"], ["javaOrm", "javaOrm"], ["javaAuth", "javaAuth"], + ["elixirWebFramework", "elixirWebFramework"], + ["elixirOrm", "elixirOrm"], + ["elixirAuth", "elixirAuth"], + ["elixirApi", "elixirApi"], + ["elixirRealtime", "elixirRealtime"], + ["elixirJobs", "elixirJobs"], + ["elixirValidation", "elixirValidation"], + ["elixirHttp", "elixirHttp"], + ["elixirJson", "elixirJson"], + ["elixirEmail", "elixirEmail"], + ["elixirCaching", "elixirCaching"], + ["elixirObservability", "elixirObservability"], + ["elixirTesting", "elixirTesting"], + ["elixirQuality", "elixirQuality"], + ["elixirDeploy", "elixirDeploy"], ] as const satisfies readonly (readonly [keyof CLIInput, keyof ProjectConfig])[]; const CLI_NON_EMPTY_ARRAY_CONFIG_FIELDS = [ @@ -637,6 +697,24 @@ const JAVA_CONFIG_KEYS = [ "javaTestingLibraries", ] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[]; +const ELIXIR_CONFIG_KEYS = [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", +] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[]; + const COMMAND_ADDONS = new Set([ "pwa", "tauri", @@ -875,6 +953,21 @@ function buildProjectConfigBase( javaTestingLibraries: toUniqueNonNoneArray( stack.javaTestingLibraries, ) as ProjectConfig["javaTestingLibraries"], + elixirWebFramework: stack.elixirWebFramework as ProjectConfig["elixirWebFramework"], + elixirOrm: stack.elixirOrm as ProjectConfig["elixirOrm"], + elixirAuth: stack.elixirAuth as ProjectConfig["elixirAuth"], + elixirApi: stack.elixirApi as ProjectConfig["elixirApi"], + elixirRealtime: stack.elixirRealtime as ProjectConfig["elixirRealtime"], + elixirJobs: stack.elixirJobs as ProjectConfig["elixirJobs"], + elixirValidation: stack.elixirValidation as ProjectConfig["elixirValidation"], + elixirHttp: stack.elixirHttp as ProjectConfig["elixirHttp"], + elixirJson: stack.elixirJson as ProjectConfig["elixirJson"], + elixirEmail: stack.elixirEmail as ProjectConfig["elixirEmail"], + elixirCaching: stack.elixirCaching as ProjectConfig["elixirCaching"], + elixirObservability: stack.elixirObservability as ProjectConfig["elixirObservability"], + elixirTesting: stack.elixirTesting as ProjectConfig["elixirTesting"], + elixirQuality: stack.elixirQuality as ProjectConfig["elixirQuality"], + elixirDeploy: stack.elixirDeploy as ProjectConfig["elixirDeploy"], aiDocs: toUniqueNonNoneArray(stack.aiDocs) as ProjectConfig["aiDocs"], }; } @@ -923,6 +1016,7 @@ export function isCliDefaultStackSelection( ...PYTHON_CONFIG_KEYS, ...GO_CONFIG_KEYS, ...JAVA_CONFIG_KEYS, + ...ELIXIR_CONFIG_KEYS, ]) : new Set(); @@ -1125,6 +1219,33 @@ function generateJavaCommand(selection: StackSelectionInput, projectName: string return `${getBaseCommand(selection)} ${projectName} ${flags.join(" ")}`; } +function generateElixirCommand(selection: StackSelectionInput, projectName: string) { + const flags: string[] = [ + "--ecosystem elixir", + `--elixir-web-framework ${selection.elixirWebFramework}`, + `--elixir-orm ${selection.elixirOrm}`, + `--elixir-auth ${selection.elixirAuth}`, + `--elixir-api ${selection.elixirApi}`, + `--elixir-realtime ${selection.elixirRealtime}`, + `--elixir-jobs ${selection.elixirJobs}`, + `--elixir-validation ${selection.elixirValidation}`, + `--elixir-http ${selection.elixirHttp}`, + `--elixir-json ${selection.elixirJson}`, + `--elixir-email ${selection.elixirEmail}`, + `--elixir-caching ${selection.elixirCaching}`, + `--elixir-observability ${selection.elixirObservability}`, + `--elixir-testing ${selection.elixirTesting}`, + `--elixir-quality ${selection.elixirQuality}`, + `--elixir-deploy ${selection.elixirDeploy}`, + formatArrayFlag("ai-docs", selection.aiDocs), + ]; + + if (selection.git === "false") flags.push("--no-git"); + if (selection.install === "false") flags.push("--no-install"); + + return `${getBaseCommand(selection)} ${projectName} ${flags.join(" ")}`; +} + export function generateStackSelectionCommand(selection: StackSelectionInput): string { const projectName = getProjectName(selection); @@ -1137,6 +1258,8 @@ export function generateStackSelectionCommand(selection: StackSelectionInput): s return generateGoCommand(selection, projectName); case "java": return generateJavaCommand(selection, projectName); + case "elixir": + return generateElixirCommand(selection, projectName); case "typescript": default: return generateTypeScriptCommand(selection, projectName); diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 683992af5..8d8344373 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -80,6 +80,21 @@ import type { JavaAuthSchema, JavaLibrariesSchema, JavaTestingLibrariesSchema, + ElixirWebFrameworkSchema, + ElixirOrmSchema, + ElixirAuthSchema, + ElixirApiSchema, + ElixirRealtimeSchema, + ElixirJobsSchema, + ElixirValidationSchema, + ElixirHttpSchema, + ElixirJsonSchema, + ElixirEmailSchema, + ElixirCachingSchema, + ElixirObservabilitySchema, + ElixirTestingSchema, + ElixirQualitySchema, + ElixirDeploySchema, AiDocsSchema, ShadcnBaseSchema, ShadcnStyleSchema, @@ -164,6 +179,21 @@ export type JavaOrm = z.infer; export type JavaAuth = z.infer; export type JavaLibraries = z.infer; export type JavaTestingLibraries = z.infer; +export type ElixirWebFramework = z.infer; +export type ElixirOrm = z.infer; +export type ElixirAuth = z.infer; +export type ElixirApi = z.infer; +export type ElixirRealtime = z.infer; +export type ElixirJobs = z.infer; +export type ElixirValidation = z.infer; +export type ElixirHttp = z.infer; +export type ElixirJson = z.infer; +export type ElixirEmail = z.infer; +export type ElixirCaching = z.infer; +export type ElixirObservability = z.infer; +export type ElixirTesting = z.infer; +export type ElixirQuality = z.infer; +export type ElixirDeploy = z.infer; export type AiDocs = z.infer; export type ShadcnBase = z.infer; export type ShadcnStyle = z.infer; diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts index 6068c68b7..8a13d0179 100644 --- a/testing/lib/generate-combos/options.ts +++ b/testing/lib/generate-combos/options.ts @@ -14,6 +14,21 @@ import { DATABASE_SETUP_VALUES, DATABASE_VALUES, EFFECT_VALUES, + ELIXIR_API_VALUES, + ELIXIR_AUTH_VALUES, + ELIXIR_CACHING_VALUES, + ELIXIR_DEPLOY_VALUES, + ELIXIR_EMAIL_VALUES, + ELIXIR_HTTP_VALUES, + ELIXIR_JOBS_VALUES, + ELIXIR_JSON_VALUES, + ELIXIR_OBSERVABILITY_VALUES, + ELIXIR_ORM_VALUES, + ELIXIR_QUALITY_VALUES, + ELIXIR_REALTIME_VALUES, + ELIXIR_TESTING_VALUES, + ELIXIR_VALIDATION_VALUES, + ELIXIR_WEB_FRAMEWORK_VALUES, EMAIL_VALUES, EXAMPLES_VALUES, FILE_STORAGE_VALUES, @@ -425,6 +440,51 @@ function makeJavaDraft(args: GeneratorArgs): CandidateDraft { }; } +function makeElixirDraft(args: GeneratorArgs): CandidateDraft { + const elixirWebFramework = sampleScalar(ELIXIR_WEB_FRAMEWORK_VALUES, 0.08, "elixirWebFramework"); + const usesPhoenix = elixirWebFramework !== "none"; + const elixirOrm = usesPhoenix ? sampleScalar(ELIXIR_ORM_VALUES, 0.15, "elixirOrm") : "none"; + const hasEcto = elixirOrm !== "none"; + + return { + ecosystem: "elixir", + options: { + ...createCommonOptions("elixir", args), + elixirWebFramework, + elixirOrm, + elixirAuth: hasEcto ? sampleScalar(ELIXIR_AUTH_VALUES, 0.55, "elixirAuth") : "none", + elixirApi: usesPhoenix ? sampleScalar(ELIXIR_API_VALUES, 0.2, "elixirApi") : "none", + elixirRealtime: usesPhoenix + ? sampleScalar(ELIXIR_REALTIME_VALUES, 0.25, "elixirRealtime") + : "none", + elixirJobs: elixirOrm === "ecto-sql" + ? sampleScalar(ELIXIR_JOBS_VALUES, 0.55, "elixirJobs") + : "none", + elixirValidation: hasEcto + ? sampleScalar(ELIXIR_VALIDATION_VALUES, 0.2, "elixirValidation") + : "none", + elixirHttp: usesPhoenix ? sampleScalar(ELIXIR_HTTP_VALUES, 0.25, "elixirHttp") : "none", + elixirJson: usesPhoenix ? sampleScalar(ELIXIR_JSON_VALUES, 0.05, "elixirJson") : "none", + elixirEmail: usesPhoenix ? sampleScalar(ELIXIR_EMAIL_VALUES, 0.7, "elixirEmail") : "none", + elixirCaching: usesPhoenix + ? sampleScalar(ELIXIR_CACHING_VALUES, 0.75, "elixirCaching") + : "none", + elixirObservability: usesPhoenix + ? sampleScalar(ELIXIR_OBSERVABILITY_VALUES, 0.35, "elixirObservability") + : "none", + elixirTesting: usesPhoenix + ? sampleScalar(ELIXIR_TESTING_VALUES, 0.15, "elixirTesting") + : "none", + elixirQuality: usesPhoenix + ? sampleScalar(ELIXIR_QUALITY_VALUES, 0.25, "elixirQuality") + : "none", + elixirDeploy: usesPhoenix + ? sampleScalar(ELIXIR_DEPLOY_VALUES, 0.65, "elixirDeploy") + : "none", + }, + }; +} + function buildProvidedFlags(options: CLIInput): Set { const providedFlags = new Set(); @@ -526,6 +586,21 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje javaAuth: "none", javaLibraries: [], javaTestingLibraries: [], + 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", aiDocs: [], packageManager: "bun", git: false, @@ -592,6 +667,8 @@ function createDraft(ecosystem: Ecosystem, args: GeneratorArgs): CandidateDraft return makeGoDraft(args); case "java": return makeJavaDraft(args); + case "elixir": + return makeElixirDraft(args); } } diff --git a/testing/lib/generate-combos/render.ts b/testing/lib/generate-combos/render.ts index b4826c475..2609a04b8 100644 --- a/testing/lib/generate-combos/render.ts +++ b/testing/lib/generate-combos/render.ts @@ -73,6 +73,17 @@ export function formatNameFromFingerprint(fingerprint: TemplateFingerprint): str ? fingerprint.javaTestingLibraries.filter((value) => value !== "none").join("-") : undefined, ], + elixir: [ + typeof fingerprint.elixirWebFramework === "string" + ? fingerprint.elixirWebFramework + : undefined, + typeof fingerprint.elixirOrm === "string" ? fingerprint.elixirOrm : undefined, + typeof fingerprint.elixirAuth === "string" ? fingerprint.elixirAuth : undefined, + typeof fingerprint.elixirApi === "string" ? fingerprint.elixirApi : undefined, + typeof fingerprint.elixirRealtime === "string" ? fingerprint.elixirRealtime : undefined, + typeof fingerprint.elixirJobs === "string" ? fingerprint.elixirJobs : undefined, + typeof fingerprint.elixirDeploy === "string" ? fingerprint.elixirDeploy : undefined, + ], } as const; const ecosystemTokens = @@ -186,6 +197,24 @@ export function buildCommand(name: string, config: ProjectConfig): string { ["java-testing-libraries", withExplicitNone(config.javaTestingLibraries)], ]; + const elixirFlags: Array<[string, string | readonly string[]]> = [ + ["elixir-web-framework", config.elixirWebFramework], + ["elixir-orm", config.elixirOrm], + ["elixir-auth", config.elixirAuth], + ["elixir-api", config.elixirApi], + ["elixir-realtime", config.elixirRealtime], + ["elixir-jobs", config.elixirJobs], + ["elixir-validation", config.elixirValidation], + ["elixir-http", config.elixirHttp], + ["elixir-json", config.elixirJson], + ["elixir-email", config.elixirEmail], + ["elixir-caching", config.elixirCaching], + ["elixir-observability", config.elixirObservability], + ["elixir-testing", config.elixirTesting], + ["elixir-quality", config.elixirQuality], + ["elixir-deploy", config.elixirDeploy], + ]; + const orderedFlags = [...commonFlags]; switch (config.ecosystem) { case "typescript": @@ -219,6 +248,9 @@ export function buildCommand(name: string, config: ProjectConfig): string { case "java": orderedFlags.push(...sharedServiceFlags, ...javaFlags); break; + case "elixir": + orderedFlags.push(...elixirFlags); + break; } for (const [flag, value] of orderedFlags) { diff --git a/testing/lib/generate-combos/types.ts b/testing/lib/generate-combos/types.ts index 5f6fb1c1c..8debe1181 100644 --- a/testing/lib/generate-combos/types.ts +++ b/testing/lib/generate-combos/types.ts @@ -20,7 +20,7 @@ export type GeneratorArgs = { export const DEFAULT_ARGS: GeneratorArgs = { count: 10, - ecosystems: ["typescript", "rust", "python", "go", "java"], + ecosystems: ["typescript", "rust", "python", "go", "java", "elixir"], installMode: "install", }; @@ -30,6 +30,7 @@ export const DEFAULT_ECOSYSTEM_WEIGHTS: Record = { python: 2, go: 2, java: 2, + elixir: 2, }; export const TEMPLATE_FINGERPRINT_KEYS = [ @@ -105,6 +106,21 @@ export const TEMPLATE_FINGERPRINT_KEYS = [ "javaAuth", "javaLibraries", "javaTestingLibraries", + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", ] as const; export type TemplateFingerprintKey = (typeof TEMPLATE_FINGERPRINT_KEYS)[number]; diff --git a/testing/lib/presets.ts b/testing/lib/presets.ts index bb0433eab..832fd5401 100644 --- a/testing/lib/presets.ts +++ b/testing/lib/presets.ts @@ -83,6 +83,21 @@ export function makeBaseConfig(name: string, ecosystem: Ecosystem): ProjectConfi javaAuth: "none", javaLibraries: [], javaTestingLibraries: [], + 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", versionChannel: "stable", } as ProjectConfig; } From 3a7a076ca48f1aaad80dcc11a83a183e288049fd Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:29:49 +0300 Subject: [PATCH 03/33] Fix mobile defaults in template tests --- .../template-generator/test/_fixtures/config-factory.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/template-generator/test/_fixtures/config-factory.ts b/packages/template-generator/test/_fixtures/config-factory.ts index 7eff2139d..b8553fd0c 100644 --- a/packages/template-generator/test/_fixtures/config-factory.ts +++ b/packages/template-generator/test/_fixtures/config-factory.ts @@ -31,6 +31,13 @@ const DEFAULT_CONFIG = { email: "none", cssFramework: "tailwind", uiLibrary: "none", + mobileNavigation: "none", + mobileUI: "none", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "none", shadcnBase: "radix", shadcnStyle: "nova", shadcnIconLibrary: "lucide", From a63b791749571186d140df5daed0f89049c07642 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:31:08 +0300 Subject: [PATCH 04/33] Fix ecosystem count tests --- apps/web/test/go-ecosystem.test.ts | 4 ++-- apps/web/test/java-ecosystem.test.ts | 4 ++-- apps/web/test/python-ecosystem.test.ts | 4 ++-- apps/web/test/rust-ecosystem.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/test/go-ecosystem.test.ts b/apps/web/test/go-ecosystem.test.ts index d3fc9efc5..2c38379e3 100644 --- a/apps/web/test/go-ecosystem.test.ts +++ b/apps/web/test/go-ecosystem.test.ts @@ -35,8 +35,8 @@ describe("Go Ecosystem Tab", () => { expect(goEcosystem?.description).toBe("High-performance Go ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should list every ecosystem category", () => { + expect(ECOSYSTEMS).toHaveLength(Object.keys(ECOSYSTEM_CATEGORIES).length); }); }); diff --git a/apps/web/test/java-ecosystem.test.ts b/apps/web/test/java-ecosystem.test.ts index b00528641..197937709 100644 --- a/apps/web/test/java-ecosystem.test.ts +++ b/apps/web/test/java-ecosystem.test.ts @@ -37,8 +37,8 @@ describe("Java Ecosystem Tab", () => { expect(javaEcosystem?.description).toBe("Modern Java ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should list every ecosystem category", () => { + expect(ECOSYSTEMS).toHaveLength(Object.keys(ECOSYSTEM_CATEGORIES).length); }); it("should use the Java language icon for Java presets", () => { diff --git a/apps/web/test/python-ecosystem.test.ts b/apps/web/test/python-ecosystem.test.ts index 543ac3a0f..fc2c535f4 100644 --- a/apps/web/test/python-ecosystem.test.ts +++ b/apps/web/test/python-ecosystem.test.ts @@ -49,8 +49,8 @@ describe("Python Ecosystem Tab", () => { expect(pythonEcosystem?.description).toBe("Python full-stack ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should list every ecosystem category", () => { + expect(ECOSYSTEMS).toHaveLength(Object.keys(ECOSYSTEM_CATEGORIES).length); }); }); diff --git a/apps/web/test/rust-ecosystem.test.ts b/apps/web/test/rust-ecosystem.test.ts index 872e185b5..4745dd53d 100644 --- a/apps/web/test/rust-ecosystem.test.ts +++ b/apps/web/test/rust-ecosystem.test.ts @@ -43,8 +43,8 @@ describe("Rust Ecosystem Tab", () => { expect(rustEcosystem?.description).toBe("High-performance Rust ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should list every ecosystem category", () => { + expect(ECOSYSTEMS).toHaveLength(Object.keys(ECOSYSTEM_CATEGORIES).length); }); }); From 98c43bd79d6ea11ce8b20cb2b8faa589747631c0 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:34:19 +0300 Subject: [PATCH 05/33] Avoid regex in Elixir app name normalization --- .../src/template-handlers/elixir-base.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/template-generator/src/template-handlers/elixir-base.ts b/packages/template-generator/src/template-handlers/elixir-base.ts index 401a63c0e..a9fa9e817 100644 --- a/packages/template-generator/src/template-handlers/elixir-base.ts +++ b/packages/template-generator/src/template-handlers/elixir-base.ts @@ -6,11 +6,27 @@ import type { TemplateData } from "./utils"; import { isBinaryFile, processTemplateString, transformFilename } from "../core/template-processor"; function getElixirAppName(config: ProjectConfig) { - return String(config.projectName ?? "my_app") - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, "") || "my_app"; + const rawName = String(config.projectName ?? "my_app").toLowerCase(); + const parts: string[] = []; + let pendingSeparator = false; + + for (const char of rawName) { + const code = char.charCodeAt(0); + const isLowercaseLetter = code >= 97 && code <= 122; + const isDigit = code >= 48 && code <= 57; + + if (isLowercaseLetter || isDigit) { + if (pendingSeparator && parts.length > 0) { + parts.push("_"); + } + parts.push(char); + pendingSeparator = false; + } else { + pendingSeparator = parts.length > 0; + } + } + + return parts.join("") || "my_app"; } export async function processElixirBaseTemplate( From c72126e37a2667ef09d7296a79d7f22b4d8321c0 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:44:51 +0300 Subject: [PATCH 06/33] Fix CI ecosystem assertions and Fresh Deno deps --- apps/cli/test/frontend.test.ts | 3 +++ apps/cli/test/go-language.test.ts | 3 +-- apps/cli/test/java-ecosystem.test.ts | 1 - apps/cli/test/python-language.test.ts | 3 +-- apps/cli/test/rust-ecosystem.test.ts | 3 +-- .../templates/frontend/fresh/package.json.hbs | 8 ++++++++ 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/cli/test/frontend.test.ts b/apps/cli/test/frontend.test.ts index 49834b67d..9a21eccd0 100644 --- a/apps/cli/test/frontend.test.ts +++ b/apps/cli/test/frontend.test.ts @@ -478,6 +478,9 @@ describe("Frontend Configurations", () => { expect(denoJson).toContain('"fresh": "jsr:@fresh/core@^2.2.0"'); expect(denoJson).toContain('"build": "vite build"'); expect(webPkg.scripts["check-types"]).toBe("deno check"); + expect(webPkg.dependencies["@preact/signals"]).toBeDefined(); + expect(webPkg.dependencies.preact).toBeDefined(); + expect(webPkg.dependencies.vite).toBeDefined(); expect(readme).toContain("http://localhost:5173"); expect(await viteConfig.exists()).toBe(true); expect(await clientEntry.exists()).toBe(true); diff --git a/apps/cli/test/go-language.test.ts b/apps/cli/test/go-language.test.ts index e99ad7417..3e2b6b9f6 100644 --- a/apps/cli/test/go-language.test.ts +++ b/apps/cli/test/go-language.test.ts @@ -66,13 +66,12 @@ const GO_LOGGINGS = extractEnumValues(GoLoggingSchema); describe("Go Language Support", () => { describe("Schema Definitions", () => { - it("should have ecosystem schema with typescript, rust, python, go, and java", () => { + it("should include Go alongside the other ecosystem schema values", () => { expect(ECOSYSTEMS).toContain("typescript"); expect(ECOSYSTEMS).toContain("rust"); expect(ECOSYSTEMS).toContain("python"); expect(ECOSYSTEMS).toContain("go"); expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS.length).toBe(5); }); it("should include GoBetterAuth in auth options", () => { diff --git a/apps/cli/test/java-ecosystem.test.ts b/apps/cli/test/java-ecosystem.test.ts index c768691d0..b0c987a45 100644 --- a/apps/cli/test/java-ecosystem.test.ts +++ b/apps/cli/test/java-ecosystem.test.ts @@ -150,7 +150,6 @@ describe("Java Ecosystem", () => { describe("Schema Definitions", () => { it("should expose java as a valid ecosystem", () => { expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS).toHaveLength(5); }); it("should expose scaffolded Java web framework values", () => { diff --git a/apps/cli/test/python-language.test.ts b/apps/cli/test/python-language.test.ts index b23db4754..2c98ec645 100644 --- a/apps/cli/test/python-language.test.ts +++ b/apps/cli/test/python-language.test.ts @@ -76,13 +76,12 @@ const PYTHON_QUALITIES = extractEnumValues(PythonQualitySchema); describe("Python Language Support", () => { describe("Schema Definitions", () => { - it("should have ecosystem schema with typescript, rust, python, go, and java", () => { + it("should include Python alongside the other ecosystem schema values", () => { expect(ECOSYSTEMS).toContain("typescript"); expect(ECOSYSTEMS).toContain("rust"); expect(ECOSYSTEMS).toContain("python"); expect(ECOSYSTEMS).toContain("go"); expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS.length).toBe(5); }); it("should have python web framework options", () => { diff --git a/apps/cli/test/rust-ecosystem.test.ts b/apps/cli/test/rust-ecosystem.test.ts index 9cd9288f0..06a270f58 100644 --- a/apps/cli/test/rust-ecosystem.test.ts +++ b/apps/cli/test/rust-ecosystem.test.ts @@ -69,13 +69,12 @@ const RUST_LOGGINGS = extractEnumValues(RustLoggingSchema); describe("Rust Ecosystem", () => { describe("Schema Definitions", () => { - it("should have ecosystem schema with typescript, rust, python, go, and java", () => { + it("should include Rust alongside the other ecosystem schema values", () => { expect(ECOSYSTEMS).toContain("typescript"); expect(ECOSYSTEMS).toContain("rust"); expect(ECOSYSTEMS).toContain("python"); expect(ECOSYSTEMS).toContain("go"); expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS.length).toBe(5); }); it("should have rust web framework options", () => { diff --git a/packages/template-generator/templates/frontend/fresh/package.json.hbs b/packages/template-generator/templates/frontend/fresh/package.json.hbs index 25ca977fa..ccee4cbc4 100644 --- a/packages/template-generator/templates/frontend/fresh/package.json.hbs +++ b/packages/template-generator/templates/frontend/fresh/package.json.hbs @@ -9,5 +9,13 @@ "start": "deno task start", "check": "deno task check", "check-types": "deno check" + }, + "dependencies": { + "@preact/signals": "^2.5.0", + "preact": "^10.27.2", + "vite": "^7.3.1"{{#if (eq cssFramework "tailwind")}}, + "@tailwindcss/vite": "^4.1.12", + "tailwindcss": "^4.1.12"{{#if (eq uiLibrary "daisyui")}}, + "daisyui": "^5.5.19"{{/if}}{{/if}} } } From bdecc2e1f8364bea5e532ec80bb5f6fe7f0ffbb3 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:51:37 +0300 Subject: [PATCH 07/33] Fix mobile defaults in CLI tests --- apps/cli/test/go-language.test.ts | 7 +++++++ apps/cli/test/test-utils.ts | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/cli/test/go-language.test.ts b/apps/cli/test/go-language.test.ts index e99ad7417..b63175730 100644 --- a/apps/cli/test/go-language.test.ts +++ b/apps/cli/test/go-language.test.ts @@ -576,6 +576,13 @@ describe("Go Language Support", () => { api: "none", webDeploy: "none", serverDeploy: "none", + mobileNavigation: "none", + mobileUI: "none", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "none", yolo: "false", rustWebFramework: "none", rustFrontend: "none", diff --git a/apps/cli/test/test-utils.ts b/apps/cli/test/test-utils.ts index b4d7e17cd..a00221c92 100644 --- a/apps/cli/test/test-utils.ts +++ b/apps/cli/test/test-utils.ts @@ -43,6 +43,13 @@ import type { Analytics, FeatureFlags, AiDocs, + MobileNavigation, + MobileUI, + MobileStorage, + MobileTesting, + MobilePush, + MobileOTA, + MobileDeepLinking, } from "../src/types"; import { create } from "../src/index"; @@ -165,6 +172,13 @@ export async function runTRPCTest(config: TestConfig): Promise { "jobQueue", "analytics", "featureFlags", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", "aiDocs", ]; const hasSpecificCoreConfig = coreStackFlags.some((flag) => config[flag] !== undefined); @@ -215,6 +229,13 @@ export async function runTRPCTest(config: TestConfig): Promise { jobQueue: "none" as JobQueue, analytics: "none" as Analytics, featureFlags: "none" as FeatureFlags, + mobileNavigation: "none" as MobileNavigation, + mobileUI: "none" as MobileUI, + mobileStorage: "none" as MobileStorage, + mobileTesting: "none" as MobileTesting, + mobilePush: "none" as MobilePush, + mobileOTA: "none" as MobileOTA, + mobileDeepLinking: "none" as MobileDeepLinking, aiDocs: [] as AiDocs[], }; From 9b138223d65a822a3eaffcb05de4e1a848dd5159 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 22 May 2026 23:58:26 +0300 Subject: [PATCH 08/33] Fix Fresh Deno 2.8 runtime tasks --- apps/cli/test/frontend.test.ts | 9 +++++++-- .../templates/frontend/fresh/deno.json.hbs | 6 +++--- .../templates/frontend/fresh/package.json.hbs | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/cli/test/frontend.test.ts b/apps/cli/test/frontend.test.ts index 9a21eccd0..972c18887 100644 --- a/apps/cli/test/frontend.test.ts +++ b/apps/cli/test/frontend.test.ts @@ -476,8 +476,13 @@ describe("Frontend Configurations", () => { const legacyLayout = Bun.file(`${result.projectDir}/apps/web/src/routes/_layout.tsx`); expect(denoJson).toContain('"fresh": "jsr:@fresh/core@^2.2.0"'); - expect(denoJson).toContain('"build": "vite build"'); - expect(webPkg.scripts["check-types"]).toBe("deno check"); + expect(denoJson).toContain( + '"build": "deno run --node-modules-dir=auto -A npm:vite build"', + ); + expect(webPkg.scripts["check-types"]).toBe( + "deno check --node-modules-dir=auto main.ts client.ts", + ); + expect(webPkg.dependencies["@opentelemetry/api"]).toBeDefined(); expect(webPkg.dependencies["@preact/signals"]).toBeDefined(); expect(webPkg.dependencies.preact).toBeDefined(); expect(webPkg.dependencies.vite).toBeDefined(); diff --git a/packages/template-generator/templates/frontend/fresh/deno.json.hbs b/packages/template-generator/templates/frontend/fresh/deno.json.hbs index 559a3ece3..814a1915a 100644 --- a/packages/template-generator/templates/frontend/fresh/deno.json.hbs +++ b/packages/template-generator/templates/frontend/fresh/deno.json.hbs @@ -1,9 +1,9 @@ { "lock": false, "tasks": { - "check": "deno fmt --check . && deno lint . && deno check", - "dev": "vite", - "build": "vite build", + "check": "deno fmt --check . && deno lint . && deno check --node-modules-dir=auto main.ts client.ts", + "dev": "deno run --node-modules-dir=auto -A npm:vite", + "build": "deno run --node-modules-dir=auto -A npm:vite build", "start": "deno serve -A _fresh/server.js", "update": "deno run -A -r jsr:@fresh/update ." }, diff --git a/packages/template-generator/templates/frontend/fresh/package.json.hbs b/packages/template-generator/templates/frontend/fresh/package.json.hbs index ccee4cbc4..f38b79a4f 100644 --- a/packages/template-generator/templates/frontend/fresh/package.json.hbs +++ b/packages/template-generator/templates/frontend/fresh/package.json.hbs @@ -8,9 +8,10 @@ "build": "deno task build", "start": "deno task start", "check": "deno task check", - "check-types": "deno check" + "check-types": "deno check --node-modules-dir=auto main.ts client.ts" }, "dependencies": { + "@opentelemetry/api": "^1.9.0", "@preact/signals": "^2.5.0", "preact": "^10.27.2", "vite": "^7.3.1"{{#if (eq cssFramework "tailwind")}}, From 447a2a416bd02014f0038f0c91b2173719400001 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 00:52:27 +0300 Subject: [PATCH 09/33] Fix smoke test template regressions --- apps/cli/test/cms.test.ts | 9 +++++++-- apps/cli/test/java-ecosystem.test.ts | 3 +++ .../cms/sanity/web/astro/src/sanity/lib/image.ts.hbs | 2 +- .../cms/sanity/web/next/src/sanity/lib/image.ts.hbs | 2 +- .../cms/sanity/web/nuxt/src/sanity/lib/image.ts.hbs | 2 +- .../cms/sanity/web/react/src/sanity/lib/image.ts.hbs | 2 +- .../cms/sanity/web/svelte/src/sanity/lib/image.ts.hbs | 2 +- .../templates/java-base/build.gradle.kts.hbs | 1 + 8 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/cli/test/cms.test.ts b/apps/cli/test/cms.test.ts index d694f4685..a5acd8189 100644 --- a/apps/cli/test/cms.test.ts +++ b/apps/cli/test/cms.test.ts @@ -1,4 +1,4 @@ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createCustomConfig, expectSuccess, runTRPCTest } from "./test-utils"; @@ -488,6 +488,12 @@ describe("CMS Options", () => { }), ); expectSuccess(result); + + const imageHelper = await Bun.file( + `${result.projectDir}/apps/web/src/sanity/lib/image.ts`, + ).text(); + expect(imageHelper).toContain('import type { SanityImageSource } from "@sanity/image-url";'); + expect(imageHelper).not.toContain("@sanity/image-url/lib/types/types"); }); test("sanity with Astro", async () => { @@ -826,4 +832,3 @@ describe("CMS Options", () => { }); }); }); - diff --git a/apps/cli/test/java-ecosystem.test.ts b/apps/cli/test/java-ecosystem.test.ts index b0c987a45..3560780eb 100644 --- a/apps/cli/test/java-ecosystem.test.ts +++ b/apps/cli/test/java-ecosystem.test.ts @@ -477,6 +477,9 @@ describe("Java Ecosystem", () => { expect(gradleContent).toContain("application"); expect(gradleContent).toContain('mainClass = "com.example.javaplaingradletests.Application"'); expect(gradleContent).toContain('testImplementation(platform("org.junit:junit-bom:5.12.2"))'); + expect(gradleContent).toContain( + 'testRuntimeOnly("org.junit.platform:junit-platform-launcher")', + ); expect(gradleContent).toContain( 'testImplementation("org.mockito:mockito-junit-jupiter:5.23.0")', ); diff --git a/packages/template-generator/templates/cms/sanity/web/astro/src/sanity/lib/image.ts.hbs b/packages/template-generator/templates/cms/sanity/web/astro/src/sanity/lib/image.ts.hbs index e9c8c1a7c..f57cbd613 100644 --- a/packages/template-generator/templates/cms/sanity/web/astro/src/sanity/lib/image.ts.hbs +++ b/packages/template-generator/templates/cms/sanity/web/astro/src/sanity/lib/image.ts.hbs @@ -1,5 +1,5 @@ import createImageUrlBuilder from "@sanity/image-url"; -import type { SanityImageSource } from "@sanity/image-url/lib/types/types"; +import type { SanityImageSource } from "@sanity/image-url"; import { dataset, projectId } from "../env"; diff --git a/packages/template-generator/templates/cms/sanity/web/next/src/sanity/lib/image.ts.hbs b/packages/template-generator/templates/cms/sanity/web/next/src/sanity/lib/image.ts.hbs index e9c8c1a7c..f57cbd613 100644 --- a/packages/template-generator/templates/cms/sanity/web/next/src/sanity/lib/image.ts.hbs +++ b/packages/template-generator/templates/cms/sanity/web/next/src/sanity/lib/image.ts.hbs @@ -1,5 +1,5 @@ import createImageUrlBuilder from "@sanity/image-url"; -import type { SanityImageSource } from "@sanity/image-url/lib/types/types"; +import type { SanityImageSource } from "@sanity/image-url"; import { dataset, projectId } from "../env"; diff --git a/packages/template-generator/templates/cms/sanity/web/nuxt/src/sanity/lib/image.ts.hbs b/packages/template-generator/templates/cms/sanity/web/nuxt/src/sanity/lib/image.ts.hbs index e9c8c1a7c..f57cbd613 100644 --- a/packages/template-generator/templates/cms/sanity/web/nuxt/src/sanity/lib/image.ts.hbs +++ b/packages/template-generator/templates/cms/sanity/web/nuxt/src/sanity/lib/image.ts.hbs @@ -1,5 +1,5 @@ import createImageUrlBuilder from "@sanity/image-url"; -import type { SanityImageSource } from "@sanity/image-url/lib/types/types"; +import type { SanityImageSource } from "@sanity/image-url"; import { dataset, projectId } from "../env"; diff --git a/packages/template-generator/templates/cms/sanity/web/react/src/sanity/lib/image.ts.hbs b/packages/template-generator/templates/cms/sanity/web/react/src/sanity/lib/image.ts.hbs index e9c8c1a7c..f57cbd613 100644 --- a/packages/template-generator/templates/cms/sanity/web/react/src/sanity/lib/image.ts.hbs +++ b/packages/template-generator/templates/cms/sanity/web/react/src/sanity/lib/image.ts.hbs @@ -1,5 +1,5 @@ import createImageUrlBuilder from "@sanity/image-url"; -import type { SanityImageSource } from "@sanity/image-url/lib/types/types"; +import type { SanityImageSource } from "@sanity/image-url"; import { dataset, projectId } from "../env"; diff --git a/packages/template-generator/templates/cms/sanity/web/svelte/src/sanity/lib/image.ts.hbs b/packages/template-generator/templates/cms/sanity/web/svelte/src/sanity/lib/image.ts.hbs index e9c8c1a7c..f57cbd613 100644 --- a/packages/template-generator/templates/cms/sanity/web/svelte/src/sanity/lib/image.ts.hbs +++ b/packages/template-generator/templates/cms/sanity/web/svelte/src/sanity/lib/image.ts.hbs @@ -1,5 +1,5 @@ import createImageUrlBuilder from "@sanity/image-url"; -import type { SanityImageSource } from "@sanity/image-url/lib/types/types"; +import type { SanityImageSource } from "@sanity/image-url"; import { dataset, projectId } from "../env"; 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 3251b9e60..b4898a641 100644 --- a/packages/template-generator/templates/java-base/build.gradle.kts.hbs +++ b/packages/template-generator/templates/java-base/build.gradle.kts.hbs @@ -184,6 +184,7 @@ dependencies { {{#if hasJavaTests}} testImplementation(platform("org.junit:junit-bom:5.12.2")) testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") {{#if hasJavaAssertj}} testImplementation("org.assertj:assertj-core:3.27.7") {{/if}} From 36ebdedaf6fbb900a97b4a9000e9ad06d7de82d3 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 00:03:58 +0300 Subject: [PATCH 10/33] Fix Fresh Deno workspace config --- .github/workflows/e2e-test.yaml | 21 ++++++++++ .github/workflows/pr-size.yml | 2 +- apps/cli/test/frontend.test.ts | 7 ++++ .../templates/base/deno.json.hbs | 9 +++++ .../templates/frontend/fresh/deno.json.hbs | 6 +-- testing/lib/generate-combos/options.ts | 38 +++++++++++++++++++ testing/lib/generate-combos/render.test.ts | 29 ++++++++++++++ testing/lib/generate-combos/render.ts | 11 ++++++ testing/lib/generate-combos/types.ts | 7 ++++ testing/lib/presets.test.ts | 8 ++++ 10 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 packages/template-generator/templates/base/deno.json.hbs create mode 100644 testing/lib/generate-combos/render.test.ts diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index 90a069bd5..1cafc0269 100644 --- a/.github/workflows/e2e-test.yaml +++ b/.github/workflows/e2e-test.yaml @@ -43,6 +43,13 @@ jobs: with: node-version: "22" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - uses: astral-sh/setup-uv@v5 + - uses: actions/cache@v4 with: path: | @@ -90,6 +97,13 @@ jobs: with: node-version: "22" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - uses: astral-sh/setup-uv@v5 + - uses: actions/cache@v4 with: path: | @@ -148,6 +162,13 @@ jobs: with: node-version: "22" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - uses: astral-sh/setup-uv@v5 + - uses: actions/cache@v4 with: path: | diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 283ff1898..469599298 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -61,7 +61,7 @@ jobs: runs-on: ubuntu-24.04 permissions: contents: read - issues: read + issues: write pull-requests: write concurrency: group: pr-size-${{ github.event.pull_request.number }} diff --git a/apps/cli/test/frontend.test.ts b/apps/cli/test/frontend.test.ts index 49834b67d..070fc2578 100644 --- a/apps/cli/test/frontend.test.ts +++ b/apps/cli/test/frontend.test.ts @@ -466,6 +466,7 @@ describe("Frontend Configurations", () => { expectSuccess(result); if (result.projectDir) { + const rootDenoJson = await Bun.file(`${result.projectDir}/deno.json`).text(); const denoJson = await Bun.file(`${result.projectDir}/apps/web/deno.json`).text(); const webPkg = await Bun.file(`${result.projectDir}/apps/web/package.json`).json(); const readme = await Bun.file(`${result.projectDir}/README.md`).text(); @@ -475,8 +476,14 @@ describe("Frontend Configurations", () => { const modernApp = Bun.file(`${result.projectDir}/apps/web/routes/_app.tsx`); const legacyLayout = Bun.file(`${result.projectDir}/apps/web/src/routes/_layout.tsx`); + expect(rootDenoJson).toContain('"workspace"'); + expect(rootDenoJson).toContain('"./apps/web"'); + expect(rootDenoJson).toContain('"nodeModulesDir": "auto"'); expect(denoJson).toContain('"fresh": "jsr:@fresh/core@^2.2.0"'); + expect(denoJson).toContain('"jsxImportSource": "npm:preact@^10.27.2"'); expect(denoJson).toContain('"build": "vite build"'); + expect(denoJson).not.toContain('"lock"'); + expect(denoJson).not.toContain('"nodeModulesDir"'); expect(webPkg.scripts["check-types"]).toBe("deno check"); expect(readme).toContain("http://localhost:5173"); expect(await viteConfig.exists()).toBe(true); diff --git a/packages/template-generator/templates/base/deno.json.hbs b/packages/template-generator/templates/base/deno.json.hbs new file mode 100644 index 000000000..6a6837650 --- /dev/null +++ b/packages/template-generator/templates/base/deno.json.hbs @@ -0,0 +1,9 @@ +{{#if (includes frontend "fresh")}} +{ + "workspace": [ + "./apps/web" + ], + "lock": false, + "nodeModulesDir": "auto" +} +{{/if}} diff --git a/packages/template-generator/templates/frontend/fresh/deno.json.hbs b/packages/template-generator/templates/frontend/fresh/deno.json.hbs index 559a3ece3..4d4723746 100644 --- a/packages/template-generator/templates/frontend/fresh/deno.json.hbs +++ b/packages/template-generator/templates/frontend/fresh/deno.json.hbs @@ -1,5 +1,4 @@ { - "lock": false, "tasks": { "check": "deno fmt --check . && deno lint . && deno check", "dev": "vite", @@ -37,7 +36,7 @@ "deno.ns" ], "jsx": "precompile", - "jsxImportSource": "preact", + "jsxImportSource": "npm:preact@^10.27.2", "jsxPrecompileSkipElements": [ "a", "img", @@ -57,6 +56,5 @@ "types": [ "vite/client" ] - }, - "nodeModulesDir": "auto" + } } diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts index 6068c68b7..ef6efe356 100644 --- a/testing/lib/generate-combos/options.ts +++ b/testing/lib/generate-combos/options.ts @@ -533,6 +533,42 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje }; } +function applyDerivedMobileDefaults(config: ProjectConfig, providedFlags: Set) { + if (config.ecosystem !== "typescript") return; + + const hasNativeFrontend = config.frontend.some((frontend) => frontend.startsWith("native-")); + if (!hasNativeFrontend) { + config.mobileNavigation = "none"; + config.mobileUI = "none"; + config.mobileStorage = "none"; + config.mobileTesting = "none"; + config.mobilePush = "none"; + config.mobileOTA = "none"; + config.mobileDeepLinking = "none"; + return; + } + + if (!providedFlags.has("mobileNavigation") && config.mobileNavigation === "none") { + config.mobileNavigation = "expo-router"; + } + + if (!providedFlags.has("mobileUI")) { + if (config.frontend.includes("native-uniwind")) { + config.mobileUI = "uniwind"; + } else if (config.frontend.includes("native-unistyles")) { + config.mobileUI = "unistyles"; + } + } + + if ( + !providedFlags.has("mobileDeepLinking") && + config.mobileDeepLinking === "none" && + config.auth !== "none" + ) { + config.mobileDeepLinking = "expo-linking"; + } +} + function validateDraft(draft: CandidateDraft, projectName: string): ProjectConfig { const providedFlags = buildProvidedFlags(draft.options); const processed = processFlags({ ...draft.options, projectName } as CLIInput, projectName); @@ -544,6 +580,8 @@ function validateDraft(draft: CandidateDraft, projectName: string): ProjectConfi projectDir: path.resolve(process.cwd(), projectName), } as ProjectConfig; + applyDerivedMobileDefaults(config, providedFlags); + runWithContext({ silent: true }, () => { validateFullConfig(config, providedFlags, { ...draft.options, projectName } as CLIInput); }); diff --git a/testing/lib/generate-combos/render.test.ts b/testing/lib/generate-combos/render.test.ts new file mode 100644 index 000000000..876ea2b90 --- /dev/null +++ b/testing/lib/generate-combos/render.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; +import { createCliDefaultProjectConfigBase, type ProjectConfig } from "@better-fullstack/types"; + +import { buildCommand } from "./render"; + +describe("smoke combo command rendering", () => { + it("includes mobile flags for TypeScript commands", () => { + const config: ProjectConfig = { + ...createCliDefaultProjectConfigBase("bun"), + projectName: "mobile-smoke", + relativePath: "mobile-smoke", + projectDir: "/tmp/mobile-smoke", + frontend: ["solid-start", "native-unistyles"], + mobileNavigation: "expo-router", + mobileUI: "unistyles", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "none", + git: false, + install: false, + }; + + expect(buildCommand("mobile-smoke", config)).toContain( + "--mobile-navigation expo-router --mobile-ui unistyles --mobile-storage none --mobile-testing none --mobile-push none --mobile-ota none --mobile-deep-linking none", + ); + }); +}); diff --git a/testing/lib/generate-combos/render.ts b/testing/lib/generate-combos/render.ts index b4826c475..13783012b 100644 --- a/testing/lib/generate-combos/render.ts +++ b/testing/lib/generate-combos/render.ts @@ -13,6 +13,10 @@ function renderFlag(flag: string, value: string | readonly string[]): string { return `--${flag} ${formatted}`; } +function withExplicitScalar(value: string | undefined, fallback = "none"): string { + return value ?? fallback; +} + export function formatNameFromFingerprint(fingerprint: TemplateFingerprint): string { const ecosystem = typeof fingerprint.ecosystem === "string" ? fingerprint.ecosystem : "combo"; @@ -131,6 +135,13 @@ export function buildCommand(name: string, config: ProjectConfig): string { ["i18n", config.i18n], ["search", config.search], ["file-storage", config.fileStorage], + ["mobile-navigation", withExplicitScalar(config.mobileNavigation)], + ["mobile-ui", withExplicitScalar(config.mobileUI)], + ["mobile-storage", withExplicitScalar(config.mobileStorage)], + ["mobile-testing", withExplicitScalar(config.mobileTesting)], + ["mobile-push", withExplicitScalar(config.mobilePush)], + ["mobile-ota", withExplicitScalar(config.mobileOTA)], + ["mobile-deep-linking", withExplicitScalar(config.mobileDeepLinking)], ["web-deploy", config.webDeploy], ["server-deploy", config.serverDeploy], ]; diff --git a/testing/lib/generate-combos/types.ts b/testing/lib/generate-combos/types.ts index 5f6fb1c1c..299d745f6 100644 --- a/testing/lib/generate-combos/types.ts +++ b/testing/lib/generate-combos/types.ts @@ -71,6 +71,13 @@ export const TEMPLATE_FINGERPRINT_KEYS = [ "caching", "search", "fileStorage", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", "addons", "examples", "aiDocs", diff --git a/testing/lib/presets.test.ts b/testing/lib/presets.test.ts index e14fd9828..fea870ba5 100644 --- a/testing/lib/presets.test.ts +++ b/testing/lib/presets.test.ts @@ -30,6 +30,8 @@ const PR_BROAD_PRESET_NAMES = [ "preset-react-vite-hono", "preset-solid-start-express", "preset-angular-fets", + "preset-vinext-minimal", + "preset-vinext-basic", ]; describe("preset groups", () => { @@ -51,4 +53,10 @@ describe("preset groups", () => { ...PR_BROAD_PRESET_NAMES, ]); }); + + it("renders complete CLI commands for all presets", () => { + for (const combo of getPresetCombos("all")) { + expect(combo.command).not.toContain(" undefined"); + } + }); }); From dcc37e2d24cbf1fff51b5f5cafffdf5c3e62b657 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 11:27:31 +0300 Subject: [PATCH 11/33] Address Elixir review feedback --- .../template-snapshots.test.ts.snap | 14 +--- .../virtual-generator-regressions.test.ts | 69 +++++++++++++++++++ .../src/core/template-processor.ts | 50 ++++++++++---- .../src/template-handlers/elixir-base.ts | 35 +++------- .../live/item_live/index.ex.hbs | 18 +++++ 5 files changed, 135 insertions(+), 51 deletions(-) diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 1b8b77116..4f2d27e3c 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -15549,7 +15549,6 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f "lib/snapshot_elixir_phoenix_ecto_rest/catalog/item.ex", "lib/snapshot_elixir_phoenix_ecto_rest/http_client.ex", "lib/snapshot_elixir_phoenix_ecto_rest/repo.ex", - "lib/snapshot_elixir_phoenix_ecto_rest/scheduler.ex", "lib/snapshot_elixir_phoenix_ecto_rest_web.ex", "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/room_channel.ex", "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/user_socket.ex", @@ -15592,7 +15591,6 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f "lib/snapshot_elixir_phoenix_liveview_full/http_client.ex", "lib/snapshot_elixir_phoenix_liveview_full/mailer.ex", "lib/snapshot_elixir_phoenix_liveview_full/repo.ex", - "lib/snapshot_elixir_phoenix_liveview_full/scheduler.ex", "lib/snapshot_elixir_phoenix_liveview_full/workers/sample_worker.ex", "lib/snapshot_elixir_phoenix_liveview_full_web.ex", "lib/snapshot_elixir_phoenix_liveview_full_web/channels/presence.ex", @@ -15624,7 +15622,7 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-ecto-rest 1`] = ` { - "fileCount": 35, + "fileCount": 34, "files": [ { "content": @@ -15737,10 +15735,6 @@ CORS_ORIGIN=http://localhost:3001" "content": "[exists]", "path": "lib/snapshot_elixir_phoenix_ecto_rest/repo.ex", }, - { - "content": "[exists]", - "path": "lib/snapshot_elixir_phoenix_ecto_rest/scheduler.ex", - }, { "content": "[exists]", "path": "mix.exs", @@ -15779,7 +15773,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-liveview-full 1`] = ` { - "fileCount": 45, + "fileCount": 44, "files": [ { "content": @@ -15924,10 +15918,6 @@ CORS_ORIGIN=http://localhost:3001" "content": "[exists]", "path": "lib/snapshot_elixir_phoenix_liveview_full/repo.ex", }, - { - "content": "[exists]", - "path": "lib/snapshot_elixir_phoenix_liveview_full/scheduler.ex", - }, { "content": "[exists]", "path": "lib/snapshot_elixir_phoenix_liveview_full/workers/sample_worker.ex", diff --git a/apps/cli/test/virtual-generator-regressions.test.ts b/apps/cli/test/virtual-generator-regressions.test.ts index 20e7ec2b5..b4dd0160f 100644 --- a/apps/cli/test/virtual-generator-regressions.test.ts +++ b/apps/cli/test/virtual-generator-regressions.test.ts @@ -24,6 +24,23 @@ function readJsonFromTree( return undefined; } +function readTextFromTree( + tree: NonNullable>["tree"]>, + targetPath: string, +) { + const stack = [...tree.root.children]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === "file" && node.path === targetPath) { + return node.content; + } + if (node.type === "directory") { + stack.push(...node.children); + } + } + return undefined; +} + describe("Virtual Generator Regressions", () => { const packageManagers = ["npm", "pnpm", "bun", "yarn"] as const; @@ -111,4 +128,56 @@ describe("Virtual Generator Regressions", () => { expect(rootPackageJson?.scripts?.["ai:models"]).toBe("ai models"); expect(rootPackageJson?.scripts?.["ai:completions"]).toBe("ai completions"); }); + + it("omits the Quantum scheduler unless Quantum jobs are selected", async () => { + const result = await createVirtual({ + projectName: "elixir-no-quantum", + ecosystem: "elixir", + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + elixirJobs: "none", + }); + + expect(result.success).toBe(true); + expect(readTextFromTree(result.tree!, "lib/elixir_no_quantum/scheduler.ex")).toBeUndefined(); + }); + + it("keeps Phoenix LiveView demos self-contained without Ecto", async () => { + const result = await createVirtual({ + projectName: "elixir-live-no-ecto", + ecosystem: "elixir", + elixirWebFramework: "phoenix-live-view", + elixirOrm: "none", + elixirApi: "none", + elixirRealtime: "live-view-streams", + }); + + expect(result.success).toBe(true); + + const liveView = readTextFromTree( + result.tree!, + "lib/elixir_live_no_ecto_web/live/item_live/index.ex", + ); + expect(liveView).toContain("System.unique_integer"); + expect(liveView).not.toContain("Catalog."); + expect(readTextFromTree(result.tree!, "lib/elixir_live_no_ecto/catalog.ex")).toBeUndefined(); + }); + + it("normalizes Elixir app and module names that start with digits", async () => { + const result = await createVirtual({ + projectName: "123-app", + ecosystem: "elixir", + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + }); + + expect(result.success).toBe(true); + + const mixProject = readTextFromTree(result.tree!, "mix.exs"); + expect(mixProject).toContain("defmodule App123App.MixProject do"); + expect(mixProject).toContain("app: :app_123_app"); + expect(readTextFromTree(result.tree!, "lib/app_123_app/application.ex")).toContain( + "defmodule App123App.Application do", + ); + }); }); diff --git a/packages/template-generator/src/core/template-processor.ts b/packages/template-generator/src/core/template-processor.ts index 0bf1a0f2b..028c522fd 100644 --- a/packages/template-generator/src/core/template-processor.ts +++ b/packages/template-generator/src/core/template-processor.ts @@ -73,26 +73,50 @@ Handlebars.registerHelper("projectNameWithClosingBrace", function (this: Project return `${this.projectName ?? ""}}`; }); -Handlebars.registerHelper("elixirAppName", function (this: ProjectConfig) { - return String(this.projectName ?? "my_app") - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, "") || "my_app"; -}); +export function normalizeElixirAppName(projectName?: string): string { + const rawName = String(projectName ?? "my_app").toLowerCase(); + const parts: string[] = []; + let pendingSeparator = false; + + for (const char of rawName) { + const code = char.charCodeAt(0); + const isLowercaseLetter = code >= 97 && code <= 122; + const isDigit = code >= 48 && code <= 57; + + if (isLowercaseLetter || isDigit) { + if (pendingSeparator && parts.length > 0) { + parts.push("_"); + } + parts.push(char); + pendingSeparator = false; + } else { + pendingSeparator = parts.length > 0; + } + } -Handlebars.registerHelper("elixirModuleName", function (this: ProjectConfig) { - const appName = String(this.projectName ?? "my_app") - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]+/g, "_") - .replace(/^_+|_+$/g, "") || "my_app"; + const appName = parts.join("") || "my_app"; + const firstCode = appName.charCodeAt(0); + const startsWithLetter = firstCode >= 97 && firstCode <= 122; + + return startsWithLetter ? appName : `app_${appName}`; +} + +export function elixirModuleName(projectName?: string): string { + const appName = normalizeElixirAppName(projectName); return appName .split("_") .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(""); +} + +Handlebars.registerHelper("elixirAppName", function (this: ProjectConfig) { + return normalizeElixirAppName(this.projectName); +}); + +Handlebars.registerHelper("elixirModuleName", function (this: ProjectConfig) { + return elixirModuleName(this.projectName); }); /** Returns the CSS font-family string for the chosen shadcn font. */ diff --git a/packages/template-generator/src/template-handlers/elixir-base.ts b/packages/template-generator/src/template-handlers/elixir-base.ts index a9fa9e817..b27edad50 100644 --- a/packages/template-generator/src/template-handlers/elixir-base.ts +++ b/packages/template-generator/src/template-handlers/elixir-base.ts @@ -3,31 +3,12 @@ import type { ProjectConfig } from "@better-fullstack/types"; import type { VirtualFileSystem } from "../core/virtual-fs"; import type { TemplateData } from "./utils"; -import { isBinaryFile, processTemplateString, transformFilename } from "../core/template-processor"; - -function getElixirAppName(config: ProjectConfig) { - const rawName = String(config.projectName ?? "my_app").toLowerCase(); - const parts: string[] = []; - let pendingSeparator = false; - - for (const char of rawName) { - const code = char.charCodeAt(0); - const isLowercaseLetter = code >= 97 && code <= 122; - const isDigit = code >= 48 && code <= 57; - - if (isLowercaseLetter || isDigit) { - if (pendingSeparator && parts.length > 0) { - parts.push("_"); - } - parts.push(char); - pendingSeparator = false; - } else { - pendingSeparator = parts.length > 0; - } - } - - return parts.join("") || "my_app"; -} +import { + isBinaryFile, + normalizeElixirAppName, + processTemplateString, + transformFilename, +} from "../core/template-processor"; export async function processElixirBaseTemplate( vfs: VirtualFileSystem, @@ -43,6 +24,7 @@ export async function processElixirBaseTemplate( const hasChannels = config.elixirRealtime === "channels" || config.elixirRealtime === "presence"; const hasPresence = config.elixirRealtime === "presence"; const hasOban = config.elixirJobs === "oban"; + const hasQuantum = config.elixirJobs === "quantum"; const hasAbsinthe = config.elixirApi === "absinthe" && hasEcto; const hasEmail = config.elixirEmail === "swoosh"; const hasDocker = ["docker", "fly", "gigalixir", "mix-release"].includes(config.elixirDeploy); @@ -58,6 +40,7 @@ export async function processElixirBaseTemplate( if (!hasPresence && templatePath.includes("/channels/presence")) continue; if (!hasOban && templatePath.includes("/workers/")) continue; if (!hasOban && templatePath.includes("add_oban_jobs")) continue; + if (!hasQuantum && templatePath.includes("/scheduler.ex")) continue; if (!hasAbsinthe && templatePath.includes("/graphql/")) continue; if (!hasEmail && templatePath.includes("/mailer.ex")) continue; if (!hasDocker && templatePath.includes("Dockerfile")) continue; @@ -66,7 +49,7 @@ export async function processElixirBaseTemplate( const relativePath = templatePath.slice(prefix.length); const outputPath = transformFilename(relativePath).replace( /__elixirAppName__/g, - getElixirAppName(config), + normalizeElixirAppName(config.projectName), ); let processedContent: string; diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs index 012f31332..fa17f4741 100644 --- a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs @@ -1,20 +1,29 @@ defmodule {{elixirModuleName}}Web.ItemLive.Index do use {{elixirModuleName}}Web, :live_view +{{#if (ne elixirOrm "none")}} alias {{elixirModuleName}}.Catalog +{{/if}} @impl true def mount(_params, _session, socket) do socket = socket |> assign(:form, to_form(%{"name" => "", "description" => ""})) +{{#if (ne elixirOrm "none")}} |> stream(:items, Catalog.list_items()) +{{else}} + |> stream(:items, [ + %{id: 1, name: "Phoenix", description: "Generated by Better Fullstack"} + ]) +{{/if}} {:ok, socket} end @impl true def handle_event("create", %{"name" => name, "description" => description}, socket) do +{{#if (ne elixirOrm "none")}} case Catalog.create_item(%{"name" => name, "description" => description}) do {:ok, item} -> {:noreply, stream_insert(socket, :items, item)} @@ -22,6 +31,15 @@ defmodule {{elixirModuleName}}Web.ItemLive.Index do {:error, changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end +{{else}} + item = %{ + id: System.unique_integer([:positive]), + name: name, + description: description + } + + {:noreply, stream_insert(socket, :items, item)} +{{/if}} end @impl true From 4f64670093774054d7c1d519d945389ecff61679 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 12:20:15 +0300 Subject: [PATCH 12/33] Make React Native a standalone ecosystem --- README.md | 8 +- apps/cli/README.md | 4 +- apps/cli/src/create-command-input.ts | 4 +- apps/cli/src/helpers/core/create-project.ts | 7 +- apps/cli/src/index.ts | 20 ++-- apps/cli/src/mcp.ts | 37 ++++--- apps/cli/src/prompts/auth.ts | 4 +- apps/cli/src/prompts/config-prompts.ts | 40 +++++-- apps/cli/src/prompts/ecosystem.ts | 12 ++- apps/cli/src/prompts/frontend.ts | 17 +++ apps/cli/src/utils/config-validation.ts | 26 +++++ .../utils/generate-reproducible-command.ts | 26 +++++ apps/cli/test/go-language.test.ts | 2 +- apps/cli/test/java-ecosystem.test.ts | 2 +- apps/cli/test/python-language.test.ts | 2 +- apps/cli/test/rust-ecosystem.test.ts | 2 +- apps/web/content/docs/cli/create.mdx | 26 +++-- apps/web/content/docs/ecosystems/index.mdx | 5 +- apps/web/content/docs/ecosystems/meta.json | 2 +- .../content/docs/ecosystems/react-native.mdx | 61 +++++++++++ .../content/docs/ecosystems/typescript.mdx | 10 +- apps/web/content/docs/index.mdx | 2 +- .../content/docs/reference/options/index.mdx | 3 +- .../content/docs/reference/options/meta.json | 2 +- .../docs/reference/options/react-native.mdx | 24 +++++ .../docs/reference/options/typescript.mdx | 10 +- apps/web/content/guides/index.mdx | 2 +- .../docs/mdx/compatibility-matrix.tsx | 20 ++++ .../components/home/combinations-section.tsx | 2 +- .../src/components/home/features-section.tsx | 8 +- apps/web/src/lib/constant.ts | 36 +++++-- apps/web/src/lib/llms.ts | 1 + apps/web/src/lib/project-stats.generated.ts | 11 +- apps/web/src/lib/stack-utils.ts | 27 +++-- apps/web/test/go-ecosystem.test.ts | 6 +- apps/web/test/java-ecosystem.test.ts | 6 +- apps/web/test/python-ecosystem.test.ts | 4 +- apps/web/test/rust-ecosystem.test.ts | 4 +- apps/web/test/stack-command-parity.test.ts | 11 +- packages/template-generator/src/generator.ts | 4 +- .../components/mobile-ui-provider.tsx.hbs | 6 +- packages/types/src/compatibility.ts | 102 +++++++++++++++++- packages/types/src/schemas.ts | 4 +- packages/types/src/stack-translation.ts | 51 ++++++++- testing/lib/generate-combos/options.ts | 53 ++++++++- testing/lib/generate-combos/render.ts | 29 +++++ testing/lib/generate-combos/types.ts | 3 +- testing/smoke-test.ts | 6 +- 48 files changed, 625 insertions(+), 129 deletions(-) create mode 100644 apps/web/content/docs/ecosystems/react-native.mdx create mode 100644 apps/web/content/docs/reference/options/react-native.mdx diff --git a/README.md b/README.md index f589edd54..e8d722c3d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@
-**Scaffold production-ready fullstack apps in seconds. Browse 450+ tools across five ecosystems — the CLI wires everything together.** +**Scaffold production-ready fullstack apps in seconds. Browse 450+ tools across six ecosystems — the CLI wires everything together.**
@@ -35,7 +35,7 @@ Most scaffolding tools lock you into one framework and one opinion. Better Fullstack doesn't. - **450+ tools** — frontend, backend, database, auth, payments, AI, DevOps, and more -- **5 ecosystems** — TypeScript, Rust, Python, Go, Java — with more coming +- **6 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java — with more coming - **Visual builder** — configure your stack in the browser, get a ready-to-run CLI command - **Wired for you** — no manual glue code; every picked integration is preconfigured and working out of the box @@ -113,8 +113,8 @@ Better Fullstack is organized around the decisions that matter: pick an ecosyste Only the relevant options surface for the stack you pick. -5 ecosystems
-TypeScript, Rust, Python, Go, Java. +6 ecosystems
+TypeScript, React Native, Rust, Python, Go, Java. One command
diff --git a/apps/cli/README.md b/apps/cli/README.md index 45f4e37af..1127ae7f6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -32,7 +32,7 @@ Configure your stack visually — pick every option from a UI, preview your choi ## Features - **425 options** — frontend, backend, database, auth, payments, AI, DevOps, and more -- **5 ecosystems** — TypeScript, Rust, Python, Go, Java +- **6 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java - **Visual builder** — configure your stack in the browser - **Wired for you** — every picked integration is preconfigured and working out of the box @@ -42,7 +42,7 @@ Configure your stack visually — pick every option from a UI, preview your choi --yes # Accept all defaults --yolo # Scaffold a random stack — good for exploring --template # Use a preset (t3, mern, pern, uniwind) ---ecosystem # Start in typescript, rust, python, go, or java mode +--ecosystem # Start in typescript, react-native, rust, python, go, or java mode --version-channel # Dependency channel: stable, latest, beta --no-git # Skip git initialization --no-install # Skip dependency installation diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts index ba6349dda..668c4484f 100644 --- a/apps/cli/src/create-command-input.ts +++ b/apps/cli/src/create-command-input.ts @@ -105,7 +105,9 @@ export const CreateCommandOptionsSchema = z.object({ .optional() .default(false) .describe("Preview generated file tree without writing to disk"), - ecosystem: EcosystemSchema.optional().describe("Language ecosystem (typescript, rust, python, go, or java)"), + ecosystem: EcosystemSchema.optional().describe( + "Language ecosystem (typescript, react-native, rust, python, go, or java)", + ), database: DatabaseSchema.optional(), orm: ORMSchema.optional(), auth: AuthSchema.optional(), diff --git a/apps/cli/src/helpers/core/create-project.ts b/apps/cli/src/helpers/core/create-project.ts index aca78c577..c12d65182 100644 --- a/apps/cli/src/helpers/core/create-project.ts +++ b/apps/cli/src/helpers/core/create-project.ts @@ -66,8 +66,11 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj if (!isSilent()) log.success("Project template successfully scaffolded!"); - // Skip npm/pnpm/bun install for Rust/Python projects (they use cargo/uv) - if (options.install && options.ecosystem === "typescript") { + // Skip npm/pnpm/bun install for Rust/Python/Go/Java projects (they use native toolchains) + if ( + options.install && + (options.ecosystem === "typescript" || options.ecosystem === "react-native") + ) { await installDependencies({ projectDir, packageManager: options.packageManager, diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index dad812b53..7320f372e 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -416,16 +416,18 @@ export async function createVirtual( options: Partial>, ): Promise<{ success: boolean; tree?: VirtualFileTree; error?: string }> { try { + const ecosystem = options.ecosystem || "typescript"; + const isReactNative = ecosystem === "react-native"; const config: ProjectConfig = { + ecosystem, projectName: options.projectName || "my-project", projectDir: "/virtual", relativePath: "./virtual", - ecosystem: options.ecosystem || "typescript", database: options.database || "none", orm: options.orm || "none", - backend: options.backend || "hono", - runtime: options.runtime || "bun", - frontend: options.frontend || ["tanstack-router"], + backend: options.backend || (isReactNative ? "none" : "hono"), + runtime: options.runtime || (isReactNative ? "none" : "bun"), + frontend: options.frontend || (isReactNative ? ["native-bare"] : ["tanstack-router"]), addons: options.addons || [], examples: options.examples || [], auth: options.auth || "none", @@ -438,11 +440,11 @@ export async function createVirtual( versionChannel: options.versionChannel || "stable", install: false, dbSetup: options.dbSetup || "none", - api: options.api || "trpc", + api: options.api || (isReactNative ? "none" : "trpc"), webDeploy: options.webDeploy || "none", serverDeploy: options.serverDeploy || "none", - cssFramework: options.cssFramework || "tailwind", - uiLibrary: options.uiLibrary || "shadcn-ui", + cssFramework: options.cssFramework || (isReactNative ? "none" : "tailwind"), + uiLibrary: options.uiLibrary || (isReactNative ? "none" : "shadcn-ui"), shadcnBase: options.shadcnBase ?? "radix", shadcnStyle: options.shadcnStyle ?? "nova", shadcnIconLibrary: options.shadcnIconLibrary ?? "lucide", @@ -452,8 +454,8 @@ export async function createVirtual( shadcnRadius: options.shadcnRadius ?? "default", ai: options.ai || "none", stateManagement: options.stateManagement || "none", - forms: options.forms || "react-hook-form", - testing: options.testing || "vitest", + forms: options.forms || (isReactNative ? "none" : "react-hook-form"), + testing: options.testing || (isReactNative ? "none" : "vitest"), validation: options.validation || "zod", realtime: options.realtime || "none", jobQueue: options.jobQueue || "none", diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index cb97bd6bc..927bdb5fd 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -93,7 +93,7 @@ const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce( 0, ); -const INSTRUCTIONS = `Better-Fullstack scaffolds fullstack projects across TypeScript, Rust, Go, Python, and Java ecosystems with ${OPTION_ENTRY_COUNT} configurable options. +const INSTRUCTIONS = `Better-Fullstack scaffolds fullstack projects across TypeScript, React Native, Rust, Go, Python, and Java ecosystems with ${OPTION_ENTRY_COUNT} configurable options. RECOMMENDED WORKFLOW: 1. Call bfs_get_guidance to understand field semantics, required fields, and workflow rules. @@ -111,7 +111,7 @@ CRITICAL RULES: - Array fields: "frontend", "addons", "examples", "aiDocs", "rustLibraries", "pythonAi", "javaLibraries", and "javaTestingLibraries". Most other option fields are strings. - "none" means "skip this feature entirely", not "use the default". - Always specify "ecosystem" first — it determines which other fields are relevant. -- TypeScript-specific fields (frontend, backend, orm, etc.) are IGNORED for rust/python/go/java ecosystems. +- TypeScript web-specific fields (web frontend, backend, orm, etc.) are IGNORED for react-native/rust/python/go/java ecosystems. - The compatibility engine auto-adjusts invalid combinations — always call bfs_check_compatibility first to see adjustments.`; function getGuidance() { @@ -126,7 +126,9 @@ function getGuidance() { ], ecosystems: { typescript: - "Full-featured: frontend + backend + database + ORM + auth + payments + 20+ feature categories.", + "Full-featured web: frontend + backend + database + ORM + auth + payments + 20+ feature categories.", + "react-native": + "Mobile: Expo/React Native frontend variants plus mobile navigation, UI, storage, testing, push, OTA, and deep linking.", rust: "Backend/CLI: web framework (axum/actix-web), ORM (sea-orm/sqlx), gRPC, GraphQL, CLI tools.", python: "Backend/AI: web framework (fastapi/django), ORM (sqlalchemy/sqlmodel), AI/ML integrations, task queues.", @@ -267,9 +269,11 @@ const ECOSYSTEM_CATEGORIES: Record = { "email", "fileUpload", "effect", "ai", "stateManagement", "forms", "validation", "testing", "cssFramework", "uiLibrary", "realtime", "jobQueue", "animation", "logging", "observability", "featureFlags", "analytics", "cms", "caching", - "i18n", "search", "fileStorage", "astroIntegration", "mobileNavigation", - "mobileUI", "mobileStorage", "mobileTesting", "mobilePush", "mobileOTA", - "mobileDeepLinking", + "i18n", "search", "fileStorage", "astroIntegration", + ], + "react-native": [ + "frontend", "auth", "mobileNavigation", "mobileUI", "mobileStorage", + "mobileTesting", "mobilePush", "mobileOTA", "mobileDeepLinking", ], rust: ["rustWebFramework", "rustFrontend", "rustOrm", "rustApi", "rustCli", "rustLibraries", "rustLogging", "rustErrorHandling", "rustCaching", "rustAuth", "email", "observability", "caching", "search"], python: ["pythonWebFramework", "pythonOrm", "pythonValidation", "pythonAi", "pythonAuth", "pythonApi", "pythonTaskQueue", "pythonGraphql", "pythonQuality", "email", "observability", "caching", "search"], @@ -363,17 +367,24 @@ function buildProjectConfig( overrides?: { projectDir: string }, ): ProjectConfig { const projectName = (input.projectName as string) ?? "my-project"; + const ecosystem = (input.ecosystem as ProjectConfig["ecosystem"]) ?? "typescript"; return { projectName, projectDir: overrides?.projectDir ?? "/virtual", relativePath: overrides ? `./${projectName}` : "./virtual", - ecosystem: (input.ecosystem as ProjectConfig["ecosystem"]) ?? "typescript", - frontend: (input.frontend as ProjectConfig["frontend"]) ?? ["tanstack-router"], - backend: (input.backend as ProjectConfig["backend"]) ?? "hono", - runtime: (input.runtime as ProjectConfig["runtime"]) ?? "bun", + ecosystem, + frontend: + (input.frontend as ProjectConfig["frontend"]) ?? + (ecosystem === "react-native" ? ["native-bare"] : ["tanstack-router"]), + backend: + (input.backend as ProjectConfig["backend"]) ?? + (ecosystem === "react-native" ? "none" : "hono"), + runtime: + (input.runtime as ProjectConfig["runtime"]) ?? + (ecosystem === "react-native" ? "none" : "bun"), database: (input.database as ProjectConfig["database"]) ?? "none", orm: (input.orm as ProjectConfig["orm"]) ?? "none", - api: (input.api as ProjectConfig["api"]) ?? "none", + api: (input.api as ProjectConfig["api"]) ?? (ecosystem === "react-native" ? "none" : "none"), auth: (input.auth as ProjectConfig["auth"]) ?? "none", payments: (input.payments as ProjectConfig["payments"]) ?? "none", email: (input.email as ProjectConfig["email"]) ?? "none", @@ -384,7 +395,9 @@ function buildProjectConfig( forms: (input.forms as ProjectConfig["forms"]) ?? "none", validation: (input.validation as ProjectConfig["validation"]) ?? "none", testing: (input.testing as ProjectConfig["testing"]) ?? "none", - cssFramework: (input.cssFramework as ProjectConfig["cssFramework"]) ?? "tailwind", + cssFramework: + (input.cssFramework as ProjectConfig["cssFramework"]) ?? + (ecosystem === "react-native" ? "none" : "tailwind"), uiLibrary: (input.uiLibrary as ProjectConfig["uiLibrary"]) ?? "none", shadcnBase: "radix", shadcnStyle: "nova", diff --git a/apps/cli/src/prompts/auth.ts b/apps/cli/src/prompts/auth.ts index 77854864d..012bb74d5 100644 --- a/apps/cli/src/prompts/auth.ts +++ b/apps/cli/src/prompts/auth.ts @@ -10,7 +10,7 @@ type AuthPromptContext = { auth?: Auth; backend?: Backend; frontend?: string[]; - ecosystem?: "typescript" | "go"; + ecosystem?: "typescript" | "react-native" | "go"; }; export function resolveAuthPrompt(context: AuthPromptContext = {}): PromptSingleResolution { @@ -75,7 +75,7 @@ export async function getAuthChoice( auth: Auth | undefined, backend?: Backend, frontend?: string[], - ecosystem: "typescript" | "go" = "typescript", + ecosystem: "typescript" | "react-native" | "go" = "typescript", ) { const resolution = resolveAuthPrompt({ auth, diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 043ee12bc..a3c50651c 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -102,7 +102,7 @@ import { getExamplesChoice } from "./examples"; import { getFileStorageChoice } from "./file-storage"; import { getFileUploadChoice } from "./file-upload"; import { getFormsChoice } from "./forms"; -import { getFrontendChoice } from "./frontend"; +import { getFrontendChoice, getNativeFrontendChoice } from "./frontend"; import { getGitChoice } from "./git"; import { getGoApiChoice, @@ -274,6 +274,9 @@ export async function gatherConfig( ecosystem: () => getEcosystemChoice(flags.ecosystem), // TypeScript ecosystem prompts (skip if Rust or Python) frontend: ({ results }) => { + if (results.ecosystem === "react-native") { + return getNativeFrontendChoice(flags.frontend); + } if (results.ecosystem !== "typescript") return Promise.resolve([] as Frontend[]); return getFrontendChoice(flags.frontend, flags.backend, flags.auth); }, @@ -349,6 +352,9 @@ export async function gatherConfig( if (results.ecosystem === "typescript") { return getAuthChoice(flags.auth, results.backend, results.frontend, "typescript"); } + if (results.ecosystem === "react-native") { + return Promise.resolve((flags.auth ?? "none") as Auth); + } if (results.ecosystem === "go") { return getAuthChoice(flags.auth, undefined, undefined, "go"); } @@ -359,6 +365,7 @@ export async function gatherConfig( return getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend); }, email: ({ results }) => { + if (results.ecosystem === "react-native") return Promise.resolve("none" as Email); return getEmailChoice(flags.email, results.backend, results.ecosystem); }, effect: ({ results }) => { @@ -466,6 +473,7 @@ export async function gatherConfig( return getLoggingChoice(flags.logging, results.backend); }, observability: ({ results }) => { + if (results.ecosystem === "react-native") return Promise.resolve("none" as Observability); return getObservabilityChoice( flags.observability, results.backend, @@ -485,6 +493,7 @@ export async function gatherConfig( return getCMSChoice(flags.cms, results.backend); }, caching: ({ results }) => { + if (results.ecosystem === "react-native") return Promise.resolve("none" as Caching); return getCachingChoice(flags.caching, results.backend, results.ecosystem); }, i18n: ({ results }) => { @@ -492,6 +501,7 @@ export async function gatherConfig( return getI18nChoice(flags.i18n, results.frontend); }, search: ({ results }) => { + if (results.ecosystem === "react-native") return Promise.resolve("none" as Search); return getSearchChoice(flags.search, results.backend, results.ecosystem); }, fileStorage: ({ results }) => { @@ -499,14 +509,18 @@ export async function gatherConfig( return getFileStorageChoice(flags.fileStorage, results.backend); }, mobileNavigation: ({ results }) => { - if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileNavigation); + if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") { + return Promise.resolve("none" as MobileNavigation); + } if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { return Promise.resolve("none" as MobileNavigation); } return getMobileNavigationChoice(flags.mobileNavigation); }, mobileUI: ({ results }) => { - if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileUI); + if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") { + return Promise.resolve("none" as MobileUI); + } if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { return Promise.resolve("none" as MobileUI); } @@ -517,35 +531,45 @@ export async function gatherConfig( return getMobileUIChoice(flags.mobileUI); }, mobileStorage: ({ results }) => { - if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileStorage); + if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") { + return Promise.resolve("none" as MobileStorage); + } if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { return Promise.resolve("none" as MobileStorage); } return getMobileStorageChoice(flags.mobileStorage); }, mobileTesting: ({ results }) => { - if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileTesting); + if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") { + return Promise.resolve("none" as MobileTesting); + } if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { return Promise.resolve("none" as MobileTesting); } return getMobileTestingChoice(flags.mobileTesting); }, mobilePush: ({ results }) => { - if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobilePush); + if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") { + return Promise.resolve("none" as MobilePush); + } if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { return Promise.resolve("none" as MobilePush); } return getMobilePushChoice(flags.mobilePush); }, mobileOTA: ({ results }) => { - if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileOTA); + if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") { + return Promise.resolve("none" as MobileOTA); + } if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { return Promise.resolve("none" as MobileOTA); } return getMobileOTAChoice(flags.mobileOTA); }, mobileDeepLinking: ({ results }) => { - if (results.ecosystem !== "typescript") return Promise.resolve("none" as MobileDeepLinking); + if (results.ecosystem !== "typescript" && results.ecosystem !== "react-native") { + return Promise.resolve("none" as MobileDeepLinking); + } if (!results.frontend?.some((frontend) => frontend.startsWith("native-"))) { return Promise.resolve("none" as MobileDeepLinking); } diff --git a/apps/cli/src/prompts/ecosystem.ts b/apps/cli/src/prompts/ecosystem.ts index 0cff9d6b8..f616a08ba 100644 --- a/apps/cli/src/prompts/ecosystem.ts +++ b/apps/cli/src/prompts/ecosystem.ts @@ -10,7 +10,12 @@ export async function getEcosystemChoice(ecosystem?: Ecosystem) { { value: "typescript" as const, label: "TypeScript", - hint: "Full-stack TypeScript with React, Vue, Svelte, and more", + hint: "Full-stack TypeScript web with React, Vue, Svelte, and more", + }, + { + value: "react-native" as const, + label: "React Native", + hint: "Expo and React Native mobile apps with native integrations", }, { value: "rust" as const, @@ -27,6 +32,11 @@ export async function getEcosystemChoice(ecosystem?: Ecosystem) { label: "Go", hint: "Go ecosystem with Gin, Echo, GORM, and more", }, + { + value: "java" as const, + label: "Java", + hint: "Java ecosystem with Spring Boot, Maven, Gradle, and more", + }, ]; const response = await navigableSelect({ diff --git a/apps/cli/src/prompts/frontend.ts b/apps/cli/src/prompts/frontend.ts index 62c81e13c..be88226d1 100644 --- a/apps/cli/src/prompts/frontend.ts +++ b/apps/cli/src/prompts/frontend.ts @@ -259,3 +259,20 @@ export async function getFrontendChoice( return result; } } + +export async function getNativeFrontendChoice(frontendOptions?: Frontend[]): Promise { + if (frontendOptions !== undefined) { + return frontendOptions.filter((frontend) => frontend.startsWith("native-")); + } + + const nativeFramework = await navigableSelect({ + message: "Choose React Native app type", + options: NATIVE_FRONTEND_PROMPT_OPTIONS, + initialValue: "native-bare", + }); + + if (isGoBack(nativeFramework)) return GO_BACK_SYMBOL; + if (isCancel(nativeFramework)) return exitCancelled("Operation cancelled"); + + return [nativeFramework as Frontend]; +} diff --git a/apps/cli/src/utils/config-validation.ts b/apps/cli/src/utils/config-validation.ts index 751b7e9ac..3a1fd8b13 100644 --- a/apps/cli/src/utils/config-validation.ts +++ b/apps/cli/src/utils/config-validation.ts @@ -559,6 +559,32 @@ export function validateFrontendConstraints( const { frontend } = config; if (frontend && frontend.length > 0) { + if ( + config.ecosystem === "react-native" && + frontend.some((item) => !item.startsWith("native-") && item !== "none") + ) { + incompatibilityError({ + message: "React Native ecosystem only supports native Expo frontends.", + provided: { ecosystem: "react-native", frontend: frontend.join(" ") }, + suggestions: [ + "Use --frontend native-bare", + "Use --frontend native-uniwind", + "Use --frontend native-unistyles", + ], + }); + } + + if ( + config.ecosystem === "typescript" && + frontend.some((item) => item.startsWith("native-")) + ) { + incompatibilityError({ + message: "Native Expo frontends now belong to the React Native ecosystem.", + provided: { ecosystem: "typescript", frontend: frontend.join(" ") }, + suggestions: ["Use --ecosystem react-native with --frontend native-bare"], + }); + } + ensureSingleWebAndNative(frontend); if (providedFlags.has("api") && providedFlags.has("frontend") && config.api) { diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 8096badb0..7f8f95dd8 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -126,6 +126,29 @@ function getTypeScriptFlags(config: ProjectConfig) { return flags; } +function getReactNativeFlags(config: ProjectConfig) { + const flags = ["--ecosystem react-native"]; + + if (config.frontend && config.frontend.length > 0) { + flags.push(`--frontend ${config.frontend.join(" ")}`); + } else { + flags.push("--frontend native-bare"); + } + + flags.push(`--auth ${config.auth}`); + flags.push(`--mobile-navigation ${config.mobileNavigation}`); + flags.push(`--mobile-ui ${config.mobileUI}`); + flags.push(`--mobile-storage ${config.mobileStorage}`); + flags.push(`--mobile-testing ${config.mobileTesting}`); + flags.push(`--mobile-push ${config.mobilePush}`); + flags.push(`--mobile-ota ${config.mobileOTA}`); + flags.push(`--mobile-deep-linking ${config.mobileDeepLinking}`); + + appendCommonFlags(flags, config); + + return flags; +} + function getRustFlags(config: ProjectConfig) { const flags = ["--ecosystem rust"]; @@ -202,6 +225,9 @@ export function generateReproducibleCommand(config: ProjectConfig) { let flags: string[]; switch (config.ecosystem) { + case "react-native": + flags = getReactNativeFlags(config); + break; case "rust": flags = getRustFlags(config); break; diff --git a/apps/cli/test/go-language.test.ts b/apps/cli/test/go-language.test.ts index b63175730..9a5c9c00d 100644 --- a/apps/cli/test/go-language.test.ts +++ b/apps/cli/test/go-language.test.ts @@ -72,7 +72,7 @@ describe("Go Language Support", () => { expect(ECOSYSTEMS).toContain("python"); expect(ECOSYSTEMS).toContain("go"); expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS.length).toBe(5); + expect(ECOSYSTEMS.length).toBe(6); }); it("should include GoBetterAuth in auth options", () => { diff --git a/apps/cli/test/java-ecosystem.test.ts b/apps/cli/test/java-ecosystem.test.ts index c768691d0..01eb5150d 100644 --- a/apps/cli/test/java-ecosystem.test.ts +++ b/apps/cli/test/java-ecosystem.test.ts @@ -150,7 +150,7 @@ describe("Java Ecosystem", () => { describe("Schema Definitions", () => { it("should expose java as a valid ecosystem", () => { expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS).toHaveLength(5); + expect(ECOSYSTEMS).toHaveLength(6); }); it("should expose scaffolded Java web framework values", () => { diff --git a/apps/cli/test/python-language.test.ts b/apps/cli/test/python-language.test.ts index b23db4754..85ce92b17 100644 --- a/apps/cli/test/python-language.test.ts +++ b/apps/cli/test/python-language.test.ts @@ -82,7 +82,7 @@ describe("Python Language Support", () => { expect(ECOSYSTEMS).toContain("python"); expect(ECOSYSTEMS).toContain("go"); expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS.length).toBe(5); + expect(ECOSYSTEMS.length).toBe(6); }); it("should have python web framework options", () => { diff --git a/apps/cli/test/rust-ecosystem.test.ts b/apps/cli/test/rust-ecosystem.test.ts index 9cd9288f0..13eec8a88 100644 --- a/apps/cli/test/rust-ecosystem.test.ts +++ b/apps/cli/test/rust-ecosystem.test.ts @@ -75,7 +75,7 @@ describe("Rust Ecosystem", () => { expect(ECOSYSTEMS).toContain("python"); expect(ECOSYSTEMS).toContain("go"); expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS.length).toBe(5); + expect(ECOSYSTEMS.length).toBe(6); }); it("should have rust web framework options", () => { diff --git a/apps/web/content/docs/cli/create.mdx b/apps/web/content/docs/cli/create.mdx index 8daad81b4..38a806f5e 100644 --- a/apps/web/content/docs/cli/create.mdx +++ b/apps/web/content/docs/cli/create.mdx @@ -30,7 +30,7 @@ With `npm create`, pass Better Fullstack flags after `--`. Generated reproducibl | Flag | Values | | --- | --- | -| `--frontend` | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `native-bare` `native-uniwind` `native-unistyles` `redwood` `fresh` `none` | +| `--frontend` | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` | | `--backend` | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` | | `--runtime` | `node` `bun` `workers` `none` | | `--database` | `sqlite` `postgres` `mysql` `mongodb` `edgedb` `redis` `none` | @@ -40,13 +40,6 @@ With `npm create`, pass Better Fullstack flags after `--`. Generated reproducibl | `--auth` | `better-auth` `clerk` `nextauth` `stack-auth` `supabase-auth` `auth0` `go-better-auth` `none` | | `--api` | `orpc` `trpc` `ts-rest` `graphql-yoga` `garph` `none` | | `--astro-integration` | `react` `vue` `svelte` `solid` `none` | -| `--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` | ### Features and services @@ -144,11 +137,26 @@ Go also supports the global `--auth go-better-auth` option for GoBetterAuth. Tha Spring Boot requires Maven or Gradle. Java ORM, auth, and libraries require Spring Boot plus a build tool. Flyway requires Spring Data JPA. +## React Native flags + +Use `--ecosystem react-native` for Expo/React Native mobile projects. + +| 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` | + ## Global flags | Flag | Description | | --- | --- | -| `--ecosystem` | `typescript` `rust` `python` `go` `java`. Defaults to `typescript`. | +| `--ecosystem` | `typescript` `react-native` `rust` `python` `go` `java`. Defaults to `typescript`. | | `--template` | `t3` `mern` `pern` `uniwind` `none`. | | `--addons` | `mcp` `skills` `turborepo` `starlight` `fumadocs` `biome` `oxlint` `ultracite` `lefthook` `husky` `pwa` `tauri` `wxt` `msw` `storybook` `tanstack-query` `tanstack-table` `tanstack-virtual` `tanstack-db` `tanstack-pacer` `opentui` `ruler` `none`. | | `--examples` | `ai` `chat-sdk` `tanstack-showcase` `none`. | diff --git a/apps/web/content/docs/ecosystems/index.mdx b/apps/web/content/docs/ecosystems/index.mdx index 4f3607f3e..c022ab42d 100644 --- a/apps/web/content/docs/ecosystems/index.mdx +++ b/apps/web/content/docs/ecosystems/index.mdx @@ -3,11 +3,12 @@ title: Ecosystems description: Supported language ecosystems and what Better Fullstack scaffolds for each one. --- -Better Fullstack uses one CLI for multiple language ecosystems, but each ecosystem has its own scaffold surface. TypeScript has the broadest integration set; Rust, Python, Go, and Java focus on language-native project templates. +Better Fullstack uses one CLI for multiple ecosystems, but each ecosystem has its own scaffold surface. TypeScript focuses on fullstack web, React Native focuses on Expo mobile apps, and Rust, Python, Go, and Java focus on language-native project templates. | Ecosystem | Best for | Notes | | --- | --- | --- | -| [TypeScript](/docs/ecosystems/typescript/) | Fullstack web, APIs, workers, mobile, desktop, and the largest integration matrix. | Most shared options are TypeScript-first. | +| [TypeScript](/docs/ecosystems/typescript/) | Fullstack web, APIs, workers, desktop, and the largest web integration matrix. | Web options are TypeScript-first. | +| [React Native](/docs/ecosystems/react-native/) | Expo mobile apps with navigation, UI, storage, testing, push, OTA, and deep linking. | Uses the React Native ecosystem surface instead of TypeScript web frontend categories. | | [Rust](/docs/ecosystems/rust/) | Backend services, CLI apps, GraphQL/gRPC, Rust web frontends, and strongly typed libraries. | Uses Cargo; templates are conditional by selected Rust options. | | [Python](/docs/ecosystems/python/) | API and AI-oriented services with framework, ORM, validation, queue, GraphQL, and quality choices. | Uses `uv`; Python AI is a separate multi-select category. | | [Go](/docs/ecosystems/go/) | API services, gRPC, CLIs, logging, ORM, and Go auth helpers. | Uses Go modules; GoBetterAuth is selected with global `--auth go-better-auth`. | diff --git a/apps/web/content/docs/ecosystems/meta.json b/apps/web/content/docs/ecosystems/meta.json index fa4c2a151..0968f8dbb 100644 --- a/apps/web/content/docs/ecosystems/meta.json +++ b/apps/web/content/docs/ecosystems/meta.json @@ -1,5 +1,5 @@ { "title": "Ecosystems", "defaultOpen": true, - "pages": ["index", "typescript", "rust", "python", "go", "java"] + "pages": ["index", "typescript", "react-native", "rust", "python", "go", "java"] } diff --git a/apps/web/content/docs/ecosystems/react-native.mdx b/apps/web/content/docs/ecosystems/react-native.mdx new file mode 100644 index 000000000..2f5a439ac --- /dev/null +++ b/apps/web/content/docs/ecosystems/react-native.mdx @@ -0,0 +1,61 @@ +--- +title: React Native +description: Expo and React Native mobile scaffolds with production-minded integrations. +--- + +React Native is the dedicated mobile ecosystem for Better Fullstack. It scaffolds runnable Expo projects and keeps mobile choices separate from TypeScript web frontend categories. + +## Prerequisites + +- Node.js 20+. +- npm, pnpm, Yarn, or Bun. +- Expo-compatible iOS/Android tooling when you want simulator or device validation. + +## Scripted example + +```bash +npm create better-fullstack@latest mobile-app -- \ + --ecosystem react-native \ + --frontend native-bare \ + --mobile-navigation expo-router \ + --mobile-ui tamagui \ + --mobile-storage mmkv \ + --mobile-testing maestro-react-native-testing-library \ + --mobile-push expo-notifications \ + --mobile-ota expo-updates \ + --mobile-deep-linking expo-linking \ + --package-manager npm \ + --ai-docs none \ + --no-install +``` + +## Mobile scaffold categories + +| Category | Values | +| --- | --- | +| Native frontend | `native-bare` `native-uniwind` `native-unistyles` | +| Navigation | `expo-router` `react-navigation` `none` | +| UI | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` | +| Storage | `mmkv` `none` | +| Testing | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none` | +| Push | `expo-notifications` `none` | +| OTA | `expo-updates` `none` | +| Deep linking | `expo-linking` `none` | + +## Generated integrations + +- Expo config with scheme/runtime settings when deep linking or OTA is selected. +- Expo Router route files or a React Navigation entrypoint, depending on navigation. +- Mobile UI provider/theme files for Tamagui, Gluestack UI, Uniwind, or Unistyles. +- MMKV client helpers when storage is selected. +- Jest Expo and React Native Testing Library examples, Maestro flows, or both. +- Push notification registration helpers for Expo Notifications. +- OTA update helpers for Expo Updates. +- `.env.example` with `EXPO_PUBLIC_` conventions for mobile client configuration. + +## Compatibility notes + +- React Native does not expose TypeScript web frontend, backend, database, web UI, web deploy, or server deploy categories. +- Uniwind and Unistyles UI modes are tied to their matching Expo frontend variants. +- Tamagui and Gluestack UI use the bare Expo variant to avoid conflicting styling setup. +- Deep linking is enabled automatically when a mobile auth flow requires redirect URI examples. diff --git a/apps/web/content/docs/ecosystems/typescript.mdx b/apps/web/content/docs/ecosystems/typescript.mdx index 6583ee716..e8f2872ad 100644 --- a/apps/web/content/docs/ecosystems/typescript.mdx +++ b/apps/web/content/docs/ecosystems/typescript.mdx @@ -1,9 +1,9 @@ --- title: TypeScript -description: Fullstack web, mobile, desktop, API, worker, and integration scaffolds. +description: Fullstack web, desktop, API, worker, and integration scaffolds. --- -TypeScript is the primary Better Fullstack ecosystem and has the broadest support for shared categories: frontend, backend, database, auth, API, UI, services, addons, deploy targets, and examples. +TypeScript is the primary Better Fullstack web ecosystem and has the broadest support for shared categories: frontend, backend, database, auth, API, UI, services, addons, deploy targets, and examples. React Native/Expo mobile scaffolds now live in the dedicated [React Native](/docs/ecosystems/react-native/) ecosystem. ## Prerequisites @@ -37,11 +37,6 @@ npm create better-fullstack@latest my-app -- \ | Category | Values | | --- | --- | | Web frontend | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` | -| Native frontend | `native-bare` `native-uniwind` `native-unistyles` `none` | -| Mobile navigation | `expo-router` `react-navigation` `none` | -| Mobile UI | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` | -| Mobile storage/testing | `mmkv`, `maestro`, `react-native-testing-library` | -| Mobile services | `expo-notifications` `expo-updates` `expo-linking` | | Backend | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` | | Runtime | `node` `bun` `workers` `none` | | Database | `sqlite` `postgres` `mysql` `mongodb` `edgedb` `redis` `none` | @@ -77,6 +72,5 @@ npm create better-fullstack@latest my-app -- \ - `self` backend is for fullstack frameworks and pairs with `runtime none`. - Qwik, Angular, Redwood, and Fresh have narrower API/backend support than the full option table. - Workers runtime support is backend-dependent. -- Mobile options require a native Expo frontend. React Navigation emits an `App.tsx` entrypoint, Expo Router emits file routes, and push/OTA/deep-link choices add Expo config plus helper files. - UI libraries often depend on frontend family and CSS framework. For example, shadcn/ui is React-oriented and Tailwind-based. - Some providers have extra constraints, such as Polar requiring Better Auth and a web frontend. diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index dd93b712b..0face2445 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -45,4 +45,4 @@ Not ready to scaffold yet? Use the [Stack Builder](https://better-fullstack.dev/ - [Installation](/docs/getting-started/installation/) — prerequisites and launchers. - [First Project](/docs/getting-started/first-project/) — dry-runs, scripted examples, and project structure. - [CLI Create](/docs/cli/create/) — flags and examples. -- [Ecosystems](/docs/ecosystems/) — TypeScript, Rust, Python, Go, and Java support. +- [Ecosystems](/docs/ecosystems/) — TypeScript, React Native, Rust, Python, Go, and Java support. diff --git a/apps/web/content/docs/reference/options/index.mdx b/apps/web/content/docs/reference/options/index.mdx index 7428cd47a..d62a5156c 100644 --- a/apps/web/content/docs/reference/options/index.mdx +++ b/apps/web/content/docs/reference/options/index.mdx @@ -9,7 +9,8 @@ Use CLI values in commands and MCP payloads. Some builder labels normalize to th | Section | Scope | Categories | Options | | --- | --- | --- | --- | -| [TypeScript Options](/docs/reference/options/typescript/) | TypeScript categories: frontend, backend, runtime, data, API, auth, UI, services, tooling, examples, addons, and deploy. | 52 | 325 | +| [TypeScript Options](/docs/reference/options/typescript/) | TypeScript web categories: frontend, backend, runtime, data, API, auth, UI, services, tooling, examples, addons, and deploy. | 45 | 308 | +| [React Native Options](/docs/reference/options/react-native/) | React Native categories: Expo frontend variant, navigation, UI, storage, testing, push, OTA, and deep linking. | 8 | 20 | | [Rust Options](/docs/reference/options/rust/) | Rust-ecosystem categories: web framework, ORM, API, CLI, libraries, auth. | 10 | 35 | | [Python Options](/docs/reference/options/python/) | Python-ecosystem categories: web framework, ORM, validation, AI, quality. | 8 | 27 | | [Go Options](/docs/reference/options/go/) | Go-ecosystem categories: web framework, ORM, API, CLI, logging, auth. | 6 | 21 | diff --git a/apps/web/content/docs/reference/options/meta.json b/apps/web/content/docs/reference/options/meta.json index a792aecee..9cf43537d 100644 --- a/apps/web/content/docs/reference/options/meta.json +++ b/apps/web/content/docs/reference/options/meta.json @@ -1,5 +1,5 @@ { "title": "Options", "defaultOpen": true, - "pages": ["index", "typescript", "rust", "python", "go", "java"] + "pages": ["index", "typescript", "react-native", "rust", "python", "go", "java"] } diff --git a/apps/web/content/docs/reference/options/react-native.mdx b/apps/web/content/docs/reference/options/react-native.mdx new file mode 100644 index 000000000..87b3c1327 --- /dev/null +++ b/apps/web/content/docs/reference/options/react-native.mdx @@ -0,0 +1,24 @@ +--- +title: React Native Options +description: Reference for Expo and React Native stack options. +--- + +React Native options are selected with `--ecosystem react-native`. They generate a runnable Expo app and do not expose TypeScript web frontend, backend, database, or web deployment categories. + +| Category | CLI flag | Values | +| --- | --- | --- | +| Native frontend | `--frontend` | `native-bare` `native-uniwind` `native-unistyles` | +| Mobile navigation | `--mobile-navigation` | `expo-router` `react-navigation` `none` | +| Mobile UI | `--mobile-ui` | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` | +| Mobile storage | `--mobile-storage` | `mmkv` `none` | +| Mobile testing | `--mobile-testing` | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none` | +| Mobile push | `--mobile-push` | `expo-notifications` `none` | +| Mobile OTA | `--mobile-ota` | `expo-updates` `none` | +| Mobile deep linking | `--mobile-deep-linking` | `expo-linking` `none` | + +## Compatibility + +- `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. +- `expo-notifications`, `expo-updates`, and `expo-linking` require Expo-compatible native projects. diff --git a/apps/web/content/docs/reference/options/typescript.mdx b/apps/web/content/docs/reference/options/typescript.mdx index 20c9292b5..82662c536 100644 --- a/apps/web/content/docs/reference/options/typescript.mdx +++ b/apps/web/content/docs/reference/options/typescript.mdx @@ -16,14 +16,6 @@ The tables list valid option values, not every valid combination. tRPC is React- | Category | Selection | Values | | --- | --- | --- | | Web frontend | multiple | `tanstack-router` `react-router` `react-vite` `tanstack-start` `next` `nuxt` `svelte` `solid` `solid-start` `astro` `qwik` `angular` `redwood` `fresh` `none` | -| Native frontend | multiple | `native-bare` `native-uniwind` `native-unistyles` `none` | -| Mobile navigation | single | `expo-router` `react-navigation` `none` | -| Mobile UI | single | `tamagui` `gluestack-ui` `uniwind` `unistyles` `none` | -| Mobile storage | single | `mmkv` `none` | -| Mobile testing | single | `maestro` `react-native-testing-library` `maestro-react-native-testing-library` `none` | -| Mobile push | single | `expo-notifications` `none` | -| Mobile OTA | single | `expo-updates` `none` | -| Mobile deep linking | single | `expo-linking` `none` | | Astro integration | single | `react` `vue` `svelte` `solid` `none` | | Backend | single | `hono` `express` `fastify` `elysia` `fets` `nestjs` `adonisjs` `nitro` `encore` `convex` `self` `none` | | Runtime | single | `bun` `node` `workers` `none` | @@ -35,7 +27,7 @@ The tables list valid option values, not every valid combination. tRPC is React- `dbSetup: upstash` is a Redis setup path. GoBetterAuth is documented on the [Go Options](/docs/reference/options/go/) page because it targets Go projects. -Mobile options only apply when a native Expo frontend is selected. Expo Router and React Navigation are mutually exclusive navigation setups. Tamagui and Gluestack UI target `native-bare`; `native-uniwind` and `native-unistyles` keep their aligned styling systems. Push, OTA, and deep linking emit Expo config plus helper code rather than package-only installs. +React Native and Expo options live on the [React Native Options](/docs/reference/options/react-native/) page. ## Services And Integrations diff --git a/apps/web/content/guides/index.mdx b/apps/web/content/guides/index.mdx index f96dbc672..30d778d66 100644 --- a/apps/web/content/guides/index.mdx +++ b/apps/web/content/guides/index.mdx @@ -1,6 +1,6 @@ --- title: Fullstack App Starter Guides -description: Practical Better Fullstack guides for creating TypeScript, Rust, Python, Go, and Java projects with supported frameworks, databases, auth, APIs, email, AI, and deployment options. +description: Practical Better Fullstack guides for creating TypeScript, React Native, Rust, Python, Go, and Java projects with supported frameworks, databases, auth, APIs, email, AI, and deployment options. updated: 2026-05-12 category: Guides tags: diff --git a/apps/web/src/components/docs/mdx/compatibility-matrix.tsx b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx index 7094f62bc..fadc13516 100644 --- a/apps/web/src/components/docs/mdx/compatibility-matrix.tsx +++ b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx @@ -23,6 +23,7 @@ type BaselineControl = { const ECOSYSTEMS: Array<{ id: Ecosystem; label: string }> = [ { id: "typescript", label: "TypeScript" }, + { id: "react-native", label: "React Native" }, { id: "rust", label: "Rust" }, { id: "python", label: "Python" }, { id: "go", label: "Go" }, @@ -69,6 +70,18 @@ const TYPESCRIPT_CATEGORIES: SelectCategory[] = [ const ECOSYSTEM_CATEGORIES: Record = { typescript: TYPESCRIPT_CATEGORIES, + "react-native": [ + "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + "auth", + "packageManager", + ], rust: [ "rustWebFramework", "rustFrontend", @@ -147,6 +160,13 @@ const BASELINE_CONTROLS: Record = { { category: "cssFramework", label: "CSS" }, { category: "uiLibrary", label: "UI library" }, ], + "react-native": [ + { category: "nativeFrontend", label: "Expo app" }, + { category: "mobileNavigation", label: "Navigation" }, + { category: "mobileUI", label: "Mobile UI" }, + { category: "mobileStorage", label: "Storage" }, + { category: "mobileTesting", label: "Testing" }, + ], rust: [ { category: "rustWebFramework", label: "Framework" }, { category: "rustOrm", label: "ORM" }, diff --git a/apps/web/src/components/home/combinations-section.tsx b/apps/web/src/components/home/combinations-section.tsx index 29074eecc..1a061803b 100644 --- a/apps/web/src/components/home/combinations-section.tsx +++ b/apps/web/src/components/home/combinations-section.tsx @@ -9,7 +9,7 @@ const { totalScientific, yearsAtOneMillisecondScientific, universeLifetimesScien const funFacts = [ `${universeLifetimesScientific.mantissa} × 10^${universeLifetimesScientific.exponent} universe lifetimes to test all combinations`, `${combinationsMetrics.universeSandRatioScientific.mantissa} × 10^${combinationsMetrics.universeSandRatioScientific.exponent}× more combinations than grains of sand in the observable universe`, - "Across TypeScript, Rust, Python, Go, and Java", + "Across TypeScript, React Native, Rust, Python, Go, and Java", "Each combination scaffolds a unique, production-ready app", "YOLO mode doubles every single one of them", ]; diff --git a/apps/web/src/components/home/features-section.tsx b/apps/web/src/components/home/features-section.tsx index 22c07c007..16f9c39f1 100644 --- a/apps/web/src/components/home/features-section.tsx +++ b/apps/web/src/components/home/features-section.tsx @@ -77,7 +77,7 @@ export default function FeaturesSection() {

- ✦ five ecosystems + ✦ six ecosystems

Everything.

- TypeScript, Rust, Python, Go, Java — one CLI scaffolds production-ready - apps across all five. Pick your ecosystem, pick your stack. + TypeScript, React Native, Rust, Python, Go, Java — one CLI scaffolds production-ready + apps across all six. Pick your ecosystem, pick your stack.

@@ -291,7 +291,7 @@ function TotalBlock() {

- options across 5 ecosystems · ts · rust · go · python · java + options across 6 ecosystems · ts · rn · rust · go · python · java

diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 715a8d119..38277fdce 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -4083,10 +4083,17 @@ export const ECOSYSTEMS: { { id: "typescript", name: "TypeScript", - description: "Full-stack TypeScript ecosystem", + description: "Full-stack TypeScript web ecosystem", icon: "https://cdn.simpleicons.org/typescript/3178C6", color: "from-blue-500 to-blue-700", }, + { + id: "react-native", + name: "React Native", + description: "Expo and React Native mobile ecosystem", + icon: "https://cdn.simpleicons.org/react/61DAFB", + color: "from-cyan-500 to-blue-700", + }, { id: "rust", name: "Rust", @@ -4121,14 +4128,6 @@ export const ECOSYSTEMS: { export const ECOSYSTEM_CATEGORIES: Record = { typescript: [ "webFrontend", - "nativeFrontend", - "mobileNavigation", - "mobileUI", - "mobileStorage", - "mobileTesting", - "mobilePush", - "mobileOTA", - "mobileDeepLinking", "astroIntegration", "cssFramework", "uiLibrary", @@ -4162,6 +4161,21 @@ export const ECOSYSTEM_CATEGORIES: Record = { "git", "install", ], + "react-native": [ + "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + "auth", + "packageManager", + "aiDocs", + "git", + "install", + ], rust: [ "rustWebFramework", "rustFrontend", @@ -4242,7 +4256,7 @@ export const PRESET_CATEGORIES = [ { id: "solid", name: "Solid", icon: "solid", ecosystem: "typescript" }, { id: "angular", name: "Angular", icon: "angular", ecosystem: "typescript" }, { id: "qwik", name: "Qwik", icon: "qwik", ecosystem: "typescript" }, - { id: "mobile", name: "Mobile", icon: "native-uniwind", ecosystem: "typescript" }, + { id: "mobile", name: "Mobile", icon: "native-uniwind", ecosystem: "react-native" }, { id: "ai-tools", name: "AI Tools", icon: "ai-cli", ecosystem: "typescript" }, { id: "rust", name: "Rust", icon: "", ecosystem: "rust" }, { id: "python", name: "Python", icon: "fastapi", ecosystem: "python" }, @@ -4939,6 +4953,7 @@ export const PRESET_TEMPLATES: { description: "Expo + Uniwind native app with no backend", category: "mobile", stack: { + ecosystem: "react-native", projectName: "my-app", webFrontend: ["none"], nativeFrontend: ["native-uniwind"], @@ -4974,6 +4989,7 @@ export const PRESET_TEMPLATES: { description: "Expo with bare workflow — no backend", category: "mobile", stack: { + ecosystem: "react-native", projectName: "my-app", webFrontend: ["none"], nativeFrontend: ["native-bare"], diff --git a/apps/web/src/lib/llms.ts b/apps/web/src/lib/llms.ts index cb3266052..626b16906 100644 --- a/apps/web/src/lib/llms.ts +++ b/apps/web/src/lib/llms.ts @@ -34,6 +34,7 @@ export function generateLlmsTxt({ "/docs/ai/mcp", "/docs/ai/mcp-tools", "/docs/ecosystems/typescript", + "/docs/ecosystems/react-native", "/docs/ecosystems/rust", "/docs/ecosystems/python", "/docs/ecosystems/go", diff --git a/apps/web/src/lib/project-stats.generated.ts b/apps/web/src/lib/project-stats.generated.ts index bedcb8b06..910d0fd9f 100644 --- a/apps/web/src/lib/project-stats.generated.ts +++ b/apps/web/src/lib/project-stats.generated.ts @@ -6,5 +6,12 @@ export const OPTION_ENTRY_COUNT = Object.values(OPTION_CATEGORY_METADATA).reduce 0, ); export const CATEGORY_COUNT = Object.keys(OPTION_CATEGORY_METADATA).length; -export const ECOSYSTEM_COUNT = 5; -export const ECOSYSTEM_NAMES = ["TypeScript", "Rust", "Python", "Go", "Java"] as const; +export const ECOSYSTEM_COUNT = 6; +export const ECOSYSTEM_NAMES = [ + "TypeScript", + "React Native", + "Rust", + "Python", + "Go", + "Java", +] as const; diff --git a/apps/web/src/lib/stack-utils.ts b/apps/web/src/lib/stack-utils.ts index 912c1c49b..2be52d7c9 100644 --- a/apps/web/src/lib/stack-utils.ts +++ b/apps/web/src/lib/stack-utils.ts @@ -10,14 +10,6 @@ import { createStackSearchParams } from "@/lib/stack-url-state.shared"; // TypeScript ecosystem category order const TYPESCRIPT_CATEGORY_ORDER: Array = [ "webFrontend", - "nativeFrontend", - "mobileNavigation", - "mobileUI", - "mobileStorage", - "mobileTesting", - "mobilePush", - "mobileOTA", - "mobileDeepLinking", "astroIntegration", "cssFramework", "uiLibrary", @@ -69,6 +61,23 @@ const TYPESCRIPT_CATEGORY_ORDER: Array = [ "install", ]; +// React Native ecosystem category order +const REACT_NATIVE_CATEGORY_ORDER: Array = [ + "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + "auth", + "packageManager", + "aiDocs", + "git", + "install", +]; + // Rust ecosystem category order const RUST_CATEGORY_ORDER: Array = [ "rustWebFramework", @@ -149,6 +158,7 @@ const JAVA_CATEGORY_ORDER: Array = [ const CATEGORY_ORDER = [ ...new Set([ ...TYPESCRIPT_CATEGORY_ORDER, + ...REACT_NATIVE_CATEGORY_ORDER, ...RUST_CATEGORY_ORDER, ...PYTHON_CATEGORY_ORDER, ...GO_CATEGORY_ORDER, @@ -206,6 +216,7 @@ export function generateStackSharingUrl(stack: StackState, baseUrl?: string) { export { CATEGORY_ORDER, TYPESCRIPT_CATEGORY_ORDER, + REACT_NATIVE_CATEGORY_ORDER, RUST_CATEGORY_ORDER, PYTHON_CATEGORY_ORDER, GO_CATEGORY_ORDER, diff --git a/apps/web/test/go-ecosystem.test.ts b/apps/web/test/go-ecosystem.test.ts index d3fc9efc5..f951fb69c 100644 --- a/apps/web/test/go-ecosystem.test.ts +++ b/apps/web/test/go-ecosystem.test.ts @@ -21,7 +21,7 @@ import { describe("Go Ecosystem Tab", () => { describe("Ecosystem Type", () => { it("should have go as a valid ecosystem value", () => { - const ecosystems: Ecosystem[] = ["typescript", "rust", "python", "go", "java"]; + const ecosystems: Ecosystem[] = ["typescript", "react-native", "rust", "python", "go", "java"]; expect(ecosystems).toContain("go"); }); }); @@ -35,8 +35,8 @@ describe("Go Ecosystem Tab", () => { expect(goEcosystem?.description).toBe("High-performance Go ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should have exactly 6 ecosystems", () => { + expect(ECOSYSTEMS).toHaveLength(6); }); }); diff --git a/apps/web/test/java-ecosystem.test.ts b/apps/web/test/java-ecosystem.test.ts index b00528641..93655b814 100644 --- a/apps/web/test/java-ecosystem.test.ts +++ b/apps/web/test/java-ecosystem.test.ts @@ -24,7 +24,7 @@ import { CATEGORY_ORDER, JAVA_CATEGORY_ORDER, generateStackCommand } from "../sr describe("Java Ecosystem Tab", () => { describe("Ecosystem Type", () => { it("should have java as a valid ecosystem value", () => { - const ecosystems: Ecosystem[] = ["typescript", "rust", "python", "go", "java"]; + const ecosystems: Ecosystem[] = ["typescript", "react-native", "rust", "python", "go", "java"]; expect(ecosystems).toContain("java"); }); }); @@ -37,8 +37,8 @@ describe("Java Ecosystem Tab", () => { expect(javaEcosystem?.description).toBe("Modern Java ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should have exactly 6 ecosystems", () => { + expect(ECOSYSTEMS).toHaveLength(6); }); it("should use the Java language icon for Java presets", () => { diff --git a/apps/web/test/python-ecosystem.test.ts b/apps/web/test/python-ecosystem.test.ts index 543ac3a0f..15b419dc6 100644 --- a/apps/web/test/python-ecosystem.test.ts +++ b/apps/web/test/python-ecosystem.test.ts @@ -49,8 +49,8 @@ describe("Python Ecosystem Tab", () => { expect(pythonEcosystem?.description).toBe("Python full-stack ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should have exactly 6 ecosystems", () => { + expect(ECOSYSTEMS).toHaveLength(6); }); }); diff --git a/apps/web/test/rust-ecosystem.test.ts b/apps/web/test/rust-ecosystem.test.ts index 872e185b5..81e018fe3 100644 --- a/apps/web/test/rust-ecosystem.test.ts +++ b/apps/web/test/rust-ecosystem.test.ts @@ -43,8 +43,8 @@ describe("Rust Ecosystem Tab", () => { expect(rustEcosystem?.description).toBe("High-performance Rust ecosystem"); }); - it("should have exactly 5 ecosystems", () => { - expect(ECOSYSTEMS).toHaveLength(5); + it("should have exactly 6 ecosystems", () => { + expect(ECOSYSTEMS).toHaveLength(6); }); }); diff --git a/apps/web/test/stack-command-parity.test.ts b/apps/web/test/stack-command-parity.test.ts index ebf350d9c..e5485b7f4 100644 --- a/apps/web/test/stack-command-parity.test.ts +++ b/apps/web/test/stack-command-parity.test.ts @@ -39,14 +39,19 @@ describe("generateStackCommand parity", () => { expect(command).toContain("--ai langgraph"); }); - it("serializes merged frontend selections into a single --frontend flag", () => { + it("serializes React Native frontend selections through the mobile ecosystem", () => { const command = generateStackCommand({ ...DEFAULT_STACK, - webFrontend: ["next"], + ecosystem: "react-native", + webFrontend: ["none"], nativeFrontend: ["native-bare"], + mobileNavigation: "expo-router", }); - expect(command).toContain("--frontend next native-bare"); + expect(command).toContain("--ecosystem react-native"); + expect(command).toContain("--frontend native-bare"); + expect(command).toContain("--mobile-navigation expo-router"); + expect(command).not.toContain("--backend"); }); it("serializes addons from codeQuality, documentation, and appPlatforms", () => { diff --git a/packages/template-generator/src/generator.ts b/packages/template-generator/src/generator.ts index 9f383d8f2..6f41944b9 100644 --- a/packages/template-generator/src/generator.ts +++ b/packages/template-generator/src/generator.ts @@ -72,7 +72,7 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise // Java ecosystem - use Maven project structure await processJavaBaseTemplate(vfs, templates, config); } else { - // TypeScript ecosystem - use package.json and TypeScript project structure + // TypeScript and React Native ecosystems use package.json and TS project structure. await processBaseTemplate(vfs, templates, config); await processFrontendTemplates(vfs, templates, config); await processBackendTemplates(vfs, templates, config); @@ -107,7 +107,7 @@ export async function generateVirtualProject(options: GeneratorOptions): Promise processCatalogs(vfs, config); } - if (config.ecosystem !== "typescript") { + if (config.ecosystem !== "typescript" && config.ecosystem !== "react-native") { await processAddonTemplates(vfs, templates, config); processEnvVariables(vfs, config); } diff --git a/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs b/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs index f5c9a83a3..41224a4b3 100644 --- a/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs @@ -11,7 +11,11 @@ import { GluestackUIProvider } from "@gluestack-ui/themed"; export function MobileUIProvider({ children }: PropsWithChildren) { {{#if (eq mobileUI "tamagui")}} - return {children}; + return ( + + {children} + + ); {{else if (eq mobileUI "gluestack-ui")}} return {children}; {{else}} diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index 5ccfe538e..56a306105 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -116,7 +116,7 @@ export type CompatibilityAdjustment = { }; export type CompatibilityInput = { - ecosystem: "typescript" | "rust" | "python" | "go" | "java"; + ecosystem: "typescript" | "react-native" | "rust" | "python" | "go" | "java"; projectName: string | null; webFrontend: string[]; nativeFrontend: string[]; @@ -1100,7 +1100,79 @@ export const analyzeStackCompatibility = ( } } - const hasNativeFrontend = nextStack.nativeFrontend.some((f) => f !== "none"); + let hasNativeFrontend = nextStack.nativeFrontend.some((f) => f !== "none"); + + if (nextStack.ecosystem === "react-native") { + const reactNativeOnlyCategories = [ + ["webFrontend", ["none"], "Web frontend set to 'None' (React Native ecosystem)"], + ["backend", "none", "Backend set to 'None' (React Native ecosystem)"], + ["runtime", "none", "Runtime set to 'None' (React Native ecosystem)"], + ["api", "none", "API set to 'None' (React Native ecosystem)"], + ["database", "none", "Database set to 'None' (React Native ecosystem)"], + ["orm", "none", "ORM set to 'None' (React Native ecosystem)"], + ["dbSetup", "none", "Database setup set to 'None' (React Native ecosystem)"], + ["webDeploy", "none", "Web deployment set to 'None' (React Native ecosystem)"], + ["serverDeploy", "none", "Server deployment set to 'None' (React Native ecosystem)"], + ["cssFramework", "none", "CSS framework set to 'None' (React Native ecosystem)"], + ["uiLibrary", "none", "UI library set to 'None' (React Native ecosystem)"], + ["testing", "none", "Web testing set to 'None' (React Native ecosystem)"], + ["forms", "none", "Web forms set to 'None' (React Native ecosystem)"], + ["stateManagement", "none", "Web state management set to 'None' (React Native ecosystem)"], + ["animation", "none", "Web animation set to 'None' (React Native ecosystem)"], + ["realtime", "none", "Realtime set to 'None' (React Native ecosystem)"], + ["jobQueue", "none", "Job queue set to 'None' (React Native ecosystem)"], + ["fileUpload", "none", "File upload set to 'None' (React Native ecosystem)"], + ["payments", "none", "Payments set to 'None' (React Native ecosystem)"], + ["email", "none", "Email set to 'None' (React Native ecosystem)"], + ["search", "none", "Search set to 'None' (React Native ecosystem)"], + ["fileStorage", "none", "File storage set to 'None' (React Native ecosystem)"], + ["cms", "none", "CMS set to 'None' (React Native ecosystem)"], + ["caching", "none", "Caching set to 'None' (React Native ecosystem)"], + ["i18n", "none", "i18n set to 'None' (React Native ecosystem)"], + ["featureFlags", "none", "Feature flags set to 'None' (React Native ecosystem)"], + ["analytics", "none", "Analytics set to 'None' (React Native ecosystem)"], + ["aiSdk", "none", "AI SDK set to 'None' (React Native ecosystem)"], + ["backendLibraries", "none", "Backend libraries set to 'None' (React Native ecosystem)"], + ["examples", [], "Examples cleared (React Native ecosystem)"], + ] as const; + + for (const [category, value, message] of reactNativeOnlyCategories) { + const currentValue = nextStack[category]; + const isSameArray = + Array.isArray(currentValue) && + Array.isArray(value) && + currentValue.length === value.length && + currentValue.every((entry, index) => entry === value[index]); + if (Array.isArray(value) ? !isSameArray : currentValue !== value) { + (nextStack as Record)[category] = Array.isArray(value) ? [...value] : value; + changed = true; + changes.push({ category, message }); + } + } + + if (!hasNativeFrontend) { + nextStack.nativeFrontend = ["native-bare"]; + hasNativeFrontend = true; + changed = true; + changes.push({ + category: "nativeFrontend", + message: "Native frontend set to 'Expo + Bare' (React Native ecosystem)", + }); + } + } + + if ( + nextStack.ecosystem === "typescript" && + nextStack.nativeFrontend.some((frontend) => frontend !== "none") + ) { + nextStack.nativeFrontend = ["none"]; + hasNativeFrontend = false; + changed = true; + changes.push({ + category: "nativeFrontend", + message: "Native frontend set to 'None' (use React Native ecosystem for Expo apps)", + }); + } if (!hasNativeFrontend) { const nativeOnlyCategories = [ @@ -1583,6 +1655,32 @@ export const getDisabledReason = ( } } + if (currentStack.ecosystem === "react-native") { + const reactNativeCategories = new Set([ + "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + "auth", + "packageManager", + "aiDocs", + "git", + "install", + ]); + + if (!reactNativeCategories.has(category) && optionId !== "none" && optionId !== "false") { + return "React Native ecosystem only supports native mobile options"; + } + } + + if (currentStack.ecosystem === "typescript" && category === "nativeFrontend" && optionId !== "none") { + return "Use the React Native ecosystem for native Expo frontends"; + } + // ============================================ // NO BACKEND - locks down backend-dependent options // ============================================ diff --git a/packages/types/src/schemas.ts b/packages/types/src/schemas.ts index 5193766ca..fa040c048 100644 --- a/packages/types/src/schemas.ts +++ b/packages/types/src/schemas.ts @@ -1,8 +1,8 @@ import { z } from "zod"; export const EcosystemSchema = z - .enum(["typescript", "rust", "python", "go", "java"]) - .describe("Language ecosystem (typescript, rust, python, go, or java)"); + .enum(["typescript", "react-native", "rust", "python", "go", "java"]) + .describe("Language ecosystem (typescript, react-native, rust, python, go, or java)"); export const DatabaseSchema = z .enum(["none", "sqlite", "postgres", "mysql", "mongodb", "edgedb", "redis"]) diff --git a/packages/types/src/stack-translation.ts b/packages/types/src/stack-translation.ts index 16510cb5b..7ce9f37ee 100644 --- a/packages/types/src/stack-translation.ts +++ b/packages/types/src/stack-translation.ts @@ -665,6 +665,23 @@ const JAVA_CONFIG_KEYS = [ "javaTestingLibraries", ] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[]; +const REACT_NATIVE_CONFIG_KEYS = [ + "frontend", + "backend", + "runtime", + "database", + "orm", + "api", + "auth", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", +] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[]; + const COMMAND_ADDONS = new Set([ "pwa", "tauri", @@ -952,12 +969,13 @@ export function isCliDefaultStackSelection( relativePath: projectName, }; const ignoredKeys = - selection.ecosystem === "typescript" + selection.ecosystem === "typescript" || selection.ecosystem === "react-native" ? new Set([ ...RUST_CONFIG_KEYS, ...PYTHON_CONFIG_KEYS, ...GO_CONFIG_KEYS, ...JAVA_CONFIG_KEYS, + ...(selection.ecosystem === "typescript" ? REACT_NATIVE_CONFIG_KEYS : []), ]) : new Set(); @@ -1064,6 +1082,35 @@ function generateTypeScriptCommand(selection: StackSelectionInput, projectName: return `${base} ${projectName} ${flags.join(" ")}`; } +function generateReactNativeCommand(selection: StackSelectionInput, projectName: string) { + const flags = [ + "--ecosystem react-native", + `--frontend ${ + selection.nativeFrontend + .filter((value, _, values) => value !== "none" || values.length === 1) + .join(" ") || "native-bare" + }`, + `--auth ${selection.auth}`, + `--mobile-navigation ${selection.mobileNavigation}`, + `--mobile-ui ${selection.mobileUI}`, + `--mobile-storage ${selection.mobileStorage}`, + `--mobile-testing ${selection.mobileTesting}`, + `--mobile-push ${selection.mobilePush}`, + `--mobile-ota ${selection.mobileOTA}`, + `--mobile-deep-linking ${selection.mobileDeepLinking}`, + `--package-manager ${selection.packageManager}`, + selection.git === "false" ? "--no-git" : "--git", + selection.install === "false" ? "--no-install" : "--install", + formatArrayFlag("ai-docs", selection.aiDocs), + ]; + + if (selection.yolo === "true") { + flags.push("--yolo"); + } + + return `${getBaseCommand(selection)} ${projectName} ${flags.join(" ")}`; +} + function generateRustCommand(selection: StackSelectionInput, projectName: string) { const flags: string[] = [ "--ecosystem rust", @@ -1164,6 +1211,8 @@ export function generateStackSelectionCommand(selection: StackSelectionInput): s const projectName = getProjectName(selection); switch (selection.ecosystem) { + case "react-native": + return generateReactNativeCommand(selection, projectName); case "rust": return generateRustCommand(selection, projectName); case "python": diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts index ef6efe356..ede2fd0e2 100644 --- a/testing/lib/generate-combos/options.ts +++ b/testing/lib/generate-combos/options.ts @@ -33,6 +33,13 @@ import { JAVA_WEB_FRAMEWORK_VALUES, JOB_QUEUE_VALUES, LOGGING_VALUES, + MOBILE_DEEP_LINKING_VALUES, + MOBILE_NAVIGATION_VALUES, + MOBILE_OTA_VALUES, + MOBILE_PUSH_VALUES, + MOBILE_STORAGE_VALUES, + MOBILE_TESTING_VALUES, + MOBILE_UI_VALUES, OBSERVABILITY_VALUES, ORM_VALUES, PAYMENTS_VALUES, @@ -316,6 +323,41 @@ function makeTypeScriptDraft(args: GeneratorArgs): CandidateDraft { }; } +function makeReactNativeDraft(args: GeneratorArgs): CandidateDraft { + const frontend = [sampleOne(NATIVE_FRONTENDS.filter((value) => value !== "none"))]; + const mobileUI = frontend.includes("native-uniwind") + ? "uniwind" + : frontend.includes("native-unistyles") + ? "unistyles" + : sampleScalar(MOBILE_UI_VALUES, 0.55, "mobileUI"); + + return { + ecosystem: "react-native", + options: { + ...createCommonOptions("react-native", args), + frontend, + backend: "none", + runtime: "none", + api: "none", + database: "none", + orm: "none", + dbSetup: "none", + auth: "none", + cssFramework: "none", + uiLibrary: "none", + forms: "none", + testing: "none", + mobileNavigation: sampleScalar(MOBILE_NAVIGATION_VALUES, 0.05, "mobileNavigation"), + mobileUI, + mobileStorage: sampleScalar(MOBILE_STORAGE_VALUES, 0.55, "mobileStorage"), + mobileTesting: sampleScalar(MOBILE_TESTING_VALUES, 0.45, "mobileTesting"), + mobilePush: sampleScalar(MOBILE_PUSH_VALUES, 0.7, "mobilePush"), + mobileOTA: sampleScalar(MOBILE_OTA_VALUES, 0.7, "mobileOTA"), + mobileDeepLinking: sampleScalar(MOBILE_DEEP_LINKING_VALUES, 0.35, "mobileDeepLinking"), + }, + }; +} + function makeRustDraft(args: GeneratorArgs): CandidateDraft { return { ecosystem: "rust", @@ -452,7 +494,12 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje projectDir: path.resolve(process.cwd(), projectName), relativePath: projectName, ecosystem: draft.ecosystem, - frontend: draft.ecosystem === "typescript" ? getDefaultConfig().frontend : [], + frontend: + draft.ecosystem === "typescript" + ? getDefaultConfig().frontend + : draft.ecosystem === "react-native" + ? ["native-bare"] + : [], backend: "none", runtime: "none", database: "none", @@ -534,7 +581,7 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje } function applyDerivedMobileDefaults(config: ProjectConfig, providedFlags: Set) { - if (config.ecosystem !== "typescript") return; + if (config.ecosystem !== "typescript" && config.ecosystem !== "react-native") return; const hasNativeFrontend = config.frontend.some((frontend) => frontend.startsWith("native-")); if (!hasNativeFrontend) { @@ -622,6 +669,8 @@ function createDraft(ecosystem: Ecosystem, args: GeneratorArgs): CandidateDraft switch (ecosystem) { case "typescript": return makeTypeScriptDraft(args); + case "react-native": + return makeReactNativeDraft(args); case "rust": return makeRustDraft(args); case "python": diff --git a/testing/lib/generate-combos/render.ts b/testing/lib/generate-combos/render.ts index 13783012b..ad989c5a1 100644 --- a/testing/lib/generate-combos/render.ts +++ b/testing/lib/generate-combos/render.ts @@ -33,6 +33,20 @@ export function formatNameFromFingerprint(fingerprint: TemplateFingerprint): str typeof fingerprint.cssFramework === "string" ? fingerprint.cssFramework : undefined, typeof fingerprint.uiLibrary === "string" ? fingerprint.uiLibrary : undefined, ], + "react-native": [ + Array.isArray(fingerprint.frontend) + ? fingerprint.frontend.filter((value) => value !== "none").join("-") + : undefined, + typeof fingerprint.mobileNavigation === "string" ? fingerprint.mobileNavigation : undefined, + typeof fingerprint.mobileUI === "string" ? fingerprint.mobileUI : undefined, + typeof fingerprint.mobileStorage === "string" ? fingerprint.mobileStorage : undefined, + typeof fingerprint.mobileTesting === "string" ? fingerprint.mobileTesting : undefined, + typeof fingerprint.mobilePush === "string" ? fingerprint.mobilePush : undefined, + typeof fingerprint.mobileOTA === "string" ? fingerprint.mobileOTA : undefined, + typeof fingerprint.mobileDeepLinking === "string" + ? fingerprint.mobileDeepLinking + : undefined, + ], rust: [ typeof fingerprint.rustWebFramework === "string" ? fingerprint.rustWebFramework : undefined, typeof fingerprint.rustFrontend === "string" ? fingerprint.rustFrontend : undefined, @@ -153,6 +167,18 @@ export function buildCommand(name: string, config: ProjectConfig): string { ["search", config.search], ]; + const reactNativeFlags: Array<[string, string | readonly string[]]> = [ + ["frontend", withExplicitNone(config.frontend)], + ["auth", config.auth], + ["mobile-navigation", withExplicitScalar(config.mobileNavigation)], + ["mobile-ui", withExplicitScalar(config.mobileUI)], + ["mobile-storage", withExplicitScalar(config.mobileStorage)], + ["mobile-testing", withExplicitScalar(config.mobileTesting)], + ["mobile-push", withExplicitScalar(config.mobilePush)], + ["mobile-ota", withExplicitScalar(config.mobileOTA)], + ["mobile-deep-linking", withExplicitScalar(config.mobileDeepLinking)], + ]; + const rustFlags: Array<[string, string | readonly string[]]> = [ ["rust-web-framework", config.rustWebFramework], ["rust-frontend", config.rustFrontend], @@ -218,6 +244,9 @@ export function buildCommand(name: string, config: ProjectConfig): string { if (config.shadcnFont) orderedFlags.push(["shadcn-font", config.shadcnFont]); if (config.shadcnRadius) orderedFlags.push(["shadcn-radius", config.shadcnRadius]); break; + case "react-native": + orderedFlags.push(...reactNativeFlags); + break; case "rust": orderedFlags.push(...sharedServiceFlags, ...rustFlags); break; diff --git a/testing/lib/generate-combos/types.ts b/testing/lib/generate-combos/types.ts index 299d745f6..b7665728f 100644 --- a/testing/lib/generate-combos/types.ts +++ b/testing/lib/generate-combos/types.ts @@ -20,12 +20,13 @@ export type GeneratorArgs = { export const DEFAULT_ARGS: GeneratorArgs = { count: 10, - ecosystems: ["typescript", "rust", "python", "go", "java"], + ecosystems: ["typescript", "react-native", "rust", "python", "go", "java"], installMode: "install", }; export const DEFAULT_ECOSYSTEM_WEIGHTS: Record = { typescript: 4, + "react-native": 2, rust: 2, python: 2, go: 2, diff --git a/testing/smoke-test.ts b/testing/smoke-test.ts index b3e142d70..f97807a3f 100644 --- a/testing/smoke-test.ts +++ b/testing/smoke-test.ts @@ -68,7 +68,7 @@ function parseArgs(argv: string[]): SmokeTestArgs { i++; break; case "--ecosystem": - if (next && ["typescript", "rust", "python", "go", "java"].includes(next)) { + if (next && ["typescript", "react-native", "rust", "python", "go", "java"].includes(next)) { args.ecosystem = next as Ecosystem; } i++; @@ -163,7 +163,9 @@ function generateCombos(args: SmokeTestArgs) { const generatorArgs: GeneratorArgs = { count: args.count, - ecosystems: args.ecosystem ? [args.ecosystem] : ["typescript", "rust", "python", "go", "java"], + ecosystems: args.ecosystem + ? [args.ecosystem] + : ["typescript", "react-native", "rust", "python", "go", "java"], installMode: "no-install", rng, forceOptions: args.forceOptions, From 3b1a7536052b2fe54c51f03f868a7f593d16d177 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 12:27:51 +0300 Subject: [PATCH 13/33] Fix mobile deep linking defaults and CI Bun pin --- .github/workflows/codebase-deps.yaml | 2 +- .github/workflows/dep-freshness.yaml | 4 +-- .github/workflows/deps-check.yaml | 4 +-- .github/workflows/e2e-test.yaml | 12 ++++---- .github/workflows/pr-preview.yaml | 2 +- .github/workflows/smoke-test.yaml | 2 +- .github/workflows/template-matrix.yaml | 2 +- .github/workflows/test.yaml | 8 +++--- .github/workflows/upstream-gap.yml | 2 +- apps/cli/src/mcp.ts | 28 +++++++++++++------ apps/cli/test/mobile.test.ts | 27 ++++++++++++++++++ .../frontend/native/bare/package.json.hbs | 2 ++ .../frontend/native/base/App.tsx.hbs | 6 ++++ .../native/base/lib/deep-linking.ts.hbs | 2 ++ .../base/navigation/native-navigation.tsx.hbs | 8 ++++++ .../native/unistyles/package.json.hbs | 2 ++ .../frontend/native/uniwind/package.json.hbs | 2 ++ 17 files changed, 87 insertions(+), 28 deletions(-) diff --git a/.github/workflows/codebase-deps.yaml b/.github/workflows/codebase-deps.yaml index f9a60b9a3..1b54435d5 100644 --- a/.github/workflows/codebase-deps.yaml +++ b/.github/workflows/codebase-deps.yaml @@ -34,7 +34,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Resolve Update Mode id: mode diff --git a/.github/workflows/dep-freshness.yaml b/.github/workflows/dep-freshness.yaml index 3c21186b9..361c73feb 100644 --- a/.github/workflows/dep-freshness.yaml +++ b/.github/workflows/dep-freshness.yaml @@ -21,7 +21,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Install Dependencies run: bun install --frozen-lockfile @@ -47,7 +47,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Install Dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/deps-check.yaml b/.github/workflows/deps-check.yaml index 878fe811c..ed79113ba 100644 --- a/.github/workflows/deps-check.yaml +++ b/.github/workflows/deps-check.yaml @@ -43,7 +43,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Cache Dependencies uses: actions/cache@v4 @@ -227,7 +227,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index 1cafc0269..75a573a75 100644 --- a/.github/workflows/e2e-test.yaml +++ b/.github/workflows/e2e-test.yaml @@ -37,7 +37,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - uses: actions/setup-node@v4 with: @@ -91,7 +91,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - uses: actions/setup-node@v4 with: @@ -156,7 +156,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - uses: actions/setup-node@v4 with: @@ -221,7 +221,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - uses: actions/setup-node@v4 with: @@ -272,7 +272,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - uses: actions/setup-node@v4 with: @@ -317,7 +317,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pr-preview.yaml b/.github/workflows/pr-preview.yaml index 7941e6f25..70af9dec1 100644 --- a/.github/workflows/pr-preview.yaml +++ b/.github/workflows/pr-preview.yaml @@ -28,7 +28,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Cache Dependencies uses: actions/cache@v4 diff --git a/.github/workflows/smoke-test.yaml b/.github/workflows/smoke-test.yaml index 2da75570e..5dcd7f7c6 100644 --- a/.github/workflows/smoke-test.yaml +++ b/.github/workflows/smoke-test.yaml @@ -59,7 +59,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/template-matrix.yaml b/.github/workflows/template-matrix.yaml index e72420885..c5f223300 100644 --- a/.github/workflows/template-matrix.yaml +++ b/.github/workflows/template-matrix.yaml @@ -43,7 +43,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Setup Rust if: startsWith(matrix.preset, 'rust-') diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 83c7cb14c..bc3917719 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,7 +22,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Cache Dependencies uses: actions/cache@v4 @@ -51,7 +51,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Cache Dependencies uses: actions/cache@v4 @@ -89,7 +89,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Setup Deno uses: denoland/setup-deno@v2 @@ -124,7 +124,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Cache Dependencies uses: actions/cache@v4 diff --git a/.github/workflows/upstream-gap.yml b/.github/workflows/upstream-gap.yml index efb04d219..e7569ea6a 100644 --- a/.github/workflows/upstream-gap.yml +++ b/.github/workflows/upstream-gap.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.12 - name: Generate upstream gap report run: bun run scripts/upstream-gap-report.ts --markdown > upstream-gap-report.md diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 927bdb5fd..905163ecd 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -368,14 +368,17 @@ function buildProjectConfig( ): ProjectConfig { const projectName = (input.projectName as string) ?? "my-project"; const ecosystem = (input.ecosystem as ProjectConfig["ecosystem"]) ?? "typescript"; + const frontend = + (input.frontend as ProjectConfig["frontend"]) ?? + (ecosystem === "react-native" ? ["native-bare"] : ["tanstack-router"]); + const hasNativeFrontend = frontend.some((item) => item.startsWith("native-")); + const hasMobileProject = ecosystem === "react-native" || hasNativeFrontend; return { projectName, projectDir: overrides?.projectDir ?? "/virtual", relativePath: overrides ? `./${projectName}` : "./virtual", ecosystem, - frontend: - (input.frontend as ProjectConfig["frontend"]) ?? - (ecosystem === "react-native" ? ["native-bare"] : ["tanstack-router"]), + frontend, backend: (input.backend as ProjectConfig["backend"]) ?? (ecosystem === "react-native" ? "none" : "hono"), @@ -384,7 +387,7 @@ function buildProjectConfig( (ecosystem === "react-native" ? "none" : "bun"), database: (input.database as ProjectConfig["database"]) ?? "none", orm: (input.orm as ProjectConfig["orm"]) ?? "none", - api: (input.api as ProjectConfig["api"]) ?? (ecosystem === "react-native" ? "none" : "none"), + api: (input.api as ProjectConfig["api"]) ?? "none", auth: (input.auth as ProjectConfig["auth"]) ?? "none", payments: (input.payments as ProjectConfig["payments"]) ?? "none", email: (input.email as ProjectConfig["email"]) ?? "none", @@ -413,14 +416,17 @@ function buildProjectConfig( observability: (input.observability as ProjectConfig["observability"]) ?? "none", featureFlags: (input.featureFlags as ProjectConfig["featureFlags"]) ?? "none", analytics: (input.analytics as ProjectConfig["analytics"]) ?? "none", - mobileNavigation: (input.mobileNavigation as ProjectConfig["mobileNavigation"]) ?? "expo-router", + mobileNavigation: + (input.mobileNavigation as ProjectConfig["mobileNavigation"]) ?? + (hasMobileProject ? "expo-router" : "none"), mobileUI: (input.mobileUI as ProjectConfig["mobileUI"]) ?? "none", mobileStorage: (input.mobileStorage as ProjectConfig["mobileStorage"]) ?? "none", mobileTesting: (input.mobileTesting as ProjectConfig["mobileTesting"]) ?? "none", mobilePush: (input.mobilePush as ProjectConfig["mobilePush"]) ?? "none", mobileOTA: (input.mobileOTA as ProjectConfig["mobileOTA"]) ?? "none", mobileDeepLinking: - (input.mobileDeepLinking as ProjectConfig["mobileDeepLinking"]) ?? "expo-linking", + (input.mobileDeepLinking as ProjectConfig["mobileDeepLinking"]) ?? + (hasMobileProject ? "expo-linking" : "none"), cms: (input.cms as ProjectConfig["cms"]) ?? "none", caching: (input.caching as ProjectConfig["caching"]) ?? "none", i18n: (input.i18n as ProjectConfig["i18n"]) ?? "none", @@ -490,6 +496,8 @@ function buildCompatibilityInput(input: Record): CompatibilityI const webFrontend = (frontend ?? []).filter((item) => !item.startsWith("native-")); const nativeFrontend = (frontend ?? []).filter((item) => item.startsWith("native-")); const addons = (input.addons as string[] | undefined) ?? []; + const ecosystem = (input.ecosystem as CompatibilityInput["ecosystem"]) ?? "typescript"; + const hasMobileProject = ecosystem === "react-native" || nativeFrontend.length > 0; const codeQuality = addons.filter((a) => ["biome", "oxlint", "ultracite", "lefthook", "husky", "ruler"].includes(a), @@ -501,7 +509,7 @@ function buildCompatibilityInput(input: Record): CompatibilityI ); return { - ecosystem: (input.ecosystem as CompatibilityInput["ecosystem"]) ?? "typescript", + ecosystem, projectName: (input.projectName as string) ?? null, webFrontend, nativeFrontend, @@ -541,13 +549,15 @@ function buildCompatibilityInput(input: Record): CompatibilityI cms: (input.cms as string) ?? "none", search: (input.search as string) ?? "none", fileStorage: (input.fileStorage as string) ?? "none", - mobileNavigation: (input.mobileNavigation as string) ?? "expo-router", + mobileNavigation: + (input.mobileNavigation as string) ?? (hasMobileProject ? "expo-router" : "none"), mobileUI: (input.mobileUI as string) ?? "none", mobileStorage: (input.mobileStorage as string) ?? "none", mobileTesting: (input.mobileTesting as string) ?? "none", mobilePush: (input.mobilePush as string) ?? "none", mobileOTA: (input.mobileOTA as string) ?? "none", - mobileDeepLinking: (input.mobileDeepLinking as string) ?? "expo-linking", + mobileDeepLinking: + (input.mobileDeepLinking as string) ?? (hasMobileProject ? "expo-linking" : "none"), codeQuality, documentation, appPlatforms, diff --git a/apps/cli/test/mobile.test.ts b/apps/cli/test/mobile.test.ts index d6ac8699a..923c5401e 100644 --- a/apps/cli/test/mobile.test.ts +++ b/apps/cli/test/mobile.test.ts @@ -91,4 +91,31 @@ describe("mobile native scaffolding", () => { expect(pkg.dependencies["expo-router"]).toBe("^55.0.14"); expect(appConfig.expo.plugins).toContain("expo-router"); }); + + test("omits deep-linking wiring when React Navigation selects no deep linking", async () => { + const result = await createVirtual({ + projectName: "mobile-no-linking", + frontend: ["native-bare"], + backend: "hono", + api: "none", + database: "none", + orm: "none", + auth: "none", + mobileNavigation: "react-navigation", + mobileDeepLinking: "none", + }); + + expect(result.success).toBe(true); + const root = result.tree!.root; + const pkg = JSON.parse(getFile(root, "apps/native/package.json")); + const app = getFile(root, "apps/native/App.tsx"); + const navigation = getFile(root, "apps/native/navigation/native-navigation.tsx"); + + expect(pkg.dependencies["expo-linking"]).toBeUndefined(); + expect(app).toContain(""); + expect(app).not.toContain("linking={linking}"); + expect(app).not.toContain("@/lib/deep-linking"); + expect(navigation).not.toContain("@/lib/deep-linking"); + expect(findFile(root, "apps/native/lib/deep-linking.ts")).toBeUndefined(); + }); }); diff --git a/packages/template-generator/templates/frontend/native/bare/package.json.hbs b/packages/template-generator/templates/frontend/native/bare/package.json.hbs index 74e8e72e6..be2308070 100644 --- a/packages/template-generator/templates/frontend/native/bare/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/bare/package.json.hbs @@ -36,7 +36,9 @@ "expo-device": "^8.0.9", {{/if}} "expo-crypto": "^55.0.15", + {{#if (eq mobileDeepLinking "expo-linking")}} "expo-linking": "^55.0.15", + {{/if}} "expo-navigation-bar": "^55.0.13", "expo-network": "^55.0.14", {{#if (eq mobilePush "expo-notifications")}} diff --git a/packages/template-generator/templates/frontend/native/base/App.tsx.hbs b/packages/template-generator/templates/frontend/native/base/App.tsx.hbs index e43b8e1b1..577199480 100644 --- a/packages/template-generator/templates/frontend/native/base/App.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/base/App.tsx.hbs @@ -29,7 +29,9 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StyleSheet } from "react-native"; import { MobileUIProvider } from "@/components/mobile-ui-provider"; +{{#if (eq mobileDeepLinking "expo-linking")}} import { linking } from "@/lib/deep-linking"; +{{/if}} {{#if (eq api "trpc")}} import { queryClient } from "@/utils/trpc"; {{/if}} @@ -55,7 +57,11 @@ function AppShell() { + {{#if (eq mobileDeepLinking "expo-linking")}} + {{else}} + + {{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs b/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs index 4aba6fbcf..22cc063a4 100644 --- a/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs +++ b/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs @@ -1,3 +1,4 @@ +{{#if (eq mobileDeepLinking "expo-linking")}} import * as Linking from "expo-linking"; export const appScheme = "{{projectName}}"; @@ -21,3 +22,4 @@ export function getAuthRedirectUri(path = "auth/callback") { export function getDeepLinkUrl(path = "") { return Linking.createURL(path); } +{{/if}} diff --git a/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs b/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs index 597f138c5..07dcaf871 100644 --- a/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs @@ -14,7 +14,9 @@ import { useUpdateCheck } from "@/lib/updates"; {{#if (eq mobileStorage "mmkv")}} import { mobileStorage } from "@/lib/mobile-storage"; {{/if}} +{{#if (eq mobileDeepLinking "expo-linking")}} import { getAuthRedirectUri } from "@/lib/deep-linking"; +{{/if}} import { useEffect } from "react"; type RootStackParamList = { @@ -55,7 +57,11 @@ function HomeScreen() { {user ? `Signed in as ${user.primaryEmailAddress?.emailAddress}` : "Auth routes are ready for Clerk."} {{/if}} {{#if (eq auth "better-auth")}} + {{#if (eq mobileDeepLinking "expo-linking")}} Use {getAuthRedirectUri()} as the Better Auth mobile callback URL. + {{else}} + Better Auth mobile routes are ready for your callback URL. + {{/if}} {{/if}} {{#if (eq mobileOTA "expo-updates")}} {isChecking ? "Checking for updates..." : isUpdateAvailable ? "An update is ready." : "App is up to date."} @@ -68,7 +74,9 @@ function SettingsScreen() { return ( Mobile integrations + {{#if (eq mobileDeepLinking "expo-linking")}} Deep link redirect URI: {getAuthRedirectUri()} + {{/if}} {{#if (eq mobileStorage "mmkv")}} MMKV is configured in lib/mobile-storage.ts. {{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs index 752e422a2..4f5753bb1 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs @@ -35,7 +35,9 @@ "expo-device": "^8.0.9", {{/if}} "expo-crypto": "^55.0.15", + {{#if (eq mobileDeepLinking "expo-linking")}} "expo-linking": "^55.0.15", + {{/if}} {{#if (eq mobileNavigation "expo-router")}} "expo-router": "^55.0.14", {{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs index 98222b6f5..08a2b927d 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs @@ -33,7 +33,9 @@ {{/if}} "expo-font": "^55.0.7", "expo-haptics": "^55.0.14", + {{#if (eq mobileDeepLinking "expo-linking")}} "expo-linking": "^55.0.15", + {{/if}} "expo-network": "^55.0.14", {{#if (eq mobilePush "expo-notifications")}} "expo-notifications": "^56.0.12", From f347c9cd40c847a30d1ae24bcecc6352fce1da1a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 12:35:06 +0300 Subject: [PATCH 14/33] Fix TypeScript builder command default detection --- apps/web/test/stack-command-parity.test.ts | 16 ++++++++++++++++ packages/types/src/stack-translation.ts | 7 ------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/web/test/stack-command-parity.test.ts b/apps/web/test/stack-command-parity.test.ts index e5485b7f4..b04f016b9 100644 --- a/apps/web/test/stack-command-parity.test.ts +++ b/apps/web/test/stack-command-parity.test.ts @@ -26,6 +26,22 @@ describe("generateStackCommand parity", () => { expect(command).toContain("--feature-flags launchdarkly"); }); + it("does not treat core TypeScript stack selections as React Native-only defaults", () => { + const backendCommand = generateStackCommand({ + ...DEFAULT_STACK, + backend: "fastify", + }); + const frontendCommand = generateStackCommand({ + ...DEFAULT_STACK, + webFrontend: ["next"], + }); + + expect(backendCommand).not.toContain("--yes"); + expect(backendCommand).toContain("--backend fastify"); + expect(frontendCommand).not.toContain("--yes"); + expect(frontendCommand).toContain("--frontend next"); + }); + it("maps builder-only aliases to CLI flags", () => { const command = generateStackCommand({ ...DEFAULT_STACK, diff --git a/packages/types/src/stack-translation.ts b/packages/types/src/stack-translation.ts index 7ce9f37ee..c0b87972b 100644 --- a/packages/types/src/stack-translation.ts +++ b/packages/types/src/stack-translation.ts @@ -666,13 +666,6 @@ const JAVA_CONFIG_KEYS = [ ] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[]; const REACT_NATIVE_CONFIG_KEYS = [ - "frontend", - "backend", - "runtime", - "database", - "orm", - "api", - "auth", "mobileNavigation", "mobileUI", "mobileStorage", From b4c7494c195808119cd5f1337c2c47a966a0d6c8 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 12:47:33 +0300 Subject: [PATCH 15/33] Fix React Native smoke verification --- apps/cli/src/mcp.ts | 4 +- .../native/uniwind/app/(drawer)/index.tsx.hbs | 4 +- .../native/uniwind/app/_layout.tsx.hbs | 3 +- .../frontend/native/uniwind/tsconfig.json.hbs | 4 +- .../native/uniwind/uniwind-env.d.ts.hbs | 3 + testing/lib/verify.ts | 60 +++++++++++++++++++ testing/smoke-test.ts | 3 +- 7 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 packages/template-generator/templates/frontend/native/uniwind/uniwind-env.d.ts.hbs diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts index 905163ecd..66ced3818 100644 --- a/apps/cli/src/mcp.ts +++ b/apps/cli/src/mcp.ts @@ -732,11 +732,11 @@ export async function startMcpServer() { { instructions: INSTRUCTIONS, capabilities: { logging: {} } }, ); - const registerTool = server.tool.bind(server) as unknown as ( + const registerTool = server.tool.bind(server) as unknown as >( name: string, description: string, inputSchema: Record, - cb: (input: any) => unknown, + cb: (input: Input) => unknown, ) => void; registerTool( diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs index 22c0b5182..d00b7367d 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/(drawer)/index.tsx.hbs @@ -28,7 +28,7 @@ import { api } from "@{{projectName}}/backend/convex/_generated/api"; {{#unless (or (eq backend "none") (and (eq backend "convex") (eq auth "better-auth")))}} import { Ionicons } from "@expo/vector-icons"; {{/unless}} -import { Button, Chip, Divider, Spinner, Surface, useThemeColor } from "heroui-native"; +import { Button, Chip, Spinner, Surface, useThemeColor } from "heroui-native"; export default function Home() { {{#if (eq api "orpc")}} @@ -83,7 +83,7 @@ return ( - + diff --git a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs index 6d4e1e744..bb53a3543 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/app/_layout.tsx.hbs @@ -29,6 +29,7 @@ import "@/global.css"; import { Stack } from "expo-router"; import { HeroUINativeProvider } from "heroui-native"; +import { useEffect } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; {{#if (eq mobilePush "expo-notifications")}} @@ -72,7 +73,7 @@ export default function Layout() { {{#if (eq mobileOTA "expo-updates")}} useUpdateCheck(); {{/if}} - React.useEffect(() => { + useEffect(() => { {{#if (eq mobilePush "expo-notifications")}} registerForPushNotificationsAsync().catch(console.warn); {{/if}} diff --git a/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs index 6f9774af6..f889a00d0 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/tsconfig.json.hbs @@ -2,12 +2,14 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "jsx": "react-jsx", "paths": { "@/*": ["./*"] } }, "include": [ "**/*.ts", - "**/*.tsx" + "**/*.tsx", + "uniwind-env.d.ts" ] } diff --git a/packages/template-generator/templates/frontend/native/uniwind/uniwind-env.d.ts.hbs b/packages/template-generator/templates/frontend/native/uniwind/uniwind-env.d.ts.hbs new file mode 100644 index 000000000..71f7bd466 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/uniwind/uniwind-env.d.ts.hbs @@ -0,0 +1,3 @@ +/// + +declare module "*.css"; diff --git a/testing/lib/verify.ts b/testing/lib/verify.ts index 751d14a80..f90103901 100644 --- a/testing/lib/verify.ts +++ b/testing/lib/verify.ts @@ -204,6 +204,16 @@ function skippedStep(step: string): StepResult { return { step, success: true, durationMs: 0, skipped: true }; } +function templateFailure(step: string, stderr: string): StepResult { + return { + step, + success: false, + durationMs: 0, + stderr, + classification: "template", + }; +} + async function runAdvisoryStep( step: string, command: string, @@ -311,6 +321,54 @@ export async function verifyTypeScript( return wrapResult("typescript", comboName, projectDir, steps); } +export async function verifyReactNative( + comboName: string, + projectDir: string, + options?: VerifyOptions, +): Promise { + const steps: StepResult[] = []; + const nativeDir = join(projectDir, "apps", "native"); + + if (!existsSync(nativeDir)) { + steps.push(templateFailure("structure", "Expected React Native app at apps/native")); + return wrapResult("react-native", comboName, projectDir, steps); + } + + for (const requiredFile of ["package.json", "app.json", "tsconfig.json"]) { + if (!existsSync(join(nativeDir, requiredFile))) { + steps.push(templateFailure("structure", `Expected apps/native/${requiredFile}`)); + return wrapResult("react-native", comboName, projectDir, steps); + } + } + + steps.push(await runStep("install", "bun", ["install"], projectDir)); + if (!steps.at(-1)!.success) return wrapResult("react-native", comboName, projectDir, steps); + + steps.push( + await runStep( + "typecheck", + "bunx", + ["tsc", "-p", "apps/native/tsconfig.json", "--noEmit"], + projectDir, + ), + ); + if (!steps.at(-1)!.success) return wrapResult("react-native", comboName, projectDir, steps); + + if ( + hasPackageScript(nativeDir, "test") && + (options?.config?.mobileTesting === "react-native-testing-library" || + options?.config?.mobileTesting === "maestro-react-native-testing-library") + ) { + steps.push(await runStep("test", "bun", ["run", "test", "--runInBand"], nativeDir)); + } else { + steps.push(skippedStep("test")); + } + + steps.push(skippedStep("simulator")); + + return wrapResult("react-native", comboName, projectDir, steps); +} + export async function verifyRust(comboName: string, projectDir: string): Promise { const steps: StepResult[] = []; @@ -428,6 +486,8 @@ export function getVerifier( switch (ecosystem) { case "typescript": return verifyTypeScript; + case "react-native": + return verifyReactNative; case "rust": return verifyRust; case "python": diff --git a/testing/smoke-test.ts b/testing/smoke-test.ts index f97807a3f..ac5a7329d 100644 --- a/testing/smoke-test.ts +++ b/testing/smoke-test.ts @@ -2,8 +2,7 @@ import type { Ecosystem } from "@better-fullstack/types"; -import { existsSync } from "node:fs"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; From a57f94602303c7594d0940377a40f01023c33dff Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 12:58:50 +0300 Subject: [PATCH 16/33] Preserve TypeScript native frontend compatibility --- apps/cli/src/utils/config-validation.ts | 11 ----------- packages/types/src/compatibility.ts | 4 ---- 2 files changed, 15 deletions(-) diff --git a/apps/cli/src/utils/config-validation.ts b/apps/cli/src/utils/config-validation.ts index 3a1fd8b13..dd30a7e57 100644 --- a/apps/cli/src/utils/config-validation.ts +++ b/apps/cli/src/utils/config-validation.ts @@ -574,17 +574,6 @@ export function validateFrontendConstraints( }); } - if ( - config.ecosystem === "typescript" && - frontend.some((item) => item.startsWith("native-")) - ) { - incompatibilityError({ - message: "Native Expo frontends now belong to the React Native ecosystem.", - provided: { ecosystem: "typescript", frontend: frontend.join(" ") }, - suggestions: ["Use --ecosystem react-native with --frontend native-bare"], - }); - } - ensureSingleWebAndNative(frontend); if (providedFlags.has("api") && providedFlags.has("frontend") && config.api) { diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index 56a306105..1b8d9aaf7 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -1677,10 +1677,6 @@ export const getDisabledReason = ( } } - if (currentStack.ecosystem === "typescript" && category === "nativeFrontend" && optionId !== "none") { - return "Use the React Native ecosystem for native Expo frontends"; - } - // ============================================ // NO BACKEND - locks down backend-dependent options // ============================================ From f7b9fa459cd11e5d5092cbf38a93749b5edd5c84 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 13:57:50 +0300 Subject: [PATCH 17/33] Fix React Native builder ecosystem categories --- .../stack-builder/stack-builder.tsx | 19 ++----------------- apps/web/src/lib/stack-utils.ts | 19 +++++++++++++++++++ apps/web/test/stack-state-contract.test.ts | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx index fa12ed9ea..73d9a2008 100644 --- a/apps/web/src/components/stack-builder/stack-builder.tsx +++ b/apps/web/src/components/stack-builder/stack-builder.tsx @@ -56,11 +56,7 @@ import { CATEGORY_ORDER, generateStackCommand, generateStackSharingUrl, - GO_CATEGORY_ORDER, - JAVA_CATEGORY_ORDER, - PYTHON_CATEGORY_ORDER, - RUST_CATEGORY_ORDER, - TYPESCRIPT_CATEGORY_ORDER, + getCategoryOrderForEcosystem, } from "@/lib/stack-utils"; import { ICON_REGISTRY } from "@/lib/tech-icons"; import { getTechResourceLinks } from "@/lib/tech-resource-links"; @@ -540,18 +536,7 @@ const StackBuilder = () => { // ─── Derived state ────────────────────────────────────────────────────── const categoryOrder = useMemo(() => { - switch (stack.ecosystem) { - case "rust": - return RUST_CATEGORY_ORDER; - case "python": - return PYTHON_CATEGORY_ORDER; - case "go": - return GO_CATEGORY_ORDER; - case "java": - return JAVA_CATEGORY_ORDER; - default: - return TYPESCRIPT_CATEGORY_ORDER; - } + return getCategoryOrderForEcosystem(stack.ecosystem); }, [stack.ecosystem]); const sidebarCategories = useMemo(() => { diff --git a/apps/web/src/lib/stack-utils.ts b/apps/web/src/lib/stack-utils.ts index 2be52d7c9..a926e9775 100644 --- a/apps/web/src/lib/stack-utils.ts +++ b/apps/web/src/lib/stack-utils.ts @@ -166,6 +166,25 @@ const CATEGORY_ORDER = [ ]), ] as Array; +export function getCategoryOrderForEcosystem( + ecosystem: StackState["ecosystem"], +): Array { + switch (ecosystem) { + case "react-native": + return REACT_NATIVE_CATEGORY_ORDER; + case "rust": + return RUST_CATEGORY_ORDER; + case "python": + return PYTHON_CATEGORY_ORDER; + case "go": + return GO_CATEGORY_ORDER; + case "java": + return JAVA_CATEGORY_ORDER; + case "typescript": + return TYPESCRIPT_CATEGORY_ORDER; + } +} + export function generateStackSummary(stack: StackState) { const selectedTechs = CATEGORY_ORDER.flatMap((category) => { const options = TECH_OPTIONS[category]; diff --git a/apps/web/test/stack-state-contract.test.ts b/apps/web/test/stack-state-contract.test.ts index 8885e96f6..6d31e405e 100644 --- a/apps/web/test/stack-state-contract.test.ts +++ b/apps/web/test/stack-state-contract.test.ts @@ -3,7 +3,13 @@ import { describe, expect, it } from "bun:test"; import { DEFAULT_STACK } from "../src/lib/stack-defaults"; import { NON_OPTION_STACK_KEYS, STACK_STATE_OPTION_CATEGORY_BY_KEY } from "../src/lib/stack-contract"; +import { ECOSYSTEM_CATEGORIES } from "../src/lib/constant"; import { normalizeStackStateSelections } from "../src/lib/stack-option-normalization"; +import { + getCategoryOrderForEcosystem, + REACT_NATIVE_CATEGORY_ORDER, + TYPESCRIPT_CATEGORY_ORDER, +} from "../src/lib/stack-utils"; import { stackUrlKeys } from "../src/lib/stack-url-keys"; import { createStackSearchParams, @@ -115,4 +121,15 @@ describe("StackState contract", () => { expect(normalized.pythonAi).toEqual([]); expect(normalized.aiDocs).toEqual([]); }); + + it("uses React Native categories when the React Native ecosystem is selected", () => { + expect(getCategoryOrderForEcosystem("react-native")).toBe(REACT_NATIVE_CATEGORY_ORDER); + expect(getCategoryOrderForEcosystem("react-native")).not.toBe(TYPESCRIPT_CATEGORY_ORDER); + expect(getCategoryOrderForEcosystem("react-native")).toContain("nativeFrontend"); + expect(getCategoryOrderForEcosystem("react-native")).toContain("mobileNavigation"); + expect(getCategoryOrderForEcosystem("react-native")).not.toContain("webFrontend"); + expect(getCategoryOrderForEcosystem("react-native")).toEqual( + expect.arrayContaining(ECOSYSTEM_CATEGORIES["react-native"]), + ); + }); }); From 2fd9c9a701e10f3dd6414378a4fe004d8668a525 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 19:02:55 +0300 Subject: [PATCH 18/33] Fix mobile smoke template failures --- .../backend/server/adonisjs/tsconfig.json.hbs | 3 +- .../components/src/emails/index.ts.hbs | 2 +- .../resend/components/src/emails/index.ts.hbs | 2 +- .../native/unistyles/app/_layout.tsx.hbs | 9 ++++- .../native/unistyles/package.json.hbs | 3 ++ .../frontend/native/unistyles/theme.ts.hbs | 1 + testing/lib/generate-combos/options.test.ts | 34 +++++++++++++++++++ testing/lib/generate-combos/options.ts | 5 --- testing/lib/generate-combos/render.test.ts | 5 +-- 9 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 testing/lib/generate-combos/options.test.ts diff --git a/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs b/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs index f0ddf05b5..20dd85815 100644 --- a/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs +++ b/packages/template-generator/templates/backend/server/adonisjs/tsconfig.json.hbs @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "./build", "rootDir": ".", + "jsx": "react-jsx", "paths": { "#controllers/*": ["./app/controllers/*.js"], "#middleware/*": ["./app/middleware/*.js"], @@ -10,6 +11,6 @@ "#config/*": ["./config/*.js"] } }, - "include": ["**/*.ts"], + "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules", "build"] } diff --git a/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs b/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs index 7e15ae9f0..b2bc023fb 100644 --- a/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs +++ b/packages/template-generator/templates/email/react-email/components/src/emails/index.ts.hbs @@ -1 +1 @@ -export { WelcomeEmail } from "./welcome"; +export { WelcomeEmail } from "./welcome.js"; diff --git a/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs b/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs index 7e15ae9f0..b2bc023fb 100644 --- a/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs +++ b/packages/template-generator/templates/email/resend/components/src/emails/index.ts.hbs @@ -1 +1 @@ -export { WelcomeEmail } from "./welcome"; +export { WelcomeEmail } from "./welcome.js"; diff --git a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs index 44a5a8e0a..fdd1450eb 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/app/_layout.tsx.hbs @@ -29,9 +29,16 @@ import { QueryClientProvider } from "@tanstack/react-query"; {{/unless}} {{/if}} import { Stack } from "expo-router"; +import { useEffect } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { useUnistyles } from "react-native-unistyles"; import { StatusBar } from "expo-status-bar"; +{{#if (eq mobilePush "expo-notifications")}} +import { registerForPushNotificationsAsync } from "@/lib/notifications"; +{{/if}} +{{#if (eq mobileOTA "expo-updates")}} +import { useUpdateCheck } from "@/lib/updates"; +{{/if}} export const unstable_settings = { initialRouteName: "(drawer)", @@ -47,7 +54,7 @@ export default function RootLayout() { {{#if (eq mobileOTA "expo-updates")}} useUpdateCheck(); {{/if}} - React.useEffect(() => { + useEffect(() => { {{#if (eq mobilePush "expo-notifications")}} registerForPushNotificationsAsync().catch(console.warn); {{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs index 4f5753bb1..29a2badb2 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs @@ -38,6 +38,9 @@ {{#if (eq mobileDeepLinking "expo-linking")}} "expo-linking": "^55.0.15", {{/if}} + {{#if (eq mobilePush "expo-notifications")}} + "expo-notifications": "^56.0.12", + {{/if}} {{#if (eq mobileNavigation "expo-router")}} "expo-router": "^55.0.14", {{/if}} diff --git a/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs b/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs index 4c58baac7..19d9d4b52 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/theme.ts.hbs @@ -1,6 +1,7 @@ const sharedColors = { success: "#22C55E", destructive: "#EF4444", + destructiveForeground: "#FFFFFF", warning: "#F59E0B", info: "#3B82F6", } as const; diff --git a/testing/lib/generate-combos/options.test.ts b/testing/lib/generate-combos/options.test.ts new file mode 100644 index 000000000..7d07044cd --- /dev/null +++ b/testing/lib/generate-combos/options.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; + +import { generateBatch } from "./options"; +import { createSeededRandom, seedFromString } from "./seed-random"; + +describe("smoke combo generation", () => { + it("keeps native frontends in the React Native ecosystem", () => { + const combos = generateBatch( + { + count: 24, + ecosystems: ["typescript", "react-native"], + installMode: "no-install", + rng: createSeededRandom(seedFromString("react-native-ecosystem-split")), + }, + { + fingerprintKeys: new Set(), + legacyNames: new Set(), + historyCount: 0, + }, + ); + + const nativeFrontend = (frontend: string) => frontend.startsWith("native-"); + + for (const combo of combos) { + if (combo.ecosystem === "typescript") { + expect(combo.config.frontend.some(nativeFrontend)).toBe(false); + } + + if (combo.config.frontend.some(nativeFrontend)) { + expect(combo.ecosystem).toBe("react-native"); + } + } + }); +}); diff --git a/testing/lib/generate-combos/options.ts b/testing/lib/generate-combos/options.ts index ede2fd0e2..ed9e4dff6 100644 --- a/testing/lib/generate-combos/options.ts +++ b/testing/lib/generate-combos/options.ts @@ -186,11 +186,6 @@ function sampleTypeScriptFrontends(): CLIInput["frontend"] { picked.push(web); } - const native = sampleScalar(NATIVE_FRONTENDS, picked.length === 0 ? 0.85 : 0.95); - if (native !== "none") { - picked.push(native); - } - if (picked.length === 0 && _rng() > 0.25) { picked.push(sampleOne(WEB_FRONTENDS.filter((value) => value !== "none"))); } diff --git a/testing/lib/generate-combos/render.test.ts b/testing/lib/generate-combos/render.test.ts index 876ea2b90..f81174153 100644 --- a/testing/lib/generate-combos/render.test.ts +++ b/testing/lib/generate-combos/render.test.ts @@ -4,13 +4,14 @@ import { createCliDefaultProjectConfigBase, type ProjectConfig } from "@better-f import { buildCommand } from "./render"; describe("smoke combo command rendering", () => { - it("includes mobile flags for TypeScript commands", () => { + it("includes mobile flags for React Native commands", () => { const config: ProjectConfig = { ...createCliDefaultProjectConfigBase("bun"), projectName: "mobile-smoke", relativePath: "mobile-smoke", projectDir: "/tmp/mobile-smoke", - frontend: ["solid-start", "native-unistyles"], + ecosystem: "react-native", + frontend: ["native-unistyles"], mobileNavigation: "expo-router", mobileUI: "unistyles", mobileStorage: "none", From 1d87ba5bc375d03d9a5332e2b00a9a62eb057635 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 19:48:27 +0300 Subject: [PATCH 19/33] Validate builder tech icons --- .github/workflows/test.yaml | 3 + apps/cli/test/frontend.test.ts | 61 ++++--- apps/web/package.json | 1 + apps/web/scripts/validate-tech-icons.ts | 162 ++++++++++++++++++ apps/web/src/lib/constant.ts | 14 +- apps/web/src/lib/tech-icons.ts | 8 - .../src/template-handlers/frontend.ts | 14 +- .../frontend/fresh-root/deno.json.hbs | 7 + .../frontend/fresh/components/Header.tsx.hbs | 1 + .../templates/frontend/fresh/deno.json.hbs | 8 +- .../frontend/fresh/islands/Counter.tsx.hbs | 1 + .../frontend/fresh/routes/_app.tsx.hbs | 1 + .../frontend/fresh/routes/index.tsx.hbs | 1 + 13 files changed, 239 insertions(+), 43 deletions(-) create mode 100644 apps/web/scripts/validate-tech-icons.ts create mode 100644 packages/template-generator/templates/frontend/fresh-root/deno.json.hbs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 83c7cb14c..4560d5038 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -75,6 +75,9 @@ jobs: - name: Validate Builder Tech Links run: bun run --cwd apps/web validate:tech-links + - name: Validate Builder Tech Icons + run: bun run --cwd apps/web validate:tech-icons + - name: Run Lint run: bun run lint diff --git a/apps/cli/test/frontend.test.ts b/apps/cli/test/frontend.test.ts index 49834b67d..426006ce7 100644 --- a/apps/cli/test/frontend.test.ts +++ b/apps/cli/test/frontend.test.ts @@ -466,6 +466,7 @@ describe("Frontend Configurations", () => { expectSuccess(result); if (result.projectDir) { + const rootDenoJson = await Bun.file(`${result.projectDir}/deno.json`).json(); const denoJson = await Bun.file(`${result.projectDir}/apps/web/deno.json`).text(); const webPkg = await Bun.file(`${result.projectDir}/apps/web/package.json`).json(); const readme = await Bun.file(`${result.projectDir}/README.md`).text(); @@ -475,8 +476,18 @@ describe("Frontend Configurations", () => { const modernApp = Bun.file(`${result.projectDir}/apps/web/routes/_app.tsx`); const legacyLayout = Bun.file(`${result.projectDir}/apps/web/src/routes/_layout.tsx`); + expect(rootDenoJson).toMatchObject({ + lock: false, + nodeModulesDir: "auto", + workspace: ["./apps/web"], + }); expect(denoJson).toContain('"fresh": "jsr:@fresh/core@^2.2.0"'); + expect(denoJson).toContain('"preact/": "npm:preact@^10.27.2/"'); + expect(denoJson).toContain('"preact/jsx-runtime": "npm:preact@^10.27.2/jsx-runtime"'); + expect(denoJson).toContain('"preact/jsx-dev-runtime": "npm:preact@^10.27.2/jsx-dev-runtime"'); + expect(denoJson).toContain('"@preact/signals/": "npm:@preact/signals@^2.5.0/"'); expect(denoJson).toContain('"build": "vite build"'); + expect(denoJson).not.toContain('"nodeModulesDir"'); expect(webPkg.scripts["check-types"]).toBe("deno check"); expect(readme).toContain("http://localhost:5173"); expect(await viteConfig.exists()).toBe(true); @@ -487,32 +498,36 @@ describe("Frontend Configurations", () => { } }); - it.skipIf(!hasDeno)("should pass Deno check and build for Fresh", async () => { - const result = await runTRPCTest({ - projectName: "fresh-runtime-smoke", - frontend: ["fresh"], - backend: "none", - runtime: "none", - database: "none", - orm: "none", - auth: "none", - api: "none", - addons: ["none"], - examples: ["none"], - dbSetup: "none", - webDeploy: "none", - serverDeploy: "none", - install: true, - }); + it.skipIf(!hasDeno)( + "should pass Deno check and build for Fresh", + async () => { + const result = await runTRPCTest({ + projectName: "fresh-runtime-smoke", + frontend: ["fresh"], + backend: "none", + runtime: "none", + database: "none", + orm: "none", + auth: "none", + api: "none", + addons: ["none"], + examples: ["none"], + dbSetup: "none", + webDeploy: "none", + serverDeploy: "none", + install: true, + }); - expectSuccess(result); - expect(result.projectDir).toBeDefined(); + expectSuccess(result); + expect(result.projectDir).toBeDefined(); - const projectDir = result.projectDir!; + const projectDir = result.projectDir!; - await execa("bun", ["run", "--filter", "web", "check-types"], { cwd: projectDir }); - await execa("bun", ["run", "--filter", "web", "build"], { cwd: projectDir }); - }, 120000); + await execa("bun", ["run", "--filter", "web", "check-types"], { cwd: projectDir }); + await execa("bun", ["run", "--filter", "web", "build"], { cwd: projectDir }); + }, + 120000, + ); it("should fail Fresh with tRPC API", async () => { const result = await runTRPCTest({ diff --git a/apps/web/package.json b/apps/web/package.json index da2d97beb..6d99b1171 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "test": "bun test ./test/*.test.ts", "typecheck": "tsc --noEmit", "validate:tech-links": "bun run scripts/validate-tech-resource-links.ts", + "validate:tech-icons": "bun run scripts/validate-tech-icons.ts", "perf:check": "node scripts/check-performance-budget.mjs", "perf:baseline": "node scripts/check-performance-budget.mjs --update-baseline", "test:e2e": "playwright test", diff --git a/apps/web/scripts/validate-tech-icons.ts b/apps/web/scripts/validate-tech-icons.ts new file mode 100644 index 000000000..bb61b2d3e --- /dev/null +++ b/apps/web/scripts/validate-tech-icons.ts @@ -0,0 +1,162 @@ +import { ECOSYSTEMS, PRESET_CATEGORIES, TECH_OPTIONS } from "../src/lib/constant"; +import { computeSiUrl, ICON_REGISTRY, type IconConfig } from "../src/lib/tech-icons"; + +type IconTarget = { + owner: string; + src: string; +}; + +const REQUEST_TIMEOUT_MS = 15_000; + +function addTarget(targets: Map>, owner: string, src: string) { + const value = src.trim(); + if (!value) return; + + const owners = targets.get(value) ?? new Set(); + owners.add(owner); + targets.set(value, owners); +} + +function addConfigTarget(targets: Map>, owner: string, config?: IconConfig) { + if (!config) return; + + if (config.type === "si") { + addTarget(targets, owner, computeSiUrl(config.slug, config.hex, false)); + return; + } + + addTarget(targets, owner, config.src); +} + +function addRenderedTechIconTarget( + targets: Map>, + owner: string, + techId: string, + fallbackIcon: string, +) { + const config = ICON_REGISTRY[techId]; + if (config) { + addConfigTarget(targets, `${owner} via ICON_REGISTRY:${techId}`, config); + return; + } + + addTarget(targets, owner, fallbackIcon); +} + +function getFetchErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +async function fetchStatus(url: string): Promise { + const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const head = await fetch(url, { + method: "HEAD", + redirect: "follow", + signal, + }); + + if (head.ok) return head.status; + if (head.status === 403 || head.status === 405) { + const get = await fetch(url, { + method: "GET", + redirect: "follow", + signal, + }); + return get.status; + } + + return head.status; +} + +function isLocalIconPath(src: string): boolean { + return src.startsWith("/icon/"); +} + +function isRemoteIconUrl(src: string): boolean { + try { + const url = new URL(src); + return url.protocol === "https:"; + } catch { + return false; + } +} + +function collectIconTargets(): IconTarget[] { + const targets = new Map>(); + + for (const [category, options] of Object.entries(TECH_OPTIONS)) { + for (const option of options) { + const owner = `${category}:${option.id}`; + addRenderedTechIconTarget(targets, owner, option.id, option.icon); + } + } + + for (const ecosystem of ECOSYSTEMS) { + addRenderedTechIconTarget(targets, `ECOSYSTEMS:${ecosystem.id}`, ecosystem.id, ecosystem.icon); + } + + for (const category of PRESET_CATEGORIES) { + addRenderedTechIconTarget(targets, `PRESET_CATEGORIES:${category.id}`, category.icon, ""); + } + + for (const [id, config] of Object.entries(ICON_REGISTRY)) { + addConfigTarget(targets, `ICON_REGISTRY:${id}`, config); + } + + return [...targets.entries()].map(([src, owners]) => ({ + src, + owner: [...owners].sort().join(", "), + })); +} + +async function run() { + const errors: string[] = []; + const warnings: string[] = []; + const targets = collectIconTargets(); + + for (const target of targets) { + if (isLocalIconPath(target.src)) { + const path = `public${target.src}`; + if (!(await Bun.file(path).exists())) { + errors.push(`${target.owner} uses missing local icon ${target.src}`); + } + continue; + } + + if (!isRemoteIconUrl(target.src)) { + warnings.push(`${target.owner} uses a non-loadable icon token "${target.src}"`); + continue; + } + + try { + const status = await fetchStatus(target.src); + if (status >= 400) { + errors.push(`${target.owner} icon returned ${status}: ${target.src}`); + } + } catch (error) { + errors.push( + `${target.owner} icon request failed for ${target.src}: ${getFetchErrorMessage(error)}`, + ); + } + } + + console.log(`[tech-icons] validated ${targets.length} configured icon sources`); + + if (warnings.length > 0) { + console.log(`[tech-icons] warnings (${warnings.length})`); + for (const warning of warnings) { + console.log(` - ${warning}`); + } + } + + if (errors.length > 0) { + console.error(`[tech-icons] errors (${errors.length})`); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } +} + +await run(); diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index 815231bbc..347ac763a 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -398,7 +398,7 @@ export const TECH_OPTIONS: Record< id: "self-vinext", name: "Fullstack Vinext", description: "Use Vinext server routes", - icon: "https://cdn.simpleicons.org/vue", + icon: "https://cdn.simpleicons.org/vuedotjs/4FC08D", color: "from-green-400 to-green-600", }, { @@ -977,7 +977,7 @@ export const TECH_OPTIONS: Record< id: "launchdarkly", name: "LaunchDarkly", description: "Enterprise feature management with client and server SDKs", - icon: "https://cdn.simpleicons.org/launchdarkly/405BFF", + icon: "", color: "from-blue-500 to-indigo-600", default: false, }, @@ -985,7 +985,7 @@ export const TECH_OPTIONS: Record< id: "flagsmith", name: "Flagsmith", description: "Open-source feature flags and remote config platform", - icon: "https://cdn.simpleicons.org/flagsmith/1A1A1A", + icon: "", color: "from-emerald-500 to-cyan-600", default: false, }, @@ -993,7 +993,7 @@ export const TECH_OPTIONS: Record< id: "unleash", name: "Unleash", description: "Open-source feature management with Edge and proxy SDKs", - icon: "https://cdn.simpleicons.org/unleash/1D4ED8", + icon: "", color: "from-sky-500 to-violet-600", default: false, }, @@ -3374,7 +3374,7 @@ export const TECH_OPTIONS: Record< id: "pyright", name: "Pyright", description: "Fast Python type checker from Microsoft", - icon: "https://cdn.simpleicons.org/microsoft/5E5E5E", + icon: "", color: "from-violet-500 to-blue-600", default: false, isNew: true, @@ -3836,7 +3836,7 @@ export const TECH_OPTIONS: Record< id: "mockito", name: "Mockito", description: "Mocking framework for isolated unit tests", - icon: "https://cdn.simpleicons.org/mockito/78A641", + icon: "", color: "from-lime-500 to-green-600", default: false, }, @@ -3844,7 +3844,7 @@ export const TECH_OPTIONS: Record< id: "testcontainers", name: "Testcontainers", description: "Disposable Docker-based integration testing", - icon: "https://cdn.simpleicons.org/testcontainers/2496ED", + icon: "", color: "from-sky-500 to-blue-700", default: false, }, diff --git a/apps/web/src/lib/tech-icons.ts b/apps/web/src/lib/tech-icons.ts index cd00e2f68..3a85876b2 100644 --- a/apps/web/src/lib/tech-icons.ts +++ b/apps/web/src/lib/tech-icons.ts @@ -127,7 +127,6 @@ export const ICON_REGISTRY: Record = { kysely: { type: "local", src: "https://kysely.dev/img/logo.svg" }, mikroorm: { type: "local", src: "https://mikro-orm.io/img/logo.svg" }, sequelize: { type: "si", slug: "sequelize", hex: "52B0E7" }, - "tortoise-orm": { type: "local", src: "/icon/python.svg" }, // ─── DB Setup ────────────────────────────────────────────────────────────── turso: { type: "si", slug: "turso", hex: "4FF8D2" }, @@ -184,11 +183,7 @@ export const ICON_REGISTRY: Record = { opentelemetry: { type: "si", slug: "opentelemetry", hex: "000000" }, // ─── Feature Flags ───────────────────────────────────────────────────────── - growthbook: { type: "si", slug: "growthbook", hex: "4E00DF" }, posthog: { type: "si", slug: "posthog", hex: "F54E00" }, - launchdarkly: { type: "si", slug: "launchdarkly", hex: "405BFF" }, - flagsmith: { type: "si", slug: "flagsmith", hex: "1A1A1A" }, - unleash: { type: "si", slug: "unleash", hex: "1D4ED8" }, // ─── State Management ────────────────────────────────────────────────────── "redux-toolkit": { type: "si", slug: "redux", hex: "764ABC" }, @@ -353,7 +348,6 @@ export const ICON_REGISTRY: Record = { ariadne: { type: "si", slug: "graphql", hex: "E10098" }, ruff: { type: "si", slug: "ruff", hex: "D7FF64" }, mypy: { type: "si", slug: "python", hex: "3776AB" }, - pyright: { type: "si", slug: "microsoft", hex: "5E5E5E" }, // ─── Go ──────────────────────────────────────────────────────────────────── gin: { type: "si", slug: "gin", hex: "00ADD8" }, @@ -391,8 +385,6 @@ export const ICON_REGISTRY: Record = { "micrometer-prometheus": { type: "si", slug: "prometheus", hex: "E6522C" }, thymeleaf: { type: "si", slug: "thymeleaf", hex: "005F0F" }, junit5: { type: "si", slug: "junit5", hex: "25A162" }, - mockito: { type: "si", slug: "mockito", hex: "78A641" }, - testcontainers: { type: "si", slug: "testcontainers", hex: "2496ED" }, assertj: { type: "local", src: "https://assertj.github.io/doc/images/favicon.png" }, "rest-assured": { type: "local", src: "https://rest-assured.io/img/logo-transparent.png" }, wiremock: { type: "local", src: "https://wiremock.org/images/favicon.svg" }, diff --git a/packages/template-generator/src/template-handlers/frontend.ts b/packages/template-generator/src/template-handlers/frontend.ts index db8b68678..271432393 100644 --- a/packages/template-generator/src/template-handlers/frontend.ts +++ b/packages/template-generator/src/template-handlers/frontend.ts @@ -10,7 +10,9 @@ export async function processFrontendTemplates( config: ProjectConfig, ): Promise { const hasReactWeb = config.frontend.some((f) => - ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext"].includes(f), + ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext"].includes( + f, + ), ); const hasNuxtWeb = config.frontend.includes("nuxt"); const hasSvelteWeb = config.frontend.includes("svelte"); @@ -42,7 +44,14 @@ export async function processFrontendTemplates( processTemplatesFromPrefix(vfs, templates, "frontend/react/web-base", "apps/web", config); const reactFramework = config.frontend.find((f) => - ["tanstack-router", "react-router", "react-vite", "tanstack-start", "next", "vinext"].includes(f), + [ + "tanstack-router", + "react-router", + "react-vite", + "tanstack-start", + "next", + "vinext", + ].includes(f), ); if (reactFramework) { processTemplatesFromPrefix( @@ -86,6 +95,7 @@ export async function processFrontendTemplates( processTemplatesFromPrefix(vfs, templates, "frontend/redwood", ".", config); } else if (hasFreshWeb) { // Fresh (Deno) outputs to apps/web like other frameworks + processTemplatesFromPrefix(vfs, templates, "frontend/fresh-root", ".", config); processTemplatesFromPrefix(vfs, templates, "frontend/fresh", "apps/web", config); } } diff --git a/packages/template-generator/templates/frontend/fresh-root/deno.json.hbs b/packages/template-generator/templates/frontend/fresh-root/deno.json.hbs new file mode 100644 index 000000000..8a070ded3 --- /dev/null +++ b/packages/template-generator/templates/frontend/fresh-root/deno.json.hbs @@ -0,0 +1,7 @@ +{ + "lock": false, + "nodeModulesDir": "auto", + "workspace": [ + "./apps/web" + ] +} diff --git a/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs b/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs index a96f37580..ddf072389 100644 --- a/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs +++ b/packages/template-generator/templates/frontend/fresh/components/Header.tsx.hbs @@ -1,3 +1,4 @@ +/** @jsxImportSource preact */ interface HeaderProps { projectName: string; } diff --git a/packages/template-generator/templates/frontend/fresh/deno.json.hbs b/packages/template-generator/templates/frontend/fresh/deno.json.hbs index 559a3ece3..7f3174683 100644 --- a/packages/template-generator/templates/frontend/fresh/deno.json.hbs +++ b/packages/template-generator/templates/frontend/fresh/deno.json.hbs @@ -1,5 +1,4 @@ { - "lock": false, "tasks": { "check": "deno fmt --check . && deno lint . && deno check", "dev": "vite", @@ -22,7 +21,11 @@ "@/": "./", "fresh": "jsr:@fresh/core@^2.2.0", "preact": "npm:preact@^10.27.2", + "preact/": "npm:preact@^10.27.2/", + "preact/jsx-runtime": "npm:preact@^10.27.2/jsx-runtime", + "preact/jsx-dev-runtime": "npm:preact@^10.27.2/jsx-dev-runtime", "@preact/signals": "npm:@preact/signals@^2.5.0", + "@preact/signals/": "npm:@preact/signals@^2.5.0/", "@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.8", "vite": "npm:vite@^7.3.1"{{#if (eq cssFramework "tailwind")}}, "tailwindcss": "npm:tailwindcss@^4.1.12", @@ -57,6 +60,5 @@ "types": [ "vite/client" ] - }, - "nodeModulesDir": "auto" + } } diff --git a/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs b/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs index 90ea3ba81..dddd1952f 100644 --- a/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs +++ b/packages/template-generator/templates/frontend/fresh/islands/Counter.tsx.hbs @@ -1,3 +1,4 @@ +/** @jsxImportSource preact */ import { useSignal } from "@preact/signals"; interface CounterProps { diff --git a/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs b/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs index e3fd13bd5..06bea8bbf 100644 --- a/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs +++ b/packages/template-generator/templates/frontend/fresh/routes/_app.tsx.hbs @@ -1,3 +1,4 @@ +/** @jsxImportSource preact */ import { define } from "../utils.ts"; import Header from "../components/Header.tsx"; diff --git a/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs b/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs index 485297ebd..69b4ff1ef 100644 --- a/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs +++ b/packages/template-generator/templates/frontend/fresh/routes/index.tsx.hbs @@ -1,3 +1,4 @@ +/** @jsxImportSource preact */ import { Head } from "fresh/runtime"; import { define } from "../utils.ts"; import Counter from "../islands/Counter.tsx"; From 709b176e3547a1ecfbcbb38d949badca85f51200 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 20:46:59 +0300 Subject: [PATCH 20/33] Redesign builder UI and unify lime/background theming Builder: - Replace fixed left sidebar with a toggleable section-navigation drawer (nav only) - Redesign command bar (scrollable, copy button) pinned at bottom; add scroll-to-top - Move project name into the toolbar as a notched-label field; reorder toolbar (input, tabs, actions) - Whole header scrolls with content; remove per-ecosystem grid borders for hover/active states - Remove 'New' tool labels; add cursor-pointer to tabs/ecosystem/action buttons Navbar: 'Try now' becomes a Copy button on the builder page. Theme: - Backgrounds: dark #0E0E10, light #F4F8F4 - Unify homepage greens to brand lime #C6E853 - Darken muted-foreground/primary and homepage lime text to keep WCAG AA contrast on the new backgrounds Also: Elixir/Phoenix syntax highlighting in code viewer (+test); update e2e specs for the new UI. --- apps/web/.gitignore | 2 + .../components/home/collapsible-section.tsx | 27 +- .../components/home/combinations-section.tsx | 6 +- .../components/home/contributors-section.tsx | 4 +- .../src/components/home/features-section.tsx | 23 +- apps/web/src/components/home/hero-section.tsx | 28 +- .../src/components/home/testimonials-data.ts | 3 +- .../components/home/testimonials-section.tsx | 10 +- apps/web/src/components/navbar.tsx | 71 +- .../components/stack-builder/code-viewer.tsx | 6 +- .../components/stack-builder/share-button.tsx | 11 +- .../stack-builder/stack-builder.tsx | 2196 ++++++++--------- apps/web/src/styles/global.css | 41 +- apps/web/test/code-viewer.test.ts | 12 + apps/web/test/e2e/builder-parity.spec.ts | 5 +- apps/web/test/e2e/mobile.spec.ts | 13 +- 16 files changed, 1168 insertions(+), 1290 deletions(-) create mode 100644 apps/web/test/code-viewer.test.ts diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 8a4aa6500..dcd917490 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -9,6 +9,8 @@ public/analytics-minimal.json # test & build /coverage +/test-results/ +/playwright-report/ /.next/ /out/ /build diff --git a/apps/web/src/components/home/collapsible-section.tsx b/apps/web/src/components/home/collapsible-section.tsx index da9ed03c1..db648cb59 100644 --- a/apps/web/src/components/home/collapsible-section.tsx +++ b/apps/web/src/components/home/collapsible-section.tsx @@ -22,11 +22,13 @@ export function CollapsibleSection({ const prefersReducedMotion = useRef(false); useEffect(() => { - prefersReducedMotion.current = - window.matchMedia("(prefers-reduced-motion: reduce)").matches; + prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches; }, []); - const sectionSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); + const sectionSlug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); const headingId = `section-heading-${sectionSlug}`; const contentId = `section-content-${sectionSlug}`; @@ -82,8 +84,7 @@ export function CollapsibleSection({ style={{ width: 300, height: 300, - background: - "radial-gradient(circle, hsl(var(--primary) / 0.08) 0%, transparent 70%)", + background: "radial-gradient(circle, hsl(var(--primary) / 0.08) 0%, transparent 70%)", }} /> @@ -95,9 +96,7 @@ export function CollapsibleSection({ @@ -106,17 +105,13 @@ export function CollapsibleSection({ id={headingId} className={cn( "font-pixel text-lg font-bold transition-colors duration-200 sm:text-xl", - isOpen - ? "text-foreground" - : "text-foreground/70 group-hover:text-foreground", + isOpen ? "text-foreground" : "text-foreground/70 group-hover:text-foreground", )} > {title} {subtitle && ( -

- {subtitle} -

+

{subtitle}

)} @@ -132,9 +127,7 @@ export function CollapsibleSection({ transition={reduced ? { duration: 0 } : { duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }} className="overflow-hidden" > -
- {children} -
+
{children}
)} diff --git a/apps/web/src/components/home/combinations-section.tsx b/apps/web/src/components/home/combinations-section.tsx index 29074eecc..d56a35204 100644 --- a/apps/web/src/components/home/combinations-section.tsx +++ b/apps/web/src/components/home/combinations-section.tsx @@ -29,7 +29,7 @@ export default function CombinationsSection() {
-

+

✦ combinatorics

{totalScientific.exponent} @@ -101,7 +101,7 @@ export default function CombinationsSection() { {yearsAtOneMillisecondScientific.exponent} years {" "} — that’s{" "} - + {universeLifetimesScientific.mantissa} × 10 {universeLifetimesScientific.exponent} universe lifetimes diff --git a/apps/web/src/components/home/contributors-section.tsx b/apps/web/src/components/home/contributors-section.tsx index f84581818..ff5054407 100644 --- a/apps/web/src/components/home/contributors-section.tsx +++ b/apps/web/src/components/home/contributors-section.tsx @@ -52,7 +52,7 @@ function ContributorCard({ contributor, index }: { contributor: Contributor; ind src={`https://github.com/${contributor.username}.png`} alt={contributor.name} loading="lazy" - className="h-14 w-14 rounded-full border-2 border-border transition-colors group-hover:border-lime-500/50" + className="h-14 w-14 rounded-full border-2 border-border transition-colors group-hover:border-[#C6E853]/50" />
{contributor.name} @@ -74,7 +74,7 @@ export default function ContributorsSection() {
-

+

✦ contributors

-

+

✦ six ecosystems

- Not just TypeScript.{" "} - Everything. + Not just TypeScript. Everything.

- TypeScript, Rust, Python, Go, Java, Elixir — one CLI scaffolds production-ready - apps across all six. Pick your ecosystem, pick your stack. + TypeScript, Rust, Python, Go, Java, Elixir — one CLI scaffolds production-ready apps + across all six. Pick your ecosystem, pick your stack.

@@ -189,7 +188,7 @@ function LayerRow({ layer, index }: { layer: Layer; index: number }) { />
✦ {String(index + 1).padStart(2, "0")} @@ -230,9 +229,7 @@ function LayerRow({ layer, index }: { layer: Layer; index: number }) { className="flex flex-col items-center gap-2" > - - {opt.name} - + {opt.name} ) : (
-

+

✦ total

- Multiply this by every database, every CSS framework, every AI SDK, and you get - more combinations than there are grains of sand. + Multiply this by every database, every CSS framework, every AI SDK, and you get more + combinations than there are grains of sand.

@@ -287,7 +284,7 @@ function TotalBlock() { transformTiming={{ duration: 1100, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" }} /> - + diff --git a/apps/web/src/components/home/hero-section.tsx b/apps/web/src/components/home/hero-section.tsx index 62779cd7a..862be7953 100644 --- a/apps/web/src/components/home/hero-section.tsx +++ b/apps/web/src/components/home/hero-section.tsx @@ -21,7 +21,7 @@ const COMMANDS: Record = { yarn: "yarn create better-fullstack@latest", }; -const ACCENT_TEXT = "text-black dark:text-[#bef264]"; +const ACCENT_TEXT = "text-black dark:text-[#C6E853]"; const RELEASE_BADGE = `v${__BFS_CLI_VERSION__} · ${__BFS_BUILD_DATE__}`; export default function HeroSection() { @@ -53,12 +53,7 @@ export default function HeroSection() { )} >
- + ✦ install -
+
{PMS.map((p) => ( + ); +} + export function Navbar() { + const matchRoute = useMatchRoute(); + const onBuilder = Boolean(matchRoute({ to: "/new" })); + return (
diff --git a/apps/web/src/components/stack-builder/code-viewer.tsx b/apps/web/src/components/stack-builder/code-viewer.tsx index c1f713fb0..173cb8fe2 100644 --- a/apps/web/src/components/stack-builder/code-viewer.tsx +++ b/apps/web/src/components/stack-builder/code-viewer.tsx @@ -21,7 +21,7 @@ interface CodeViewerProps { } // Map file extensions to Shiki language IDs -function getLanguage(extension: string): BundledLanguage { +export function getLanguage(extension: string): BundledLanguage { const languageMap: Record = { ts: "typescript", tsx: "tsx", @@ -40,6 +40,10 @@ function getLanguage(extension: string): BundledLanguage { yml: "yaml", toml: "toml", sql: "sql", + ex: "elixir", + exs: "elixir", + eex: "elixir", + heex: "elixir", prisma: "prisma", graphql: "graphql", sh: "bash", diff --git a/apps/web/src/components/stack-builder/share-button.tsx b/apps/web/src/components/stack-builder/share-button.tsx index 79eaead55..bdcb0a158 100644 --- a/apps/web/src/components/stack-builder/share-button.tsx +++ b/apps/web/src/components/stack-builder/share-button.tsx @@ -1,4 +1,3 @@ - import { Check, Link } from "lucide-react"; import { useState } from "react"; @@ -26,15 +25,11 @@ export function ShareButton({ stackUrl }: ShareButtonProps) { title={copied ? "Copied!" : "Copy share link"} className={ copied - ? "rounded-md p-1.5 text-green-500 transition-colors" - : "rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + ? "cursor-pointer rounded-md p-1.5 text-green-500 transition-colors" + : "cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" } > - {copied ? ( - - ) : ( - - )} + {copied ? : } ); } diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx index 3d0969da9..d55f3e269 100644 --- a/apps/web/src/components/stack-builder/stack-builder.tsx +++ b/apps/web/src/components/stack-builder/stack-builder.tsx @@ -1,5 +1,6 @@ - +import { isMultiSelectCategory, type OptionCategory } from "@better-fullstack/types"; import { + ArrowUp, Bookmark, BookOpen, Check, @@ -11,27 +12,22 @@ import { Hammer, InfoIcon, Link, - List, + PanelLeft, + Pencil, RefreshCw, Save, Settings, Shuffle, Terminal, + X, Zap, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { Suspense, lazy, startTransition, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { isMultiSelectCategory, type OptionCategory } from "@better-fullstack/types"; import type { Ecosystem } from "@/lib/types"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -40,8 +36,13 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { DEFAULT_STACK, @@ -50,6 +51,12 @@ import { type StackState, TECH_OPTIONS, } from "@/lib/constant"; +import { + buildSavedStackEntry, + loadSavedStacks, + saveSavedStacks, + type SavedStackEntry, +} from "@/lib/saved-stacks"; import { usesVirtualNoneSelection } from "@/lib/stack-contract"; import { useStackState } from "@/lib/stack-url-state"; import { @@ -80,17 +87,9 @@ import { validateProjectName, } from "./utils"; import { YoloToggle } from "./yolo-toggle"; -import { - buildSavedStackEntry, - loadSavedStacks, - saveSavedStacks, - type SavedStackEntry, -} from "@/lib/saved-stacks"; // ─── Helpers ───────────────────────────────────────────────────────────────── -type MobileTab = "summary" | "configure"; - function formatProjectName(name: string): string { return name.replace(/\s+/g, "-"); } @@ -252,19 +251,6 @@ function TechResourceButtons({ category, techId }: { category: string; techId: s ); } -function NewToolLabel({ compact = false }: { compact?: boolean }) { - return ( - - New - - ); -} - function DisabledReasonInline({ reason, compact = false }: { reason: string; compact?: boolean }) { return (
- Grouped add-ons: platforms, - integrations, AI agents, and TanStack extras are split below. MCP and Skills still add the - addon flags first, then the CLI asks follow-up questions to configure them. + Grouped add-ons: platforms, integrations, + AI agents, and TanStack extras are split below. MCP and Skills still add the addon flags + first, then the CLI asks follow-up questions to configure them.
); } @@ -335,131 +321,6 @@ function isSelectedCheck(stack: StackState, categoryKey: string, techId: string) return currentValue === techId; } -// ─── SidebarAccordionItem ──────────────────────────────────────────────────── - -function SidebarAccordionItem({ - category, - isOpen, - onToggle, - stack, - handleTechSelect, - compatibilityNotes, -}: { - category: keyof typeof TECH_OPTIONS; - isOpen: boolean; - onToggle: () => void; - stack: StackState; - handleTechSelect: (cat: keyof typeof TECH_OPTIONS, techId: string) => void; - compatibilityNotes?: CompatibilityNotes; -}) { - const optionGroups = getCategoryRenderGroups(stack, category); - const options = optionGroups.flatMap((group) => - group.options.map((option) => ({ option, category: group.category })), - ); - if (options.length === 0) return null; - - const count = getSelectedCount(category, stack); - const displayName = getCategoryDisplayName(category); - - return ( -
- - - {isOpen && ( - -
- {options.map(({ option, category: optionCategory }) => { - const selected = isSelectedCheck(stack, optionCategory, option.id); - const disabled = !isOptionCompatible(stack, optionCategory, option.id); - const disabledReason = disabled - ? getDisabledReason(stack, optionCategory, option.id) - : null; - - return ( - - ); - })} -
-
- )} -
-
- ); -} - // ─── Collapsible section config ────────────────────────────────────────────── const INITIALLY_COLLAPSED_SET = new Set([ @@ -509,13 +370,13 @@ const StackBuilder = () => { const [command, setCommand] = useState(""); const [copied, setCopied] = useState(false); + const [showScrollTop, setShowScrollTop] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); const [savedStacks, setSavedStacks] = useState(() => loadSavedStacks()); const [, setLastChanges] = useState>([]); - const [mobileTab, setMobileTab] = useState("configure"); const [isSaveInputVisible, setIsSaveInputVisible] = useState(false); const [savePresetName, setSavePresetName] = useState(""); const [pendingUpdateEntryId, setPendingUpdateEntryId] = useState(null); - const [openCategory, setOpenCategory] = useState(null); const [collapsedSections, setCollapsedSections] = useState>(() => { const initial = new Set(INITIALLY_COLLAPSED_SET); for (const cat of INITIALLY_COLLAPSED_SET) { @@ -528,9 +389,13 @@ const StackBuilder = () => { }); const sectionRefs = useRef>({}); - const mainScrollRef = useRef(null); + const scrollContainerRef = useRef(null); const lastAppliedStackString = useRef(""); + const scrollToTop = () => { + scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }; + const compatibilityAnalysis = analyzeStackCompatibility(stack); const adjustedStack = useMemo(() => { if (!compatibilityAnalysis.adjustedStack) return null; @@ -557,56 +422,7 @@ const StackBuilder = () => { } }, [stack.ecosystem]); - const sidebarCategories = useMemo(() => { - const cats: (keyof typeof TECH_OPTIONS)[] = []; - for (const cat of categoryOrder) { - if (cat === "astroIntegration") { - if (stack.webFrontend.includes("astro")) { - cats.push(cat); - } - continue; - } - - if (SHADCN_SUB_CATEGORIES.has(cat)) { - continue; - } - - if (stack.ecosystem === "go" && cat === "auth") { - continue; - } - - cats.push(cat); - } - return cats; - }, [categoryOrder, stack.ecosystem, stack.webFrontend]); - - // Open first category when ecosystem changes - const prevEcosystem = useRef(stack.ecosystem); - useEffect(() => { - if (prevEcosystem.current !== stack.ecosystem) { - prevEcosystem.current = stack.ecosystem; - if ( - sidebarCategories.length > 0 && - !sidebarCategories.includes(openCategory as keyof typeof TECH_OPTIONS) - ) { - setOpenCategory(sidebarCategories[0] || null); - } - } - }, [stack.ecosystem, sidebarCategories, openCategory]); - - // Get the main scroll viewport for scrollIntoView - useEffect(() => { - if (mainScrollRef.current) { - const viewport = mainScrollRef.current.querySelector( - '[data-slot="scroll-area-viewport"]', - ); - if (viewport) { - mainScrollRef.current = viewport; - } - } - }, []); - - // ─── URL & command generation ─────────────────────────────────────────── + // ─── URL generation ────────────────────────────────────────────────────── const getStackUrl = (): string => { const stackToUse = adjustedStack || stack; @@ -661,6 +477,12 @@ const StackBuilder = () => { // ─── Handlers ─────────────────────────────────────────────────────────── + const copyToClipboard = () => { + navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + const handleTechSelect = (category: keyof typeof TECH_OPTIONS, techId: string) => { if (!isOptionCompatible(stack, category, techId)) return; @@ -745,12 +567,6 @@ const StackBuilder = () => { }); }; - const copyToClipboard = () => { - navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - const resetStack = () => { startTransition(() => { setStack(DEFAULT_STACK); @@ -902,21 +718,6 @@ const StackBuilder = () => { ? null : savedStacks.find((entry) => entry.id === pendingUpdateEntryId) || null; - const handleAccordionToggle = (category: string) => { - setOpenCategory((prev) => (prev === category ? null : category)); - setCollapsedSections((prev) => { - if (!prev.has(category)) return prev; - const next = new Set(prev); - next.delete(category); - return next; - }); - // Scroll to the corresponding section in main content - const sectionEl = sectionRefs.current[category]; - if (sectionEl) { - sectionEl.scrollIntoView({ behavior: "smooth", block: "start" }); - } - }; - const toggleSection = (categoryKey: string) => { setCollapsedSections((prev) => { const next = new Set(prev); @@ -929,6 +730,36 @@ const StackBuilder = () => { }); }; + // Sections shown in the navigation drawer — mirrors the rendered category sections. + const navSections = useMemo( + () => + categoryOrder + .filter((categoryKey) => { + if (categoryKey === "astroIntegration") return false; + if (SHADCN_SUB_CATEGORIES.has(categoryKey)) return false; + if (stack.ecosystem === "go" && categoryKey === "auth") return false; + return ( + getCategoryRenderGroups(stack, categoryKey as keyof typeof TECH_OPTIONS).length > 0 + ); + }) + .map((categoryKey) => ({ key: categoryKey, name: getCategoryDisplayName(categoryKey) })), + [categoryOrder, stack], + ); + + const goToSection = (categoryKey: string) => { + // Expand the section if collapsed, then scroll it into view. + setCollapsedSections((prev) => { + if (!prev.has(categoryKey)) return prev; + const next = new Set(prev); + next.delete(categoryKey); + return next; + }); + setSidebarOpen(false); + requestAnimationFrame(() => { + sectionRefs.current[categoryKey]?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }; + // ─── Render ───────────────────────────────────────────────────────────── return ( @@ -960,1010 +791,1019 @@ const StackBuilder = () => {
-
- {/* Mobile tab navigation */} -
- - -
+
+ {/* Single scroller: header + toolbar + content scroll together (header is not pinned) */} +
{ + const next = e.currentTarget.scrollTop > 120; + setShowScrollTop((prev) => (prev === next ? prev : next)); + }} + className="flex min-h-0 flex-1 flex-col overflow-y-auto" + > + {/* ─── Ecosystem Header Bar ─────────────────────────────────────── */} +
+
+ {ECOSYSTEMS.map((eco) => { + const isActive = stack.ecosystem === eco.id; + return ( + + ); + })} +
+
- + {/* ─── Main Content Area ──────────────────────────────────────────── */} +
+
+ {/* ─── Project name field ─────────────────────────────────────── */} + + + -
-
- {/* ─── Left Sidebar ───────────────────────────────────────────────── */} - - - {/* ─── Main Content Area ──────────────────────────────────────────── */} -
-
- - - - - -
- {/* Desktop action buttons */} - - {isSaveInputVisible && ( - -
- setSavePresetName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - saveCurrentStack(savePresetName || stack.projectName || "Untitled preset"); - } - if (e.key === "Escape") { - setIsSaveInputVisible(false); - setSavePresetName(""); - } - }} - placeholder={stack.projectName || "My preset"} - className="h-8 min-w-0" - /> - -
-
- )} -
-
- - { - const nextVisible = !isSaveInputVisible; - setIsSaveInputVisible(nextVisible); - setSavePresetName(nextVisible ? stack.projectName || "" : ""); - }} - title="Save current preset" - aria-label="Save current preset" - className={cn( - "rounded-md p-1.5 transition-colors", - isSaveInputVisible - ? "bg-primary/15 text-primary" - : "text-muted-foreground hover:bg-muted hover:text-foreground", - )} - /> - } - > - - - Save the current stack as a named preset - - - - } - > - - - Reset all builder options to defaults - - - - } - > - - - Generate a random stack configuration - - + {/* Mobile three-dot menu */} } > - + - - setStack({ yolo })} /> + + { + saveCurrentStack(stack.projectName || "Untitled preset"); + }} + > + + Save Preset + + + + Reset to Defaults + + + + Random Stack + + { + try { + await navigator.clipboard.writeText(getStackUrl()); + toast.success("Share link copied!"); + } catch { + toast.error("Failed to copy link"); + } + }} + > + + Copy Share Link +
- - {/* Mobile three-dot menu */} - - - } - > - - - - { - saveCurrentStack(stack.projectName || "Untitled preset"); - }} - > - - Save Preset - - - - Reset to Defaults - - - - Random Stack - - { - try { - await navigator.clipboard.writeText(getStackUrl()); - toast.success("Share link copied!"); - } catch { - toast.error("Failed to copy link"); - } - }} - > - - Copy Share Link - - -
-
- {viewMode === "command" ? ( -
-
- -
-
-
+
+
+ + {/* ─── Floating command bar ─────────────────────────────────────────── */} +
+
+ {viewMode === "command" && ( + + )} +
+ $ + + {command} + + +
+ {viewMode === "command" && ( + )} - +
+ + {/* ─── Section navigation drawer (toggled, builder view only) ──────── */} + {viewMode === "command" && ( + <> + +
+ + + + )}
); diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index f7b0462fd..269ca0e6e 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -190,26 +190,26 @@ } :root { - --color-fd-primary: #8839ef; + --color-fd-primary: #7c3aed; --color-fd-primary-foreground: #ffffff; - --background: oklch(1 0 0); + --background: #f4f8f4; --foreground: oklch(0.44 0.04 279.33); - --card: oklch(1 0 0); + --card: #f4f8f4; --card-foreground: oklch(0.44 0.04 279.33); --popover: oklch(0.86 0.01 268.48); --popover-foreground: oklch(0.44 0.04 279.33); - --primary: #8839ef; + --primary: #7c3aed; --primary-foreground: #ffffff; --secondary: oklch(0.86 0.01 268.48); --secondary-foreground: oklch(0.44 0.04 279.33); --muted: oklch(0.91 0.01 264.51); - --muted-foreground: oklch(0.55 0.03 279.08); + --muted-foreground: oklch(0.48 0.03 279.08); --accent: #9353d3; --accent-foreground: #ffffff; --destructive: #d20f39; --border: oklch(0.81 0.02 271.2); --input: oklch(0.86 0.01 268.48); - --ring: #8839ef; + --ring: #7c3aed; --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); @@ -217,12 +217,12 @@ --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.93 0.01 264.52); --sidebar-foreground: oklch(0.44 0.04 279.33); - --sidebar-primary: #8839ef; + --sidebar-primary: #7c3aed; --sidebar-primary-foreground: #ffffff; --sidebar-accent: #9353d3; --sidebar-accent-foreground: #ffffff; --sidebar-border: oklch(0.81 0.02 271.2); - --sidebar-ring: #8839ef; + --sidebar-ring: #7c3aed; --destructive-foreground: oklch(1 0 0); --radius: 0.35rem; --shadow-color: hsl(240 30% 25%); @@ -255,10 +255,10 @@ .dark { --color-fd-primary: #f2eeee; --color-fd-primary-foreground: #0c0c0e; - --color-fd-background: #0c0c0e; - --background: #0c0c0e; + --color-fd-background: #0e0e10; + --background: #0e0e10; --foreground: #f2eeee; - --card: #0c0c0e; + --card: #0e0e10; --card-foreground: #f2eeee; --popover: #131212; --popover-foreground: #f2eeee; @@ -279,7 +279,7 @@ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: #0c0c0e; + --sidebar: #0e0e10; --sidebar-foreground: #f2eeee; --sidebar-primary: #f2eeee; --sidebar-primary-foreground: #0c0c0e; @@ -310,7 +310,6 @@ --code-bg: #161b22; --code-border: #30363d; } - /* @layer base { * { @apply border-border outline-ring/50; @@ -417,13 +416,21 @@ } @keyframes marquee-left { - 0% { transform: translateX(0); } - 100% { transform: translateX(-50%); } + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } } @keyframes marquee-right { - 0% { transform: translateX(-50%); } - 100% { transform: translateX(0); } + 0% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0); + } } .animate-marquee-left { diff --git a/apps/web/test/code-viewer.test.ts b/apps/web/test/code-viewer.test.ts new file mode 100644 index 000000000..fe6d445fd --- /dev/null +++ b/apps/web/test/code-viewer.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "bun:test"; + +import { getLanguage } from "../src/components/stack-builder/code-viewer"; + +describe("getLanguage", () => { + it("maps Elixir and Phoenix template files to Elixir highlighting", () => { + expect(getLanguage("ex")).toBe("elixir"); + expect(getLanguage("exs")).toBe("elixir"); + expect(getLanguage("eex")).toBe("elixir"); + expect(getLanguage("heex")).toBe("elixir"); + }); +}); diff --git a/apps/web/test/e2e/builder-parity.spec.ts b/apps/web/test/e2e/builder-parity.spec.ts index 9b518e264..e7be6a315 100644 --- a/apps/web/test/e2e/builder-parity.spec.ts +++ b/apps/web/test/e2e/builder-parity.spec.ts @@ -77,13 +77,12 @@ test.describe("Builder parity", () => { }); test("disabled options do not mutate the command output", async ({ page }) => { - await clickVisibleTestId(page, "sidebar-category-toggle-cms"); + await clickVisibleTestId(page, "category-toggle-cms"); const command = commandOutput(page); const initialCommand = await command.textContent(); - const payloadOption = visibleTestId(page, "sidebar-option-cms-payload"); + const payloadOption = visibleTestId(page, "option-cms-payload"); await expect(payloadOption).toContainText("Unavailable"); - await expect(payloadOption).toBeDisabled(); await payloadOption.click({ force: true }); await expect(command).toHaveText(initialCommand ?? ""); diff --git a/apps/web/test/e2e/mobile.spec.ts b/apps/web/test/e2e/mobile.spec.ts index 95e610b49..61d399897 100644 --- a/apps/web/test/e2e/mobile.spec.ts +++ b/apps/web/test/e2e/mobile.spec.ts @@ -1,12 +1,6 @@ import { test, expect } from "@playwright/test"; -import { - clickVisibleTestId, - commandOutput, - gotoAppPage, - openBuilder, - visibleTestId, -} from "./test-helpers"; +import { commandOutput, gotoAppPage, openBuilder, visibleTestId } from "./test-helpers"; test.use({ viewport: { width: 390, height: 844 } }); @@ -26,11 +20,8 @@ test.describe("Stack Builder - Mobile", () => { }); test("CLI command is visible on mobile", async ({ page }) => { - await page.setViewportSize({ width: 1280, height: 900 }); + // The command bar is pinned and always visible (no mobile tab toggle anymore). await openBuilder(page); - await page.setViewportSize({ width: 390, height: 844 }); - await expect(visibleTestId(page, "mobile-tab-summary")).toBeVisible(); - await clickVisibleTestId(page, "mobile-tab-summary"); const mobileCommand = commandOutput(page); await expect(mobileCommand).toBeVisible(); await expect(mobileCommand).toContainText("bun create better-fullstack"); From 82545ac93181867974a8f09e90b71e271ab50a10 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 22:47:12 +0300 Subject: [PATCH 21/33] fix cli defaults and elixir auth scaffold --- apps/cli/src/helpers/core/command-handlers.ts | 52 +++++++++++++++++- .../template-snapshots.test.ts.snap | 7 ++- apps/cli/test/mobile.test.ts | 22 +++++++- .../virtual-generator-regressions.test.ts | 31 +++++++++++ .../src/template-handlers/elixir-base.ts | 1 + .../lib/__elixirAppName__/accounts.ex.hbs | 11 ++++ .../__elixirAppName__/accounts/user.ex.hbs | 45 ++++++++++++++-- .../user_session_controller.ex.hbs | 54 +++++++++++++++++++ .../lib/__elixirAppName___web/router.ex.hbs | 5 ++ .../templates/elixir-base/mix.exs.hbs | 3 ++ 10 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/user_session_controller.ex.hbs diff --git a/apps/cli/src/helpers/core/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts index 9a829aa47..b202b574b 100644 --- a/apps/cli/src/helpers/core/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -35,6 +35,56 @@ export interface CreateHandlerOptions { silent?: boolean; } +function getYesBaseConfig(flagConfig: Partial): ProjectConfig { + const baseConfig = getDefaultConfig(); + + if (flagConfig.ecosystem !== "react-native") { + return baseConfig; + } + + return { + ...baseConfig, + backend: "none", + runtime: "none", + frontend: ["native-bare"], + addons: [], + examples: [], + auth: "none", + payments: "none", + email: "none", + fileUpload: "none", + effect: "none", + dbSetup: "none", + api: "none", + webDeploy: "none", + serverDeploy: "none", + cssFramework: "none", + uiLibrary: "none", + stateManagement: "none", + forms: "none", + testing: "none", + realtime: "none", + jobQueue: "none", + animation: "none", + logging: "none", + observability: "none", + featureFlags: "none", + analytics: "none", + cms: "none", + caching: "none", + i18n: "none", + search: "none", + fileStorage: "none", + mobileNavigation: "expo-router", + mobileUI: "none", + mobileStorage: "none", + mobileTesting: "none", + mobilePush: "none", + mobileOTA: "none", + mobileDeepLinking: "none", + }; +} + function shouldPromptForVersionChannel(input: CreateInput & { projectName?: string }): boolean { if (input.yes || input.versionChannel !== undefined || isSilent()) { return false; @@ -268,7 +318,7 @@ export async function createProjectHandler( const flagConfig = processProvidedFlagsWithoutValidation(cliInput, finalBaseName); config = { - ...getDefaultConfig(), + ...getYesBaseConfig(flagConfig), ...flagConfig, projectName: finalBaseName, projectDir: finalResolvedPath, diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index 6228c53d0..f9b497acd 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -16309,6 +16309,7 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/health_controller.ex", "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/item_controller.ex", "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/page_controller.ex", + "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/user_session_controller.ex", "lib/snapshot_elixir_phoenix_liveview_full_web/endpoint.ex", "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/resolvers/catalog.ex", "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/schema.ex", @@ -16479,7 +16480,7 @@ CORS_ORIGIN=http://localhost:3001" exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-liveview-full 1`] = ` { - "fileCount": 44, + "fileCount": 45, "files": [ { "content": @@ -16564,6 +16565,10 @@ CORS_ORIGIN=http://localhost:3001" "content": "[exists]", "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/page_controller.ex", }, + { + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/user_session_controller.ex", + }, { "content": "[exists]", "path": "lib/snapshot_elixir_phoenix_liveview_full_web/endpoint.ex", diff --git a/apps/cli/test/mobile.test.ts b/apps/cli/test/mobile.test.ts index 923c5401e..031a285f3 100644 --- a/apps/cli/test/mobile.test.ts +++ b/apps/cli/test/mobile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createVirtual } from "../src/index"; +import { create, createVirtual } from "../src/index"; import type { VirtualDirectory, VirtualFile, VirtualNode } from "../src/index"; function findFile(node: VirtualNode, path: string): VirtualFile | undefined { @@ -21,6 +21,26 @@ function getFile(root: VirtualNode, path: string) { } describe("mobile native scaffolding", () => { + test("uses React Native defaults when --yes selects the React Native ecosystem", async () => { + const result = await create("mobile-yes", { + ecosystem: "react-native", + yes: true, + dryRun: true, + install: false, + git: false, + packageManager: "bun", + }); + + expect(result.success).toBe(true); + expect(result.projectConfig.ecosystem).toBe("react-native"); + expect(result.projectConfig.frontend).toEqual(["native-bare"]); + expect(result.projectConfig.backend).toBe("none"); + expect(result.projectConfig.runtime).toBe("none"); + expect(result.projectConfig.api).toBe("none"); + expect(result.projectConfig.cssFramework).toBe("none"); + expect(result.projectConfig.uiLibrary).toBe("none"); + }); + test("generates React Navigation with production mobile integrations", async () => { const result = await createVirtual({ projectName: "mobile-rn", diff --git a/apps/cli/test/virtual-generator-regressions.test.ts b/apps/cli/test/virtual-generator-regressions.test.ts index b4dd0160f..19a623152 100644 --- a/apps/cli/test/virtual-generator-regressions.test.ts +++ b/apps/cli/test/virtual-generator-regressions.test.ts @@ -163,6 +163,37 @@ describe("Virtual Generator Regressions", () => { expect(readTextFromTree(result.tree!, "lib/elixir_live_no_ecto/catalog.ex")).toBeUndefined(); }); + it("scaffolds phx.gen.auth-style password hashing and session endpoints", async () => { + const result = await createVirtual({ + projectName: "elixir-auth", + ecosystem: "elixir", + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + elixirAuth: "phx-gen-auth", + }); + + expect(result.success).toBe(true); + + const mixProject = readTextFromTree(result.tree!, "mix.exs"); + const userSchema = readTextFromTree(result.tree!, "lib/elixir_auth/accounts/user.ex"); + const accounts = readTextFromTree(result.tree!, "lib/elixir_auth/accounts.ex"); + const router = readTextFromTree(result.tree!, "lib/elixir_auth_web/router.ex"); + const sessionController = readTextFromTree( + result.tree!, + "lib/elixir_auth_web/controllers/user_session_controller.ex", + ); + + expect(mixProject).toContain("{:bcrypt_elixir"); + expect(userSchema).toContain("field :password, :string, virtual: true"); + expect(userSchema).toContain("Bcrypt.hash_pwd_salt(password)"); + expect(userSchema).toContain("Bcrypt.verify_pass(password, hashed_password)"); + expect(userSchema).not.toContain("cast(attrs, [:email, :hashed_password])"); + expect(accounts).toContain("get_user_by_email_and_password"); + expect(router).toContain('post "/users/register", UserSessionController, :register'); + expect(router).toContain('post "/users/login", UserSessionController, :login'); + expect(sessionController).toContain("def login"); + }); + it("normalizes Elixir app and module names that start with digits", async () => { const result = await createVirtual({ projectName: "123-app", diff --git a/packages/template-generator/src/template-handlers/elixir-base.ts b/packages/template-generator/src/template-handlers/elixir-base.ts index b27edad50..c4c0dc005 100644 --- a/packages/template-generator/src/template-handlers/elixir-base.ts +++ b/packages/template-generator/src/template-handlers/elixir-base.ts @@ -36,6 +36,7 @@ export async function processElixirBaseTemplate( if (!hasEcto && (templatePath.includes("/repo.ex") || templatePath.includes("/migrations/"))) continue; if (!hasEcto && (templatePath.includes("/catalog") || templatePath.includes("/item_controller"))) continue; if (!hasAuth && templatePath.includes("/accounts")) continue; + if (!hasAuth && templatePath.includes("/user_session_controller")) continue; if (!hasChannels && templatePath.includes("/channels/room_channel")) continue; if (!hasPresence && templatePath.includes("/channels/presence")) continue; if (!hasOban && templatePath.includes("/workers/")) continue; diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs index 414a928d5..b04ea35c9 100644 --- a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs @@ -2,6 +2,17 @@ defmodule {{elixirModuleName}}.Accounts do alias {{elixirModuleName}}.Accounts.User alias {{elixirModuleName}}.Repo + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + def get_user_by_email_and_password(email, password) + when is_binary(email) and is_binary(password) do + user = get_user_by_email(email) + + if User.valid_password?(user, password), do: user + end + def create_user(attrs) do %User{} |> User.registration_changeset(attrs) diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs index 5f28b0582..e08076429 100644 --- a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs @@ -4,16 +4,53 @@ defmodule {{elixirModuleName}}.Accounts.User do schema "users" do field :email, :string - field :hashed_password, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true timestamps(type: :utc_datetime) end - def registration_changeset(user, attrs) do + def registration_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:email, :hashed_password]) - |> validate_required([:email, :hashed_password]) + |> cast(attrs, [:email, :password]) + |> validate_email() + |> validate_password(opts) + end + + def valid_password?(%__MODULE__{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/) |> unique_constraint(:email) end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/user_session_controller.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/user_session_controller.ex.hbs new file mode 100644 index 000000000..66e2a903f --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/controllers/user_session_controller.ex.hbs @@ -0,0 +1,54 @@ +defmodule {{elixirModuleName}}Web.UserSessionController do + use {{elixirModuleName}}Web, :controller + + alias {{elixirModuleName}}.Accounts + + def register(conn, %{"user" => user_params}) do + case Accounts.create_user(user_params) do + {:ok, user} -> + conn + |> configure_session(renew: true) + |> put_session(:user_id, user.id) + |> put_status(:created) + |> json(%{data: user_json(user)}) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: errors_json(changeset)}) + end + end + + def login(conn, %{"user" => %{"email" => email, "password" => password}}) do + case Accounts.get_user_by_email_and_password(email, password) do + nil -> + conn + |> put_status(:unauthorized) + |> json(%{errors: %{credentials: ["are invalid"]}}) + + user -> + conn + |> configure_session(renew: true) + |> put_session(:user_id, user.id) + |> json(%{data: user_json(user)}) + end + end + + def logout(conn, _params) do + conn + |> configure_session(drop: true) + |> json(%{data: %{ok: true}}) + end + + defp user_json(user) do + %{id: user.id, email: user.email} + end + + defp errors_json(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Enum.reduce(opts, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs index 1fe249444..93a143dfc 100644 --- a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs @@ -27,6 +27,11 @@ defmodule {{elixirModuleName}}Web.Router do pipe_through :api get "/health", HealthController, :show +{{#if (eq elixirAuth "phx-gen-auth")}} + post "/users/register", UserSessionController, :register + post "/users/login", UserSessionController, :login + delete "/users/logout", UserSessionController, :logout +{{/if}} {{#if (and (eq elixirApi "rest") (ne elixirOrm "none"))}} resources "/items", ItemController, only: [:index, :show, :create] {{/if}} diff --git a/packages/template-generator/templates/elixir-base/mix.exs.hbs b/packages/template-generator/templates/elixir-base/mix.exs.hbs index 856b92c10..7876dea55 100644 --- a/packages/template-generator/templates/elixir-base/mix.exs.hbs +++ b/packages/template-generator/templates/elixir-base/mix.exs.hbs @@ -43,6 +43,9 @@ defmodule {{elixirModuleName}}.MixProject do {{#if (eq elixirAuth "guardian")}} {:guardian, "~> 2.3"}, {{/if}} +{{#if (eq elixirAuth "phx-gen-auth")}} + {:bcrypt_elixir, "~> 3.0"}, +{{/if}} {{#if (eq elixirJobs "oban")}} {:oban, "~> 2.19"}, {{/if}} From a728e2a773b8722c85fc600aa608a1e45878e5c0 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 22:50:42 +0300 Subject: [PATCH 22/33] clean up CI bun pins and ecosystem docs --- .github/workflows/codebase-deps.yaml | 2 +- .github/workflows/dep-freshness.yaml | 4 ++-- .github/workflows/deps-check.yaml | 4 ++-- .github/workflows/e2e-test.yaml | 12 ++++++------ .github/workflows/pr-preview.yaml | 2 +- .github/workflows/smoke-test.yaml | 2 +- .github/workflows/template-matrix.yaml | 2 +- .github/workflows/test.yaml | 8 ++++---- .github/workflows/upstream-gap.yml | 2 +- README.md | 8 ++++---- apps/cli/README.md | 4 ++-- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/codebase-deps.yaml b/.github/workflows/codebase-deps.yaml index 1b54435d5..f9a60b9a3 100644 --- a/.github/workflows/codebase-deps.yaml +++ b/.github/workflows/codebase-deps.yaml @@ -34,7 +34,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Resolve Update Mode id: mode diff --git a/.github/workflows/dep-freshness.yaml b/.github/workflows/dep-freshness.yaml index 361c73feb..3c21186b9 100644 --- a/.github/workflows/dep-freshness.yaml +++ b/.github/workflows/dep-freshness.yaml @@ -21,7 +21,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Install Dependencies run: bun install --frozen-lockfile @@ -47,7 +47,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Install Dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/deps-check.yaml b/.github/workflows/deps-check.yaml index ed79113ba..878fe811c 100644 --- a/.github/workflows/deps-check.yaml +++ b/.github/workflows/deps-check.yaml @@ -43,7 +43,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Cache Dependencies uses: actions/cache@v4 @@ -227,7 +227,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index 75a573a75..1cafc0269 100644 --- a/.github/workflows/e2e-test.yaml +++ b/.github/workflows/e2e-test.yaml @@ -37,7 +37,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - uses: actions/setup-node@v4 with: @@ -91,7 +91,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - uses: actions/setup-node@v4 with: @@ -156,7 +156,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - uses: actions/setup-node@v4 with: @@ -221,7 +221,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - uses: actions/setup-node@v4 with: @@ -272,7 +272,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - uses: actions/setup-node@v4 with: @@ -317,7 +317,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pr-preview.yaml b/.github/workflows/pr-preview.yaml index 70af9dec1..7941e6f25 100644 --- a/.github/workflows/pr-preview.yaml +++ b/.github/workflows/pr-preview.yaml @@ -28,7 +28,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Cache Dependencies uses: actions/cache@v4 diff --git a/.github/workflows/smoke-test.yaml b/.github/workflows/smoke-test.yaml index 5dcd7f7c6..2da75570e 100644 --- a/.github/workflows/smoke-test.yaml +++ b/.github/workflows/smoke-test.yaml @@ -59,7 +59,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/template-matrix.yaml b/.github/workflows/template-matrix.yaml index c5f223300..e72420885 100644 --- a/.github/workflows/template-matrix.yaml +++ b/.github/workflows/template-matrix.yaml @@ -43,7 +43,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Setup Rust if: startsWith(matrix.preset, 'rust-') diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cff887e08..4560d5038 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,7 +22,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Cache Dependencies uses: actions/cache@v4 @@ -51,7 +51,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Cache Dependencies uses: actions/cache@v4 @@ -92,7 +92,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Setup Deno uses: denoland/setup-deno@v2 @@ -127,7 +127,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Cache Dependencies uses: actions/cache@v4 diff --git a/.github/workflows/upstream-gap.yml b/.github/workflows/upstream-gap.yml index e7569ea6a..efb04d219 100644 --- a/.github/workflows/upstream-gap.yml +++ b/.github/workflows/upstream-gap.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.12 + bun-version: latest - name: Generate upstream gap report run: bun run scripts/upstream-gap-report.ts --markdown > upstream-gap-report.md diff --git a/README.md b/README.md index e8d722c3d..5edf8cce4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@
-**Scaffold production-ready fullstack apps in seconds. Browse 450+ tools across six ecosystems — the CLI wires everything together.** +**Scaffold production-ready fullstack apps in seconds. Browse 450+ tools across seven ecosystems — the CLI wires everything together.**
@@ -35,7 +35,7 @@ Most scaffolding tools lock you into one framework and one opinion. Better Fullstack doesn't. - **450+ tools** — frontend, backend, database, auth, payments, AI, DevOps, and more -- **6 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java — with more coming +- **7 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java, Elixir — with more coming - **Visual builder** — configure your stack in the browser, get a ready-to-run CLI command - **Wired for you** — no manual glue code; every picked integration is preconfigured and working out of the box @@ -113,8 +113,8 @@ Better Fullstack is organized around the decisions that matter: pick an ecosyste Only the relevant options surface for the stack you pick. -6 ecosystems
-TypeScript, React Native, Rust, Python, Go, Java. +7 ecosystems
+TypeScript, React Native, Rust, Python, Go, Java, Elixir. One command
diff --git a/apps/cli/README.md b/apps/cli/README.md index 1127ae7f6..6ab277902 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -32,7 +32,7 @@ Configure your stack visually — pick every option from a UI, preview your choi ## Features - **425 options** — frontend, backend, database, auth, payments, AI, DevOps, and more -- **6 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java +- **7 ecosystems** — TypeScript, React Native, Rust, Python, Go, Java, Elixir - **Visual builder** — configure your stack in the browser - **Wired for you** — every picked integration is preconfigured and working out of the box @@ -42,7 +42,7 @@ Configure your stack visually — pick every option from a UI, preview your choi --yes # Accept all defaults --yolo # Scaffold a random stack — good for exploring --template # Use a preset (t3, mern, pern, uniwind) ---ecosystem # Start in typescript, react-native, rust, python, go, or java mode +--ecosystem # Start in typescript, react-native, rust, python, go, java, or elixir mode --version-channel # Dependency channel: stable, latest, beta --no-git # Skip git initialization --no-install # Skip dependency installation From a8ab877077f45923068d51219078109bdcaa2d22 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 22:53:09 +0300 Subject: [PATCH 23/33] skip elixir auth migration without auth --- .../template-snapshots.test.ts.snap | 7 +------ .../test/virtual-generator-regressions.test.ts | 17 +++++++++++++++++ .../src/template-handlers/elixir-base.ts | 1 + 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index f9b497acd..d06ea4820 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -16270,7 +16270,6 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f "lib/snapshot_elixir_phoenix_ecto_rest_web/telemetry.ex", "mix.exs", "priv/repo/migrations/20260101000000_create_items.exs", - "priv/repo/migrations/20260101000001_create_users.exs", "priv/repo/seeds.exs", "test/snapshot_elixir_phoenix_ecto_rest_web/controllers/health_controller_test.exs", "test/support/conn_case.ex", @@ -16329,7 +16328,7 @@ exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots f exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-ecto-rest 1`] = ` { - "fileCount": 34, + "fileCount": 33, "files": [ { "content": @@ -16450,10 +16449,6 @@ CORS_ORIGIN=http://localhost:3001" "content": "[exists]", "path": "priv/repo/migrations/20260101000000_create_items.exs", }, - { - "content": "[exists]", - "path": "priv/repo/migrations/20260101000001_create_users.exs", - }, { "content": "[exists]", "path": "priv/repo/seeds.exs", diff --git a/apps/cli/test/virtual-generator-regressions.test.ts b/apps/cli/test/virtual-generator-regressions.test.ts index 19a623152..f552cc336 100644 --- a/apps/cli/test/virtual-generator-regressions.test.ts +++ b/apps/cli/test/virtual-generator-regressions.test.ts @@ -188,12 +188,29 @@ describe("Virtual Generator Regressions", () => { expect(userSchema).toContain("Bcrypt.hash_pwd_salt(password)"); expect(userSchema).toContain("Bcrypt.verify_pass(password, hashed_password)"); expect(userSchema).not.toContain("cast(attrs, [:email, :hashed_password])"); + expect(readTextFromTree(result.tree!, "priv/repo/migrations/20260101000001_create_users.exs")).toBeDefined(); expect(accounts).toContain("get_user_by_email_and_password"); expect(router).toContain('post "/users/register", UserSessionController, :register'); expect(router).toContain('post "/users/login", UserSessionController, :login'); expect(sessionController).toContain("def login"); }); + it("skips auth-only user migration when Elixir auth is disabled", async () => { + const result = await createVirtual({ + projectName: "elixir-no-auth", + ecosystem: "elixir", + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + elixirAuth: "none", + elixirApi: "rest", + elixirRealtime: "none", + elixirJobs: "none", + }); + + expect(result.success).toBe(true); + expect(readTextFromTree(result.tree!, "priv/repo/migrations/20260101000001_create_users.exs")).toBeUndefined(); + }); + it("normalizes Elixir app and module names that start with digits", async () => { const result = await createVirtual({ projectName: "123-app", diff --git a/packages/template-generator/src/template-handlers/elixir-base.ts b/packages/template-generator/src/template-handlers/elixir-base.ts index c4c0dc005..fb7471d1f 100644 --- a/packages/template-generator/src/template-handlers/elixir-base.ts +++ b/packages/template-generator/src/template-handlers/elixir-base.ts @@ -36,6 +36,7 @@ export async function processElixirBaseTemplate( if (!hasEcto && (templatePath.includes("/repo.ex") || templatePath.includes("/migrations/"))) continue; if (!hasEcto && (templatePath.includes("/catalog") || templatePath.includes("/item_controller"))) continue; if (!hasAuth && templatePath.includes("/accounts")) continue; + if (!hasAuth && templatePath.includes("create_users")) continue; if (!hasAuth && templatePath.includes("/user_session_controller")) continue; if (!hasChannels && templatePath.includes("/channels/room_channel")) continue; if (!hasPresence && templatePath.includes("/channels/presence")) continue; From 1a3a3c3c13d18afc4f77c2a7bd483f7fca38f8a5 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sat, 23 May 2026 23:10:22 +0300 Subject: [PATCH 24/33] Fix ModelFusion fets AI smoke --- apps/cli/test/ai-deps.test.ts | 50 +++++++++++++++++++ .../src/processors/examples-deps.ts | 8 ++- .../backend/server/fets/src/index.ts.hbs | 4 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/apps/cli/test/ai-deps.test.ts b/apps/cli/test/ai-deps.test.ts index ca50b0776..3af2f44f9 100644 --- a/apps/cli/test/ai-deps.test.ts +++ b/apps/cli/test/ai-deps.test.ts @@ -195,6 +195,56 @@ describe("AI SDK Dependencies", () => { } }); + it("should install standalone server example deps for ModelFusion AI examples", async () => { + const result = await runTRPCTest({ + projectName: "ai-deps-modelfusion-example-fets", + ecosystem: "typescript", + frontend: ["react-vite"], + backend: "fets", + runtime: "node", + database: "redis", + orm: "none", + api: "ts-rest", + auth: "none", + payments: "none", + addons: ["none"], + examples: ["ai"], + dbSetup: "none", + webDeploy: "none", + serverDeploy: "none", + ai: "modelfusion", + cssFramework: "tailwind", + uiLibrary: "none", + effect: "none", + email: "none", + stateManagement: "valtio", + forms: "none", + testing: "none", + validation: "typebox", + realtime: "socket-io", + jobQueue: "none", + animation: "react-spring", + logging: "none", + observability: "opentelemetry", + analytics: "plausible", + cms: "none", + caching: "none", + fileUpload: "none", + fileStorage: "none", + packageManager: "bun", + }); + expectSuccess(result); + + if (result.projectDir) { + const serverPkg = await Bun.file(`${result.projectDir}/apps/server/package.json`).json(); + + expect(serverPkg.dependencies["modelfusion"]).toBeDefined(); + expect(serverPkg.dependencies["ai"]).toBeDefined(); + expect(serverPkg.dependencies["@ai-sdk/google"]).toBeDefined(); + expect(serverPkg.dependencies["@ai-sdk/devtools"]).toBeDefined(); + } + }); + it("should install llamaindex SDK when selected", async () => { const result = await runTRPCTest({ projectName: "ai-deps-llamaindex", diff --git a/packages/template-generator/src/processors/examples-deps.ts b/packages/template-generator/src/processors/examples-deps.ts index 4e18e1d22..62e2f0db4 100644 --- a/packages/template-generator/src/processors/examples-deps.ts +++ b/packages/template-generator/src/processors/examples-deps.ts @@ -102,6 +102,10 @@ function setupAIDependencies(vfs: VirtualFileSystem, config: ProjectConfig): voi const useGoogleADK = ai === "google-adk"; const useModelFusion = ai === "modelfusion"; const sharedAIExampleServerDeps: AvailableDependencies[] = ["ai", "@ai-sdk/google", "@ai-sdk/devtools"]; + const modelFusionServerDeps: AvailableDependencies[] = + backend === "nitro" || backend === "self" + ? ["modelfusion"] + : ["modelfusion", ...sharedAIExampleServerDeps]; if (backend === "convex" && convexBackendExists) { addPackageDependency({ @@ -160,7 +164,7 @@ function setupAIDependencies(vfs: VirtualFileSystem, config: ProjectConfig): voi addPackageDependency({ vfs, packagePath: webPkgPath, - dependencies: ["modelfusion"], + dependencies: modelFusionServerDeps, }); } else { addPackageDependency({ @@ -219,7 +223,7 @@ function setupAIDependencies(vfs: VirtualFileSystem, config: ProjectConfig): voi addPackageDependency({ vfs, packagePath: serverPkgPath, - dependencies: ["modelfusion"], + dependencies: modelFusionServerDeps, }); } else { addPackageDependency({ diff --git a/packages/template-generator/templates/backend/server/fets/src/index.ts.hbs b/packages/template-generator/templates/backend/server/fets/src/index.ts.hbs index 052a0a44c..0536c74f4 100644 --- a/packages/template-generator/templates/backend/server/fets/src/index.ts.hbs +++ b/packages/template-generator/templates/backend/server/fets/src/index.ts.hbs @@ -206,7 +206,7 @@ const router: Router = createRouter({ model, messages: await convertToModelMessages(uiMessages), }); - return result.toUIMessageStreamResponse(); + return result.toUIMessageStreamResponse() as any; }, }) {{/if}} @@ -228,7 +228,7 @@ const router: Router = createRouter({ model, messages: await convertToModelMessages(uiMessages), }); - return result.toUIMessageStreamResponse(); + return result.toUIMessageStreamResponse() as any; }, }) {{/if}}; From b09a014230bfacf84d3612284074675ff54d9af0 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 00:14:15 +0300 Subject: [PATCH 25/33] Fix virtual native defaults --- apps/cli/src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5ed5fa3a2..d71dada7b 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -433,6 +433,10 @@ export async function createVirtual( try { const ecosystem = options.ecosystem || "typescript"; const isReactNative = ecosystem === "react-native"; + const frontend = options.frontend || (isReactNative ? ["native-bare"] : ["tanstack-router"]); + const hasNativeFrontend = frontend.some((item) => + item === "native-bare" || item === "native-uniwind" || item === "native-unistyles" + ); const config: ProjectConfig = { ecosystem, projectName: options.projectName || "my-project", @@ -442,7 +446,7 @@ export async function createVirtual( orm: options.orm || "none", backend: options.backend || (isReactNative ? "none" : "hono"), runtime: options.runtime || (isReactNative ? "none" : "bun"), - frontend: options.frontend || (isReactNative ? ["native-bare"] : ["tanstack-router"]), + frontend, addons: options.addons || [], examples: options.examples || [], auth: options.auth || "none", @@ -479,13 +483,13 @@ export async function createVirtual( observability: options.observability || "none", featureFlags: options.featureFlags || "none", analytics: options.analytics || "none", - mobileNavigation: options.mobileNavigation || "expo-router", + mobileNavigation: options.mobileNavigation || (hasNativeFrontend ? "expo-router" : "none"), mobileUI: options.mobileUI || "none", mobileStorage: options.mobileStorage || "none", mobileTesting: options.mobileTesting || "none", mobilePush: options.mobilePush || "none", mobileOTA: options.mobileOTA || "none", - mobileDeepLinking: options.mobileDeepLinking || "expo-linking", + mobileDeepLinking: options.mobileDeepLinking || (hasNativeFrontend ? "expo-linking" : "none"), cms: options.cms || "none", caching: options.caching || "none", i18n: options.i18n || "none", From 6967d10013c2f7262e764ee93e1b82f66a42b7fd Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 00:21:03 +0300 Subject: [PATCH 26/33] Add plain Elixir scaffold support --- .../virtual-generator-regressions.test.ts | 34 +++++++++++ .../src/processors/readme-generator.ts | 24 ++++---- .../src/template-handlers/elixir-base.ts | 9 ++- .../templates/elixir-base/.env.example.hbs | 4 ++ .../templates/elixir-base/README.md.hbs | 12 ++++ .../elixir-base/config/config.exs.hbs | 4 ++ .../templates/elixir-base/config/dev.exs.hbs | 4 ++ .../elixir-base/config/runtime.exs.hbs | 4 ++ .../templates/elixir-base/config/test.exs.hbs | 2 + .../elixir-base/lib/__elixirAppName__.ex.hbs | 4 ++ .../lib/__elixirAppName__/application.ex.hbs | 8 +++ .../templates/elixir-base/mix.exs.hbs | 8 +++ packages/types/src/compatibility.ts | 61 ++++++++++--------- packages/types/test/compatibility.test.ts | 48 +++++++++++++++ 14 files changed, 184 insertions(+), 42 deletions(-) diff --git a/apps/cli/test/virtual-generator-regressions.test.ts b/apps/cli/test/virtual-generator-regressions.test.ts index f552cc336..eeb089c28 100644 --- a/apps/cli/test/virtual-generator-regressions.test.ts +++ b/apps/cli/test/virtual-generator-regressions.test.ts @@ -142,6 +142,40 @@ describe("Virtual Generator Regressions", () => { expect(readTextFromTree(result.tree!, "lib/elixir_no_quantum/scheduler.ex")).toBeUndefined(); }); + it("scaffolds plain Elixir projects without Phoenix web files", async () => { + const result = await createVirtual({ + projectName: "plain-elixir", + ecosystem: "elixir", + elixirWebFramework: "none", + elixirOrm: "none", + elixirAuth: "none", + elixirApi: "none", + elixirRealtime: "none", + elixirJobs: "quantum", + elixirHttp: "req", + elixirJson: "jason", + elixirTesting: "none", + }); + + expect(result.success).toBe(true); + + const mixProject = readTextFromTree(result.tree!, "mix.exs"); + const application = readTextFromTree(result.tree!, "lib/plain_elixir/application.ex"); + const readme = readTextFromTree(result.tree!, "README.md"); + + expect(mixProject).toContain("{:quantum"); + expect(mixProject).toContain("{:req"); + expect(mixProject).not.toContain("{:phoenix"); + expect(mixProject).not.toContain("{:plug_cowboy"); + expect(application).toContain("PlainElixir.Scheduler"); + expect(application).not.toContain("PlainElixirWeb.Endpoint"); + expect(readme).toContain("for the Elixir ecosystem"); + expect(readme).toContain("iex -S mix"); + expect(readme).not.toContain("mix phx.server"); + expect(readTextFromTree(result.tree!, "lib/plain_elixir_web/router.ex")).toBeUndefined(); + expect(readTextFromTree(result.tree!, "test/support/conn_case.ex")).toBeUndefined(); + }); + it("keeps Phoenix LiveView demos self-contained without Ecto", async () => { const result = await createVirtual({ projectName: "elixir-live-no-ecto", diff --git a/packages/template-generator/src/processors/readme-generator.ts b/packages/template-generator/src/processors/readme-generator.ts index f759d91e9..09b6dd2e8 100644 --- a/packages/template-generator/src/processors/readme-generator.ts +++ b/packages/template-generator/src/processors/readme-generator.ts @@ -83,9 +83,15 @@ export function processReadme(vfs: VirtualFileSystem, config: ProjectConfig): vo } function generateElixirReadmeContent(config: ProjectConfig): string { + const hasPhoenix = config.elixirWebFramework !== "none"; + const hasEcto = config.elixirOrm !== "none"; const features = [ - config.elixirWebFramework === "phoenix-live-view" ? "Phoenix LiveView" : "Phoenix", - config.elixirOrm !== "none" ? `${config.elixirOrm} with PostgreSQL` : null, + hasPhoenix + ? config.elixirWebFramework === "phoenix-live-view" + ? "Phoenix LiveView" + : "Phoenix" + : "Plain Elixir", + hasEcto ? `${config.elixirOrm} with PostgreSQL` : null, config.elixirApi !== "none" ? `API: ${config.elixirApi}` : null, config.elixirRealtime !== "none" ? `Realtime: ${config.elixirRealtime}` : null, config.elixirJobs !== "none" ? `Jobs: ${config.elixirJobs}` : null, @@ -95,7 +101,7 @@ function generateElixirReadmeContent(config: ProjectConfig): string { return `# ${config.projectName} -This project was created with [Better Fullstack](https://github.com/Marve10s/Better-Fullstack) for the Elixir/Phoenix ecosystem. +This project was created with [Better Fullstack](https://github.com/Marve10s/Better-Fullstack) for the Elixir${hasPhoenix ? "/Phoenix" : ""} ecosystem. ## Stack @@ -103,24 +109,20 @@ ${features.map((feature) => `- ${feature}`).join("\n")} ## Getting Started -Make sure Elixir, Erlang/OTP, and PostgreSQL are installed. +Make sure Elixir and Erlang/OTP${hasEcto ? ", and PostgreSQL" : ""} are installed. \`\`\`sh mix deps.get -mix ecto.setup -mix phx.server +${hasEcto ? "mix ecto.setup\n" : ""}${hasPhoenix ? "mix phx.server" : "iex -S mix"} \`\`\` -Open http://localhost:4000. - -## Tests +${hasPhoenix ? "Open http://localhost:4000.\n\n" : ""}## Tests \`\`\`sh mix test \`\`\` -Copy \`.env.example\` values into your environment before production release builds. -`; +${hasPhoenix || hasEcto ? "Copy `.env.example` values into your environment before production release builds.\n" : ""}`; } function sanitizeJavaPackageSuffix(projectName: string): string { diff --git a/packages/template-generator/src/template-handlers/elixir-base.ts b/packages/template-generator/src/template-handlers/elixir-base.ts index fb7471d1f..9581c1e95 100644 --- a/packages/template-generator/src/template-handlers/elixir-base.ts +++ b/packages/template-generator/src/template-handlers/elixir-base.ts @@ -18,20 +18,23 @@ export async function processElixirBaseTemplate( if (config.ecosystem !== "elixir") return; const prefix = "elixir-base/"; + const hasPhoenix = config.elixirWebFramework !== "none"; const hasLiveView = config.elixirWebFramework === "phoenix-live-view"; const hasEcto = config.elixirOrm !== "none"; - const hasAuth = config.elixirAuth === "phx-gen-auth" && hasEcto; - const hasChannels = config.elixirRealtime === "channels" || config.elixirRealtime === "presence"; + const hasAuth = hasPhoenix && config.elixirAuth === "phx-gen-auth" && hasEcto; + const hasChannels = hasPhoenix && (config.elixirRealtime === "channels" || config.elixirRealtime === "presence"); const hasPresence = config.elixirRealtime === "presence"; const hasOban = config.elixirJobs === "oban"; const hasQuantum = config.elixirJobs === "quantum"; - const hasAbsinthe = config.elixirApi === "absinthe" && hasEcto; + const hasAbsinthe = hasPhoenix && config.elixirApi === "absinthe" && hasEcto; const hasEmail = config.elixirEmail === "swoosh"; const hasDocker = ["docker", "fly", "gigalixir", "mix-release"].includes(config.elixirDeploy); const hasHttpClient = config.elixirHttp !== "none"; for (const [templatePath, content] of templates) { if (!templatePath.startsWith(prefix)) continue; + if (!hasPhoenix && templatePath.includes("___web")) continue; + if (!hasPhoenix && templatePath.includes("test/support/conn_case")) continue; if (!hasLiveView && templatePath.includes("/live/")) continue; if (!hasEcto && (templatePath.includes("/repo.ex") || templatePath.includes("/migrations/"))) continue; if (!hasEcto && (templatePath.includes("/catalog") || templatePath.includes("/item_controller"))) continue; diff --git a/packages/template-generator/templates/elixir-base/.env.example.hbs b/packages/template-generator/templates/elixir-base/.env.example.hbs index 671ce5fd9..a0cc64e1a 100644 --- a/packages/template-generator/templates/elixir-base/.env.example.hbs +++ b/packages/template-generator/templates/elixir-base/.env.example.hbs @@ -1,9 +1,13 @@ +{{#if (ne elixirWebFramework "none")}} PHX_HOST=localhost PORT=4000 SECRET_KEY_BASE=replace-with-mix-phx-gen-secret +{{/if}} +{{#if (ne elixirOrm "none")}} DATABASE_URL=ecto://postgres:postgres@localhost/{{elixirAppName}}_prod POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_HOST=localhost POSTGRES_DB={{elixirAppName}}_dev POSTGRES_TEST_DB={{elixirAppName}}_test +{{/if}} diff --git a/packages/template-generator/templates/elixir-base/README.md.hbs b/packages/template-generator/templates/elixir-base/README.md.hbs index 7af984dc0..49e529d32 100644 --- a/packages/template-generator/templates/elixir-base/README.md.hbs +++ b/packages/template-generator/templates/elixir-base/README.md.hbs @@ -1,6 +1,10 @@ # {{projectName}} +{{#if (ne elixirWebFramework "none")}} Phoenix project generated by Better Fullstack. +{{else}} +Elixir project generated by Better Fullstack. +{{/if}} ## Stack @@ -15,11 +19,19 @@ Phoenix project generated by Better Fullstack. ```sh mix deps.get +{{#if (ne elixirOrm "none")}} mix ecto.setup +{{/if}} +{{#if (ne elixirWebFramework "none")}} mix phx.server +{{else}} +iex -S mix +{{/if}} ``` +{{#if (ne elixirWebFramework "none")}} Open http://localhost:4000. +{{/if}} For tests: diff --git a/packages/template-generator/templates/elixir-base/config/config.exs.hbs b/packages/template-generator/templates/elixir-base/config/config.exs.hbs index 0a863924e..5ada23d12 100644 --- a/packages/template-generator/templates/elixir-base/config/config.exs.hbs +++ b/packages/template-generator/templates/elixir-base/config/config.exs.hbs @@ -9,6 +9,7 @@ config :{{elixirAppName}}, generators: [timestamp_type: :utc_datetime] {{/if}} +{{#if (ne elixirWebFramework "none")}} config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, url: [host: "localhost"], render_errors: [ @@ -17,12 +18,15 @@ config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, ], pubsub_server: {{elixirModuleName}}.PubSub, live_view: [signing_salt: "change-me"] +{{/if}} config :logger, :console, format: "$time $metadata[$level] $message\n", metadata: [:request_id] +{{#if (ne elixirWebFramework "none")}} config :phoenix, :json_library, Jason +{{/if}} {{#if (eq elixirJobs "oban")}} config :{{elixirAppName}}, Oban, diff --git a/packages/template-generator/templates/elixir-base/config/dev.exs.hbs b/packages/template-generator/templates/elixir-base/config/dev.exs.hbs index 0bd8e2728..bb22e8eb9 100644 --- a/packages/template-generator/templates/elixir-base/config/dev.exs.hbs +++ b/packages/template-generator/templates/elixir-base/config/dev.exs.hbs @@ -1,5 +1,6 @@ import Config +{{#if (ne elixirWebFramework "none")}} config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")], check_origin: false, @@ -7,6 +8,7 @@ config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, debug_errors: true, secret_key_base: "dev-secret-key-base-replace-before-production", watchers: [] +{{/if}} {{#if (ne elixirOrm "none")}} config :{{elixirAppName}}, {{elixirModuleName}}.Repo, @@ -20,5 +22,7 @@ config :{{elixirAppName}}, {{elixirModuleName}}.Repo, {{/if}} config :logger, :console, format: "[$level] $message\n" +{{#if (ne elixirWebFramework "none")}} config :phoenix, :stacktrace_depth, 20 config :phoenix, :plug_init_mode, :runtime +{{/if}} diff --git a/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs b/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs index 229ef0685..261ba95cc 100644 --- a/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs +++ b/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs @@ -1,6 +1,7 @@ import Config if config_env() == :prod do +{{#if (ne elixirOrm "none")}} database_url = System.get_env("DATABASE_URL") if database_url do @@ -9,7 +10,9 @@ if config_env() == :prod do pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), ssl: System.get_env("ECTO_SSL") == "true" end +{{/if}} +{{#if (ne elixirWebFramework "none")}} secret_key_base = System.get_env("SECRET_KEY_BASE") || raise "SECRET_KEY_BASE is missing. Generate one with: mix phx.gen.secret" @@ -22,4 +25,5 @@ if config_env() == :prod do http: [ip: {0, 0, 0, 0}, port: port], secret_key_base: secret_key_base, server: true +{{/if}} end diff --git a/packages/template-generator/templates/elixir-base/config/test.exs.hbs b/packages/template-generator/templates/elixir-base/config/test.exs.hbs index f935935e5..fc4d628c5 100644 --- a/packages/template-generator/templates/elixir-base/config/test.exs.hbs +++ b/packages/template-generator/templates/elixir-base/config/test.exs.hbs @@ -1,9 +1,11 @@ import Config +{{#if (ne elixirWebFramework "none")}} config :{{elixirAppName}}, {{elixirModuleName}}Web.Endpoint, http: [ip: {127, 0, 0, 1}, port: 4002], secret_key_base: "test-secret-key-base-replace-before-production", server: false +{{/if}} {{#if (ne elixirOrm "none")}} config :{{elixirAppName}}, {{elixirModuleName}}.Repo, diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs index c22e5f3b4..70b64e9a7 100644 --- a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs @@ -1,5 +1,9 @@ defmodule {{elixirModuleName}} do @moduledoc """ +{{#if (ne elixirWebFramework "none")}} Domain entrypoint for the {{projectName}} Phoenix application. +{{else}} + Domain entrypoint for the {{projectName}} Elixir application. +{{/if}} """ end diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs index cec5462cb..d2b98b6f6 100644 --- a/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs @@ -7,7 +7,9 @@ defmodule {{elixirModuleName}}.Application do {{#if (ne elixirOrm "none")}} {{elixirModuleName}}.Repo, {{/if}} +{{#if (ne elixirWebFramework "none")}} {Phoenix.PubSub, name: {{elixirModuleName}}.PubSub}, +{{/if}} {{#if (eq elixirHttp "finch")}} {Finch, name: {{elixirModuleName}}.Finch}, {{/if}} @@ -23,7 +25,9 @@ defmodule {{elixirModuleName}}.Application do {{#if (eq elixirJobs "quantum")}} {{elixirModuleName}}.Scheduler, {{/if}} +{{#if (ne elixirWebFramework "none")}} {{elixirModuleName}}Web.Endpoint +{{/if}} ] opts = [strategy: :one_for_one, name: {{elixirModuleName}}.Supervisor] @@ -32,7 +36,11 @@ defmodule {{elixirModuleName}}.Application do @impl true def config_change(changed, _new, removed) do +{{#if (ne elixirWebFramework "none")}} {{elixirModuleName}}Web.Endpoint.config_change(changed, removed) +{{else}} + _ = {changed, removed} +{{/if}} :ok end end diff --git a/packages/template-generator/templates/elixir-base/mix.exs.hbs b/packages/template-generator/templates/elixir-base/mix.exs.hbs index 7876dea55..6995f0780 100644 --- a/packages/template-generator/templates/elixir-base/mix.exs.hbs +++ b/packages/template-generator/templates/elixir-base/mix.exs.hbs @@ -26,6 +26,7 @@ defmodule {{elixirModuleName}}.MixProject do defp deps do [ +{{#if (ne elixirWebFramework "none")}} {:phoenix, "~> 1.7.21"}, {:phoenix_html, "~> 4.2"}, {:phoenix_live_reload, "~> 1.5", only: :dev}, @@ -33,6 +34,7 @@ defmodule {{elixirModuleName}}.MixProject do {{#if (eq elixirWebFramework "phoenix-live-view")}} {:phoenix_live_view, "~> 1.0"}, {{/if}} +{{/if}} {{#if (ne elixirOrm "none")}} {:ecto_sql, "~> 3.12"}, {:postgrex, ">= 0.0.0"}, @@ -65,7 +67,9 @@ defmodule {{elixirModuleName}}.MixProject do {{#if (eq elixirHttp "finch")}} {:finch, "~> 0.19"}, {{/if}} +{{#if (or (ne elixirWebFramework "none") (eq elixirJson "jason"))}} {:jason, "~> 1.4"}, +{{/if}} {{#if (eq elixirEmail "swoosh")}} {:swoosh, "~> 1.17"}, {:finch, "~> 0.19"}, @@ -102,7 +106,11 @@ defmodule {{elixirModuleName}}.MixProject do {{#if (eq elixirQuality "sobelow")}} {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {{/if}} +{{#if (ne elixirWebFramework "none")}} {:plug_cowboy, "~> 2.7"} +{{else}} + {:telemetry, "~> 1.3"} +{{/if}} ] end diff --git a/packages/types/src/compatibility.ts b/packages/types/src/compatibility.ts index 4e8753869..fc7aa8f89 100644 --- a/packages/types/src/compatibility.ts +++ b/packages/types/src/compatibility.ts @@ -1617,29 +1617,25 @@ export const analyzeStackCompatibility = ( if (nextStack.ecosystem === "elixir") { if (nextStack.elixirWebFramework === "none") { const dependentKeys: Array = [ - "elixirOrm", "elixirAuth", "elixirApi", "elixirRealtime", - "elixirJobs", - "elixirValidation", - "elixirHttp", - "elixirJson", - "elixirEmail", - "elixirCaching", "elixirObservability", - "elixirTesting", - "elixirQuality", - "elixirDeploy", ]; for (const key of dependentKeys) { - if (nextStack[key] !== "none") { + const value = nextStack[key]; + const shouldClear = + key !== "elixirObservability" + ? value !== "none" + : value === "phoenix-telemetry"; + + if (shouldClear) { nextStack[key] = "none" as never; changed = true; changes.push({ category: "elixirWebFramework", - message: `${getCategoryDisplayName(key)} set to 'None' (no Phoenix project selected)`, + message: `${getCategoryDisplayName(key)} set to 'None' (requires Phoenix)`, }); } } @@ -2740,21 +2736,10 @@ export const getDisabledReason = ( if (currentStack.ecosystem !== "elixir") { return "Elixir options only apply when the Elixir ecosystem is selected"; } - if (currentStack.elixirWebFramework === "none") { - return "Elixir options require a Phoenix project"; - } } if (category === "elixirWebFramework" && optionId !== "none" && currentStack.ecosystem !== "elixir") { - return "Phoenix is available only in the Elixir ecosystem"; - } - - if ( - category === "elixirWebFramework" && - optionId === "none" && - currentStack.ecosystem === "elixir" - ) { - return "The generated Elixir scaffold currently targets Phoenix projects"; + return "Elixir web frameworks are available only in the Elixir ecosystem"; } const elixirNotYetGenerated: Partial>> = { @@ -2768,9 +2753,6 @@ export const getDisabledReason = ( elixirValidation: { "nimble-options": "NimbleOptions is not generated yet; use Ecto Changesets or no extra validation", }, - elixirJson: { - none: "Phoenix JSON scaffolds require Jason", - }, elixirCaching: { nebulex: "Nebulex cache modules are not generated yet; use Cachex or no cache", }, @@ -2782,7 +2764,6 @@ export const getDisabledReason = ( mox: "Mox-specific test boundaries are not generated yet; use ExUnit", bypass: "Bypass-specific HTTP tests are not generated yet; use ExUnit", wallaby: "Wallaby browser tests are not generated yet; use ExUnit", - none: "Generated Phoenix projects include ExUnit tests", }, elixirDeploy: { fly: "Fly.io config is not generated yet; use Docker or mix releases", @@ -2795,6 +2776,30 @@ export const getDisabledReason = ( return unsupportedElixirReason; } + if (currentStack.ecosystem === "elixir" && currentStack.elixirWebFramework === "none") { + if (category === "elixirAuth" && optionId !== "none") { + return "Elixir auth scaffolds require Phoenix"; + } + if (category === "elixirApi" && optionId !== "none") { + return "Elixir API scaffolds require Phoenix"; + } + if (category === "elixirRealtime" && optionId !== "none") { + return "Elixir realtime scaffolds require Phoenix"; + } + if (category === "elixirObservability" && optionId === "phoenix-telemetry") { + return "Phoenix telemetry requires Phoenix"; + } + } + + if ( + category === "elixirJson" && + optionId === "none" && + currentStack.ecosystem === "elixir" && + currentStack.elixirWebFramework !== "none" + ) { + return "Phoenix JSON scaffolds require Jason"; + } + if (category === "elixirAuth") { if (optionId === "phx-gen-auth" && currentStack.elixirOrm === "none") { return "phx.gen.auth requires Ecto"; diff --git a/packages/types/test/compatibility.test.ts b/packages/types/test/compatibility.test.ts index eca91828a..7491b4d21 100644 --- a/packages/types/test/compatibility.test.ts +++ b/packages/types/test/compatibility.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "bun:test"; import { + analyzeStackCompatibility, evaluateCompatibility, getAIFrontendCompatibilityIssue, getApiFrontendCompatibilityIssue, + getDisabledReason, } from "../src/compatibility"; import { DEFAULT_STACK_SELECTION } from "../src/stack-translation"; @@ -63,4 +65,50 @@ describe("compatibility issue helpers", () => { "TANSTACK_AI_REQUIRES_REACT_OR_SOLID_FRONTEND", ); }); + + it("allows plain Elixir projects while blocking Phoenix-specific scaffolds", () => { + const stack = { + ...DEFAULT_STACK_SELECTION, + ecosystem: "elixir", + elixirWebFramework: "none", + }; + + expect(getDisabledReason(stack, "elixirWebFramework", "none")).toBeNull(); + expect(getDisabledReason(stack, "elixirJobs", "quantum")).toBeNull(); + expect(getDisabledReason(stack, "elixirHttp", "req")).toBeNull(); + expect(getDisabledReason(stack, "elixirAuth", "phx-gen-auth")).toBe( + "Elixir auth scaffolds require Phoenix", + ); + expect(getDisabledReason(stack, "elixirApi", "rest")).toBe( + "Elixir API scaffolds require Phoenix", + ); + expect(getDisabledReason(stack, "elixirRealtime", "channels")).toBe( + "Elixir realtime scaffolds require Phoenix", + ); + }); + + it("keeps non-Phoenix Elixir selections when Phoenix is removed", () => { + const result = analyzeStackCompatibility({ + ...DEFAULT_STACK_SELECTION, + ecosystem: "elixir", + elixirWebFramework: "none", + elixirOrm: "ecto-sql", + elixirAuth: "phx-gen-auth", + elixirApi: "rest", + elixirRealtime: "channels", + elixirJobs: "quantum", + elixirHttp: "req", + elixirObservability: "phoenix-telemetry", + }); + + expect(result.adjustedStack).toMatchObject({ + elixirOrm: "ecto-sql", + elixirAuth: "none", + elixirApi: "none", + elixirRealtime: "none", + elixirJobs: "quantum", + elixirHttp: "req", + elixirObservability: "none", + }); + }); }); From faccaf9892b6c008a6e80d0aed74d4921ba0665a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 00:29:46 +0300 Subject: [PATCH 27/33] Add Elixir builder presets --- apps/web/src/lib/constant.ts | 85 ++++++++++++++++++++++++++ apps/web/test/elixir-ecosystem.test.ts | 74 ++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 apps/web/test/elixir-ecosystem.test.ts diff --git a/apps/web/src/lib/constant.ts b/apps/web/src/lib/constant.ts index c12f8f477..049fd7158 100644 --- a/apps/web/src/lib/constant.ts +++ b/apps/web/src/lib/constant.ts @@ -5555,6 +5555,91 @@ export const PRESET_TEMPLATES: { install: "true", }, }, + // ── Elixir ────────────────────────────────────────── + { + id: "elixir-phoenix-api", + name: "Phoenix API", + description: "Phoenix + Ecto SQL + REST + Channels", + category: "elixir", + stack: { + ecosystem: "elixir", + projectName: "my-app", + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + elixirAuth: "none", + elixirApi: "rest", + elixirRealtime: "channels", + elixirJobs: "none", + elixirValidation: "ecto-changesets", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "none", + elixirCaching: "none", + elixirObservability: "telemetry", + elixirTesting: "ex_unit", + elixirQuality: "credo", + elixirDeploy: "docker", + aiDocs: ["claude-md"], + git: "true", + install: "true", + }, + }, + { + id: "elixir-liveview-full", + name: "LiveView Fullstack", + description: "Phoenix LiveView + Ecto + Auth + Oban", + category: "elixir", + stack: { + ecosystem: "elixir", + projectName: "my-app", + elixirWebFramework: "phoenix-live-view", + elixirOrm: "ecto-sql", + elixirAuth: "phx-gen-auth", + elixirApi: "absinthe", + elixirRealtime: "presence", + elixirJobs: "oban", + elixirValidation: "ecto-changesets", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "swoosh", + elixirCaching: "cachex", + elixirObservability: "telemetry", + elixirTesting: "ex_unit", + elixirQuality: "sobelow", + elixirDeploy: "docker", + aiDocs: ["claude-md"], + git: "true", + install: "true", + }, + }, + { + id: "elixir-plain-worker", + name: "Plain Elixir Worker", + description: "Mix app + Quantum + Req — no Phoenix", + category: "elixir", + stack: { + ecosystem: "elixir", + projectName: "my-app", + elixirWebFramework: "none", + elixirOrm: "none", + elixirAuth: "none", + elixirApi: "none", + elixirRealtime: "none", + elixirJobs: "quantum", + elixirValidation: "none", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "none", + elixirCaching: "cachex", + elixirObservability: "none", + elixirTesting: "ex_unit", + elixirQuality: "credo", + elixirDeploy: "mix-release", + aiDocs: ["claude-md"], + git: "true", + install: "true", + }, + }, ]; export { DEFAULT_STACK, isStackDefault }; diff --git a/apps/web/test/elixir-ecosystem.test.ts b/apps/web/test/elixir-ecosystem.test.ts new file mode 100644 index 000000000..9d79ec918 --- /dev/null +++ b/apps/web/test/elixir-ecosystem.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; + +import { getDisabledReason } from "../src/components/stack-builder/utils"; +import { + DEFAULT_STACK, + ECOSYSTEMS, + PRESET_CATEGORIES, + PRESET_TEMPLATES, + type StackState, +} from "../src/lib/constant"; +import { generateStackCommand } from "../src/lib/stack-utils"; + +const ELIXIR_PRESET_CHECK_CATEGORIES = [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", +] as const; + +describe("Elixir Ecosystem Tab", () => { + it("exposes Elixir as a preset category", () => { + const elixirEcosystem = ECOSYSTEMS.find((ecosystem) => ecosystem.id === "elixir"); + const elixirPresetCategory = PRESET_CATEGORIES.find((category) => category.id === "elixir"); + + expect(elixirEcosystem).toBeDefined(); + expect(elixirEcosystem?.name).toBe("Elixir"); + expect(elixirPresetCategory).toBeDefined(); + expect(elixirPresetCategory?.icon).toBe("phoenix"); + }); + + it("defines Elixir presets for Phoenix, LiveView, and plain Mix apps", () => { + const elixirPresets = PRESET_TEMPLATES.filter((preset) => preset.category === "elixir"); + + expect(elixirPresets.map((preset) => preset.id)).toEqual([ + "elixir-phoenix-api", + "elixir-liveview-full", + "elixir-plain-worker", + ]); + }); + + it("keeps Elixir presets compatible with their selected stack options", () => { + const elixirPresets = PRESET_TEMPLATES.filter((preset) => preset.category === "elixir"); + + for (const preset of elixirPresets) { + const stack = { ...DEFAULT_STACK, ...preset.stack } as StackState; + + for (const category of ELIXIR_PRESET_CHECK_CATEGORIES) { + const optionId = stack[category]; + expect(getDisabledReason(stack, category, optionId)).toBeNull(); + } + } + }); + + it("serializes Elixir presets into ecosystem-specific commands", () => { + const plainWorker = PRESET_TEMPLATES.find((preset) => preset.id === "elixir-plain-worker"); + const stack = { ...DEFAULT_STACK, ...plainWorker?.stack } as StackState; + const command = generateStackCommand(stack); + + expect(command).toContain("--ecosystem elixir"); + expect(command).toContain("--elixir-web-framework none"); + expect(command).toContain("--elixir-jobs quantum"); + }); +}); From 8a9bf6d2d5996b24a863673b06893778b26ef47d Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 00:51:07 +0300 Subject: [PATCH 28/33] Fix React Native Jest scaffold deps --- apps/cli/test/__snapshots__/template-snapshots.test.ts.snap | 2 ++ apps/cli/test/mobile.test.ts | 1 + .../templates/frontend/native/bare/package.json.hbs | 1 + .../templates/frontend/native/unistyles/package.json.hbs | 1 + .../templates/frontend/native/uniwind/package.json.hbs | 1 + 5 files changed, 6 insertions(+) diff --git a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap index d06ea4820..4ab53c46c 100644 --- a/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap +++ b/apps/cli/test/__snapshots__/template-snapshots.test.ts.snap @@ -11582,6 +11582,7 @@ fontWeight: "bold", }, "devDependencies": { "@babel/core": "^7.29.0", + "babel-preset-expo": "^55.0.0", "@types/react": "^19.2.14", "typescript": "catalog:", "@snapshot-native-react-native/config": "workspace:*" @@ -12284,6 +12285,7 @@ registerRootComponent(App); }, "devDependencies": { "@babel/core": "^7.29.0", + "babel-preset-expo": "^55.0.0", "@types/react": "^19.2.14", "@types/jest": "^29.5.14", "@testing-library/react-native": "^13.3.3", diff --git a/apps/cli/test/mobile.test.ts b/apps/cli/test/mobile.test.ts index 031a285f3..563b9d76d 100644 --- a/apps/cli/test/mobile.test.ts +++ b/apps/cli/test/mobile.test.ts @@ -76,6 +76,7 @@ describe("mobile native scaffolding", () => { "expo-updates": "^56.0.15", }); expect(pkg.dependencies["expo-router"]).toBeUndefined(); + expect(pkg.devDependencies["babel-preset-expo"]).toBe("^55.0.0"); expect(pkg.scripts.test).toBe("jest"); expect(appConfig.expo.plugins).not.toContain("expo-router"); diff --git a/packages/template-generator/templates/frontend/native/bare/package.json.hbs b/packages/template-generator/templates/frontend/native/bare/package.json.hbs index be2308070..df1a7e962 100644 --- a/packages/template-generator/templates/frontend/native/bare/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/bare/package.json.hbs @@ -70,6 +70,7 @@ }, "devDependencies": { "@babel/core": "^7.29.0", + "babel-preset-expo": "^55.0.0", "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, "@types/jest": "^29.5.14", "@testing-library/react-native": "^13.3.3", diff --git a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs index 29a2badb2..8c00eb777 100644 --- a/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/unistyles/package.json.hbs @@ -72,6 +72,7 @@ "devDependencies": { "ajv": "^8.20.0", "@babel/core": "^7.29.0", + "babel-preset-expo": "^55.0.0", "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, "@types/jest": "^29.5.14", "@testing-library/react-native": "^13.3.3", diff --git a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs index 08a2b927d..836abe32a 100644 --- a/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs +++ b/packages/template-generator/templates/frontend/native/uniwind/package.json.hbs @@ -70,6 +70,7 @@ }, "devDependencies": { "@types/node": "^25.8.0", + "babel-preset-expo": "^55.0.0", "@types/react": "^19.2.14"{{#if (or (eq mobileTesting "react-native-testing-library") (eq mobileTesting "maestro-react-native-testing-library"))}}, "@types/jest": "^29.5.14", "@testing-library/react-native": "^13.3.3", From 111abb30e53b4e97ceee9c8ad82895861bf2cce6 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 13:31:41 +0300 Subject: [PATCH 29/33] Add Elixir smoke runtime coverage --- .github/workflows/e2e-test.yaml | 57 ++++++++++++++++++++++ .github/workflows/smoke-test.yaml | 23 +++++++++ .github/workflows/template-matrix.yaml | 23 +++++++++ testing/lib/generate-combos/render.test.ts | 33 ++++++++++++- testing/lib/presets.test.ts | 2 + testing/lib/presets.ts | 44 +++++++++++++++++ testing/lib/verify.test.ts | 9 ++++ testing/lib/verify.ts | 32 ++++++++++++ testing/smoke-test.ts | 16 ++++-- 9 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 testing/lib/verify.test.ts diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index 1cafc0269..4637b9134 100644 --- a/.github/workflows/e2e-test.yaml +++ b/.github/workflows/e2e-test.yaml @@ -32,6 +32,20 @@ jobs: if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.run_e2e) }} runs-on: ubuntu-latest timeout-minutes: 90 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -50,6 +64,11 @@ jobs: - uses: astral-sh/setup-uv@v5 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27" + elixir-version: "1.18" + - uses: actions/cache@v4 with: path: | @@ -86,6 +105,20 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_smoke_strict }} runs-on: ubuntu-latest timeout-minutes: 60 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -104,6 +137,11 @@ jobs: - uses: astral-sh/setup-uv@v5 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27" + elixir-version: "1.18" + - uses: actions/cache@v4 with: path: | @@ -151,6 +189,20 @@ jobs: if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_smoke_strict }} runs-on: ubuntu-latest timeout-minutes: 60 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 @@ -169,6 +221,11 @@ jobs: - uses: astral-sh/setup-uv@v5 + - uses: erlef/setup-beam@v1 + with: + otp-version: "27" + elixir-version: "1.18" + - uses: actions/cache@v4 with: path: | diff --git a/.github/workflows/smoke-test.yaml b/.github/workflows/smoke-test.yaml index 2da75570e..713b4269b 100644 --- a/.github/workflows/smoke-test.yaml +++ b/.github/workflows/smoke-test.yaml @@ -34,6 +34,9 @@ on: - rust - python - go + - java + - react-native + - elixir count: description: "Number of combos to generate" required: false @@ -52,6 +55,20 @@ jobs: name: Smoke Test runs-on: ubuntu-latest timeout-minutes: 60 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout uses: actions/checkout@v4 @@ -95,6 +112,12 @@ jobs: with: go-version: "stable" + - name: Setup Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: "27" + elixir-version: "1.18" + - name: Cache Dependencies uses: actions/cache@v4 id: cache-deps diff --git a/.github/workflows/template-matrix.yaml b/.github/workflows/template-matrix.yaml index e72420885..6e20cf556 100644 --- a/.github/workflows/template-matrix.yaml +++ b/.github/workflows/template-matrix.yaml @@ -17,6 +17,20 @@ jobs: name: Typecheck ${{ matrix.preset }} runs-on: ubuntu-latest timeout-minutes: 45 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 strategy: fail-fast: false matrix: @@ -36,6 +50,8 @@ jobs: - java-spring-maven - java-spring-gradle-jpa - java-plain-cli + - elixir-phoenix-api + - elixir-plain-worker steps: - name: Checkout Code uses: actions/checkout@v4 @@ -66,6 +82,13 @@ jobs: distribution: temurin java-version: "21" + - name: Setup Elixir + if: startsWith(matrix.preset, 'elixir-') + uses: erlef/setup-beam@v1 + with: + otp-version: "27" + elixir-version: "1.18" + - name: Cache Dependencies uses: actions/cache@v4 with: diff --git a/testing/lib/generate-combos/render.test.ts b/testing/lib/generate-combos/render.test.ts index f81174153..e2fe0fbf6 100644 --- a/testing/lib/generate-combos/render.test.ts +++ b/testing/lib/generate-combos/render.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "bun:test"; import { createCliDefaultProjectConfigBase, type ProjectConfig } from "@better-fullstack/types"; +import { describe, expect, it } from "bun:test"; import { buildCommand } from "./render"; @@ -27,4 +27,35 @@ describe("smoke combo command rendering", () => { "--mobile-navigation expo-router --mobile-ui unistyles --mobile-storage none --mobile-testing none --mobile-push none --mobile-ota none --mobile-deep-linking none", ); }); + + it("includes Elixir flags for Elixir commands", () => { + const config: ProjectConfig = { + ...createCliDefaultProjectConfigBase("bun"), + projectName: "elixir-smoke", + relativePath: "elixir-smoke", + projectDir: "/tmp/elixir-smoke", + ecosystem: "elixir", + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + elixirAuth: "none", + elixirApi: "rest", + elixirRealtime: "channels", + elixirJobs: "none", + elixirValidation: "ecto-changesets", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "none", + elixirCaching: "none", + elixirObservability: "telemetry", + elixirTesting: "ex_unit", + elixirQuality: "credo", + elixirDeploy: "none", + git: false, + install: false, + }; + + expect(buildCommand("elixir-smoke", config)).toContain( + "--elixir-web-framework phoenix --elixir-orm ecto-sql --elixir-auth none --elixir-api rest --elixir-realtime channels --elixir-jobs none --elixir-validation ecto-changesets --elixir-http req --elixir-json jason --elixir-email none --elixir-caching none --elixir-observability telemetry --elixir-testing ex_unit --elixir-quality credo --elixir-deploy none", + ); + }); }); diff --git a/testing/lib/presets.test.ts b/testing/lib/presets.test.ts index fea870ba5..60380ad32 100644 --- a/testing/lib/presets.test.ts +++ b/testing/lib/presets.test.ts @@ -12,6 +12,7 @@ const PR_CORE_PRESET_NAMES = [ "preset-python-fastapi-sqlalchemy", "preset-go-gin-gorm", "preset-java-spring-maven", + "preset-elixir-plain-worker", "preset-frontend-only-react-vite", ]; @@ -27,6 +28,7 @@ const PR_BROAD_PRESET_NAMES = [ "preset-go-echo-sqlc", "preset-java-spring-gradle-jpa", "preset-java-plain-cli", + "preset-elixir-phoenix-api", "preset-react-vite-hono", "preset-solid-start-express", "preset-angular-fets", diff --git a/testing/lib/presets.ts b/testing/lib/presets.ts index 832fd5401..7f6621eec 100644 --- a/testing/lib/presets.ts +++ b/testing/lib/presets.ts @@ -531,6 +531,48 @@ const SMOKE_TEST_PRESETS: Record = { javaTestingLibraries: [], }, }, + + // === ELIXIR PRESETS === + "elixir-phoenix-api": { + ecosystem: "elixir", + overrides: { + elixirWebFramework: "phoenix", + elixirOrm: "ecto-sql", + elixirAuth: "none", + elixirApi: "rest", + elixirRealtime: "channels", + elixirJobs: "none", + elixirValidation: "ecto-changesets", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "none", + elixirCaching: "none", + elixirObservability: "telemetry", + elixirTesting: "ex_unit", + elixirQuality: "credo", + elixirDeploy: "none", + }, + }, + "elixir-plain-worker": { + ecosystem: "elixir", + overrides: { + elixirWebFramework: "none", + elixirOrm: "none", + elixirAuth: "none", + elixirApi: "none", + elixirRealtime: "none", + elixirJobs: "quantum", + elixirValidation: "none", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "none", + elixirCaching: "cachex", + elixirObservability: "none", + elixirTesting: "ex_unit", + elixirQuality: "credo", + elixirDeploy: "mix-release", + }, + }, }; const PRESET_GROUPS = { @@ -544,6 +586,7 @@ const PRESET_GROUPS = { "python-fastapi-sqlalchemy", "go-gin-gorm", "java-spring-maven", + "elixir-plain-worker", "frontend-only-react-vite", ], "pr-broad": [ @@ -558,6 +601,7 @@ const PRESET_GROUPS = { "go-echo-sqlc", "java-spring-gradle-jpa", "java-plain-cli", + "elixir-phoenix-api", "react-vite-hono", "solid-start-express", "angular-fets", diff --git a/testing/lib/verify.test.ts b/testing/lib/verify.test.ts new file mode 100644 index 000000000..d5c88d490 --- /dev/null +++ b/testing/lib/verify.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "bun:test"; + +import { getVerifier, verifyElixir } from "./verify"; + +describe("smoke verifiers", () => { + it("routes Elixir smoke combos to the Elixir verifier", () => { + expect(getVerifier("elixir")).toBe(verifyElixir); + }); +}); diff --git a/testing/lib/verify.ts b/testing/lib/verify.ts index f90103901..564b7e33f 100644 --- a/testing/lib/verify.ts +++ b/testing/lib/verify.ts @@ -63,6 +63,7 @@ const ENVIRONMENT_PATTERNS = [ const TEMPLATE_PATTERNS = [ /error\[E\d+\]/, // Rust compiler errors + /\*\* \(CompileError\)/, /SyntaxError/, /TypeError/, /ReferenceError/, @@ -480,6 +481,35 @@ export async function verifyJava( return wrapResult("java", comboName, projectDir, steps); } +export async function verifyElixir( + comboName: string, + projectDir: string, + options?: VerifyOptions, +): Promise { + const steps: StepResult[] = []; + + if (!existsSync(join(projectDir, "mix.exs"))) { + steps.push(templateFailure("structure", "Expected Elixir project mix.exs")); + return wrapResult("elixir", comboName, projectDir, steps); + } + + steps.push(await runStep("setup-hex", "mix", ["local.hex", "--force"], projectDir)); + if (!steps.at(-1)!.success) return wrapResult("elixir", comboName, projectDir, steps); + + steps.push(await runStep("setup-rebar", "mix", ["local.rebar", "--force"], projectDir)); + if (!steps.at(-1)!.success) return wrapResult("elixir", comboName, projectDir, steps); + + steps.push(await runStep("install", "mix", ["deps.get"], projectDir)); + if (!steps.at(-1)!.success) return wrapResult("elixir", comboName, projectDir, steps); + + steps.push(await runStep("compile", "mix", ["compile", "--warnings-as-errors"], projectDir)); + if (!steps.at(-1)!.success) return wrapResult("elixir", comboName, projectDir, steps); + + steps.push(await runStep("test", "mix", ["test"], projectDir)); + + return wrapResult("elixir", comboName, projectDir, steps); +} + export function getVerifier( ecosystem: Ecosystem, ): (comboName: string, projectDir: string, options?: VerifyOptions) => Promise { @@ -496,5 +526,7 @@ export function getVerifier( return verifyGo; case "java": return verifyJava; + case "elixir": + return verifyElixir; } } diff --git a/testing/smoke-test.ts b/testing/smoke-test.ts index ac5a7329d..8995c5b5e 100644 --- a/testing/smoke-test.ts +++ b/testing/smoke-test.ts @@ -24,6 +24,16 @@ import { import { getPresetCombos } from "./lib/presets"; import { getVerifier, type VerifyResult } from "./lib/verify"; +const SUPPORTED_SMOKE_ECOSYSTEMS: readonly Ecosystem[] = [ + "typescript", + "react-native", + "rust", + "python", + "go", + "java", + "elixir", +]; + // ── Types ─────────────────────────────────────────────────────────────── interface SmokeTestArgs { @@ -67,7 +77,7 @@ function parseArgs(argv: string[]): SmokeTestArgs { i++; break; case "--ecosystem": - if (next && ["typescript", "react-native", "rust", "python", "go", "java"].includes(next)) { + if (next && SUPPORTED_SMOKE_ECOSYSTEMS.includes(next as Ecosystem)) { args.ecosystem = next as Ecosystem; } i++; @@ -162,9 +172,7 @@ function generateCombos(args: SmokeTestArgs) { const generatorArgs: GeneratorArgs = { count: args.count, - ecosystems: args.ecosystem - ? [args.ecosystem] - : ["typescript", "react-native", "rust", "python", "go", "java"], + ecosystems: args.ecosystem ? [args.ecosystem] : SUPPORTED_SMOKE_ECOSYSTEMS, installMode: "no-install", rng, forceOptions: args.forceOptions, From e54056f26c7c42bfc859eb5c2f09aa706bc02b0e Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 13:43:08 +0300 Subject: [PATCH 30/33] Skip shared email prompt for Elixir --- apps/cli/src/prompts/config-prompts.ts | 4 +++- apps/cli/src/prompts/email.ts | 9 +++++++++ apps/cli/test/cli-builder-sync.test.ts | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index fe6cb6bbb..210ec2709 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -413,7 +413,9 @@ export async function gatherConfig( return getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend); }, email: ({ results }) => { - if (results.ecosystem === "react-native") return Promise.resolve("none" as Email); + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Email); + } return getEmailChoice(flags.email, results.backend, results.ecosystem); }, effect: ({ results }) => { diff --git a/apps/cli/src/prompts/email.ts b/apps/cli/src/prompts/email.ts index 28d8742f9..7764ab871 100644 --- a/apps/cli/src/prompts/email.ts +++ b/apps/cli/src/prompts/email.ts @@ -66,6 +66,15 @@ type EmailPromptContext = { export function resolveEmailPrompt( context: EmailPromptContext = {}, ): PromptSingleResolution { + if (context.ecosystem === "react-native" || context.ecosystem === "elixir") { + return { + shouldPrompt: false, + mode: "single", + options: [], + autoValue: "none", + }; + } + const options = context.ecosystem && context.ecosystem !== "typescript" ? NON_TYPESCRIPT_EMAIL_PROMPT_OPTIONS diff --git a/apps/cli/test/cli-builder-sync.test.ts b/apps/cli/test/cli-builder-sync.test.ts index d3fb7274b..c9087cab0 100644 --- a/apps/cli/test/cli-builder-sync.test.ts +++ b/apps/cli/test/cli-builder-sync.test.ts @@ -256,6 +256,22 @@ describe("CLI prompts vs schemas parity", () => { expect(goResolution.options.map((option) => option.value)).toContain("go-better-auth"); }); + it("auto-resolves shared email for ecosystems that do not use it", () => { + const reactNativeResolution = PROMPT_RESOLVER_REGISTRY.email.resolve({ + ecosystem: "react-native", + backend: "none", + }); + const elixirResolution = PROMPT_RESOLVER_REGISTRY.email.resolve({ + ecosystem: "elixir", + backend: "none", + }); + + expect(reactNativeResolution.shouldPrompt).toBe(false); + expect(reactNativeResolution.autoValue).toBe("none"); + expect(elixirResolution.shouldPrompt).toBe(false); + expect(elixirResolution.autoValue).toBe("none"); + }); + it("keeps the Rust libraries prompt default aligned with CLI defaults", () => { const resolution = resolveRustLibrariesPrompt(); From e8ed88614392468ea040a722e8f00354b4aeca4a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 13:54:32 +0300 Subject: [PATCH 31/33] Skip shared service prompts for Elixir --- apps/cli/src/prompts/caching.ts | 9 ++++++++ apps/cli/src/prompts/config-prompts.ts | 12 +++++++--- apps/cli/src/prompts/observability.ts | 9 ++++++++ apps/cli/src/prompts/search.ts | 9 ++++++++ apps/cli/test/cli-builder-sync.test.ts | 31 +++++++++++++++++--------- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/prompts/caching.ts b/apps/cli/src/prompts/caching.ts index 6d2e35559..8d9858040 100644 --- a/apps/cli/src/prompts/caching.ts +++ b/apps/cli/src/prompts/caching.ts @@ -26,6 +26,15 @@ type CachingPromptContext = { export function resolveCachingPrompt( context: CachingPromptContext = {}, ): PromptSingleResolution { + if (context.ecosystem === "react-native" || context.ecosystem === "elixir") { + return { + shouldPrompt: false, + mode: "single", + options: [], + autoValue: "none", + }; + } + if (context.ecosystem && context.ecosystem !== "typescript") { return context.caching !== undefined ? { diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index 210ec2709..cbadce640 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -523,7 +523,9 @@ export async function gatherConfig( return getLoggingChoice(flags.logging, results.backend); }, observability: ({ results }) => { - if (results.ecosystem === "react-native") return Promise.resolve("none" as Observability); + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Observability); + } return getObservabilityChoice( flags.observability, results.backend, @@ -543,7 +545,9 @@ export async function gatherConfig( return getCMSChoice(flags.cms, results.backend); }, caching: ({ results }) => { - if (results.ecosystem === "react-native") return Promise.resolve("none" as Caching); + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Caching); + } return getCachingChoice(flags.caching, results.backend, results.ecosystem); }, i18n: ({ results }) => { @@ -551,7 +555,9 @@ export async function gatherConfig( return getI18nChoice(flags.i18n, results.frontend); }, search: ({ results }) => { - if (results.ecosystem === "react-native") return Promise.resolve("none" as Search); + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Search); + } return getSearchChoice(flags.search, results.backend, results.ecosystem); }, fileStorage: ({ results }) => { diff --git a/apps/cli/src/prompts/observability.ts b/apps/cli/src/prompts/observability.ts index f41e96860..17815cad7 100644 --- a/apps/cli/src/prompts/observability.ts +++ b/apps/cli/src/prompts/observability.ts @@ -40,6 +40,15 @@ type ObservabilityPromptContext = { export function resolveObservabilityPrompt( context: ObservabilityPromptContext = {}, ): PromptSingleResolution { + if (context.ecosystem === "react-native" || context.ecosystem === "elixir") { + return { + shouldPrompt: false, + mode: "single", + options: [], + autoValue: "none", + }; + } + const options = context.ecosystem && context.ecosystem !== "typescript" ? NON_TYPESCRIPT_OBSERVABILITY_PROMPT_OPTIONS diff --git a/apps/cli/src/prompts/search.ts b/apps/cli/src/prompts/search.ts index d88c8bc4d..3bb1c9db5 100644 --- a/apps/cli/src/prompts/search.ts +++ b/apps/cli/src/prompts/search.ts @@ -45,6 +45,15 @@ type SearchPromptContext = { export function resolveSearchPrompt( context: SearchPromptContext = {}, ): PromptSingleResolution { + if (context.ecosystem === "react-native" || context.ecosystem === "elixir") { + return { + shouldPrompt: false, + mode: "single", + options: [], + autoValue: "none", + }; + } + const options = context.ecosystem && context.ecosystem !== "typescript" ? NON_TYPESCRIPT_SEARCH_PROMPT_OPTIONS diff --git a/apps/cli/test/cli-builder-sync.test.ts b/apps/cli/test/cli-builder-sync.test.ts index c9087cab0..51a29966e 100644 --- a/apps/cli/test/cli-builder-sync.test.ts +++ b/apps/cli/test/cli-builder-sync.test.ts @@ -13,6 +13,7 @@ import { resolveJavaTestingLibrariesPrompt, } from "../src/prompts/java-ecosystem"; import { resolveRustLibrariesPrompt } from "../src/prompts/rust-ecosystem"; +import { resolveSearchPrompt } from "../src/prompts/search"; import { DEFAULT_CONFIG } from "../src/constants"; import { validateArrayOptions } from "../src/utils/config-processing"; import { STACK_STATE_OPTION_CATEGORY_BY_KEY } from "../../web/src/lib/stack-contract"; @@ -256,20 +257,30 @@ describe("CLI prompts vs schemas parity", () => { expect(goResolution.options.map((option) => option.value)).toContain("go-better-auth"); }); - it("auto-resolves shared email for ecosystems that do not use it", () => { - const reactNativeResolution = PROMPT_RESOLVER_REGISTRY.email.resolve({ - ecosystem: "react-native", - backend: "none", - }); - const elixirResolution = PROMPT_RESOLVER_REGISTRY.email.resolve({ + it("auto-resolves shared service prompts for ecosystems that do not use them", () => { + for (const prompt of ["email", "observability", "caching"] as const) { + const reactNativeResolution = PROMPT_RESOLVER_REGISTRY[prompt].resolve({ + ecosystem: "react-native", + backend: "none", + }); + const elixirResolution = PROMPT_RESOLVER_REGISTRY[prompt].resolve({ + ecosystem: "elixir", + backend: "none", + }); + + expect(reactNativeResolution.shouldPrompt).toBe(false); + expect(reactNativeResolution.autoValue).toBe("none"); + expect(elixirResolution.shouldPrompt).toBe(false); + expect(elixirResolution.autoValue).toBe("none"); + } + + const searchResolution = resolveSearchPrompt({ ecosystem: "elixir", backend: "none", }); - expect(reactNativeResolution.shouldPrompt).toBe(false); - expect(reactNativeResolution.autoValue).toBe("none"); - expect(elixirResolution.shouldPrompt).toBe(false); - expect(elixirResolution.autoValue).toBe("none"); + expect(searchResolution.shouldPrompt).toBe(false); + expect(searchResolution.autoValue).toBe("none"); }); it("keeps the Rust libraries prompt default aligned with CLI defaults", () => { From 28689d5c9c84c1fdc222826d7763b8584bc0ef43 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 14:14:02 +0300 Subject: [PATCH 32/33] Allow plain Elixir smoke presets --- apps/cli/src/utils/config-validation.ts | 75 +++++++++---------- .../virtual-generator-regressions.test.ts | 41 ++++++++++ 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/apps/cli/src/utils/config-validation.ts b/apps/cli/src/utils/config-validation.ts index b4627db45..6b472c58d 100644 --- a/apps/cli/src/utils/config-validation.ts +++ b/apps/cli/src/utils/config-validation.ts @@ -659,17 +659,6 @@ export function validateElixirConstraints(config: Partial) { const hasPhoenix = config.elixirWebFramework !== "none"; const hasEcto = config.elixirOrm !== "none"; - if (!hasPhoenix) { - incompatibilityError({ - message: "The generated Elixir scaffold currently targets Phoenix projects.", - provided: { "elixir-web-framework": config.elixirWebFramework ?? "none" }, - suggestions: [ - "Use --elixir-web-framework phoenix", - "Use --elixir-web-framework phoenix-live-view", - ], - }); - } - const unsupportedSelections = [ { flag: "elixir-orm", @@ -692,13 +681,6 @@ export function validateElixirConstraints(config: Partial) { message: "NimbleOptions is not generated yet.", suggestions: ["Use --elixir-validation ecto-changesets", "Use --elixir-validation none"], }, - { - flag: "elixir-json", - value: config.elixirJson, - unsupported: ["none"], - message: "Phoenix JSON scaffolds require Jason.", - suggestions: ["Use --elixir-json jason"], - }, { flag: "elixir-caching", value: config.elixirCaching, @@ -716,7 +698,7 @@ export function validateElixirConstraints(config: Partial) { { flag: "elixir-testing", value: config.elixirTesting, - unsupported: ["mox", "bypass", "wallaby", "none"], + unsupported: ["mox", "bypass", "wallaby"], message: "Generated Phoenix projects currently include ExUnit tests only.", suggestions: ["Use --elixir-testing ex_unit"], }, @@ -740,35 +722,50 @@ export function validateElixirConstraints(config: Partial) { } if (!hasPhoenix) { - const hasPhoenixFeature = [ - config.elixirOrm, - config.elixirAuth, - config.elixirApi, - config.elixirRealtime, - config.elixirJobs, - config.elixirValidation, - config.elixirHttp, - config.elixirJson, - config.elixirEmail, - config.elixirCaching, - config.elixirObservability, - config.elixirTesting, - config.elixirQuality, - config.elixirDeploy, - ].some((value) => value !== undefined && value !== "none"); - - if (hasPhoenixFeature) { + const phoenixOnlySelections = [ + { + flag: "elixir-auth", + value: config.elixirAuth, + message: "Elixir auth scaffolds require Phoenix.", + }, + { + flag: "elixir-api", + value: config.elixirApi, + message: "Elixir API scaffolds require Phoenix.", + }, + { + flag: "elixir-realtime", + value: config.elixirRealtime, + message: "Elixir realtime scaffolds require Phoenix.", + }, + ]; + + for (const selection of phoenixOnlySelections) { + if (!selection.value || selection.value === "none") continue; + incompatibilityError({ - message: "Elixir feature options require a Phoenix project.", - provided: { "elixir-web-framework": config.elixirWebFramework ?? "none" }, + message: selection.message, + provided: { + "elixir-web-framework": config.elixirWebFramework ?? "none", + [selection.flag]: selection.value, + }, suggestions: [ "Use --elixir-web-framework phoenix", "Use --elixir-web-framework phoenix-live-view", + `Use --${selection.flag} none`, ], }); } } + if (hasPhoenix && config.elixirJson === "none") { + incompatibilityError({ + message: "Phoenix JSON scaffolds require Jason.", + provided: { "elixir-json": "none" }, + suggestions: ["Use --elixir-json jason"], + }); + } + if (config.elixirAuth === "phx-gen-auth" && !hasEcto) { incompatibilityError({ message: "phx.gen.auth requires Ecto in the generated Phoenix scaffold.", diff --git a/apps/cli/test/virtual-generator-regressions.test.ts b/apps/cli/test/virtual-generator-regressions.test.ts index eeb089c28..155aeb564 100644 --- a/apps/cli/test/virtual-generator-regressions.test.ts +++ b/apps/cli/test/virtual-generator-regressions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "bun:test"; import { createVirtual } from "../src/index"; +import { runWithContext } from "../src/utils/context"; +import { validateConfigForProgrammaticUse } from "../src/utils/config-validation"; function readJsonFromTree( tree: NonNullable>["tree"]>, @@ -176,6 +178,45 @@ describe("Virtual Generator Regressions", () => { expect(readTextFromTree(result.tree!, "test/support/conn_case.ex")).toBeUndefined(); }); + it("allows CLI validation for generated plain Elixir worker projects", () => { + expect(() => + runWithContext({ silent: true }, () => + validateConfigForProgrammaticUse({ + projectName: "plain-elixir", + ecosystem: "elixir", + elixirWebFramework: "none", + elixirOrm: "none", + elixirAuth: "none", + elixirApi: "none", + elixirRealtime: "none", + elixirJobs: "quantum", + elixirValidation: "none", + elixirHttp: "req", + elixirJson: "jason", + elixirEmail: "none", + elixirCaching: "cachex", + elixirObservability: "none", + elixirTesting: "ex_unit", + elixirQuality: "credo", + elixirDeploy: "mix-release", + }), + ), + ).not.toThrow(); + }); + + it("continues to reject Phoenix-only Elixir features without Phoenix", () => { + expect(() => + runWithContext({ silent: true }, () => + validateConfigForProgrammaticUse({ + projectName: "plain-elixir-auth", + ecosystem: "elixir", + elixirWebFramework: "none", + elixirAuth: "phx-gen-auth", + }), + ), + ).toThrow("Elixir auth scaffolds require Phoenix."); + }); + it("keeps Phoenix LiveView demos self-contained without Ecto", async () => { const result = await createVirtual({ projectName: "elixir-live-no-ecto", From d842de4894c8333251696f73795703fc20531384 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Sun, 24 May 2026 15:53:58 +0300 Subject: [PATCH 33/33] Fix Phoenix API smoke router flash --- .../elixir-base/lib/__elixirAppName___web/router.ex.hbs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs index 93a143dfc..aa138f66b 100644 --- a/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs @@ -4,7 +4,11 @@ defmodule {{elixirModuleName}}Web.Router do pipeline :browser do plug :accepts, ["html"] plug :fetch_session +{{#if (eq elixirWebFramework "phoenix-live-view")}} plug :fetch_live_flash +{{else}} + plug :fetch_flash +{{/if}} plug :put_root_layout, html: { {{elixirModuleName}}Web.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers