diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index 90a069bd5..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 @@ -43,6 +57,18 @@ jobs: with: node-version: "22" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - 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: | @@ -79,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 @@ -90,6 +130,18 @@ jobs: with: node-version: "22" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - 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: | @@ -137,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 @@ -148,6 +214,18 @@ jobs: with: node-version: "22" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - 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/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/.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/.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/README.md b/README.md index f589edd54..5edf8cce4 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 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 -- **5 ecosystems** — TypeScript, 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. -5 ecosystems
-TypeScript, 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 45f4e37af..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 -- **5 ecosystems** — TypeScript, 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, 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 diff --git a/apps/cli/src/create-command-input.ts b/apps/cli/src/create-command-input.ts index cde519651..50860eaa8 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, @@ -24,6 +39,13 @@ import { FileStorageSchema, FileUploadSchema, FormsSchema, + MobileDeepLinkingSchema, + MobileNavigationSchema, + MobileOTASchema, + MobilePushSchema, + MobileStorageSchema, + MobileTestingSchema, + MobileUISchema, FrontendSchema, GoApiSchema, GoAuthSchema, @@ -98,7 +120,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, java, or elixir)", + ), database: DatabaseSchema.optional(), orm: ORMSchema.optional(), auth: AuthSchema.optional(), @@ -123,6 +147,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)", @@ -219,6 +250,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..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; @@ -170,6 +220,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", @@ -191,6 +248,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: "", @@ -246,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/src/helpers/core/create-project.ts b/apps/cli/src/helpers/core/create-project.ts index aca78c577..a6cb75d84 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"; @@ -66,8 +67,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, @@ -102,6 +106,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..d71dada7b 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, @@ -416,16 +431,22 @@ 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 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", 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, addons: options.addons || [], examples: options.examples || [], auth: options.auth || "none", @@ -438,11 +459,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 +473,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", @@ -462,6 +483,13 @@ export async function createVirtual( observability: options.observability || "none", featureFlags: options.featureFlags || "none", analytics: options.analytics || "none", + 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 || (hasNativeFrontend ? "expo-linking" : "none"), cms: options.cms || "none", caching: options.caching || "none", i18n: options.i18n || "none", @@ -501,6 +529,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 +622,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..52f665de8 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, @@ -25,6 +40,13 @@ import { FileUploadSchema, FormsSchema, FrontendSchema, + MobileDeepLinkingSchema, + MobileNavigationSchema, + MobileOTASchema, + MobilePushSchema, + MobileStorageSchema, + MobileTestingSchema, + MobileUISchema, GoApiSchema, GoCliSchema, GoAuthSchema, @@ -86,7 +108,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, 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 +126,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/elixir ecosystems. - The compatibility engine auto-adjusts invalid combinations — always call bfs_check_compatibility first to see adjustments.`; function getGuidance() { @@ -119,13 +141,17 @@ 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.", 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: @@ -207,6 +233,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, @@ -244,7 +277,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 = { @@ -255,6 +303,10 @@ const ECOSYSTEM_CATEGORIES: Record = { "logging", "observability", "featureFlags", "analytics", "cms", "caching", "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"], go: ["goWebFramework", "goOrm", "goApi", "goCli", "goLogging", "goAuth", "auth", "email", "observability", "caching", "search"], @@ -270,6 +322,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 +377,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" @@ -347,14 +417,24 @@ function buildProjectConfig( overrides?: { projectDir: string }, ): 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: (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, + 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", @@ -362,13 +442,15 @@ 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", 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", @@ -383,7 +465,18 @@ 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"]) ?? + (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"]) ?? + (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", @@ -433,6 +526,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", }; } @@ -450,7 +561,11 @@ 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 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), @@ -462,10 +577,10 @@ function buildCompatibilityInput(input: Record): CompatibilityI ); return { - ecosystem: (input.ecosystem as CompatibilityInput["ecosystem"]) ?? "typescript", + ecosystem, 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 +617,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) ?? (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) ?? (hasMobileProject ? "expo-linking" : "none"), codeQuality, documentation, appPlatforms, @@ -547,6 +671,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 +746,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. `; @@ -676,11 +815,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( @@ -742,6 +881,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"), @@ -788,6 +934,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 { @@ -839,6 +1000,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"), @@ -880,6 +1048,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/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/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 2680c8e78..cbadce640 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, @@ -36,6 +51,13 @@ import type { JavaWebFramework, JobQueue, Logging, + MobileDeepLinking, + MobileNavigation, + MobileOTA, + MobilePush, + MobileStorage, + MobileTesting, + MobileUI, Observability, ORM, PackageManager, @@ -88,6 +110,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"; @@ -95,7 +134,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, @@ -117,6 +156,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 +245,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; @@ -232,6 +287,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; @@ -251,6 +322,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); }, @@ -326,6 +400,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"); } @@ -336,6 +413,9 @@ export async function gatherConfig( return getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend); }, email: ({ results }) => { + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Email); + } return getEmailChoice(flags.email, results.backend, results.ecosystem); }, effect: ({ results }) => { @@ -443,6 +523,9 @@ export async function gatherConfig( return getLoggingChoice(flags.logging, results.backend); }, observability: ({ results }) => { + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Observability); + } return getObservabilityChoice( flags.observability, results.backend, @@ -462,6 +545,9 @@ export async function gatherConfig( return getCMSChoice(flags.cms, results.backend); }, caching: ({ results }) => { + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Caching); + } return getCachingChoice(flags.caching, results.backend, results.ecosystem); }, i18n: ({ results }) => { @@ -469,12 +555,82 @@ export async function gatherConfig( return getI18nChoice(flags.i18n, results.frontend); }, search: ({ results }) => { + if (results.ecosystem === "react-native" || results.ecosystem === "elixir") { + return Promise.resolve("none" as Search); + } return getSearchChoice(flags.search, results.backend, results.ecosystem); }, fileStorage: ({ results }) => { if (results.ecosystem !== "typescript") return Promise.resolve("none" as FileStorage); return getFileStorageChoice(flags.fileStorage, results.backend); }, + mobileNavigation: ({ results }) => { + 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" && results.ecosystem !== "react-native") { + 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" && 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" && 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" && 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" && 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" && results.ecosystem !== "react-native") { + 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); @@ -618,6 +774,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 +844,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); @@ -685,6 +903,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 @@ -722,6 +947,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/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/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/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/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/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/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/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/prompt-resolver-registry.ts b/apps/cli/src/prompts/prompt-resolver-registry.ts index 1e6a97b6c..20c9225a1 100644 --- a/apps/cli/src/prompts/prompt-resolver-registry.ts +++ b/apps/cli/src/prompts/prompt-resolver-registry.ts @@ -10,10 +10,32 @@ 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, 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, @@ -68,6 +90,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"; @@ -90,6 +129,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 +314,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, @@ -442,4 +527,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/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/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index d736ab7b4..00e222c44 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, @@ -81,6 +88,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, }; @@ -121,6 +143,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, @@ -157,6 +186,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..6b472c58d 100644 --- a/apps/cli/src/utils/config-validation.ts +++ b/apps/cli/src/utils/config-validation.ts @@ -559,6 +559,21 @@ 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", + ], + }); + } + ensureSingleWebAndNative(frontend); if (providedFlags.has("api") && providedFlags.has("frontend") && config.api) { @@ -638,6 +653,167 @@ export function validateJavaConstraints( } } +export function validateElixirConstraints(config: Partial) { + if (config.ecosystem !== "elixir") return; + + const hasPhoenix = config.elixirWebFramework !== "none"; + const hasEcto = config.elixirOrm !== "none"; + + 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-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"], + 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 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: 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.", + 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 +987,7 @@ export function validateFullConfig( validateCachingConstraints(config); validateSearchConstraints(config); validateJavaConstraints(config, providedFlags); + validateElixirConstraints(config); validateServerDeployRequiresBackend(config.serverDeploy, config.backend); @@ -905,6 +1082,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/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..16825a99e 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(" ")}`); @@ -119,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"]; @@ -191,10 +221,37 @@ 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[]; switch (config.ecosystem) { + case "react-native": + flags = getReactNativeFlags(config); + break; case "rust": flags = getRustFlags(config); break; @@ -207,6 +264,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 72e0e6741..c392b94a6 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", @@ -11517,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:*" @@ -12021,228 +12087,2130 @@ export const db = drizzle({ client, schema }); } `; -exports[`Template Snapshots Key File Content Snapshots key files: java-spring-boot-jpa-security 1`] = ` +exports[`Template Snapshots Key File Content Snapshots key files: native-mobile-integrations 1`] = ` { - "fileCount": 22, + "fileCount": 59, "files": [ + { + "content": "[exists]", + "path": "apps/native/__tests__/mobile-ui-provider.test.tsx", + }, + { + "content": "[exists]", + "path": "apps/native/.env", + }, { "content": -"PORT=8080 -SPRING_PROFILES_ACTIVE=dev -APP_BASIC_USERNAME=admin -APP_BASIC_PASSWORD=change-me -CORS_ORIGIN=http://localhost:3001" +"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": ".env.example", + "path": "apps/native/.env.example", }, { "content": "[exists]", - "path": "build.gradle.kts", + "path": "apps/native/.maestro/home.yaml", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "apps/native/app.json", }, { - "content": "[exists]", - "path": "gradle/wrapper/gradle-wrapper.jar", + "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": "gradle/wrapper/gradle-wrapper.properties", + "path": "apps/native/components/container.tsx", }, { "content": "[exists]", - "path": "gradlew", + "path": "apps/native/components/header-button.tsx", }, { "content": "[exists]", - "path": "gradlew.bat", + "path": "apps/native/components/mobile-ui-provider.tsx", }, { "content": "[exists]", - "path": "README.md", + "path": "apps/native/components/tabbar-icon.tsx", }, { - "content": "[exists]", - "path": "settings.gradle.kts", + "content": +"import { registerRootComponent } from "expo"; + +import App from "./App"; + +registerRootComponent(App); +" +, + "path": "apps/native/index.js", }, { "content": "[exists]", - "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/Application.java", + "path": "apps/native/jest.config.js", }, { "content": "[exists]", - "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/config/SecurityConfig.java", + "path": "apps/native/lib/android-navigation-bar.tsx", }, { "content": "[exists]", - "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/controller/HealthController.java", + "path": "apps/native/lib/constants.ts", }, { "content": "[exists]", - "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/controller/UserController.java", + "path": "apps/native/lib/deep-linking.ts", }, { "content": "[exists]", - "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/domain/AppUser.java", + "path": "apps/native/lib/mobile-storage.ts", }, { "content": "[exists]", - "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/repository/AppUserRepository.java", + "path": "apps/native/lib/notifications.ts", }, { "content": "[exists]", - "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/service/AppUserService.java", + "path": "apps/native/lib/updates.ts", }, { "content": "[exists]", - "path": "src/main/resources/application.yml", + "path": "apps/native/lib/use-color-scheme.ts", }, { "content": "[exists]", - "path": "src/main/resources/db/migration/V1__init.sql", + "path": "apps/native/metro.config.js", }, { "content": "[exists]", - "path": "src/test/java/com/example/snapshotjavaspringbootjpasecurity/ApplicationTests.java", + "path": "apps/native/navigation/native-navigation.tsx", }, { - "content": "[exists]", - "path": "src/test/java/com/example/snapshotjavaspringbootjpasecurity/service/AppUserServiceTest.java", + "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", + "babel-preset-expo": "^55.0.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"] } -`; -exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-leptos-seaorm 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "Cargo.toml", - "README.md", - "crates/client/Cargo.toml", - "crates/client/Trunk.toml", - "crates/client/index.html", - "crates/client/src/lib.rs", - "crates/client/style/main.css", - "crates/server/Cargo.toml", - "crates/server/src/auth.rs", - "crates/server/src/cache.rs", - "crates/server/src/email.rs", - "crates/server/src/error.rs", - "crates/server/src/main.rs", - "crates/server/src/meilisearch.rs", - "crates/server/src/observability.rs", - "crates/server/src/upstash_cache.rs", - "rust-toolchain.toml", -] -`; +" +, + "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"; -exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: actix-dioxus-sqlx 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "Cargo.toml", - "README.md", - "crates/dioxus-client/Cargo.toml", - "crates/dioxus-client/Dioxus.toml", - "crates/dioxus-client/assets/main.css", - "crates/dioxus-client/src/main.rs", - "crates/server/Cargo.toml", - "crates/server/src/auth.rs", - "crates/server/src/cache.rs", - "crates/server/src/email.rs", - "crates/server/src/error.rs", - "crates/server/src/main.rs", - "crates/server/src/meilisearch.rs", - "crates/server/src/observability.rs", - "crates/server/src/upstash_cache.rs", - "rust-toolchain.toml", -] -`; +export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + console.log(error) + }, + }), +}); -exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: cli-clap 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "Cargo.toml", - "README.md", - "crates/cli/Cargo.toml", - "crates/cli/src/main.rs", - "crates/server/Cargo.toml", - "crates/server/src/auth.rs", - "crates/server/src/cache.rs", - "crates/server/src/email.rs", - "crates/server/src/error.rs", - "crates/server/src/main.rs", - "crates/server/src/meilisearch.rs", - "crates/server/src/observability.rs", - "crates/server/src/upstash_cache.rs", +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, + "files": [ + { + "content": +"PORT=8080 +SPRING_PROFILES_ACTIVE=dev +APP_BASIC_USERNAME=admin +APP_BASIC_PASSWORD=change-me +CORS_ORIGIN=http://localhost:3001" +, + "path": ".env.example", + }, + { + "content": "[exists]", + "path": "build.gradle.kts", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": "[exists]", + "path": "gradle/wrapper/gradle-wrapper.jar", + }, + { + "content": "[exists]", + "path": "gradle/wrapper/gradle-wrapper.properties", + }, + { + "content": "[exists]", + "path": "gradlew", + }, + { + "content": "[exists]", + "path": "gradlew.bat", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": "[exists]", + "path": "settings.gradle.kts", + }, + { + "content": "[exists]", + "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/Application.java", + }, + { + "content": "[exists]", + "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/config/SecurityConfig.java", + }, + { + "content": "[exists]", + "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/controller/HealthController.java", + }, + { + "content": "[exists]", + "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/controller/UserController.java", + }, + { + "content": "[exists]", + "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/domain/AppUser.java", + }, + { + "content": "[exists]", + "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/repository/AppUserRepository.java", + }, + { + "content": "[exists]", + "path": "src/main/java/com/example/snapshotjavaspringbootjpasecurity/service/AppUserService.java", + }, + { + "content": "[exists]", + "path": "src/main/resources/application.yml", + }, + { + "content": "[exists]", + "path": "src/main/resources/db/migration/V1__init.sql", + }, + { + "content": "[exists]", + "path": "src/test/java/com/example/snapshotjavaspringbootjpasecurity/ApplicationTests.java", + }, + { + "content": "[exists]", + "path": "src/test/java/com/example/snapshotjavaspringbootjpasecurity/service/AppUserServiceTest.java", + }, + ], +} +`; + +exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-leptos-seaorm 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "Cargo.toml", + "README.md", + "crates/client/Cargo.toml", + "crates/client/Trunk.toml", + "crates/client/index.html", + "crates/client/src/lib.rs", + "crates/client/style/main.css", + "crates/server/Cargo.toml", + "crates/server/src/auth.rs", + "crates/server/src/cache.rs", + "crates/server/src/email.rs", + "crates/server/src/error.rs", + "crates/server/src/main.rs", + "crates/server/src/meilisearch.rs", + "crates/server/src/observability.rs", + "crates/server/src/upstash_cache.rs", + "rust-toolchain.toml", +] +`; + +exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: actix-dioxus-sqlx 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "Cargo.toml", + "README.md", + "crates/dioxus-client/Cargo.toml", + "crates/dioxus-client/Dioxus.toml", + "crates/dioxus-client/assets/main.css", + "crates/dioxus-client/src/main.rs", + "crates/server/Cargo.toml", + "crates/server/src/auth.rs", + "crates/server/src/cache.rs", + "crates/server/src/email.rs", + "crates/server/src/error.rs", + "crates/server/src/main.rs", + "crates/server/src/meilisearch.rs", + "crates/server/src/observability.rs", + "crates/server/src/upstash_cache.rs", + "rust-toolchain.toml", +] +`; + +exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: cli-clap 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "Cargo.toml", + "README.md", + "crates/cli/Cargo.toml", + "crates/cli/src/main.rs", + "crates/server/Cargo.toml", + "crates/server/src/auth.rs", + "crates/server/src/cache.rs", + "crates/server/src/email.rs", + "crates/server/src/error.rs", + "crates/server/src/main.rs", + "crates/server/src/meilisearch.rs", + "crates/server/src/observability.rs", + "crates/server/src/upstash_cache.rs", + "rust-toolchain.toml", +] +`; + +exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-envlogger 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "Cargo.toml", + "README.md", + "crates/server/Cargo.toml", + "crates/server/src/auth.rs", + "crates/server/src/cache.rs", + "crates/server/src/email.rs", + "crates/server/src/error.rs", + "crates/server/src/main.rs", + "crates/server/src/meilisearch.rs", + "crates/server/src/observability.rs", + "crates/server/src/upstash_cache.rs", + "rust-toolchain.toml", +] +`; + +exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-eyre 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "Cargo.toml", + "README.md", + "crates/server/Cargo.toml", + "crates/server/src/auth.rs", + "crates/server/src/cache.rs", + "crates/server/src/email.rs", + "crates/server/src/error.rs", + "crates/server/src/main.rs", + "crates/server/src/meilisearch.rs", + "crates/server/src/observability.rs", + "crates/server/src/upstash_cache.rs", + "rust-toolchain.toml", +] +`; + +exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: rocket-seaorm 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "Cargo.toml", + "README.md", + "crates/server/Cargo.toml", + "crates/server/src/auth.rs", + "crates/server/src/cache.rs", + "crates/server/src/email.rs", + "crates/server/src/error.rs", + "crates/server/src/main.rs", + "crates/server/src/meilisearch.rs", + "crates/server/src/observability.rs", + "crates/server/src/upstash_cache.rs", "rust-toolchain.toml", ] `; -exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-envlogger 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "Cargo.toml", - "README.md", - "crates/server/Cargo.toml", - "crates/server/src/auth.rs", - "crates/server/src/cache.rs", - "crates/server/src/email.rs", - "crates/server/src/error.rs", - "crates/server/src/main.rs", - "crates/server/src/meilisearch.rs", - "crates/server/src/observability.rs", - "crates/server/src/upstash_cache.rs", - "rust-toolchain.toml", -] -`; +exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-leptos-seaorm 1`] = ` +{ + "fileCount": 20, + "files": [ + { + "content": +"# Application +RUST_LOG=debug +APP_ENV=development + +# Server +HOST=127.0.0.1 +PORT=3000 + +# gRPC (if using tonic) +# GRPC_PORT=50051 + +# Database (if using) +# DATABASE_URL=postgres://user:password@localhost:5432/dbname +# DATABASE_URL=sqlite:./data.db + +# JWT Secret (if using jsonwebtoken) +# JWT_SECRET=your-secret-key-here +CORS_ORIGIN=http://localhost:3001" +, + "path": ".env.example", + }, + { + "content": +"[workspace] +resolver = "2" +members = [ + "crates/server", + "crates/client", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +license = "MIT" +repository = "" + +[workspace.dependencies] +# Async runtime +tokio = { version = "1.51", features = ["full"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling (anyhow + thiserror) +thiserror = "2.0" +anyhow = "1.0" + +# Tracing (used by server logging, CLI, TUI, and WASM frontends) +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Environment variables +dotenvy = "0.15" + + +# Web framework (Axum) +axum = "0.8" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } + +# Database (SeaORM) +sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite", "sqlx-mysql"] } +sea-orm-migration = "1.1" + + +# Frontend (Leptos) +leptos = "0.7" +leptos_router = "0.7" +leptos_meta = "0.7" +console_error_panic_hook = "0.1" +console_log = "1" +log = "0.4" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "console"] } + + + + +[profile.dev] +opt-level = 0 + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +" +, + "path": "Cargo.toml", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": +"[package] +name = "snapshot-rust-axum-leptos-seaorm-client" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +# Leptos framework +leptos.workspace = true +leptos_router.workspace = true +leptos_meta.workspace = true + +# Logging +log.workspace = true +console_log.workspace = true + +# WASM utilities +wasm-bindgen.workspace = true +wasm-bindgen-futures.workspace = true +web-sys.workspace = true +console_error_panic_hook.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +[features] +default = [] +hydrate = ["leptos/hydrate"] +ssr = ["leptos/ssr", "leptos_router/ssr", "leptos_meta/ssr"] +" +, + "path": "crates/client/Cargo.toml", + }, + { + "content": "[exists]", + "path": "crates/client/index.html", + }, + { + "content": +"use leptos::prelude::*; +use leptos_meta::*; +use leptos_router::components::*; +use leptos_router::path; + +/// Main application component +#[component] +pub fn App() -> impl IntoView { + // Provides context for managing document head metadata + provide_meta_context(); + + view! { + + + <Meta name="description" content="A Rust WASM application built with Leptos"/> + + <Router> + <main class="container"> + <Routes fallback=|| "Page not found.".into_view()> + <Route path=path!("/") view=HomePage/> + <Route path=path!("/about") view=AboutPage/> + </Routes> + </main> + </Router> + } +} + +/// Home page component +#[component] +fn HomePage() -> impl IntoView { + let (count, set_count) = signal(0); + + view! { + <div class="home"> + <h1>"Welcome to snapshot-rust-axum-leptos-seaorm"</h1> + <p>"A full-stack Rust application powered by Leptos"</p> + + <div class="counter"> + <button + class="btn" + on:click=move |_| set_count.update(|n| *n -= 1) + > + "-" + </button> + <span class="count">{count}</span> + <button + class="btn" + on:click=move |_| set_count.update(|n| *n += 1) + > + "+" + </button> + </div> + + <nav> + <a href="/about">"About"</a> + </nav> + </div> + } +} + +/// About page component +#[component] +fn AboutPage() -> impl IntoView { + view! { + <div class="about"> + <h1>"About"</h1> + <p>"This application was generated with Better-Fullstack using the Leptos framework."</p> + + <h2>"Technology Stack"</h2> + <ul> + <li>"Leptos - Fine-grained reactive framework"</li> + <li>"Rust + WebAssembly - High-performance frontend"</li> + <li>"Axum - Ergonomic web framework by Tokio team"</li> + </ul> + + <nav> + <a href="/">"Back to Home"</a> + </nav> + </div> + } +} + +/// Initialize the Leptos client-side app +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn main() { + // Set up better panic messages in console + console_error_panic_hook::set_once(); + + // Initialize logging + _ = console_log::init_with_level(log::Level::Debug); + + log::info!("Starting Leptos client application"); + + leptos::mount::mount_to_body(App); +} +" +, + "path": "crates/client/src/lib.rs", + }, + { + "content": "[exists]", + "path": "crates/client/style/main.css", + }, + { + "content": "[exists]", + "path": "crates/client/Trunk.toml", + }, + { + "content": +"[package] +name = "snapshot-rust-axum-leptos-seaorm-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +# Async runtime +tokio.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Environment +dotenvy.workspace = true + + +# Web framework +axum.workspace = true +tower.workspace = true +tower-http.workspace = true + +# Database +sea-orm.workspace = true + + + + +[[bin]] +name = "server" +path = "src/main.rs" + +" +, + "path": "crates/server/Cargo.toml", + }, + { + "content": "[exists]", + "path": "crates/server/src/auth.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/cache.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/email.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/error.rs", + }, + { + "content": +"use axum::{routing::get, Json, Router}; +use serde::Serialize; +use tower_http::cors::CorsLayer; +mod error; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use sea_orm::{Database, DatabaseConnection}; +use std::sync::Arc; + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + message: &'static str, + database: &'static str, +} + +#[derive(Clone)] +pub struct AppState { + pub db: Arc<DatabaseConnection>, +} + +async fn health( + axum::extract::State(state): axum::extract::State<AppState>, +) -> Json<HealthResponse> { + let db_status = if state.db.ping().await.is_ok() { + "connected" + } else { + "disconnected" + }; + Json(HealthResponse { + status: "ok", + message: "Server is running", + database: db_status, + }) +} + + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Load environment variables + dotenvy::dotenv().ok(); + + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + + + // Initialize database connection + let database_url = std::env::var("DATABASE_URL") + .expect("DATABASE_URL must be set"); + + tracing::info!("Connecting to database..."); + let db = Database::connect(&database_url).await?; + tracing::info!("Database connected successfully"); + + let state = AppState { db: Arc::new(db) }; + + // Build router with state + let app = Router::new() + .route("/health", get(health)) + .layer(CorsLayer::permissive()) + .with_state(state); + + // Get host and port from environment + let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); + let addr = format!("{}:{}", host, port); + + tracing::info!("Starting HTTP server at http://{}", addr); + + // Start server + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} +" +, + "path": "crates/server/src/main.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/meilisearch.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/observability.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/upstash_cache.rs", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": "[exists]", + "path": "rust-toolchain.toml", + }, + ], +} +`; + +exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: actix-dioxus-sqlx 1`] = ` +{ + "fileCount": 19, + "files": [ + { + "content": +"# Application +RUST_LOG=debug +APP_ENV=development + +# Server +HOST=127.0.0.1 +PORT=3000 + +# gRPC (if using tonic) +# GRPC_PORT=50051 + +# Database (if using) +# DATABASE_URL=postgres://user:password@localhost:5432/dbname +# DATABASE_URL=sqlite:./data.db + +# JWT Secret (if using jsonwebtoken) +# JWT_SECRET=your-secret-key-here +CORS_ORIGIN=http://localhost:3001" +, + "path": ".env.example", + }, + { + "content": +"[workspace] +resolver = "2" +members = [ + "crates/server", + "crates/dioxus-client", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Your Name <your.email@example.com>"] +license = "MIT" +repository = "" + +[workspace.dependencies] +# Async runtime +tokio = { version = "1.51", features = ["full"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling (anyhow + thiserror) +thiserror = "2.0" +anyhow = "1.0" + +# Tracing (used by server logging, CLI, TUI, and WASM frontends) +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Environment variables +dotenvy = "0.15" + + +# Web framework (Actix-web) +actix-web = "4" +actix-rt = "2" +actix-cors = "0.7" + +# Database (SQLx) +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "mysql", "migrate"] } + + +# Frontend (Dioxus) +dioxus = { version = "0.6", features = ["router"] } +dioxus-router = "0.6" +dioxus-logger = "0.6" +console_error_panic_hook = "0.1" +wasm-bindgen = "0.2" + + +# Validation +validator = { version = "0.19", features = ["derive"] } + + +[profile.dev] +opt-level = 0 + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +" +, + "path": "Cargo.toml", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": "[exists]", + "path": "crates/dioxus-client/assets/main.css", + }, + { + "content": +"[package] +name = "snapshot-rust-actix-dioxus-sqlx-client" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[[bin]] +name = "snapshot-rust-actix-dioxus-sqlx-client" +path = "src/main.rs" + +[dependencies] +# Dioxus framework +dioxus.workspace = true +dioxus-router.workspace = true + +# Logging +tracing.workspace = true +dioxus-logger.workspace = true + +# WASM utilities +wasm-bindgen.workspace = true +console_error_panic_hook.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +[features] +default = ["web"] +web = ["dioxus/web"] +desktop = ["dioxus/desktop"] +" +, + "path": "crates/dioxus-client/Cargo.toml", + }, + { + "content": "[exists]", + "path": "crates/dioxus-client/Dioxus.toml", + }, + { + "content": +"#![allow(non_snake_case)] + +use dioxus::prelude::*; +use dioxus_router::prelude::*; +use tracing::info; + +/// Application routes +#[derive(Clone, Routable, Debug, PartialEq)] +enum Route { + #[route("/")] + Home {}, + #[route("/about")] + About {}, +} + +fn main() { + // Set up better panic messages in console + console_error_panic_hook::set_once(); + + // Initialize logging + dioxus_logger::init(tracing::Level::INFO).expect("Failed to init logger"); + + info!("Starting Dioxus client application"); + + launch(App); +} + +/// Main application component +fn App() -> Element { + rsx! { + Router::<Route> {} + } +} + +/// Home page component +#[component] +fn Home() -> Element { + let mut count = use_signal(|| 0); + + rsx! { + div { class: "container", + div { class: "home", + h1 { "Welcome to snapshot-rust-actix-dioxus-sqlx" } + p { "A full-stack Rust application powered by Dioxus" } + + div { class: "counter", + button { + class: "btn", + onclick: move |_| count -= 1, + "-" + } + span { class: "count", "{count}" } + button { + class: "btn", + onclick: move |_| count += 1, + "+" + } + } + + nav { + Link { to: Route::About {}, "About" } + } + } + } + } +} + +/// About page component +#[component] +fn About() -> Element { + rsx! { + div { class: "container", + div { class: "about", + h1 { "About" } + p { "This application was generated with Better-Fullstack using the Dioxus framework." } + + h2 { "Technology Stack" } + ul { + li { "Dioxus - React-like reactive framework" } + li { "Rust + WebAssembly - High-performance frontend" } + li { "Actix-web - Powerful, pragmatic web framework" } + } + + nav { + Link { to: Route::Home {}, "Back to Home" } + } + } + } + } +} +" +, + "path": "crates/dioxus-client/src/main.rs", + }, + { + "content": +"[package] +name = "snapshot-rust-actix-dioxus-sqlx-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +# Async runtime +tokio.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Environment +dotenvy.workspace = true + + +# Web framework +actix-web.workspace = true +actix-rt.workspace = true +actix-cors.workspace = true + +# Database +sqlx.workspace = true + + +validator.workspace = true + + +[[bin]] +name = "server" +path = "src/main.rs" + +" +, + "path": "crates/server/Cargo.toml", + }, + { + "content": "[exists]", + "path": "crates/server/src/auth.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/cache.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/email.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/error.rs", + }, + { + "content": +"use actix_cors::Cors; +use actix_web::{get, web, App, HttpResponse, HttpServer, Responder}; +use serde::Serialize; +mod error; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + message: &'static str, + database: &'static str, +} + +pub struct AppState { + pub db: PgPool, +} + +#[get("/health")] +async fn health(data: web::Data<AppState>) -> impl Responder { + let db_status = if data.db.acquire().await.is_ok() { + "connected" + } else { + "disconnected" + }; + HttpResponse::Ok().json(HealthResponse { + status: "ok", + message: "Server is running", + database: db_status, + }) +} + + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + // Load environment variables + dotenvy::dotenv().ok(); + + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + + + // Initialize database connection pool + let database_url = std::env::var("DATABASE_URL") + .expect("DATABASE_URL must be set"); + + tracing::info!("Connecting to database..."); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await?; + tracing::info!("Database connected successfully"); + + let state = web::Data::new(AppState { db: pool.clone() }); + + // Get host and port from environment + let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse()?; + + tracing::info!("Starting HTTP server at http://{}:{}", host, port); + + // Start server + HttpServer::new(move || { + let cors = Cors::permissive(); + App::new() + .wrap(cors) + .app_data(state.clone()) + .service(health) + }) + .bind((host, port))? + .run() + .await?; + + Ok(()) +} +" +, + "path": "crates/server/src/main.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/meilisearch.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/observability.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/upstash_cache.rs", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": "[exists]", + "path": "rust-toolchain.toml", + }, + ], +} +`; + +exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: cli-clap 1`] = ` +{ + "fileCount": 17, + "files": [ + { + "content": +"# Application +RUST_LOG=debug +APP_ENV=development + +# Server +HOST=127.0.0.1 +PORT=3000 + +# gRPC (if using tonic) +# GRPC_PORT=50051 + +# Database (if using) +# DATABASE_URL=postgres://user:password@localhost:5432/dbname +# DATABASE_URL=sqlite:./data.db + +# JWT Secret (if using jsonwebtoken) +# JWT_SECRET=your-secret-key-here +CORS_ORIGIN=http://localhost:3001" +, + "path": ".env.example", + }, + { + "content": +"[workspace] +resolver = "2" +members = [ + "crates/server", + "crates/cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Your Name <your.email@example.com>"] +license = "MIT" +repository = "" + +[workspace.dependencies] +# Async runtime +tokio = { version = "1.51", features = ["full"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling (anyhow + thiserror) +thiserror = "2.0" +anyhow = "1.0" + +# Tracing (used by server logging, CLI, TUI, and WASM frontends) +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Environment variables +dotenvy = "0.15" + + + + + + +# CLI (Clap) +clap = { version = "4", features = ["derive"] } + + + +[profile.dev] +opt-level = 0 + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +" +, + "path": "Cargo.toml", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": +"[package] +name = "snapshot-rust-cli-clap-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Command-line interface for snapshot-rust-cli-clap" + +[dependencies] +# CLI argument parsing +clap.workspace = true + +# Async runtime +tokio.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Error handling +anyhow.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Environment +dotenvy.workspace = true + +[[bin]] +name = "cli" +path = "src/main.rs" +" +, + "path": "crates/cli/Cargo.toml", + }, + { + "content": +"use anyhow::Result; +use clap::{Parser, Subcommand}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +/// snapshot-rust-cli-clap CLI - Command-line interface +#[derive(Parser, Debug)] +#[command(name = "snapshot-rust-cli-clap")] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Turn on verbose output + #[arg(short, long, global = true)] + verbose: bool, + + /// Config file path + #[arg(short, long, global = true)] + config: Option<String>, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Start the application + Start { + /// Port to listen on + #[arg(short, long, default_value = "3000")] + port: u16, + + /// Host to bind to + #[arg(short = 'H', long, default_value = "127.0.0.1")] + host: String, + }, + + /// Check configuration and health + Check { + /// Check database connection + #[arg(long)] + database: bool, + + /// Check all services + #[arg(long)] + all: bool, + }, + + /// Run database migrations + Migrate { + /// Run pending migrations + #[arg(long)] + up: bool, + + /// Rollback last migration + #[arg(long)] + down: bool, + + /// Migration status + #[arg(long)] + status: bool, + }, + + /// Generate project components + Generate { + /// Component type to generate + #[arg(value_enum)] + component: GenerateComponent, + + /// Name for the generated component + name: String, + }, +} + +#[derive(clap::ValueEnum, Clone, Debug)] +enum GenerateComponent { + /// Generate a new model + Model, + /// Generate a new handler/controller + Handler, + /// Generate a new service + Service, + /// Generate a new migration + Migration, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Load environment variables + dotenvy::dotenv().ok(); + + let cli = Cli::parse(); + + // Initialize tracing with verbosity level + let filter = if cli.verbose { "debug" } else { "info" }; + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + if let Some(config_path) = &cli.config { + tracing::info!("Using config file: {}", config_path); + } + + match cli.command { + Commands::Start { port, host } => { + tracing::info!("Starting server on {}:{}", host, port); + // TODO: Implement server start logic + // This is where you would start your Axum/Actix server + println!("Server would start on {}:{}", host, port); + } + + Commands::Check { database, all } => { + tracing::info!("Running health checks..."); + + if database || all { + tracing::info!("Checking database connection..."); + // TODO: Implement database check + println!("Database check: Not implemented"); + } + + if all { + tracing::info!("Checking all services..."); + // TODO: Implement other service checks + println!("All checks complete"); + } + + if !database && !all { + println!("No checks specified. Use --database or --all"); + } + } + + Commands::Migrate { up, down, status } => { + if status { + tracing::info!("Checking migration status..."); + // TODO: Implement migration status check + println!("Migration status: Not implemented"); + } else if up { + tracing::info!("Running pending migrations..."); + // TODO: Implement migration up + println!("Migrations run: Not implemented"); + } else if down { + tracing::info!("Rolling back last migration..."); + // TODO: Implement migration down + println!("Migration rollback: Not implemented"); + } else { + println!("No migration action specified. Use --up, --down, or --status"); + } + } + + Commands::Generate { component, name } => { + tracing::info!("Generating {:?}: {}", component, name); + match component { + GenerateComponent::Model => { + println!("Would generate model: {}", name); + // TODO: Implement model generation + } + GenerateComponent::Handler => { + println!("Would generate handler: {}", name); + // TODO: Implement handler generation + } + GenerateComponent::Service => { + println!("Would generate service: {}", name); + // TODO: Implement service generation + } + GenerateComponent::Migration => { + println!("Would generate migration: {}", name); + // TODO: Implement migration generation + } + } + } + } + + Ok(()) +} +" +, + "path": "crates/cli/src/main.rs", + }, + { + "content": +"[package] +name = "snapshot-rust-cli-clap-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +# Async runtime +tokio.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Environment +dotenvy.workspace = true -exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: axum-eyre 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "Cargo.toml", - "README.md", - "crates/server/Cargo.toml", - "crates/server/src/auth.rs", - "crates/server/src/cache.rs", - "crates/server/src/email.rs", - "crates/server/src/error.rs", - "crates/server/src/main.rs", - "crates/server/src/meilisearch.rs", - "crates/server/src/observability.rs", - "crates/server/src/upstash_cache.rs", - "rust-toolchain.toml", -] -`; -exports[`Template Snapshots - Rust Ecosystem Rust File Structure Snapshots file structure: rocket-seaorm 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "Cargo.toml", - "README.md", - "crates/server/Cargo.toml", - "crates/server/src/auth.rs", - "crates/server/src/cache.rs", - "crates/server/src/email.rs", - "crates/server/src/error.rs", - "crates/server/src/main.rs", - "crates/server/src/meilisearch.rs", - "crates/server/src/observability.rs", - "crates/server/src/upstash_cache.rs", - "rust-toolchain.toml", -] + + + + +# CLI +clap.workspace = true + + +[[bin]] +name = "server" +path = "src/main.rs" + +" +, + "path": "crates/server/Cargo.toml", + }, + { + "content": "[exists]", + "path": "crates/server/src/auth.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/cache.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/email.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/error.rs", + }, + { + "content": +"mod error; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Load environment variables + dotenvy::dotenv().ok(); + + // Initialize tracing + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + + + tracing::info!("Hello from snapshot-rust-cli-clap!"); + tracing::info!("Add a web framework (axum, actix-web, or rocket) to start building your API."); + + Ok(()) +} +" +, + "path": "crates/server/src/main.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/meilisearch.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/observability.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/upstash_cache.rs", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": "[exists]", + "path": "rust-toolchain.toml", + }, + ], +} `; -exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-leptos-seaorm 1`] = ` +exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-envlogger 1`] = ` { - "fileCount": 20, + "fileCount": 15, "files": [ { "content": @@ -12273,7 +14241,6 @@ CORS_ORIGIN=http://localhost:3001" resolver = "2" members = [ "crates/server", - "crates/client", ] [workspace.package] @@ -12291,214 +14258,50 @@ tokio = { version = "1.51", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -# Error handling (anyhow + thiserror) -thiserror = "2.0" -anyhow = "1.0" - -# Tracing (used by server logging, CLI, TUI, and WASM frontends) -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Environment variables -dotenvy = "0.15" - - -# Web framework (Axum) -axum = "0.8" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace"] } - -# Database (SeaORM) -sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite", "sqlx-mysql"] } -sea-orm-migration = "1.1" - - -# Frontend (Leptos) -leptos = "0.7" -leptos_router = "0.7" -leptos_meta = "0.7" -console_error_panic_hook = "0.1" -console_log = "1" -log = "0.4" -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "console"] } - - - - -[profile.dev] -opt-level = 0 - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -" -, - "path": "Cargo.toml", - }, - { - "content": "[exists]", - "path": "CLAUDE.md", - }, - { - "content": -"[package] -name = "snapshot-rust-axum-leptos-seaorm-client" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -# Leptos framework -leptos.workspace = true -leptos_router.workspace = true -leptos_meta.workspace = true - -# Logging -log.workspace = true -console_log.workspace = true - -# WASM utilities -wasm-bindgen.workspace = true -wasm-bindgen-futures.workspace = true -web-sys.workspace = true -console_error_panic_hook.workspace = true - -# Serialization -serde.workspace = true -serde_json.workspace = true - -[features] -default = [] -hydrate = ["leptos/hydrate"] -ssr = ["leptos/ssr", "leptos_router/ssr", "leptos_meta/ssr"] -" -, - "path": "crates/client/Cargo.toml", - }, - { - "content": "[exists]", - "path": "crates/client/index.html", - }, - { - "content": -"use leptos::prelude::*; -use leptos_meta::*; -use leptos_router::components::*; -use leptos_router::path; - -/// Main application component -#[component] -pub fn App() -> impl IntoView { - // Provides context for managing document head metadata - provide_meta_context(); - - view! { - <Stylesheet id="leptos" href="/pkg/snapshot-rust-axum-leptos-seaorm_client.css"/> - <Title text="snapshot-rust-axum-leptos-seaorm - Built with Leptos"/> - <Meta name="description" content="A Rust WASM application built with Leptos"/> +# Error handling (anyhow + thiserror) +thiserror = "2.0" +anyhow = "1.0" - <Router> - <main class="container"> - <Routes fallback=|| "Page not found.".into_view()> - <Route path=path!("/") view=HomePage/> - <Route path=path!("/about") view=AboutPage/> - </Routes> - </main> - </Router> - } -} +# Logging (env_logger) +log = "0.4" +env_logger = "0.11" -/// Home page component -#[component] -fn HomePage() -> impl IntoView { - let (count, set_count) = signal(0); +# Environment variables +dotenvy = "0.15" - view! { - <div class="home"> - <h1>"Welcome to snapshot-rust-axum-leptos-seaorm"</h1> - <p>"A full-stack Rust application powered by Leptos"</p> - <div class="counter"> - <button - class="btn" - on:click=move |_| set_count.update(|n| *n -= 1) - > - "-" - </button> - <span class="count">{count}</span> - <button - class="btn" - on:click=move |_| set_count.update(|n| *n += 1) - > - "+" - </button> - </div> +# Web framework (Axum) +axum = "0.8" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } - <nav> - <a href="/about">"About"</a> - </nav> - </div> - } -} +# Database (SQLx) +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "mysql", "migrate"] } -/// About page component -#[component] -fn AboutPage() -> impl IntoView { - view! { - <div class="about"> - <h1>"About"</h1> - <p>"This application was generated with Better-Fullstack using the Leptos framework."</p> - <h2>"Technology Stack"</h2> - <ul> - <li>"Leptos - Fine-grained reactive framework"</li> - <li>"Rust + WebAssembly - High-performance frontend"</li> - <li>"Axum - Ergonomic web framework by Tokio team"</li> - </ul> - <nav> - <a href="/">"Back to Home"</a> - </nav> - </div> - } -} -/// Initialize the Leptos client-side app -#[wasm_bindgen::prelude::wasm_bindgen(start)] -pub fn main() { - // Set up better panic messages in console - console_error_panic_hook::set_once(); - // Initialize logging - _ = console_log::init_with_level(log::Level::Debug); - log::info!("Starting Leptos client application"); +[profile.dev] +opt-level = 0 - leptos::mount::mount_to_body(App); -} +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 " , - "path": "crates/client/src/lib.rs", - }, - { - "content": "[exists]", - "path": "crates/client/style/main.css", + "path": "Cargo.toml", }, { "content": "[exists]", - "path": "crates/client/Trunk.toml", + "path": "CLAUDE.md", }, { "content": "[package] -name = "snapshot-rust-axum-leptos-seaorm-server" +name = "snapshot-rust-axum-envlogger-server" version.workspace = true edition.workspace = true authors.workspace = true @@ -12517,8 +14320,8 @@ thiserror.workspace = true anyhow.workspace = true # Logging -tracing.workspace = true -tracing-subscriber.workspace = true +log.workspace = true +env_logger.workspace = true # Environment dotenvy.workspace = true @@ -12530,7 +14333,7 @@ tower.workspace = true tower-http.workspace = true # Database -sea-orm.workspace = true +sqlx.workspace = true @@ -12565,9 +14368,8 @@ path = "src/main.rs" use serde::Serialize; use tower_http::cors::CorsLayer; mod error; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use sea_orm::{Database, DatabaseConnection}; -use std::sync::Arc; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; #[derive(Serialize)] struct HealthResponse { @@ -12578,13 +14380,13 @@ struct HealthResponse { #[derive(Clone)] pub struct AppState { - pub db: Arc<DatabaseConnection>, + pub db: PgPool, } async fn health( axum::extract::State(state): axum::extract::State<AppState>, ) -> Json<HealthResponse> { - let db_status = if state.db.ping().await.is_ok() { + let db_status = if state.db.acquire().await.is_ok() { "connected" } else { "disconnected" @@ -12602,23 +14404,23 @@ async fn main() -> anyhow::Result<()> { // Load environment variables dotenvy::dotenv().ok(); - // Initialize tracing - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) - .with(tracing_subscriber::fmt::layer()) - .init(); + // Initialize logging + env_logger::init(); - // Initialize database connection + // Initialize database connection pool let database_url = std::env::var("DATABASE_URL") .expect("DATABASE_URL must be set"); - tracing::info!("Connecting to database..."); - let db = Database::connect(&database_url).await?; - tracing::info!("Database connected successfully"); + log::info!("Connecting to database..."); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await?; + log::info!("Database connected successfully"); - let state = AppState { db: Arc::new(db) }; + let state = AppState { db: pool.clone() }; // Build router with state let app = Router::new() @@ -12631,7 +14433,7 @@ async fn main() -> anyhow::Result<()> { let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); let addr = format!("{}:{}", host, port); - tracing::info!("Starting HTTP server at http://{}", addr); + log::info!("Starting HTTP server at http://{}", addr); // Start server let listener = tokio::net::TcpListener::bind(&addr).await?; @@ -12667,251 +14469,98 @@ async fn main() -> anyhow::Result<()> { } `; -exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: actix-dioxus-sqlx 1`] = ` +exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-eyre 1`] = ` { - "fileCount": 19, - "files": [ - { - "content": -"# Application -RUST_LOG=debug -APP_ENV=development - -# Server -HOST=127.0.0.1 -PORT=3000 - -# gRPC (if using tonic) -# GRPC_PORT=50051 - -# Database (if using) -# DATABASE_URL=postgres://user:password@localhost:5432/dbname -# DATABASE_URL=sqlite:./data.db - -# JWT Secret (if using jsonwebtoken) -# JWT_SECRET=your-secret-key-here -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", - }, - { - "content": -"[workspace] -resolver = "2" -members = [ - "crates/server", - "crates/dioxus-client", -] - -[workspace.package] -version = "0.1.0" -edition = "2021" -authors = ["Your Name <your.email@example.com>"] -license = "MIT" -repository = "" - -[workspace.dependencies] -# Async runtime -tokio = { version = "1.51", features = ["full"] } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Error handling (anyhow + thiserror) -thiserror = "2.0" -anyhow = "1.0" - -# Tracing (used by server logging, CLI, TUI, and WASM frontends) -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Environment variables -dotenvy = "0.15" - - -# Web framework (Actix-web) -actix-web = "4" -actix-rt = "2" -actix-cors = "0.7" - -# Database (SQLx) -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "mysql", "migrate"] } - - -# Frontend (Dioxus) -dioxus = { version = "0.6", features = ["router"] } -dioxus-router = "0.6" -dioxus-logger = "0.6" -console_error_panic_hook = "0.1" -wasm-bindgen = "0.2" - - -# Validation -validator = { version = "0.19", features = ["derive"] } - - -[profile.dev] -opt-level = 0 - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -" -, - "path": "Cargo.toml", - }, - { - "content": "[exists]", - "path": "CLAUDE.md", - }, - { - "content": "[exists]", - "path": "crates/dioxus-client/assets/main.css", - }, - { - "content": -"[package] -name = "snapshot-rust-actix-dioxus-sqlx-client" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[[bin]] -name = "snapshot-rust-actix-dioxus-sqlx-client" -path = "src/main.rs" - -[dependencies] -# Dioxus framework -dioxus.workspace = true -dioxus-router.workspace = true + "fileCount": 15, + "files": [ + { + "content": +"# Application +RUST_LOG=debug +APP_ENV=development -# Logging -tracing.workspace = true -dioxus-logger.workspace = true +# Server +HOST=127.0.0.1 +PORT=3000 -# WASM utilities -wasm-bindgen.workspace = true -console_error_panic_hook.workspace = true +# gRPC (if using tonic) +# GRPC_PORT=50051 -# Serialization -serde.workspace = true -serde_json.workspace = true +# Database (if using) +# DATABASE_URL=postgres://user:password@localhost:5432/dbname +# DATABASE_URL=sqlite:./data.db -[features] -default = ["web"] -web = ["dioxus/web"] -desktop = ["dioxus/desktop"] -" +# JWT Secret (if using jsonwebtoken) +# JWT_SECRET=your-secret-key-here +CORS_ORIGIN=http://localhost:3001" , - "path": "crates/dioxus-client/Cargo.toml", - }, - { - "content": "[exists]", - "path": "crates/dioxus-client/Dioxus.toml", + "path": ".env.example", }, { "content": -"#![allow(non_snake_case)] +"[workspace] +resolver = "2" +members = [ + "crates/server", +] -use dioxus::prelude::*; -use dioxus_router::prelude::*; -use tracing::info; +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["Your Name <your.email@example.com>"] +license = "MIT" +repository = "" -/// Application routes -#[derive(Clone, Routable, Debug, PartialEq)] -enum Route { - #[route("/")] - Home {}, - #[route("/about")] - About {}, -} +[workspace.dependencies] +# Async runtime +tokio = { version = "1.51", features = ["full"] } -fn main() { - // Set up better panic messages in console - console_error_panic_hook::set_once(); +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" - // Initialize logging - dioxus_logger::init(tracing::Level::INFO).expect("Failed to init logger"); +# Error handling (eyre + color-eyre) +eyre = "0.6" +color-eyre = "0.6" - info!("Starting Dioxus client application"); +# Tracing (used by server logging, CLI, TUI, and WASM frontends) +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } - launch(App); -} +# Environment variables +dotenvy = "0.15" -/// Main application component -fn App() -> Element { - rsx! { - Router::<Route> {} - } -} -/// Home page component -#[component] -fn Home() -> Element { - let mut count = use_signal(|| 0); +# Web framework (Axum) +axum = "0.8" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } - rsx! { - div { class: "container", - div { class: "home", - h1 { "Welcome to snapshot-rust-actix-dioxus-sqlx" } - p { "A full-stack Rust application powered by Dioxus" } - div { class: "counter", - button { - class: "btn", - onclick: move |_| count -= 1, - "-" - } - span { class: "count", "{count}" } - button { - class: "btn", - onclick: move |_| count += 1, - "+" - } - } - nav { - Link { to: Route::About {}, "About" } - } - } - } - } -} -/// About page component -#[component] -fn About() -> Element { - rsx! { - div { class: "container", - div { class: "about", - h1 { "About" } - p { "This application was generated with Better-Fullstack using the Dioxus framework." } - h2 { "Technology Stack" } - ul { - li { "Dioxus - React-like reactive framework" } - li { "Rust + WebAssembly - High-performance frontend" } - li { "Actix-web - Powerful, pragmatic web framework" } - } - nav { - Link { to: Route::Home {}, "Back to Home" } - } - } - } - } -} + +[profile.dev] +opt-level = 0 + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 " , - "path": "crates/dioxus-client/src/main.rs", + "path": "Cargo.toml", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", }, { "content": "[package] -name = "snapshot-rust-actix-dioxus-sqlx-server" +name = "snapshot-rust-axum-eyre-server" version.workspace = true edition.workspace = true authors.workspace = true @@ -12926,8 +14575,8 @@ serde.workspace = true serde_json.workspace = true # Error handling -thiserror.workspace = true -anyhow.workspace = true +eyre.workspace = true +color-eyre.workspace = true # Logging tracing.workspace = true @@ -12938,15 +14587,12 @@ dotenvy.workspace = true # Web framework -actix-web.workspace = true -actix-rt.workspace = true -actix-cors.workspace = true +axum.workspace = true +tower.workspace = true +tower-http.workspace = true -# Database -sqlx.workspace = true -validator.workspace = true [[bin]] @@ -12975,45 +14621,34 @@ path = "src/main.rs" }, { "content": -"use actix_cors::Cors; -use actix_web::{get, web, App, HttpResponse, HttpServer, Responder}; +"use axum::{routing::get, Json, Router}; use serde::Serialize; +use tower_http::cors::CorsLayer; mod error; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use sqlx::postgres::PgPoolOptions; -use sqlx::PgPool; #[derive(Serialize)] struct HealthResponse { status: &'static str, message: &'static str, - database: &'static str, -} - -pub struct AppState { - pub db: PgPool, } -#[get("/health")] -async fn health(data: web::Data<AppState>) -> impl Responder { - let db_status = if data.db.acquire().await.is_ok() { - "connected" - } else { - "disconnected" - }; - HttpResponse::Ok().json(HealthResponse { +async fn health() -> Json<HealthResponse> { + Json(HealthResponse { status: "ok", message: "Server is running", - database: db_status, }) } -#[actix_web::main] -async fn main() -> anyhow::Result<()> { +#[tokio::main] +async fn main() -> eyre::Result<()> { // Load environment variables dotenvy::dotenv().ok(); + // Install color-eyre error handler + color_eyre::install()?; + // Initialize tracing tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) @@ -13022,38 +14657,21 @@ async fn main() -> anyhow::Result<()> { - // Initialize database connection pool - let database_url = std::env::var("DATABASE_URL") - .expect("DATABASE_URL must be set"); - - tracing::info!("Connecting to database..."); - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(&database_url) - .await?; - tracing::info!("Database connected successfully"); - - let state = web::Data::new(AppState { db: pool.clone() }); + // Build router + let app = Router::new() + .route("/health", get(health)) + .layer(CorsLayer::permissive()); // Get host and port from environment let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port: u16 = std::env::var("PORT") - .unwrap_or_else(|_| "3000".to_string()) - .parse()?; + let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); + let addr = format!("{}:{}", host, port); - tracing::info!("Starting HTTP server at http://{}:{}", host, port); + tracing::info!("Starting HTTP server at http://{}", addr); // Start server - HttpServer::new(move || { - let cors = Cors::permissive(); - App::new() - .wrap(cors) - .app_data(state.clone()) - .service(health) - }) - .bind((host, port))? - .run() - .await?; + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; Ok(()) } @@ -13085,9 +14703,9 @@ async fn main() -> anyhow::Result<()> { } `; -exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: cli-clap 1`] = ` +exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: rocket-seaorm 1`] = ` { - "fileCount": 17, + "fileCount": 15, "files": [ { "content": @@ -13118,7 +14736,6 @@ CORS_ORIGIN=http://localhost:3001" resolver = "2" members = [ "crates/server", - "crates/cli", ] [workspace.package] @@ -13148,12 +14765,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } dotenvy = "0.15" +# Web framework (Rocket) +rocket = { version = "0.5", features = ["json"] } +rocket_cors = "0.6" +# Database (SeaORM) +sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite", "sqlx-mysql"] } +sea-orm-migration = "1.1" -# CLI (Clap) -clap = { version = "4", features = ["derive"] } @@ -13175,17 +14796,13 @@ codegen-units = 1 { "content": "[package] -name = "snapshot-rust-cli-clap-cli" +name = "snapshot-rust-rocket-seaorm-server" version.workspace = true edition.workspace = true authors.workspace = true -license.workspace = true -description = "Command-line interface for snapshot-rust-cli-clap" - -[dependencies] -# CLI argument parsing -clap.workspace = true +license.workspace = true +[dependencies] # Async runtime tokio.workspace = true @@ -13194,6 +14811,7 @@ serde.workspace = true serde_json.workspace = true # Error handling +thiserror.workspace = true anyhow.workspace = true # Logging @@ -13203,785 +14821,657 @@ tracing-subscriber.workspace = true # Environment dotenvy.workspace = true -[[bin]] -name = "cli" -path = "src/main.rs" -" -, - "path": "crates/cli/Cargo.toml", - }, - { - "content": -"use anyhow::Result; -use clap::{Parser, Subcommand}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -/// snapshot-rust-cli-clap CLI - Command-line interface -#[derive(Parser, Debug)] -#[command(name = "snapshot-rust-cli-clap")] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Turn on verbose output - #[arg(short, long, global = true)] - verbose: bool, +# Web framework +rocket.workspace = true +rocket_cors.workspace = true - /// Config file path - #[arg(short, long, global = true)] - config: Option<String>, +# Database +sea-orm.workspace = true - #[command(subcommand)] - command: Commands, -} -#[derive(Subcommand, Debug)] -enum Commands { - /// Start the application - Start { - /// Port to listen on - #[arg(short, long, default_value = "3000")] - port: u16, - /// Host to bind to - #[arg(short = 'H', long, default_value = "127.0.0.1")] - host: String, - }, - /// Check configuration and health - Check { - /// Check database connection - #[arg(long)] - database: bool, +[[bin]] +name = "server" +path = "src/main.rs" - /// Check all services - #[arg(long)] - all: bool, +" +, + "path": "crates/server/Cargo.toml", }, - - /// Run database migrations - Migrate { - /// Run pending migrations - #[arg(long)] - up: bool, - - /// Rollback last migration - #[arg(long)] - down: bool, - - /// Migration status - #[arg(long)] - status: bool, + { + "content": "[exists]", + "path": "crates/server/src/auth.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/cache.rs", }, + { + "content": "[exists]", + "path": "crates/server/src/email.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/error.rs", + }, + { + "content": +"use rocket::{get, routes, serde::json::Json}; +use rocket_cors::CorsOptions; +use serde::Serialize; +mod error; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use sea_orm::{Database, DatabaseConnection}; +use std::sync::Arc; - /// Generate project components - Generate { - /// Component type to generate - #[arg(value_enum)] - component: GenerateComponent, +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + message: &'static str, + database: &'static str, +} - /// Name for the generated component - name: String, - }, +pub struct AppState { + pub db: Arc<DatabaseConnection>, } -#[derive(clap::ValueEnum, Clone, Debug)] -enum GenerateComponent { - /// Generate a new model - Model, - /// Generate a new handler/controller - Handler, - /// Generate a new service - Service, - /// Generate a new migration - Migration, +#[get("/health")] +async fn health(state: &rocket::State<AppState>) -> Json<HealthResponse> { + let db_status = if state.db.ping().await.is_ok() { + "connected" + } else { + "disconnected" + }; + Json(HealthResponse { + status: "ok", + message: "Server is running", + database: db_status, + }) } -#[tokio::main] -async fn main() -> Result<()> { + +#[rocket::launch] +async fn rocket() -> _ { // Load environment variables dotenvy::dotenv().ok(); - let cli = Cli::parse(); - - // Initialize tracing with verbosity level - let filter = if cli.verbose { "debug" } else { "info" }; + // Initialize tracing tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into())) + .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) .with(tracing_subscriber::fmt::layer()) .init(); - if let Some(config_path) = &cli.config { - tracing::info!("Using config file: {}", config_path); - } - - match cli.command { - Commands::Start { port, host } => { - tracing::info!("Starting server on {}:{}", host, port); - // TODO: Implement server start logic - // This is where you would start your Axum/Actix server - println!("Server would start on {}:{}", host, port); - } - - Commands::Check { database, all } => { - tracing::info!("Running health checks..."); - - if database || all { - tracing::info!("Checking database connection..."); - // TODO: Implement database check - println!("Database check: Not implemented"); - } + // Configure CORS + let cors = CorsOptions::default() + .to_cors() + .expect("CORS configuration failed"); - if all { - tracing::info!("Checking all services..."); - // TODO: Implement other service checks - println!("All checks complete"); - } - if !database && !all { - println!("No checks specified. Use --database or --all"); - } - } + // Initialize database connection + let database_url = std::env::var("DATABASE_URL") + .expect("DATABASE_URL must be set"); - Commands::Migrate { up, down, status } => { - if status { - tracing::info!("Checking migration status..."); - // TODO: Implement migration status check - println!("Migration status: Not implemented"); - } else if up { - tracing::info!("Running pending migrations..."); - // TODO: Implement migration up - println!("Migrations run: Not implemented"); - } else if down { - tracing::info!("Rolling back last migration..."); - // TODO: Implement migration down - println!("Migration rollback: Not implemented"); - } else { - println!("No migration action specified. Use --up, --down, or --status"); - } - } + tracing::info!("Connecting to database..."); + let db = Database::connect(&database_url).await.expect("Failed to connect to database"); + tracing::info!("Database connected successfully"); - Commands::Generate { component, name } => { - tracing::info!("Generating {:?}: {}", component, name); - match component { - GenerateComponent::Model => { - println!("Would generate model: {}", name); - // TODO: Implement model generation - } - GenerateComponent::Handler => { - println!("Would generate handler: {}", name); - // TODO: Implement handler generation - } - GenerateComponent::Service => { - println!("Would generate service: {}", name); - // TODO: Implement service generation - } - GenerateComponent::Migration => { - println!("Would generate migration: {}", name); - // TODO: Implement migration generation - } - } - } - } + let state = AppState { db: Arc::new(db) }; - Ok(()) + rocket::build() + .manage(state) + .mount("/", routes![health]) + .attach(cors) } " , - "path": "crates/cli/src/main.rs", + "path": "crates/server/src/main.rs", }, { - "content": -"[package] -name = "snapshot-rust-cli-clap-server" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[dependencies] -# Async runtime -tokio.workspace = true + "content": "[exists]", + "path": "crates/server/src/meilisearch.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/observability.rs", + }, + { + "content": "[exists]", + "path": "crates/server/src/upstash_cache.rs", + }, + { + "content": "[exists]", + "path": "README.md", + }, + { + "content": "[exists]", + "path": "rust-toolchain.toml", + }, + ], +} +`; -# Serialization -serde.workspace = true -serde_json.workspace = true +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-gorm-zap 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/server/main.go", + "go.mod", + "internal/database/database.go", + "internal/handlers/handlers.go", + "internal/models/models.go", +] +`; -# Error handling -thiserror.workspace = true -anyhow.workspace = true +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-sqlc-grpc 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/server/main.go", + "go.mod", + "internal/database/database.go", + "internal/handlers/handlers.go", + "internal/models/models.go", + "proto/greeter.go", + "proto/greeter.pb.go", + "proto/greeter.proto", + "proto/greeter_grpc.pb.go", + "sql/queries/posts.sql", + "sql/queries/users.sql", + "sql/schema/001_schema.sql", + "sqlc.yaml", +] +`; -# Logging -tracing.workspace = true -tracing-subscriber.workspace = true +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: fiber-gorm-zap 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/server/main.go", + "go.mod", + "internal/database/database.go", + "internal/handlers/handlers.go", + "internal/models/models.go", +] +`; -# Environment -dotenvy.workspace = true +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: chi-gorm 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/server/main.go", + "go.mod", + "internal/database/database.go", + "internal/handlers/handlers.go", + "internal/models/models.go", +] +`; +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: cli-cobra 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/cli/main.go", + "cmd/server/main.go", + "go.mod", +] +`; +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-gorm-zerolog 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/server/main.go", + "go.mod", + "internal/database/database.go", + "internal/handlers/handlers.go", + "internal/models/models.go", +] +`; +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-sqlc-slog 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/server/main.go", + "go.mod", + "internal/database/database.go", + "internal/handlers/handlers.go", + "internal/models/models.go", + "sql/queries/posts.sql", + "sql/queries/users.sql", + "sql/schema/001_schema.sql", + "sqlc.yaml", +] +`; +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-casbin 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "auth_model.conf", + "auth_policy.csv", + "cmd/server/main.go", + "go.mod", + "internal/auth/goauth.go", + "internal/handlers/handlers.go", +] +`; +exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-jwt-auth 1`] = ` +[ + ".env.example", + "CLAUDE.md", + "README.md", + "cmd/server/main.go", + "go.mod", + "internal/auth/goauth.go", + "internal/handlers/handlers.go", +] +`; -# CLI -clap.workspace = true +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-gorm-zap 1`] = ` +{ + "fileCount": 9, + "files": [ + { + "content": +"# Application settings +HOST=0.0.0.0 +PORT=8080 +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable -[[bin]] -name = "server" -path = "src/main.rs" +# gRPC settings (if using gRPC) +GRPC_PORT=50051 -" +# Logging level +LOG_LEVEL=debug +CORS_ORIGIN=http://localhost:3001" , - "path": "crates/server/Cargo.toml", + "path": ".env.example", }, { "content": "[exists]", - "path": "crates/server/src/auth.rs", + "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "crates/server/src/cache.rs", + "path": "cmd/server/main.go", }, { "content": "[exists]", - "path": "crates/server/src/email.rs", + "path": "go.mod", }, { "content": "[exists]", - "path": "crates/server/src/error.rs", + "path": "internal/database/database.go", }, { - "content": -"mod error; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Load environment variables - dotenvy::dotenv().ok(); - - // Initialize tracing - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) - .with(tracing_subscriber::fmt::layer()) - .init(); + "content": "[exists]", + "path": "internal/handlers/handlers.go", + }, + { + "content": "[exists]", + "path": "internal/models/models.go", + }, + { + "content": "[exists]", + "path": "README.md", + }, + ], +} +`; +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-sqlc-grpc 1`] = ` +{ + "fileCount": 17, + "files": [ + { + "content": +"# Application settings +HOST=0.0.0.0 +PORT=8080 +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable - tracing::info!("Hello from snapshot-rust-cli-clap!"); - tracing::info!("Add a web framework (axum, actix-web, or rocket) to start building your API."); +# gRPC settings (if using gRPC) +GRPC_PORT=50051 - Ok(()) -} -" +# Logging level +LOG_LEVEL=debug +CORS_ORIGIN=http://localhost:3001" , - "path": "crates/server/src/main.rs", + "path": ".env.example", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": "[exists]", + "path": "cmd/server/main.go", + }, + { + "content": "[exists]", + "path": "go.mod", + }, + { + "content": "[exists]", + "path": "internal/database/database.go", + }, + { + "content": "[exists]", + "path": "internal/handlers/handlers.go", + }, + { + "content": "[exists]", + "path": "internal/models/models.go", + }, + { + "content": "[exists]", + "path": "proto/greeter_grpc.pb.go", + }, + { + "content": "[exists]", + "path": "proto/greeter.go", + }, + { + "content": "[exists]", + "path": "proto/greeter.pb.go", }, { "content": "[exists]", - "path": "crates/server/src/meilisearch.rs", + "path": "proto/greeter.proto", }, { "content": "[exists]", - "path": "crates/server/src/observability.rs", + "path": "README.md", }, { "content": "[exists]", - "path": "crates/server/src/upstash_cache.rs", + "path": "sql/queries/posts.sql", }, { "content": "[exists]", - "path": "README.md", + "path": "sql/queries/users.sql", }, { "content": "[exists]", - "path": "rust-toolchain.toml", + "path": "sql/schema/001_schema.sql", + }, + { + "content": "[exists]", + "path": "sqlc.yaml", }, ], } `; -exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-envlogger 1`] = ` +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: fiber-gorm-zap 1`] = ` { - "fileCount": 15, + "fileCount": 9, "files": [ { "content": -"# Application -RUST_LOG=debug -APP_ENV=development - -# Server -HOST=127.0.0.1 -PORT=3000 +"# Application settings +HOST=0.0.0.0 +PORT=8080 -# gRPC (if using tonic) -# GRPC_PORT=50051 +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable -# Database (if using) -# DATABASE_URL=postgres://user:password@localhost:5432/dbname -# DATABASE_URL=sqlite:./data.db +# gRPC settings (if using gRPC) +GRPC_PORT=50051 -# JWT Secret (if using jsonwebtoken) -# JWT_SECRET=your-secret-key-here +# Logging level +LOG_LEVEL=debug CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, - { - "content": -"[workspace] -resolver = "2" -members = [ - "crates/server", -] - -[workspace.package] -version = "0.1.0" -edition = "2021" -authors = ["Your Name <your.email@example.com>"] -license = "MIT" -repository = "" - -[workspace.dependencies] -# Async runtime -tokio = { version = "1.51", features = ["full"] } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Error handling (anyhow + thiserror) -thiserror = "2.0" -anyhow = "1.0" - -# Logging (env_logger) -log = "0.4" -env_logger = "0.11" - -# Environment variables -dotenvy = "0.15" - - -# Web framework (Axum) -axum = "0.8" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace"] } - -# Database (SQLx) -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "mysql", "migrate"] } - - - - - - -[profile.dev] -opt-level = 0 - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -" -, - "path": "Cargo.toml", - }, { "content": "[exists]", "path": "CLAUDE.md", }, { - "content": -"[package] -name = "snapshot-rust-axum-envlogger-server" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[dependencies] -# Async runtime -tokio.workspace = true - -# Serialization -serde.workspace = true -serde_json.workspace = true - -# Error handling -thiserror.workspace = true -anyhow.workspace = true - -# Logging -log.workspace = true -env_logger.workspace = true - -# Environment -dotenvy.workspace = true - - -# Web framework -axum.workspace = true -tower.workspace = true -tower-http.workspace = true - -# Database -sqlx.workspace = true - - - - -[[bin]] -name = "server" -path = "src/main.rs" - -" -, - "path": "crates/server/Cargo.toml", + "content": "[exists]", + "path": "cmd/server/main.go", }, { "content": "[exists]", - "path": "crates/server/src/auth.rs", + "path": "go.mod", }, { "content": "[exists]", - "path": "crates/server/src/cache.rs", + "path": "internal/database/database.go", }, { "content": "[exists]", - "path": "crates/server/src/email.rs", + "path": "internal/handlers/handlers.go", }, { "content": "[exists]", - "path": "crates/server/src/error.rs", + "path": "internal/models/models.go", }, { - "content": -"use axum::{routing::get, Json, Router}; -use serde::Serialize; -use tower_http::cors::CorsLayer; -mod error; -use sqlx::postgres::PgPoolOptions; -use sqlx::PgPool; - -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, - message: &'static str, - database: &'static str, -} - -#[derive(Clone)] -pub struct AppState { - pub db: PgPool, -} - -async fn health( - axum::extract::State(state): axum::extract::State<AppState>, -) -> Json<HealthResponse> { - let db_status = if state.db.acquire().await.is_ok() { - "connected" - } else { - "disconnected" - }; - Json(HealthResponse { - status: "ok", - message: "Server is running", - database: db_status, - }) + "content": "[exists]", + "path": "README.md", + }, + ], } +`; +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: chi-gorm 1`] = ` +{ + "fileCount": 9, + "files": [ + { + "content": +"# Application settings +HOST=0.0.0.0 +PORT=8080 -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Load environment variables - dotenvy::dotenv().ok(); - - // Initialize logging - env_logger::init(); - - - - // Initialize database connection pool - let database_url = std::env::var("DATABASE_URL") - .expect("DATABASE_URL must be set"); - - log::info!("Connecting to database..."); - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(&database_url) - .await?; - log::info!("Database connected successfully"); - - let state = AppState { db: pool.clone() }; - - // Build router with state - let app = Router::new() - .route("/health", get(health)) - .layer(CorsLayer::permissive()) - .with_state(state); - - // Get host and port from environment - let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); - let addr = format!("{}:{}", host, port); - - log::info!("Starting HTTP server at http://{}", addr); +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable - // Start server - let listener = tokio::net::TcpListener::bind(&addr).await?; - axum::serve(listener, app).await?; +# gRPC settings (if using gRPC) +GRPC_PORT=50051 - Ok(()) -} -" +# Logging level +LOG_LEVEL=debug +CORS_ORIGIN=http://localhost:3001" , - "path": "crates/server/src/main.rs", + "path": ".env.example", + }, + { + "content": "[exists]", + "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "crates/server/src/meilisearch.rs", + "path": "cmd/server/main.go", }, { "content": "[exists]", - "path": "crates/server/src/observability.rs", + "path": "go.mod", }, { "content": "[exists]", - "path": "crates/server/src/upstash_cache.rs", + "path": "internal/database/database.go", }, { "content": "[exists]", - "path": "README.md", + "path": "internal/handlers/handlers.go", }, { "content": "[exists]", - "path": "rust-toolchain.toml", + "path": "internal/models/models.go", + }, + { + "content": "[exists]", + "path": "README.md", }, ], } `; -exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: axum-eyre 1`] = ` +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: cli-cobra 1`] = ` { - "fileCount": 15, + "fileCount": 7, "files": [ { "content": -"# Application -RUST_LOG=debug -APP_ENV=development - -# Server -HOST=127.0.0.1 -PORT=3000 +"# Application settings +HOST=0.0.0.0 +PORT=8080 -# gRPC (if using tonic) -# GRPC_PORT=50051 +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable -# Database (if using) -# DATABASE_URL=postgres://user:password@localhost:5432/dbname -# DATABASE_URL=sqlite:./data.db +# gRPC settings (if using gRPC) +GRPC_PORT=50051 -# JWT Secret (if using jsonwebtoken) -# JWT_SECRET=your-secret-key-here +# Logging level +LOG_LEVEL=debug CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, { - "content": -"[workspace] -resolver = "2" -members = [ - "crates/server", -] - -[workspace.package] -version = "0.1.0" -edition = "2021" -authors = ["Your Name <your.email@example.com>"] -license = "MIT" -repository = "" - -[workspace.dependencies] -# Async runtime -tokio = { version = "1.51", features = ["full"] } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Error handling (eyre + color-eyre) -eyre = "0.6" -color-eyre = "0.6" - -# Tracing (used by server logging, CLI, TUI, and WASM frontends) -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Environment variables -dotenvy = "0.15" - - -# Web framework (Axum) -axum = "0.8" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace"] } - - - - + "content": "[exists]", + "path": "CLAUDE.md", + }, + { + "content": "[exists]", + "path": "cmd/cli/main.go", + }, + { + "content": "[exists]", + "path": "cmd/server/main.go", + }, + { + "content": "[exists]", + "path": "go.mod", + }, + { + "content": "[exists]", + "path": "README.md", + }, + ], +} +`; +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-gorm-zerolog 1`] = ` +{ + "fileCount": 9, + "files": [ + { + "content": +"# Application settings +HOST=0.0.0.0 +PORT=8080 +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable -[profile.dev] -opt-level = 0 +# gRPC settings (if using gRPC) +GRPC_PORT=50051 -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -" +# Logging level +LOG_LEVEL=debug +CORS_ORIGIN=http://localhost:3001" , - "path": "Cargo.toml", + "path": ".env.example", }, { "content": "[exists]", "path": "CLAUDE.md", }, { - "content": -"[package] -name = "snapshot-rust-axum-eyre-server" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[dependencies] -# Async runtime -tokio.workspace = true - -# Serialization -serde.workspace = true -serde_json.workspace = true - -# Error handling -eyre.workspace = true -color-eyre.workspace = true - -# Logging -tracing.workspace = true -tracing-subscriber.workspace = true - -# Environment -dotenvy.workspace = true - - -# Web framework -axum.workspace = true -tower.workspace = true -tower-http.workspace = true - - - - - -[[bin]] -name = "server" -path = "src/main.rs" - -" -, - "path": "crates/server/Cargo.toml", + "content": "[exists]", + "path": "cmd/server/main.go", }, { "content": "[exists]", - "path": "crates/server/src/auth.rs", + "path": "go.mod", }, { "content": "[exists]", - "path": "crates/server/src/cache.rs", + "path": "internal/database/database.go", }, { "content": "[exists]", - "path": "crates/server/src/email.rs", + "path": "internal/handlers/handlers.go", }, { "content": "[exists]", - "path": "crates/server/src/error.rs", + "path": "internal/models/models.go", }, { - "content": -"use axum::{routing::get, Json, Router}; -use serde::Serialize; -use tower_http::cors::CorsLayer; -mod error; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, - message: &'static str, -} - -async fn health() -> Json<HealthResponse> { - Json(HealthResponse { - status: "ok", - message: "Server is running", - }) + "content": "[exists]", + "path": "README.md", + }, + ], } +`; +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-sqlc-slog 1`] = ` +{ + "fileCount": 13, + "files": [ + { + "content": +"# Application settings +HOST=0.0.0.0 +PORT=8080 -#[tokio::main] -async fn main() -> eyre::Result<()> { - // Load environment variables - dotenvy::dotenv().ok(); - - // Install color-eyre error handler - color_eyre::install()?; - - // Initialize tracing - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) - .with(tracing_subscriber::fmt::layer()) - .init(); - - - - // Build router - let app = Router::new() - .route("/health", get(health)) - .layer(CorsLayer::permissive()); - - // Get host and port from environment - let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); - let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string()); - let addr = format!("{}:{}", host, port); - - tracing::info!("Starting HTTP server at http://{}", addr); - - // Start server - let listener = tokio::net::TcpListener::bind(&addr).await?; - axum::serve(listener, app).await?; +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable - Ok(()) -} -" +# gRPC settings (if using gRPC) +GRPC_PORT=50051 + +# Logging level +LOG_LEVEL=debug +CORS_ORIGIN=http://localhost:3001" , - "path": "crates/server/src/main.rs", + "path": ".env.example", }, { "content": "[exists]", - "path": "crates/server/src/meilisearch.rs", + "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "crates/server/src/observability.rs", + "path": "cmd/server/main.go", }, { "content": "[exists]", - "path": "crates/server/src/upstash_cache.rs", + "path": "go.mod", + }, + { + "content": "[exists]", + "path": "internal/database/database.go", + }, + { + "content": "[exists]", + "path": "internal/handlers/handlers.go", + }, + { + "content": "[exists]", + "path": "internal/models/models.go", }, { "content": "[exists]", @@ -13989,654 +15479,491 @@ async fn main() -> eyre::Result<()> { }, { "content": "[exists]", - "path": "rust-toolchain.toml", + "path": "sql/queries/posts.sql", + }, + { + "content": "[exists]", + "path": "sql/queries/users.sql", + }, + { + "content": "[exists]", + "path": "sql/schema/001_schema.sql", + }, + { + "content": "[exists]", + "path": "sqlc.yaml", }, ], } `; -exports[`Template Snapshots - Rust Ecosystem Rust Key File Content Snapshots key files: rocket-seaorm 1`] = ` +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-casbin 1`] = ` { - "fileCount": 15, + "fileCount": 10, "files": [ { "content": -"# Application -RUST_LOG=debug -APP_ENV=development +"# Application settings +HOST=0.0.0.0 +PORT=8080 -# Server -HOST=127.0.0.1 -PORT=3000 +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable -# gRPC (if using tonic) -# GRPC_PORT=50051 +# gRPC settings (if using gRPC) +GRPC_PORT=50051 -# Database (if using) -# DATABASE_URL=postgres://user:password@localhost:5432/dbname -# DATABASE_URL=sqlite:./data.db +# Logging level +LOG_LEVEL=debug -# JWT Secret (if using jsonwebtoken) -# JWT_SECRET=your-secret-key-here +# Casbin settings +CASBIN_MODEL=auth_model.conf +CASBIN_POLICY=auth_policy.csv CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, { - "content": -"[workspace] -resolver = "2" -members = [ - "crates/server", -] - -[workspace.package] -version = "0.1.0" -edition = "2021" -authors = ["Your Name <your.email@example.com>"] -license = "MIT" -repository = "" - -[workspace.dependencies] -# Async runtime -tokio = { version = "1.51", features = ["full"] } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Error handling (anyhow + thiserror) -thiserror = "2.0" -anyhow = "1.0" - -# Tracing (used by server logging, CLI, TUI, and WASM frontends) -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Environment variables -dotenvy = "0.15" - - -# Web framework (Rocket) -rocket = { version = "0.5", features = ["json"] } -rocket_cors = "0.6" - -# Database (SeaORM) -sea-orm = { version = "1.1", features = ["runtime-tokio-rustls", "sqlx-postgres", "sqlx-sqlite", "sqlx-mysql"] } -sea-orm-migration = "1.1" - - - - - - -[profile.dev] -opt-level = 0 - -[profile.release] -opt-level = 3 -lto = true -codegen-units = 1 -" -, - "path": "Cargo.toml", + "content": "[exists]", + "path": "auth_model.conf", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "auth_policy.csv", }, { - "content": -"[package] -name = "snapshot-rust-rocket-seaorm-server" -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true - -[dependencies] -# Async runtime -tokio.workspace = true - -# Serialization -serde.workspace = true -serde_json.workspace = true - -# Error handling -thiserror.workspace = true -anyhow.workspace = true - -# Logging -tracing.workspace = true -tracing-subscriber.workspace = true - -# Environment -dotenvy.workspace = true - - -# Web framework -rocket.workspace = true -rocket_cors.workspace = true - -# Database -sea-orm.workspace = true - - - - -[[bin]] -name = "server" -path = "src/main.rs" - -" -, - "path": "crates/server/Cargo.toml", + "content": "[exists]", + "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "crates/server/src/auth.rs", + "path": "cmd/server/main.go", }, { "content": "[exists]", - "path": "crates/server/src/cache.rs", + "path": "go.mod", }, { "content": "[exists]", - "path": "crates/server/src/email.rs", + "path": "internal/auth/goauth.go", }, { "content": "[exists]", - "path": "crates/server/src/error.rs", + "path": "internal/handlers/handlers.go", }, { - "content": -"use rocket::{get, routes, serde::json::Json}; -use rocket_cors::CorsOptions; -use serde::Serialize; -mod error; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use sea_orm::{Database, DatabaseConnection}; -use std::sync::Arc; - -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, - message: &'static str, - database: &'static str, -} - -pub struct AppState { - pub db: Arc<DatabaseConnection>, -} - -#[get("/health")] -async fn health(state: &rocket::State<AppState>) -> Json<HealthResponse> { - let db_status = if state.db.ping().await.is_ok() { - "connected" - } else { - "disconnected" - }; - Json(HealthResponse { - status: "ok", - message: "Server is running", - database: db_status, - }) + "content": "[exists]", + "path": "README.md", + }, + ], } +`; +exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-jwt-auth 1`] = ` +{ + "fileCount": 8, + "files": [ + { + "content": +"# Application settings +HOST=0.0.0.0 +PORT=8080 -#[rocket::launch] -async fn rocket() -> _ { - // Load environment variables - dotenvy::dotenv().ok(); - - // Initialize tracing - tracing_subscriber::registry() - .with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "debug".into())) - .with(tracing_subscriber::fmt::layer()) - .init(); - - // Configure CORS - let cors = CorsOptions::default() - .to_cors() - .expect("CORS configuration failed"); - - - // Initialize database connection - let database_url = std::env::var("DATABASE_URL") - .expect("DATABASE_URL must be set"); +# Database settings (if using GORM or sqlc) +DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable - tracing::info!("Connecting to database..."); - let db = Database::connect(&database_url).await.expect("Failed to connect to database"); - tracing::info!("Database connected successfully"); +# gRPC settings (if using gRPC) +GRPC_PORT=50051 - let state = AppState { db: Arc::new(db) }; +# Logging level +LOG_LEVEL=debug - rocket::build() - .manage(state) - .mount("/", routes![health]) - .attach(cors) -} -" +# JWT settings +JWT_SECRET=change-me-to-a-real-secret +CORS_ORIGIN=http://localhost:3001" , - "path": "crates/server/src/main.rs", + "path": ".env.example", }, { "content": "[exists]", - "path": "crates/server/src/meilisearch.rs", + "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "crates/server/src/observability.rs", + "path": "cmd/server/main.go", }, { "content": "[exists]", - "path": "crates/server/src/upstash_cache.rs", + "path": "go.mod", }, { "content": "[exists]", - "path": "README.md", + "path": "internal/auth/goauth.go", }, { "content": "[exists]", - "path": "rust-toolchain.toml", + "path": "internal/handlers/handlers.go", + }, + { + "content": "[exists]", + "path": "README.md", }, ], } `; -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-gorm-zap 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "README.md", - "cmd/server/main.go", - "go.mod", - "internal/database/database.go", - "internal/handlers/handlers.go", - "internal/models/models.go", -] -`; - -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-sqlc-grpc 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "README.md", - "cmd/server/main.go", - "go.mod", - "internal/database/database.go", - "internal/handlers/handlers.go", - "internal/models/models.go", - "proto/greeter.go", - "proto/greeter.pb.go", - "proto/greeter.proto", - "proto/greeter_grpc.pb.go", - "sql/queries/posts.sql", - "sql/queries/users.sql", - "sql/schema/001_schema.sql", - "sqlc.yaml", -] -`; - -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: fiber-gorm-zap 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "README.md", - "cmd/server/main.go", - "go.mod", - "internal/database/database.go", - "internal/handlers/handlers.go", - "internal/models/models.go", -] -`; - -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: chi-gorm 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-sqlalchemy-celery 1`] = ` [ ".env.example", "CLAUDE.md", "README.md", - "cmd/server/main.go", - "go.mod", - "internal/database/database.go", - "internal/handlers/handlers.go", - "internal/models/models.go", + "alembic.ini", + "migrations/env.py", + "migrations/script.py.mako", + "pyproject.toml", + "src/app/__init__.py", + "src/app/celery_app.py", + "src/app/celery_schemas.py", + "src/app/crud.py", + "src/app/database.py", + "src/app/main.py", + "src/app/models.py", + "src/app/schemas.py", + "src/app/settings.py", + "src/app/tasks.py", + "tests/__init__.py", + "tests/test_database.py", + "tests/test_main.py", ] `; -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: cli-cobra 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: django-sqlmodel-langchain 1`] = ` [ ".env.example", "CLAUDE.md", "README.md", - "cmd/cli/main.go", - "cmd/server/main.go", - "go.mod", + "alembic.ini", + "migrations/env.py", + "migrations/script.py.mako", + "pyproject.toml", + "src/app/__init__.py", + "src/app/crud.py", + "src/app/database.py", + "src/app/langchain_client.py", + "src/app/langchain_schemas.py", + "src/app/main.py", + "src/app/models.py", + "src/app/settings.py", + "tests/__init__.py", + "tests/test_database.py", + "tests/test_main.py", ] `; -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-gorm-zerolog 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-ai-multi 1`] = ` [ ".env.example", "CLAUDE.md", "README.md", - "cmd/server/main.go", - "go.mod", - "internal/database/database.go", - "internal/handlers/handlers.go", - "internal/models/models.go", + "pyproject.toml", + "src/app/__init__.py", + "src/app/anthropic_client.py", + "src/app/anthropic_schemas.py", + "src/app/main.py", + "src/app/openai_client.py", + "src/app/openai_schemas.py", + "src/app/schemas.py", + "src/app/settings.py", + "tests/__init__.py", + "tests/test_main.py", ] `; -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-sqlc-slog 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: flask-pydantic-ruff 1`] = ` [ ".env.example", "CLAUDE.md", "README.md", - "cmd/server/main.go", - "go.mod", - "internal/database/database.go", - "internal/handlers/handlers.go", - "internal/models/models.go", - "sql/queries/posts.sql", - "sql/queries/users.sql", - "sql/schema/001_schema.sql", - "sqlc.yaml", + "pyproject.toml", + "src/app/__init__.py", + "src/app/main.py", + "src/app/schemas.py", + "src/app/settings.py", + "tests/__init__.py", + "tests/test_main.py", ] `; -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: gin-casbin 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: litestar-pydantic-ruff 1`] = ` [ ".env.example", "CLAUDE.md", "README.md", - "auth_model.conf", - "auth_policy.csv", - "cmd/server/main.go", - "go.mod", - "internal/auth/goauth.go", - "internal/handlers/handlers.go", + "pyproject.toml", + "src/app/__init__.py", + "src/app/main.py", + "src/app/schemas.py", + "src/app/settings.py", + "tests/__init__.py", + "tests/test_main.py", ] `; -exports[`Template Snapshots - Go Ecosystem Go File Structure Snapshots file structure: echo-jwt-auth 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-tortoise-orm 1`] = ` [ ".env.example", "CLAUDE.md", "README.md", - "cmd/server/main.go", - "go.mod", - "internal/auth/goauth.go", - "internal/handlers/handlers.go", + "pyproject.toml", + "src/app/__init__.py", + "src/app/crud.py", + "src/app/database.py", + "src/app/main.py", + "src/app/models.py", + "tests/__init__.py", + "tests/test_database.py", + "tests/test_main.py", ] `; -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-gorm-zap 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-sqlalchemy-celery 1`] = ` { - "fileCount": 9, + "fileCount": 21, "files": [ { "content": -"# Application settings -HOST=0.0.0.0 -PORT=8080 - -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable - -# gRPC settings (if using gRPC) -GRPC_PORT=50051 - -# Logging level -LOG_LEVEL=debug -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", - }, - { - "content": "[exists]", - "path": "CLAUDE.md", - }, - { - "content": "[exists]", - "path": "cmd/server/main.go", - }, - { - "content": "[exists]", - "path": "go.mod", - }, - { - "content": "[exists]", - "path": "internal/database/database.go", - }, - { - "content": "[exists]", - "path": "internal/handlers/handlers.go", - }, - { - "content": "[exists]", - "path": "internal/models/models.go", - }, - { - "content": "[exists]", - "path": "README.md", - }, - ], -} -`; +"# Environment Configuration +# Copy this file to .env and update the values -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-sqlc-grpc 1`] = ` -{ - "fileCount": 17, - "files": [ - { - "content": -"# Application settings +# Application +DEBUG=true HOST=0.0.0.0 -PORT=8080 +PORT=8000 -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable +# Database (if using SQLAlchemy/SQLModel) +DATABASE_URL=sqlite:///./app.db + +# OpenAI (if using AI frameworks) +OPENAI_API_KEY=your-openai-api-key -# gRPC settings (if using gRPC) -GRPC_PORT=50051 +# Anthropic (if using Anthropic SDK) +ANTHROPIC_API_KEY=your-anthropic-api-key -# Logging level -LOG_LEVEL=debug +# Celery (if using task queue) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "alembic.ini", + }, { "content": "[exists]", "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "cmd/server/main.go", + "path": "migrations/env.py", }, { "content": "[exists]", - "path": "go.mod", + "path": "migrations/script.py.mako", }, { "content": "[exists]", - "path": "internal/database/database.go", + "path": "pyproject.toml", }, { "content": "[exists]", - "path": "internal/handlers/handlers.go", + "path": "README.md", }, { "content": "[exists]", - "path": "internal/models/models.go", + "path": "src/app/__init__.py", }, { "content": "[exists]", - "path": "proto/greeter_grpc.pb.go", + "path": "src/app/celery_app.py", }, { "content": "[exists]", - "path": "proto/greeter.go", + "path": "src/app/celery_schemas.py", }, { "content": "[exists]", - "path": "proto/greeter.pb.go", + "path": "src/app/crud.py", }, { "content": "[exists]", - "path": "proto/greeter.proto", + "path": "src/app/database.py", }, { "content": "[exists]", - "path": "README.md", + "path": "src/app/main.py", }, { "content": "[exists]", - "path": "sql/queries/posts.sql", + "path": "src/app/models.py", }, { "content": "[exists]", - "path": "sql/queries/users.sql", + "path": "src/app/schemas.py", }, { "content": "[exists]", - "path": "sql/schema/001_schema.sql", + "path": "src/app/settings.py", }, { "content": "[exists]", - "path": "sqlc.yaml", + "path": "src/app/tasks.py", + }, + { + "content": "[exists]", + "path": "tests/__init__.py", + }, + { + "content": "[exists]", + "path": "tests/test_database.py", + }, + { + "content": "[exists]", + "path": "tests/test_main.py", }, ], } `; -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: fiber-gorm-zap 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: django-sqlmodel-langchain 1`] = ` { - "fileCount": 9, + "fileCount": 19, "files": [ { "content": -"# Application settings +"# Environment Configuration +# Copy this file to .env and update the values + +# Application +DEBUG=true HOST=0.0.0.0 -PORT=8080 +PORT=8000 -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable +# Database (if using SQLAlchemy/SQLModel) +DATABASE_URL=sqlite:///./app.db -# gRPC settings (if using gRPC) -GRPC_PORT=50051 +# OpenAI (if using AI frameworks) +OPENAI_API_KEY=your-openai-api-key -# Logging level -LOG_LEVEL=debug +# Anthropic (if using Anthropic SDK) +ANTHROPIC_API_KEY=your-anthropic-api-key + +# Celery (if using task queue) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, + { + "content": "[exists]", + "path": "alembic.ini", + }, { "content": "[exists]", "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "cmd/server/main.go", + "path": "migrations/env.py", }, { "content": "[exists]", - "path": "go.mod", + "path": "migrations/script.py.mako", }, { "content": "[exists]", - "path": "internal/database/database.go", + "path": "pyproject.toml", }, { "content": "[exists]", - "path": "internal/handlers/handlers.go", + "path": "README.md", }, { "content": "[exists]", - "path": "internal/models/models.go", + "path": "src/app/__init__.py", }, { "content": "[exists]", - "path": "README.md", + "path": "src/app/crud.py", }, - ], -} -`; - -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: chi-gorm 1`] = ` -{ - "fileCount": 9, - "files": [ { - "content": -"# Application settings -HOST=0.0.0.0 -PORT=8080 - -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable - -# gRPC settings (if using gRPC) -GRPC_PORT=50051 - -# Logging level -LOG_LEVEL=debug -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", + "content": "[exists]", + "path": "src/app/database.py", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "src/app/langchain_client.py", }, { "content": "[exists]", - "path": "cmd/server/main.go", + "path": "src/app/langchain_schemas.py", }, { "content": "[exists]", - "path": "go.mod", + "path": "src/app/main.py", }, { "content": "[exists]", - "path": "internal/database/database.go", + "path": "src/app/models.py", }, { "content": "[exists]", - "path": "internal/handlers/handlers.go", + "path": "src/app/settings.py", }, { "content": "[exists]", - "path": "internal/models/models.go", + "path": "tests/__init__.py", }, { "content": "[exists]", - "path": "README.md", + "path": "tests/test_database.py", + }, + { + "content": "[exists]", + "path": "tests/test_main.py", }, ], } `; -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: cli-cobra 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-ai-multi 1`] = ` { - "fileCount": 7, + "fileCount": 15, "files": [ { "content": -"# Application settings +"# Environment Configuration +# Copy this file to .env and update the values + +# Application +DEBUG=true HOST=0.0.0.0 -PORT=8080 +PORT=8000 -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable +# Database (if using SQLAlchemy/SQLModel) +DATABASE_URL=sqlite:///./app.db -# gRPC settings (if using gRPC) -GRPC_PORT=50051 +# OpenAI (if using AI frameworks) +OPENAI_API_KEY=your-openai-api-key -# Logging level -LOG_LEVEL=debug +# Anthropic (if using Anthropic SDK) +ANTHROPIC_API_KEY=your-anthropic-api-key + +# Celery (if using task queue) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", @@ -14647,231 +15974,222 @@ CORS_ORIGIN=http://localhost:3001" }, { "content": "[exists]", - "path": "cmd/cli/main.go", + "path": "pyproject.toml", }, { "content": "[exists]", - "path": "cmd/server/main.go", + "path": "README.md", }, { "content": "[exists]", - "path": "go.mod", + "path": "src/app/__init__.py", }, { "content": "[exists]", - "path": "README.md", + "path": "src/app/anthropic_client.py", }, - ], -} -`; - -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-gorm-zerolog 1`] = ` -{ - "fileCount": 9, - "files": [ { - "content": -"# Application settings -HOST=0.0.0.0 -PORT=8080 - -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable - -# gRPC settings (if using gRPC) -GRPC_PORT=50051 - -# Logging level -LOG_LEVEL=debug -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", + "content": "[exists]", + "path": "src/app/anthropic_schemas.py", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "src/app/main.py", }, { "content": "[exists]", - "path": "cmd/server/main.go", + "path": "src/app/openai_client.py", }, { "content": "[exists]", - "path": "go.mod", + "path": "src/app/openai_schemas.py", }, { "content": "[exists]", - "path": "internal/database/database.go", + "path": "src/app/schemas.py", }, { "content": "[exists]", - "path": "internal/handlers/handlers.go", + "path": "src/app/settings.py", }, { "content": "[exists]", - "path": "internal/models/models.go", + "path": "tests/__init__.py", }, { "content": "[exists]", - "path": "README.md", + "path": "tests/test_main.py", }, ], } `; -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-sqlc-slog 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: flask-pydantic-ruff 1`] = ` { - "fileCount": 13, + "fileCount": 11, "files": [ { "content": -"# Application settings +"# Environment Configuration +# Copy this file to .env and update the values + +# Application +DEBUG=true HOST=0.0.0.0 -PORT=8080 +PORT=8000 -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable +# Database (if using SQLAlchemy/SQLModel) +DATABASE_URL=sqlite:///./app.db -# gRPC settings (if using gRPC) -GRPC_PORT=50051 +# OpenAI (if using AI frameworks) +OPENAI_API_KEY=your-openai-api-key -# Logging level -LOG_LEVEL=debug +# Anthropic (if using Anthropic SDK) +ANTHROPIC_API_KEY=your-anthropic-api-key + +# Celery (if using task queue) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", - }, - { - "content": "[exists]", - "path": "CLAUDE.md", - }, - { - "content": "[exists]", - "path": "cmd/server/main.go", +, + "path": ".env.example", }, { "content": "[exists]", - "path": "go.mod", + "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "internal/database/database.go", + "path": "pyproject.toml", }, { "content": "[exists]", - "path": "internal/handlers/handlers.go", + "path": "README.md", }, { "content": "[exists]", - "path": "internal/models/models.go", + "path": "src/app/__init__.py", }, { "content": "[exists]", - "path": "README.md", + "path": "src/app/main.py", }, { "content": "[exists]", - "path": "sql/queries/posts.sql", + "path": "src/app/schemas.py", }, { "content": "[exists]", - "path": "sql/queries/users.sql", + "path": "src/app/settings.py", }, { "content": "[exists]", - "path": "sql/schema/001_schema.sql", + "path": "tests/__init__.py", }, { "content": "[exists]", - "path": "sqlc.yaml", + "path": "tests/test_main.py", }, ], } `; -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: gin-casbin 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: litestar-pydantic-ruff 1`] = ` { - "fileCount": 10, + "fileCount": 11, "files": [ { "content": -"# Application settings +"# Environment Configuration +# Copy this file to .env and update the values + +# Application +DEBUG=true HOST=0.0.0.0 -PORT=8080 +PORT=8000 -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable +# Database (if using SQLAlchemy/SQLModel) +DATABASE_URL=sqlite:///./app.db -# gRPC settings (if using gRPC) -GRPC_PORT=50051 +# OpenAI (if using AI frameworks) +OPENAI_API_KEY=your-openai-api-key -# Logging level -LOG_LEVEL=debug +# Anthropic (if using Anthropic SDK) +ANTHROPIC_API_KEY=your-anthropic-api-key -# Casbin settings -CASBIN_MODEL=auth_model.conf -CASBIN_POLICY=auth_policy.csv +# Celery (if using task queue) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", }, { "content": "[exists]", - "path": "auth_model.conf", + "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "auth_policy.csv", + "path": "pyproject.toml", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "README.md", }, { "content": "[exists]", - "path": "cmd/server/main.go", + "path": "src/app/__init__.py", }, { "content": "[exists]", - "path": "go.mod", + "path": "src/app/main.py", }, { "content": "[exists]", - "path": "internal/auth/goauth.go", + "path": "src/app/schemas.py", }, { "content": "[exists]", - "path": "internal/handlers/handlers.go", + "path": "src/app/settings.py", }, { "content": "[exists]", - "path": "README.md", + "path": "tests/__init__.py", + }, + { + "content": "[exists]", + "path": "tests/test_main.py", }, ], } `; -exports[`Template Snapshots - Go Ecosystem Go Key File Content Snapshots key files: echo-jwt-auth 1`] = ` +exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-tortoise-orm 1`] = ` { - "fileCount": 8, + "fileCount": 13, "files": [ { "content": -"# Application settings +"# Environment Configuration +# Copy this file to .env and update the values + +# Application +DEBUG=true HOST=0.0.0.0 -PORT=8080 +PORT=8000 -# Database settings (if using GORM or sqlc) -DATABASE_URL=postgres://user:password@localhost:5432/dbname?sslmode=disable +# Database (if using SQLAlchemy/SQLModel) +DATABASE_URL=sqlite:///./app.db -# gRPC settings (if using gRPC) -GRPC_PORT=50051 +# OpenAI (if using AI frameworks) +OPENAI_API_KEY=your-openai-api-key -# Logging level -LOG_LEVEL=debug +# Anthropic (if using Anthropic SDK) +ANTHROPIC_API_KEY=your-anthropic-api-key -# JWT settings -JWT_SECRET=change-me-to-a-real-secret +# Celery (if using task queue) +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 CORS_ORIGIN=http://localhost:3001" , "path": ".env.example", @@ -14882,380 +16200,296 @@ CORS_ORIGIN=http://localhost:3001" }, { "content": "[exists]", - "path": "cmd/server/main.go", + "path": "pyproject.toml", }, { "content": "[exists]", - "path": "go.mod", + "path": "README.md", }, { "content": "[exists]", - "path": "internal/auth/goauth.go", + "path": "src/app/__init__.py", }, { "content": "[exists]", - "path": "internal/handlers/handlers.go", + "path": "src/app/crud.py", }, { "content": "[exists]", - "path": "README.md", + "path": "src/app/database.py", + }, + { + "content": "[exists]", + "path": "src/app/main.py", + }, + { + "content": "[exists]", + "path": "src/app/models.py", + }, + { + "content": "[exists]", + "path": "tests/__init__.py", + }, + { + "content": "[exists]", + "path": "tests/test_database.py", + }, + { + "content": "[exists]", + "path": "tests/test_main.py", }, ], } `; -exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-sqlalchemy-celery 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "README.md", - "alembic.ini", - "migrations/env.py", - "migrations/script.py.mako", - "pyproject.toml", - "src/app/__init__.py", - "src/app/celery_app.py", - "src/app/celery_schemas.py", - "src/app/crud.py", - "src/app/database.py", - "src/app/main.py", - "src/app/models.py", - "src/app/schemas.py", - "src/app/settings.py", - "src/app/tasks.py", - "tests/__init__.py", - "tests/test_database.py", - "tests/test_main.py", -] -`; - -exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: django-sqlmodel-langchain 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "README.md", - "alembic.ini", - "migrations/env.py", - "migrations/script.py.mako", - "pyproject.toml", - "src/app/__init__.py", - "src/app/crud.py", - "src/app/database.py", - "src/app/langchain_client.py", - "src/app/langchain_schemas.py", - "src/app/main.py", - "src/app/models.py", - "src/app/settings.py", - "tests/__init__.py", - "tests/test_database.py", - "tests/test_main.py", -] -`; - -exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: fastapi-ai-multi 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "README.md", - "pyproject.toml", - "src/app/__init__.py", - "src/app/anthropic_client.py", - "src/app/anthropic_schemas.py", - "src/app/main.py", - "src/app/openai_client.py", - "src/app/openai_schemas.py", - "src/app/schemas.py", - "src/app/settings.py", - "tests/__init__.py", - "tests/test_main.py", -] -`; - -exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: flask-pydantic-ruff 1`] = ` -[ - ".env.example", - "CLAUDE.md", - "README.md", - "pyproject.toml", - "src/app/__init__.py", - "src/app/main.py", - "src/app/schemas.py", - "src/app/settings.py", - "tests/__init__.py", - "tests/test_main.py", -] -`; - -exports[`Template Snapshots - Python Ecosystem Python File Structure Snapshots file structure: litestar-pydantic-ruff 1`] = ` +exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots file structure: phoenix-ecto-rest 1`] = ` [ ".env.example", "CLAUDE.md", "README.md", - "pyproject.toml", - "src/app/__init__.py", - "src/app/main.py", - "src/app/schemas.py", - "src/app/settings.py", - "tests/__init__.py", - "tests/test_main.py", + "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_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/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 - Python Ecosystem Python File Structure Snapshots file structure: fastapi-tortoise-orm 1`] = ` +exports[`Template Snapshots - Elixir Ecosystem Elixir File Structure Snapshots file structure: phoenix-liveview-full 1`] = ` [ ".env.example", "CLAUDE.md", + "Dockerfile", "README.md", - "pyproject.toml", - "src/app/__init__.py", - "src/app/crud.py", - "src/app/database.py", - "src/app/main.py", - "src/app/models.py", - "tests/__init__.py", - "tests/test_database.py", - "tests/test_main.py", + "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/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/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", + "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 - Python Ecosystem Python Key File Content Snapshots key files: fastapi-sqlalchemy-celery 1`] = ` -{ - "fileCount": 21, - "files": [ - { - "content": -"# Environment Configuration -# Copy this file to .env and update the values - -# Application -DEBUG=true -HOST=0.0.0.0 -PORT=8000 - -# Database (if using SQLAlchemy/SQLModel) -DATABASE_URL=sqlite:///./app.db - -# OpenAI (if using AI frameworks) -OPENAI_API_KEY=your-openai-api-key - -# Anthropic (if using Anthropic SDK) -ANTHROPIC_API_KEY=your-anthropic-api-key - -# Celery (if using task queue) -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-ecto-rest 1`] = ` +{ + "fileCount": 33, + "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": "alembic.ini", - }, { "content": "[exists]", "path": "CLAUDE.md", }, { "content": "[exists]", - "path": "migrations/env.py", - }, - { - "content": "[exists]", - "path": "migrations/script.py.mako", - }, - { - "content": "[exists]", - "path": "pyproject.toml", - }, - { - "content": "[exists]", - "path": "README.md", - }, - { - "content": "[exists]", - "path": "src/app/__init__.py", + "path": "config/config.exs", }, { "content": "[exists]", - "path": "src/app/celery_app.py", + "path": "config/dev.exs", }, { "content": "[exists]", - "path": "src/app/celery_schemas.py", + "path": "config/runtime.exs", }, { "content": "[exists]", - "path": "src/app/crud.py", + "path": "config/test.exs", }, { "content": "[exists]", - "path": "src/app/database.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web.ex", }, { "content": "[exists]", - "path": "src/app/main.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/room_channel.ex", }, { "content": "[exists]", - "path": "src/app/models.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/channels/user_socket.ex", }, { "content": "[exists]", - "path": "src/app/schemas.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/components/layouts.ex", }, { "content": "[exists]", - "path": "src/app/settings.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/components/layouts/root.html.heex", }, { "content": "[exists]", - "path": "src/app/tasks.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/error_html.ex", }, { "content": "[exists]", - "path": "tests/__init__.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/error_json.ex", }, { "content": "[exists]", - "path": "tests/test_database.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/health_controller.ex", }, { "content": "[exists]", - "path": "tests/test_main.py", - }, - ], -} -`; - -exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: django-sqlmodel-langchain 1`] = ` -{ - "fileCount": 19, - "files": [ - { - "content": -"# Environment Configuration -# Copy this file to .env and update the values - -# Application -DEBUG=true -HOST=0.0.0.0 -PORT=8000 - -# Database (if using SQLAlchemy/SQLModel) -DATABASE_URL=sqlite:///./app.db - -# OpenAI (if using AI frameworks) -OPENAI_API_KEY=your-openai-api-key - -# Anthropic (if using Anthropic SDK) -ANTHROPIC_API_KEY=your-anthropic-api-key - -# Celery (if using task queue) -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/0 -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/item_controller.ex", }, { "content": "[exists]", - "path": "alembic.ini", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/controllers/page_controller.ex", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/endpoint.ex", }, { "content": "[exists]", - "path": "migrations/env.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/router.ex", }, { "content": "[exists]", - "path": "migrations/script.py.mako", + "path": "lib/snapshot_elixir_phoenix_ecto_rest_web/telemetry.ex", }, { "content": "[exists]", - "path": "pyproject.toml", + "path": "lib/snapshot_elixir_phoenix_ecto_rest.ex", }, { "content": "[exists]", - "path": "README.md", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/application.ex", }, { "content": "[exists]", - "path": "src/app/__init__.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/catalog.ex", }, { "content": "[exists]", - "path": "src/app/crud.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/catalog/item.ex", }, { "content": "[exists]", - "path": "src/app/database.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/http_client.ex", }, { "content": "[exists]", - "path": "src/app/langchain_client.py", + "path": "lib/snapshot_elixir_phoenix_ecto_rest/repo.ex", }, { "content": "[exists]", - "path": "src/app/langchain_schemas.py", + "path": "mix.exs", }, { "content": "[exists]", - "path": "src/app/main.py", + "path": "priv/repo/migrations/20260101000000_create_items.exs", }, { "content": "[exists]", - "path": "src/app/models.py", + "path": "priv/repo/seeds.exs", }, { "content": "[exists]", - "path": "src/app/settings.py", + "path": "README.md", }, { "content": "[exists]", - "path": "tests/__init__.py", + "path": "test/snapshot_elixir_phoenix_ecto_rest_web/controllers/health_controller_test.exs", }, { "content": "[exists]", - "path": "tests/test_database.py", + "path": "test/support/conn_case.ex", }, { "content": "[exists]", - "path": "tests/test_main.py", + "path": "test/test_helper.exs", }, ], } `; -exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-ai-multi 1`] = ` +exports[`Template Snapshots - Elixir Ecosystem Elixir Key File Content Snapshots key files: phoenix-liveview-full 1`] = ` { - "fileCount": 15, + "fileCount": 45, "files": [ { "content": -"# Environment Configuration -# Copy this file to .env and update the values - -# Application -DEBUG=true -HOST=0.0.0.0 -PORT=8000 - -# Database (if using SQLAlchemy/SQLModel) -DATABASE_URL=sqlite:///./app.db - -# OpenAI (if using AI frameworks) -OPENAI_API_KEY=your-openai-api-key - -# Anthropic (if using Anthropic SDK) -ANTHROPIC_API_KEY=your-anthropic-api-key - -# Celery (if using task queue) -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/0 +"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", @@ -15266,269 +16500,171 @@ CORS_ORIGIN=http://localhost:3001" }, { "content": "[exists]", - "path": "pyproject.toml", + "path": "config/config.exs", }, { "content": "[exists]", - "path": "README.md", + "path": "config/dev.exs", }, { "content": "[exists]", - "path": "src/app/__init__.py", + "path": "config/runtime.exs", }, { "content": "[exists]", - "path": "src/app/anthropic_client.py", + "path": "config/test.exs", }, { "content": "[exists]", - "path": "src/app/anthropic_schemas.py", + "path": "Dockerfile", }, { "content": "[exists]", - "path": "src/app/main.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web.ex", }, { "content": "[exists]", - "path": "src/app/openai_client.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/channels/presence.ex", }, { "content": "[exists]", - "path": "src/app/openai_schemas.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/channels/room_channel.ex", }, { "content": "[exists]", - "path": "src/app/schemas.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/channels/user_socket.ex", }, { "content": "[exists]", - "path": "src/app/settings.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/components/layouts.ex", }, { "content": "[exists]", - "path": "tests/__init__.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/components/layouts/root.html.heex", }, { "content": "[exists]", - "path": "tests/test_main.py", - }, - ], -} -`; - -exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: flask-pydantic-ruff 1`] = ` -{ - "fileCount": 11, - "files": [ - { - "content": -"# Environment Configuration -# Copy this file to .env and update the values - -# Application -DEBUG=true -HOST=0.0.0.0 -PORT=8000 - -# Database (if using SQLAlchemy/SQLModel) -DATABASE_URL=sqlite:///./app.db - -# OpenAI (if using AI frameworks) -OPENAI_API_KEY=your-openai-api-key - -# Anthropic (if using Anthropic SDK) -ANTHROPIC_API_KEY=your-anthropic-api-key - -# Celery (if using task queue) -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/0 -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/error_html.ex", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/error_json.ex", }, { "content": "[exists]", - "path": "pyproject.toml", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/health_controller.ex", }, { "content": "[exists]", - "path": "README.md", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/item_controller.ex", }, { "content": "[exists]", - "path": "src/app/__init__.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/page_controller.ex", }, { "content": "[exists]", - "path": "src/app/main.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/controllers/user_session_controller.ex", }, { "content": "[exists]", - "path": "src/app/schemas.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/endpoint.ex", }, { "content": "[exists]", - "path": "src/app/settings.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/resolvers/catalog.ex", }, { "content": "[exists]", - "path": "tests/__init__.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/graphql/schema.ex", }, { "content": "[exists]", - "path": "tests/test_main.py", - }, - ], -} -`; - -exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: litestar-pydantic-ruff 1`] = ` -{ - "fileCount": 11, - "files": [ - { - "content": -"# Environment Configuration -# Copy this file to .env and update the values - -# Application -DEBUG=true -HOST=0.0.0.0 -PORT=8000 - -# Database (if using SQLAlchemy/SQLModel) -DATABASE_URL=sqlite:///./app.db - -# OpenAI (if using AI frameworks) -OPENAI_API_KEY=your-openai-api-key - -# Anthropic (if using Anthropic SDK) -ANTHROPIC_API_KEY=your-anthropic-api-key - -# Celery (if using task queue) -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/0 -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/live/item_live/index.ex", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/router.ex", }, { "content": "[exists]", - "path": "pyproject.toml", + "path": "lib/snapshot_elixir_phoenix_liveview_full_web/telemetry.ex", }, { "content": "[exists]", - "path": "README.md", + "path": "lib/snapshot_elixir_phoenix_liveview_full.ex", }, { "content": "[exists]", - "path": "src/app/__init__.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full/accounts.ex", }, { "content": "[exists]", - "path": "src/app/main.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full/accounts/user.ex", }, { "content": "[exists]", - "path": "src/app/schemas.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full/application.ex", }, { "content": "[exists]", - "path": "src/app/settings.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full/catalog.ex", }, { "content": "[exists]", - "path": "tests/__init__.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full/catalog/item.ex", }, { "content": "[exists]", - "path": "tests/test_main.py", + "path": "lib/snapshot_elixir_phoenix_liveview_full/http_client.ex", }, - ], -} -`; - -exports[`Template Snapshots - Python Ecosystem Python Key File Content Snapshots key files: fastapi-tortoise-orm 1`] = ` -{ - "fileCount": 13, - "files": [ { - "content": -"# Environment Configuration -# Copy this file to .env and update the values - -# Application -DEBUG=true -HOST=0.0.0.0 -PORT=8000 - -# Database (if using SQLAlchemy/SQLModel) -DATABASE_URL=sqlite:///./app.db - -# OpenAI (if using AI frameworks) -OPENAI_API_KEY=your-openai-api-key - -# Anthropic (if using Anthropic SDK) -ANTHROPIC_API_KEY=your-anthropic-api-key - -# Celery (if using task queue) -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/0 -CORS_ORIGIN=http://localhost:3001" -, - "path": ".env.example", + "content": "[exists]", + "path": "lib/snapshot_elixir_phoenix_liveview_full/mailer.ex", }, { "content": "[exists]", - "path": "CLAUDE.md", + "path": "lib/snapshot_elixir_phoenix_liveview_full/repo.ex", }, { "content": "[exists]", - "path": "pyproject.toml", + "path": "lib/snapshot_elixir_phoenix_liveview_full/workers/sample_worker.ex", }, { "content": "[exists]", - "path": "README.md", + "path": "mix.exs", }, { "content": "[exists]", - "path": "src/app/__init__.py", + "path": "priv/repo/migrations/20260101000000_create_items.exs", }, { "content": "[exists]", - "path": "src/app/crud.py", + "path": "priv/repo/migrations/20260101000001_create_users.exs", }, { "content": "[exists]", - "path": "src/app/database.py", + "path": "priv/repo/migrations/20260101000002_add_oban_jobs.exs", }, { "content": "[exists]", - "path": "src/app/main.py", + "path": "priv/repo/seeds.exs", }, { "content": "[exists]", - "path": "src/app/models.py", + "path": "README.md", }, { "content": "[exists]", - "path": "tests/__init__.py", + "path": "test/snapshot_elixir_phoenix_liveview_full_web/controllers/health_controller_test.exs", }, { "content": "[exists]", - "path": "tests/test_database.py", + "path": "test/support/conn_case.ex", }, { "content": "[exists]", - "path": "tests/test_main.py", + "path": "test/test_helper.exs", }, ], } 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/apps/cli/test/cli-builder-sync.test.ts b/apps/cli/test/cli-builder-sync.test.ts index d3fb7274b..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,6 +257,32 @@ describe("CLI prompts vs schemas parity", () => { expect(goResolution.options.map((option) => option.value)).toContain("go-better-auth"); }); + 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(searchResolution.shouldPrompt).toBe(false); + expect(searchResolution.autoValue).toBe("none"); + }); + it("keeps the Rust libraries prompt default aligned with CLI defaults", () => { const resolution = resolveRustLibrariesPrompt(); 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/frontend.test.ts b/apps/cli/test/frontend.test.ts index 49834b67d..63c3ad360 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,9 +476,26 @@ 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('"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(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(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(); expect(readme).toContain("http://localhost:5173"); expect(await viteConfig.exists()).toBe(true); expect(await clientEntry.exists()).toBe(true); @@ -487,32 +505,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/cli/test/go-language.test.ts b/apps/cli/test/go-language.test.ts index e99ad7417..220cf85fe 100644 --- a/apps/cli/test/go-language.test.ts +++ b/apps/cli/test/go-language.test.ts @@ -66,13 +66,14 @@ 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); + expect(ECOSYSTEMS).toContain("react-native"); + expect(ECOSYSTEMS).toContain("elixir"); }); it("should include GoBetterAuth in auth options", () => { @@ -576,6 +577,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/java-ecosystem.test.ts b/apps/cli/test/java-ecosystem.test.ts index c768691d0..25908b435 100644 --- a/apps/cli/test/java-ecosystem.test.ts +++ b/apps/cli/test/java-ecosystem.test.ts @@ -150,7 +150,8 @@ describe("Java Ecosystem", () => { describe("Schema Definitions", () => { it("should expose java as a valid ecosystem", () => { expect(ECOSYSTEMS).toContain("java"); - expect(ECOSYSTEMS).toHaveLength(5); + expect(ECOSYSTEMS).toContain("react-native"); + expect(ECOSYSTEMS).toContain("elixir"); }); it("should expose scaffolded Java web framework values", () => { @@ -478,6 +479,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/apps/cli/test/mobile.test.ts b/apps/cli/test/mobile.test.ts new file mode 100644 index 000000000..563b9d76d --- /dev/null +++ b/apps/cli/test/mobile.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; + +import { create, 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("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", + 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.devDependencies["babel-preset-expo"]).toBe("^55.0.0"); + 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"); + }); + + 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("<NavigationContainer>"); + 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/apps/cli/test/python-language.test.ts b/apps/cli/test/python-language.test.ts index b23db4754..0f0cf07a3 100644 --- a/apps/cli/test/python-language.test.ts +++ b/apps/cli/test/python-language.test.ts @@ -76,13 +76,14 @@ 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); + expect(ECOSYSTEMS).toContain("react-native"); + expect(ECOSYSTEMS).toContain("elixir"); }); 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..0a1649273 100644 --- a/apps/cli/test/rust-ecosystem.test.ts +++ b/apps/cli/test/rust-ecosystem.test.ts @@ -69,13 +69,14 @@ 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); + expect(ECOSYSTEMS).toContain("react-native"); + expect(ECOSYSTEMS).toContain("elixir"); }); it("should have rust web framework options", () => { diff --git a/apps/cli/test/template-snapshots.test.ts b/apps/cli/test/template-snapshots.test.ts index ddcb87c5e..cd020754a 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<ProjectConfig> = { 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", () => { @@ -689,3 +714,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/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<TestResult> { "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<TestResult> { 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[], }; diff --git a/apps/cli/test/virtual-generator-regressions.test.ts b/apps/cli/test/virtual-generator-regressions.test.ts index 20e7ec2b5..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<Awaited<ReturnType<typeof createVirtual>>["tree"]>, @@ -24,6 +26,23 @@ function readJsonFromTree( return undefined; } +function readTextFromTree( + tree: NonNullable<Awaited<ReturnType<typeof createVirtual>>["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 +130,177 @@ 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("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("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", + 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("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(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", + 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/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/content/docs/cli/create.mdx b/apps/web/content/docs/cli/create.mdx index d660cd871..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` | @@ -137,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/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/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..6b26f5f8d 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", "elixir"] } 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 2648fef89..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,7 +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` | | 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` | 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 36c8fd648..82662c536 100644 --- a/apps/web/content/docs/reference/options/typescript.mdx +++ b/apps/web/content/docs/reference/options/typescript.mdx @@ -16,7 +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` | | 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 +27,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. +React Native and Expo options live on the [React Native Options](/docs/reference/options/react-native/) page. + ## 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/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/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<string, Set<string>>, owner: string, src: string) { + const value = src.trim(); + if (!value) return; + + const owners = targets.get(value) ?? new Set<string>(); + owners.add(owner); + targets.set(value, owners); +} + +function addConfigTarget(targets: Map<string, Set<string>>, 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<string, Set<string>>, + 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<number> { + 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<string, Set<string>>(); + + 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/components/docs/mdx/compatibility-matrix.tsx b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx index 7094f62bc..04eb84096 100644 --- a/apps/web/src/components/docs/mdx/compatibility-matrix.tsx +++ b/apps/web/src/components/docs/mdx/compatibility-matrix.tsx @@ -23,10 +23,12 @@ 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" }, { id: "java", label: "Java" }, + { id: "elixir", label: "Elixir" }, ]; const TYPESCRIPT_CATEGORIES: SelectCategory[] = [ @@ -69,6 +71,18 @@ const TYPESCRIPT_CATEGORIES: SelectCategory[] = [ const ECOSYSTEM_CATEGORIES: Record<Ecosystem, SelectCategory[]> = { typescript: TYPESCRIPT_CATEGORIES, + "react-native": [ + "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + "auth", + "packageManager", + ], rust: [ "rustWebFramework", "rustFrontend", @@ -133,6 +147,25 @@ const ECOSYSTEM_CATEGORIES: Record<Ecosystem, SelectCategory[]> = { "packageManager", "versionChannel", ], + elixir: [ + "elixirWebFramework", + "elixirOrm", + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirJobs", + "elixirValidation", + "elixirHttp", + "elixirJson", + "elixirEmail", + "elixirCaching", + "elixirObservability", + "elixirTesting", + "elixirQuality", + "elixirDeploy", + "packageManager", + "versionChannel", + ], }; const BASELINE_CONTROLS: Record<Ecosystem, BaselineControl[]> = { @@ -147,6 +180,13 @@ const BASELINE_CONTROLS: Record<Ecosystem, BaselineControl[]> = { { 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" }, @@ -176,6 +216,13 @@ const BASELINE_CONTROLS: Record<Ecosystem, BaselineControl[]> = { { 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<SelectCategory>([ 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({ <ChevronRight className={cn( "h-5 w-5 shrink-0 transition-colors duration-200", - isOpen - ? "text-primary" - : "text-muted-foreground/40 group-hover:text-primary", + isOpen ? "text-primary" : "text-muted-foreground/40 group-hover:text-primary", )} /> </motion.div> @@ -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} </h2> {subtitle && ( - <p className="mt-1 text-sm text-muted-foreground sm:text-base"> - {subtitle} - </p> + <p className="mt-1 text-sm text-muted-foreground sm:text-base">{subtitle}</p> )} </div> </button> @@ -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" > - <div className="pb-8 sm:pb-12"> - {children} - </div> + <div className="pb-8 sm:pb-12">{children}</div> </motion.section> )} </AnimatePresence> diff --git a/apps/web/src/components/home/combinations-section.tsx b/apps/web/src/components/home/combinations-section.tsx index 29074eecc..96900c03d 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", ]; @@ -29,7 +29,7 @@ export default function CombinationsSection() { <div className="px-4 py-20 sm:px-8 sm:py-28"> <div className="grid grid-cols-12 items-end gap-x-4 gap-y-6"> <div className="col-span-12 sm:col-span-6"> - <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#bef264]"> + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#C6E853]"> ✦ combinatorics </p> <h2 @@ -68,7 +68,7 @@ export default function CombinationsSection() { × 10 </span> <span - className="font-mono font-bold leading-none text-black dark:text-[#bef264]" + className="font-mono font-bold leading-none text-black dark:text-[#C6E853]" style={{ fontSize: "clamp(1.5rem, 4vw, 3rem)" }} > {totalScientific.exponent} @@ -101,7 +101,7 @@ export default function CombinationsSection() { <sup>{yearsAtOneMillisecondScientific.exponent}</sup> years </span>{" "} <span className="text-muted-foreground">— that’s</span>{" "} - <span className="font-mono font-semibold text-lime-700"> + <span className="font-mono font-semibold text-lime-800 dark:text-[#C6E853]"> {universeLifetimesScientific.mantissa} × 10 <sup>{universeLifetimesScientific.exponent}</sup> universe lifetimes </span> 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" /> <div className="min-w-0 flex-1"> <span className="block truncate font-medium text-foreground">{contributor.name}</span> @@ -74,7 +74,7 @@ export default function ContributorsSection() { <div className="px-4 py-20 sm:px-8 sm:py-28"> <div className="grid grid-cols-12 gap-x-4 gap-y-6"> <div className="col-span-12 sm:col-span-7"> - <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#bef264]"> + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#C6E853]"> ✦ contributors </p> <h2 diff --git a/apps/web/src/components/home/features-section.tsx b/apps/web/src/components/home/features-section.tsx index 22c07c007..b94f7c1ae 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<Layer> = [ "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", }, { @@ -76,8 +77,8 @@ export default function FeaturesSection() { <div className="relative overflow-hidden border-b border-border"> <div className="grid grid-cols-12 gap-x-6 gap-y-10 px-4 py-20 sm:px-8 sm:py-24"> <div className="col-span-12 lg:col-span-7"> - <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#bef264]"> - ✦ five ecosystems + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#C6E853]"> + ✦ seven ecosystems </p> <h2 className="mt-4 max-w-[24ch] text-balance font-mono font-bold tracking-[-0.045em]" @@ -86,12 +87,11 @@ export default function FeaturesSection() { lineHeight: 0.94, }} > - Not just TypeScript.{" "} - <span className="italic text-muted-foreground">Everything.</span> + Not just TypeScript. <span className="italic text-muted-foreground">Everything.</span> </h2> <p className="mt-8 max-w-md text-pretty text-base text-muted-foreground sm:text-lg"> - 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, Elixir — one CLI scaffolds + production-ready apps across all seven. Pick your ecosystem, pick your stack. </p> </div> @@ -188,7 +188,7 @@ function LayerRow({ layer, index }: { layer: Layer; index: number }) { /> </motion.div> <div - className="mt-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#bef264]" + className="mt-2 font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#C6E853]" style={{ direction: "ltr" }} > ✦ {String(index + 1).padStart(2, "0")} @@ -229,9 +229,7 @@ function LayerRow({ layer, index }: { layer: Layer; index: number }) { className="flex flex-col items-center gap-2" > <TechIcon techId={opt.id} name={opt.name} className="size-12 sm:size-14" /> - <span className="font-mono text-xs font-medium text-foreground"> - {opt.name} - </span> + <span className="font-mono text-xs font-medium text-foreground">{opt.name}</span> </motion.div> ) : ( <motion.span @@ -265,12 +263,12 @@ function TotalBlock() { <ContainerScroll className="px-4 py-12 sm:px-8 sm:py-16"> <div className="grid grid-cols-12 items-baseline gap-x-4 gap-y-4"> <div className="col-span-12 sm:col-span-4 lg:col-span-3"> - <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-lime-300"> + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-[#C6E853]"> ✦ total </p> <p className="mt-2 max-w-[26ch] text-pretty text-sm text-background/70"> - 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. </p> </div> <div className="col-span-12 sm:col-span-8 lg:col-span-9"> @@ -286,12 +284,12 @@ function TotalBlock() { transformTiming={{ duration: 1100, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" }} /> </span> - <span className="text-lime-300" style={{ fontSize: "clamp(2rem, 6vw, 5rem)" }}> + <span className="text-[#C6E853]" style={{ fontSize: "clamp(2rem, 6vw, 5rem)" }}> ✦ </span> </motion.div> <p className="mt-3 font-mono text-[11px] uppercase tracking-[0.22em] text-background/70"> - options across 5 ecosystems · ts · rust · go · python · java + options across 7 ecosystems · ts · rn · rust · go · python · java · elixir </p> </div> </div> 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<PM, string> = { 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() { )} > <div className="flex items-baseline justify-between"> - <span - className={cn( - "font-mono text-[11px] uppercase tracking-[0.22em]", - ACCENT_TEXT, - )} - > + <span className={cn("font-mono text-[11px] uppercase tracking-[0.22em]", ACCENT_TEXT)}> ✦ install </span> <span @@ -80,11 +75,7 @@ export default function HeroSection() { "dark:border-[#1f1f1f] dark:bg-[#111111]", )} > - <div - className={cn( - "flex border-b border-[#e5e5e5] dark:border-[#1f1f1f]", - )} - > + <div className={cn("flex border-b border-[#e5e5e5] dark:border-[#1f1f1f]")}> {PMS.map((p) => ( <button key={p} @@ -94,7 +85,7 @@ export default function HeroSection() { "flex cursor-pointer items-center gap-1.5 border-r border-[#e5e5e5] px-3 py-2 text-xs font-medium transition-colors sm:gap-2 sm:px-4", "dark:border-[#1f1f1f]", pm === p - ? "bg-[#bef264] text-[#0a0a0a]" + ? "bg-[#C6E853] text-[#0a0a0a]" : "bg-transparent text-[#3f6212] dark:text-[#a3a3a3]", )} > @@ -113,9 +104,7 @@ export default function HeroSection() { aria-label="Copy command" className={cn( "flex size-8 cursor-pointer items-center justify-center rounded-md bg-transparent transition-colors active:translate-y-[1px]", - copied - ? "text-black dark:text-[#bef264]" - : "text-[#3f6212] dark:text-[#a3a3a3]", + copied ? "text-black dark:text-[#C6E853]" : "text-[#3f6212] dark:text-[#a3a3a3]", )} > {copied ? <Check className="size-4" /> : <Copy className="size-4" />} @@ -178,9 +167,8 @@ export default function HeroSection() { "dark:text-[#a3a3a3]", )} > - A CLI that scaffolds production-ready fullstack apps across five language - ecosystems. Pick your stack — frontend, database, auth, payments, AI — and run - one command. + A CLI that scaffolds production-ready fullstack apps across five language ecosystems. Pick + your stack — frontend, database, auth, payments, AI — and run one command. </motion.p> <motion.div @@ -192,7 +180,7 @@ export default function HeroSection() { <Link to="/new" search={{ view: "command", file: "" }} - className="group inline-flex items-center gap-1.5 rounded-md bg-[#bef264] px-5 py-2.5 text-sm font-semibold text-[#0a0a0a] transition-all hover:gap-2.5" + className="group inline-flex items-center gap-1.5 rounded-md bg-[#C6E853] px-5 py-2.5 text-sm font-semibold text-[#0a0a0a] transition-all hover:gap-2.5" > Open the builder <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" /> diff --git a/apps/web/src/components/home/testimonials-data.ts b/apps/web/src/components/home/testimonials-data.ts index ad641c3a1..5fa2c1b44 100644 --- a/apps/web/src/components/home/testimonials-data.ts +++ b/apps/web/src/components/home/testimonials-data.ts @@ -101,8 +101,7 @@ export const ROW_2: Testimonial[] = [ name: "Phlisg", avatar: "https://media.daily.dev/image/upload/s--FN_ynnjb--/f_auto/v1755006303/avatars/avatar_Y1klcvmY0LiikjVFthCLU", - comment: - "Great project! Would love to see more frameworks proposed. Awesome work!", + comment: "Great project! Would love to see more frameworks proposed. Awesome work!", }, { name: "Dani", diff --git a/apps/web/src/components/home/testimonials-section.tsx b/apps/web/src/components/home/testimonials-section.tsx index 6c6b3b622..94386c5ae 100644 --- a/apps/web/src/components/home/testimonials-section.tsx +++ b/apps/web/src/components/home/testimonials-section.tsx @@ -55,7 +55,7 @@ function TestimonialCard({ testimonial, index }: { testimonial: Testimonial; ind className="group relative flex h-full flex-col gap-3 rounded-xl border border-border bg-background p-5 transition-colors hover:border-foreground/30" > <Quote - className="absolute right-4 top-4 h-5 w-5 text-muted-foreground/15 transition-colors group-hover:text-lime-700/40" + className="absolute right-4 top-4 h-5 w-5 text-muted-foreground/15 transition-colors group-hover:text-lime-800/40 dark:group-hover:text-[#C6E853]/40" aria-hidden /> <p className="text-pretty text-sm leading-relaxed text-muted-foreground"> @@ -97,7 +97,7 @@ export default function TestimonialsSection() { <div className="px-4 py-20 sm:px-8 sm:py-28"> <div className="grid grid-cols-12 gap-x-4 gap-y-6"> <div className="col-span-12 sm:col-span-7"> - <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#bef264]"> + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#C6E853]"> ✦ on the record </p> <h2 @@ -117,7 +117,7 @@ export default function TestimonialsSection() { href="https://app.daily.dev/posts/a42eCYoJk" target="_blank" rel="noopener noreferrer" - className="text-foreground underline underline-offset-4 transition-colors hover:text-lime-700" + className="text-foreground underline underline-offset-4 transition-colors hover:text-lime-800 dark:hover:text-[#C6E853]" > daily.dev </a>{" "} @@ -126,7 +126,7 @@ export default function TestimonialsSection() { href="https://x.com" target="_blank" rel="noopener noreferrer" - className="text-foreground underline underline-offset-4 transition-colors hover:text-lime-700" + className="text-foreground underline underline-offset-4 transition-colors hover:text-lime-800 dark:hover:text-[#C6E853]" > X </a>{" "} @@ -155,7 +155,7 @@ export default function TestimonialsSection() { <div className="mt-16 border-t border-border pt-10"> <div className="grid grid-cols-12 gap-x-4 gap-y-4"> <div className="col-span-12 sm:col-span-3"> - <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#bef264]"> + <p className="font-mono text-[11px] uppercase tracking-[0.22em] text-black dark:text-[#C6E853]"> ✦ liked on x </p> <p className="mt-2 text-xs text-muted-foreground">Builders who hearted the launch.</p> diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 7e819fa26..bb184f3f9 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -1,7 +1,10 @@ -import { Link } from "@tanstack/react-router"; -import { ArrowRight, Github } from "lucide-react"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { ArrowRight, Check, ClipboardCopy, Github } from "lucide-react"; +import { useState } from "react"; import { ThemeToggle } from "@/components/theme-toggle"; +import { parseStackFromUrlRecord } from "@/lib/stack-url-state.shared"; +import { generateStackCommand } from "@/lib/stack-utils"; const BUILDER_COMMAND_SEARCH = { view: "command", file: "" } as const; const BUILDER_PRESETS_SEARCH = { view: "presets", file: "" } as const; @@ -11,7 +14,51 @@ const DOCS_ACTIVE_PROPS = { className: "active" } as const; const NAV_LINK_CLASS = "font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground transition-colors hover:text-foreground [&.active]:text-foreground sm:text-[12px]"; +// On the builder page the "Try now" CTA (which links to /new) is redundant, so it +// becomes a Copy button. The builder syncs the live stack to the URL via +// replaceState, so we read it at click-time and regenerate the same command. +function HeaderCopyButton() { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + const sp = new URLSearchParams(window.location.search); + const record: Record<string, string | string[]> = {}; + for (const key of sp.keys()) { + if (key in record) continue; + const values = sp.getAll(key); + record[key] = values.length > 1 ? values : (values[0] ?? ""); + } + const stack = parseStackFromUrlRecord(record); + await navigator.clipboard.writeText(generateStackCommand(stack)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard unavailable — no-op + } + }; + + return ( + <button + type="button" + onClick={handleCopy} + aria-label={copied ? "Command copied" : "Copy install command"} + className="inline-flex items-center gap-1.5 rounded-md bg-[#C6E853] px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.18em] text-black transition-colors hover:bg-[#d2ee72] sm:px-4 sm:py-2 sm:text-[12px]" + > + {copied ? ( + <Check className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + ) : ( + <ClipboardCopy className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> + )} + {copied ? "Copied" : "Copy"} + </button> + ); +} + export function Navbar() { + const matchRoute = useMatchRoute(); + const onBuilder = Boolean(matchRoute({ to: "/new" })); + return ( <header className="sticky top-0 z-50 w-full border-b border-border bg-background/85 backdrop-blur-md"> <nav className="container mx-auto flex h-14 items-center justify-between gap-3 px-4 sm:px-6"> @@ -72,14 +119,18 @@ export function Navbar() { </a> <ThemeToggle /> <span className="hidden h-4 w-px bg-border sm:block" aria-hidden /> - <Link - to="/new" - search={BUILDER_COMMAND_SEARCH} - className="group inline-flex items-center gap-1.5 rounded-md bg-lime-500 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.18em] text-black transition-all hover:gap-2 hover:bg-lime-400 sm:px-4 sm:py-2 sm:text-[12px]" - > - Try now - <ArrowRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5 sm:h-3.5 sm:w-3.5" /> - </Link> + {onBuilder ? ( + <HeaderCopyButton /> + ) : ( + <Link + to="/new" + search={BUILDER_COMMAND_SEARCH} + className="group inline-flex items-center gap-1.5 rounded-md bg-[#C6E853] px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.18em] text-black transition-all hover:gap-2 hover:bg-[#d2ee72] sm:px-4 sm:py-2 sm:text-[12px]" + > + Try now + <ArrowRight className="h-3 w-3 transition-transform group-hover:translate-x-0.5 sm:h-3.5 sm:w-3.5" /> + </Link> + )} </div> </nav> </header> 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<string, BundledLanguage> = { 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/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<string, readonly string[]> = { "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<string, readonly (keyof StackState)[]> 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/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 ? ( - <Check className="h-3.5 w-3.5" /> - ) : ( - <Link className="h-3.5 w-3.5" /> - )} + {copied ? <Check className="h-3.5 w-3.5" /> : <Link className="h-3.5 w-3.5" />} </button> ); } diff --git a/apps/web/src/components/stack-builder/stack-builder.tsx b/apps/web/src/components/stack-builder/stack-builder.tsx index fa12ed9ea..eaff312e5 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,17 +51,19 @@ 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 { 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"; @@ -79,17 +82,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, "-"); } @@ -251,19 +246,6 @@ function TechResourceButtons({ category, techId }: { category: string; techId: s ); } -function NewToolLabel({ compact = false }: { compact?: boolean }) { - return ( - <span - className={cn( - "bg-[#bef264] font-mono font-semibold uppercase leading-none text-[#0a0a0a]", - compact ? "px-1 py-0.5 text-[8px]" : "px-1.5 py-0.5 text-[9px]", - )} - > - New - </span> - ); -} - function DisabledReasonInline({ reason, compact = false }: { reason: string; compact?: boolean }) { return ( <div @@ -283,9 +265,9 @@ function CategoryHint({ categoryKey }: { categoryKey: string }) { return ( <div className="mb-3 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"> - <span className="font-medium text-foreground">Grouped add-ons:</span> 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. + <span className="font-medium text-foreground">Grouped add-ons:</span> 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. </div> ); } @@ -334,131 +316,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 ( - <div className="border-b border-border/50 last:border-b-0"> - <button - type="button" - onClick={onToggle} - data-testid={`sidebar-category-toggle-${category}`} - className={cn( - "flex w-full items-center justify-between px-3 py-2.5 text-left text-sm transition-colors", - isOpen - ? "bg-muted/80 text-foreground font-medium" - : "text-muted-foreground hover:bg-muted/40 hover:text-foreground", - )} - > - <span className="truncate pr-2 font-mono">{displayName}</span> - <div className="flex items-center gap-1.5 shrink-0"> - {compatibilityNotes?.hasIssue && <InfoIcon className="h-3.5 w-3.5 text-amber-500" />} - {count > 0 && ( - <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 font-mono text-[10px] font-semibold text-primary-foreground"> - {count} - </span> - )} - <motion.div animate={{ rotate: isOpen ? 180 : 0 }} transition={{ duration: 0.2 }}> - <ChevronDown className="h-3.5 w-3.5" /> - </motion.div> - </div> - </button> - <AnimatePresence initial={false}> - {isOpen && ( - <motion.div - initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} - exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.25, ease: "easeInOut" }} - className="overflow-hidden" - > - <div className="space-y-0.5 px-2 py-1.5"> - {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 ( - <button - key={`${optionCategory}-${option.id}`} - type="button" - data-testid={`sidebar-option-${optionCategory}-${option.id}`} - onClick={() => { - if (!disabled) { - handleTechSelect(optionCategory, option.id); - } - }} - disabled={disabled} - title={disabledReason || option.description} - className={cn( - "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors", - selected - ? "bg-primary/10 text-foreground" - : "text-muted-foreground hover:bg-muted/60 hover:text-foreground", - disabled && "opacity-40 cursor-not-allowed", - )} - > - <div - className={cn( - "flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition-colors", - selected ? "border-primary bg-primary" : "border-border bg-background", - )} - > - {selected && <Check className="h-2.5 w-2.5 text-primary-foreground" />} - </div> - {option.icon !== undefined && ( - <TechIcon - techId={option.id} - icon={option.icon} - name={option.name} - className="h-4 w-4" - /> - )} - <div className="min-w-0 flex-1"> - <span className="block truncate">{option.name}</span> - {disabledReason && <DisabledReasonInline reason={disabledReason} compact />} - </div> - {option.default && !selected && ( - <span className="ml-auto shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] text-muted-foreground"> - default - </span> - )} - </button> - ); - })} - </div> - </motion.div> - )} - </AnimatePresence> - </div> - ); -} - // ─── Collapsible section config ────────────────────────────────────────────── const INITIALLY_COLLAPSED_SET = new Set([ @@ -508,13 +365,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<SavedStackEntry[]>(() => loadSavedStacks()); const [, setLastChanges] = useState<Array<{ category: string; message: string }>>([]); - const [mobileTab, setMobileTab] = useState<MobileTab>("configure"); const [isSaveInputVisible, setIsSaveInputVisible] = useState(false); const [savePresetName, setSavePresetName] = useState(""); const [pendingUpdateEntryId, setPendingUpdateEntryId] = useState<string | null>(null); - const [openCategory, setOpenCategory] = useState<string | null>(null); const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => { const initial = new Set(INITIALLY_COLLAPSED_SET); for (const cat of INITIALLY_COLLAPSED_SET) { @@ -527,9 +384,13 @@ const StackBuilder = () => { }); const sectionRefs = useRef<Record<string, HTMLElement | null>>({}); - const mainScrollRef = useRef<HTMLDivElement | null>(null); + const scrollContainerRef = useRef<HTMLDivElement | null>(null); const lastAppliedStackString = useRef<string>(""); + const scrollToTop = () => { + scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }; + const compatibilityAnalysis = analyzeStackCompatibility(stack); const adjustedStack = useMemo<StackState | null>(() => { if (!compatibilityAnalysis.adjustedStack) return null; @@ -540,70 +401,10 @@ 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(() => { - 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<HTMLDivElement>( - '[data-slot="scroll-area-viewport"]', - ); - if (viewport) { - mainScrollRef.current = viewport; - } - } - }, []); - - // ─── URL & command generation ─────────────────────────────────────────── + // ─── URL generation ────────────────────────────────────────────────────── const getStackUrl = (): string => { const stackToUse = adjustedStack || stack; @@ -658,6 +459,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; @@ -742,12 +549,6 @@ const StackBuilder = () => { }); }; - const copyToClipboard = () => { - navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - const resetStack = () => { startTransition(() => { setStack(DEFAULT_STACK); @@ -899,21 +700,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); @@ -926,6 +712,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 ( @@ -957,1010 +773,1019 @@ const StackBuilder = () => { </div> </DialogContent> </Dialog> - <div className="flex h-full w-full flex-col overflow-hidden border-border text-foreground"> - {/* Mobile tab navigation */} - <div className="flex border-b border-border bg-fd-background pl-2 lg:hidden"> - <button - type="button" - onClick={() => setMobileTab("summary")} - data-testid="mobile-tab-summary" - aria-pressed={mobileTab === "summary"} - data-state={mobileTab === "summary" ? "active" : "inactive"} - className={cn( - "flex flex-1 items-center justify-center gap-2 border-b-2 px-1 py-3 text-xs font-medium transition-all hover:bg-muted/50", - mobileTab === "summary" - ? "border-primary text-primary" - : "border-transparent text-muted-foreground hover:text-foreground", - )} - > - <List className="h-4 w-4" /> - <span>Categories</span> - </button> - <button - type="button" - onClick={() => setMobileTab("configure")} - data-testid="mobile-tab-configure" - aria-pressed={mobileTab === "configure"} - data-state={mobileTab === "configure" ? "active" : "inactive"} - className={cn( - "flex flex-1 items-center justify-center gap-2 border-b-2 px-1 py-3 text-xs font-medium transition-all hover:bg-muted/50", - mobileTab === "configure" - ? "border-primary text-primary" - : "border-transparent text-muted-foreground hover:text-foreground", - )} - > - <Terminal className="h-4 w-4" /> - <span>Configure</span> - </button> - </div> + <div className="relative flex h-full w-full flex-col overflow-hidden border-border text-foreground"> + {/* Single scroller: header + toolbar + content scroll together (header is not pinned) */} + <div + ref={scrollContainerRef} + onScroll={(e) => { + 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 ─────────────────────────────────────── */} + <div className="relative shrink-0 border-b border-border bg-fd-background"> + <div className="grid grid-cols-5"> + {ECOSYSTEMS.map((eco) => { + const isActive = stack.ecosystem === eco.id; + return ( + <button + key={eco.id} + type="button" + data-testid={`ecosystem-${eco.id}`} + onClick={() => { + startTransition(() => { + setStack({ ecosystem: eco.id as Ecosystem }); + }); + }} + className={cn( + "group relative flex cursor-pointer items-center justify-center gap-2 px-3 py-3 transition-all sm:gap-2.5 sm:px-4 sm:py-3.5", + isActive + ? "bg-muted/40 text-foreground" + : "text-muted-foreground hover:bg-muted/30 hover:text-foreground", + )} + > + {/* Active underline */} + {isActive && ( + <motion.div + layoutId="ecosystem-indicator" + className={cn( + "absolute inset-x-0 bottom-0 h-[2px] bg-gradient-to-r", + eco.color, + )} + transition={{ type: "spring", bounce: 0.15, duration: 0.5 }} + /> + )} - {/* ─── Ecosystem Header Bar ─────────────────────────────────────── */} - <div className="relative border-b border-border bg-fd-background"> - <div className="grid grid-cols-5"> - {ECOSYSTEMS.map((eco) => { - const isActive = stack.ecosystem === eco.id; - return ( - <button - key={eco.id} - type="button" - data-testid={`ecosystem-${eco.id}`} - onClick={() => { - startTransition(() => { - setStack({ ecosystem: eco.id as Ecosystem }); - }); - }} - className={cn( - "group relative flex items-center justify-center gap-2 px-3 py-3 transition-all sm:gap-2.5 sm:px-4 sm:py-3.5", - isActive - ? "text-foreground" - : "text-muted-foreground hover:text-foreground hover:bg-muted/30", - )} - > - {/* Active underline */} - {isActive && ( - <motion.div - layoutId="ecosystem-indicator" + <TechIcon + techId={eco.id} + icon={eco.icon} + name={eco.name} className={cn( - "absolute inset-x-0 bottom-0 h-[2px] bg-gradient-to-r", - eco.color, + "relative h-4.5 w-4.5 transition-all sm:h-5 sm:w-5", + isActive ? "scale-110" : "opacity-50 group-hover:opacity-75", )} - transition={{ type: "spring", bounce: 0.15, duration: 0.5 }} /> - )} + <span + className={cn( + "relative hidden font-mono text-[11px] uppercase tracking-wide transition-all min-[480px]:inline sm:text-xs", + isActive ? "font-bold" : "", + )} + > + {eco.name} + </span> + </button> + ); + })} + </div> + </div> - <TechIcon - techId={eco.id} - icon={eco.icon} - name={eco.name} + <div + className={cn("flex", viewMode === "command" ? "" : "min-h-0 flex-1 overflow-hidden")} + > + {/* ─── Main Content Area ──────────────────────────────────────────── */} + <main + className={cn( + "flex min-w-0 flex-1 flex-col", + viewMode === "command" ? "" : "overflow-hidden", + )} + > + <div className="flex shrink-0 items-center gap-1 border-border border-b bg-fd-background px-2 py-2 sm:gap-2 sm:px-4"> + {/* ─── Project name field ─────────────────────────────────────── */} + <label + htmlFor="project-name" + className={cn( + "group relative inline-flex h-9 w-40 min-w-0 cursor-text items-center gap-2 rounded-none border bg-background/40 px-3 transition-colors hover:bg-card focus-within:bg-card sm:w-60", + projectNameError + ? "border-destructive focus-within:border-destructive focus-within:shadow-[0_0_0_4px_rgba(239,68,68,0.12)]" + : "border-border focus-within:border-foreground focus-within:shadow-[0_0_0_4px_rgba(24,24,27,0.05)] dark:focus-within:shadow-[0_0_0_4px_rgba(255,255,255,0.06)]", + )} + > + <span className="pointer-events-none absolute -top-[7px] left-3 bg-fd-background px-1.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> + Project name + </span> + <input + id="project-name" + value={stack.projectName || ""} + onChange={(e) => setStack({ projectName: e.target.value })} + placeholder="my-app" + aria-label="Project name" + aria-invalid={projectNameError ? true : undefined} + title={ + projectNameError || + ((stack.projectName || "my-app").includes(" ") + ? `Will be saved as: ${(stack.projectName || "my-app").replace(/\s+/g, "-")}` + : undefined) + } className={cn( - "relative h-4.5 w-4.5 transition-all sm:h-5 sm:w-5", - isActive ? "scale-110" : "opacity-50 group-hover:opacity-75", + "min-w-0 flex-1 border-none bg-transparent p-0 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground/50", + projectNameError && "text-destructive", )} /> - <span - className={cn( - "relative hidden font-mono text-[11px] uppercase tracking-wide transition-all min-[480px]:inline sm:text-xs", - isActive ? "font-bold" : "", - )} - > - {eco.name} - </span> + <Pencil className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50 transition-colors group-focus-within:text-foreground" /> + </label> + + <div className="mx-1 h-6 w-px shrink-0 bg-border sm:mx-2" aria-hidden="true" /> + + <button + type="button" + onClick={() => setViewMode("command")} + data-testid="tab-builder" + aria-pressed={viewMode === "command"} + data-state={viewMode === "command" ? "active" : "inactive"} + className={cn( + "flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", + viewMode === "command" + ? "bg-primary/10 text-primary" + : "text-muted-foreground hover:bg-muted hover:text-foreground", + )} + > + <Hammer className="h-3 w-3" /> + <span className="hidden min-[480px]:inline">Builder</span> + </button> + <button + type="button" + onClick={() => setViewMode("presets")} + data-testid="tab-presets" + aria-pressed={viewMode === "presets"} + data-state={viewMode === "presets" ? "active" : "inactive"} + className={cn( + "flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", + viewMode === "presets" + ? "bg-primary/10 text-primary" + : "text-muted-foreground hover:bg-muted hover:text-foreground", + )} + > + <Zap className="h-3 w-3" /> + <span className="hidden min-[480px]:inline">Presets</span> + </button> + <button + type="button" + onClick={() => setViewMode("preview")} + data-testid="tab-preview" + aria-pressed={viewMode === "preview"} + data-state={viewMode === "preview" ? "active" : "inactive"} + className={cn( + "flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", + viewMode === "preview" + ? "bg-primary/10 text-primary" + : "text-muted-foreground hover:bg-muted hover:text-foreground", + )} + > + <Eye className="h-3 w-3" /> + <span className="hidden min-[480px]:inline">Preview</span> + </button> + <button + type="button" + onClick={() => setViewMode("saved")} + data-testid="tab-saved" + aria-pressed={viewMode === "saved"} + data-state={viewMode === "saved" ? "active" : "inactive"} + className={cn( + "flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", + viewMode === "saved" + ? "bg-primary/10 text-primary" + : "text-muted-foreground hover:bg-muted hover:text-foreground", + )} + > + <Bookmark className="h-3 w-3" /> + <span className="hidden min-[480px]:inline">Saved</span> </button> - ); - })} - </div> - </div> - <div className="flex min-h-0 flex-1 overflow-hidden"> - {/* ─── Left Sidebar ───────────────────────────────────────────────── */} - <aside - className={cn( - "flex h-full w-full shrink-0 flex-col overflow-hidden border-r border-border bg-background lg:w-[270px]", - mobileTab === "summary" ? "flex" : "hidden lg:flex", - )} - > - {/* Category Accordion */} - <div className="relative min-h-0 flex-1"> - <div className="absolute inset-0"> - <ScrollArea className="h-full"> - <div className="py-1"> - {sidebarCategories.map((category) => ( - <SidebarAccordionItem - key={category} - category={category} - isOpen={openCategory === category} - onToggle={() => handleAccordionToggle(category)} - stack={stack} - handleTechSelect={handleTechSelect} - compatibilityNotes={ - stack.ecosystem === "go" && category === "goAuth" - ? mergeCompatibilityNotes( - compatibilityAnalysis.notes.goAuth, - compatibilityAnalysis.notes.auth, + <div className="ml-auto flex items-center gap-1"> + {/* Desktop action buttons */} + <AnimatePresence initial={false}> + {isSaveInputVisible && ( + <motion.div + initial={{ width: 0, opacity: 0 }} + animate={{ width: 220, opacity: 1 }} + exit={{ width: 0, opacity: 0 }} + transition={{ duration: 0.2, ease: "easeInOut" }} + className="hidden overflow-hidden sm:block" + > + <div className="flex items-center gap-1 pr-1"> + <Input + value={savePresetName} + onChange={(e) => 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" + /> + <button + type="button" + onClick={() => + saveCurrentStack( + savePresetName || stack.projectName || "Untitled preset", ) - : compatibilityAnalysis.notes[category] + } + className="cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + title="Save preset" + > + <Check className="h-3.5 w-3.5" /> + </button> + </div> + </motion.div> + )} + </AnimatePresence> + <div className="hidden items-center gap-1 sm:flex"> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + onClick={() => { + const nextVisible = !isSaveInputVisible; + setIsSaveInputVisible(nextVisible); + setSavePresetName(nextVisible ? stack.projectName || "" : ""); + }} + title="Save current preset" + aria-label="Save current preset" + className={cn( + "cursor-pointer rounded-md p-1.5 transition-colors", + isSaveInputVisible + ? "bg-primary/15 text-primary" + : "text-muted-foreground hover:bg-muted hover:text-foreground", + )} + /> } - /> - ))} - </div> - </ScrollArea> - </div> - </div> - - {/* Sidebar Command (Option 2) */} - <div className="relative z-10 border-t border-border bg-background px-3 pt-2 pb-1"> - <div className="rounded-md border border-border bg-fd-background p-2"> - <div className="flex items-center justify-between mb-1"> - <div className="flex items-center gap-1.5"> - <Terminal className="h-3 w-3 text-muted-foreground" /> - <span className="font-mono text-[10px] text-muted-foreground">Command</span> + > + <Save className="h-3.5 w-3.5" /> + </TooltipTrigger> + <TooltipContent>Save the current stack as a named preset</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + onClick={resetStack} + title="Reset to defaults" + aria-label="Reset to defaults" + data-testid="btn-reset" + className="cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + /> + } + > + <RefreshCw className="h-3.5 w-3.5" /> + </TooltipTrigger> + <TooltipContent>Reset all builder options to defaults</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger + render={ + <button + type="button" + onClick={getRandomStack} + title="Generate a random stack" + aria-label="Generate a random stack" + data-testid="btn-random" + className="cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + /> + } + > + <Shuffle className="h-3.5 w-3.5" /> + </TooltipTrigger> + <TooltipContent>Generate a random stack configuration</TooltipContent> + </Tooltip> + <ShareButton stackUrl={getStackUrl()} /> + <DropdownMenu> + <DropdownMenuTrigger + render={ + <button + type="button" + aria-label="Builder settings" + title="Builder settings" + className="cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + /> + } + > + <Settings className="h-3.5 w-3.5" /> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-64 bg-fd-background"> + <YoloToggle stack={stack} onToggle={(yolo) => setStack({ yolo })} /> + </DropdownMenuContent> + </DropdownMenu> </div> - <button - type="button" - onClick={copyToClipboard} - className={cn( - "flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[10px] transition-colors", - copied - ? "text-green-600" - : "text-muted-foreground hover:bg-muted hover:text-foreground", - )} - > - {copied ? ( - <> - <Check className="h-2.5 w-2.5" /> - Copied - </> - ) : ( - <> - <ClipboardCopy className="h-2.5 w-2.5" /> - Copy - </> - )} - </button> - </div> - <code data-testid="command-output" className="block break-all text-muted-foreground text-[11px] leading-relaxed max-h-32 overflow-y-auto"> - <span className="select-none text-chart-4">$ </span> - {command} - </code> - </div> - </div> - - </aside> - - {/* ─── Main Content Area ──────────────────────────────────────────── */} - <main - className={cn( - "flex min-w-0 flex-1 flex-col overflow-hidden", - mobileTab === "summary" ? "hidden lg:flex" : "flex", - )} - > - <div className="flex items-center gap-1 border-border border-b bg-fd-background px-2 py-2 sm:gap-2 sm:px-4"> - <button - type="button" - onClick={() => setViewMode("command")} - data-testid="tab-builder" - aria-pressed={viewMode === "command"} - data-state={viewMode === "command" ? "active" : "inactive"} - className={cn( - "flex items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", - viewMode === "command" - ? "bg-primary/10 text-primary" - : "text-muted-foreground hover:bg-muted hover:text-foreground", - )} - > - <Hammer className="h-3 w-3" /> - <span className="hidden min-[480px]:inline">Builder</span> - </button> - <button - type="button" - onClick={() => setViewMode("presets")} - data-testid="tab-presets" - aria-pressed={viewMode === "presets"} - data-state={viewMode === "presets" ? "active" : "inactive"} - className={cn( - "flex items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", - viewMode === "presets" - ? "bg-primary/10 text-primary" - : "text-muted-foreground hover:bg-muted hover:text-foreground", - )} - > - <Zap className="h-3 w-3" /> - <span className="hidden min-[480px]:inline">Presets</span> - </button> - <button - type="button" - onClick={() => setViewMode("preview")} - data-testid="tab-preview" - aria-pressed={viewMode === "preview"} - data-state={viewMode === "preview" ? "active" : "inactive"} - className={cn( - "flex items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", - viewMode === "preview" - ? "bg-primary/10 text-primary" - : "text-muted-foreground hover:bg-muted hover:text-foreground", - )} - > - <Eye className="h-3 w-3" /> - <span className="hidden min-[480px]:inline">Preview</span> - </button> - <button - type="button" - onClick={() => setViewMode("saved")} - data-testid="tab-saved" - aria-pressed={viewMode === "saved"} - data-state={viewMode === "saved" ? "active" : "inactive"} - className={cn( - "flex items-center gap-1 rounded-md px-1.5 py-1.5 font-mono text-[10px] uppercase tracking-wide transition-colors sm:px-2.5 sm:text-[11px]", - viewMode === "saved" - ? "bg-primary/10 text-primary" - : "text-muted-foreground hover:bg-muted hover:text-foreground", - )} - > - <Bookmark className="h-3 w-3" /> - <span className="hidden min-[480px]:inline">Saved</span> - </button> - <div className="ml-auto flex items-center gap-1"> - {/* Desktop action buttons */} - <AnimatePresence initial={false}> - {isSaveInputVisible && ( - <motion.div - initial={{ width: 0, opacity: 0 }} - animate={{ width: 220, opacity: 1 }} - exit={{ width: 0, opacity: 0 }} - transition={{ duration: 0.2, ease: "easeInOut" }} - className="hidden overflow-hidden sm:block" - > - <div className="flex items-center gap-1 pr-1"> - <Input - value={savePresetName} - onChange={(e) => 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" - /> - <button - type="button" - onClick={() => - saveCurrentStack(savePresetName || stack.projectName || "Untitled preset") - } - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - title="Save preset" - > - <Check className="h-3.5 w-3.5" /> - </button> - </div> - </motion.div> - )} - </AnimatePresence> - <div className="hidden items-center gap-1 sm:flex"> - <Tooltip> - <TooltipTrigger - render={ - <button - type="button" - onClick={() => { - 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 className="h-3.5 w-3.5" /> - </TooltipTrigger> - <TooltipContent>Save the current stack as a named preset</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={ - <button - type="button" - onClick={resetStack} - title="Reset to defaults" - aria-label="Reset to defaults" - data-testid="btn-reset" - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - /> - } - > - <RefreshCw className="h-3.5 w-3.5" /> - </TooltipTrigger> - <TooltipContent>Reset all builder options to defaults</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger - render={ - <button - type="button" - onClick={getRandomStack} - title="Generate a random stack" - aria-label="Generate a random stack" - data-testid="btn-random" - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - /> - } - > - <Shuffle className="h-3.5 w-3.5" /> - </TooltipTrigger> - <TooltipContent>Generate a random stack configuration</TooltipContent> - </Tooltip> - <ShareButton stackUrl={getStackUrl()} /> + {/* Mobile three-dot menu */} <DropdownMenu> <DropdownMenuTrigger render={ <button type="button" - aria-label="Builder settings" - title="Builder settings" - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + aria-label="More actions" + title="More actions" + className="flex items-center justify-center cursor-pointer rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:hidden" /> } > - <Settings className="h-3.5 w-3.5" /> + <EllipsisVertical className="h-4 w-4" /> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-64 bg-fd-background"> - <YoloToggle stack={stack} onToggle={(yolo) => setStack({ yolo })} /> + <DropdownMenuContent + align="end" + sideOffset={8} + className="w-48 bg-fd-background" + > + <DropdownMenuItem + onClick={() => { + saveCurrentStack(stack.projectName || "Untitled preset"); + }} + > + <Save className="h-3.5 w-3.5" /> + Save Preset + </DropdownMenuItem> + <DropdownMenuItem onClick={resetStack}> + <RefreshCw className="h-3.5 w-3.5" /> + Reset to Defaults + </DropdownMenuItem> + <DropdownMenuItem onClick={getRandomStack}> + <Shuffle className="h-3.5 w-3.5" /> + Random Stack + </DropdownMenuItem> + <DropdownMenuItem + onClick={async () => { + try { + await navigator.clipboard.writeText(getStackUrl()); + toast.success("Share link copied!"); + } catch { + toast.error("Failed to copy link"); + } + }} + > + <Link className="h-3.5 w-3.5" /> + Copy Share Link + </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> - - {/* Mobile three-dot menu */} - <DropdownMenu> - <DropdownMenuTrigger - render={ - <button - type="button" - aria-label="More actions" - title="More actions" - className="flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:hidden" - /> - } - > - <EllipsisVertical className="h-4 w-4" /> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" sideOffset={8} className="w-48 bg-fd-background"> - <DropdownMenuItem - onClick={() => { - saveCurrentStack(stack.projectName || "Untitled preset"); - }} - > - <Save className="h-3.5 w-3.5" /> - Save Preset - </DropdownMenuItem> - <DropdownMenuItem onClick={resetStack}> - <RefreshCw className="h-3.5 w-3.5" /> - Reset to Defaults - </DropdownMenuItem> - <DropdownMenuItem onClick={getRandomStack}> - <Shuffle className="h-3.5 w-3.5" /> - Random Stack - </DropdownMenuItem> - <DropdownMenuItem - onClick={async () => { - try { - await navigator.clipboard.writeText(getStackUrl()); - toast.success("Share link copied!"); - } catch { - toast.error("Failed to copy link"); - } - }} - > - <Link className="h-3.5 w-3.5" /> - Copy Share Link - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> </div> - </div> - {viewMode === "command" ? ( - <div className="relative min-h-0 flex-1"> - <div className="absolute inset-0"> - <ScrollArea ref={mainScrollRef} className="h-full"> - <div className="p-3 sm:p-4"> - <div className="mb-6"> - <label - htmlFor="project-name" - className="mb-1.5 block font-mono text-[10px] uppercase tracking-wider text-muted-foreground" + {viewMode === "command" ? ( + <div className="p-3 pb-24 sm:p-4 sm:pb-28"> + {/* Category sections - all options for each category */} + {categoryOrder.map((categoryKey) => { + // Skip astroIntegration - rendered conditionally after webFrontend + if (categoryKey === "astroIntegration") return null; + + // Skip shadcn sub-categories - rendered conditionally after uiLibrary + if (SHADCN_SUB_CATEGORIES.has(categoryKey)) return null; + + if (stack.ecosystem === "go" && categoryKey === "auth") return null; + + const categoryOptionGroups = getCategoryRenderGroups( + stack, + categoryKey as keyof typeof TECH_OPTIONS, + ); + const categoryDisplayName = getCategoryDisplayName(categoryKey); + const sectionCompatibilityNotes = + stack.ecosystem === "go" && categoryKey === "goAuth" + ? mergeCompatibilityNotes( + compatibilityAnalysis.notes.goAuth, + compatibilityAnalysis.notes.auth, + ) + : compatibilityAnalysis.notes[categoryKey]; + + if (categoryOptionGroups.length === 0) return null; + + const isSectionCollapsed = collapsedSections.has(categoryKey); + const sectionSelectedCount = getSelectedCount( + categoryKey as keyof typeof TECH_OPTIONS, + stack, + ); + + return ( + <div key={categoryKey}> + <section + ref={(el) => { + sectionRefs.current[categoryKey] = el; + }} + id={`section-${categoryKey}`} + data-testid={`category-${categoryKey}`} + className="mb-6 scroll-mt-4 sm:mb-8" > - Project Name - </label> - <Input - id="project-name" - value={stack.projectName || ""} - onChange={(e) => setStack({ projectName: e.target.value })} - placeholder="my-app" - className={cn( - "max-w-sm", - projectNameError - ? "border-destructive bg-destructive/10 text-destructive-foreground" - : "focus-visible:border-primary", - )} - /> - {projectNameError && ( - <p className="mt-1 text-destructive text-xs">{projectNameError}</p> - )} - {(stack.projectName || "my-app").includes(" ") && ( - <p className="mt-1 text-muted-foreground text-xs"> - Will be saved as:{" "} - <code className="rounded bg-muted px-1 py-0.5 text-xs"> - {(stack.projectName || "my-app").replace(/\s+/g, "-")} - </code> - </p> - )} - </div> - - {/* Category sections - all options for each category */} - {categoryOrder.map((categoryKey) => { - // Skip astroIntegration - rendered conditionally after webFrontend - if (categoryKey === "astroIntegration") return null; - - // Skip shadcn sub-categories - rendered conditionally after uiLibrary - if (SHADCN_SUB_CATEGORIES.has(categoryKey)) return null; - - if (stack.ecosystem === "go" && categoryKey === "auth") return null; - - const categoryOptionGroups = getCategoryRenderGroups( - stack, - categoryKey as keyof typeof TECH_OPTIONS, - ); - const categoryDisplayName = getCategoryDisplayName(categoryKey); - const sectionCompatibilityNotes = - stack.ecosystem === "go" && categoryKey === "goAuth" - ? mergeCompatibilityNotes( - compatibilityAnalysis.notes.goAuth, - compatibilityAnalysis.notes.auth, - ) - : compatibilityAnalysis.notes[categoryKey]; - - if (categoryOptionGroups.length === 0) return null; - - const isSectionCollapsed = collapsedSections.has(categoryKey); - const sectionSelectedCount = getSelectedCount( - categoryKey as keyof typeof TECH_OPTIONS, - stack, - ); - - return ( - <div key={categoryKey}> - <section - ref={(el) => { - sectionRefs.current[categoryKey] = el; - }} - id={`section-${categoryKey}`} - data-testid={`category-${categoryKey}`} - className="mb-6 scroll-mt-4 sm:mb-8" + <button + type="button" + onClick={() => toggleSection(categoryKey)} + data-testid={`category-toggle-${categoryKey}`} + className="mb-3 flex w-full items-center gap-2 border-b border-border pb-2 text-left transition-opacity hover:opacity-80" + > + <Terminal className="h-4 w-4 shrink-0 text-muted-foreground sm:h-5 sm:w-5" /> + <h2 className="flex-1 font-mono text-foreground text-sm sm:text-base"> + {categoryDisplayName} + </h2> + {sectionCompatibilityNotes?.hasIssue && ( + <InfoIcon className="h-4 w-4 shrink-0 text-amber-500" /> + )} + {isSectionCollapsed && sectionSelectedCount > 0 && ( + <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 font-mono text-[10px] font-semibold text-primary-foreground"> + {sectionSelectedCount} + </span> + )} + <motion.div + animate={{ rotate: isSectionCollapsed ? 0 : 180 }} + transition={{ duration: 0.2 }} > - <button - type="button" - onClick={() => toggleSection(categoryKey)} - data-testid={`category-toggle-${categoryKey}`} - className="mb-3 flex w-full items-center gap-2 border-b border-border pb-2 text-left transition-opacity hover:opacity-80" + <ChevronDown className="h-4 w-4 text-muted-foreground" /> + </motion.div> + </button> + <AnimatePresence initial={false}> + {!isSectionCollapsed && ( + <motion.div + initial={{ height: 0, opacity: 0 }} + animate={{ height: "auto", opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ duration: 0.25, ease: "easeInOut" }} + className="overflow-hidden" > - <Terminal className="h-4 w-4 shrink-0 text-muted-foreground sm:h-5 sm:w-5" /> - <h2 className="flex-1 font-mono text-foreground text-sm sm:text-base"> - {categoryDisplayName} - </h2> - {sectionCompatibilityNotes?.hasIssue && ( - <InfoIcon className="h-4 w-4 shrink-0 text-amber-500" /> - )} - {isSectionCollapsed && sectionSelectedCount > 0 && ( - <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 font-mono text-[10px] font-semibold text-primary-foreground"> - {sectionSelectedCount} - </span> - )} - <motion.div - animate={{ rotate: isSectionCollapsed ? 0 : 180 }} - transition={{ duration: 0.2 }} - > - <ChevronDown className="h-4 w-4 text-muted-foreground" /> - </motion.div> - </button> - <AnimatePresence initial={false}> - {!isSectionCollapsed && ( - <motion.div - initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} - exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.25, ease: "easeInOut" }} - className="overflow-hidden" - > - <CategoryHint categoryKey={categoryKey} /> - <div className="space-y-4"> - {categoryOptionGroups.map((group) => ( - <div key={group.key}> - {group.heading && ( - <h3 className="mb-2 font-mono text-[10px] uppercase tracking-wider text-muted-foreground"> - {group.heading} - </h3> - )} - <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 2xl:grid-cols-4"> - {group.options.map((tech) => { - const isSelected = isSelectedCheck( - stack, - group.category, - tech.id, - ); - const isDisabled = !isOptionCompatible( - stack, - group.category, - tech.id, - ); - const disabledReason = isDisabled - ? getDisabledReason( - stack, - group.category, - tech.id, - ) - : null; - - return ( - <motion.div - key={tech.id} - data-testid={`option-${group.category}-${tech.id}`} - className={cn( - "group relative cursor-pointer rounded-lg border p-3 transition-all sm:p-4", - isSelected - ? "border-primary bg-primary/5 ring-1 ring-primary/20" - : isDisabled - ? "border-destructive/30 bg-destructive/5 opacity-50 hover:opacity-75" - : "border-border bg-fd-background hover:border-primary/40 hover:bg-gradient-to-br hover:from-primary/6 hover:to-transparent hover:shadow-[0_0_10px_0px_hsl(var(--primary)/0.10)]", - )} - onClick={(e) => { - e.stopPropagation(); - handleTechSelect(group.category, tech.id); - }} - title={disabledReason || undefined} - > - <div className="absolute top-2 right-2 flex items-center gap-1"> - <TechResourceButtons - category={group.category} - techId={tech.id} - /> - {tech.default && !isSelected && ( - <span className="rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground"> - Default - </span> - )} - {tech.legacy && ( - <Tooltip> - <TooltipTrigger - onClick={(e) => e.stopPropagation()} - className="cursor-default" - > - <span className="rounded-sm border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 font-mono text-[9px] text-amber-500 dark:text-amber-400"> - Legacy - </span> - </TooltipTrigger> - <TooltipContent> - No longer actively maintained - </TooltipContent> - </Tooltip> - )} - </div> - <div className="flex items-start gap-3"> - {(tech.icon !== "" || ICON_REGISTRY[tech.id]) && ( - <div className="flex shrink-0 flex-col items-center gap-1"> - <div - className={cn( - "flex h-10 w-10 items-center justify-center rounded-lg transition-colors", - isSelected - ? "bg-primary/10" - : "bg-muted/50 group-hover:bg-muted", - )} - > - <TechIcon - techId={tech.id} - icon={tech.icon} - name={tech.name} - className="h-5 w-5" - /> - </div> - {tech.isNew && <NewToolLabel />} - </div> - )} - <div className="min-w-0 flex-1 pt-0.5"> - <span - className={cn( - "block font-semibold text-sm", - isSelected - ? "text-primary" - : "text-foreground", - )} - > - {tech.name} + <CategoryHint categoryKey={categoryKey} /> + <div className="space-y-4"> + {categoryOptionGroups.map((group) => ( + <div key={group.key}> + {group.heading && ( + <h3 className="mb-2 font-mono text-[10px] uppercase tracking-wider text-muted-foreground"> + {group.heading} + </h3> + )} + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 2xl:grid-cols-4"> + {group.options.map((tech) => { + const isSelected = isSelectedCheck( + stack, + group.category, + tech.id, + ); + const isDisabled = !isOptionCompatible( + stack, + group.category, + tech.id, + ); + const disabledReason = isDisabled + ? getDisabledReason(stack, group.category, tech.id) + : null; + + return ( + <motion.div + key={tech.id} + data-testid={`option-${group.category}-${tech.id}`} + className={cn( + "group relative cursor-pointer rounded-lg border p-3 transition-all sm:p-4", + isSelected + ? "border-primary bg-primary/5 ring-1 ring-primary/20" + : isDisabled + ? "border-destructive/30 bg-destructive/5 opacity-50 hover:opacity-75" + : "border-border bg-fd-background hover:border-primary/40 hover:bg-gradient-to-br hover:from-primary/6 hover:to-transparent hover:shadow-[0_0_10px_0px_hsl(var(--primary)/0.10)]", + )} + onClick={(e) => { + e.stopPropagation(); + handleTechSelect(group.category, tech.id); + }} + title={disabledReason || undefined} + > + <div className="absolute top-2 right-2 flex items-center gap-1"> + <TechResourceButtons + category={group.category} + techId={tech.id} + /> + {tech.default && !isSelected && ( + <span className="rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground"> + Default + </span> + )} + {tech.legacy && ( + <Tooltip> + <TooltipTrigger + onClick={(e) => e.stopPropagation()} + className="cursor-default" + > + <span className="rounded-sm border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 font-mono text-[9px] text-amber-500 dark:text-amber-400"> + Legacy </span> - <p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-relaxed"> - {tech.description} - </p> - {isDisabled && disabledReason && ( - <DisabledReasonInline - reason={disabledReason} - /> + </TooltipTrigger> + <TooltipContent> + No longer actively maintained + </TooltipContent> + </Tooltip> + )} + </div> + <div className="flex items-start gap-3"> + {(tech.icon !== "" || ICON_REGISTRY[tech.id]) && ( + <div className="flex shrink-0 flex-col items-center gap-1"> + <div + className={cn( + "flex h-10 w-10 items-center justify-center rounded-lg transition-colors", + isSelected + ? "bg-primary/10" + : "bg-muted/50 group-hover:bg-muted", )} + > + <TechIcon + techId={tech.id} + icon={tech.icon} + name={tech.name} + className="h-5 w-5" + /> </div> </div> - </motion.div> - ); - })} - </div> - </div> - ))} + )} + <div className="min-w-0 flex-1 pt-0.5"> + <span + className={cn( + "block font-semibold text-sm", + isSelected + ? "text-primary" + : "text-foreground", + )} + > + {tech.name} + </span> + <p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-relaxed"> + {tech.description} + </p> + {isDisabled && disabledReason && ( + <DisabledReasonInline reason={disabledReason} /> + )} + </div> + </div> + </motion.div> + ); + })} + </div> </div> - </motion.div> - )} - </AnimatePresence> - </section> - - {/* shadcn/ui Configuration - shown only when shadcn-ui is selected */} - {categoryKey === "uiLibrary" && ( - <AnimatePresence> - {stack.uiLibrary === "shadcn-ui" && ( - <motion.section - ref={(el) => { - sectionRefs.current.shadcnBase = el; + ))} + </div> + </motion.div> + )} + </AnimatePresence> + </section> + + {/* shadcn/ui Configuration - shown only when shadcn-ui is selected */} + {categoryKey === "uiLibrary" && ( + <AnimatePresence> + {stack.uiLibrary === "shadcn-ui" && ( + <motion.section + ref={(el) => { + sectionRefs.current.shadcnBase = el; + }} + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + transition={{ duration: 0.3, ease: "easeInOut" }} + data-testid="category-shadcnBase" + className="mb-6 scroll-mt-4 sm:mb-8 overflow-hidden" + > + <button + type="button" + onClick={() => toggleSection("shadcnBase")} + data-testid="category-toggle-shadcnBase" + className="mb-3 flex w-full items-center gap-2 border-b border-border pb-2 text-left transition-opacity hover:opacity-80" + > + <Terminal className="h-4 w-4 shrink-0 text-muted-foreground sm:h-5 sm:w-5" /> + <h2 className="flex-1 font-mono text-foreground text-sm sm:text-base"> + shadcn/ui Configuration + </h2> + <motion.div + animate={{ + rotate: collapsedSections.has("shadcnBase") ? 0 : 180, }} - initial={{ opacity: 0, height: 0 }} - animate={{ opacity: 1, height: "auto" }} - exit={{ opacity: 0, height: 0 }} - transition={{ duration: 0.3, ease: "easeInOut" }} - data-testid="category-shadcnBase" - className="mb-6 scroll-mt-4 sm:mb-8 overflow-hidden" + transition={{ duration: 0.2 }} > - <button - type="button" - onClick={() => toggleSection("shadcnBase")} - data-testid="category-toggle-shadcnBase" - className="mb-3 flex w-full items-center gap-2 border-b border-border pb-2 text-left transition-opacity hover:opacity-80" + <ChevronDown className="h-4 w-4 text-muted-foreground" /> + </motion.div> + </button> + <AnimatePresence initial={false}> + {!collapsedSections.has("shadcnBase") && ( + <motion.div + initial={{ height: 0, opacity: 0 }} + animate={{ height: "auto", opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ duration: 0.25, ease: "easeInOut" }} + className="overflow-hidden" > - <Terminal className="h-4 w-4 shrink-0 text-muted-foreground sm:h-5 sm:w-5" /> - <h2 className="flex-1 font-mono text-foreground text-sm sm:text-base"> - shadcn/ui Configuration - </h2> - <motion.div - animate={{ - rotate: collapsedSections.has("shadcnBase") ? 0 : 180, - }} - transition={{ duration: 0.2 }} - > - <ChevronDown className="h-4 w-4 text-muted-foreground" /> - </motion.div> - </button> - <AnimatePresence initial={false}> - {!collapsedSections.has("shadcnBase") && ( - <motion.div - initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} - exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.25, ease: "easeInOut" }} - className="overflow-hidden" - > - <div className="space-y-4"> - {( - [ - { - key: "shadcnBase" as const, - label: "Base Library", - }, - { - key: "shadcnStyle" as const, - label: "Visual Style", - }, - { - key: "shadcnIconLibrary" as const, - label: "Icon Library", - }, - { - key: "shadcnColorTheme" as const, - label: "Color Theme", - }, - { - key: "shadcnBaseColor" as const, - label: "Base Color", - }, - { key: "shadcnFont" as const, label: "Font" }, - { - key: "shadcnRadius" as const, - label: "Border Radius", - }, - ] as const - ).map(({ key, label }) => ( - <div key={key}> - <h3 className="mb-2 font-medium text-muted-foreground text-xs uppercase tracking-wider"> - {label} - </h3> - <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 2xl:grid-cols-5"> - {(TECH_OPTIONS[key] || []).map((tech) => { - const isSelected = - stack[key as keyof StackState] === tech.id; - return ( - <motion.div - key={tech.id} - data-testid={`option-${key}-${tech.id}`} - className={cn( - "group relative cursor-pointer rounded-lg border p-2.5 transition-all sm:p-3", - isSelected - ? "border-primary bg-primary/5 ring-1 ring-primary/20" - : "border-border bg-fd-background hover:border-primary/40 hover:bg-gradient-to-br hover:from-primary/6 hover:to-transparent hover:shadow-[0_0_10px_0px_hsl(var(--primary)/0.10)]", - )} - onClick={(e) => { - e.stopPropagation(); - handleTechSelect(key, tech.id); - }} - > - <div className="absolute top-1.5 right-1.5 flex items-center gap-1"> - <TechResourceButtons - category={key} - techId={tech.id} - /> - {tech.default && !isSelected && ( - <span className="rounded-full bg-muted px-1.5 py-0.5 font-medium text-[9px] text-muted-foreground"> - Default - </span> - )} + <div className="space-y-4"> + {( + [ + { + key: "shadcnBase" as const, + label: "Base Library", + }, + { + key: "shadcnStyle" as const, + label: "Visual Style", + }, + { + key: "shadcnIconLibrary" as const, + label: "Icon Library", + }, + { + key: "shadcnColorTheme" as const, + label: "Color Theme", + }, + { + key: "shadcnBaseColor" as const, + label: "Base Color", + }, + { key: "shadcnFont" as const, label: "Font" }, + { + key: "shadcnRadius" as const, + label: "Border Radius", + }, + ] as const + ).map(({ key, label }) => ( + <div key={key}> + <h3 className="mb-2 font-medium text-muted-foreground text-xs uppercase tracking-wider"> + {label} + </h3> + <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4 2xl:grid-cols-5"> + {(TECH_OPTIONS[key] || []).map((tech) => { + const isSelected = + stack[key as keyof StackState] === tech.id; + return ( + <motion.div + key={tech.id} + data-testid={`option-${key}-${tech.id}`} + className={cn( + "group relative cursor-pointer rounded-lg border p-2.5 transition-all sm:p-3", + isSelected + ? "border-primary bg-primary/5 ring-1 ring-primary/20" + : "border-border bg-fd-background hover:border-primary/40 hover:bg-gradient-to-br hover:from-primary/6 hover:to-transparent hover:shadow-[0_0_10px_0px_hsl(var(--primary)/0.10)]", + )} + onClick={(e) => { + e.stopPropagation(); + handleTechSelect(key, tech.id); + }} + > + <div className="absolute top-1.5 right-1.5 flex items-center gap-1"> + <TechResourceButtons + category={key} + techId={tech.id} + /> + {tech.default && !isSelected && ( + <span className="rounded-full bg-muted px-1.5 py-0.5 font-medium text-[9px] text-muted-foreground"> + Default + </span> + )} + </div> + <div className="flex items-start gap-2.5"> + {key === "shadcnColorTheme" || + key === "shadcnBaseColor" ? ( + <div className="flex shrink-0 flex-col items-center gap-1"> + <div + className={cn( + "flex h-8 w-8 items-center justify-center rounded-md transition-colors", + isSelected + ? "bg-primary/10" + : "bg-muted/50 group-hover:bg-muted", + )} + > + <div + className={cn( + "h-4 w-4 rounded-full bg-gradient-to-br", + tech.color, + )} + /> + </div> </div> - <div className="flex items-start gap-2.5"> - {key === "shadcnColorTheme" || - key === "shadcnBaseColor" ? ( - <div className="flex shrink-0 flex-col items-center gap-1"> - <div - className={cn( - "flex h-8 w-8 items-center justify-center rounded-md transition-colors", - isSelected - ? "bg-primary/10" - : "bg-muted/50 group-hover:bg-muted", - )} - > - <div - className={cn( - "h-4 w-4 rounded-full bg-gradient-to-br", - tech.color, - )} - /> - </div> - {tech.isNew && <NewToolLabel compact />} - </div> - ) : ( - (tech.icon !== "" || - ICON_REGISTRY[tech.id]) && ( - <div className="flex shrink-0 flex-col items-center gap-1"> - <div - className={cn( - "flex h-8 w-8 items-center justify-center rounded-md transition-colors", - isSelected - ? "bg-primary/10" - : "bg-muted/50 group-hover:bg-muted", - )} - > - <TechIcon - techId={tech.id} - icon={tech.icon} - name={tech.name} - className="h-4 w-4" - /> - </div> - {tech.isNew && <NewToolLabel compact />} - </div> - ) - )} - <div className="min-w-0 flex-1"> - <span + ) : ( + (tech.icon !== "" || + ICON_REGISTRY[tech.id]) && ( + <div className="flex shrink-0 flex-col items-center gap-1"> + <div className={cn( - "block font-semibold text-xs sm:text-sm", + "flex h-8 w-8 items-center justify-center rounded-md transition-colors", isSelected - ? "text-primary" - : "text-foreground", + ? "bg-primary/10" + : "bg-muted/50 group-hover:bg-muted", )} > - {tech.name} - </span> - <p className="mt-0.5 line-clamp-1 text-muted-foreground text-[10px] sm:text-xs leading-relaxed"> - {tech.description} - </p> + <TechIcon + techId={tech.id} + icon={tech.icon} + name={tech.name} + className="h-4 w-4" + /> + </div> </div> - </div> - </motion.div> - ); - })} - </div> - </div> - ))} + ) + )} + <div className="min-w-0 flex-1"> + <span + className={cn( + "block font-semibold text-xs sm:text-sm", + isSelected + ? "text-primary" + : "text-foreground", + )} + > + {tech.name} + </span> + <p className="mt-0.5 line-clamp-1 text-muted-foreground text-[10px] sm:text-xs leading-relaxed"> + {tech.description} + </p> + </div> + </div> + </motion.div> + ); + })} + </div> </div> - </motion.div> - )} - </AnimatePresence> - </motion.section> - )} - </AnimatePresence> + ))} + </div> + </motion.div> + )} + </AnimatePresence> + </motion.section> )} + </AnimatePresence> + )} - {/* Astro Integration - shown only when Astro is selected, right after webFrontend */} - {categoryKey === "webFrontend" && ( - <AnimatePresence> - {stack.webFrontend.includes("astro") && ( - <motion.section - ref={(el) => { - sectionRefs.current.astroIntegration = el; - }} - initial={{ opacity: 0, height: 0 }} - animate={{ opacity: 1, height: "auto" }} - exit={{ opacity: 0, height: 0 }} - transition={{ duration: 0.3, ease: "easeInOut" }} - data-testid="category-astroIntegration" - className="mb-6 scroll-mt-4 sm:mb-8 overflow-hidden" - > - <div className="mb-3 flex items-center gap-2 border-border border-b pb-2"> - <Terminal className="h-4 w-4 shrink-0 text-muted-foreground sm:h-5 sm:w-5" /> - <h2 className="font-mono text-foreground text-sm sm:text-base"> - Astro Integration - </h2> - </div> - <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 2xl:grid-cols-4"> - {(TECH_OPTIONS.astroIntegration || []).map((tech) => { - const isSelected = stack.astroIntegration === tech.id; - const isDisabled = !isOptionCompatible( - stack, - "astroIntegration", - tech.id, - ); - const disabledReason = isDisabled - ? getDisabledReason(stack, "astroIntegration", tech.id) - : null; - - return ( - <motion.div - key={tech.id} - data-testid={`option-astroIntegration-${tech.id}`} - className={cn( - "group relative cursor-pointer rounded-lg border p-3 transition-all sm:p-4", - isSelected - ? "border-primary bg-primary/5 ring-1 ring-primary/20" - : isDisabled - ? "border-destructive/30 bg-destructive/5 opacity-50 hover:opacity-75" - : "border-border bg-fd-background hover:border-primary/40 hover:bg-gradient-to-br hover:from-primary/6 hover:to-transparent hover:shadow-[0_0_10px_0px_hsl(var(--primary)/0.10)]", - )} - whileHover={{ scale: 1.01 }} - whileTap={{ scale: 0.99 }} - onClick={(e) => { - e.stopPropagation(); - handleTechSelect("astroIntegration", tech.id); - }} - title={disabledReason || undefined} - > - {tech.default && !isSelected && ( - <span className="absolute top-2 right-2 rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground"> - Default + {/* Astro Integration - shown only when Astro is selected, right after webFrontend */} + {categoryKey === "webFrontend" && ( + <AnimatePresence> + {stack.webFrontend.includes("astro") && ( + <motion.section + ref={(el) => { + sectionRefs.current.astroIntegration = el; + }} + initial={{ opacity: 0, height: 0 }} + animate={{ opacity: 1, height: "auto" }} + exit={{ opacity: 0, height: 0 }} + transition={{ duration: 0.3, ease: "easeInOut" }} + data-testid="category-astroIntegration" + className="mb-6 scroll-mt-4 sm:mb-8 overflow-hidden" + > + <div className="mb-3 flex items-center gap-2 border-border border-b pb-2"> + <Terminal className="h-4 w-4 shrink-0 text-muted-foreground sm:h-5 sm:w-5" /> + <h2 className="font-mono text-foreground text-sm sm:text-base"> + Astro Integration + </h2> + </div> + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 2xl:grid-cols-4"> + {(TECH_OPTIONS.astroIntegration || []).map((tech) => { + const isSelected = stack.astroIntegration === tech.id; + const isDisabled = !isOptionCompatible( + stack, + "astroIntegration", + tech.id, + ); + const disabledReason = isDisabled + ? getDisabledReason(stack, "astroIntegration", tech.id) + : null; + + return ( + <motion.div + key={tech.id} + data-testid={`option-astroIntegration-${tech.id}`} + className={cn( + "group relative cursor-pointer rounded-lg border p-3 transition-all sm:p-4", + isSelected + ? "border-primary bg-primary/5 ring-1 ring-primary/20" + : isDisabled + ? "border-destructive/30 bg-destructive/5 opacity-50 hover:opacity-75" + : "border-border bg-fd-background hover:border-primary/40 hover:bg-gradient-to-br hover:from-primary/6 hover:to-transparent hover:shadow-[0_0_10px_0px_hsl(var(--primary)/0.10)]", + )} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + onClick={(e) => { + e.stopPropagation(); + handleTechSelect("astroIntegration", tech.id); + }} + title={disabledReason || undefined} + > + {tech.default && !isSelected && ( + <span className="absolute top-2 right-2 rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground"> + Default + </span> + )} + {tech.legacy && ( + <Tooltip> + <TooltipTrigger + onClick={(e) => e.stopPropagation()} + className="absolute top-2 right-2 cursor-default" + > + <span className="rounded-sm border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 font-mono text-[9px] text-amber-500 dark:text-amber-400"> + Legacy </span> - )} - {tech.legacy && ( - <Tooltip> - <TooltipTrigger - onClick={(e) => e.stopPropagation()} - className="absolute top-2 right-2 cursor-default" - > - <span className="rounded-sm border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 font-mono text-[9px] text-amber-500 dark:text-amber-400"> - Legacy - </span> - </TooltipTrigger> - <TooltipContent> - No longer actively maintained - </TooltipContent> - </Tooltip> - )} - <div className="flex items-start gap-3"> - {(tech.icon !== "" || ICON_REGISTRY[tech.id]) && ( - <div className="flex shrink-0 flex-col items-center gap-1"> - <div - className={cn( - "flex h-10 w-10 items-center justify-center rounded-lg transition-colors", - isSelected - ? "bg-primary/10" - : "bg-muted/50 group-hover:bg-muted", - )} - > - <TechIcon - techId={tech.id} - icon={tech.icon} - name={tech.name} - className="h-5 w-5" - /> - </div> - {tech.isNew && <NewToolLabel />} - </div> - )} - <div className="min-w-0 flex-1 pt-0.5"> - <span - className={cn( - "block font-semibold text-sm", - isSelected ? "text-primary" : "text-foreground", - )} - > - {tech.name} - </span> - <p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-relaxed"> - {tech.description} - </p> - {isDisabled && disabledReason && ( - <DisabledReasonInline reason={disabledReason} /> + </TooltipTrigger> + <TooltipContent> + No longer actively maintained + </TooltipContent> + </Tooltip> + )} + <div className="flex items-start gap-3"> + {(tech.icon !== "" || ICON_REGISTRY[tech.id]) && ( + <div className="flex shrink-0 flex-col items-center gap-1"> + <div + className={cn( + "flex h-10 w-10 items-center justify-center rounded-lg transition-colors", + isSelected + ? "bg-primary/10" + : "bg-muted/50 group-hover:bg-muted", )} + > + <TechIcon + techId={tech.id} + icon={tech.icon} + name={tech.name} + className="h-5 w-5" + /> </div> </div> - </motion.div> - ); - })} - </div> - </motion.section> - )} - </AnimatePresence> + )} + <div className="min-w-0 flex-1 pt-0.5"> + <span + className={cn( + "block font-semibold text-sm", + isSelected ? "text-primary" : "text-foreground", + )} + > + {tech.name} + </span> + <p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-relaxed"> + {tech.description} + </p> + {isDisabled && disabledReason && ( + <DisabledReasonInline reason={disabledReason} /> + )} + </div> + </div> + </motion.div> + ); + })} + </div> + </motion.section> )} - </div> - ); - })} + </AnimatePresence> + )} + </div> + ); + })} - <div className="h-10" /> - </div> - </ScrollArea> + <div className="h-10" /> </div> - </div> - ) : viewMode === "preview" ? ( - <div className="min-h-0 flex-1 overflow-hidden"> - <Suspense - fallback={ - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Loading preview... - </div> - } - > - <PreviewPanel + ) : viewMode === "preview" ? ( + <div className="min-h-0 flex-1 overflow-hidden"> + <Suspense + fallback={ + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Loading preview... + </div> + } + > + <PreviewPanel + stack={adjustedStack || stack} + selectedFilePath={selectedFile || null} + onSelectFile={setSelectedFile} + /> + </Suspense> + </div> + ) : viewMode === "presets" ? ( + <div className="min-h-0 flex-1 overflow-hidden"> + <PresetsPanel stack={adjustedStack || stack} - selectedFilePath={selectedFile || null} - onSelectFile={setSelectedFile} + ecosystem={stack.ecosystem} + onApplyPreset={applyPreset} + onCustomizePreset={(presetId) => { + applyPreset(presetId); + setViewMode("command"); + }} /> - </Suspense> - </div> - ) : viewMode === "presets" ? ( - <div className="min-h-0 flex-1 overflow-hidden"> - <PresetsPanel - stack={adjustedStack || stack} - ecosystem={stack.ecosystem} - onApplyPreset={applyPreset} - onCustomizePreset={(presetId) => { - applyPreset(presetId); - setViewMode("command"); - }} - /> - </div> - ) : ( - <div className="min-h-0 flex-1 overflow-hidden"> - <SavedStacksPanel - entries={savedStacks} - onLoadEntry={loadSavedStack} - onOverwriteEntry={overwriteSavedStack} - onDeleteEntry={deleteSavedStack} - onRenameEntry={renameSavedStack} - onDuplicateEntry={duplicateSavedStack} - /> - </div> + </div> + ) : ( + <div className="min-h-0 flex-1 overflow-hidden"> + <SavedStacksPanel + entries={savedStacks} + onLoadEntry={loadSavedStack} + onOverwriteEntry={overwriteSavedStack} + onDeleteEntry={deleteSavedStack} + onRenameEntry={renameSavedStack} + onDuplicateEntry={duplicateSavedStack} + /> + </div> + )} + </main> + </div> + </div> + + {/* ─── Floating command bar ─────────────────────────────────────────── */} + <div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 bg-gradient-to-t from-background via-background/85 to-transparent px-4 pt-6 pb-4 sm:px-6 sm:pb-5"> + <div className="pointer-events-auto mx-auto flex w-full max-w-5xl items-center"> + {viewMode === "command" && ( + <button + type="button" + onClick={() => setSidebarOpen((open) => !open)} + aria-label="Toggle section navigation" + aria-pressed={sidebarOpen} + title="Section navigation" + className="mr-2.5 flex h-12 w-12 shrink-0 cursor-pointer items-center justify-center rounded-[14px] border border-transparent bg-[#18181B] text-[#FAFAF7] shadow-[0_1px_0_rgba(24,24,27,0.05),0_6px_18px_rgba(24,24,27,0.06)] transition-colors hover:bg-[#26262b] dark:border-white/10 dark:bg-[#1a1a1a] dark:hover:bg-[#242429]" + > + <PanelLeft className="h-4 w-4" /> + </button> )} - </main> + <div className="flex h-12 min-w-0 flex-1 items-center gap-2.5 rounded-[14px] border border-transparent bg-[#18181B] pr-1.5 pl-4 font-mono text-[12.5px] text-[#FAFAF7] shadow-[0_1px_0_rgba(24,24,27,0.05),0_6px_18px_rgba(24,24,27,0.06)] dark:border-white/10 dark:bg-[#1a1a1a]"> + <span className="shrink-0 font-medium text-[#C6E853] select-none">$</span> + <code + data-testid="command-output" + className="no-scrollbar min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[rgba(250,250,247,0.88)]" + > + {command} + </code> + <button + type="button" + onClick={copyToClipboard} + aria-label={copied ? "Command copied" : "Copy command"} + className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-[9px] bg-[#C6E853] px-3 font-mono text-[11.5px] font-semibold text-[#2A3303] transition-colors hover:bg-[#d2ee72]" + > + {copied ? <Check className="h-3 w-3" /> : <ClipboardCopy className="h-3 w-3" />} + {copied ? "Copied" : "Copy"} + </button> + </div> + {viewMode === "command" && ( + <button + type="button" + onClick={scrollToTop} + aria-label="Scroll to top" + title="Scroll to top" + tabIndex={showScrollTop ? 0 : -1} + aria-hidden={!showScrollTop} + className={cn( + "ml-2.5 flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] border border-transparent bg-[#18181B] text-[#FAFAF7] shadow-[0_1px_0_rgba(24,24,27,0.05),0_6px_18px_rgba(24,24,27,0.06)] transition-all duration-200 ease-out dark:border-white/10 dark:bg-[#1a1a1a]", + showScrollTop + ? "scale-100 cursor-pointer opacity-100 hover:bg-[#26262b] dark:hover:bg-[#242429]" + : "pointer-events-none scale-90 opacity-0", + )} + > + <ArrowUp className="h-4 w-4" /> + </button> + )} + </div> </div> + + {/* ─── Section navigation drawer (toggled, builder view only) ──────── */} + {viewMode === "command" && ( + <> + <button + type="button" + aria-label="Close section navigation" + tabIndex={sidebarOpen ? 0 : -1} + onClick={() => setSidebarOpen(false)} + className={cn( + "absolute inset-0 z-50 bg-black/30 transition-opacity duration-200", + sidebarOpen ? "opacity-100" : "pointer-events-none opacity-0", + )} + /> + <aside + aria-label="Section navigation" + inert={!sidebarOpen} + className={cn( + "absolute inset-y-0 left-0 z-50 flex w-64 max-w-[80%] flex-col border-border border-r bg-fd-background shadow-xl transition-transform duration-200 ease-out", + sidebarOpen ? "translate-x-0" : "-translate-x-full", + )} + > + <div className="flex shrink-0 items-center justify-between border-border border-b px-4 py-3"> + <span className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground"> + Sections + </span> + <button + type="button" + onClick={() => setSidebarOpen(false)} + aria-label="Close section navigation" + className="cursor-pointer rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + <X className="h-4 w-4" /> + </button> + </div> + <nav className="no-scrollbar min-h-0 flex-1 overflow-y-auto py-1.5"> + {navSections.map((section) => { + const count = getSelectedCount(section.key as keyof typeof TECH_OPTIONS, stack); + return ( + <button + key={section.key} + type="button" + onClick={() => goToSection(section.key)} + className="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-2 text-left font-mono text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + <span className="truncate">{section.name}</span> + {count > 0 && ( + <span className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-primary px-1.5 font-mono font-semibold text-[10px] text-primary-foreground"> + {count} + </span> + )} + </button> + ); + })} + </nav> + </aside> + </> + )} </div> </TooltipProvider> ); 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..049fd7158 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", @@ -398,7 +563,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 +1142,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 +1150,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 +1158,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 +3539,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, @@ -3587,6 +3752,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: [ { @@ -3836,7 +4097,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 +4105,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, }, @@ -3918,10 +4179,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", @@ -3950,13 +4218,19 @@ 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 export const ECOSYSTEM_CATEGORIES: Record<Ecosystem, TechCategory[]> = { typescript: [ "webFrontend", - "nativeFrontend", "astroIntegration", "cssFramework", "uiLibrary", @@ -3990,6 +4264,21 @@ export const ECOSYSTEM_CATEGORIES: Record<Ecosystem, TechCategory[]> = { "git", "install", ], + "react-native": [ + "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + "auth", + "packageManager", + "aiDocs", + "git", + "install", + ], rust: [ "rustWebFramework", "rustFrontend", @@ -4058,6 +4347,26 @@ export const ECOSYSTEM_CATEGORIES: Record<Ecosystem, TechCategory[]> = { "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 = [ @@ -4070,12 +4379,13 @@ 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" }, { 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"]; @@ -4767,6 +5077,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"], @@ -4802,6 +5113,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"], @@ -5243,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/src/lib/llms.ts b/apps/web/src/lib/llms.ts index cb3266052..72b07ffb3 100644 --- a/apps/web/src/lib/llms.ts +++ b/apps/web/src/lib/llms.ts @@ -34,10 +34,12 @@ 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", "/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..8ac7c5d44 100644 --- a/apps/web/src/lib/project-stats.generated.ts +++ b/apps/web/src/lib/project-stats.generated.ts @@ -6,5 +6,13 @@ 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 = 7; +export const ECOSYSTEM_NAMES = [ + "TypeScript", + "React Native", + "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..ee43fa669 100644 --- a/apps/web/src/lib/stack-utils.ts +++ b/apps/web/src/lib/stack-utils.ts @@ -10,7 +10,6 @@ import { createStackSearchParams } from "@/lib/stack-url-state.shared"; // TypeScript ecosystem category order const TYPESCRIPT_CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [ "webFrontend", - "nativeFrontend", "astroIntegration", "cssFramework", "uiLibrary", @@ -62,6 +61,23 @@ const TYPESCRIPT_CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [ "install", ]; +// React Native ecosystem category order +const REACT_NATIVE_CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [ + "nativeFrontend", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", + "auth", + "packageManager", + "aiDocs", + "git", + "install", +]; + // Rust ecosystem category order const RUST_CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [ "rustWebFramework", @@ -138,17 +154,61 @@ const JAVA_CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [ "install", ]; +const ELIXIR_CATEGORY_ORDER: Array<keyof typeof TECH_OPTIONS> = [ + "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([ ...TYPESCRIPT_CATEGORY_ORDER, + ...REACT_NATIVE_CATEGORY_ORDER, ...RUST_CATEGORY_ORDER, ...PYTHON_CATEGORY_ORDER, ...GO_CATEGORY_ORDER, ...JAVA_CATEGORY_ORDER, + ...ELIXIR_CATEGORY_ORDER, ]), ] as Array<keyof typeof TECH_OPTIONS>; +export function getCategoryOrderForEcosystem( + ecosystem: StackState["ecosystem"], +): Array<keyof typeof TECH_OPTIONS> { + 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 "elixir": + return ELIXIR_CATEGORY_ORDER; + case "typescript": + return TYPESCRIPT_CATEGORY_ORDER; + } +} + export function generateStackSummary(stack: StackState) { const selectedTechs = CATEGORY_ORDER.flatMap((category) => { const options = TECH_OPTIONS[category]; @@ -199,8 +259,10 @@ 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, 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..a46c28a2c 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<string, IconConfig> = { 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: "openid", 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" }, @@ -127,7 +161,6 @@ export const ICON_REGISTRY: Record<string, IconConfig> = { 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 +217,7 @@ export const ICON_REGISTRY: Record<string, IconConfig> = { 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 +382,6 @@ export const ICON_REGISTRY: Record<string, IconConfig> = { 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 +419,6 @@ export const ICON_REGISTRY: Record<string, IconConfig> = { "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/apps/web/src/lib/tech-resource-links.ts b/apps/web/src/lib/tech-resource-links.ts index 702b14b60..52f3dcf04 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", @@ -1125,6 +1177,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/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"); 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"); + }); +}); diff --git a/apps/web/test/go-ecosystem.test.ts b/apps/web/test/go-ecosystem.test.ts index d3fc9efc5..69743afbd 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", "elixir"]; 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 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..4878f34af 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", "elixir"]; 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 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..200d218ed 100644 --- a/apps/web/test/python-ecosystem.test.ts +++ b/apps/web/test/python-ecosystem.test.ts @@ -21,7 +21,7 @@ import { describe("Python Ecosystem Tab", () => { describe("Ecosystem Type", () => { it("should have typescript, rust, and python as valid ecosystem values", () => { - const ecosystems: Ecosystem[] = ["typescript", "rust", "python"]; + const ecosystems: Ecosystem[] = ["typescript", "react-native", "rust", "python", "go", "java", "elixir"]; expect(ecosystems).toContain("typescript"); expect(ecosystems).toContain("rust"); expect(ecosystems).toContain("python"); @@ -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..a98f39469 100644 --- a/apps/web/test/rust-ecosystem.test.ts +++ b/apps/web/test/rust-ecosystem.test.ts @@ -21,7 +21,7 @@ import { describe("Rust Ecosystem Tab", () => { describe("Ecosystem Type", () => { it("should have typescript and rust as valid ecosystem values", () => { - const ecosystems: Ecosystem[] = ["typescript", "rust"]; + const ecosystems: Ecosystem[] = ["typescript", "react-native", "rust", "python", "go", "java", "elixir"]; expect(ecosystems).toContain("typescript"); expect(ecosystems).toContain("rust"); }); @@ -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); }); }); diff --git a/apps/web/test/stack-command-parity.test.ts b/apps/web/test/stack-command-parity.test.ts index ebf350d9c..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, @@ -39,14 +55,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/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"]), + ); + }); }); diff --git a/packages/template-generator/src/core/template-processor.ts b/packages/template-generator/src/core/template-processor.ts index 0b0b7b802..028c522fd 100644 --- a/packages/template-generator/src/core/template-processor.ts +++ b/packages/template-generator/src/core/template-processor.ts @@ -73,6 +73,52 @@ Handlebars.registerHelper("projectNameWithClosingBrace", function (this: Project return `${this.projectName ?? ""}}`; }); +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; + } + } + + 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. */ 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..1a9d0089a 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,8 +72,11 @@ 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 + // 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 +111,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/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/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/src/processors/readme-generator.ts b/packages/template-generator/src/processors/readme-generator.ts index bdeb63087..09b6dd2e8 100644 --- a/packages/template-generator/src/processors/readme-generator.ts +++ b/packages/template-generator/src/processors/readme-generator.ts @@ -74,12 +74,57 @@ 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 hasPhoenix = config.elixirWebFramework !== "none"; + const hasEcto = config.elixirOrm !== "none"; + const features = [ + 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, + 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${hasPhoenix ? "/Phoenix" : ""} ecosystem. + +## Stack + +${features.map((feature) => `- ${feature}`).join("\n")} + +## Getting Started + +Make sure Elixir and Erlang/OTP${hasEcto ? ", and PostgreSQL" : ""} are installed. + +\`\`\`sh +mix deps.get +${hasEcto ? "mix ecto.setup\n" : ""}${hasPhoenix ? "mix phx.server" : "iex -S mix"} +\`\`\` + +${hasPhoenix ? "Open http://localhost:4000.\n\n" : ""}## Tests + +\`\`\`sh +mix test +\`\`\` + +${hasPhoenix || hasEcto ? "Copy `.env.example` values into your environment before production release builds.\n" : ""}`; +} + 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..9581c1e95 --- /dev/null +++ b/packages/template-generator/src/template-handlers/elixir-base.ts @@ -0,0 +1,74 @@ +import type { ProjectConfig } from "@better-fullstack/types"; + +import type { VirtualFileSystem } from "../core/virtual-fs"; +import type { TemplateData } from "./utils"; + +import { + isBinaryFile, + normalizeElixirAppName, + processTemplateString, + transformFilename, +} from "../core/template-processor"; + +export async function processElixirBaseTemplate( + vfs: VirtualFileSystem, + templates: TemplateData, + config: ProjectConfig, +): Promise<void> { + 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 = 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 = 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; + 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; + 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; + if (!hasHttpClient && templatePath.includes("/http_client.ex")) continue; + + const relativePath = templatePath.slice(prefix.length); + const outputPath = transformFilename(relativePath).replace( + /__elixirAppName__/g, + normalizeElixirAppName(config.projectName), + ); + + 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/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<void> { 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/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/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 e4033f246..dc0593dfe 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 a648e61f5..0d16b6726 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 7619dad04..722405e98 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 ( </Container> ); } +{{/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 <Stack />; } +{{/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() { </View> ); } +{{/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() { </View> ); } +{{/if}} 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/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<any, any, any> = createRouter({ model, messages: await convertToModelMessages(uiMessages), }); - return result.toUIMessageStreamResponse(); + return result.toUIMessageStreamResponse() as any; }, }) {{/if}} @@ -228,7 +228,7 @@ const router: Router<any, any, any> = createRouter({ model, messages: await convertToModelMessages(uiMessages), }); - return result.toUIMessageStreamResponse(); + return result.toUIMessageStreamResponse() as any; }, }) {{/if}}; 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/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/elixir-base/.env.example.hbs b/packages/template-generator/templates/elixir-base/.env.example.hbs new file mode 100644 index 000000000..a0cc64e1a --- /dev/null +++ b/packages/template-generator/templates/elixir-base/.env.example.hbs @@ -0,0 +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/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..49e529d32 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/README.md.hbs @@ -0,0 +1,40 @@ +# {{projectName}} + +{{#if (ne elixirWebFramework "none")}} +Phoenix project generated by Better Fullstack. +{{else}} +Elixir project generated by Better Fullstack. +{{/if}} + +## 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 +{{#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: + +```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..5ada23d12 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/config.exs.hbs @@ -0,0 +1,52 @@ +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}} + +{{#if (ne elixirWebFramework "none")}} +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"] +{{/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, + 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..bb22e8eb9 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/dev.exs.hbs @@ -0,0 +1,28 @@ +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, + code_reloader: true, + debug_errors: true, + secret_key_base: "dev-secret-key-base-replace-before-production", + watchers: [] +{{/if}} + +{{#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" +{{#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 new file mode 100644 index 000000000..261ba95cc --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/runtime.exs.hbs @@ -0,0 +1,29 @@ +import Config + +if config_env() == :prod do +{{#if (ne elixirOrm "none")}} + 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 +{{/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" + + 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 +{{/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 new file mode 100644 index 000000000..fc4d628c5 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/config/test.exs.hbs @@ -0,0 +1,20 @@ +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, + 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..70b64e9a7 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__.ex.hbs @@ -0,0 +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__/accounts.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs new file mode 100644 index 000000000..b04ea35c9 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts.ex.hbs @@ -0,0 +1,21 @@ +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) + |> 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..e08076429 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/accounts/user.ex.hbs @@ -0,0 +1,56 @@ +defmodule {{elixirModuleName}}.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :email, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + + timestamps(type: :utc_datetime) + end + + def registration_changeset(user, attrs, opts \\ []) do + user + |> 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__/application.ex.hbs b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs new file mode 100644 index 000000000..d2b98b6f6 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName__/application.ex.hbs @@ -0,0 +1,46 @@ +defmodule {{elixirModuleName}}.Application do + use Application + + @impl true + def start(_type, _args) do + children = [ +{{#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}} +{{#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}} +{{#if (ne elixirWebFramework "none")}} + {{elixirModuleName}}Web.Endpoint +{{/if}} + ] + + opts = [strategy: :one_for_one, name: {{elixirModuleName}}.Supervisor] + Supervisor.start_link(children, opts) + end + + @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/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 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="csrf-token" content={get_csrf_token()} /> + <title>{{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/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/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..fa17f4741 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/live/item_live/index.ex.hbs @@ -0,0 +1,65 @@ +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)} + + {: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 + 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..aa138f66b --- /dev/null +++ b/packages/template-generator/templates/elixir-base/lib/__elixirAppName___web/router.ex.hbs @@ -0,0 +1,56 @@ +defmodule {{elixirModuleName}}Web.Router do + use {{elixirModuleName}}Web, :router + + 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 + 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 (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}} + 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..6995f0780 --- /dev/null +++ b/packages/template-generator/templates/elixir-base/mix.exs.hbs @@ -0,0 +1,130 @@ +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 + [ +{{#if (ne elixirWebFramework "none")}} + {: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}} +{{#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 elixirAuth "phx-gen-auth")}} + {:bcrypt_elixir, "~> 3.0"}, +{{/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}} +{{#if (or (ne elixirWebFramework "none") (eq elixirJson "jason"))}} + {:jason, "~> 1.4"}, +{{/if}} +{{#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}} +{{#if (ne elixirWebFramework "none")}} + {:plug_cowboy, "~> 2.7"} +{{else}} + {:telemetry, "~> 1.3"} +{{/if}} + ] + 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/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/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..340fbdd54 100644 --- a/packages/template-generator/templates/frontend/fresh/deno.json.hbs +++ b/packages/template-generator/templates/frontend/fresh/deno.json.hbs @@ -1,9 +1,8 @@ { - "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 ." }, @@ -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", @@ -37,7 +40,7 @@ "deno.ns" ], "jsx": "precompile", - "jsxImportSource": "preact", + "jsxImportSource": "npm:preact@^10.27.2", "jsxPrecompileSkipElements": [ "a", "img", @@ -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/package.json.hbs b/packages/template-generator/templates/frontend/fresh/package.json.hbs index 25ca977fa..f38b79a4f 100644 --- a/packages/template-generator/templates/frontend/fresh/package.json.hbs +++ b/packages/template-generator/templates/frontend/fresh/package.json.hbs @@ -8,6 +8,15 @@ "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")}}, + "@tailwindcss/vite": "^4.1.12", + "tailwindcss": "^4.1.12"{{#if (eq uiLibrary "daisyui")}}, + "daisyui": "^5.5.19"{{/if}}{{/if}} } } 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"; 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 232e1aa74..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,49 +1,73 @@ { - "expo": { - "name": "{{projectName}}", - "slug": "{{projectName}}", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "mybetterfullstackapp", - "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.mybetterfullstackapp" - }, - "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 830467bcb..06165f5c8 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..df1a7e962 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,35 @@ {{/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", + {{#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")}} + "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 +70,15 @@ }, "devDependencies": { "@babel/core": "^7.29.0", - "@types/react": "^19.2.14" + "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", + "@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..577199480 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/App.tsx.hbs @@ -0,0 +1,103 @@ +{{#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"; +{{#if (eq mobileDeepLinking "expo-linking")}} +import { linking } from "@/lib/deep-linking"; +{{/if}} +{{#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 ( + + + + {{#if (eq mobileDeepLinking "expo-linking")}} + + {{else}} + + {{/if}} + + + + + + + ); +} + +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..41224a4b3 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/components/mobile-ui-provider.tsx.hbs @@ -0,0 +1,24 @@ +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..22cc063a4 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/lib/deep-linking.ts.hbs @@ -0,0 +1,25 @@ +{{#if (eq mobileDeepLinking "expo-linking")}} +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); +} +{{/if}} 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..07dcaf871 --- /dev/null +++ b/packages/template-generator/templates/frontend/native/base/navigation/native-navigation.tsx.hbs @@ -0,0 +1,153 @@ +{{#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}} +{{#if (eq mobileDeepLinking "expo-linking")}} +import { getAuthRedirectUri } from "@/lib/deep-linking"; +{{/if}} +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")}} + {{#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."} + {{/if}} + + ); +} + +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}} + + ); +} + +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 df6c47607..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": "mybetterfullstackapp", + "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.mybetterfullstackapp" + "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 bd2f25ce3..be5ebf020 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..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 @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} {{#if (includes examples "ai")}} import "@/polyfills"; {{/if}} @@ -28,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)", @@ -43,6 +51,14 @@ const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, { {{/if}} export default function RootLayout() { + {{#if (eq mobileOTA "expo-updates")}} + useUpdateCheck(); + {{/if}} + useEffect(() => { + {{#if (eq mobilePush "expo-notifications")}} + registerForPushNotificationsAsync().catch(console.warn); + {{/if}} + }, []); const { theme } = useUnistyles(); return ( @@ -167,3 +183,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..8c00eb777 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,64 @@ "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", + {{#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}} "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 +72,14 @@ "devDependencies": { "ajv": "^8.20.0", "@babel/core": "^7.29.0", - "@types/react": "^19.2.14" + "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", + "@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/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/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 8357e541e..9166198eb 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")}} @@ -27,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")}} @@ -82,7 +83,7 @@ return ( - + @@ -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..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 @@ -1,3 +1,4 @@ +{{#if (eq mobileNavigation "expo-router")}} {{#if (includes examples "ai")}} import "@/polyfills"; {{/if}} @@ -28,8 +29,15 @@ 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")}} +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 +70,14 @@ function StackLayout() { } export default function Layout() { + {{#if (eq mobileOTA "expo-updates")}} + useUpdateCheck(); + {{/if}} + useEffect(() => { + {{#if (eq mobilePush "expo-notifications")}} + registerForPushNotificationsAsync().catch(console.warn); + {{/if}} + }, []); return ( {{#if (eq backend "convex")}} {{#if (eq auth "clerk")}} @@ -130,3 +146,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..836abe32a 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,42 @@ "@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", + {{#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", + {{/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 +70,14 @@ }, "devDependencies": { "@types/node": "^25.8.0", - "@types/react": "^19.2.14" + "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", + "@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/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/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}} 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/template-generator/test/_fixtures/config-factory.ts b/packages/template-generator/test/_fixtures/config-factory.ts index 7eff2139d..1ae64c9aa 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", @@ -83,6 +90,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..fc7aa8f89 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" @@ -88,7 +95,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 +131,7 @@ export type CompatibilityAdjustment = { }; export type CompatibilityInput = { - ecosystem: "typescript" | "rust" | "python" | "go" | "java"; + ecosystem: "typescript" | "react-native" | "rust" | "python" | "go" | "java" | "elixir"; projectName: string | null; webFrontend: string[]; nativeFrontend: string[]; @@ -149,6 +171,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[]; @@ -194,6 +223,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[] = [ @@ -230,6 +274,13 @@ const TYPESCRIPT_CATEGORY_ORDER: CompatibilityCategory[] = [ "i18n", "search", "fileStorage", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", "animation", "cms", "codeQuality", @@ -272,6 +323,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 +451,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,9 +485,20 @@ 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)", + 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 +1167,139 @@ export const analyzeStackCompatibility = ( } } + 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 = [ + ["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") { @@ -1382,6 +1610,83 @@ export const analyzeStackCompatibility = ( } } + // ============================================ + // ELIXIR ECOSYSTEM CONSTRAINTS + // ============================================ + + if (nextStack.ecosystem === "elixir") { + if (nextStack.elixirWebFramework === "none") { + const dependentKeys: Array = [ + "elixirAuth", + "elixirApi", + "elixirRealtime", + "elixirObservability", + ]; + + for (const key of dependentKeys) { + 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' (requires Phoenix)`, + }); + } + } + } + + 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 // ============================================ @@ -1494,6 +1799,28 @@ 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"; + } + } + // ============================================ // NO BACKEND - locks down backend-dependent options // ============================================ @@ -2115,6 +2442,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 // ============================================ @@ -2341,6 +2712,115 @@ 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 (category === "elixirWebFramework" && optionId !== "none" && currentStack.ecosystem !== "elixir") { + return "Elixir web frameworks are available only in the Elixir ecosystem"; + } + + 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", + }, + 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", + }, + 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 (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"; + } + 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 +3437,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..05f3da8b6 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", @@ -87,6 +94,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 26bc879c5..76b7dd5f9 100644 --- a/packages/types/src/json-schema.ts +++ b/packages/types/src/json-schema.ts @@ -22,6 +22,21 @@ import { BetterFullstackConfigSchema, BetterTStackConfigSchema, InitResultSchema, + ElixirApiSchema, + ElixirAuthSchema, + ElixirCachingSchema, + ElixirDeploySchema, + ElixirEmailSchema, + ElixirHttpSchema, + ElixirJobsSchema, + ElixirJsonSchema, + ElixirObservabilitySchema, + ElixirOrmSchema, + ElixirQualitySchema, + ElixirRealtimeSchema, + ElixirTestingSchema, + ElixirValidationSchema, + ElixirWebFrameworkSchema, JavaAuthSchema, JavaBuildToolSchema, JavaLibrariesSchema, @@ -139,6 +154,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 { @@ -164,6 +239,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(), betterFullstackConfig: getBetterFullstackConfigJsonSchema(), diff --git a/packages/types/src/option-metadata.ts b/packages/types/src/option-metadata.ts index cf536474e..e0b1a33a6 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, @@ -19,6 +34,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 +132,13 @@ export type OptionCategory = | "cms" | "featureFlags" | "analytics" + | "mobileNavigation" + | "mobileUI" + | "mobileStorage" + | "mobileTesting" + | "mobilePush" + | "mobileOTA" + | "mobileDeepLinking" | "codeQuality" | "documentation" | "appPlatforms" @@ -158,7 +187,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"; @@ -302,6 +346,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, @@ -351,6 +402,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>>> = { @@ -480,6 +546,33 @@ const EXACT_LABEL_OVERRIDES: Partial>>> = @@ -871,6 +1031,13 @@ export const OPTION_CATEGORY_METADATA: Record; export const STACK_SELECTION_KEYS = Object.keys( @@ -514,6 +580,13 @@ const CLI_SCALAR_CONFIG_FIELDS = [ ["search", "search"], ["fileStorage", "fileStorage"], ["analytics", "analytics"], + ["mobileNavigation", "mobileNavigation"], + ["mobileUI", "mobileUI"], + ["mobileStorage", "mobileStorage"], + ["mobileTesting", "mobileTesting"], + ["mobilePush", "mobilePush"], + ["mobileOTA", "mobileOTA"], + ["mobileDeepLinking", "mobileDeepLinking"], ["featureFlags", "featureFlags"], ["fileUpload", "fileUpload"], ["git", "git"], @@ -561,6 +634,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 +725,34 @@ 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 REACT_NATIVE_CONFIG_KEYS = [ + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", +] as const satisfies readonly (keyof CliDefaultProjectConfigBase)[]; + const COMMAND_ADDONS = new Set([ "pwa", "tauri", @@ -837,6 +953,13 @@ function buildProjectConfigBase( observability: stack.observability as ProjectConfig["observability"], featureFlags: stack.featureFlags as ProjectConfig["featureFlags"], analytics: stack.analytics as ProjectConfig["analytics"], + mobileNavigation: stack.mobileNavigation as ProjectConfig["mobileNavigation"], + mobileUI: stack.mobileUI as ProjectConfig["mobileUI"], + mobileStorage: stack.mobileStorage as ProjectConfig["mobileStorage"], + mobileTesting: stack.mobileTesting as ProjectConfig["mobileTesting"], + mobilePush: stack.mobilePush as ProjectConfig["mobilePush"], + mobileOTA: stack.mobileOTA as ProjectConfig["mobileOTA"], + mobileDeepLinking: stack.mobileDeepLinking as ProjectConfig["mobileDeepLinking"], cms: stack.cms as ProjectConfig["cms"], caching: stack.caching as ProjectConfig["caching"], i18n: stack.i18n as ProjectConfig["i18n"], @@ -875,6 +998,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"], }; } @@ -917,12 +1055,14 @@ 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, + ...ELIXIR_CONFIG_KEYS, + ...(selection.ecosystem === "typescript" ? REACT_NATIVE_CONFIG_KEYS : []), ]) : new Set(); @@ -1029,6 +1169,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", @@ -1125,10 +1294,39 @@ 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); switch (selection.ecosystem) { + case "react-native": + return generateReactNativeCommand(selection, projectName); case "rust": return generateRustCommand(selection, projectName); case "python": @@ -1137,6 +1335,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 36a72c9e7..c2c0341be 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -44,6 +44,13 @@ import type { ObservabilitySchema, FeatureFlagsSchema, AnalyticsSchema, + MobileNavigationSchema, + MobileUISchema, + MobileStorageSchema, + MobileTestingSchema, + MobilePushSchema, + MobileOTASchema, + MobileDeepLinkingSchema, CMSSchema, CachingSchema, I18nSchema, @@ -81,6 +88,21 @@ import type { JavaAuthSchema, JavaLibrariesSchema, JavaTestingLibrariesSchema, + ElixirWebFrameworkSchema, + ElixirOrmSchema, + ElixirAuthSchema, + ElixirApiSchema, + ElixirRealtimeSchema, + ElixirJobsSchema, + ElixirValidationSchema, + ElixirHttpSchema, + ElixirJsonSchema, + ElixirEmailSchema, + ElixirCachingSchema, + ElixirObservabilitySchema, + ElixirTestingSchema, + ElixirQualitySchema, + ElixirDeploySchema, AiDocsSchema, ShadcnBaseSchema, ShadcnStyleSchema, @@ -128,6 +150,13 @@ export type Logging = z.infer; 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; @@ -165,6 +194,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/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", + }); + }); }); 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 6068c68b7..2a9c26d1c 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, @@ -33,6 +48,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, @@ -179,11 +201,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"))); } @@ -316,6 +333,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", @@ -425,6 +477,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(); @@ -452,7 +549,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", @@ -526,6 +628,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, @@ -533,6 +650,42 @@ function createValidationBase(projectName: string, draft: CandidateDraft): Proje }; } +function applyDerivedMobileDefaults(config: ProjectConfig, providedFlags: Set) { + if (config.ecosystem !== "typescript" && config.ecosystem !== "react-native") 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 +697,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); }); @@ -584,6 +739,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": @@ -592,6 +749,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.test.ts b/testing/lib/generate-combos/render.test.ts new file mode 100644 index 000000000..e2fe0fbf6 --- /dev/null +++ b/testing/lib/generate-combos/render.test.ts @@ -0,0 +1,61 @@ +import { createCliDefaultProjectConfigBase, type ProjectConfig } from "@better-fullstack/types"; +import { describe, expect, it } from "bun:test"; + +import { buildCommand } from "./render"; + +describe("smoke combo command rendering", () => { + it("includes mobile flags for React Native commands", () => { + const config: ProjectConfig = { + ...createCliDefaultProjectConfigBase("bun"), + projectName: "mobile-smoke", + relativePath: "mobile-smoke", + projectDir: "/tmp/mobile-smoke", + ecosystem: "react-native", + frontend: ["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", + ); + }); + + 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/generate-combos/render.ts b/testing/lib/generate-combos/render.ts index b4826c475..c6398358f 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"; @@ -29,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, @@ -73,6 +91,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 = @@ -131,6 +160,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], ]; @@ -142,6 +178,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], @@ -186,6 +234,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": @@ -207,6 +273,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; @@ -219,6 +288,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..fe6604c58 100644 --- a/testing/lib/generate-combos/types.ts +++ b/testing/lib/generate-combos/types.ts @@ -20,16 +20,18 @@ export type GeneratorArgs = { export const DEFAULT_ARGS: GeneratorArgs = { count: 10, - ecosystems: ["typescript", "rust", "python", "go", "java"], + ecosystems: ["typescript", "react-native", "rust", "python", "go", "java", "elixir"], installMode: "install", }; export const DEFAULT_ECOSYSTEM_WEIGHTS: Record = { typescript: 4, + "react-native": 2, rust: 2, python: 2, go: 2, java: 2, + elixir: 2, }; export const TEMPLATE_FINGERPRINT_KEYS = [ @@ -71,6 +73,13 @@ export const TEMPLATE_FINGERPRINT_KEYS = [ "caching", "search", "fileStorage", + "mobileNavigation", + "mobileUI", + "mobileStorage", + "mobileTesting", + "mobilePush", + "mobileOTA", + "mobileDeepLinking", "addons", "examples", "aiDocs", @@ -105,6 +114,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.test.ts b/testing/lib/presets.test.ts index e14fd9828..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,9 +28,12 @@ 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", + "preset-vinext-minimal", + "preset-vinext-basic", ]; describe("preset groups", () => { @@ -51,4 +55,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"); + } + }); }); diff --git a/testing/lib/presets.ts b/testing/lib/presets.ts index bb0433eab..7f6621eec 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; } @@ -516,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 = { @@ -529,6 +586,7 @@ const PRESET_GROUPS = { "python-fastapi-sqlalchemy", "go-gin-gorm", "java-spring-maven", + "elixir-plain-worker", "frontend-only-react-vite", ], "pr-broad": [ @@ -543,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 751d14a80..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/, @@ -204,6 +205,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 +322,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[] = []; @@ -422,12 +481,43 @@ 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 { switch (ecosystem) { case "typescript": return verifyTypeScript; + case "react-native": + return verifyReactNative; case "rust": return verifyRust; case "python": @@ -436,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 b3e142d70..8995c5b5e 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"; @@ -25,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 { @@ -68,7 +77,7 @@ function parseArgs(argv: string[]): SmokeTestArgs { i++; break; case "--ecosystem": - if (next && ["typescript", "rust", "python", "go", "java"].includes(next)) { + if (next && SUPPORTED_SMOKE_ECOSYSTEMS.includes(next as Ecosystem)) { args.ecosystem = next as Ecosystem; } i++; @@ -163,7 +172,7 @@ 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] : SUPPORTED_SMOKE_ECOSYSTEMS, installMode: "no-install", rng, forceOptions: args.forceOptions,