Repository: better-t-stack/create-better-t-stack Branch: main Commit: b214fd809a42 Files: 832 Total size: 3.4 MB Directory structure: gitextract_1fybze6m/ ├── .github/ │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ └── workflows/ │ ├── pr-preview.yaml │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .oxfmtrc.json ├── AGENTS.md ├── LICENSE ├── README.md ├── apps/ │ ├── cli/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── bunfig.toml │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── commands/ │ │ │ │ ├── history.ts │ │ │ │ └── meta.ts │ │ │ ├── constants.ts │ │ │ ├── helpers/ │ │ │ │ ├── addons/ │ │ │ │ │ ├── addons-setup.ts │ │ │ │ │ ├── evlog-setup.ts │ │ │ │ │ ├── fumadocs-setup.ts │ │ │ │ │ ├── mcp-setup.ts │ │ │ │ │ ├── oxlint-setup.ts │ │ │ │ │ ├── skills-setup.ts │ │ │ │ │ ├── starlight-setup.ts │ │ │ │ │ ├── tauri-setup.ts │ │ │ │ │ ├── tui-setup.ts │ │ │ │ │ ├── ultracite-setup.ts │ │ │ │ │ └── wxt-setup.ts │ │ │ │ ├── core/ │ │ │ │ │ ├── add-handler.ts │ │ │ │ │ ├── command-handlers.ts │ │ │ │ │ ├── convex-codegen.ts │ │ │ │ │ ├── create-project.ts │ │ │ │ │ ├── db-setup-options.ts │ │ │ │ │ ├── db-setup.ts │ │ │ │ │ ├── detect-project-config.ts │ │ │ │ │ ├── git.ts │ │ │ │ │ ├── install-dependencies.ts │ │ │ │ │ └── post-installation.ts │ │ │ │ └── database-providers/ │ │ │ │ ├── d1-setup.ts │ │ │ │ ├── docker-compose-setup.ts │ │ │ │ ├── mongodb-atlas-setup.ts │ │ │ │ ├── neon-setup.ts │ │ │ │ ├── planetscale-setup.ts │ │ │ │ ├── prisma-postgres-setup.ts │ │ │ │ ├── supabase-setup.ts │ │ │ │ └── turso-setup.ts │ │ │ ├── index.ts │ │ │ ├── mcp.ts │ │ │ ├── prompts/ │ │ │ │ ├── addons.ts │ │ │ │ ├── api.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── backend.ts │ │ │ │ ├── config-prompts.ts │ │ │ │ ├── database-setup.ts │ │ │ │ ├── database.ts │ │ │ │ ├── examples.ts │ │ │ │ ├── frontend.ts │ │ │ │ ├── git.ts │ │ │ │ ├── install.ts │ │ │ │ ├── navigable-group.ts │ │ │ │ ├── navigable.ts │ │ │ │ ├── orm.ts │ │ │ │ ├── package-manager.ts │ │ │ │ ├── payments.ts │ │ │ │ ├── project-name.ts │ │ │ │ ├── runtime.ts │ │ │ │ ├── server-deploy.ts │ │ │ │ └── web-deploy.ts │ │ │ ├── types.ts │ │ │ ├── utils/ │ │ │ │ ├── add-package-deps.ts │ │ │ │ ├── analytics.ts │ │ │ │ ├── bts-config.ts │ │ │ │ ├── command-exists.ts │ │ │ │ ├── compatibility-rules.ts │ │ │ │ ├── compatibility.ts │ │ │ │ ├── config-processing.ts │ │ │ │ ├── config-validation.ts │ │ │ │ ├── context.ts │ │ │ │ ├── display-config.ts │ │ │ │ ├── docker-utils.ts │ │ │ │ ├── env-utils.ts │ │ │ │ ├── errors.ts │ │ │ │ ├── external-commands.ts │ │ │ │ ├── file-formatter.ts │ │ │ │ ├── get-latest-cli-version.ts │ │ │ │ ├── get-package-manager.ts │ │ │ │ ├── input-hardening.ts │ │ │ │ ├── navigation.ts │ │ │ │ ├── open-url.ts │ │ │ │ ├── package-runner.ts │ │ │ │ ├── project-directory.ts │ │ │ │ ├── project-history.ts │ │ │ │ ├── project-name-validation.ts │ │ │ │ ├── render-title.ts │ │ │ │ ├── sponsors.ts │ │ │ │ ├── telemetry.ts │ │ │ │ ├── templates.ts │ │ │ │ ├── terminal-output.ts │ │ │ │ └── ts-morph.ts │ │ │ ├── validation.ts │ │ │ └── virtual.ts │ │ ├── test/ │ │ │ ├── add-handler.test.ts │ │ │ ├── addon-options.test.ts │ │ │ ├── addon-setup-regressions.test.ts │ │ │ ├── addons.test.ts │ │ │ ├── api.test.ts │ │ │ ├── auth.test.ts │ │ │ ├── backend-runtime.test.ts │ │ │ ├── basic-configurations.test.ts │ │ │ ├── benchmark.test.ts │ │ │ ├── clerk-matrix.test.ts │ │ │ ├── cli-validation.test.ts │ │ │ ├── cloudflare-db-clients.test.ts │ │ │ ├── database-orm.test.ts │ │ │ ├── database-setup.test.ts │ │ │ ├── db-setup-mode-resolution.test.ts │ │ │ ├── db-setup-options.test.ts │ │ │ ├── deployment.test.ts │ │ │ ├── dry-run.test.ts │ │ │ ├── electrobun-addon.test.ts │ │ │ ├── examples.test.ts │ │ │ ├── external-commands.test.ts │ │ │ ├── frontend.test.ts │ │ │ ├── index.test.ts │ │ │ ├── input-schemas.test.ts │ │ │ ├── integration.test.ts │ │ │ ├── mcp.test.ts │ │ │ ├── project-name-validation.test.ts │ │ │ ├── readme.test.ts │ │ │ ├── schema-command.test.ts │ │ │ ├── setup.ts │ │ │ ├── silent-create-output.test.ts │ │ │ ├── sponsors.test.ts │ │ │ ├── tauri-setup.test.ts │ │ │ ├── test-utils.ts │ │ │ ├── tui-setup.test.ts │ │ │ └── ultracite-setup.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ └── web/ │ ├── .eslintrc.json │ ├── .gitignore │ ├── .vercelignore │ ├── README.md │ ├── cli.json │ ├── components.json │ ├── content/ │ │ └── docs/ │ │ ├── analytics.mdx │ │ ├── bts-config.mdx │ │ ├── cli/ │ │ │ ├── agent-workflows.mdx │ │ │ ├── compatibility.mdx │ │ │ ├── index.mdx │ │ │ ├── meta.json │ │ │ ├── options.mdx │ │ │ ├── programmatic-api.mdx │ │ │ └── prompts.mdx │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── guides/ │ │ │ ├── cloudflare-alchemy.mdx │ │ │ ├── index.mdx │ │ │ └── meta.json │ │ ├── index.mdx │ │ ├── meta.json │ │ └── project-structure.mdx │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public/ │ │ ├── _headers │ │ ├── favicon/ │ │ │ └── site.webmanifest │ │ └── robots.txt │ ├── scripts/ │ │ └── generate-schema.ts │ ├── source.config.ts │ ├── src/ │ │ ├── app/ │ │ │ ├── (home)/ │ │ │ │ ├── _components/ │ │ │ │ │ ├── FeatureCard.tsx │ │ │ │ │ ├── code-container.tsx │ │ │ │ │ ├── command-section.tsx │ │ │ │ │ ├── footer.tsx │ │ │ │ │ ├── hero-section.tsx │ │ │ │ │ ├── icons.tsx │ │ │ │ │ ├── npm-package.tsx │ │ │ │ │ ├── shiny-text.tsx │ │ │ │ │ ├── sponsors-section.tsx │ │ │ │ │ ├── stats-section.tsx │ │ │ │ │ └── testimonials.tsx │ │ │ │ ├── analytics/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── analytics-header.tsx │ │ │ │ │ │ ├── analytics-helpers.ts │ │ │ │ │ │ ├── analytics-page.tsx │ │ │ │ │ │ ├── analytics-sources.tsx │ │ │ │ │ │ ├── chart-card.tsx │ │ │ │ │ │ ├── dev-environment-charts.tsx │ │ │ │ │ │ ├── evil-chart-utils.ts │ │ │ │ │ │ ├── live-logs.tsx │ │ │ │ │ │ ├── metrics-cards.tsx │ │ │ │ │ │ ├── preference-chart-card.tsx │ │ │ │ │ │ ├── section-header.tsx │ │ │ │ │ │ ├── stack-configuration-charts.tsx │ │ │ │ │ │ ├── timeline-charts.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── analytics-client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── new/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── action-buttons.tsx │ │ │ │ │ │ ├── code-viewer.tsx │ │ │ │ │ │ ├── file-explorer.tsx │ │ │ │ │ │ ├── get-badge-color.ts │ │ │ │ │ │ ├── preset-dropdown.tsx │ │ │ │ │ │ ├── preview-panel.tsx │ │ │ │ │ │ ├── share-button.tsx │ │ │ │ │ │ ├── special-sponsors-panel.tsx │ │ │ │ │ │ ├── stack-builder/ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── selected-stack-badges.tsx │ │ │ │ │ │ │ ├── tech-categories.tsx │ │ │ │ │ │ │ └── use-stack-builder.ts │ │ │ │ │ │ ├── tech-icon.tsx │ │ │ │ │ │ ├── utils.ts │ │ │ │ │ │ └── yolo-toggle.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── showcase/ │ │ │ │ │ ├── _components/ │ │ │ │ │ │ ├── ShowcaseItem.tsx │ │ │ │ │ │ └── showcase-page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── stack/ │ │ │ │ ├── _components/ │ │ │ │ │ └── stack-display.tsx │ │ │ │ └── page.tsx │ │ │ ├── api/ │ │ │ │ ├── preview/ │ │ │ │ │ └── route.ts │ │ │ │ └── search/ │ │ │ │ └── route.ts │ │ │ ├── docs/ │ │ │ │ ├── [[...slug]]/ │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── global.css │ │ │ ├── layout.config.tsx │ │ │ ├── layout.tsx │ │ │ ├── llms-full.txt/ │ │ │ │ └── route.ts │ │ │ ├── llms.mdx/ │ │ │ │ └── [[...slug]]/ │ │ │ │ └── route.ts │ │ │ ├── manifest.ts │ │ │ ├── not-found.tsx │ │ │ ├── og/ │ │ │ │ └── docs/ │ │ │ │ └── [...slug]/ │ │ │ │ └── route.tsx │ │ │ └── sitemap.ts │ │ ├── components/ │ │ │ ├── ai/ │ │ │ │ └── page-actions.tsx │ │ │ ├── evilcharts/ │ │ │ │ ├── charts/ │ │ │ │ │ ├── area-chart.tsx │ │ │ │ │ ├── bar-chart.tsx │ │ │ │ │ ├── line-chart.tsx │ │ │ │ │ ├── pie-chart.tsx │ │ │ │ │ ├── radar-chart.tsx │ │ │ │ │ └── radial-chart.tsx │ │ │ │ └── ui/ │ │ │ │ ├── background.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── dot.tsx │ │ │ │ ├── evil-brush.tsx │ │ │ │ ├── legend.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── providers.tsx │ │ │ ├── special-sponsor-banner.tsx │ │ │ ├── theme-toggle.tsx │ │ │ └── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── file-tree.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input.tsx │ │ │ ├── kibo-ui/ │ │ │ │ ├── code-block/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── server.tsx │ │ │ │ └── qr-code/ │ │ │ │ ├── index.tsx │ │ │ │ └── server.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── share-dialog.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── sonner.tsx │ │ │ ├── spinner.tsx │ │ │ ├── switch.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tech-badge.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── lib/ │ │ ├── constant.ts │ │ ├── get-llm-text.ts │ │ ├── sanitize-stack-addons.ts │ │ ├── search-config.ts │ │ ├── source.ts │ │ ├── sponsor-utils.ts │ │ ├── sponsors.ts │ │ ├── stack-url-keys.ts │ │ ├── stack-url-state.client.ts │ │ ├── stack-url-state.ts │ │ ├── stack-utils.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── test/ │ │ └── stack-builder-compatibility.test.ts │ └── tsconfig.json ├── bunfig.toml ├── changelogithub.config.ts ├── lefthook.yml ├── package.json ├── packages/ │ ├── backend/ │ │ ├── .gitignore │ │ ├── convex/ │ │ │ ├── README.md │ │ │ ├── _generated/ │ │ │ │ ├── api.d.ts │ │ │ │ ├── api.js │ │ │ │ ├── dataModel.d.ts │ │ │ │ ├── server.d.ts │ │ │ │ └── server.js │ │ │ ├── analytics.ts │ │ │ ├── analytics_date_utils.ts │ │ │ ├── convex.config.ts │ │ │ ├── healthCheck.ts │ │ │ ├── hooks.ts │ │ │ ├── http.ts │ │ │ ├── schema.ts │ │ │ ├── showcase.ts │ │ │ ├── stats.ts │ │ │ ├── testimonials.ts │ │ │ └── tsconfig.json │ │ └── package.json │ ├── create-bts/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── cli.js │ │ ├── index.d.ts │ │ ├── index.js │ │ └── package.json │ ├── template-generator/ │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── generate-templates.ts │ │ ├── src/ │ │ │ ├── bts-config.ts │ │ │ ├── core/ │ │ │ │ ├── template-processor.ts │ │ │ │ ├── template-reader.ts │ │ │ │ └── virtual-fs.ts │ │ │ ├── fs-writer.ts │ │ │ ├── generator.ts │ │ │ ├── index.ts │ │ │ ├── post-process/ │ │ │ │ ├── catalogs.ts │ │ │ │ ├── index.ts │ │ │ │ └── package-configs.ts │ │ │ ├── processors/ │ │ │ │ ├── addons-deps.ts │ │ │ │ ├── alchemy-plugins.ts │ │ │ │ ├── api-deps.ts │ │ │ │ ├── auth-deps.ts │ │ │ │ ├── auth-plugins.ts │ │ │ │ ├── backend-deps.ts │ │ │ │ ├── db-deps.ts │ │ │ │ ├── deploy-deps.ts │ │ │ │ ├── env-deps.ts │ │ │ │ ├── env-vars.ts │ │ │ │ ├── examples-deps.ts │ │ │ │ ├── frontend-deps.ts │ │ │ │ ├── index.ts │ │ │ │ ├── infra-deps.ts │ │ │ │ ├── nx-generator.ts │ │ │ │ ├── payments-deps.ts │ │ │ │ ├── pwa-plugins.ts │ │ │ │ ├── readme-generator.ts │ │ │ │ ├── runtime-deps.ts │ │ │ │ ├── turbo-generator.ts │ │ │ │ └── workspace-deps.ts │ │ │ ├── template-handlers/ │ │ │ │ ├── addons.ts │ │ │ │ ├── api.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── backend.ts │ │ │ │ ├── base.ts │ │ │ │ ├── database.ts │ │ │ │ ├── deploy.ts │ │ │ │ ├── examples.ts │ │ │ │ ├── extras.ts │ │ │ │ ├── frontend.ts │ │ │ │ ├── index.ts │ │ │ │ ├── packages.ts │ │ │ │ ├── payments.ts │ │ │ │ └── utils.ts │ │ │ ├── templates.generated.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── add-deps.ts │ │ │ ├── db-scripts.ts │ │ │ └── reproducible-command.ts │ │ ├── templates/ │ │ │ ├── addons/ │ │ │ │ ├── biome/ │ │ │ │ │ └── biome.json.hbs │ │ │ │ ├── electrobun/ │ │ │ │ │ └── apps/ │ │ │ │ │ └── desktop/ │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── electrobun.config.ts.hbs │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── src/ │ │ │ │ │ │ └── bun/ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ ├── husky/ │ │ │ │ │ └── .husky/ │ │ │ │ │ └── pre-commit │ │ │ │ ├── lefthook/ │ │ │ │ │ └── lefthook.yml.hbs │ │ │ │ └── pwa/ │ │ │ │ └── apps/ │ │ │ │ └── web/ │ │ │ │ ├── next/ │ │ │ │ │ ├── public/ │ │ │ │ │ │ └── favicon/ │ │ │ │ │ │ └── site.webmanifest.hbs │ │ │ │ │ └── src/ │ │ │ │ │ └── app/ │ │ │ │ │ └── manifest.ts.hbs │ │ │ │ └── vite/ │ │ │ │ └── pwa-assets.config.ts.hbs │ │ │ ├── api/ │ │ │ │ ├── orpc/ │ │ │ │ │ ├── fullstack/ │ │ │ │ │ │ ├── astro/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── pages/ │ │ │ │ │ │ │ └── rpc/ │ │ │ │ │ │ │ └── [...rest].ts.hbs │ │ │ │ │ │ ├── next/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ │ └── rpc/ │ │ │ │ │ │ │ └── [[...rest]]/ │ │ │ │ │ │ │ └── route.ts.hbs │ │ │ │ │ │ ├── nuxt/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ └── plugins/ │ │ │ │ │ │ │ │ ├── orpc.client.ts.hbs │ │ │ │ │ │ │ │ └── orpc.server.ts.hbs │ │ │ │ │ │ │ └── server/ │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ └── rpc/ │ │ │ │ │ │ │ ├── [...].ts.hbs │ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ │ ├── svelte/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── lib/ │ │ │ │ │ │ │ │ └── orpc.server.ts.hbs │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ └── rpc/ │ │ │ │ │ │ │ └── [...rest]/ │ │ │ │ │ │ │ └── +server.ts.hbs │ │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ └── rpc/ │ │ │ │ │ │ └── $.ts.hbs │ │ │ │ │ ├── native/ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── orpc.ts.hbs │ │ │ │ │ ├── server/ │ │ │ │ │ │ ├── _gitignore │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ ├── src/ │ │ │ │ │ │ │ ├── context.ts.hbs │ │ │ │ │ │ │ ├── index.ts.hbs │ │ │ │ │ │ │ └── routers/ │ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ │ └── web/ │ │ │ │ │ ├── astro/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── lib/ │ │ │ │ │ │ └── orpc.ts.hbs │ │ │ │ │ ├── nuxt/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── plugins/ │ │ │ │ │ │ ├── orpc.ts.hbs │ │ │ │ │ │ └── vue-query.ts.hbs │ │ │ │ │ ├── react/ │ │ │ │ │ │ └── base/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── orpc.ts.hbs │ │ │ │ │ ├── solid/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── orpc.ts.hbs │ │ │ │ │ └── svelte/ │ │ │ │ │ └── src/ │ │ │ │ │ └── lib/ │ │ │ │ │ └── orpc.ts.hbs │ │ │ │ └── trpc/ │ │ │ │ ├── fullstack/ │ │ │ │ │ ├── next/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ └── trpc/ │ │ │ │ │ │ └── [trpc]/ │ │ │ │ │ │ └── route.ts.hbs │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── api/ │ │ │ │ │ └── trpc/ │ │ │ │ │ └── $.ts.hbs │ │ │ │ ├── native/ │ │ │ │ │ └── utils/ │ │ │ │ │ └── trpc.ts.hbs │ │ │ │ ├── server/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── src/ │ │ │ │ │ │ ├── context.ts.hbs │ │ │ │ │ │ ├── index.ts.hbs │ │ │ │ │ │ └── routers/ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ └── web/ │ │ │ │ └── react/ │ │ │ │ └── base/ │ │ │ │ └── src/ │ │ │ │ └── utils/ │ │ │ │ └── trpc.ts.hbs │ │ │ ├── auth/ │ │ │ │ ├── better-auth/ │ │ │ │ │ ├── convex/ │ │ │ │ │ │ ├── backend/ │ │ │ │ │ │ │ └── convex/ │ │ │ │ │ │ │ ├── auth.config.ts.hbs │ │ │ │ │ │ │ ├── auth.ts.hbs │ │ │ │ │ │ │ ├── http.ts.hbs │ │ │ │ │ │ │ └── privateData.ts.hbs │ │ │ │ │ │ ├── native/ │ │ │ │ │ │ │ ├── bare/ │ │ │ │ │ │ │ │ └── components/ │ │ │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ │ └── lib/ │ │ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ │ │ ├── unistyles/ │ │ │ │ │ │ │ │ └── components/ │ │ │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ │ │ └── uniwind/ │ │ │ │ │ │ │ └── components/ │ │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ │ └── web/ │ │ │ │ │ │ └── react/ │ │ │ │ │ │ ├── next/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ │ │ └── auth/ │ │ │ │ │ │ │ │ │ └── [...all]/ │ │ │ │ │ │ │ │ │ └── route.ts.hbs │ │ │ │ │ │ │ │ └── dashboard/ │ │ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ │ └── lib/ │ │ │ │ │ │ │ ├── auth-client.ts.hbs │ │ │ │ │ │ │ └── auth-server.ts.hbs │ │ │ │ │ │ ├── react-router/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ │ ├── lib/ │ │ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ │ │ ├── tanstack-router/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ │ ├── lib/ │ │ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ ├── lib/ │ │ │ │ │ │ │ ├── auth-client.ts.hbs │ │ │ │ │ │ │ └── auth-server.ts.hbs │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── auth/ │ │ │ │ │ │ │ └── $.ts.hbs │ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ │ ├── fullstack/ │ │ │ │ │ │ ├── astro/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── env.d.ts.hbs │ │ │ │ │ │ │ ├── middleware.ts.hbs │ │ │ │ │ │ │ └── pages/ │ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ │ └── auth/ │ │ │ │ │ │ │ └── [...all].ts.hbs │ │ │ │ │ │ ├── next/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ │ └── auth/ │ │ │ │ │ │ │ └── [...all]/ │ │ │ │ │ │ │ └── route.ts.hbs │ │ │ │ │ │ ├── nuxt/ │ │ │ │ │ │ │ └── server/ │ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ │ └── auth/ │ │ │ │ │ │ │ └── [...all].ts.hbs │ │ │ │ │ │ ├── svelte/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── hooks.server.ts.hbs │ │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ └── auth/ │ │ │ │ │ │ └── $.ts.hbs │ │ │ │ │ ├── native/ │ │ │ │ │ │ ├── bare/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ │ └── components/ │ │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ └── lib/ │ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ │ ├── unistyles/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ │ └── components/ │ │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ │ └── uniwind/ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ └── components/ │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ ├── server/ │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ ├── _gitignore │ │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ │ ├── src/ │ │ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ │ │ └── db/ │ │ │ │ │ │ ├── drizzle/ │ │ │ │ │ │ │ ├── mysql/ │ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ │ └── auth.ts.hbs │ │ │ │ │ │ │ ├── postgres/ │ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ │ └── auth.ts.hbs │ │ │ │ │ │ │ └── sqlite/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ └── auth.ts.hbs │ │ │ │ │ │ ├── mongoose/ │ │ │ │ │ │ │ └── mongodb/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── models/ │ │ │ │ │ │ │ └── auth.model.ts.hbs │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ ├── mongodb/ │ │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ └── auth.prisma.hbs │ │ │ │ │ │ ├── mysql/ │ │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ └── auth.prisma.hbs │ │ │ │ │ │ ├── postgres/ │ │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ └── auth.prisma.hbs │ │ │ │ │ │ └── sqlite/ │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── auth.prisma.hbs │ │ │ │ │ └── web/ │ │ │ │ │ ├── astro/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── SignInForm.astro.hbs │ │ │ │ │ │ │ └── SignUpForm.astro.hbs │ │ │ │ │ │ ├── lib/ │ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ │ └── pages/ │ │ │ │ │ │ ├── dashboard.astro.hbs │ │ │ │ │ │ ├── login.astro.hbs │ │ │ │ │ │ └── signup.astro.hbs │ │ │ │ │ ├── nuxt/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── SignInForm.vue.hbs │ │ │ │ │ │ │ ├── SignUpForm.vue.hbs │ │ │ │ │ │ │ └── UserMenu.vue.hbs │ │ │ │ │ │ ├── middleware/ │ │ │ │ │ │ │ └── auth.ts.hbs │ │ │ │ │ │ ├── pages/ │ │ │ │ │ │ │ ├── dashboard.vue.hbs │ │ │ │ │ │ │ └── login.vue.hbs │ │ │ │ │ │ └── plugins/ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ ├── react/ │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── lib/ │ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ │ ├── next/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ ├── dashboard/ │ │ │ │ │ │ │ │ │ ├── dashboard.tsx.hbs │ │ │ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ │ │ │ └── login/ │ │ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ │ │ └── components/ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ ├── react-router/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ ├── dashboard.tsx.hbs │ │ │ │ │ │ │ └── login.tsx.hbs │ │ │ │ │ │ ├── tanstack-router/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ ├── dashboard.tsx.hbs │ │ │ │ │ │ │ └── login.tsx.hbs │ │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ ├── functions/ │ │ │ │ │ │ │ └── get-user.ts.hbs │ │ │ │ │ │ ├── middleware/ │ │ │ │ │ │ │ └── auth.ts.hbs │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ ├── dashboard.tsx.hbs │ │ │ │ │ │ └── login.tsx.hbs │ │ │ │ │ ├── solid/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── sign-in-form.tsx.hbs │ │ │ │ │ │ │ ├── sign-up-form.tsx.hbs │ │ │ │ │ │ │ └── user-menu.tsx.hbs │ │ │ │ │ │ ├── lib/ │ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ ├── dashboard.tsx.hbs │ │ │ │ │ │ └── login.tsx.hbs │ │ │ │ │ └── svelte/ │ │ │ │ │ └── src/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── SignInForm.svelte.hbs │ │ │ │ │ │ ├── SignUpForm.svelte.hbs │ │ │ │ │ │ └── UserMenu.svelte.hbs │ │ │ │ │ ├── lib/ │ │ │ │ │ │ └── auth-client.ts.hbs │ │ │ │ │ └── routes/ │ │ │ │ │ ├── dashboard/ │ │ │ │ │ │ └── +page.svelte.hbs │ │ │ │ │ └── login/ │ │ │ │ │ └── +page.svelte.hbs │ │ │ │ └── clerk/ │ │ │ │ ├── convex/ │ │ │ │ │ ├── backend/ │ │ │ │ │ │ └── convex/ │ │ │ │ │ │ ├── auth.config.ts.hbs │ │ │ │ │ │ └── privateData.ts.hbs │ │ │ │ │ ├── native/ │ │ │ │ │ │ └── base/ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ └── (auth)/ │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ │ └── components/ │ │ │ │ │ │ └── sign-out-button.tsx.hbs │ │ │ │ │ └── web/ │ │ │ │ │ └── react/ │ │ │ │ │ ├── next/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ └── dashboard/ │ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ │ └── proxy.ts.hbs │ │ │ │ │ ├── react-router/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ │ ├── tanstack-router/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ └── src/ │ │ │ │ │ ├── routes/ │ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ │ └── start.ts.hbs │ │ │ │ ├── native/ │ │ │ │ │ └── base/ │ │ │ │ │ ├── app/ │ │ │ │ │ │ └── (auth)/ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ ├── sign-in.tsx.hbs │ │ │ │ │ │ └── sign-up.tsx.hbs │ │ │ │ │ ├── components/ │ │ │ │ │ │ └── sign-out-button.tsx.hbs │ │ │ │ │ └── utils/ │ │ │ │ │ └── clerk-auth.ts.hbs │ │ │ │ └── web/ │ │ │ │ └── react/ │ │ │ │ ├── base/ │ │ │ │ │ └── src/ │ │ │ │ │ └── utils/ │ │ │ │ │ └── clerk-auth.ts.hbs │ │ │ │ ├── next/ │ │ │ │ │ └── src/ │ │ │ │ │ ├── app/ │ │ │ │ │ │ └── dashboard/ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ └── proxy.ts.hbs │ │ │ │ ├── react-router/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ ├── tanstack-router/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ └── tanstack-start/ │ │ │ │ └── src/ │ │ │ │ ├── routes/ │ │ │ │ │ └── dashboard.tsx.hbs │ │ │ │ └── start.ts.hbs │ │ │ ├── backend/ │ │ │ │ ├── convex/ │ │ │ │ │ └── packages/ │ │ │ │ │ └── backend/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── convex/ │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ ├── convex.config.ts.hbs │ │ │ │ │ │ ├── healthCheck.ts.hbs │ │ │ │ │ │ ├── schema.ts.hbs │ │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ │ └── package.json.hbs │ │ │ │ └── server/ │ │ │ │ ├── base/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ │ └── tsdown.config.ts.hbs │ │ │ │ ├── elysia/ │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ ├── express/ │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ ├── fastify/ │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ └── hono/ │ │ │ │ └── src/ │ │ │ │ └── index.ts.hbs │ │ │ ├── base/ │ │ │ │ ├── _gitignore │ │ │ │ ├── package.json.hbs │ │ │ │ └── tsconfig.json.hbs │ │ │ ├── db/ │ │ │ │ ├── base/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ ├── drizzle/ │ │ │ │ │ ├── base/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ ├── mysql/ │ │ │ │ │ │ ├── drizzle.config.ts.hbs │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ ├── postgres/ │ │ │ │ │ │ ├── drizzle.config.ts.hbs │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── index.ts.hbs │ │ │ │ │ └── sqlite/ │ │ │ │ │ ├── drizzle.config.ts.hbs │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ ├── mongoose/ │ │ │ │ │ └── mongodb/ │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ └── prisma/ │ │ │ │ ├── mongodb/ │ │ │ │ │ ├── prisma/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── schema.prisma.hbs │ │ │ │ │ ├── prisma.config.ts.hbs │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ ├── mysql/ │ │ │ │ │ ├── prisma/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── schema.prisma.hbs │ │ │ │ │ ├── prisma.config.ts.hbs │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ ├── postgres/ │ │ │ │ │ ├── prisma/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── schema.prisma.hbs │ │ │ │ │ ├── prisma.config.ts.hbs │ │ │ │ │ └── src/ │ │ │ │ │ └── index.ts.hbs │ │ │ │ └── sqlite/ │ │ │ │ ├── prisma/ │ │ │ │ │ └── schema/ │ │ │ │ │ └── schema.prisma.hbs │ │ │ │ ├── prisma.config.ts.hbs │ │ │ │ └── src/ │ │ │ │ └── index.ts.hbs │ │ │ ├── db-setup/ │ │ │ │ └── docker-compose/ │ │ │ │ ├── mongodb/ │ │ │ │ │ └── docker-compose.yml.hbs │ │ │ │ ├── mysql/ │ │ │ │ │ └── docker-compose.yml.hbs │ │ │ │ └── postgres/ │ │ │ │ └── docker-compose.yml.hbs │ │ │ ├── examples/ │ │ │ │ ├── ai/ │ │ │ │ │ ├── convex/ │ │ │ │ │ │ └── packages/ │ │ │ │ │ │ └── backend/ │ │ │ │ │ │ └── convex/ │ │ │ │ │ │ ├── agent.ts.hbs │ │ │ │ │ │ └── chat.ts.hbs │ │ │ │ │ ├── fullstack/ │ │ │ │ │ │ ├── next/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ │ └── ai/ │ │ │ │ │ │ │ └── route.ts.hbs │ │ │ │ │ │ ├── nuxt/ │ │ │ │ │ │ │ └── server/ │ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ │ └── ai.post.ts.hbs │ │ │ │ │ │ ├── svelte/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ │ └── ai/ │ │ │ │ │ │ │ └── +server.ts.hbs │ │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── api/ │ │ │ │ │ │ └── ai/ │ │ │ │ │ │ └── $.ts.hbs │ │ │ │ │ ├── native/ │ │ │ │ │ │ ├── bare/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ │ │ └── ai.tsx.hbs │ │ │ │ │ │ │ └── polyfills.js │ │ │ │ │ │ ├── unistyles/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ │ │ └── ai.tsx.hbs │ │ │ │ │ │ │ └── polyfills.js │ │ │ │ │ │ └── uniwind/ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ │ └── ai.tsx.hbs │ │ │ │ │ │ └── polyfills.js │ │ │ │ │ └── web/ │ │ │ │ │ ├── nuxt/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── pages/ │ │ │ │ │ │ └── ai.vue.hbs │ │ │ │ │ ├── react/ │ │ │ │ │ │ ├── next/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ │ └── ai/ │ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ │ ├── react-router/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ └── ai.tsx.hbs │ │ │ │ │ │ ├── tanstack-router/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ └── ai.tsx.hbs │ │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── ai.tsx.hbs │ │ │ │ │ └── svelte/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── ai/ │ │ │ │ │ └── +page.svelte.hbs │ │ │ │ └── todo/ │ │ │ │ ├── convex/ │ │ │ │ │ └── packages/ │ │ │ │ │ └── backend/ │ │ │ │ │ └── convex/ │ │ │ │ │ └── todos.ts.hbs │ │ │ │ ├── native/ │ │ │ │ │ ├── bare/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ └── todos.tsx.hbs │ │ │ │ │ ├── unistyles/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ │ └── todos.tsx.hbs │ │ │ │ │ └── uniwind/ │ │ │ │ │ └── app/ │ │ │ │ │ └── (drawer)/ │ │ │ │ │ └── todos.tsx.hbs │ │ │ │ ├── server/ │ │ │ │ │ ├── drizzle/ │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── routers/ │ │ │ │ │ │ │ └── todo.ts.hbs │ │ │ │ │ │ ├── mysql/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ └── todo.ts │ │ │ │ │ │ ├── postgres/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ │ └── todo.ts │ │ │ │ │ │ └── sqlite/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── todo.ts │ │ │ │ │ ├── mongoose/ │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ │ └── routers/ │ │ │ │ │ │ │ └── todo.ts.hbs │ │ │ │ │ │ └── mongodb/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── models/ │ │ │ │ │ │ └── todo.model.ts.hbs │ │ │ │ │ └── prisma/ │ │ │ │ │ ├── base/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routers/ │ │ │ │ │ │ └── todo.ts.hbs │ │ │ │ │ ├── mongodb/ │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── todo.prisma.hbs │ │ │ │ │ ├── mysql/ │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── todo.prisma.hbs │ │ │ │ │ ├── postgres/ │ │ │ │ │ │ └── prisma/ │ │ │ │ │ │ └── schema/ │ │ │ │ │ │ └── todo.prisma.hbs │ │ │ │ │ └── sqlite/ │ │ │ │ │ └── prisma/ │ │ │ │ │ └── schema/ │ │ │ │ │ └── todo.prisma.hbs │ │ │ │ └── web/ │ │ │ │ ├── astro/ │ │ │ │ │ └── src/ │ │ │ │ │ └── pages/ │ │ │ │ │ └── todos.astro.hbs │ │ │ │ ├── nuxt/ │ │ │ │ │ └── app/ │ │ │ │ │ └── pages/ │ │ │ │ │ └── todos.vue.hbs │ │ │ │ ├── react/ │ │ │ │ │ ├── next/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── todos/ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ ├── react-router/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── todos.tsx.hbs │ │ │ │ │ ├── tanstack-router/ │ │ │ │ │ │ └── src/ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ └── todos.tsx.hbs │ │ │ │ │ └── tanstack-start/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── todos.tsx.hbs │ │ │ │ ├── solid/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── todos.tsx.hbs │ │ │ │ └── svelte/ │ │ │ │ └── src/ │ │ │ │ └── routes/ │ │ │ │ └── todos/ │ │ │ │ └── +page.svelte.hbs │ │ │ ├── extras/ │ │ │ │ ├── _npmrc.hbs │ │ │ │ ├── bunfig.toml.hbs │ │ │ │ ├── env.d.ts.hbs │ │ │ │ └── pnpm-workspace.yaml │ │ │ ├── frontend/ │ │ │ │ ├── astro/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── astro.config.mjs.hbs │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── src/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ └── Header.astro.hbs │ │ │ │ │ │ ├── layouts/ │ │ │ │ │ │ │ └── Layout.astro.hbs │ │ │ │ │ │ ├── pages/ │ │ │ │ │ │ │ └── index.astro.hbs │ │ │ │ │ │ └── styles/ │ │ │ │ │ │ └── global.css │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ ├── native/ │ │ │ │ │ ├── bare/ │ │ │ │ │ │ ├── _gitignore │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ ├── (drawer)/ │ │ │ │ │ │ │ │ ├── (tabs)/ │ │ │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ │ │ ├── index.tsx.hbs │ │ │ │ │ │ │ │ │ └── two.tsx.hbs │ │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ │ ├── +not-found.tsx.hbs │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ └── modal.tsx.hbs │ │ │ │ │ │ ├── app.json.hbs │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── container.tsx.hbs │ │ │ │ │ │ │ ├── header-button.tsx.hbs │ │ │ │ │ │ │ └── tabbar-icon.tsx.hbs │ │ │ │ │ │ ├── lib/ │ │ │ │ │ │ │ ├── constants.ts.hbs │ │ │ │ │ │ │ └── use-color-scheme.ts.hbs │ │ │ │ │ │ ├── metro.config.js.hbs │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ │ ├── unistyles/ │ │ │ │ │ │ ├── _gitignore │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ ├── (drawer)/ │ │ │ │ │ │ │ │ ├── (tabs)/ │ │ │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ │ │ ├── index.tsx.hbs │ │ │ │ │ │ │ │ │ └── two.tsx.hbs │ │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ │ ├── +html.tsx.hbs │ │ │ │ │ │ │ ├── +not-found.tsx.hbs │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ └── modal.tsx.hbs │ │ │ │ │ │ ├── app.json.hbs │ │ │ │ │ │ ├── babel.config.js.hbs │ │ │ │ │ │ ├── breakpoints.ts.hbs │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── container.tsx.hbs │ │ │ │ │ │ │ ├── header-button.tsx.hbs │ │ │ │ │ │ │ └── tabbar-icon.tsx.hbs │ │ │ │ │ │ ├── index.js.hbs │ │ │ │ │ │ ├── metro.config.js.hbs │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ ├── theme.ts.hbs │ │ │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ │ │ └── unistyles.ts.hbs │ │ │ │ │ └── uniwind/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── app/ │ │ │ │ │ │ ├── (drawer)/ │ │ │ │ │ │ │ ├── (tabs)/ │ │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ │ ├── index.tsx.hbs │ │ │ │ │ │ │ │ └── two.tsx.hbs │ │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ ├── +not-found.tsx.hbs │ │ │ │ │ │ ├── _layout.tsx.hbs │ │ │ │ │ │ └── modal.tsx.hbs │ │ │ │ │ ├── app.json.hbs │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── container.tsx.hbs │ │ │ │ │ │ └── theme-toggle.tsx.hbs │ │ │ │ │ ├── contexts/ │ │ │ │ │ │ └── app-theme-context.tsx.hbs │ │ │ │ │ ├── global.css │ │ │ │ │ ├── metro.config.js.hbs │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ │ └── uniwind-env.d.ts │ │ │ │ ├── nuxt/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── app/ │ │ │ │ │ │ ├── app.config.ts.hbs │ │ │ │ │ │ ├── app.vue.hbs │ │ │ │ │ │ ├── assets/ │ │ │ │ │ │ │ └── css/ │ │ │ │ │ │ │ └── main.css │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ └── Header.vue.hbs │ │ │ │ │ │ ├── layouts/ │ │ │ │ │ │ │ └── default.vue.hbs │ │ │ │ │ │ └── pages/ │ │ │ │ │ │ └── index.vue.hbs │ │ │ │ │ ├── nuxt.config.ts.hbs │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── public/ │ │ │ │ │ │ └── robots.txt │ │ │ │ │ ├── server/ │ │ │ │ │ │ └── tsconfig.json │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ ├── react/ │ │ │ │ │ ├── next/ │ │ │ │ │ │ ├── next-env.d.ts.hbs │ │ │ │ │ │ ├── next.config.ts.hbs │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ ├── postcss.config.mjs.hbs │ │ │ │ │ │ ├── src/ │ │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ │ ├── layout.tsx.hbs │ │ │ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ │ │ │ └── components/ │ │ │ │ │ │ │ ├── mode-toggle.tsx.hbs │ │ │ │ │ │ │ ├── providers.tsx.hbs │ │ │ │ │ │ │ └── theme-provider.tsx.hbs │ │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ │ ├── react-router/ │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ ├── react-router.config.ts │ │ │ │ │ │ ├── src/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── mode-toggle.tsx.hbs │ │ │ │ │ │ │ │ └── theme-provider.tsx.hbs │ │ │ │ │ │ │ ├── root.tsx.hbs │ │ │ │ │ │ │ ├── routes/ │ │ │ │ │ │ │ │ └── _index.tsx.hbs │ │ │ │ │ │ │ └── routes.ts │ │ │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ │ │ └── vite.config.ts.hbs │ │ │ │ │ ├── tanstack-router/ │ │ │ │ │ │ ├── index.html.hbs │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ ├── src/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── mode-toggle.tsx.hbs │ │ │ │ │ │ │ │ └── theme-provider.tsx.hbs │ │ │ │ │ │ │ ├── main.tsx.hbs │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ ├── __root.tsx.hbs │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ │ │ └── vite.config.ts.hbs │ │ │ │ │ ├── tanstack-start/ │ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ │ ├── public/ │ │ │ │ │ │ │ └── robots.txt │ │ │ │ │ │ ├── src/ │ │ │ │ │ │ │ ├── router.tsx.hbs │ │ │ │ │ │ │ └── routes/ │ │ │ │ │ │ │ ├── __root.tsx.hbs │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ │ │ └── vite.config.ts.hbs │ │ │ │ │ └── web-base/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── components.json.hbs │ │ │ │ │ └── src/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── header.tsx.hbs │ │ │ │ │ │ └── loader.tsx.hbs │ │ │ │ │ └── index.css.hbs │ │ │ │ ├── solid/ │ │ │ │ │ ├── _gitignore │ │ │ │ │ ├── index.html │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── public/ │ │ │ │ │ │ └── robots.txt │ │ │ │ │ ├── src/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── header.tsx.hbs │ │ │ │ │ │ │ └── loader.tsx │ │ │ │ │ │ ├── main.tsx.hbs │ │ │ │ │ │ ├── routes/ │ │ │ │ │ │ │ ├── __root.tsx.hbs │ │ │ │ │ │ │ └── index.tsx.hbs │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ │ └── vite.config.ts.hbs │ │ │ │ └── svelte/ │ │ │ │ ├── _gitignore │ │ │ │ ├── _npmrc │ │ │ │ ├── package.json.hbs │ │ │ │ ├── src/ │ │ │ │ │ ├── app.css │ │ │ │ │ ├── app.d.ts.hbs │ │ │ │ │ ├── app.html │ │ │ │ │ ├── components/ │ │ │ │ │ │ └── Header.svelte.hbs │ │ │ │ │ ├── lib/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── routes/ │ │ │ │ │ ├── +layout.svelte.hbs │ │ │ │ │ └── +page.svelte.hbs │ │ │ │ ├── svelte.config.js.hbs │ │ │ │ ├── tsconfig.json.hbs │ │ │ │ └── vite.config.ts.hbs │ │ │ ├── packages/ │ │ │ │ ├── config/ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ └── tsconfig.base.json.hbs │ │ │ │ ├── env/ │ │ │ │ │ ├── package.json.hbs │ │ │ │ │ ├── src/ │ │ │ │ │ │ ├── cloudflare-local.ts.hbs │ │ │ │ │ │ ├── native.ts.hbs │ │ │ │ │ │ ├── server.ts.hbs │ │ │ │ │ │ └── web.ts.hbs │ │ │ │ │ └── tsconfig.json.hbs │ │ │ │ ├── infra/ │ │ │ │ │ ├── alchemy.run.ts.hbs │ │ │ │ │ └── package.json.hbs │ │ │ │ └── ui/ │ │ │ │ ├── components.json.hbs │ │ │ │ ├── package.json.hbs │ │ │ │ ├── postcss.config.mjs.hbs │ │ │ │ ├── src/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── button.tsx.hbs │ │ │ │ │ │ ├── card.tsx.hbs │ │ │ │ │ │ ├── checkbox.tsx.hbs │ │ │ │ │ │ ├── dropdown-menu.tsx.hbs │ │ │ │ │ │ ├── input.tsx.hbs │ │ │ │ │ │ ├── label.tsx.hbs │ │ │ │ │ │ ├── skeleton.tsx.hbs │ │ │ │ │ │ └── sonner.tsx.hbs │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ └── .gitkeep │ │ │ │ │ ├── lib/ │ │ │ │ │ │ └── utils.ts.hbs │ │ │ │ │ └── styles/ │ │ │ │ │ └── globals.css.hbs │ │ │ │ └── tsconfig.json.hbs │ │ │ └── payments/ │ │ │ └── polar/ │ │ │ ├── server/ │ │ │ │ └── base/ │ │ │ │ └── src/ │ │ │ │ └── lib/ │ │ │ │ └── payments.ts.hbs │ │ │ └── web/ │ │ │ ├── nuxt/ │ │ │ │ └── app/ │ │ │ │ └── pages/ │ │ │ │ └── success.vue.hbs │ │ │ ├── react/ │ │ │ │ ├── next/ │ │ │ │ │ └── src/ │ │ │ │ │ └── app/ │ │ │ │ │ └── success/ │ │ │ │ │ └── page.tsx.hbs │ │ │ │ ├── react-router/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── success.tsx.hbs │ │ │ │ ├── tanstack-router/ │ │ │ │ │ └── src/ │ │ │ │ │ └── routes/ │ │ │ │ │ └── success.tsx.hbs │ │ │ │ └── tanstack-start/ │ │ │ │ └── src/ │ │ │ │ ├── functions/ │ │ │ │ │ └── get-payment.ts.hbs │ │ │ │ └── routes/ │ │ │ │ └── success.tsx.hbs │ │ │ ├── solid/ │ │ │ │ └── src/ │ │ │ │ └── routes/ │ │ │ │ └── success.tsx.hbs │ │ │ └── svelte/ │ │ │ └── src/ │ │ │ └── routes/ │ │ │ └── success/ │ │ │ └── +page.svelte.hbs │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ └── types/ │ ├── package.json │ ├── src/ │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── json-schema.ts │ │ ├── schemas.ts │ │ └── types.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── scripts/ │ ├── bump-version.ts │ ├── canary-release.ts │ ├── cleanup-previews.ts │ ├── publish-smoke.ts │ └── release.ts ├── tsconfig.json └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contributing to Better-T-Stack Thank you for your interest in contributing to Better-T-Stack! This document provides guidelines and setup instructions for contributors. > **⚠️ Important**: Before starting work on any new features or major changes, please open an issue first to discuss your proposal and get approval. We don't want you to waste time on work that might not align with the project's direction or get merged. ## Project Structure This repository is organized as a monorepo containing: - **CLI**: [`apps/cli`](apps/cli) - The scaffolding CLI tool (`create-better-t-stack`) - **Documentation**: [`apps/web`](apps/web) - Official website and documentation ## Development Setup ### Prerequisites - Node.js (lts) - Bun (recommended) - Git ### Initial Setup 1. **Clone the repository** ```bash git clone https://github.com/AmanVarshney01/create-better-t-stack.git cd create-better-t-stack ``` 2. **Install dependencies** ```bash bun install ``` ### CLI Development 1. **Navigate to CLI directory** ```bash cd apps/cli ``` 2. **Link the CLI globally** (optional, for testing anywhere in your system) ```bash bun link ``` Now you can use `create-better-t-stack` from anywhere in your system. 3. **Start development server** ```bash bun dev ``` This runs tsdown build in watch mode, automatically rebuilding on changes. 4. **Test the CLI** Now go to anywhere else in your system (maybe like a test folder) and run: ```bash create-better-t-stack ``` This will run the locally installed CLI. ### Web Development 1. **Install dependencies** ```bash # from repo root bun i ``` 2. **Setup backend** ```bash cd packages/backend bun dev:setup # you can choose local development too in prompts ``` 3. **Configure environment** Copy the Convex URL from `packages/backend/.env.local` to `apps/web/.env`: ``` NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210/ ``` 4. **Set GitHub tokens** Now run `bun dev` in the root. It will complain about GitHub token, so run this in `packages/backend`: ```bash npx convex env set GITHUB_ACCESS_TOKEN=xxxxx npx convex env set GITHUB_WEBHOOK_SECRET=xxxxx ``` 5. **Start the documentation website** ```bash bun dev ``` This starts the Next.js development server for the documentation site. ## Contribution Guidelines ### Standard Contribution Steps 1. **Create an issue** (if one doesn't exist) - Describe the bug or feature request - Include steps to reproduce (for bugs) - Discuss the proposed solution 2. **Fork the repository** - Click the "Fork" button on GitHub - Clone your fork locally 3. **Create a feature branch** ```bash git checkout -b feature/your-feature-name # or git checkout -b fix/your-bug-fix ``` 4. **Make your changes** - Follow the existing code style - Update documentation as needed 5. **Test and format your changes** (see Testing section below) 6. **Commit your changes** ```bash git add . git commit -m "feat(web): add your feature description" # or git commit -m "fix(cli): fix your bug description" ``` 7. **Push to your fork** ```bash git push origin feature/your-feature-name ``` 8. **Create a Pull Request** - Link to the related issue - Describe your changes ### Testing **Before committing, make sure to test your changes:** ```bash # For CLI changes cd apps/cli bun dev bun run test # Lint and format files (from root, uses oxlint and oxfmt) bun run check ``` - **Manual testing**: Test your changes manually to ensure everything works as expected - For CLI changes: Test with different configurations and options - For web changes: Ensure the site builds and displays correctly ## Commit Conventions Use conventional commit messages with the appropriate scope: - `feat(cli): add new CLI feature` - `fix(cli): fix CLI bug` - `feat(web): add new web feature` - `fix(web): fix web bug` - `chore(web): update dependencies` ## Getting Help - Open an issue for bugs or feature requests - Join discussions for questions or ideas - Check existing issues and PRs for similar work - Join our [Discord](https://discord.gg/ZYsbjpDaM5) if you have any problems ## License By contributing to Better-T-Stack, you agree that your contributions will be licensed under the MIT License. ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [amanvarshney01] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/workflows/pr-preview.yaml ================================================ name: PR Preview on: pull_request_target: types: [labeled] permissions: contents: read pull-requests: write id-token: write concurrency: group: pr-preview-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: publish-preview: name: Publish Preview runs-on: ubuntu-latest if: github.event.label.name == 'preview' steps: - name: Checkout PR Code uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: package.json check-latest: true registry-url: https://registry.npmjs.org - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install Dependencies run: bun install --frozen-lockfile env: BTS_TELEMETRY: 0 - name: Verify NPM auth run: npm whoami env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Generate Preview Version id: version run: | PR_NUMBER=${{ github.event.pull_request.number }} COMMIT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) BASE_VERSION=$(jq -r '.version' apps/cli/package.json | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/') PREVIEW_VERSION="${BASE_VERSION}-pr${PR_NUMBER}.${COMMIT_SHA}" NPM_TAG="pr${PR_NUMBER}" echo "version=$PREVIEW_VERSION" >> $GITHUB_OUTPUT echo "tag=$NPM_TAG" >> $GITHUB_OUTPUT echo "pr=$PR_NUMBER" >> $GITHUB_OUTPUT echo "commit=$COMMIT_SHA" >> $GITHUB_OUTPUT echo "Preview version: $PREVIEW_VERSION" echo "NPM tag: $NPM_TAG" - name: Update types package version run: | cd packages/types jq --arg v "${{ steps.version.outputs.version }}" '.version = $v' package.json > tmp.json && mv tmp.json package.json - name: Build types package run: cd packages/types && bun run build - name: Publish types to NPM run: cd packages/types && npm publish --access public --provenance --tag ${{ steps.version.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 0 - name: Update template-generator package version and types dependency run: | cd packages/template-generator jq --arg v "${{ steps.version.outputs.version }}" '.version = $v | .dependencies["@better-t-stack/types"] = $v' package.json > tmp.json && mv tmp.json package.json - name: Build template-generator package run: cd packages/template-generator && bun run build - name: Publish template-generator to NPM run: cd packages/template-generator && npm publish --access public --provenance --tag ${{ steps.version.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 0 - name: Update CLI package version and dependencies run: | cd apps/cli jq --arg v "${{ steps.version.outputs.version }}" '.version = $v | .dependencies["@better-t-stack/types"] = $v | .dependencies["@better-t-stack/template-generator"] = $v' package.json > tmp.json && mv tmp.json package.json - name: Update create-bts alias package version run: | cd packages/create-bts jq --arg v "${{ steps.version.outputs.version }}" '.version = $v | .dependencies["create-better-t-stack"] = $v' package.json > tmp.json && mv tmp.json package.json - name: Build CLI run: cd apps/cli && bun run build env: BTS_TELEMETRY: 0 - name: Publish CLI to NPM run: cd apps/cli && npm publish --access public --provenance --tag ${{ steps.version.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 0 - name: Publish create-bts to NPM run: cd packages/create-bts && npm publish --access public --provenance --tag ${{ steps.version.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 0 - name: Find existing preview comment uses: peter-evans/find-comment@v3 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} comment-author: "github-actions[bot]" body-includes: "PR Preview Release" - name: Create or update PR comment uses: peter-evans/create-or-update-comment@v4 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} edit-mode: replace body: | ## PR Preview Release A preview version has been published for this PR. | Package | Version | NPM Tag | |---------|---------|---------| | `create-better-t-stack` | `${{ steps.version.outputs.version }}` | `pr${{ steps.version.outputs.pr }}` | | `create-bts` | `${{ steps.version.outputs.version }}` | `pr${{ steps.version.outputs.pr }}` | | `@better-t-stack/types` | `${{ steps.version.outputs.version }}` | `pr${{ steps.version.outputs.pr }}` | | `@better-t-stack/template-generator` | `${{ steps.version.outputs.version }}` | `pr${{ steps.version.outputs.pr }}` | **Commit:** `${{ steps.version.outputs.commit }}` ### Install ```bash # Using the PR tag (always gets latest for this PR) bunx create-better-t-stack@pr${{ steps.version.outputs.pr }} npx create-better-t-stack@pr${{ steps.version.outputs.pr }} # Using exact version bunx create-better-t-stack@${{ steps.version.outputs.version }} npx create-better-t-stack@${{ steps.version.outputs.version }} # Using the alias bunx create-bts@pr${{ steps.version.outputs.pr }} npx create-bts@pr${{ steps.version.outputs.pr }} ``` ### NPM Links - [create-better-t-stack@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/create-better-t-stack/v/${{ steps.version.outputs.version }}) - [create-bts@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/create-bts/v/${{ steps.version.outputs.version }}) - [@better-t-stack/types@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/@better-t-stack/types/v/${{ steps.version.outputs.version }}) - [@better-t-stack/template-generator@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/@better-t-stack/template-generator/v/${{ steps.version.outputs.version }}) --- *To publish a new preview after more commits, remove and re-add the `preview` label.* ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release on: push: branches: - main permissions: contents: write pull-requests: write id-token: write concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: release: name: Release to NPM if: startsWith(github.event.head_commit.message, 'chore(release):') runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout Code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: package.json check-latest: true registry-url: https://registry.npmjs.org - name: Extract version from commit message id: version run: | VERSION=$(echo "${{ github.event.head_commit.message }}" | sed -E 's/^chore\(release\): ([0-9]+\.[0-9]+\.[0-9]+).*/\1/') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Releasing version: $VERSION" - name: Validate version format run: | VERSION="${{ steps.version.outputs.version }}" if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: Invalid version format '$VERSION'. Expected semver (x.y.z)" exit 1 fi - name: Check if version already exists id: check-version run: | VERSION="${{ steps.version.outputs.version }}" if npm view create-better-t-stack@$VERSION version 2>/dev/null; then echo "Version $VERSION already exists on NPM" echo "exists=true" >> $GITHUB_OUTPUT else echo "Version $VERSION is new" echo "exists=false" >> $GITHUB_OUTPUT fi - name: Setup Bun if: steps.check-version.outputs.exists == 'false' uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install Dependencies if: steps.check-version.outputs.exists == 'false' run: bun install --frozen-lockfile - name: Verify NPM auth if: steps.check-version.outputs.exists == 'false' run: npm whoami env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Update types package version if: steps.check-version.outputs.exists == 'false' run: | VERSION="${{ steps.version.outputs.version }}" cd packages/types jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json - name: Build types package if: steps.check-version.outputs.exists == 'false' run: cd packages/types && bun run build - name: Update template-generator package version and types dependency if: steps.check-version.outputs.exists == 'false' run: | VERSION="${{ steps.version.outputs.version }}" cd packages/template-generator jq --arg v "$VERSION" --arg dep "^$VERSION" '.version = $v | .dependencies["@better-t-stack/types"] = $dep' package.json > tmp.json && mv tmp.json package.json - name: Build template-generator package if: steps.check-version.outputs.exists == 'false' run: cd packages/template-generator && bun run build - name: Update CLI types and template-generator dependencies and version if: steps.check-version.outputs.exists == 'false' run: | VERSION="${{ steps.version.outputs.version }}" cd apps/cli jq --arg v "^$VERSION" '.dependencies["@better-t-stack/types"] = $v | .dependencies["@better-t-stack/template-generator"] = $v' package.json > tmp.json && mv tmp.json package.json - name: Build CLI if: steps.check-version.outputs.exists == 'false' run: cd apps/cli && bun run build env: BTS_TELEMETRY: 1 CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }} - name: Update create-bts alias package version if: steps.check-version.outputs.exists == 'false' run: | VERSION="${{ steps.version.outputs.version }}" cd packages/create-bts jq --arg v "$VERSION" --arg dep "^$VERSION" '.version = $v | .dependencies["create-better-t-stack"] = $dep' package.json > tmp.json && mv tmp.json package.json - name: Enable pnpm via corepack if: steps.check-version.outputs.exists == 'false' run: corepack enable pnpm # Gate all publishes on the smoke so a failure here doesn't leave a # partial release on npm (e.g. types/template-generator published but # create-better-t-stack broken). - name: Publish smoke test (npm, pnpm, bun) if: steps.check-version.outputs.exists == 'false' run: bun run smoke:publish - name: Publish types to NPM if: steps.check-version.outputs.exists == 'false' run: cd packages/types && npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 1 CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }} - name: Publish template-generator to NPM if: steps.check-version.outputs.exists == 'false' run: cd packages/template-generator && npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 1 CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }} - name: Publish CLI to NPM if: steps.check-version.outputs.exists == 'false' run: cd apps/cli && npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 1 CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }} - name: Publish create-bts alias to NPM if: steps.check-version.outputs.exists == 'false' run: cd packages/create-bts && npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} BTS_TELEMETRY: 1 CONVEX_INGEST_URL: ${{ secrets.CONVEX_INGEST_URL }} - name: Create and push tag if: steps.check-version.outputs.exists == 'false' run: | VERSION="${{ steps.version.outputs.version }}" git config --local user.email "amanvarshney.work@gmail.com" git config --local user.name "Aman Varshney" if ! git rev-parse "v$VERSION" >/dev/null 2>&1; then git tag -a "v$VERSION" -m "Release v$VERSION" git push --tags else echo "Tag v$VERSION already exists, skipping" fi - name: Create GitHub Release if: steps.check-version.outputs.exists == 'false' run: | VERSION="${{ steps.version.outputs.version }}" if ! gh release view "v$VERSION" >/dev/null 2>&1; then bunx changelogithub else echo "Release v$VERSION already exists, skipping" fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Release Summary if: steps.check-version.outputs.exists == 'false' run: | VERSION="${{ steps.version.outputs.version }}" echo "## Release v$VERSION Complete!" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Published Packages" >> $GITHUB_STEP_SUMMARY echo "- [create-better-t-stack@$VERSION](https://www.npmjs.com/package/create-better-t-stack/v/$VERSION)" >> $GITHUB_STEP_SUMMARY echo "- [create-bts@$VERSION](https://www.npmjs.com/package/create-bts/v/$VERSION)" >> $GITHUB_STEP_SUMMARY echo "- [@better-t-stack/types@$VERSION](https://www.npmjs.com/package/@better-t-stack/types/v/$VERSION)" >> $GITHUB_STEP_SUMMARY echo "- [@better-t-stack/template-generator@$VERSION](https://www.npmjs.com/package/@better-t-stack/template-generator/v/$VERSION)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### GitHub Release" >> $GITHUB_STEP_SUMMARY echo "[v$VERSION](https://github.com/${{ github.repository }}/releases/tag/v$VERSION)" >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/test.yaml ================================================ name: Test on: pull_request: types: [opened, synchronize, reopened] concurrency: group: test-${{ github.head_ref }} cancel-in-progress: true jobs: test: name: Test Suite runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout Code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: package.json check-latest: true - name: Setup Git user run: | git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install Dependencies run: bun install --frozen-lockfile env: BTS_TELEMETRY: 0 - name: Build Types run: cd packages/types && bun run build - name: Build Template Generator run: cd packages/template-generator && bun run build - name: Build and Test CLI working-directory: apps/cli run: bun run build && bun run test:ci env: AGENT: 1 BTS_TELEMETRY: 0 - name: Enable pnpm via corepack run: corepack enable pnpm - name: Publish smoke test (npm, pnpm, bun) run: bun run smoke:publish ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies node_modules .pnp .pnp.js # Local env files .env .env.local .env.development.local .env.test.local .env.production.local # Testing coverage # Turbo .turbo # Vercel .vercel # Build Outputs out/ build dist # Debug npm-debug.log* yarn-debug.log* yarn-error.log* # Misc .DS_Store *.pem .vscode .env*.local .smoke .idea templates-binary # opensrc - source code for packages opensrc/ ================================================ FILE: .oxfmtrc.json ================================================ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "experimentalSortImports": { "order": "asc" }, "experimentalSortPackageJson": true, "ignorePatterns": [ "*.hbs", "apps/cli/templates/**", "packages/backend/convex/_generated/**", "packages/template-generator/src/templates.generated.ts" ] } ================================================ FILE: AGENTS.md ================================================ # Repository Guidelines ## Project Structure & Module Organization This repo is a Bun + Turborepo monorepo. - `apps/cli`: published CLI (`create-better-t-stack`), with source in `apps/cli/src` and tests in `apps/cli/test`. - `apps/web`: Next.js docs/site (`apps/web/src`, `apps/web/content/docs`, `apps/web/public`). - `packages/template-generator`: template generation engine used by the CLI. - `packages/types`: shared schemas/types. - `packages/backend`: Convex backend used by web features. ## Build, Test, and Development Commands - `bun install`: install workspace dependencies. - `bun dev:cli`: watch-build CLI package. - `bun dev:web`: run web app locally (`next dev --port 3333`). - `bun build`: build all packages/apps through Turbo. - `bun build:cli`: build only the CLI target. - `bun run check`: format + lint (`oxfmt . && oxlint .`). - `cd apps/cli && bun run test`: run CLI tests. ## Coding Style & Naming Conventions - Language: TypeScript (strict mode enabled across projects). - Modules: ESM-first (`"type": "module"` where applicable). - Formatting/linting: `oxfmt` and `oxlint`; run `bun run check` before committing. - File naming: prefer kebab-case files (for example `database-setup.ts`). - Symbols: `camelCase` for functions/variables, `PascalCase` for types/components. - Keep feature logic near domain folders (`helpers`, `utils`, `template-handlers`). ## Error Handling Conventions - In CLI code, prefer `better-result` over ad-hoc `try/catch` for recoverable flows. - Return typed `Result` and use `Result.ok`, `Result.err`, `Result.try`, and `Result.tryPromise`. - Reuse domain errors from `apps/cli/src/utils/errors.ts` (`CLIError`, `ProjectCreationError`, `UserCancelledError`) and convert thrown prompt errors at boundaries. ## Template Authoring (Handlebars) - Templates live in `packages/template-generator/templates` and use helpers from `packages/template-generator/src/core/template-processor.ts` (`eq`, `ne`, `and`, `or`, `includes`). - For conditional ORM-specific output, use helper form with quoted values: - `{{#if (eq orm "prisma")}}` - `{{else if (eq orm "drizzle")}}` - `{{/if}}` - Example: `packages/template-generator/templates/packages/infra/alchemy.run.ts.hbs`. - When files must contain literal `{{ ... }}` (Vue/JSX/template syntax), escape opening braces as `\{{` in `.hbs` files so Handlebars does not evaluate them. - Example: `packages/template-generator/templates/frontend/nuxt/app/pages/index.vue.hbs`. ## Testing Guidelines - Framework: `bun:test`. - Test files use `*.test.ts` naming (see `apps/cli/test` and `packages/template-generator/test`). - Add or update tests with behavior changes, especially prompt flows, template output, and config validation. - Keep tests deterministic; reuse shared setup utilities in `apps/cli/test/setup.ts`. ## Commit & Pull Request Guidelines - Use Conventional Commits with scope, matching history: - `feat(cli): ...`, `fix(web): ...`, `docs(cli): ...` - Open an issue/discussion before major feature work. - PRs should include: - clear summary, - linked issue (if applicable), - verification steps run (`bun run check`, relevant tests), - screenshots/GIFs for web UI changes. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Better T Stack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Better-T-Stack A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations
Vercel OSS Program ## Sponsors

Sponsors

![demo](https://github.com/user-attachments/assets/12fd4d67-8494-462a-8124-76670798308a) ## Philosophy - Roll your own stack: you pick only the parts you need, nothing extra. - Minimal templates: bare-bones scaffolds with zero bloat. - Latest dependencies: always use current, stable versions by default. - Free and open source: forever. ## Quick Start ```bash # Using bun (recommended) bun create better-t-stack@latest # Using pnpm pnpm create better-t-stack@latest # Using npm npx create-better-t-stack@latest ``` ## Features - Frontend: React (TanStack Router, React Router, TanStack Start), Next.js, Nuxt, Svelte, Solid, Astro, React Native (Bare, NativeWind, Unistyles), or none - Backend: Hono, Express, Fastify, Elysia, Self (fullstack web app), Convex, or none - API: tRPC or oRPC (or none) - Runtime: Bun, Node.js, or Cloudflare Workers - Databases: SQLite, PostgreSQL, MySQL, MongoDB (or none) - ORMs: Drizzle, Prisma, Mongoose (or none) - Auth: Better Auth or Clerk (optional) - Addons: Turborepo, Nx, PWA, Tauri, Electrobun, Biome, Lefthook, Husky, Starlight, Fumadocs, Ultracite, Oxlint, MCP, OpenTUI, WXT, Skills - Examples: Todo, AI - DB Setup: Turso, Neon, Supabase, Prisma PostgreSQL, MongoDB Atlas, Cloudflare D1, Docker - Web Deploy: Cloudflare Workers Type safety end-to-end, clean monorepo layout, and zero lock-in: you choose only what you need. ## Repository Structure This repository is organized as a monorepo containing: - **CLI**: [`apps/cli`](apps/cli) - The scaffolding CLI tool - **Documentation**: [`apps/web`](apps/web) - Official website and documentation ## Documentation Visit [better-t-stack.dev](https://better-t-stack.dev) for full documentation, guides, and examples. You can also use the visual Stack Builder at `https://better-t-stack.dev/new` to generate a command for your stack. ## Development ```bash # Clone the repository git clone https://github.com/AmanVarshney01/create-better-t-stack.git # Install dependencies bun install # Start CLI development bun dev:cli # Start website development bun dev:web ``` ## Want to contribute? Please read the Contribution Guide first and open an issue before starting new features to ensure alignment with project goals. - Docs: [`./apps/web/content/docs/contributing.mdx`](./apps/web/content/docs/contributing.mdx) - Repo guide: [`./.github/CONTRIBUTING.md`](./.github/CONTRIBUTING.md) ## Star History Star History Chart ================================================ FILE: apps/cli/.gitignore ================================================ /node_modules /dist .smoke ================================================ FILE: apps/cli/README.md ================================================ # Create Better-T-Stack CLI A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations ## Sponsors

Sponsors

![demo](https://cdn.jsdelivr.net/gh/amanvarshney01/create-better-t-stack@master/demo.gif) ## Quick Start Run without installing globally: ```bash # Using bun (recommended) bun create better-t-stack@latest # Using pnpm pnpm create better-t-stack@latest # Using npm npx create-better-t-stack@latest ``` Follow the prompts to configure your project or use the `--yes` flag for defaults. ## Features | Category | Options | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **TypeScript** | End-to-end type safety across all parts of your application | | **Frontend** | • React with TanStack Router
• React with React Router
• React with TanStack Start (SSR)
• Next.js
• SvelteKit
• Nuxt (Vue)
• SolidJS
• Astro
• React Native bare Expo
• React Native with NativeWind (via Expo)
• React Native with Unistyles (via Expo)
• None | | **Backend** | • Hono
• Express
• Elysia
• Fastify
• Self (fullstack inside the web app)
• Convex
• None | | **API Layer** | • tRPC (type-safe APIs)
• oRPC (OpenAPI-compatible type-safe APIs)
• None | | **Runtime** | • Bun
• Node.js
• Cloudflare Workers
• None | | **Database** | • SQLite
• PostgreSQL
• MySQL
• MongoDB
• None | | **ORM** | • Drizzle (TypeScript-first)
• Prisma (feature-rich)
• Mongoose (for MongoDB)
• None | | **Database Setup** | • Turso (SQLite)
• Cloudflare D1 (SQLite)
• Neon (PostgreSQL)
• Supabase (PostgreSQL)
• Prisma Postgres
• MongoDB Atlas
• None (manual setup) | | **Authentication** | • Better Auth
• Clerk | | **Styling** | Tailwind CSS with a shared shadcn/ui package for React web apps | | **Addons** | • PWA support
• Tauri (desktop applications)
• Electrobun (lightweight desktop shell)
• Starlight and Fumadocs (documentation sites)
• Biome, Oxlint, Ultracite (linting and formatting)
• Lefthook, Husky (Git hooks)
• evlog (request logging for server/fullstack backends)
• MCP, Skills (agent tooling)
• OpenTUI, WXT (platform extensions)
• Turborepo or Nx (monorepo orchestration) | | **Examples** | • Todo app
• AI Chat interface (using Vercel AI SDK) | | **Developer Experience** | • Automatic Git initialization
• Package manager choice (npm, pnpm, bun)
• Automatic dependency installation | ## Usage ```bash Usage: create-better-t-stack [project-directory] [options] Options: -V, --version Output the version number -y, --yes Use default configuration --template Use a template (mern, pern, t3, uniwind, none) --database Database type (none, sqlite, postgres, mysql, mongodb) --orm ORM type (none, drizzle, prisma, mongoose) --dry-run Validate configuration without writing files --auth Authentication (better-auth, clerk, none) --payments Payments provider (polar, none) --frontend Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, astro, native-bare, native-uniwind, native-unistyles, none) --addons Additional addons (pwa, tauri, electrobun, starlight, biome, lefthook, husky, mcp, turborepo, nx, fumadocs, ultracite, oxlint, opentui, wxt, skills, evlog, none) --examples Examples to include (todo, ai, none) --git Initialize git repository --no-git Skip git initialization --package-manager Package manager (npm, pnpm, bun) --install Install dependencies --no-install Skip installing dependencies --db-setup Database setup (turso, d1, neon, supabase, prisma-postgres, planetscale, mongodb-atlas, docker, none) --web-deploy Web deployment (cloudflare, none) --server-deploy Server deployment (cloudflare, none) --backend Backend framework (hono, express, fastify, elysia, convex, self, none) --runtime Runtime (bun, node, workers, none) --api API type (trpc, orpc, none) --directory-conflict Directory strategy (merge, overwrite, increment, error) --manual-db Skip automatic database setup prompts -h, --help Display help ``` ### Agent-Focused Commands ```bash # Raw JSON payload input (agent-friendly) create-better-t-stack create-json --input '{"projectName":"my-app","yes":true,"dryRun":true}' create-better-t-stack add-json --input '{"projectDir":"./my-app","addons":["wxt"],"addonOptions":{"wxt":{"template":"react"}}}' create-better-t-stack create-json --input '{"projectName":"db-app","database":"postgres","orm":"drizzle","dbSetup":"neon","dbSetupOptions":{"mode":"manual"}}' # Runtime schema/introspection output create-better-t-stack schema --name all create-better-t-stack schema --name createInput create-better-t-stack schema --name addInput create-better-t-stack schema --name addonOptions create-better-t-stack schema --name dbSetupOptions create-better-t-stack schema --name cli # Local stdio MCP server npx create-better-t-stack@latest mcp ``` To install Better T Stack into supported agent configs with `add-mcp` and avoid relying on a global CLI install: ```bash npx -y add-mcp@latest "npx -y create-better-t-stack@latest mcp" ``` When you scaffold with the `mcp` addon, Better T Stack itself can also be installed into supported agent configs through `add-mcp` using a package runner command instead of assuming a global CLI install. For Bun projects, the generated config uses the equivalent `bunx create-better-t-stack@latest mcp` server command inside `add-mcp`. For MCP project creation, prefer `install: false`. Long dependency installs can exceed common MCP client request timeouts, so the safest flow is to scaffold first and run your package manager install command afterward in the project directory. ## Telemetry This CLI collects anonymous usage data to help improve the tool. The data collected includes: - Configuration options selected - CLI version - Node.js version - Platform (OS) **Telemetry is enabled by default in published versions** to help us understand usage patterns and improve the tool. ### Disabling Telemetry You can disable telemetry by setting the `BTS_TELEMETRY_DISABLED` environment variable: ```bash # Disable telemetry for a single run BTS_TELEMETRY_DISABLED=1 npx create-better-t-stack # Disable telemetry globally in your shell profile (.bashrc, .zshrc, etc.) export BTS_TELEMETRY_DISABLED=1 ``` ## Examples Create a project with default configuration: ```bash npx create-better-t-stack --yes ``` Validate a command without writing files: ```bash npx create-better-t-stack --yes --dry-run ``` Create a project with specific options: ```bash npx create-better-t-stack --database postgres --orm drizzle --auth better-auth --addons pwa biome ``` Create a project with Elysia backend and Node.js runtime: ```bash npx create-better-t-stack --backend elysia --runtime node ``` Create a project with multiple frontend options (one web + one native): ```bash npx create-better-t-stack --frontend tanstack-router native-bare ``` Create a project with examples: ```bash npx create-better-t-stack --examples todo ai ``` Create a project with Turso database setup: ```bash npx create-better-t-stack --database sqlite --orm drizzle --db-setup turso ``` Create a project with Supabase PostgreSQL setup: ```bash npx create-better-t-stack --database postgres --orm drizzle --db-setup supabase --auth better-auth ``` Create a project with Convex backend: ```bash npx create-better-t-stack --backend convex --frontend tanstack-router ``` Create a project with documentation site: ```bash npx create-better-t-stack --addons starlight ``` Create a minimal TypeScript project with no backend: ```bash npx create-better-t-stack --backend none --frontend tanstack-router ``` Create a backend-only project with no frontend: ```bash npx create-better-t-stack --frontend none --backend hono --database postgres --orm drizzle ``` Create a simple frontend-only project: ```bash npx create-better-t-stack --backend none --frontend next --addons none --examples none ``` Create a Cloudflare Workers project: ```bash npx create-better-t-stack --backend hono --runtime workers --database sqlite --orm drizzle --db-setup d1 ``` Create a self-hosted fullstack project on Cloudflare with D1: ```bash npx create-better-t-stack --backend self --frontend next --api trpc --database sqlite --orm drizzle --db-setup d1 --web-deploy cloudflare ``` Create a minimal API-only project: ```bash npx create-better-t-stack --frontend none --backend hono --api trpc --database none --addons none ``` ## Compatibility Notes - **Convex backend**: Requires `database`, `orm`, `api`, `runtime`, and `server-deploy` to be `none`; auth can be `better-auth`, `clerk`, or `none` depending frontend compatibility - **Backend 'none'**: If selected, this option will force related options like API, ORM, database, authentication, and runtime to 'none'. Examples will also be disabled (set to none/empty). - **Frontend 'none'**: Creates a backend-only project. When selected, PWA, Tauri, Electrobun, and certain examples may be disabled. - **API 'none'**: Disables tRPC/oRPC setup. Can be used with backend frameworks for REST APIs or custom API implementations. - **Database 'none'**: Disables database setup and requires ORM to be `none`. - **ORM 'none'**: Can be used when you want to handle database operations manually or use a different ORM. - **Runtime 'none'**: Only available with Convex backend, backend `none`, or backend `self`. - **Cloudflare Workers runtime**: Only compatible with Hono backend. If a database is used, MongoDB is not supported. - **Cloudflare D1 setup**: Requires `sqlite` and either `--runtime workers --server-deploy cloudflare` or `--backend self --web-deploy cloudflare`. For `backend self`, D1 is supported on `next`, `tanstack-start`, `nuxt`, `svelte`, and `astro`. - **Addons 'none'**: Skips all addons. - **Examples 'none'**: Skips all example implementations (todo, AI chat). - **Nuxt, Svelte, SolidJS, and Astro** frontends are only compatible with oRPC API layer - **PWA support** requires TanStack Router, React Router, Next.js, or SolidJS - **Tauri desktop app** requires TanStack Router, React Router, TanStack Start, Next.js, Nuxt, SvelteKit, SolidJS, or Astro - **Electrobun desktop app** requires TanStack Router, React Router, TanStack Start, Next.js, Nuxt, SvelteKit, SolidJS, or Astro. Desktop packaging uses static web assets, so SSR-first frontends need a static/export build before desktop builds will work. - **AI example** is not compatible with Solid or Astro. With Convex backend, it also excludes Nuxt and Svelte. ## Project Structure The created project follows a clean monorepo structure: ``` my-better-t-app/ ├── apps/ │ ├── web/ # Frontend application │ ├── server/ # Backend API │ ├── native/ # (optional) Mobile application │ └── docs/ # (optional) Documentation site ├── packages/ # Shared packages └── README.md # Auto-generated project documentation ``` After project creation, you'll receive detailed instructions for next steps and additional setup requirements. ================================================ FILE: apps/cli/bunfig.toml ================================================ [test] # Preload setup file for global setup/teardown preload = ["./test/setup.ts"] # Per-test timeout (3 minutes for smoke tests) timeout = 180000 # Skip test files from coverage reports coverageSkipTestFiles = true # Exclude patterns from coverage coveragePathIgnorePatterns = ["test/**", "dist/**", "templates/**", "node_modules/**"] ================================================ FILE: apps/cli/package.json ================================================ { "name": "create-better-t-stack", "version": "3.28.0", "description": "A modern CLI tool for scaffolding end-to-end type-safe TypeScript projects with best practices and customizable configurations", "keywords": [ "better-auth", "better-t-stack", "biome", "boilerplate", "cli", "drizzle", "elysia", "expo", "fullstack", "hono", "monorepo", "prisma", "pwa", "react", "react-native", "shadcn", "starter", "tailwind", "tanstack", "tauri", "trpc", "turborepo", "type-safety", "typescript" ], "homepage": "https://better-t-stack.dev/", "license": "MIT", "author": "Aman Varshney", "repository": { "type": "git", "url": "git+https://github.com/AmanVarshney01/create-better-t-stack.git", "directory": "apps/cli" }, "bin": { "create-better-t-stack": "dist/cli.mjs" }, "files": [ "dist" ], "type": "module", "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, "./cli": { "import": "./dist/cli.mjs" }, "./virtual": { "types": "./dist/virtual.d.mts", "import": "./dist/virtual.mjs" } }, "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown --publint", "dev": "tsdown --watch", "check-types": "tsc --noEmit", "test": "bun run build && bun test", "test:watch": "bun run build && bun test --watch", "test:coverage": "bun run build && bun test --coverage", "test:ci": "bun run build && CI=1 bun test --bail=5", "prepublishOnly": "npm run build" }, "dependencies": { "@better-t-stack/template-generator": "workspace:*", "@better-t-stack/types": "workspace:*", "@clack/core": "^1.2.0", "@clack/prompts": "^1.2.0", "@modelcontextprotocol/sdk": "1.29.0", "@trpc/server": "^11.16.0", "better-result": "^2.8.2", "consola": "^3.4.2", "env-paths": "^4.0.0", "execa": "^9.6.1", "fs-extra": "^11.3.4", "gradient-string": "^3.0.0", "handlebars": "^4.7.9", "jsonc-parser": "^3.3.1", "oxfmt": "^0.46.0", "picocolors": "^1.1.1", "tinyglobby": "^0.2.15", "trpc-cli": "^0.14.0", "ts-morph": "^28.0.0", "yaml": "^2.8.3", "zod": "^4.3.6" }, "devDependencies": { "@types/bun": "^1.3.12", "@types/fs-extra": "^11.0.4", "@types/node": "^25.6.0", "publint": "^0.3.18", "tsdown": "^0.21.9", "typescript": "^6.0.3" } } ================================================ FILE: apps/cli/src/cli.ts ================================================ import { createBtsCli } from "./index"; import { startBtsMcpServer } from "./mcp"; const [, , command, ...args] = process.argv; if (command === "mcp") { if (args.includes("--help") || args.includes("-h")) { console.log(`Usage: create-better-t-stack mcp Start the Better T Stack MCP server over stdio. This command is intended to be launched by an MCP client, for example: create-better-t-stack mcp`); process.exit(0); } await startBtsMcpServer(); } else { await createBtsCli().run(); } ================================================ FILE: apps/cli/src/commands/history.ts ================================================ import { intro, log } from "@clack/prompts"; import pc from "picocolors"; import { clearHistory, getHistory, type ProjectHistoryEntry } from "../utils/project-history"; import { renderTitle } from "../utils/render-title"; export type HistoryCommandInput = { limit: number; clear: boolean; json: boolean; }; function formatStackSummary(entry: ProjectHistoryEntry): string { const parts: string[] = []; if (entry.stack.frontend.length > 0 && !entry.stack.frontend.includes("none")) { parts.push(entry.stack.frontend.join(", ")); } if (entry.stack.backend && entry.stack.backend !== "none") { parts.push(entry.stack.backend); } if (entry.stack.database && entry.stack.database !== "none") { parts.push(entry.stack.database); } if (entry.stack.orm && entry.stack.orm !== "none") { parts.push(entry.stack.orm); } return parts.length > 0 ? parts.join(" + ") : "minimal"; } function formatDate(isoString: string): string { const date = new Date(isoString); return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } export async function historyHandler(input: HistoryCommandInput): Promise { if (input.clear) { const clearResult = await clearHistory(); if (clearResult.isErr()) { log.warn(pc.yellow(clearResult.error.message)); return; } log.success(pc.green("Project history cleared.")); return; } const historyResult = await getHistory(input.limit); if (historyResult.isErr()) { log.warn(pc.yellow(historyResult.error.message)); return; } const entries = historyResult.value; if (entries.length === 0) { log.info(pc.dim("No projects in history yet.")); log.info(pc.dim("Create a project with: create-better-t-stack my-app")); return; } if (input.json) { console.log(JSON.stringify(entries, null, 2)); return; } renderTitle(); intro(pc.magenta(`Project History (${entries.length} entries)`)); for (const [index, entry] of entries.entries()) { const num = pc.dim(`${index + 1}.`); const name = pc.cyan(pc.bold(entry.projectName)); const stack = pc.dim(formatStackSummary(entry)); log.message(`${num} ${name}`); log.message(` ${pc.dim("Created:")} ${formatDate(entry.createdAt)}`); log.message(` ${pc.dim("Path:")} ${entry.projectDir}`); log.message(` ${pc.dim("Stack:")} ${stack}`); log.message(` ${pc.dim("Command:")} ${pc.dim(entry.reproducibleCommand)}`); log.message(""); } } ================================================ FILE: apps/cli/src/commands/meta.ts ================================================ import { intro, log } from "@clack/prompts"; import { Result } from "better-result"; import pc from "picocolors"; import { displayError } from "../utils/errors"; import { openUrl } from "../utils/open-url"; import { renderTitle } from "../utils/render-title"; import { displaySponsors, fetchSponsors } from "../utils/sponsors"; const DOCS_URL = "https://better-t-stack.dev/docs"; const BUILDER_URL = "https://better-t-stack.dev/new"; async function openExternalUrl(url: string, successMessage: string) { const result = await Result.tryPromise({ try: () => openUrl(url), catch: () => null, }); if (result.isOk()) { log.success(pc.blue(successMessage)); } else { log.message(`Please visit ${url}`); } } export async function showSponsorsCommand() { renderTitle(); intro(pc.magenta("Better-T-Stack Sponsors")); const sponsorsResult = await fetchSponsors(); if (sponsorsResult.isErr()) { displayError(sponsorsResult.error); process.exit(1); return; } displaySponsors(sponsorsResult.value); } export async function openDocsCommand() { await openExternalUrl(DOCS_URL, "Opened docs in your default browser."); } export async function openBuilderCommand() { await openExternalUrl(BUILDER_URL, "Opened builder in your default browser."); } ================================================ FILE: apps/cli/src/constants.ts ================================================ import path from "node:path"; import { fileURLToPath } from "node:url"; import { desktopWebFrontends } from "@better-t-stack/types"; import { getUserPkgManager } from "./utils/get-package-manager"; // Re-export from template-generator (single source of truth) export { dependencyVersionMap, type AvailableDependencies, } from "@better-t-stack/template-generator"; const __filename = fileURLToPath(import.meta.url); const distPath = path.dirname(__filename); export const PKG_ROOT = path.join(distPath, "../"); export const DEFAULT_CONFIG_BASE = { projectName: "my-better-t-app", relativePath: "my-better-t-app", frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", auth: "better-auth", payments: "none", addons: ["turborepo"], examples: [], git: true, install: true, dbSetup: "none", backend: "hono", runtime: "bun", api: "trpc", webDeploy: "none", serverDeploy: "none", } as const; export function getDefaultConfig() { return { ...DEFAULT_CONFIG_BASE, projectDir: path.resolve(process.cwd(), DEFAULT_CONFIG_BASE.projectName), packageManager: getUserPkgManager(), frontend: [...DEFAULT_CONFIG_BASE.frontend], addons: [...DEFAULT_CONFIG_BASE.addons], examples: [...DEFAULT_CONFIG_BASE.examples], }; } export const DEFAULT_CONFIG = getDefaultConfig(); export { desktopWebFrontends }; export const ADDON_COMPATIBILITY = { pwa: ["tanstack-router", "react-router", "solid", "next"], tauri: desktopWebFrontends, electrobun: desktopWebFrontends, biome: [], husky: [], lefthook: [], turborepo: [], nx: [], starlight: [], ultracite: [], mcp: [], oxlint: [], fumadocs: [], opentui: [], wxt: [], skills: [], evlog: [], none: [], } as const; ================================================ FILE: apps/cli/src/helpers/addons/addons-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import fs from "fs-extra"; import pc from "picocolors"; import { desktopWebFrontends, type ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; import { AddonSetupError, UserCancelledError } from "../../utils/errors"; import { cliConsola } from "../../utils/terminal-output"; import { setupEvlog } from "./evlog-setup"; import { setupFumadocs } from "./fumadocs-setup"; import { setupMcp } from "./mcp-setup"; import { setupOxlint } from "./oxlint-setup"; import { setupSkills } from "./skills-setup"; import { setupStarlight } from "./starlight-setup"; import { setupTauri } from "./tauri-setup"; import { setupTui } from "./tui-setup"; import { setupUltracite } from "./ultracite-setup"; import { setupWxt } from "./wxt-setup"; // Helper to run setup and handle Result async function runSetup( setupFn: () => Promise>, ): Promise { const result = await setupFn(); if (result.isErr()) { // Re-throw user cancellation to propagate up if (UserCancelledError.is(result.error)) { throw result.error; } // Log other errors but don't fail the overall project creation cliConsola.error(pc.red(result.error.message)); } } async function runAddonStep(addon: string, step: () => Promise): Promise { const result = await Result.tryPromise({ try: async () => step(), catch: (e) => new AddonSetupError({ addon, message: `Failed to set up ${addon}: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (result.isErr()) { cliConsola.error(pc.red(result.error.message)); } } export async function setupAddons(config: ProjectConfig) { const { addons, frontend, projectDir } = config; const hasWebFrontend = frontend.some((value) => (desktopWebFrontends as readonly string[]).includes(value), ); if (addons.includes("tauri") && hasWebFrontend) { await runSetup(() => setupTauri(config)); } const hasUltracite = addons.includes("ultracite"); const hasBiome = addons.includes("biome"); const hasHusky = addons.includes("husky"); const hasLefthook = addons.includes("lefthook"); const hasOxlint = addons.includes("oxlint"); if (hasUltracite) { const gitHooks: string[] = []; if (hasHusky) gitHooks.push("husky"); if (hasLefthook) gitHooks.push("lefthook"); await runSetup(() => setupUltracite(config, gitHooks)); } else { if (hasBiome) { await runAddonStep("biome", () => setupBiome(projectDir)); } if (hasOxlint) { await runSetup(() => setupOxlint(projectDir, config.packageManager)); } if (hasHusky || hasLefthook) { let linter: "biome" | "oxlint" | undefined; if (hasOxlint) { linter = "oxlint"; } else if (hasBiome) { linter = "biome"; } if (hasHusky) { await runAddonStep("husky", () => setupHusky(projectDir, linter)); } if (hasLefthook) { await runAddonStep("lefthook", () => setupLefthook(projectDir)); } } } if (addons.includes("starlight")) { await runSetup(() => setupStarlight(config)); } if (addons.includes("fumadocs")) { await runSetup(() => setupFumadocs(config)); } if (addons.includes("opentui")) { await runSetup(() => setupTui(config)); } if (addons.includes("wxt")) { await runSetup(() => setupWxt(config)); } if (addons.includes("skills")) { await runSetup(() => setupSkills(config)); } if (addons.includes("mcp")) { await runSetup(() => setupMcp(config)); } if (addons.includes("evlog")) { await runSetup(() => setupEvlog(config)); } } export async function setupBiome(projectDir: string) { await addPackageDependency({ devDependencies: ["@biomejs/biome"], projectDir, }); const packageJsonPath = path.join(projectDir, "package.json"); if (await fs.pathExists(packageJsonPath)) { const packageJson = await fs.readJson(packageJsonPath); packageJson.scripts = { ...packageJson.scripts, check: "biome check --write .", }; await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); } } export async function setupHusky(projectDir: string, linter?: "biome" | "oxlint") { await addPackageDependency({ devDependencies: ["husky", "lint-staged"], projectDir, }); const packageJsonPath = path.join(projectDir, "package.json"); if (await fs.pathExists(packageJsonPath)) { const packageJson = await fs.readJson(packageJsonPath); packageJson.scripts = { ...packageJson.scripts, prepare: "husky", }; if (linter === "oxlint") { packageJson["lint-staged"] = { "*": ["oxlint", "oxfmt --write"], }; } else if (linter === "biome") { packageJson["lint-staged"] = { "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": ["biome check --write ."], }; } else { packageJson["lint-staged"] = { "**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "", }; } await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); } } export async function setupLefthook(projectDir: string) { await addPackageDependency({ devDependencies: ["lefthook"], projectDir, }); // lefthook.yml is generated by template-generator from templates/addons/lefthook/ } ================================================ FILE: apps/cli/src/helpers/addons/evlog-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import fs from "fs-extra"; import type { Backend, Frontend, ProjectConfig } from "../../types"; import { AddonSetupError } from "../../utils/errors"; type EvlogBackend = Extract; type EvlogWebFrontend = Extract; const evlogBackends = ["hono", "express", "fastify", "elysia"] as const; const evlogWebFrontends = ["next", "nuxt", "svelte", "tanstack-start", "astro"] as const; function isEvlogBackend(backend: Backend): backend is EvlogBackend { return (evlogBackends as readonly Backend[]).includes(backend); } function getEvlogWebFrontend(frontends: Frontend[]): EvlogWebFrontend | undefined { return frontends.find((frontend): frontend is EvlogWebFrontend => (evlogWebFrontends as readonly Frontend[]).includes(frontend), ); } function shouldIdentifyWebAuth(config: ProjectConfig) { return config.auth === "better-auth" && config.backend === "self"; } function prependMissingImports(content: string, imports: string[]) { const missingImports = imports.filter((line) => !content.includes(line)); if (missingImports.length === 0) return content; const importBlock = `${missingImports.join("\n")}\n`; const referenceMatch = content.match(/^(?:\/\/\/ \n)+/); if (referenceMatch) { return `${referenceMatch[0]}${importBlock}${content.slice(referenceMatch[0].length)}`; } return `${importBlock}${content}`; } function addNamedImport(content: string, moduleName: string, names: string[]) { const importRegex = new RegExp( `import \\{([^}]+)\\} from "${moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}";`, ); const match = content.match(importRegex); if (!match) { return prependMissingImports(content, [`import { ${names.join(", ")} } from "${moduleName}";`]); } const existingNames = match[1] .split(",") .map((name) => name.trim()) .filter(Boolean); const nextNames = [...existingNames]; for (const name of names) { if (!nextNames.includes(name)) { nextNames.push(name); } } return content.replace(match[0], `import { ${nextNames.join(", ")} } from "${moduleName}";`); } function insertBeforeOnce( content: string, marker: string, snippet: string, alreadyPresent: string, ) { if (content.includes(alreadyPresent)) return content; if (!content.includes(marker)) return content; return content.replace(marker, `${snippet}${marker}`); } function insertAfterOnce(content: string, marker: string, snippet: string, alreadyPresent: string) { if (content.includes(alreadyPresent)) return content; if (!content.includes(marker)) return content; return content.replace(marker, `${marker}${snippet}`); } async function writeFileIfChanged(filePath: string, content: string) { const existing = (await fs.pathExists(filePath)) ? await fs.readFile(filePath, "utf-8") : undefined; if (existing === content) return; await fs.ensureDir(path.dirname(filePath)); await fs.writeFile(filePath, content); } async function updateFileIfExists(filePath: string, update: (content: string) => string) { if (!(await fs.pathExists(filePath))) return; const content = await fs.readFile(filePath, "utf-8"); const nextContent = update(content); if (nextContent !== content) { await fs.writeFile(filePath, nextContent); } } function usesCreateAuthFactory(config: ProjectConfig) { return ( config.runtime === "workers" || config.serverDeploy === "cloudflare" || (config.backend === "self" && config.webDeploy === "cloudflare") ); } function getAuthImportLine(config: ProjectConfig) { return usesCreateAuthFactory(config) ? `import { createAuth } from "@${config.projectName}/auth";` : `import { auth } from "@${config.projectName}/auth";`; } function getAuthExpression(config: ProjectConfig) { return usesCreateAuthFactory(config) ? "createAuth()" : "auth"; } function addAiSdkEvlogTelemetry(content: string, loggerExpression: string) { let nextContent = addNamedImport(content, "evlog/ai", [ "createAILogger", "createEvlogIntegration", ]); if (!nextContent.includes("const ai = createAILogger(")) { nextContent = nextContent.replace( /^(\s*)const model = wrapLanguageModel\({/m, (_match, indent: string) => `${indent}const ai = createAILogger(${loggerExpression});\n${indent}const model = wrapLanguageModel({`, ); } if (!nextContent.includes("model: ai.wrap(model)")) { nextContent = nextContent.replace( /(const result = streamText\({\n\s*)model,/, "$1model: ai.wrap(model),", ); } if (!nextContent.includes("createEvlogIntegration(ai)")) { nextContent = nextContent.replace( /(messages:\s*await convertToModelMessages\([^)]+\),?)/, (match) => `${match.endsWith(",") ? match : `${match},`}\n\t\texperimental_telemetry: {\n\t\t\tisEnabled: true,\n\t\t\tintegrations: [createEvlogIntegration(ai)],\n\t\t},`, ); } return nextContent; } function addEvlogBetterAuthServerSetup( content: string, backend: EvlogBackend, authExpression: string, ) { let nextContent = addNamedImport(content, "evlog/better-auth", [ "createAuthMiddleware", "type BetterAuthInstance", ]); const usesAuthFactory = authExpression.endsWith("()"); const evlogAuthExpression = `${authExpression} as BetterAuthInstance`; const authOptions = '{ exclude: ["/api/auth/**"], maskEmail: true }'; const identifySnippet = usesAuthFactory ? "" : `const identifyUser = createAuthMiddleware(${evlogAuthExpression}, ${authOptions});\n\n`; const identifyUserSetup = usesAuthFactory ? `\n\tconst identifyUser = createAuthMiddleware(${evlogAuthExpression}, ${authOptions});` : ""; if (backend === "hono") { nextContent = insertBeforeOnce( nextContent, "const app = new Hono", identifySnippet, "createAuthMiddleware(", ); return insertAfterOnce( nextContent, "app.use(evlog());", `\napp.use("*", async (c, next) => {${identifyUserSetup}\n\tawait identifyUser(c.get("log"), c.req.raw.headers, c.req.path);\n\tawait next();\n});`, 'identifyUser(c.get("log")', ); } if (backend === "express") { nextContent = insertBeforeOnce( nextContent, "const app = express();", identifySnippet, "createAuthMiddleware(", ); return insertAfterOnce( nextContent, "app.use(evlog());", `\napp.use(async (req, _res, next) => {${identifyUserSetup}\n\tawait identifyUser(req.log, req.headers, req.path);\n\tnext();\n});`, "identifyUser(req.log", ); } if (backend === "fastify") { nextContent = addNamedImport(nextContent, "evlog/fastify", ["useLogger"]); nextContent = insertBeforeOnce( nextContent, "const fastify = Fastify", identifySnippet, "createAuthMiddleware(", ); return insertAfterOnce( nextContent, "fastify.register(evlog);", `\nfastify.addHook("preHandler", async (request) => {${identifyUserSetup}\n\tawait identifyUser(useLogger(), request.headers, request.url);\n});`, "identifyUser(useLogger()", ); } nextContent = insertBeforeOnce( nextContent, "new Elysia", identifySnippet, "createAuthMiddleware(", ); return insertAfterOnce( nextContent, ".use(evlog())", `\n\t.derive(async ({ request, log }) => {${identifyUserSetup.replace(/\n\t/g, "\n\t\t")}\n\t\tawait identifyUser(log, request.headers, new URL(request.url).pathname);\n\t\treturn {};\n\t})`, "identifyUser(log", ); } export function addEvlogServerSetup(content: string, backend: EvlogBackend, serviceName: string) { const initSnippet = `initLogger({\n\tenv: { service: "${serviceName}" },\n});\n\n`; if (backend === "hono") { let nextContent = prependMissingImports(content, [ 'import { initLogger } from "evlog";', 'import { evlog, type EvlogVariables } from "evlog/hono";', ]); nextContent = insertBeforeOnce( nextContent, "const app = new Hono", initSnippet, "initLogger({", ); nextContent = nextContent.replace( "const app = new Hono();", "const app = new Hono();", ); nextContent = nextContent .replace('import { logger } from "hono/logger";\n', "") .replace(/\napp\.use\(logger\(\)\);/, ""); return insertAfterOnce( nextContent, "const app = new Hono();", "\n\napp.use(evlog());", "app.use(evlog());", ); } if (backend === "express") { let nextContent = prependMissingImports(content, [ 'import { initLogger } from "evlog";', 'import { evlog } from "evlog/express";', ]); nextContent = insertBeforeOnce( nextContent, "const app = express();", initSnippet, "initLogger({", ); return insertAfterOnce( nextContent, "const app = express();", "\n\napp.use(evlog());", "app.use(evlog());", ); } if (backend === "fastify") { let nextContent = prependMissingImports(content, [ 'import { initLogger } from "evlog";', 'import { evlog } from "evlog/fastify";', ]); nextContent = insertBeforeOnce( nextContent, "const fastify = Fastify", initSnippet, "initLogger({", ); return insertBeforeOnce( nextContent, "fastify.register(fastifyCors", "fastify.register(evlog);\n", "fastify.register(evlog);", ); } let nextContent = prependMissingImports(content, [ 'import { initLogger } from "evlog";', 'import { evlog } from "evlog/elysia";', ]); nextContent = insertBeforeOnce(nextContent, "new Elysia", initSnippet, "initLogger({"); for (const marker of ["new Elysia({ adapter: node() })", "new Elysia()"]) { nextContent = insertAfterOnce(nextContent, marker, "\n\t.use(evlog())", ".use(evlog())"); } return nextContent; } function addNuxtEvlogSetup(content: string, serviceName: string) { let nextContent = content; if (!nextContent.includes('"evlog/nuxt"') && !nextContent.includes("'evlog/nuxt'")) { nextContent = nextContent.replace(/modules:\s*\[/, (match) => `${match}\n "evlog/nuxt",`); } if (!nextContent.includes("evlog:")) { nextContent = nextContent.replace(/\n\}\)\s*$/, (match) => { const contentBeforeConfigClose = nextContent.slice(0, -match.length); const needsComma = !/[,{]\s*$/.test(contentBeforeConfigClose); return `${needsComma ? "," : ""}\n evlog: {\n env: { service: "${serviceName}" },\n },\n})`; }); } return nextContent; } function addSvelteViteEvlogSetup(content: string, serviceName: string) { let nextContent = prependMissingImports(content, ['import evlog from "evlog/vite";']); if (nextContent.includes("evlog({")) return nextContent; return nextContent.replace( "plugins: [tailwindcss(), sveltekit()],", `plugins: [\n tailwindcss(),\n sveltekit(),\n evlog({ service: "${serviceName}" }),\n ],`, ); } function addSvelteHooksEvlogSetup(content: string) { let nextContent = prependMissingImports(content, [ 'import { createEvlogHooks } from "evlog/sveltekit";', ]); if (!nextContent.includes("export const handle") && !nextContent.includes("const authHandle")) { if (!nextContent.includes("createEvlogHooks()")) { nextContent = `${nextContent.trimEnd()}\n\nexport const { handle, handleError } = createEvlogHooks();\n`; } return nextContent; } nextContent = prependMissingImports(nextContent, [ 'import { sequence } from "@sveltejs/kit/hooks";', ]); if (!nextContent.includes("const { handle: evlogHandle, handleError }")) { nextContent = nextContent.replace( /((?:import .+\n)+)/, `$1\nconst { handle: evlogHandle, handleError } = createEvlogHooks();\n\n`, ); } nextContent = nextContent.replace( /export const handle(:\s*Handle)?\s*=\s*async/, (_match, typeAnnotation: string | undefined) => `const authHandle${typeAnnotation ?? ""} = async`, ); if (!nextContent.includes("sequence(evlogHandle, authHandle)")) { nextContent = `${nextContent.trimEnd()}\n\nexport const handle = sequence(evlogHandle as Handle, authHandle);\nexport { handleError };\n`; } return nextContent; } function addSvelteLocalsType(content: string) { let nextContent = prependMissingImports(content, ['import type { RequestLogger } from "evlog";']); if (nextContent.includes("log: RequestLogger")) return nextContent; if (nextContent.includes("// interface Locals {}")) { return nextContent.replace( "// interface Locals {}", "interface Locals {\n\t\t\tlog: RequestLogger;\n\t\t}", ); } return nextContent.replace( "namespace App {", "namespace App {\n\t\tinterface Locals {\n\t\t\tlog: RequestLogger;\n\t\t}\n", ); } function addTanstackStartRootEvlogSetup(content: string) { let nextContent = prependMissingImports(content, [ 'import { createMiddleware } from "@tanstack/react-start";', 'import { evlogErrorHandler } from "evlog/nitro/v3";', ]); const middlewareEntry = "createMiddleware().server(evlogErrorHandler)"; if (nextContent.includes(`middleware: [${middlewareEntry}]`)) { return nextContent; } if (nextContent.includes("middleware: [")) { return nextContent.replace("middleware: [", `middleware: [${middlewareEntry}, `); } if (/server:\s*{/.test(nextContent)) { return nextContent.replace( /server:\s*{\n/, `server: {\n middleware: [${middlewareEntry}],\n`, ); } return nextContent.replace( "head: () => ({", `server: {\n middleware: [${middlewareEntry}],\n },\n\n head: () => ({`, ); } function addAstroMiddlewareEvlogSetup(content: string, serviceName: string) { let nextContent = prependMissingImports(content, [ 'import { createRequestLogger, initLogger } from "evlog";', ]); const initSnippet = `initLogger({\n env: { service: "${serviceName}" },\n});\n\n`; nextContent = insertBeforeOnce( nextContent, "export const onRequest", initSnippet, "initLogger({", ); if (nextContent.includes("createRequestLogger({")) return nextContent; const contextMarker = "export const onRequest = defineMiddleware(async (context, next) => {"; if (nextContent.includes(contextMarker)) { nextContent = insertAfterOnce( nextContent, contextMarker, `\n const url = new URL(context.request.url);\n const log = createRequestLogger({\n method: context.request.method,\n path: url.pathname,\n });\n\n context.locals.log = log;\n`, "const log = createRequestLogger({", ); return nextContent.replace( "return next();", "const response = await next();\n log.emit();\n return response;", ); } const localsMarker = "export const onRequest = defineMiddleware(async ({ request, locals }, next) => {"; if (nextContent.includes(localsMarker)) { nextContent = insertAfterOnce( nextContent, localsMarker, `\n const url = new URL(request.url);\n const log = createRequestLogger({\n method: request.method,\n path: url.pathname,\n });\n\n locals.log = log;\n`, "const log = createRequestLogger({", ); return nextContent.replace( "return next();", "const response = await next();\n log.emit();\n return response;", ); } return nextContent; } function addAstroLocalsType(content: string) { let nextContent = prependMissingImports(content, ['import type { RequestLogger } from "evlog";']); if (nextContent.includes("log: RequestLogger")) return nextContent; if (nextContent.includes("interface Locals {")) { return nextContent.replace("interface Locals {", "interface Locals {\n log: RequestLogger;"); } if (nextContent.includes("declare namespace App {")) { return nextContent.replace( "declare namespace App {", "declare namespace App {\n interface Locals {\n log: RequestLogger;\n }\n", ); } return `${nextContent.trimEnd()}\n\ndeclare namespace App {\n interface Locals {\n log: RequestLogger;\n }\n}\n`; } function addNextRouteWrappers(content: string) { let nextContent = prependMissingImports(content, ['import { withEvlog } from "@/lib/evlog";']); if ( nextContent.includes("withEvlog(handler)") || nextContent.includes("withEvlog(handleRequest)") ) { return nextContent; } nextContent = nextContent.replace( "export { handler as GET, handler as POST };", "export const GET = withEvlog(handler);\nexport const POST = withEvlog(handler);", ); for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) { nextContent = nextContent.replace( `export const ${method} = handleRequest;`, `export const ${method} = withEvlog(handleRequest);`, ); } return nextContent; } function addNextAiEvlogSetup(content: string) { let nextContent = addNamedImport(content, "@/lib/evlog", ["withEvlog"]); if (!nextContent.includes("withEvlog(async (req: Request)")) { nextContent = nextContent.replace( "export async function POST(req: Request) {", "export const POST = withEvlog(async (req: Request) => {", ); if (nextContent.includes("export const POST = withEvlog(async (req: Request) => {")) { nextContent = nextContent.replace(/\n}\s*$/, "\n});\n"); } } // Next emits the evlog route event when the streaming Response is returned. // AI SDK stream telemetry arrives later, so wiring createAILogger here drops `ai`. return nextContent; } function addNuxtAiEvlogSetup(content: string) { return addAiSdkEvlogTelemetry(content, "useLogger(event)"); } function addSvelteAiEvlogSetup(content: string) { let nextContent = content.replace( "export const POST: RequestHandler = async ({ request }) => {", "export const POST: RequestHandler = async ({ request, locals }) => {", ); return addAiSdkEvlogTelemetry(nextContent, "locals.log"); } function addTanstackStartAiEvlogSetup(content: string) { let nextContent = prependMissingImports(content, [ 'import type { RequestLogger } from "evlog";', 'import { useRequest } from "nitro/context";', ]); return addAiSdkEvlogTelemetry(nextContent, "useRequest().context.log as RequestLogger"); } function addBackendAiEvlogSetup(content: string, backend: EvlogBackend) { if (backend === "hono") { return addAiSdkEvlogTelemetry(content, 'c.get("log")'); } if (backend === "express") { return addAiSdkEvlogTelemetry(content, "req.log"); } if (backend === "fastify") { const nextContent = addNamedImport(content, "evlog/fastify", ["useLogger"]); return addAiSdkEvlogTelemetry(nextContent, "useLogger()"); } return addAiSdkEvlogTelemetry(content, "context.log"); } function addNextBetterAuthToRoute(content: string) { let nextContent = addNamedImport(content, "@/lib/evlog-auth", ["identifyEvlogUser"]); nextContent = nextContent.replace("function handler(req:", "async function handler(req:"); for (const marker of [ "async function handler(req: NextRequest) {", "async function handleRequest(req: NextRequest) {", "export const POST = withEvlog(async (req: Request) => {", ]) { nextContent = insertAfterOnce( nextContent, marker, "\n\tawait identifyEvlogUser(req);", "identifyEvlogUser(req)", ); } return nextContent; } function addSvelteBetterAuthEvlogSetup(content: string, config: ProjectConfig) { if (!content.includes("authHandle") || content.includes("evlogAuthHandle")) { return content; } let nextContent = addNamedImport(content, "evlog/better-auth", [ "createAuthMiddleware", "type BetterAuthInstance", ]); if (!nextContent.includes(`@${config.projectName}/auth`)) { nextContent = prependMissingImports(nextContent, [getAuthImportLine(config)]); } if ( usesCreateAuthFactory(config) && config.webDeploy === "cloudflare" && !nextContent.includes(`@${config.projectName}/env/server`) ) { nextContent = prependMissingImports(nextContent, [ `import { env as localEnv } from "@${config.projectName}/env/server";`, ]); } const authExpression = getAuthExpression(config); const authOptions = '{ exclude: ["/api/auth/**"], maskEmail: true }'; const authHandleSnippet = usesCreateAuthFactory(config) && config.webDeploy === "cloudflare" ? `const evlogAuthHandle: Handle = async ({ event, resolve }) => {\n\tif (building) {\n\t\treturn resolve(event);\n\t}\n\n\tconst authEnv = event.platform?.env ?? localEnv;\n\tconst identifyUser = createAuthMiddleware(createAuth(authEnv) as BetterAuthInstance, ${authOptions});\n\tawait identifyUser(event.locals.log, event.request.headers, event.url.pathname);\n\treturn resolve(event);\n};\n\n` : `const identifyUser = createAuthMiddleware(${authExpression} as BetterAuthInstance, ${authOptions});\n\nconst evlogAuthHandle: Handle = async ({ event, resolve }) => {\n\tawait identifyUser(event.locals.log, event.request.headers, event.url.pathname);\n\treturn resolve(event);\n};\n\n`; nextContent = insertAfterOnce( nextContent, "const { handle: evlogHandle, handleError } = createEvlogHooks();\n\n", authHandleSnippet, "evlogAuthHandle", ); return nextContent .replace( "sequence(evlogHandle as Handle, authHandle)", "sequence(evlogHandle as Handle, evlogAuthHandle, authHandle)", ) .replace( "sequence(evlogHandle, authHandle)", "sequence(evlogHandle as Handle, evlogAuthHandle, authHandle)", ); } function addAstroBetterAuthEvlogSetup(content: string, config: ProjectConfig) { if (content.includes("createAuthMiddleware(")) return content; let nextContent = addNamedImport(content, "evlog/better-auth", [ "createAuthMiddleware", "type BetterAuthInstance", ]); if (!nextContent.includes(`@${config.projectName}/auth`)) { nextContent = prependMissingImports(nextContent, [getAuthImportLine(config)]); } const authExpression = getAuthExpression(config); const authOptions = '{ exclude: ["/api/auth/**"], maskEmail: true }'; const usesFactory = usesCreateAuthFactory(config); if (!usesFactory) { nextContent = insertBeforeOnce( nextContent, "export const onRequest", `const identifyUser = createAuthMiddleware(${authExpression} as BetterAuthInstance, ${authOptions});\n\n`, "const identifyUser = createAuthMiddleware(", ); } for (const marker of ["context.locals.log = log;", "locals.log = log;"]) { if (!nextContent.includes(marker)) continue; const requestExpression = marker.startsWith("context") ? "context.request" : "request"; const identifySnippet = usesFactory ? `\n\n const identifyUser = createAuthMiddleware(${authExpression} as BetterAuthInstance, ${authOptions});\n await identifyUser(log, ${requestExpression}.headers, url.pathname);` : `\n\n await identifyUser(log, ${requestExpression}.headers, url.pathname);`; return insertAfterOnce(nextContent, marker, identifySnippet, "identifyUser(log"); } return nextContent; } function getNextEvlogFile(serviceName: string) { return `import { createEvlog } from "evlog/next"; import { createInstrumentation } from "evlog/next/instrumentation"; export const { withEvlog, useLogger, log, createError } = createEvlog({ service: "${serviceName}", }); export const { register, onRequestError } = createInstrumentation({ service: "${serviceName}", }); `; } function getNextInstrumentationFile() { return `import { defineNodeInstrumentation } from "evlog/next/instrumentation"; export const { register, onRequestError } = defineNodeInstrumentation(() => import("./src/lib/evlog")); `; } function getNextProxyFile() { return `import { evlogMiddleware } from "evlog/next"; export const proxy = evlogMiddleware(); export const config = { matcher: ["/api/:path*"], }; `; } function getNextEvlogAuthFile(config: ProjectConfig) { if (usesCreateAuthFactory(config)) { return `${getAuthImportLine(config)} import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth"; import { useLogger } from "@/lib/evlog"; export async function identifyEvlogUser(request: Request) { const identifyUser = createAuthMiddleware(${getAuthExpression(config)} as BetterAuthInstance, { exclude: ["/api/auth/**"], maskEmail: true, }); await identifyUser(useLogger(), request.headers, new URL(request.url).pathname); } `; } return `${getAuthImportLine(config)} import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth"; import { useLogger } from "@/lib/evlog"; const identifyUser = createAuthMiddleware(${getAuthExpression(config)} as BetterAuthInstance, { exclude: ["/api/auth/**"], maskEmail: true, }); export async function identifyEvlogUser(request: Request) { await identifyUser(useLogger(), request.headers, new URL(request.url).pathname); } `; } function getNitroEvlogAuthPluginFile(config: ProjectConfig) { if (usesCreateAuthFactory(config)) { return `${getAuthImportLine(config)} import { createAuthIdentifier, type BetterAuthInstance } from "evlog/better-auth"; export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook("request", async (event) => { const identify = createAuthIdentifier(${getAuthExpression(config)} as BetterAuthInstance, { exclude: ["/api/auth/**"], maskEmail: true, }); await identify(event); }); }); `; } return `${getAuthImportLine(config)} import { createAuthIdentifier, type BetterAuthInstance } from "evlog/better-auth"; export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook( "request", createAuthIdentifier(${getAuthExpression(config)} as BetterAuthInstance, { exclude: ["/api/auth/**"], maskEmail: true, }), ); }); `; } function getNuxtEvlogAuthMiddlewareFile(config: ProjectConfig) { if (usesCreateAuthFactory(config)) { return `${getAuthImportLine(config)} import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth"; export default defineEventHandler(async (event) => { if (!event.context.log) return; const identify = createAuthMiddleware(${getAuthExpression(config)} as BetterAuthInstance, { exclude: ["/api/auth/**"], maskEmail: true, }); await identify(event.context.log, event.headers, event.path); }); `; } return `${getAuthImportLine(config)} import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth"; const identify = createAuthMiddleware(${getAuthExpression(config)} as BetterAuthInstance, { exclude: ["/api/auth/**"], maskEmail: true, }); export default defineEventHandler(async (event) => { if (!event.context.log) return; await identify(event.context.log, event.headers, event.path); }); `; } function getTanstackNitroConfigFile(serviceName: string) { return `import { defineConfig } from "nitro"; import evlog from "evlog/nitro/v3"; export default defineConfig({ experimental: { asyncContext: true, }, modules: [ evlog({ env: { service: "${serviceName}" }, }), ], }); `; } function getAstroMiddlewareFile(serviceName: string) { return `import { defineMiddleware } from "astro:middleware"; import { createRequestLogger, initLogger } from "evlog"; initLogger({ env: { service: "${serviceName}" }, }); export const onRequest = defineMiddleware(async ({ request, locals }, next) => { const url = new URL(request.url); const log = createRequestLogger({ method: request.method, path: url.pathname, }); locals.log = log; try { const response = await next(); log.emit(); return response; } catch (error) { log.error(error instanceof Error ? error : new Error(String(error))); log.emit(); throw error; } }); `; } function getAstroEnvFile() { return `/// import type { RequestLogger } from "evlog"; declare namespace App { interface Locals { log: RequestLogger; } } `; } async function setupNextEvlog(config: ProjectConfig, serviceName: string) { const webDir = path.join(config.projectDir, "apps/web"); const evlogPath = path.join(webDir, "src/lib/evlog.ts"); if (!(await fs.pathExists(evlogPath))) { await writeFileIfChanged(evlogPath, getNextEvlogFile(serviceName)); } const identifyWebAuth = shouldIdentifyWebAuth(config); if (identifyWebAuth) { const evlogAuthPath = path.join(webDir, "src/lib/evlog-auth.ts"); if (!(await fs.pathExists(evlogAuthPath))) { await writeFileIfChanged(evlogAuthPath, getNextEvlogAuthFile(config)); } } const instrumentationPath = path.join(webDir, "instrumentation.ts"); if (!(await fs.pathExists(instrumentationPath))) { await writeFileIfChanged(instrumentationPath, getNextInstrumentationFile()); } const proxyPath = path.join(webDir, "src/proxy.ts"); const rootProxyPath = path.join(webDir, "proxy.ts"); if (!(await fs.pathExists(proxyPath)) && !(await fs.pathExists(rootProxyPath))) { await writeFileIfChanged(proxyPath, getNextProxyFile()); } const updateNextApiRoute = (content: string) => { let nextContent = addNextRouteWrappers(content); if (identifyWebAuth) { nextContent = addNextBetterAuthToRoute(nextContent); } return nextContent; }; await updateFileIfExists( path.join(webDir, "src/app/api/trpc/[trpc]/route.ts"), updateNextApiRoute, ); await updateFileIfExists( path.join(webDir, "src/app/api/rpc/[[...rest]]/route.ts"), updateNextApiRoute, ); if (config.examples.includes("ai")) { await updateFileIfExists(path.join(webDir, "src/app/api/ai/route.ts"), (content) => { let nextContent = addNextAiEvlogSetup(content); if (identifyWebAuth) { nextContent = addNextBetterAuthToRoute(nextContent); } return nextContent; }); } } async function setupNuxtEvlog(config: ProjectConfig, serviceName: string) { const webDir = path.join(config.projectDir, "apps/web"); await updateFileIfExists(path.join(webDir, "nuxt.config.ts"), (content) => addNuxtEvlogSetup(content, serviceName), ); if (shouldIdentifyWebAuth(config)) { const oldAuthPluginPath = path.join(webDir, "server/plugins/evlog-auth.ts"); if (await fs.pathExists(oldAuthPluginPath)) { const oldAuthPlugin = await fs.readFile(oldAuthPluginPath, "utf-8"); if (oldAuthPlugin.includes("evlog/better-auth")) { await fs.remove(oldAuthPluginPath); } } const authMiddlewarePath = path.join(webDir, "server/middleware/evlog-auth.ts"); if (!(await fs.pathExists(authMiddlewarePath))) { await writeFileIfChanged(authMiddlewarePath, getNuxtEvlogAuthMiddlewareFile(config)); } } if (config.examples.includes("ai")) { await updateFileIfExists(path.join(webDir, "server/api/ai.post.ts"), addNuxtAiEvlogSetup); } } async function setupSvelteEvlog(config: ProjectConfig, serviceName: string) { const webDir = path.join(config.projectDir, "apps/web"); await updateFileIfExists(path.join(webDir, "vite.config.ts"), (content) => addSvelteViteEvlogSetup(content, serviceName), ); const hooksPath = path.join(webDir, "src/hooks.server.ts"); if (await fs.pathExists(hooksPath)) { await updateFileIfExists(hooksPath, addSvelteHooksEvlogSetup); } else { await writeFileIfChanged( hooksPath, `import { createEvlogHooks } from "evlog/sveltekit"; export const { handle, handleError } = createEvlogHooks(); `, ); } await updateFileIfExists(path.join(webDir, "src/app.d.ts"), addSvelteLocalsType); if (shouldIdentifyWebAuth(config)) { await updateFileIfExists(path.join(webDir, "src/hooks.server.ts"), (content) => addSvelteBetterAuthEvlogSetup(content, config), ); } if (config.examples.includes("ai")) { await updateFileIfExists( path.join(webDir, "src/routes/api/ai/+server.ts"), addSvelteAiEvlogSetup, ); } } async function setupTanstackStartEvlog(config: ProjectConfig, serviceName: string) { const webDir = path.join(config.projectDir, "apps/web"); const nitroConfigPath = path.join(webDir, "nitro.config.ts"); if (!(await fs.pathExists(nitroConfigPath))) { await writeFileIfChanged(nitroConfigPath, getTanstackNitroConfigFile(serviceName)); } await updateFileIfExists( path.join(webDir, "src/routes/__root.tsx"), addTanstackStartRootEvlogSetup, ); if (shouldIdentifyWebAuth(config)) { const authPluginPath = path.join(webDir, "server/plugins/evlog-auth.ts"); if (!(await fs.pathExists(authPluginPath))) { await writeFileIfChanged(authPluginPath, getNitroEvlogAuthPluginFile(config)); } } if (config.examples.includes("ai")) { await updateFileIfExists( path.join(webDir, "src/routes/api/ai/$.ts"), addTanstackStartAiEvlogSetup, ); } } async function setupAstroEvlog(config: ProjectConfig, serviceName: string) { const webDir = path.join(config.projectDir, "apps/web"); const middlewarePath = path.join(webDir, "src/middleware.ts"); if (!(await fs.pathExists(middlewarePath))) { await writeFileIfChanged(middlewarePath, getAstroMiddlewareFile(serviceName)); } else { await updateFileIfExists(middlewarePath, (content) => addAstroMiddlewareEvlogSetup(content, serviceName), ); } const envPath = path.join(webDir, "src/env.d.ts"); if (!(await fs.pathExists(envPath))) { await writeFileIfChanged(envPath, getAstroEnvFile()); } else { await updateFileIfExists(envPath, addAstroLocalsType); } if (shouldIdentifyWebAuth(config)) { await updateFileIfExists(middlewarePath, (content) => addAstroBetterAuthEvlogSetup(content, config), ); } } async function setupEvlogWeb(config: ProjectConfig) { const frontend = getEvlogWebFrontend(config.frontend); if (!frontend) return; const serviceName = `${config.projectName}-web`; if (frontend === "next") { await setupNextEvlog(config, serviceName); } else if (frontend === "nuxt") { await setupNuxtEvlog(config, serviceName); } else if (frontend === "svelte") { await setupSvelteEvlog(config, serviceName); } else if (frontend === "tanstack-start") { await setupTanstackStartEvlog(config, serviceName); } else if (frontend === "astro") { await setupAstroEvlog(config, serviceName); } } export async function setupEvlog(config: ProjectConfig): Promise> { return Result.tryPromise({ try: async () => { if (isEvlogBackend(config.backend)) { const serverIndexPath = path.join(config.projectDir, "apps/server/src/index.ts"); if (await fs.pathExists(serverIndexPath)) { const content = await fs.readFile(serverIndexPath, "utf-8"); let nextContent = addEvlogServerSetup( content, config.backend, `${config.projectName}-server`, ); if (config.auth === "better-auth") { nextContent = addEvlogBetterAuthServerSetup( nextContent, config.backend, getAuthExpression(config), ); } if (config.examples.includes("ai")) { nextContent = addBackendAiEvlogSetup(nextContent, config.backend); } if (nextContent !== content) { await fs.writeFile(serverIndexPath, nextContent); } } } await setupEvlogWeb(config); }, catch: (error) => new AddonSetupError({ addon: "evlog", message: `Failed to set up evlog: ${error instanceof Error ? error.message : String(error)}`, cause: error, }), }); } ================================================ FILE: apps/cli/src/helpers/addons/fumadocs-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import { navigableSelect } from "../../prompts/navigable"; import { navigableGroup } from "../../prompts/navigable-group"; import type { ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { AddonSetupError, UserCancelledError, userCancelled } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageExecutionArgs } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; type FumadocsTemplate = | "next-mdx" | "next-mdx-static" | "waku" | "react-router" | "react-router-spa" | "tanstack-start" | "tanstack-start-spa"; type FumadocsSearch = "orama" | "orama-cloud"; type FumadocsOgImage = "next-og" | "takumi"; type FumadocsAiChat = "openrouter" | "inkeep"; const TEMPLATES = { "next-mdx": { label: "Next.js: Fumadocs MDX", hint: "recommended", value: "+next+fuma-docs-mdx", }, "next-mdx-static": { label: "Next.js Static: Fumadocs MDX", value: "+next+fuma-docs-mdx+static", }, waku: { label: "Waku: Fumadocs MDX", value: "waku", }, "react-router": { label: "React Router: Fumadocs MDX (not RSC)", value: "react-router", }, "react-router-spa": { label: "React Router SPA: Fumadocs MDX (not RSC)", hint: "SPA mode allows you to host the site statically, compatible with a CDN.", value: "react-router-spa", }, "tanstack-start": { label: "Tanstack Start: Fumadocs MDX (not RSC)", value: "tanstack-start", }, "tanstack-start-spa": { label: "Tanstack Start SPA: Fumadocs MDX (not RSC)", hint: "SPA mode allows you to host the site statically, compatible with a CDN.", value: "tanstack-start-spa", }, } as const; const DEFAULT_TEMPLATE: FumadocsTemplate = "next-mdx"; const DEFAULT_DEV_PORT = 4000; function aiChatDisabledForTemplate(template: FumadocsTemplate): boolean { return template === "next-mdx-static" || template.endsWith("-spa"); } export async function setupFumadocs( config: ProjectConfig, ): Promise> { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, projectDir } = config; cliLog.info("Setting up Fumadocs..."); const configuredOptions = config.addonOptions?.fumadocs; let template = configuredOptions?.template; let search: FumadocsSearch | undefined = configuredOptions?.search; let ogImage: FumadocsOgImage | undefined = configuredOptions?.ogImage; let aiChat: FumadocsAiChat | undefined = configuredOptions?.aiChat; if (isSilent()) { template = template ?? DEFAULT_TEMPLATE; } else { const promptResult = await Result.tryPromise({ try: () => navigableGroup<{ template: FumadocsTemplate; search: FumadocsSearch; ogImage: FumadocsOgImage | "skip"; aiChat: FumadocsAiChat | "none"; }>({ template: async () => { if (template !== undefined) return template; return navigableSelect({ message: "Choose a template", options: Object.entries(TEMPLATES).map(([key, t]) => ({ value: key as FumadocsTemplate, label: t.label, hint: "hint" in t ? t.hint : undefined, })), initialValue: DEFAULT_TEMPLATE, }); }, search: async () => { if (search !== undefined) return search; return navigableSelect({ message: "Choose a search solution?", options: [ { value: "orama", label: "Default", hint: "local search powered by Orama, recommended", }, { value: "orama-cloud", label: "Orama Cloud", hint: "3rd party search solution, signup needed", }, ], initialValue: "orama", }); }, ogImage: async ({ results }) => { if (ogImage !== undefined) return ogImage; const picked = results.template ?? template ?? DEFAULT_TEMPLATE; if (!picked.startsWith("next-")) return "skip"; return navigableSelect({ message: "Configure Open Graph Image generation?", options: [ { value: "next-og", label: "next/og", hint: "Next.js built-in solution" }, { value: "takumi", label: "Takumi", hint: "Output WebP format, framework-agnostic", }, ], initialValue: "next-og", }); }, aiChat: async ({ results }) => { if (aiChat !== undefined) return aiChat; const picked = results.template ?? template ?? DEFAULT_TEMPLATE; if (aiChatDisabledForTemplate(picked)) return "none"; return navigableSelect({ message: "Configure AI Chat?", options: [ { value: "none", label: "No" }, { value: "openrouter", label: "AI SDK", hint: "default to OpenRouter" }, { value: "inkeep", label: "Inkeep AI", hint: "API key required" }, ], initialValue: "none", }); }, }), catch: (e) => new AddonSetupError({ addon: "fumadocs", message: `Failed to run Fumadocs prompts: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (promptResult.isErr()) return Result.err(promptResult.error); const results = promptResult.value; // Cancel mid-group leaves later slots undefined; skip/none sentinels are defined. if ( results.template === undefined || results.search === undefined || results.ogImage === undefined || results.aiChat === undefined ) { return userCancelled("Operation cancelled"); } template = results.template; search = results.search; ogImage = results.ogImage === "skip" ? undefined : results.ogImage; aiChat = results.aiChat === "none" ? undefined : results.aiChat; } if (!template) { return userCancelled("Operation cancelled"); } const isNextTemplate = template.startsWith("next-"); // Normalize pre-configured flags against the chosen template so we don't emit // upstream-broken combinations (AI chat on a static export, etc.). if (!isNextTemplate) { ogImage = undefined; } if (aiChatDisabledForTemplate(template)) { aiChat = undefined; } const templateArg = TEMPLATES[template].value; const devPort = configuredOptions?.devPort ?? DEFAULT_DEV_PORT; const options: string[] = [`--template ${templateArg}`, `--pm ${packageManager}`, "--no-git"]; if (isNextTemplate) { options.push("--src"); } // create-fumadocs-app's --linter flag only accepts "eslint" or "biome" // (oxlint exists only in the interactive prompt, not as a flag choice). if (config.addons.includes("biome") || config.addons.includes("ultracite")) { options.push("--linter biome"); } if (search) options.push(`--search ${search}`); if (ogImage) options.push(`--og-image ${ogImage}`); if (aiChat) options.push(`--ai-chat ${aiChat}`); const commandWithArgs = `create-fumadocs-app@latest fumadocs ${options.join(" ")}`; const args = getPackageExecutionArgs(packageManager, commandWithArgs); const appsDir = path.join(projectDir, "apps"); await fs.ensureDir(appsDir); const s = createSpinner(); s.start("Running Fumadocs create command..."); const result = await Result.tryPromise({ try: async () => { await $({ cwd: appsDir, env: { CI: "true" } })`${args}`; const fumadocsDir = path.join(projectDir, "apps", "fumadocs"); const packageJsonPath = path.join(fumadocsDir, "package.json"); if (await fs.pathExists(packageJsonPath)) { const packageJson = await fs.readJson(packageJsonPath); packageJson.name = "fumadocs"; if (packageJson.scripts?.dev) { packageJson.scripts.dev = `${packageJson.scripts.dev} --port=${devPort}`; } await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); } }, catch: (e) => new AddonSetupError({ addon: "fumadocs", message: `Failed to set up Fumadocs: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (result.isErr()) { s.stop("Failed to set up Fumadocs"); return result; } s.stop("Fumadocs setup complete!"); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/addons/mcp-setup.ts ================================================ import { Result } from "better-result"; import { $ } from "execa"; import pc from "picocolors"; import { navigableMultiselect, navigableSelect } from "../../prompts/navigable"; import { navigableGroup } from "../../prompts/navigable-group"; import type { AddonOptions, ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { AddonSetupError, UserCancelledError } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageExecutionCommand, getPackageRunnerPrefix } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; type McpTransport = "http" | "sse"; type McpOptions = NonNullable; type McpServerKey = NonNullable[number]; type McpAgent = NonNullable[number]; type InstallScope = NonNullable; export type McpServerDef = { key: McpServerKey; label: string; name: string; target: string; transport?: McpTransport; headers?: string[]; }; type AgentScope = "project" | "global" | "both"; type AgentOption = { value: McpAgent; label: string; scope: AgentScope; }; const MCP_AGENTS: AgentOption[] = [ { value: "antigravity", label: "Antigravity", scope: "global" }, { value: "cline", label: "Cline VSCode Extension", scope: "global" }, { value: "cline-cli", label: "Cline CLI", scope: "global" }, { value: "cursor", label: "Cursor", scope: "both" }, { value: "claude-code", label: "Claude Code", scope: "both" }, { value: "codex", label: "Codex", scope: "both" }, { value: "opencode", label: "OpenCode", scope: "both" }, { value: "gemini-cli", label: "Gemini CLI", scope: "both" }, { value: "github-copilot-cli", label: "GitHub Copilot CLI", scope: "both" }, { value: "mcporter", label: "MCPorter", scope: "both" }, { value: "vscode", label: "VS Code (GitHub Copilot)", scope: "both" }, { value: "zed", label: "Zed", scope: "both" }, { value: "claude-desktop", label: "Claude Desktop", scope: "global" }, { value: "goose", label: "Goose", scope: "global" }, ]; const DEFAULT_SCOPE: InstallScope = "project"; const DEFAULT_AGENTS: McpAgent[] = ["cursor", "claude-code", "vscode"]; function uniqueValues(values: T[]): T[] { return Array.from(new Set(values)); } function hasReactBasedFrontend(frontend: ProjectConfig["frontend"]): boolean { return ( frontend.includes("react-router") || frontend.includes("tanstack-router") || frontend.includes("tanstack-start") || frontend.includes("next") ); } function hasNativeFrontend(frontend: ProjectConfig["frontend"]): boolean { return ( frontend.includes("native-bare") || frontend.includes("native-uniwind") || frontend.includes("native-unistyles") ); } function getAllMcpServers(config: ProjectConfig): McpServerDef[] { return [ { key: "better-t-stack", label: "Better T Stack", name: "better-t-stack", target: getPackageExecutionCommand(config.packageManager, "create-better-t-stack@latest mcp"), }, { key: "context7", label: "Context7", name: "context7", target: "@upstash/context7-mcp", }, { key: "nx", label: "Nx Workspace", name: "nx", target: "npx nx mcp .", }, { key: "cloudflare-docs", label: "Cloudflare Docs", name: "cloudflare-docs", target: "https://docs.mcp.cloudflare.com/sse", transport: "sse", }, { key: "convex", label: "Convex", name: "convex", target: "npx -y convex@latest mcp start", }, { key: "shadcn", label: "shadcn/ui", name: "shadcn", target: "npx -y shadcn@latest mcp", }, { key: "next-devtools", label: "Next Devtools", name: "next-devtools", target: "npx -y next-devtools-mcp@latest", }, { key: "nuxt-docs", label: "Nuxt Docs", name: "nuxt", target: "https://nuxt.com/mcp", }, { key: "nuxt-ui-docs", label: "Nuxt UI Docs", name: "nuxt-ui", target: "https://ui.nuxt.com/mcp", }, { key: "svelte-docs", label: "Svelte Docs", name: "svelte", target: "https://mcp.svelte.dev/mcp", }, { key: "astro-docs", label: "Astro Docs", name: "astro-docs", target: "https://mcp.docs.astro.build/mcp", }, { key: "planetscale", label: "PlanetScale", name: "planetscale", target: "https://mcp.pscale.dev/mcp/planetscale", }, { key: "neon", label: "Neon", name: "neon", target: "https://mcp.neon.tech/mcp", }, { key: "supabase", label: "Supabase", name: "supabase", target: "https://mcp.supabase.com/mcp", }, { key: "better-auth", label: "Better Auth", name: "better-auth", target: "https://mcp.inkeep.com/better-auth/mcp", }, { key: "clerk", label: "Clerk", name: "clerk", target: "https://mcp.clerk.com/mcp", }, { key: "expo", label: "Expo", name: "expo-mcp", target: "https://mcp.expo.dev/mcp", }, { key: "polar", label: "Polar", name: "polar", target: "https://mcp.polar.sh/mcp/polar-mcp", }, ]; } export function getRecommendedMcpServers( config: ProjectConfig, scope: InstallScope, ): McpServerDef[] { const serversByKey = new Map(getAllMcpServers(config).map((server) => [server.key, server])); const recommendedServerKeys: McpServerKey[] = ["better-t-stack", "context7"]; if (scope === "project" && config.addons.includes("nx")) { recommendedServerKeys.push("nx"); } if ( config.runtime === "workers" || config.webDeploy === "cloudflare" || config.serverDeploy === "cloudflare" ) { recommendedServerKeys.push("cloudflare-docs"); } if (config.backend === "convex") { recommendedServerKeys.push("convex"); } if (hasReactBasedFrontend(config.frontend)) { recommendedServerKeys.push("shadcn"); } if (config.frontend.includes("next")) { recommendedServerKeys.push("next-devtools"); } if (config.frontend.includes("nuxt")) { recommendedServerKeys.push("nuxt-docs", "nuxt-ui-docs"); } if (config.frontend.includes("svelte")) { recommendedServerKeys.push("svelte-docs"); } if (config.frontend.includes("astro")) { recommendedServerKeys.push("astro-docs"); } if (config.dbSetup === "planetscale") { recommendedServerKeys.push("planetscale"); } if (config.dbSetup === "neon") { recommendedServerKeys.push("neon"); } if (config.dbSetup === "supabase") { recommendedServerKeys.push("supabase"); } if (config.auth === "better-auth") { recommendedServerKeys.push("better-auth"); } if (config.auth === "clerk") { recommendedServerKeys.push("clerk"); } if (hasNativeFrontend(config.frontend)) { recommendedServerKeys.push("expo"); } if (config.payments === "polar") { recommendedServerKeys.push("polar"); } return uniqueValues(recommendedServerKeys) .map((serverKey) => serversByKey.get(serverKey)) .filter((server): server is McpServerDef => server !== undefined); } function filterAgentsForScope(scope: InstallScope): AgentOption[] { return MCP_AGENTS.filter((a) => a.scope === "both" || a.scope === scope); } export async function setupMcp( config: ProjectConfig, ): Promise> { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, projectDir } = config; cliLog.info("Setting up MCP servers..."); const configuredScope = config.addonOptions?.mcp?.scope; const configuredServerKeys = config.addonOptions?.mcp?.servers; const configuredAgents = config.addonOptions?.mcp?.agents; const allServersByKey = new Map(getAllMcpServers(config).map((server) => [server.key, server])); const availableServerKeys = new Set(allServersByKey.keys()); let scope: InstallScope; let selectedServerKeys: McpServerKey[]; let selectedAgents: McpAgent[]; if (isSilent()) { scope = configuredScope ?? DEFAULT_SCOPE; const recommendedServers = getRecommendedMcpServers(config, scope); if (recommendedServers.length === 0) return Result.ok(undefined); const serverOptions = recommendedServers.map((s) => s.key); selectedServerKeys = configuredServerKeys?.filter((k) => availableServerKeys.has(k)) ?? serverOptions; if (selectedServerKeys.length === 0) return Result.ok(undefined); const agentOptions = filterAgentsForScope(scope); const defaultAgents = uniqueValues( DEFAULT_AGENTS.filter((a) => agentOptions.some((o) => o.value === a)), ); selectedAgents = configuredAgents?.filter((a) => agentOptions.some((o) => o.value === a)) ?? defaultAgents; if (selectedAgents.length === 0) return Result.ok(undefined); } else { const results = await navigableGroup<{ scope: InstallScope; servers: McpServerKey[]; agents: McpAgent[]; }>({ scope: async () => { if (configuredScope !== undefined) return configuredScope; return navigableSelect({ message: "Where should MCP servers be installed?", options: [ { value: "project", label: "Project", hint: "Writes to project config files (recommended for teams)", }, { value: "global", label: "Global", hint: "Writes to user-level config files (personal machine)", }, ], initialValue: DEFAULT_SCOPE, }); }, servers: async ({ results: r }) => { const currentScope = (r.scope ?? configuredScope ?? DEFAULT_SCOPE) as InstallScope; const recommended = getRecommendedMcpServers(config, currentScope); if (recommended.length === 0) return []; const options = recommended.map((s) => ({ value: s.key, label: s.label, hint: s.target, })); if (configuredServerKeys !== undefined) { return configuredServerKeys.filter((k) => availableServerKeys.has(k)); } return navigableMultiselect({ message: "Select MCP servers to install", options, required: false, initialValues: options.map((o) => o.value), }); }, agents: async ({ results: r }) => { const currentScope = (r.scope ?? configuredScope ?? DEFAULT_SCOPE) as InstallScope; const currentServers = r.servers as McpServerKey[] | undefined; if (currentServers !== undefined && currentServers.length === 0) return []; const agentOpts = filterAgentsForScope(currentScope); if (agentOpts.length === 0) return []; const defaults = uniqueValues( DEFAULT_AGENTS.filter((a) => agentOpts.some((o) => o.value === a)), ); if (configuredAgents !== undefined) { return configuredAgents.filter((a) => agentOpts.some((o) => o.value === a)); } return navigableMultiselect({ message: "Select agents to install MCP servers to", options: agentOpts.map((a) => ({ value: a.value, label: a.label })), required: false, initialValues: defaults, }); }, }); if ( results.scope === undefined || results.servers === undefined || results.agents === undefined ) { return Result.err(new UserCancelledError({ message: "Operation cancelled" })); } scope = results.scope; selectedServerKeys = results.servers as McpServerKey[]; selectedAgents = results.agents as McpAgent[]; if (selectedServerKeys.length === 0 || selectedAgents.length === 0) { return Result.ok(undefined); } } const selectedServers: McpServerDef[] = []; for (const key of selectedServerKeys) { const server = allServersByKey.get(key); if (server) selectedServers.push(server); } if (selectedServers.length === 0) { return Result.ok(undefined); } const installSpinner = createSpinner(); installSpinner.start("Installing MCP servers..."); const runner = getPackageRunnerPrefix(packageManager); const globalFlags = scope === "global" ? ["-g"] : []; let successfulInstalls = 0; for (const server of selectedServers) { const transportFlags = server.transport ? ["-t", server.transport] : []; const headerFlags = (server.headers ?? []).flatMap((h) => ["--header", h]); const agentFlags = selectedAgents.flatMap((agent) => ["-a", agent]); const args = [ ...runner, "add-mcp@latest", server.target, "--name", server.name, ...transportFlags, ...headerFlags, ...agentFlags, ...globalFlags, "-y", ]; const installResult = await Result.tryPromise({ try: async () => { await $({ cwd: projectDir, env: { CI: "true" } })`${args}`; }, catch: (e) => new AddonSetupError({ addon: "mcp", message: `Failed to install MCP server '${server.name}': ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (installResult.isErr()) { cliLog.warn(pc.yellow(`Warning: Could not install MCP server '${server.name}'`)); continue; } successfulInstalls += 1; } if (successfulInstalls === 0) { installSpinner.stop(pc.red("Failed to install MCP servers")); return Result.err( new AddonSetupError({ addon: "mcp", message: `Failed to install all requested MCP servers: ${selectedServers.map((server) => server.name).join(", ")}`, }), ); } installSpinner.stop( successfulInstalls === selectedServers.length ? "MCP servers installed" : "MCP servers installed with warnings", ); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/addons/oxlint-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import type { PackageManager } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; import { AddonSetupError } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageExecutionArgs } from "../../utils/package-runner"; import { createSpinner } from "../../utils/terminal-output"; export async function setupOxlint( projectDir: string, packageManager: PackageManager, ): Promise> { return Result.tryPromise({ try: async () => { await addPackageDependency({ devDependencies: ["oxlint", "oxfmt"], projectDir, }); const packageJsonPath = path.join(projectDir, "package.json"); if (await fs.pathExists(packageJsonPath)) { const packageJson = await fs.readJson(packageJsonPath); packageJson.scripts = { ...packageJson.scripts, check: "oxlint && oxfmt --write", }; await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); } if (shouldSkipExternalCommands()) { return; } const s = createSpinner(); try { s.start("Initializing oxlint and oxfmt..."); const oxlintArgs = getPackageExecutionArgs(packageManager, "oxlint@latest --init"); await $({ cwd: projectDir, env: { CI: "true" } })`${oxlintArgs}`; const oxfmtArgs = getPackageExecutionArgs(packageManager, "oxfmt@latest --init"); await $({ cwd: projectDir, env: { CI: "true" } })`${oxfmtArgs}`; s.stop("oxlint and oxfmt initialized successfully!"); } catch (error) { s.stop("Failed to initialize oxlint and oxfmt"); throw error; } }, catch: (error) => new AddonSetupError({ addon: "oxlint", message: `Failed to set up oxlint: ${error instanceof Error ? error.message : String(error)}`, cause: error, }), }); } ================================================ FILE: apps/cli/src/helpers/addons/skills-setup.ts ================================================ import { Result } from "better-result"; import { $ } from "execa"; import pc from "picocolors"; import { navigableMultiselect, navigableSelect } from "../../prompts/navigable"; import { navigableGroup } from "../../prompts/navigable-group"; import type { AddonOptions, ProjectConfig } from "../../types"; import { readBtsConfig } from "../../utils/bts-config"; import { isSilent } from "../../utils/context"; import { AddonSetupError, UserCancelledError } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageRunnerPrefix } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; type SkillSource = { label: string; }; type AgentOption = { value: SkillAgent; label: string; }; type SkillsOptions = NonNullable; type SkillAgent = NonNullable[number]; type InstallScope = NonNullable; // Skill sources - using GitHub shorthand or full URLs const SKILL_SOURCES = { "vercel-labs/agent-skills": { label: "Vercel Agent Skills", }, "vercel/ai": { label: "Vercel AI SDK", }, "vercel/turborepo": { label: "Turborepo", }, "yusukebe/hono-skill": { label: "Hono Backend", }, "vercel-labs/next-skills": { label: "Next.js Best Practices", }, "nuxt/ui": { label: "Nuxt UI", }, "heroui-inc/heroui": { label: "HeroUI Native", }, "shadcn/ui": { label: "shadcn/ui", }, "better-auth/skills": { label: "Better Auth", }, "clerk/skills": { label: "Clerk", }, "neondatabase/agent-skills": { label: "Neon Database", }, "supabase/agent-skills": { label: "Supabase", }, "planetscale/database-skills": { label: "PlanetScale", }, "expo/skills": { label: "Expo", }, "prisma/skills": { label: "Prisma", }, "elysiajs/skills": { label: "ElysiaJS", }, "waynesutton/convexskills": { label: "Convex", }, "msmps/opentui-skill": { label: "OpenTUI Platform", }, "haydenbleasel/ultracite": { label: "Ultracite", }, "https://www.evlog.dev": { label: "evlog", }, } satisfies Record; type SourceKey = keyof typeof SKILL_SOURCES; // All available agents from add-skill CLI const AVAILABLE_AGENTS: AgentOption[] = [ { value: "cursor", label: "Cursor" }, { value: "claude-code", label: "Claude Code" }, { value: "cline", label: "Cline" }, { value: "github-copilot", label: "GitHub Copilot" }, { value: "codex", label: "Codex" }, { value: "opencode", label: "OpenCode" }, { value: "windsurf", label: "Windsurf" }, { value: "goose", label: "Goose" }, { value: "roo", label: "Roo Code" }, { value: "kilo", label: "Kilo Code" }, { value: "gemini-cli", label: "Gemini CLI" }, { value: "antigravity", label: "Antigravity" }, { value: "openhands", label: "OpenHands" }, { value: "trae", label: "Trae" }, { value: "amp", label: "Amp" }, { value: "pi", label: "Pi" }, { value: "qoder", label: "Qoder" }, { value: "qwen-code", label: "Qwen Code" }, { value: "kiro-cli", label: "Kiro CLI" }, { value: "droid", label: "Droid" }, { value: "command-code", label: "Command Code" }, { value: "clawdbot", label: "Clawdbot" }, { value: "zencoder", label: "Zencoder" }, { value: "neovate", label: "Neovate" }, { value: "mcpjam", label: "MCPJam" }, ]; const DEFAULT_SCOPE: InstallScope = "project"; const DEFAULT_AGENTS: SkillAgent[] = ["cursor", "claude-code", "github-copilot"]; function hasReactBasedFrontend(frontend: ProjectConfig["frontend"]): boolean { return ( frontend.includes("react-router") || frontend.includes("tanstack-router") || frontend.includes("tanstack-start") || frontend.includes("next") ); } function hasNativeFrontend(frontend: ProjectConfig["frontend"]): boolean { return ( frontend.includes("native-bare") || frontend.includes("native-uniwind") || frontend.includes("native-unistyles") ); } function getRecommendedSourceKeys(config: ProjectConfig): SourceKey[] { const sources: SourceKey[] = []; const { frontend, backend, dbSetup, auth, examples, addons, orm } = config; if (hasReactBasedFrontend(frontend)) { sources.push("vercel-labs/agent-skills"); sources.push("shadcn/ui"); } if (frontend.includes("next")) { sources.push("vercel-labs/next-skills"); } if (frontend.includes("nuxt")) { sources.push("nuxt/ui"); } if (frontend.includes("native-uniwind")) { sources.push("heroui-inc/heroui"); } if (hasNativeFrontend(frontend)) { sources.push("expo/skills"); } if (auth === "better-auth") { sources.push("better-auth/skills"); } if (auth === "clerk") { sources.push("clerk/skills"); } if (dbSetup === "neon") { sources.push("neondatabase/agent-skills"); } if (dbSetup === "supabase") { sources.push("supabase/agent-skills"); } if (dbSetup === "planetscale") { sources.push("planetscale/database-skills"); } if (orm === "prisma" || dbSetup === "prisma-postgres") { sources.push("prisma/skills"); } if (examples.includes("ai")) { sources.push("vercel/ai"); } if (addons.includes("turborepo")) { sources.push("vercel/turborepo"); } if (backend === "hono") { sources.push("yusukebe/hono-skill"); } if (backend === "elysia") { sources.push("elysiajs/skills"); } if (backend === "convex") { sources.push("waynesutton/convexskills"); } if (addons.includes("opentui")) { sources.push("msmps/opentui-skill"); } if (addons.includes("ultracite")) { sources.push("haydenbleasel/ultracite"); } if (addons.includes("evlog")) { sources.push("https://www.evlog.dev"); } return sources; } const CURATED_SKILLS_BY_SOURCE: Record string[]> = { "vercel-labs/agent-skills": (config) => { const skills = [ "web-design-guidelines", "vercel-composition-patterns", "vercel-react-best-practices", ]; if (hasNativeFrontend(config.frontend)) { skills.push("vercel-react-native-skills"); } return skills; }, "vercel/ai": () => ["ai-sdk"], "vercel/turborepo": () => ["turborepo"], "yusukebe/hono-skill": () => ["hono"], "vercel-labs/next-skills": () => ["next-best-practices", "next-cache-components"], "nuxt/ui": () => ["nuxt-ui"], "heroui-inc/heroui": () => ["heroui-native"], "shadcn/ui": () => ["shadcn"], "better-auth/skills": () => ["better-auth-best-practices"], "clerk/skills": (config) => { const skills = [ "clerk", "clerk-setup", "clerk-custom-ui", "clerk-webhooks", "clerk-testing", "clerk-orgs", ]; if (config.frontend.includes("next")) { skills.push("clerk-nextjs-patterns"); } return skills; }, "neondatabase/agent-skills": () => ["neon-postgres"], "supabase/agent-skills": () => ["supabase-postgres-best-practices"], "planetscale/database-skills": (config) => { if (config.dbSetup !== "planetscale") { return []; } if (config.database === "postgres") { return ["postgres", "neki"]; } if (config.database === "mysql") { return ["mysql", "vitess"]; } return []; }, "expo/skills": (config) => { const skills = [ "expo-dev-client", "building-native-ui", "native-data-fetching", "expo-deployment", "expo-cicd-workflows", ]; if (config.frontend.includes("native-uniwind")) { skills.push("expo-tailwind-setup"); } return skills; }, "prisma/skills": (config) => { const skills: string[] = []; if (config.orm === "prisma") { skills.push("prisma-cli", "prisma-client-api", "prisma-database-setup"); } if (config.dbSetup === "prisma-postgres") { skills.push("prisma-postgres"); } return skills; }, "elysiajs/skills": () => ["elysiajs"], "waynesutton/convexskills": () => [ "convex-best-practices", "convex-functions", "convex-schema-validator", "convex-realtime", "convex-http-actions", "convex-cron-jobs", "convex-file-storage", "convex-migrations", "convex-security-check", ], "msmps/opentui-skill": () => ["opentui"], "haydenbleasel/ultracite": () => ["ultracite"], "https://www.evlog.dev": () => ["review-logging-patterns", "analyze-logs"], }; function getCuratedSkillNamesForSourceKey(sourceKey: SourceKey, config: ProjectConfig): string[] { return CURATED_SKILLS_BY_SOURCE[sourceKey](config); } function uniqueValues(values: T[]): T[] { return Array.from(new Set(values)); } export async function setupSkills( config: ProjectConfig, ): Promise> { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, projectDir } = config; // Load full config from bts.jsonc to get all addons (existing + new) const btsConfig = await readBtsConfig(projectDir); const fullConfig: ProjectConfig = btsConfig ? { ...config, addons: btsConfig.addons ?? config.addons, addonOptions: btsConfig.addonOptions ?? config.addonOptions, } : config; const recommendedSourceKeys = getRecommendedSourceKeys(fullConfig); const skillsOptions = fullConfig.addonOptions?.skills; const configuredSourceKeys = uniqueValues( (skillsOptions?.selections ?? []).map((selection) => selection.source), ); const sourceKeys = uniqueValues([...recommendedSourceKeys, ...configuredSourceKeys]); if (sourceKeys.length === 0) { return Result.ok(undefined); } const skillOptions = sourceKeys.flatMap((sourceKey) => { const source = SKILL_SOURCES[sourceKey]; const skillNames = getCuratedSkillNamesForSourceKey(sourceKey, fullConfig); return skillNames.map((skillName) => ({ value: `${sourceKey}::${skillName}`, label: skillName, hint: source.label, })); }); if (skillOptions.length === 0) { return Result.ok(undefined); } const configuredScope = skillsOptions?.scope; const configuredSelections = skillsOptions?.selections; const configuredAgents = skillsOptions?.agents; const allSkillValues = skillOptions.map((opt) => opt.value); let scope: InstallScope; let selectedSkills: string[]; let selectedAgents: SkillAgent[]; if (isSilent()) { scope = configuredScope ?? DEFAULT_SCOPE; selectedSkills = configuredSelections !== undefined ? configuredSelections.flatMap((selection) => selection.skills.map((skill) => `${selection.source}::${skill}`), ) : allSkillValues; if (selectedSkills.length === 0) return Result.ok(undefined); selectedAgents = configuredAgents ? [...configuredAgents] : [...DEFAULT_AGENTS]; if (selectedAgents.length === 0) return Result.ok(undefined); } else { const results = await navigableGroup<{ scope: InstallScope; skills: string[]; agents: SkillAgent[]; }>({ scope: async () => { if (configuredScope !== undefined) return configuredScope; return navigableSelect({ message: "Where should skills be installed?", options: [ { value: "project", label: "Project", hint: "Writes to project config files (recommended for teams)", }, { value: "global", label: "Global", hint: "Writes to user-level config files (personal machine)", }, ], initialValue: DEFAULT_SCOPE, }); }, skills: async () => { if (configuredSelections !== undefined) { return configuredSelections.flatMap((selection) => selection.skills.map((skill) => `${selection.source}::${skill}`), ); } return navigableMultiselect({ message: "Select skills to install", options: skillOptions, required: false, initialValues: allSkillValues, }); }, agents: async ({ results: r }) => { const pickedSkills = r.skills as string[] | undefined; if (pickedSkills !== undefined && pickedSkills.length === 0) return []; if (configuredAgents !== undefined) return [...configuredAgents]; return navigableMultiselect({ message: "Select agents to install skills to", options: AVAILABLE_AGENTS, required: false, initialValues: [...DEFAULT_AGENTS], }); }, }); if ( results.scope === undefined || results.skills === undefined || results.agents === undefined ) { return Result.err(new UserCancelledError({ message: "Operation cancelled" })); } scope = results.scope; selectedSkills = results.skills as string[]; selectedAgents = results.agents as SkillAgent[]; if (selectedSkills.length === 0 || selectedAgents.length === 0) { return Result.ok(undefined); } } // Group skills by source const skillsBySource: Record = {}; for (const skillKey of selectedSkills) { const [source, skillName] = skillKey.split("::"); if (!skillsBySource[source]) { skillsBySource[source] = []; } skillsBySource[source].push(skillName); } const installSpinner = createSpinner(); installSpinner.start("Installing skills..."); const runner = getPackageRunnerPrefix(packageManager); const globalFlags = scope === "global" ? ["-g"] : []; // Install skills grouped by source (project scope, no -g flag) for (const [source, skills] of Object.entries(skillsBySource)) { const installResult = await Result.tryPromise({ try: async () => { const args = [ ...runner, "skills@latest", "add", source, ...globalFlags, "--skill", ...skills, "--agent", ...selectedAgents, "-y", ]; await $({ cwd: projectDir, env: { CI: "true" } })`${args}`; }, catch: (e) => new AddonSetupError({ addon: "skills", message: `Failed to install skills from ${source}: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (installResult.isErr()) { cliLog.warn(pc.yellow(`Warning: Could not install skills from ${source}`)); } } installSpinner.stop("Skills installed"); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/addons/starlight-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import type { ProjectConfig } from "../../types"; import { AddonSetupError } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageExecutionArgs } from "../../utils/package-runner"; import { createSpinner } from "../../utils/terminal-output"; export async function setupStarlight( config: ProjectConfig, ): Promise> { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, projectDir } = config; const s = createSpinner(); s.start("Setting up Starlight docs..."); const starlightArgs = [ "docs", "--template", "starlight", "--yes", "--no-install", "--no-git", "--skip-houston", ]; const starlightArgsString = starlightArgs.join(" "); const commandWithArgs = `create-astro@latest ${starlightArgsString}`; const args = getPackageExecutionArgs(packageManager, commandWithArgs); const appsDir = path.join(projectDir, "apps"); await fs.ensureDir(appsDir); const result = await Result.tryPromise({ try: async () => { await $({ cwd: appsDir, env: { CI: "true" } })`${args}`; }, catch: (e) => new AddonSetupError({ addon: "starlight", message: `Failed to set up Starlight docs: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (result.isErr()) { s.stop("Failed to set up Starlight docs"); return result; } s.stop("Starlight docs setup successfully!"); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/addons/tauri-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import { execa } from "execa"; import fs from "fs-extra"; import { desktopWebFrontends, type ProjectConfig } from "../../types"; import { AddonSetupError } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageRunnerPrefix } from "../../utils/package-runner"; import { createSpinner } from "../../utils/terminal-output"; function getWebFrontend(frontend: Pick["frontend"]) { return frontend.find((value) => (desktopWebFrontends as readonly string[]).includes(value)); } function getTauriDevUrl(frontend: Pick["frontend"]) { const webFrontend = getWebFrontend(frontend); switch (webFrontend) { case "react-router": case "svelte": return "http://localhost:5173"; case "astro": return "http://localhost:4321"; default: return "http://localhost:3001"; } } function getTauriFrontendDist(frontend: Pick["frontend"]) { const webFrontend = getWebFrontend(frontend); switch (webFrontend) { case "react-router": return "../build/client"; case "tanstack-start": return "../dist/client"; case "next": return "../out"; case "nuxt": return "../.output/public"; case "svelte": return "../build"; default: return "../dist"; } } function getTauriBeforeBuildCommand( packageManager: Pick["packageManager"], frontend: Pick["frontend"], ) { return frontend.includes("nuxt") ? `${packageManager} run generate` : `${packageManager} run build`; } export function buildTauriInitArgs( config: Pick, ) { const { packageManager, frontend, projectDir } = config; return [ ...getPackageRunnerPrefix(packageManager), "@tauri-apps/cli@latest", "init", "--ci", "--app-name", path.basename(projectDir), "--window-title", path.basename(projectDir), "--frontend-dist", getTauriFrontendDist(frontend), "--dev-url", getTauriDevUrl(frontend), "--before-dev-command", `${packageManager} run dev`, "--before-build-command", getTauriBeforeBuildCommand(packageManager, frontend), ]; } export async function setupTauri(config: ProjectConfig): Promise> { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, frontend, projectDir } = config; const s = createSpinner(); const clientPackageDir = path.join(projectDir, "apps/web"); if (!(await fs.pathExists(clientPackageDir))) { return Result.ok(undefined); } s.start("Setting up Tauri desktop app support..."); const [command, ...args] = buildTauriInitArgs({ packageManager, frontend, projectDir }); const result = await Result.tryPromise({ try: async () => { await execa(command, args, { cwd: clientPackageDir, env: { CI: "true" }, }); }, catch: (e) => new AddonSetupError({ addon: "tauri", message: `Failed to set up Tauri: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (result.isErr()) { s.stop("Failed to set up Tauri"); return result; } s.stop("Tauri desktop app support configured successfully!"); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/addons/tui-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import { isCancel, navigableSelect, setIsFirstPrompt } from "../../prompts/navigable"; import type { ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { AddonSetupError, UserCancelledError, userCancelled } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageExecutionArgs } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; type TuiTemplate = "core" | "react" | "solid"; type TuiSetupResult = Result; const TEMPLATES = { core: { label: "Core", hint: "Basic OpenTUI template", }, react: { label: "React", hint: "React-based OpenTUI template", }, solid: { label: "Solid", hint: "SolidJS-based OpenTUI template", }, } as const; const DEFAULT_TEMPLATE: TuiTemplate = "core"; const TUI_LOCKFILES = ["bun.lock", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"] as const; export function resolveTuiTemplate(config: ProjectConfig): TuiTemplate | undefined { const configuredTemplate = config.addonOptions?.opentui?.template; if (configuredTemplate) { return configuredTemplate; } if (isSilent()) { return DEFAULT_TEMPLATE; } return undefined; } export async function setupTui(config: ProjectConfig): Promise { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, projectDir } = config; cliLog.info("Setting up OpenTUI..."); let template = resolveTuiTemplate(config); if (!template) { setIsFirstPrompt(true); const selectedTemplate = await navigableSelect({ message: "Choose a template", options: Object.entries(TEMPLATES).map(([key, templateOption]) => ({ value: key as TuiTemplate, label: templateOption.label, hint: templateOption.hint, })), initialValue: DEFAULT_TEMPLATE, }); if (isCancel(selectedTemplate)) { return userCancelled("Operation cancelled"); } template = selectedTemplate as TuiTemplate; } const commandWithArgs = `create-tui@latest --template ${template} --no-git --no-install tui`; const args = getPackageExecutionArgs(packageManager, commandWithArgs); const appsDir = path.join(projectDir, "apps"); const ensureDirResult = await Result.tryPromise({ try: () => fs.ensureDir(appsDir), catch: (e) => new AddonSetupError({ addon: "tui", message: `Failed to create directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (ensureDirResult.isErr()) { return ensureDirResult; } const s = createSpinner(); s.start("Running OpenTUI create command..."); const initResult = await Result.tryPromise({ try: async () => { await $({ cwd: appsDir, env: { CI: "true" } })`${args}`; }, catch: (e) => { s.stop(pc.red("Failed to run OpenTUI create command")); return new AddonSetupError({ addon: "tui", message: `Failed to set up OpenTUI: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); if (initResult.isErr()) { cliLog.error(pc.red("Failed to set up OpenTUI")); return initResult; } const postProcessResult = await postProcessTuiWorkspace(path.join(appsDir, "tui")); if (postProcessResult.isErr()) { s.stop(pc.yellow("OpenTUI setup completed with warnings")); cliLog.warn(pc.yellow("OpenTUI setup completed but workspace normalization had warnings")); return postProcessResult; } s.stop("OpenTUI setup complete!"); return Result.ok(undefined); } export async function postProcessTuiWorkspace( tuiDir: string, ): Promise> { const packageJsonPath = path.join(tuiDir, "package.json"); const packageJsonResult = await Result.tryPromise({ try: async () => { const packageJson = await fs.readJson(packageJsonPath); packageJson.scripts = packageJson.scripts || {}; if (!packageJson.scripts["check-types"]) { packageJson.scripts["check-types"] = "tsc --noEmit"; } await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); }, catch: (e) => new AddonSetupError({ addon: "tui", message: `Failed to normalize OpenTUI package.json: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (packageJsonResult.isErr()) { return packageJsonResult; } for (const lockfile of TUI_LOCKFILES) { const lockfilePath = path.join(tuiDir, lockfile); const removeLockfileResult = await Result.tryPromise({ try: async () => { if (await fs.pathExists(lockfilePath)) { await fs.remove(lockfilePath); } }, catch: (e) => new AddonSetupError({ addon: "tui", message: `Failed to remove nested OpenTUI lockfile '${lockfile}': ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (removeLockfileResult.isErr()) { return removeLockfileResult; } } return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/addons/ultracite-setup.ts ================================================ import { Result } from "better-result"; import { $ } from "execa"; import pc from "picocolors"; import { navigableMultiselect, navigableSelect } from "../../prompts/navigable"; import { navigableGroup } from "../../prompts/navigable-group"; import type { ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { AddonSetupError, UserCancelledError, userCancelled } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageRunnerPrefix } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; type UltraciteLinter = "biome" | "eslint" | "oxlint"; type UltraciteEditor = | "vscode" | "cursor" | "windsurf" | "codebuddy" | "antigravity" | "bob" | "kiro" | "trae" | "void" | "zed"; type UltraciteAgent = | "universal" | "claude" | "codex" | "jules" | "replit" | "devin" | "lovable" | "zencoder" | "ona" | "openclaw" | "continue" | "snowflake-cortex" | "deepagents" | "qoder" | "kimi-cli" | "mcpjam" | "mux" | "pi" | "adal" | "copilot" | "cline" | "amp" | "aider" | "firebase-studio" | "open-hands" | "gemini" | "junie" | "augmentcode" | "bob" | "kilo-code" | "goose" | "roo-code" | "warp" | "droid" | "opencode" | "crush" | "qwen" | "amazon-q-cli" | "firebender" | "cursor-cli" | "mistral-vibe" | "vercel"; type UltraciteHook = "cursor" | "windsurf" | "codebuddy" | "claude" | "copilot"; type UltraciteSetupResult = Result; type UltraciteInitArgsInput = { packageManager: ProjectConfig["packageManager"]; linter: UltraciteLinter; frameworks: string[]; editors: UltraciteEditor[]; agents: UltraciteAgent[]; hooks: UltraciteHook[]; gitHooks: string[]; }; const LINTERS = { biome: { label: "Biome (Recommended)" }, eslint: { label: "ESLint + Prettier + Stylelint" }, oxlint: { label: "Oxlint + Oxfmt" }, } as const; const AGENTS = { universal: { label: "Universal (AGENTS.md — covers all agents)" }, claude: { label: "Claude Code" }, codex: { label: "Codex" }, jules: { label: "Jules" }, replit: { label: "Replit Agent" }, devin: { label: "Devin" }, lovable: { label: "Lovable" }, zencoder: { label: "Zencoder" }, ona: { label: "Ona" }, openclaw: { label: "OpenClaw" }, continue: { label: "Continue" }, "snowflake-cortex": { label: "Snowflake Cortex" }, deepagents: { label: "Deepagents" }, qoder: { label: "Qoder" }, "kimi-cli": { label: "Kimi CLI" }, mcpjam: { label: "MCPJam" }, mux: { label: "Mux" }, pi: { label: "Pi" }, adal: { label: "AdaL" }, copilot: { label: "GitHub Copilot" }, cline: { label: "Cline" }, amp: { label: "AMP" }, aider: { label: "Aider" }, "firebase-studio": { label: "Firebase Studio" }, "open-hands": { label: "OpenHands" }, gemini: { label: "Gemini" }, junie: { label: "Junie" }, augmentcode: { label: "Augment Code" }, bob: { label: "IBM Bob" }, "kilo-code": { label: "Kilo Code" }, goose: { label: "Goose" }, "roo-code": { label: "Roo Code" }, warp: { label: "Warp" }, droid: { label: "Droid" }, opencode: { label: "OpenCode" }, crush: { label: "Crush" }, qwen: { label: "Qwen Code" }, "amazon-q-cli": { label: "Amazon Q CLI" }, firebender: { label: "Firebender" }, "cursor-cli": { label: "Cursor CLI" }, "mistral-vibe": { label: "Mistral Vibe" }, vercel: { label: "Vercel Agent" }, } as const; const HOOKS = { cursor: { label: "Cursor" }, windsurf: { label: "Windsurf" }, codebuddy: { label: "CodeBuddy" }, claude: { label: "Claude Code" }, copilot: { label: "GitHub Copilot" }, } as const; const DEFAULT_LINTER: UltraciteLinter = "biome"; const DEFAULT_EDITORS: UltraciteEditor[] = ["vscode"]; const DEFAULT_AGENTS: UltraciteAgent[] = ["universal"]; const DEFAULT_HOOKS: UltraciteHook[] = []; function getFrameworksFromFrontend(frontend: string[]): string[] { const frameworkMap: Record = { "tanstack-router": "react", "react-router": "react", "tanstack-start": "react", next: "next", nuxt: "vue", "native-bare": "react", "native-uniwind": "react", "native-unistyles": "react", svelte: "svelte", solid: "solid", astro: "astro", }; const frameworks = new Set(); for (const f of frontend) { if (f !== "none" && frameworkMap[f]) { frameworks.add(frameworkMap[f]); } } return Array.from(frameworks); } export function buildUltraciteInitArgs({ packageManager, linter, frameworks, editors, agents, hooks, gitHooks, }: UltraciteInitArgsInput): string[] { const ultraciteArgs = ["init", "--pm", packageManager, "--linter", linter]; if (frameworks.length > 0) { ultraciteArgs.push("--frameworks", ...frameworks); } if (editors.length > 0) { ultraciteArgs.push("--editors", ...editors); } if (agents.length > 0) { ultraciteArgs.push("--agents", ...agents); } if (hooks.length > 0) { ultraciteArgs.push("--hooks", ...hooks); } if (gitHooks.length > 0) { ultraciteArgs.push("--integrations", ...gitHooks); } return [ ...getPackageRunnerPrefix(packageManager), "ultracite@latest", ...ultraciteArgs, "--skip-install", "--quiet", ]; } export async function setupUltracite( config: ProjectConfig, gitHooks: string[], ): Promise { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, projectDir, frontend } = config; cliLog.info("Setting up Ultracite..."); const configuredOptions = config.addonOptions?.ultracite; let linter = configuredOptions?.linter; let editors = configuredOptions?.editors; let agents = configuredOptions?.agents; let hooks = configuredOptions?.hooks; if (!linter || !editors || !agents || !hooks) { if (isSilent()) { linter = linter ?? DEFAULT_LINTER; editors = editors ?? [...DEFAULT_EDITORS]; agents = agents ?? [...DEFAULT_AGENTS]; hooks = hooks ?? [...DEFAULT_HOOKS]; } else { const results = await navigableGroup<{ linter: UltraciteLinter; editors: UltraciteEditor[]; agents: UltraciteAgent[]; hooks: UltraciteHook[]; }>({ linter: async () => { if (linter !== undefined) return linter; return navigableSelect({ message: "Which linter do you want to use?", options: Object.entries(LINTERS).map(([key, linterOption]) => ({ value: key as UltraciteLinter, label: linterOption.label, })), initialValue: linter ?? DEFAULT_LINTER, }); }, editors: async () => { if (editors !== undefined) return editors; return navigableMultiselect({ message: "Which editors do you want to configure (recommended)?", required: false, options: [ { value: "vscode", label: "VSCode / Cursor / Windsurf" }, { value: "zed", label: "Zed" }, ], }); }, agents: async () => { if (agents !== undefined) return agents; return navigableMultiselect({ message: "Which agent files do you want to add (optional)?", required: false, options: Object.entries(AGENTS).map(([key, agent]) => ({ value: key as UltraciteAgent, label: agent.label, })), }); }, hooks: async () => { if (hooks !== undefined) return hooks; return navigableMultiselect({ message: "Which agent hooks do you want to enable (optional)?", required: false, options: Object.entries(HOOKS).map(([key, hook]) => ({ value: key as UltraciteHook, label: hook.label, })), }); }, }); if ( results.linter === undefined || results.editors === undefined || results.agents === undefined || results.hooks === undefined ) { return userCancelled("Operation cancelled"); } linter = results.linter; editors = results.editors; agents = results.agents; hooks = results.hooks; } } const frameworks = getFrameworksFromFrontend(frontend); const args = buildUltraciteInitArgs({ packageManager, linter, frameworks, editors, agents, hooks, gitHooks, }); const s = createSpinner(); s.start("Running Ultracite init command..."); const initResult = await Result.tryPromise({ try: async () => { await $({ cwd: projectDir, env: { CI: "true" } })`${args}`; }, catch: (e) => { s.stop(pc.red("Failed to run Ultracite init command")); return new AddonSetupError({ addon: "ultracite", message: `Failed to set up Ultracite: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); if (initResult.isErr()) { cliLog.error(pc.red("Failed to set up Ultracite")); return initResult; } s.stop("Ultracite setup successfully!"); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/addons/wxt-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import { isCancel, navigableSelect, setIsFirstPrompt } from "../../prompts/navigable"; import type { ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { AddonSetupError, UserCancelledError, userCancelled } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { getPackageExecutionArgs } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; type WxtTemplate = "vanilla" | "vue" | "react" | "solid" | "svelte"; type WxtSetupResult = Result; const TEMPLATES = { vanilla: { label: "Vanilla", hint: "Vanilla JavaScript template", }, vue: { label: "Vue", hint: "Vue.js template", }, react: { label: "React", hint: "React template", }, solid: { label: "Solid", hint: "SolidJS template", }, svelte: { label: "Svelte", hint: "Svelte template", }, } as const; const DEFAULT_TEMPLATE: WxtTemplate = "react"; const DEFAULT_DEV_PORT = 5555; export async function setupWxt(config: ProjectConfig): Promise { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const { packageManager, projectDir } = config; cliLog.info("Setting up WXT..."); const configuredOptions = config.addonOptions?.wxt; let template = configuredOptions?.template; if (!template) { if (isSilent()) { template = DEFAULT_TEMPLATE; } else { setIsFirstPrompt(true); const selectedTemplate = await navigableSelect({ message: "Choose a template", options: Object.entries(TEMPLATES).map(([key, templateOption]) => ({ value: key as WxtTemplate, label: templateOption.label, hint: templateOption.hint, })), initialValue: DEFAULT_TEMPLATE, }); if (isCancel(selectedTemplate)) { return userCancelled("Operation cancelled"); } template = selectedTemplate as WxtTemplate; } } const devPort = configuredOptions?.devPort ?? DEFAULT_DEV_PORT; const commandWithArgs = `wxt@latest init extension --template ${template} --pm ${packageManager}`; const args = getPackageExecutionArgs(packageManager, commandWithArgs); const appsDir = path.join(projectDir, "apps"); const ensureDirResult = await Result.tryPromise({ try: () => fs.ensureDir(appsDir), catch: (e) => new AddonSetupError({ addon: "wxt", message: `Failed to create directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (ensureDirResult.isErr()) { return ensureDirResult; } const s = createSpinner(); s.start("Running WXT init command..."); const initResult = await Result.tryPromise({ try: async () => { await $({ cwd: appsDir, env: { CI: "true" } })`${args}`; }, catch: (e) => { s.stop(pc.red("Failed to run WXT init command")); return new AddonSetupError({ addon: "wxt", message: `Failed to set up WXT: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); if (initResult.isErr()) { cliLog.error(pc.red("Failed to set up WXT")); return initResult; } const extensionDir = path.join(projectDir, "apps", "extension"); const packageJsonPath = path.join(extensionDir, "package.json"); const updatePackageResult = await Result.tryPromise({ try: async () => { if (await fs.pathExists(packageJsonPath)) { const packageJson = await fs.readJson(packageJsonPath); packageJson.name = "extension"; if (packageJson.scripts?.dev) { packageJson.scripts.dev = `${packageJson.scripts.dev} --port ${devPort}`; } await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); } }, catch: (e) => new AddonSetupError({ addon: "wxt", message: `Failed to update package.json: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (updatePackageResult.isErr()) { // Log but don't fail - the main setup succeeded cliLog.warn(pc.yellow("WXT setup completed but failed to update package.json")); } s.stop("WXT setup complete!"); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/core/add-handler.ts ================================================ import path from "node:path"; import { EMBEDDED_TEMPLATES, processAddonTemplates, processAddonsDeps, VirtualFileSystem, } from "@better-t-stack/template-generator"; import { writeTree } from "@better-t-stack/template-generator/fs-writer"; import { intro, log, outro } from "@clack/prompts"; import { Result } from "better-result"; import fs from "fs-extra"; import pc from "picocolors"; import { getAddonsToAdd } from "../../prompts/addons"; import type { AddInput, Addons, AddonOptions, ProjectConfig } from "../../types"; import { updateBtsConfig } from "../../utils/bts-config"; import { validateAddonsAgainstConfig } from "../../utils/compatibility-rules"; import { isSilent, runWithContextAsync } from "../../utils/context"; import { CLIError, UserCancelledError, displayError } from "../../utils/errors"; import { validateAgentSafePathInput } from "../../utils/input-hardening"; import { renderTitle } from "../../utils/render-title"; import { setupAddons } from "../addons/addons-setup"; import { detectProjectConfig } from "./detect-project-config"; import { installDependencies } from "./install-dependencies"; export interface AddHandlerOptions { silent?: boolean; } export interface AddResult { success: boolean; addedAddons: Addons[]; projectDir: string; dryRun?: boolean; plannedFileCount?: number; error?: string; } function mergeAddonOptions( existingAddonOptions?: AddonOptions, nextAddonOptions?: AddonOptions, ): AddonOptions | undefined { if (!existingAddonOptions && !nextAddonOptions) { return undefined; } const mergedAddonOptions: Partial = { ...existingAddonOptions }; if (nextAddonOptions) { for (const addonKey of Object.keys(nextAddonOptions) as (keyof AddonOptions)[]) { const existingOptionsForAddon = existingAddonOptions?.[addonKey]; const nextOptionsForAddon = nextAddonOptions[addonKey]; const mergedOptionsForAddon = existingOptionsForAddon && nextOptionsForAddon ? { ...existingOptionsForAddon, ...nextOptionsForAddon } : nextOptionsForAddon; ( mergedAddonOptions as Record< keyof AddonOptions, AddonOptions[keyof AddonOptions] | undefined > )[addonKey] = mergedOptionsForAddon as AddonOptions[keyof AddonOptions]; } } return Object.keys(mergedAddonOptions).length > 0 ? (mergedAddonOptions as AddonOptions) : undefined; } export async function addHandler( input: AddInput, options: AddHandlerOptions = {}, ): Promise { const { silent = false } = options; return runWithContextAsync({ silent }, async () => { const result = await addHandlerInternal(input); if (result.isOk()) { return result.value; } const error = result.error; if (UserCancelledError.is(error)) { if (isSilent()) { return { success: false, addedAddons: [], projectDir: "", error: error.message, }; } return undefined; } if (isSilent()) { return { success: false, addedAddons: [], projectDir: "", error: error.message, }; } displayError(error); process.exit(1); }); } async function addHandlerInternal( input: AddInput, ): Promise> { const projectDir = input.projectDir || process.cwd(); const hardeningResult = validateAgentSafePathInput(projectDir, "projectDir"); if (hardeningResult.isErr()) { return Result.err( new CLIError({ message: hardeningResult.error.message, cause: hardeningResult.error, }), ); } if (!isSilent()) { renderTitle(); intro(pc.magenta("Add addons to your Better-T-Stack project")); } // Detect existing project configuration const existingConfig = await detectProjectConfig(projectDir); if (!existingConfig) { return Result.err( new CLIError({ message: `No Better-T-Stack project found in ${projectDir}. Make sure bts.jsonc exists.`, }), ); } if (!isSilent()) { log.info(pc.dim(`Detected project: ${existingConfig.projectName}`)); } // Determine which addons to add let addonsToAdd: Addons[]; if (input.addons && input.addons.length > 0) { // Filter out 'none' and already installed addons addonsToAdd = input.addons.filter( (addon) => addon !== "none" && !existingConfig.addons.includes(addon), ); if (addonsToAdd.length === 0) { if (!isSilent()) { log.warn(pc.yellow("All specified addons are already installed or invalid.")); } return Result.ok({ success: true, addedAddons: [], projectDir, }); } } else if (isSilent()) { return Result.err( new CLIError({ message: "Addons are required in silent mode. Provide them via add() or add-json.", }), ); } else { // Interactive mode - prompt user to select addons const promptResult = await Result.tryPromise({ try: () => getAddonsToAdd(existingConfig), catch: (e: unknown) => { if (UserCancelledError.is(e)) return e; return new CLIError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }); if (promptResult.isErr()) { return Result.err(promptResult.error); } const selectedAddons = promptResult.value; if (selectedAddons.length === 0) { if (!isSilent()) { log.info(pc.dim("No addons selected.")); outro(pc.magenta("Nothing to add.")); } return Result.ok({ success: true, addedAddons: [], projectDir, }); } addonsToAdd = selectedAddons; } const addonsValidationResult = validateAddonsAgainstConfig(addonsToAdd, existingConfig); if (addonsValidationResult.isErr()) { return Result.err(new CLIError({ message: addonsValidationResult.error.message })); } if (!isSilent()) { log.info(pc.cyan(`Adding addons: ${addonsToAdd.join(", ")}`)); } // Build config for addon setup const updatedAddons = [...existingConfig.addons, ...addonsToAdd]; const mergedAddonOptions = mergeAddonOptions(existingConfig.addonOptions, input.addonOptions); const config: ProjectConfig = { projectName: existingConfig.projectName, projectDir, relativePath: ".", addonOptions: mergedAddonOptions, database: existingConfig.database, orm: existingConfig.orm, backend: existingConfig.backend, runtime: existingConfig.runtime, frontend: existingConfig.frontend, addons: addonsToAdd, // Only the new addons for template processing examples: existingConfig.examples, auth: existingConfig.auth, payments: existingConfig.payments, git: false, packageManager: input.packageManager || existingConfig.packageManager, install: input.install ?? false, dbSetup: existingConfig.dbSetup, api: existingConfig.api, webDeploy: existingConfig.webDeploy, serverDeploy: existingConfig.serverDeploy, }; // Create VFS and process addon templates using template-generator's logic if (!isSilent()) { log.info(pc.dim("Installing addon files...")); } const vfs = new VirtualFileSystem(); // Pre-load existing package.json files into VFS so processAddonsDeps can modify them const packageJsonPaths = [ "package.json", "apps/web/package.json", "apps/server/package.json", "apps/native/package.json", ]; for (const pkgPath of packageJsonPaths) { const fullPath = path.join(projectDir, pkgPath); if (await fs.pathExists(fullPath)) { const content = await fs.readFile(fullPath, "utf-8"); vfs.writeFile(pkgPath, content); } } // Process addon templates await processAddonTemplates(vfs, EMBEDDED_TEMPLATES, config); // Process addon dependencies (adds deps to package.json files in VFS) processAddonsDeps(vfs, config); // Write VFS to disk const tree = { root: vfs.toTree(""), fileCount: vfs.getFileCount(), directoryCount: vfs.getDirectoryCount(), config, }; if (input.dryRun) { if (!isSilent()) { log.success(pc.green("Dry run validation passed. No addon files were written.")); log.info(pc.dim(`Planned addon files: ${vfs.getFileCount()}`)); outro(pc.magenta("Dry run complete.")); } return Result.ok({ success: true, addedAddons: addonsToAdd, projectDir, dryRun: true, plannedFileCount: vfs.getFileCount(), }); } const writeResult = await writeTree(tree, projectDir); if (writeResult.isErr()) { return Result.err( new CLIError({ message: `Failed to write addon files: ${writeResult.error.message}`, }), ); } if (vfs.getFileCount() > 0 && !isSilent()) { log.info(pc.dim(`Wrote ${vfs.getFileCount()} addon files`)); } // Run addon setup (handles deps and interactive prompts) // Wrap with Result.tryPromise since setupAddons can throw UserCancelledError const setupResult = await Result.tryPromise({ try: () => setupAddons(config), catch: (e: unknown) => { if (UserCancelledError.is(e)) return e; return new CLIError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }); if (setupResult.isErr()) { return Result.err(setupResult.error); } // Update bts.jsonc with new addons await updateBtsConfig(projectDir, { addons: updatedAddons, addonOptions: config.addonOptions, }); // Install dependencies if requested if (input.install) { if (!isSilent()) { log.info(pc.dim("Installing dependencies...")); } await installDependencies({ projectDir, packageManager: config.packageManager }); } if (!isSilent()) { log.success(pc.green(`Successfully added: ${addonsToAdd.join(", ")}`)); if (!input.install) { log.info( pc.yellow( `Run '${config.packageManager === "npm" ? "npm install" : `${config.packageManager} install`}' to install new dependencies.`, ), ); } outro(pc.magenta("Addons added successfully!")); } return Result.ok({ success: true, addedAddons: addonsToAdd, projectDir, plannedFileCount: vfs.getFileCount(), }); } ================================================ FILE: apps/cli/src/helpers/core/command-handlers.ts ================================================ import path from "node:path"; import { generateReproducibleCommand } from "@better-t-stack/template-generator"; import { intro, log, outro } from "@clack/prompts"; import { Result, UnhandledException } from "better-result"; import fs from "fs-extra"; import pc from "picocolors"; import { getDefaultConfig } from "../../constants"; import { gatherConfig } from "../../prompts/config-prompts"; import { getProjectName } from "../../prompts/project-name"; import type { CreateInput, DirectoryConflict, ProjectConfig } from "../../types"; import { trackProjectCreation } from "../../utils/analytics"; import { validateAddonsAgainstFrontends } from "../../utils/compatibility-rules"; import { isSilent, runWithContextAsync } from "../../utils/context"; import { displayConfig } from "../../utils/display-config"; import { type AppError, CLIError, DirectoryConflictError, ProjectCreationError, UserCancelledError, displayError, } from "../../utils/errors"; import { validateAgentSafePathInput } from "../../utils/input-hardening"; import { handleDirectoryConflict, setupProjectDirectory } from "../../utils/project-directory"; import { addToHistory } from "../../utils/project-history"; import { validateProjectName } from "../../utils/project-name-validation"; import { renderTitle } from "../../utils/render-title"; import { getTemplateConfig, getTemplateDescription } from "../../utils/templates"; import { cliConsola } from "../../utils/terminal-output"; import { getProvidedFlags, processAndValidateFlags, processProvidedFlagsWithoutValidation, validateConfigCompatibility, } from "../../validation"; import { createProject } from "./create-project"; import { mergeResolvedDbSetupOptions } from "./db-setup-options"; export interface CreateHandlerOptions { silent?: boolean; } /** * Result type for project creation */ export interface CreateProjectResult { success: boolean; projectConfig: ProjectConfig; reproducibleCommand: string; timeScaffolded: string; elapsedTimeMs: number; projectDirectory: string; relativePath: string; error?: string; } /** * Create an empty/failed result */ function createEmptyResult( timeScaffolded: string, elapsedTimeMs: number, error?: string, ): CreateProjectResult { return { success: false, projectConfig: { projectName: "", projectDir: "", relativePath: "", database: "none", orm: "none", backend: "none", runtime: "none", frontend: [], addons: [], examples: [], auth: "none", payments: "none", git: false, packageManager: "npm", install: false, dbSetup: "none", api: "none", webDeploy: "none", serverDeploy: "none", } satisfies ProjectConfig, reproducibleCommand: "", timeScaffolded, elapsedTimeMs, projectDirectory: "", relativePath: "", error, }; } type CreateHandlerError = | UserCancelledError | CLIError | DirectoryConflictError | ProjectCreationError | UnhandledException; export async function createProjectHandler( input: CreateInput & { projectName?: string }, options: CreateHandlerOptions = {}, ): Promise { const { silent = false } = options; return runWithContextAsync({ silent }, async () => { const startTime = Date.now(); const timeScaffolded = new Date().toISOString(); const result = await createProjectHandlerInternal(input, startTime, timeScaffolded); // Handle success case if (result.isOk()) { return result.value; } // Handle error cases const error = result.error; const elapsedTimeMs = Date.now() - startTime; // Handle user cancellation specially if (UserCancelledError.is(error)) { if (isSilent()) { return createEmptyResult(timeScaffolded, elapsedTimeMs, error.message); } // In CLI mode, just return undefined (the cancel UI was already shown) return undefined; } // For silent mode, always return a failed result instead of throwing if (isSilent()) { return createEmptyResult(timeScaffolded, elapsedTimeMs, error.message); } // In CLI mode, display error and exit displayError(error as AppError); process.exit(1); }); } async function createProjectHandlerInternal( input: CreateInput & { projectName?: string }, startTime: number, timeScaffolded: string, ): Promise> { return Result.gen(async function* () { if (!isSilent() && input.renderTitle !== false) { renderTitle(); } if (!isSilent()) intro(pc.magenta("Creating a new Better-T-Stack project")); if (!isSilent() && input.yolo) { cliConsola.fatal("YOLO mode enabled - skipping checks. Things may break!"); } // Get project name let currentPathInput: string; if (isSilent()) { const silentProjectName = yield* Result.await(resolveProjectNameForSilent(input)); currentPathInput = silentProjectName; } else if (input.yes && input.projectName) { currentPathInput = input.projectName; } else if (input.yes) { const defaultConfig = getDefaultConfig(); let defaultName: string = defaultConfig.relativePath; let counter = 1; while ( (await fs.pathExists(path.resolve(process.cwd(), defaultName))) && (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0 ) { defaultName = `${defaultConfig.projectName}-${counter}`; counter++; } currentPathInput = defaultName; } else { // getProjectName may throw UserCancelledError const projectNameResult = yield* Result.await( Result.tryPromise({ try: async () => getProjectName(input.projectName), catch: (e: unknown) => { if (e instanceof UserCancelledError) return e; return new CLIError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }), ); currentPathInput = projectNameResult; } yield* validateResolvedProjectPathInput(currentPathInput); // Handle directory conflict let finalPathInput: string; let shouldClearDirectory: boolean; const conflictResult = yield* Result.await( handleDirectoryConflictResult(currentPathInput, input.directoryConflict), ); finalPathInput = conflictResult.finalPathInput; shouldClearDirectory = conflictResult.shouldClearDirectory; yield* validateResolvedProjectPathInput(finalPathInput); let finalResolvedPath: string; let finalBaseName: string; if (input.dryRun) { finalResolvedPath = finalPathInput === "." ? process.cwd() : path.resolve(process.cwd(), finalPathInput); finalBaseName = path.basename(finalResolvedPath); } else { // Setup project directory const setupResult = yield* Result.await( Result.tryPromise({ try: async () => setupProjectDirectory(finalPathInput, shouldClearDirectory), catch: (e: unknown) => { if (e instanceof UserCancelledError) return e; return new CLIError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }), ); finalResolvedPath = setupResult.finalResolvedPath; finalBaseName = setupResult.finalBaseName; } const originalInput = { ...input, projectDirectory: input.projectName, }; const providedFlags = getProvidedFlags(originalInput); let cliInput = originalInput; // Handle template if (input.template && input.template !== "none") { const templateConfig = getTemplateConfig(input.template); if (templateConfig) { const templateName = input.template.toUpperCase(); const templateDescription = getTemplateDescription(input.template); if (!isSilent()) { log.message(pc.bold(pc.cyan(`Using template: ${pc.white(templateName)}`))); log.message(pc.dim(` ${templateDescription}`)); } const userOverrides: Record = {}; for (const [key, value] of Object.entries(originalInput)) { if (value !== undefined) { userOverrides[key] = value; } } cliInput = { ...templateConfig, ...userOverrides, template: input.template, projectDirectory: originalInput.projectDirectory, }; } } // Build config let config: ProjectConfig; if (cliInput.yes) { const flagConfigResult = processProvidedFlagsWithoutValidation(cliInput, finalBaseName); if (flagConfigResult.isErr()) { return Result.err( new CLIError({ message: flagConfigResult.error.message, cause: flagConfigResult.error }), ); } const flagConfig = flagConfigResult.value; config = { ...getDefaultConfig(), ...flagConfig, projectName: finalBaseName, projectDir: finalResolvedPath, relativePath: finalPathInput, }; // Validate config compatibility const validationResult = validateConfigCompatibility(config, providedFlags, cliInput); if (validationResult.isErr()) { return Result.err( new CLIError({ message: validationResult.error.message, cause: validationResult.error }), ); } if (!isSilent()) { log.info(pc.yellow("Using default/flag options (config prompts skipped):")); log.message(displayConfig(config)); } } else { // Process and validate flags const flagConfigResult = processAndValidateFlags(cliInput, providedFlags, finalBaseName); if (flagConfigResult.isErr()) { return Result.err( new CLIError({ message: flagConfigResult.error.message, cause: flagConfigResult.error }), ); } const flagConfig = flagConfigResult.value; const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig; if (!isSilent() && Object.keys(otherFlags).length > 0) { log.info(pc.yellow("Using these pre-selected options:")); log.message(displayConfig(otherFlags)); log.message(""); } // gatherConfig may throw UserCancelledError const gatherResult = yield* Result.await( Result.tryPromise({ try: async () => gatherConfig(flagConfig, finalBaseName, finalResolvedPath, finalPathInput), catch: (e: unknown) => { if (e instanceof UserCancelledError) return e; return new CLIError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }), ); config = gatherResult; } const effectiveDbSetupOptions = mergeResolvedDbSetupOptions( config.dbSetup, config.dbSetupOptions, { manualDb: cliInput.manualDb ?? input.manualDb, dbSetupOptions: cliInput.dbSetupOptions ?? input.dbSetupOptions, }, ); if (effectiveDbSetupOptions) { config = { ...config, dbSetupOptions: effectiveDbSetupOptions, }; } if (!input.yolo) { const addonsValidationResult = validateAddonsAgainstFrontends( config.addons, config.frontend, config.auth, config.backend, config.runtime, ); if (addonsValidationResult.isErr()) { return Result.err(new CLIError({ message: addonsValidationResult.error.message })); } } const reproducibleCommand = generateReproducibleCommand(config); if (input.dryRun) { const elapsedTimeMs = Date.now() - startTime; if (!isSilent()) { if (shouldClearDirectory) { log.warn( pc.yellow( `Dry run: directory "${finalPathInput}" would be cleared due to overwrite strategy.`, ), ); } log.success(pc.green("Dry run validation passed. No files were written.")); log.message(pc.dim(`Target directory: ${finalResolvedPath}`)); log.message(pc.dim(`Run without --dry-run to create the project.`)); outro(pc.magenta("Dry run complete.")); } return Result.ok({ success: true, projectConfig: config, reproducibleCommand, timeScaffolded, elapsedTimeMs, projectDirectory: config.projectDir, relativePath: config.relativePath, }); } // Create the project yield* Result.await( createProject(config, { manualDb: cliInput.manualDb ?? input.manualDb, dbSetupOptions: effectiveDbSetupOptions, }), ); if (!isSilent()) { log.success( pc.blue(`You can reproduce this setup with the following command:\n${reproducibleCommand}`), ); } await trackProjectCreation(config, input.disableAnalytics); // Track locally in history.json (non-fatal) const historyResult = await addToHistory(config, reproducibleCommand); if (historyResult.isErr() && !isSilent()) { log.warn(pc.yellow(historyResult.error.message)); } const elapsedTimeMs = Date.now() - startTime; if (!isSilent()) { const elapsedTimeInSeconds = (elapsedTimeMs / 1000).toFixed(2); outro( pc.magenta(`Project created successfully in ${pc.bold(elapsedTimeInSeconds)} seconds!`), ); } return Result.ok({ success: true, projectConfig: config, reproducibleCommand, timeScaffolded, elapsedTimeMs, projectDirectory: config.projectDir, relativePath: config.relativePath, }); }); } interface DirectoryConflictResult { finalPathInput: string; shouldClearDirectory: boolean; } function isPathWithinCwd(targetPath: string) { const resolved = path.resolve(targetPath); const rel = path.relative(process.cwd(), resolved); return !rel.startsWith("..") && !path.isAbsolute(rel); } function validateResolvedProjectPathInput(candidate: string): Result { const hardeningResult = validateAgentSafePathInput(candidate, "projectName"); if (hardeningResult.isErr()) { return Result.err( new CLIError({ message: hardeningResult.error.message, cause: hardeningResult.error, }), ); } if (candidate === ".") { return Result.ok(undefined); } const finalDirName = path.basename(candidate); const validationResult = validateProjectName(finalDirName); if (validationResult.isErr()) { return Result.err( new CLIError({ message: validationResult.error.message, cause: validationResult.error, }), ); } if (!isPathWithinCwd(candidate)) { return Result.err( new CLIError({ message: "Project path must be within current directory", }), ); } return Result.ok(undefined); } async function resolveProjectNameForSilent( input: CreateInput & { projectName?: string }, ): Promise> { const defaultConfig = getDefaultConfig(); const rawProjectName = input.projectName?.trim() || undefined; const candidate = rawProjectName ?? defaultConfig.relativePath; return Result.ok(candidate); } async function handleDirectoryConflictResult( currentPathInput: string, strategy?: DirectoryConflict, ): Promise< Result > { if (strategy) { return handleDirectoryConflictProgrammatically(currentPathInput, strategy); } // Use interactive handler return Result.tryPromise({ try: async () => handleDirectoryConflict(currentPathInput), catch: (e: unknown) => { if (e instanceof UserCancelledError) return e; if (e instanceof CLIError) return e; return new CLIError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }); } async function handleDirectoryConflictProgrammatically( currentPathInput: string, strategy: DirectoryConflict, ): Promise> { const currentPath = path.resolve(process.cwd(), currentPathInput); if (!(await fs.pathExists(currentPath))) { return Result.ok({ finalPathInput: currentPathInput, shouldClearDirectory: false }); } const dirContents = await fs.readdir(currentPath); const isNotEmpty = dirContents.length > 0; if (!isNotEmpty) { return Result.ok({ finalPathInput: currentPathInput, shouldClearDirectory: false }); } switch (strategy) { case "overwrite": return Result.ok({ finalPathInput: currentPathInput, shouldClearDirectory: true }); case "merge": return Result.ok({ finalPathInput: currentPathInput, shouldClearDirectory: false }); case "increment": { let counter = 1; const baseName = currentPathInput; let finalPathInput = `${baseName}-${counter}`; while ( (await fs.pathExists(path.resolve(process.cwd(), finalPathInput))) && (await fs.readdir(path.resolve(process.cwd(), finalPathInput))).length > 0 ) { counter++; finalPathInput = `${baseName}-${counter}`; } return Result.ok({ finalPathInput, shouldClearDirectory: false }); } case "error": return Result.err(new DirectoryConflictError({ directory: currentPathInput })); default: return Result.err(new DirectoryConflictError({ directory: currentPathInput })); } } ================================================ FILE: apps/cli/src/helpers/core/convex-codegen.ts ================================================ import path from "node:path"; import { $ } from "execa"; import type { PackageManager } from "../../types"; import { getPackageExecutionArgs } from "../../utils/package-runner"; // having problems running this in convex + better-auth export async function runConvexCodegen( projectDir: string, packageManager: PackageManager | null | undefined, ) { const backendDir = path.join(projectDir, "packages/backend"); const args = getPackageExecutionArgs(packageManager, "convex codegen"); await $({ cwd: backendDir })`${args}`; } ================================================ FILE: apps/cli/src/helpers/core/create-project.ts ================================================ import os from "node:os"; import path from "node:path"; import { generate, EMBEDDED_TEMPLATES } from "@better-t-stack/template-generator"; import { writeTree } from "@better-t-stack/template-generator/fs-writer"; import { log } from "@clack/prompts"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import type { DbSetupOptions, ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { ProjectCreationError } from "../../utils/errors"; import { formatProject } from "../../utils/file-formatter"; import { getLatestCLIVersion } from "../../utils/get-latest-cli-version"; import { setupAddons } from "../addons/addons-setup"; import { setupDatabase } from "../core/db-setup"; import { initializeGit } from "./git"; import { installDependencies } from "./install-dependencies"; import { displayPostInstallInstructions } from "./post-installation"; export interface CreateProjectOptions { manualDb?: boolean; dbSetupOptions?: DbSetupOptions; } /** * Creates a new project with the given configuration. * Returns a Result with the project directory path on success, or an error on failure. */ export async function createProject( options: ProjectConfig, cliInput: CreateProjectOptions = {}, ): Promise> { return Result.gen(async function* () { const projectDir = options.projectDir; const isConvex = options.backend === "convex"; // Ensure project directory exists yield* Result.await( Result.tryPromise({ try: () => fs.ensureDir(projectDir), catch: (e) => new ProjectCreationError({ phase: "directory-setup", message: `Failed to create project directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }), ); // Generate virtual project using Result-based API const tree = yield* Result.await( generate({ config: options, templates: EMBEDDED_TEMPLATES, version: getLatestCLIVersion(), }).then((result) => result.mapError( (e) => new ProjectCreationError({ phase: e.phase || "template-generation", message: e.message, cause: e, }), ), ), ); // Write tree to filesystem using Result-based API yield* Result.await( writeTree(tree, projectDir).then((result) => result.mapError( (e) => new ProjectCreationError({ phase: "file-writing", message: e.message, cause: e, }), ), ), ); // Set package manager version yield* Result.await(setPackageManagerVersion(projectDir, options.packageManager)); // Setup database if needed if (!isConvex && options.database !== "none") { yield* Result.await( Result.tryPromise({ try: () => setupDatabase(options, cliInput), catch: (e) => new ProjectCreationError({ phase: "database-setup", message: `Failed to setup database: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }), ); } // Setup addons if any if (options.addons.length > 0 && options.addons[0] !== "none") { yield* Result.await( Result.tryPromise({ try: () => setupAddons(options), catch: (e) => new ProjectCreationError({ phase: "addons-setup", message: `Failed to setup addons: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }), ); } // Format project yield* Result.await(formatProject(projectDir)); if (!isSilent()) log.success("Project template successfully scaffolded!"); // Install dependencies if requested if (options.install) { yield* Result.await( installDependencies({ projectDir, packageManager: options.packageManager, }), ); } // Initialize git if requested yield* Result.await(initializeGit(projectDir, options.git)); // Display post-install instructions if (!isSilent()) { await displayPostInstallInstructions({ ...options, depsInstalled: options.install, }); } return Result.ok(projectDir); }); } async function setPackageManagerVersion( projectDir: string, packageManager: ProjectConfig["packageManager"], ): Promise> { const pkgJsonPath = path.join(projectDir, "package.json"); if (!(await fs.pathExists(pkgJsonPath))) { return Result.ok(undefined); } // First, try to get the version const versionResult = await Result.tryPromise({ try: async () => { // Run in a neutral directory to avoid local package manager shims affecting lookup. const { stdout } = await $({ cwd: os.tmpdir() })`${packageManager} -v`; return stdout.trim(); }, catch: () => null, // Return null if we can't get version }); // Now update the package.json return Result.tryPromise({ try: async () => { const pkgJson = await fs.readJson(pkgJsonPath); if (versionResult.isOk() && versionResult.value) { pkgJson.packageManager = `${packageManager}@${versionResult.value}`; } else { // If we can't get the version, just remove the packageManager field delete pkgJson.packageManager; } await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); }, catch: (e) => new ProjectCreationError({ phase: "package-manager-version", message: `Failed to set package manager version: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } ================================================ FILE: apps/cli/src/helpers/core/db-setup-options.ts ================================================ import type { DatabaseSetup, DbSetupOptions } from "../../types"; import { isSilent } from "../../utils/context"; export interface DatabaseSetupCliOptions { manualDb?: boolean; dbSetupOptions?: DbSetupOptions; } export type DbSetupMode = NonNullable; const REMOTE_PROVISIONING_DB_SETUPS: DatabaseSetup[] = [ "turso", "neon", "prisma-postgres", "supabase", "mongodb-atlas", ]; export function requiresProvisioningGuardrails(dbSetup: DatabaseSetup): boolean { return REMOTE_PROVISIONING_DB_SETUPS.includes(dbSetup); } export function resolveDbSetupMode( dbSetup: DatabaseSetup, cliOptions: DatabaseSetupCliOptions = {}, ): DbSetupMode | undefined { if (dbSetup === "none") { return undefined; } const explicitMode = cliOptions.dbSetupOptions?.mode; if (explicitMode) { return explicitMode; } if (cliOptions.manualDb === true) { return "manual"; } if (isSilent() && requiresProvisioningGuardrails(dbSetup)) { return "manual"; } return undefined; } export function mergeResolvedDbSetupOptions( dbSetup: DatabaseSetup, dbSetupOptions: DbSetupOptions | undefined, cliOptions: DatabaseSetupCliOptions = {}, ): DbSetupOptions | undefined { if (dbSetup === "none") { return undefined; } const resolvedMode = resolveDbSetupMode(dbSetup, { ...cliOptions, dbSetupOptions: dbSetupOptions ?? cliOptions.dbSetupOptions, }); if (!dbSetupOptions && !resolvedMode) { return undefined; } return { ...dbSetupOptions, ...(resolvedMode ? { mode: resolvedMode } : {}), }; } ================================================ FILE: apps/cli/src/helpers/core/db-setup.ts ================================================ /** * Database setup - CLI-only operations * Calls external database provider CLIs (turso, neon, prisma-postgres, etc.) * Dependencies are handled by the generator's db-deps processor */ import path from "node:path"; import { Result } from "better-result"; import fs from "fs-extra"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { DatabaseSetupError, UserCancelledError } from "../../utils/errors"; import { cliConsola } from "../../utils/terminal-output"; import { setupCloudflareD1 } from "../database-providers/d1-setup"; import { setupDockerCompose } from "../database-providers/docker-compose-setup"; import { setupMongoDBAtlas } from "../database-providers/mongodb-atlas-setup"; import { setupNeonPostgres } from "../database-providers/neon-setup"; import { setupPlanetScale } from "../database-providers/planetscale-setup"; import { setupPrismaPostgres } from "../database-providers/prisma-postgres-setup"; import { setupSupabase } from "../database-providers/supabase-setup"; import { setupTurso } from "../database-providers/turso-setup"; import { type DatabaseSetupCliOptions, mergeResolvedDbSetupOptions } from "./db-setup-options"; export async function setupDatabase(config: ProjectConfig, cliInput?: DatabaseSetupCliOptions) { const { database, dbSetup, backend, projectDir } = config; if (backend === "convex" || database === "none") { // Clean up server db dir if not using convex if (backend !== "convex") { const serverDbDir = path.join(projectDir, "apps/server/src/db"); if (await fs.pathExists(serverDbDir)) { await fs.remove(serverDbDir); } } return; } const dbPackageDir = path.join(projectDir, "packages/db"); if (!(await fs.pathExists(dbPackageDir))) { return; } // Helper to run setup and handle Result async function runSetup( setupFn: () => Promise>, ): Promise { const result = await setupFn(); if (result.isErr()) { // Re-throw user cancellation to propagate up if (UserCancelledError.is(result.error)) { throw result.error; } // Log other errors but don't fail the overall project creation cliConsola.error(pc.red(result.error.message)); } } const resolvedCliInput: DatabaseSetupCliOptions = { ...cliInput, dbSetupOptions: mergeResolvedDbSetupOptions(dbSetup, config.dbSetupOptions, cliInput), }; // Call external database provider CLIs if (dbSetup === "docker") { await runSetup(() => setupDockerCompose(config)); } else if (database === "sqlite" && dbSetup === "turso") { await runSetup(() => setupTurso(config, resolvedCliInput)); } else if (database === "sqlite" && dbSetup === "d1") { await runSetup(() => setupCloudflareD1(config)); } else if (database === "postgres") { if (dbSetup === "prisma-postgres") { await runSetup(() => setupPrismaPostgres(config, resolvedCliInput)); } else if (dbSetup === "neon") { await runSetup(() => setupNeonPostgres(config, resolvedCliInput)); } else if (dbSetup === "planetscale") { await runSetup(() => setupPlanetScale(config)); } else if (dbSetup === "supabase") { await runSetup(() => setupSupabase(config, resolvedCliInput)); } } else if (database === "mysql" && dbSetup === "planetscale") { await runSetup(() => setupPlanetScale(config)); } else if (database === "mongodb" && dbSetup === "mongodb-atlas") { await runSetup(() => setupMongoDBAtlas(config, resolvedCliInput)); } } ================================================ FILE: apps/cli/src/helpers/core/detect-project-config.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import fs from "fs-extra"; import { readBtsConfig } from "../../utils/bts-config"; export async function detectProjectConfig(projectDir: string) { const result = await Result.tryPromise({ try: async () => { const btsConfig = await readBtsConfig(projectDir); if (btsConfig) { return { projectDir, projectName: path.basename(projectDir), addonOptions: btsConfig.addonOptions, dbSetupOptions: btsConfig.dbSetupOptions, database: btsConfig.database, orm: btsConfig.orm, backend: btsConfig.backend, runtime: btsConfig.runtime, frontend: btsConfig.frontend, addons: btsConfig.addons, examples: btsConfig.examples, auth: btsConfig.auth, payments: btsConfig.payments, packageManager: btsConfig.packageManager, dbSetup: btsConfig.dbSetup, api: btsConfig.api, webDeploy: btsConfig.webDeploy, serverDeploy: btsConfig.serverDeploy, }; } return null; }, catch: () => null, }); return result.isOk() ? result.value : null; } export async function isBetterTStackProject(projectDir: string): Promise { const result = await Result.tryPromise({ try: () => fs.pathExists(path.join(projectDir, "bts.jsonc")), catch: () => false, }); return result.isOk() ? result.value : false; } ================================================ FILE: apps/cli/src/helpers/core/git.ts ================================================ import { Result } from "better-result"; import { $ } from "execa"; import pc from "picocolors"; import { ProjectCreationError } from "../../utils/errors"; import { cliLog } from "../../utils/terminal-output"; export async function initializeGit( projectDir: string, useGit: boolean, ): Promise> { if (!useGit) return Result.ok(undefined); const gitVersionResult = await $({ cwd: projectDir, reject: false, stderr: "pipe", })`git --version`; if (gitVersionResult.exitCode !== 0) { cliLog.warn(pc.yellow("Git is not installed")); return Result.ok(undefined); } const result = await $({ cwd: projectDir, reject: false, stderr: "pipe", })`git init`; if (result.exitCode !== 0) { return Result.err( new ProjectCreationError({ phase: "git-initialization", message: `Git initialization failed: ${result.stderr}`, }), ); } return Result.tryPromise({ try: async () => { await $({ cwd: projectDir })`git add -A`; await $({ cwd: projectDir })`git commit -m ${"initial commit"}`; }, catch: (e) => new ProjectCreationError({ phase: "git-initialization", message: `Git commit failed: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } ================================================ FILE: apps/cli/src/helpers/core/install-dependencies.ts ================================================ import { Result } from "better-result"; import { $ } from "execa"; import pc from "picocolors"; import type { Addons, PackageManager } from "../../types"; import { ProjectCreationError } from "../../utils/errors"; import { shouldSkipExternalCommands } from "../../utils/external-commands"; import { createSpinner } from "../../utils/terminal-output"; export async function installDependencies({ projectDir, packageManager, }: { projectDir: string; packageManager: PackageManager; addons?: Addons[]; }): Promise> { if (shouldSkipExternalCommands()) { return Result.ok(undefined); } const s = createSpinner(); s.start(`Running ${packageManager} install...`); const result = await Result.tryPromise({ try: async () => { await $({ cwd: projectDir, stderr: "inherit", })`${packageManager} install`; }, catch: (e) => new ProjectCreationError({ phase: "dependency-installation", message: `Installation error: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (result.isOk()) { s.stop("Dependencies installed successfully"); } else { s.stop(pc.red("Failed to install dependencies")); } return result; } ================================================ FILE: apps/cli/src/helpers/core/post-installation.ts ================================================ import pc from "picocolors"; import type { Backend, Database, DatabaseSetup, Frontend, ORM, ProjectConfig, Runtime, ServerDeploy, WebDeploy, } from "../../types"; import { desktopWebFrontends } from "../../types"; import { getDockerStatus } from "../../utils/docker-utils"; import { fetchSponsorsQuietly, formatPostInstallSpecialSponsorsSection, } from "../../utils/sponsors"; import { cliConsola } from "../../utils/terminal-output"; function getDesktopStaticBuildNote(frontend: Frontend[]): string { const staticBuildFrontends = new Map([ ["tanstack-start", "TanStack Start"], ["next", "Next.js"], ["nuxt", "Nuxt"], ["svelte", "SvelteKit"], ["astro", "Astro"], ]); const staticBuildFrontend = frontend.find((value) => staticBuildFrontends.has(value)); if (!staticBuildFrontend) { return ""; } return `${pc.yellow( "NOTE:", )} Desktop builds package static web assets.\n ${staticBuildFrontends.get( staticBuildFrontend, )} needs a static/export web build before desktop packaging will work.`; } export async function displayPostInstallInstructions( config: ProjectConfig & { depsInstalled: boolean }, ) { const { api, database, relativePath, packageManager, depsInstalled, orm, addons, runtime, frontend, backend, dbSetup, webDeploy, serverDeploy, } = config; const isConvex = backend === "convex"; const isBackendSelf = backend === "self"; const runCmd = packageManager === "npm" ? "npm run" : packageManager === "pnpm" ? "pnpm run" : "bun run"; const cdCmd = `cd ${relativePath}`; const hasHusky = addons?.includes("husky"); const hasLefthook = addons?.includes("lefthook"); const hasGitHooksOrLinting = addons?.includes("husky") || addons?.includes("biome") || addons?.includes("lefthook") || addons?.includes("oxlint"); const databaseInstructions = !isConvex && database !== "none" ? await getDatabaseInstructions( database, orm, runCmd, runtime, dbSetup, webDeploy, serverDeploy, backend, ) : ""; const tauriInstructions = addons?.includes("tauri") ? getTauriInstructions(runCmd, frontend) : ""; const electrobunInstructions = addons?.includes("electrobun") ? getElectrobunInstructions(runCmd, frontend) : ""; const huskyInstructions = hasHusky ? getHuskyInstructions(runCmd) : ""; const lefthookInstructions = hasLefthook ? getLefthookInstructions(packageManager) : ""; const lintingInstructions = hasGitHooksOrLinting ? getLintingInstructions(runCmd) : ""; const nativeInstructions = (frontend?.includes("native-bare") || frontend?.includes("native-uniwind") || frontend?.includes("native-unistyles")) && backend !== "none" ? getNativeInstructions(isConvex, isBackendSelf, frontend || [], runCmd) : ""; const pwaInstructions = addons?.includes("pwa") && frontend?.includes("react-router") ? getPwaInstructions() : ""; const starlightInstructions = addons?.includes("starlight") ? getStarlightInstructions(runCmd) : ""; const clerkInstructions = config.auth === "clerk" ? getClerkInstructions(frontend || [], backend, api) : ""; const polarInstructions = config.payments === "polar" && config.auth === "better-auth" ? getPolarInstructions(backend) : ""; const alchemyDeployInstructions = getAlchemyDeployInstructions( runCmd, webDeploy, serverDeploy, backend, ); const hasWeb = frontend?.some((f) => (desktopWebFrontends as readonly string[]).includes(f)); const hasNative = frontend?.includes("native-bare") || frontend?.includes("native-uniwind") || frontend?.includes("native-unistyles"); const hasReactRouter = frontend?.includes("react-router"); const hasTanStackRouter = frontend?.includes("tanstack-router"); const hasSvelte = frontend?.includes("svelte"); const hasAstro = frontend?.includes("astro"); const webPort = hasReactRouter || hasTanStackRouter || hasSvelte ? "5173" : hasAstro ? "4321" : "3001"; const betterAuthConvexInstructions = isConvex && config.auth === "better-auth" ? getBetterAuthConvexInstructions(hasWeb ?? false, webPort, packageManager) : ""; const bunWebNativeWarning = packageManager === "bun" && hasNative && hasWeb ? getBunWebNativeWarning() : ""; const noOrmWarning = !isConvex && database !== "none" && orm === "none" ? getNoOrmWarning() : ""; let output = `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}\n`; let stepCounter = 2; if (!depsInstalled) { output += `${pc.cyan(`${stepCounter++}.`)} ${packageManager} install\n`; } if (database === "sqlite" && dbSetup !== "d1") { output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} db:local\n${pc.dim( " (optional - starts local SQLite database)", )}\n`; } if (isConvex) { output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup\n${pc.dim( " (this will guide you through Convex project setup)", )}\n`; output += `${pc.cyan(`${stepCounter++}.`)} Copy environment variables from\n${pc.white( " packages/backend/.env.local", )} to ${pc.white("apps/*/.env")}\n`; output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`; } else if (isBackendSelf) { output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n`; } else { if (runtime !== "workers") { output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n`; } if (runtime === "workers") { if (dbSetup === "d1") { output += `${pc.yellow( "IMPORTANT:", )} Complete D1 database setup first\n (see Database commands below)\n`; } output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n`; } } const hasStandaloneBackend = backend !== "none"; const hasAnyService = hasWeb || hasStandaloneBackend || addons?.includes("starlight") || addons?.includes("fumadocs"); if (hasAnyService) { output += `${pc.bold("Your project will be available at:")}\n`; if (hasWeb) { output += `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`; } else if (!hasNative && !addons?.includes("starlight")) { output += `${pc.yellow( "NOTE:", )} You are creating a backend-only app\n (no frontend selected)\n`; } if (!isConvex && !isBackendSelf && hasStandaloneBackend) { output += `${pc.cyan("•")} Backend API: http://localhost:3000\n`; if (api === "orpc") { output += `${pc.cyan("•")} OpenAPI (Scalar UI): http://localhost:3000/api-reference\n`; } } if (isBackendSelf && api === "orpc") { const rpcPath = frontend?.includes("next") || frontend?.includes("tanstack-start") ? "/api/rpc" : "/rpc"; output += `${pc.cyan("•")} OpenAPI (Scalar UI): http://localhost:${webPort}${rpcPath}/api-reference\n`; } if (addons?.includes("starlight")) { output += `${pc.cyan("•")} Docs: http://localhost:4321\n`; } if (addons?.includes("fumadocs")) { output += `${pc.cyan("•")} Fumadocs: http://localhost:4000\n`; } } if (nativeInstructions) output += `\n${nativeInstructions.trim()}\n`; if (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`; if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`; if (electrobunInstructions) output += `\n${electrobunInstructions.trim()}\n`; if (huskyInstructions) output += `\n${huskyInstructions.trim()}\n`; if (lefthookInstructions) output += `\n${lefthookInstructions.trim()}\n`; if (lintingInstructions) output += `\n${lintingInstructions.trim()}\n`; if (pwaInstructions) output += `\n${pwaInstructions.trim()}\n`; if (alchemyDeployInstructions) output += `\n${alchemyDeployInstructions.trim()}\n`; if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`; if (clerkInstructions) output += `\n${clerkInstructions.trim()}\n`; if (betterAuthConvexInstructions) output += `\n${betterAuthConvexInstructions.trim()}\n`; if (polarInstructions) output += `\n${polarInstructions.trim()}\n`; if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`; if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`; const sponsorsResult = await fetchSponsorsQuietly(); const specialSponsorsSection = sponsorsResult.isOk() ? formatPostInstallSpecialSponsorsSection(sponsorsResult.value) : ""; if (specialSponsorsSection) { output += `\n${specialSponsorsSection.trim()}\n`; } output += `\n${pc.bold( "Like Better-T-Stack?", )} Please consider giving us a star\n on GitHub:\n`; output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack"); cliConsola.box(output); } function getNativeInstructions( isConvex: boolean, isBackendSelf: boolean, frontend: Frontend[], runCmd: string, ) { const envVar = isConvex ? "EXPO_PUBLIC_CONVEX_URL" : "EXPO_PUBLIC_SERVER_URL"; const selfBackendPort = frontend.includes("svelte") ? "5173" : frontend.includes("astro") ? "4321" : "3001"; const exampleUrl = isConvex ? "https://" : isBackendSelf ? `http://:${selfBackendPort}` : "http://:3000"; const envFileName = ".env"; const ipNote = isConvex ? "your Convex deployment URL (find after running 'dev:setup')" : "your local IP address"; let instructions = `${pc.yellow( "NOTE:", )} For Expo connectivity issues, update\n apps/native/${envFileName} with ${ipNote}:\n ${`${envVar}=${exampleUrl}`}\n`; if (isConvex) { instructions += `\n${pc.yellow( "IMPORTANT:", )} When using local development with Convex and native apps,\n ensure you use your local IP address instead of localhost or 127.0.0.1\n for proper connectivity.\n`; } if (frontend.includes("native-unistyles")) { instructions += `\n${pc.yellow( "NOTE:", )} Unistyles requires a development build.\n cd apps/native and run ${runCmd} android or ${runCmd} ios\n`; } return instructions; } function getHuskyInstructions(runCmd: string) { return `${pc.bold("Git hooks with Husky:")}\n${pc.cyan( "•", )} Initialize hooks: ${`${runCmd} prepare`}\n`; } function getLintingInstructions(runCmd: string) { return `${pc.bold("Linting and formatting:")}\n${pc.cyan( "•", )} Format and lint fix: ${`${runCmd} check`}\n`; } function getLefthookInstructions(packageManager: string) { const cmd = packageManager === "npm" ? "npx" : packageManager; return `${pc.bold("Git hooks with Lefthook:")}\n${pc.cyan( "•", )} Install hooks: ${cmd} lefthook install\n`; } async function getDatabaseInstructions( database: Database, orm: ORM, runCmd: string, _runtime: Runtime, dbSetup: DatabaseSetup, webDeploy: WebDeploy, serverDeploy: ServerDeploy, backend: Backend, ) { const instructions: string[] = []; const isD1Alchemy = dbSetup === "d1" && (serverDeploy === "cloudflare" || (backend === "self" && webDeploy === "cloudflare")); if (dbSetup === "docker") { const dockerStatus = await getDockerStatus(database); if (dockerStatus.message) { instructions.push(dockerStatus.message); instructions.push(""); } } if (isD1Alchemy) { if (orm === "drizzle") { instructions.push(`${pc.cyan("•")} Generate migrations: ${`${runCmd} db:generate`}`); } else if (orm === "prisma") { instructions.push(`${pc.cyan("•")} Generate Prisma client: ${`${runCmd} db:generate`}`); instructions.push(`${pc.cyan("•")} Apply migrations: ${`${runCmd} db:migrate`}`); } } if (dbSetup === "planetscale") { if (database === "mysql" && orm === "drizzle") { instructions.push( `${pc.yellow("NOTE:")} Enable foreign key constraints in PlanetScale database settings`, ); } if (database === "mysql" && orm === "prisma") { instructions.push( `${pc.yellow( "NOTE:", )} How to handle Prisma migrations with PlanetScale:\n https://github.com/prisma/prisma/issues/7292`, ); } } if (dbSetup === "turso" && orm === "prisma") { instructions.push( `${pc.yellow( "NOTE:", )} Follow Turso's Prisma guide for migrations via the Turso CLI:\n https://docs.turso.tech/sdk/ts/orm/prisma`, ); } if (orm === "prisma") { if (database === "mongodb" && dbSetup === "docker") { instructions.push( `${pc.yellow("WARNING:")} Prisma + MongoDB + Docker combination\n may not work.`, ); } if (dbSetup === "docker") { instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`); } if (!isD1Alchemy) { instructions.push(`${pc.cyan("•")} Generate Prisma Client: ${`${runCmd} db:generate`}`); instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`); } if (!isD1Alchemy) { instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`); } } else if (orm === "drizzle") { if (dbSetup === "docker") { instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`); } if (!isD1Alchemy) { instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`); } if (!isD1Alchemy) { instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`); } } else if (orm === "mongoose") { if (dbSetup === "docker") { instructions.push(`${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`); } } else if (orm === "none") { instructions.push(`${pc.yellow("NOTE:")} Manual database schema setup\n required.`); } return instructions.length ? `${pc.bold("Database commands:")}\n${instructions.join("\n")}` : ""; } function getTauriInstructions(runCmd: string, frontend: Frontend[]) { const staticBuildNote = getDesktopStaticBuildNote(frontend); return `\n${pc.bold("Desktop app with Tauri:")}\n${pc.cyan( "•", )} Start desktop app: ${`cd apps/web && ${runCmd} desktop:dev`}\n${pc.cyan( "•", )} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow( "NOTE:", )} Tauri requires Rust and platform-specific dependencies.\n See: ${"https://v2.tauri.app/start/prerequisites/"}${ staticBuildNote ? `\n${staticBuildNote}` : "" }`; } function getElectrobunInstructions(runCmd: string, frontend: Frontend[]) { const staticBuildNote = getDesktopStaticBuildNote(frontend); return `\n${pc.bold("Desktop app with Electrobun:")}\n${pc.cyan( "•", )} Start desktop app with HMR: ${`${runCmd} dev:desktop`}\n${pc.cyan( "•", )} Build stable desktop app (DMG/App): ${`${runCmd} build:desktop`}\n${pc.cyan( "•", )} Build canary desktop app: ${`${runCmd} build:desktop:canary`}\n${pc.yellow( "NOTE:", )} Electrobun wraps your web frontend in a desktop shell.\n See: ${"https://blackboard.sh/electrobun/docs/"}${ staticBuildNote ? `\n${staticBuildNote}` : "" }`; } function getPwaInstructions() { return `\n${pc.bold("PWA with React Router v7:")}\n${pc.yellow( "NOTE:", )} There is a known compatibility issue between VitePWA\n and React Router v7. See:\n https://github.com/vite-pwa/vite-plugin-pwa/issues/809`; } function getStarlightInstructions(runCmd: string) { return `\n${pc.bold("Documentation with Starlight:")}\n${pc.cyan( "•", )} Start docs site: ${`cd apps/docs && ${runCmd} dev`}\n${pc.cyan( "•", )} Build docs site: ${`cd apps/docs && ${runCmd} build`}`; } function getNoOrmWarning() { return `\n${pc.yellow( "WARNING:", )} Database selected without an ORM. Features requiring\n database access (e.g., examples, auth) need manual setup.`; } function getBunWebNativeWarning() { return `\n${pc.yellow( "WARNING:", )} 'bun' might cause issues with web + native apps in a monorepo.\n Use 'pnpm' if problems arise.`; } function getClerkQuickstartUrl(frontend: Frontend[]) { if (frontend.includes("next")) return "https://clerk.com/docs/nextjs/getting-started/quickstart"; if (frontend.includes("react-router")) { return "https://clerk.com/docs/react-router/getting-started/quickstart"; } if (frontend.includes("tanstack-start")) { return "https://clerk.com/docs/tanstack-react-start/getting-started/quickstart"; } if (frontend.includes("tanstack-router")) { return "https://clerk.com/docs/react/getting-started/quickstart"; } if ( frontend.includes("native-bare") || frontend.includes("native-uniwind") || frontend.includes("native-unistyles") ) { return "https://clerk.com/docs/expo/getting-started/quickstart"; } return "https://clerk.com/docs"; } function getClerkInstructionLines( frontend: Frontend[], backend: Backend, api: ProjectConfig["api"], ) { const lines: string[] = []; if (frontend.includes("next")) { lines.push("Set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY in apps/web/.env"); } if ( frontend.some((value) => ["react-router", "tanstack-router", "tanstack-start"].includes(value)) ) { lines.push("Set VITE_CLERK_PUBLISHABLE_KEY in apps/web/.env"); } if ( frontend.some((value) => ["native-bare", "native-uniwind", "native-unistyles"].includes(value)) ) { lines.push("Set EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY in apps/native/.env"); } if (backend === "convex") { return [ "Set CLERK_JWT_ISSUER_DOMAIN in Convex Dashboard", ...lines, ...(frontend.some((value) => ["next", "react-router", "tanstack-start"].includes(value)) ? ["Set CLERK_SECRET_KEY in apps/web/.env for Clerk server middleware"] : []), ]; } const hasClerkServerFrontend = frontend.some((value) => ["next", "react-router", "tanstack-start"].includes(value), ); const serverEnvPath = backend === "self" ? "apps/web/.env" : "apps/server/.env"; const needsServerSideClerkAuth = backend !== "none"; const needsClerkBackendPublishableKey = ["express", "fastify"].includes(backend); const needsClerkRequestVerification = api !== "none" && ["self", "hono", "elysia"].includes(backend); if (hasClerkServerFrontend && backend === "self") { lines.push( "Set CLERK_SECRET_KEY in apps/web/.env for Clerk server middleware and server-side Clerk auth", ); } else { if (hasClerkServerFrontend) { lines.push("Set CLERK_SECRET_KEY in apps/web/.env for Clerk server middleware"); } if (needsServerSideClerkAuth) { lines.push(`Set CLERK_SECRET_KEY in ${serverEnvPath} for server-side Clerk auth`); } } if (needsClerkRequestVerification) { lines.push( `Set CLERK_PUBLISHABLE_KEY in ${serverEnvPath} for server-side Clerk request verification`, ); } if (needsClerkBackendPublishableKey) { lines.push(`Set CLERK_PUBLISHABLE_KEY in ${serverEnvPath} for Clerk backend middleware`); } return lines; } function getClerkInstructions(frontend: Frontend[], backend: Backend, api: ProjectConfig["api"]) { const lines = [ `${pc.bold("Clerk Authentication Setup:")}`, `${pc.cyan("•")} Follow the guide: ${pc.underline(getClerkQuickstartUrl(frontend))}`, ...getClerkInstructionLines(frontend, backend, api).map((line) => `${pc.cyan("•")} ${line}`), ]; return lines.join("\n"); } function getBetterAuthConvexInstructions(hasWeb: boolean, webPort: string, packageManager: string) { const cmd = packageManager === "npm" ? "npx" : packageManager; return ( `${pc.bold("Better Auth + Convex Setup:")}\n` + `${pc.cyan("•")} Set environment variables from ${pc.white("packages/backend")}:\n` + `${pc.white(" cd packages/backend")}\n` + `${pc.white(` ${cmd} convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)`)}\n` + (hasWeb ? `${pc.white(` ${cmd} convex env set SITE_URL http://localhost:${webPort}`)}\n` : "") ); } function getPolarInstructions(backend: Backend) { const envPath = backend === "self" ? "apps/web/.env" : "apps/server/.env"; return `${pc.bold("Polar Payments Setup:")}\n${pc.cyan("•")} Get access token & product ID from ${pc.underline("https://sandbox.polar.sh/")}\n${pc.cyan("•")} Set POLAR_ACCESS_TOKEN in ${envPath}`; } function getAlchemyDeployInstructions( runCmd: string, webDeploy: WebDeploy, serverDeploy: ServerDeploy, backend: Backend, ) { const instructions: string[] = []; const isBackendSelf = backend === "self"; if (webDeploy === "cloudflare" && serverDeploy !== "cloudflare" && !isBackendSelf) { instructions.push( `${pc.bold("Deploy web with Cloudflare (Alchemy):")}\n${pc.cyan("•")} Dev: ${`${runCmd} dev`}\n${pc.cyan("•")} Deploy: ${`${runCmd} deploy`}\n${pc.cyan("•")} Destroy: ${`${runCmd} destroy`}`, ); } else if (serverDeploy === "cloudflare" && webDeploy !== "cloudflare" && !isBackendSelf) { instructions.push( `${pc.bold("Deploy server with Cloudflare (Alchemy):")}\n${pc.cyan("•")} Dev: ${`${runCmd} dev`}\n${pc.cyan("•")} Deploy: ${`${runCmd} deploy`}\n${pc.cyan("•")} Destroy: ${`${runCmd} destroy`}`, ); } else if (webDeploy === "cloudflare" && (serverDeploy === "cloudflare" || isBackendSelf)) { instructions.push( `${pc.bold("Deploy with Cloudflare (Alchemy):")}\n${pc.cyan("•")} Dev: ${`${runCmd} dev`}\n${pc.cyan("•")} Deploy: ${`${runCmd} deploy`}\n${pc.cyan("•")} Destroy: ${`${runCmd} destroy`}`, ); } return instructions.length ? `\n${instructions.join("\n")}` : ""; } ================================================ FILE: apps/cli/src/helpers/database-providers/d1-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import type { ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError } from "../../utils/errors"; export async function setupCloudflareD1( config: ProjectConfig, ): Promise> { const { projectDir, serverDeploy, webDeploy, orm, backend } = config; const isCloudflareD1Target = orm === "prisma" && (serverDeploy === "cloudflare" || (backend === "self" && webDeploy === "cloudflare")); if (!isCloudflareD1Target) { return Result.ok(undefined); } return Result.tryPromise({ try: async () => { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: `file:${path.join(projectDir, targetApp, "local.db")}`, condition: true, }, ]; await addEnvVariablesToFile(envPath, variables); const serverDir = path.join(projectDir, backend === "self" ? "apps/web" : "apps/server"); await addPackageDependency({ dependencies: ["@prisma/adapter-d1"], projectDir: serverDir, }); }, catch: (e) => new DatabaseSetupError({ provider: "d1", message: `Failed to set up Cloudflare D1: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } ================================================ FILE: apps/cli/src/helpers/database-providers/docker-compose-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import type { Database, ProjectConfig } from "../../types"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError } from "../../utils/errors"; export async function setupDockerCompose( config: ProjectConfig, ): Promise> { const { database, projectDir, projectName, backend } = config; if (database === "none" || database === "sqlite") { return Result.ok(undefined); } const result = await Result.tryPromise({ try: async () => { await writeEnvFile(projectDir, database, projectName, backend); }, catch: (e) => new DatabaseSetupError({ provider: "docker-compose", message: `Failed to setup docker compose env: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); return result.isErr() ? result : Result.ok(undefined); } async function writeEnvFile( projectDir: string, database: Database, projectName: string, backend?: string, ) { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: getDatabaseUrl(database, projectName), condition: true, }, ]; await addEnvVariablesToFile(envPath, variables); } function getDatabaseUrl(database: Database, projectName: string) { switch (database) { case "postgres": return `postgresql://postgres:password@localhost:5432/${projectName}`; case "mysql": return `mysql://user:password@localhost:3306/${projectName}`; case "mongodb": return `mongodb://root:password@localhost:27017/${projectName}?authSource=admin`; default: return ""; } } ================================================ FILE: apps/cli/src/helpers/database-providers/mongodb-atlas-setup.ts ================================================ import path from "node:path"; import { cancel, isCancel, select, text } from "@clack/prompts"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { commandExists } from "../../utils/command-exists"; import { isSilent } from "../../utils/context"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError, databaseSetupError, UserCancelledError, userCancelled, } from "../../utils/errors"; import { cliLog } from "../../utils/terminal-output"; import { type DatabaseSetupCliOptions, type DbSetupMode, resolveDbSetupMode, } from "../core/db-setup-options"; type MongoDBConfig = { connectionString: string; }; type MongoDBSetupResult = Result; async function checkAtlasCLI(): Promise { const exists = await commandExists("atlas"); if (exists) { cliLog.info("MongoDB Atlas CLI found"); } else { cliLog.warn(pc.yellow("MongoDB Atlas CLI not found")); } return exists; } async function initMongoDBAtlas( serverDir: string, ): Promise> { const hasAtlas = await checkAtlasCLI(); if (!hasAtlas) { cliLog.info( pc.yellow( "Please install it from: https://www.mongodb.com/docs/atlas/cli/current/install-atlas-cli/", ), ); return databaseSetupError("mongodb-atlas", "MongoDB Atlas CLI not found"); } cliLog.info("Running MongoDB Atlas setup..."); const deployResult = await Result.tryPromise({ try: async () => { await $({ cwd: serverDir, stdio: "inherit" })`atlas deployments setup`; cliLog.success("MongoDB Atlas deployment ready"); }, catch: (e) => new DatabaseSetupError({ provider: "mongodb-atlas", message: `Failed to setup MongoDB Atlas deployment: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (deployResult.isErr()) { return deployResult; } const connectionString = await text({ message: "Enter your MongoDB connection string:", placeholder: "mongodb+srv://username:password@cluster.mongodb.net/database", validate(value) { if (!value) return "Please enter a connection string"; if (!value.startsWith("mongodb")) { return "URL should start with mongodb:// or mongodb+srv://"; } }, }); if (isCancel(connectionString)) { cancel("MongoDB setup cancelled"); return userCancelled("MongoDB setup cancelled"); } return Result.ok({ connectionString: connectionString as string, }); } async function writeEnvFile( projectDir: string, backend: ProjectConfig["backend"], config?: MongoDBConfig, ): Promise> { return Result.tryPromise({ try: async () => { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: config?.connectionString ?? "mongodb://localhost:27017/mydb", condition: true, }, ]; await addEnvVariablesToFile(envPath, variables); }, catch: (e) => new DatabaseSetupError({ provider: "mongodb-atlas", message: `Failed to update environment configuration: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } function displayManualSetupInstructions() { cliLog.info(` ${pc.green("MongoDB Atlas Manual Setup Instructions:")} 1. Install Atlas CLI: ${pc.blue("https://www.mongodb.com/docs/atlas/cli/stable/install-atlas-cli/")} 2. Run the following command and follow the prompts: ${pc.blue("atlas deployments setup")} 3. Get your connection string from the Atlas dashboard: Format: ${pc.dim("mongodb+srv://USERNAME:PASSWORD@CLUSTER.mongodb.net/DATABASE_NAME")} 4. Add the connection string to your .env file: ${pc.dim('DATABASE_URL="your_connection_string"')} `); } export async function setupMongoDBAtlas( config: ProjectConfig, cliInput?: DatabaseSetupCliOptions, ): Promise { const { projectDir, backend } = config; const setupMode = resolveDbSetupMode("mongodb-atlas", { manualDb: cliInput?.manualDb, dbSetupOptions: cliInput?.dbSetupOptions ?? config.dbSetupOptions, }); const serverDir = path.join(projectDir, "packages/db"); const ensureDirResult = await Result.tryPromise({ try: () => fs.ensureDir(serverDir), catch: (e) => new DatabaseSetupError({ provider: "mongodb-atlas", message: `Failed to create directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (ensureDirResult.isErr()) { return ensureDirResult; } if (setupMode === "manual") { cliLog.info("MongoDB Atlas manual setup selected"); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(); return Result.ok(undefined); } let mode: DbSetupMode | undefined = setupMode; if (!mode) { if (isSilent()) { cliLog.warn( pc.yellow( "MongoDB Atlas automatic setup requires interactive input. Falling back to manual setup.", ), ); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(); return Result.ok(undefined); } const promptedMode = await select({ message: "MongoDB Atlas setup: choose mode", options: [ { label: "Automatic", value: "auto", hint: "Automated setup with provider CLI, sets .env", }, { label: "Manual", value: "manual", hint: "Manual setup, add env vars yourself", }, ], initialValue: "auto", }); if (isCancel(promptedMode)) { return userCancelled("Operation cancelled"); } mode = promptedMode; } if (mode === "manual") { cliLog.info("MongoDB Atlas manual setup selected"); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(); return Result.ok(undefined); } const mongoConfigResult = await initMongoDBAtlas(serverDir); if (mongoConfigResult.isOk()) { const envResult = await writeEnvFile(projectDir, backend, mongoConfigResult.value); if (envResult.isErr()) { return envResult; } cliLog.success(pc.green("MongoDB Atlas setup complete! Connection saved to .env file.")); return Result.ok(undefined); } // Handle errors - check for user cancellation if (UserCancelledError.is(mongoConfigResult.error)) { return mongoConfigResult; } cliLog.warn(pc.yellow("Falling back to local MongoDB configuration")); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/database-providers/neon-setup.ts ================================================ import path from "node:path"; import { isCancel, select, text } from "@clack/prompts"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { PackageManager, ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError, databaseSetupError, UserCancelledError, userCancelled, } from "../../utils/errors"; import { getPackageExecutionArgs } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; import { type DatabaseSetupCliOptions, type DbSetupMode, resolveDbSetupMode, } from "../core/db-setup-options"; type NeonConfig = { connectionString: string; projectId: string; dbName: string; roleName: string; }; type NeonRegion = { label: string; value: string; }; type NeonSetupResult = Result; const NEON_REGIONS: NeonRegion[] = [ { label: "AWS US East (N. Virginia)", value: "aws-us-east-1" }, { label: "AWS US East (Ohio)", value: "aws-us-east-2" }, { label: "AWS US West (Oregon)", value: "aws-us-west-2" }, { label: "AWS Europe (Frankfurt)", value: "aws-eu-central-1" }, { label: "AWS Asia Pacific (Singapore)", value: "aws-ap-southeast-1" }, { label: "AWS South America East 1 (São Paulo)", value: "aws-sa-east-1" }, { label: "AWS Asia Pacific (Sydney)", value: "aws-ap-southeast-2" }, { label: "Azure East US 2 region (Virginia)", value: "azure-eastus2" }, ]; async function executeNeonCommand( packageManager: PackageManager, commandArgsString: string, spinnerText?: string, ): Promise> { const s = createSpinner(); const args = getPackageExecutionArgs(packageManager, commandArgsString); if (spinnerText) s.start(spinnerText); return Result.tryPromise({ try: async () => { const result = await $`${args}`; if (spinnerText) s.stop(pc.green(spinnerText.replace("...", "").replace("ing ", "ed ").trim())); return result; }, catch: (e) => { if (s) s.stop(pc.red(`Failed: ${spinnerText || "Command execution"}`)); return new DatabaseSetupError({ provider: "neon", message: `Command failed: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); } async function createNeonProject( projectName: string, regionId: string, packageManager: PackageManager, ): Promise> { const commandArgsString = `neonctl@latest projects create --name ${projectName} --region-id ${regionId} --output json`; const execResult = await executeNeonCommand( packageManager, commandArgsString, `Creating Neon project "${projectName}"...`, ); if (execResult.isErr()) { return execResult; } const parseResult = Result.try({ try: () => JSON.parse(execResult.value.stdout), catch: (e) => new DatabaseSetupError({ provider: "neon", message: `Failed to parse Neon response: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (parseResult.isErr()) { return parseResult; } const response = parseResult.value; if (response.project && response.connection_uris && response.connection_uris.length > 0) { const projectId = response.project.id; const connectionUri = response.connection_uris[0].connection_uri; const params = response.connection_uris[0].connection_parameters; return Result.ok({ connectionString: connectionUri, projectId: projectId, dbName: params.database, roleName: params.role, }); } return databaseSetupError("neon", "Failed to extract connection information from Neon response"); } async function writeEnvFile( projectDir: string, backend: ProjectConfig["backend"], config?: NeonConfig, ): Promise> { return Result.tryPromise({ try: async () => { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: config?.connectionString ?? "postgresql://postgres:postgres@localhost:5432/mydb?schema=public", condition: true, }, ]; await addEnvVariablesToFile(envPath, variables); }, catch: (e) => new DatabaseSetupError({ provider: "neon", message: `Failed to update .env file: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } async function setupWithNeonDb( projectDir: string, packageManager: PackageManager, backend: ProjectConfig["backend"], ): Promise> { const s = createSpinner(); s.start("Creating Neon database using get-db..."); const targetApp = backend === "self" ? "apps/web" : "apps/server"; const targetDir = path.join(projectDir, targetApp); const ensureDirResult = await Result.tryPromise({ try: () => fs.ensureDir(targetDir), catch: (e) => new DatabaseSetupError({ provider: "neon", message: `Failed to create directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (ensureDirResult.isErr()) { s.stop(pc.red("Failed to create directory")); return ensureDirResult; } const packageArgs = getPackageExecutionArgs( packageManager, `get-db@latest --yes --ref "sbA3tIe"`, ); return Result.tryPromise({ try: async () => { await $({ cwd: targetDir })`${packageArgs}`; s.stop(pc.green("Neon database created successfully!")); }, catch: (e) => { s.stop(pc.red("Failed to create database with get-db")); return new DatabaseSetupError({ provider: "neon", message: `Failed to create database with get-db: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); } function displayManualSetupInstructions(target: "apps/web" | "apps/server") { cliLog.info(`Manual Neon PostgreSQL Setup Instructions: 1. Get Neon with Better T Stack referral: https://get.neon.com/sbA3tIe 2. Create a new project from the dashboard 3. Get your connection string 4. Add the database URL to the .env file in ${target}/.env DATABASE_URL="your_connection_string"`); } export async function setupNeonPostgres( config: ProjectConfig, cliInput?: DatabaseSetupCliOptions, ): Promise { const { packageManager, projectDir, backend } = config; const setupMode = resolveDbSetupMode("neon", { manualDb: cliInput?.manualDb, dbSetupOptions: cliInput?.dbSetupOptions ?? config.dbSetupOptions, }); const target: "apps/web" | "apps/server" = backend === "self" ? "apps/web" : "apps/server"; if (setupMode === "manual") { const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(target); return Result.ok(undefined); } let selectedMode: DbSetupMode | undefined = setupMode; if (!selectedMode) { if (isSilent()) { selectedMode = "manual"; } else { const promptedMode = await select({ message: "Neon setup: choose mode", options: [ { label: "Automatic", value: "auto", hint: "Automated setup with provider CLI, sets .env", }, { label: "Manual", value: "manual", hint: "Manual setup, add env vars yourself", }, ], initialValue: "auto", }); if (isCancel(promptedMode)) { return userCancelled("Operation cancelled"); } selectedMode = promptedMode; } } if (selectedMode === "manual") { const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(target); return Result.ok(undefined); } let setupMethod: "neondb" | "neonctl" | undefined = cliInput?.dbSetupOptions?.neon?.method ?? config.dbSetupOptions?.neon?.method; if (!setupMethod) { if (isSilent()) { setupMethod = "neondb"; } else { const promptedSetupMethod = await select<"neondb" | "neonctl">({ message: "Choose your Neon setup method:", options: [ { label: "Quick setup with get-db", value: "neondb", hint: "fastest, no auth required", }, { label: "Custom setup with neonctl", value: "neonctl", hint: "More control - choose project name and region", }, ], initialValue: "neondb", }); if (isCancel(promptedSetupMethod)) { return userCancelled("Operation cancelled"); } setupMethod = promptedSetupMethod; } } if (setupMethod === "neondb") { const neonDbResult = await setupWithNeonDb(projectDir, packageManager, backend); if (neonDbResult.isErr()) { cliLog.error(pc.red(neonDbResult.error.message)); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(target); return Result.ok(undefined); } cliLog.info( `Get Neon with Better T Stack referral: ${pc.cyan("https://get.neon.com/sbA3tIe")}`, ); return Result.ok(undefined); } // neonctl setup path const suggestedProjectName = path.basename(projectDir); let projectName = cliInput?.dbSetupOptions?.neon?.projectName ?? config.dbSetupOptions?.neon?.projectName; if (!projectName) { if (isSilent()) { projectName = suggestedProjectName; } else { const promptedProjectName = await text({ message: "Enter a name for your Neon project:", defaultValue: suggestedProjectName, initialValue: suggestedProjectName, }); if (isCancel(promptedProjectName)) { return userCancelled("Operation cancelled"); } projectName = promptedProjectName as string; } } let regionId = cliInput?.dbSetupOptions?.neon?.regionId ?? config.dbSetupOptions?.neon?.regionId; if (!regionId) { if (isSilent()) { regionId = NEON_REGIONS[0]!.value; } else { const promptedRegionId = await select({ message: "Select a region for your Neon project:", options: NEON_REGIONS, initialValue: NEON_REGIONS[0].value, }); if (isCancel(promptedRegionId)) { return userCancelled("Operation cancelled"); } regionId = promptedRegionId; } } const neonConfigResult = await createNeonProject(projectName, regionId, packageManager); if (neonConfigResult.isErr()) { cliLog.error(pc.red(neonConfigResult.error.message)); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(target); return Result.ok(undefined); } const finalSpinner = createSpinner(); finalSpinner.start("Configuring database connection"); const envResult = await writeEnvFile(projectDir, backend, neonConfigResult.value); if (envResult.isErr()) { finalSpinner.stop(pc.red("Failed to configure database connection")); return envResult; } finalSpinner.stop("Neon database configured!"); cliLog.info(`Get Neon with Better T Stack referral: ${pc.cyan("https://get.neon.com/sbA3tIe")}`); return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/database-providers/planetscale-setup.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import fs from "fs-extra"; import type { ProjectConfig } from "../../types"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError } from "../../utils/errors"; export async function setupPlanetScale( config: ProjectConfig, ): Promise> { const { projectDir, database, orm, backend } = config; if (!["mysql", "postgres"].includes(database)) { return Result.ok(undefined); } return Result.tryPromise({ try: async () => { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); if (database === "mysql" && orm === "drizzle") { const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: 'mysql://username:password@host/database?ssl={"rejectUnauthorized":true}', condition: true, }, { key: "DATABASE_HOST", value: "", condition: true, }, { key: "DATABASE_USERNAME", value: "", condition: true, }, { key: "DATABASE_PASSWORD", value: "", condition: true, }, ]; await fs.ensureDir(path.join(projectDir, targetApp)); await addEnvVariablesToFile(envPath, variables); } if (database === "postgres" && orm === "prisma") { const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: "postgresql://username:password@host/database?sslaccept=strict", condition: true, }, ]; await fs.ensureDir(path.join(projectDir, targetApp)); await addEnvVariablesToFile(envPath, variables); } if (database === "postgres" && orm === "drizzle") { const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: "postgresql://username:password@host/database?sslmode=verify-full", condition: true, }, ]; await fs.ensureDir(path.join(projectDir, targetApp)); await addEnvVariablesToFile(envPath, variables); } if (database === "mysql" && orm === "prisma") { const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: "mysql://username:password@host/database?sslaccept=strict", condition: true, }, ]; await fs.ensureDir(path.join(projectDir, targetApp)); await addEnvVariablesToFile(envPath, variables); } }, catch: (e) => new DatabaseSetupError({ provider: "planetscale", message: `Failed to set up PlanetScale env: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } ================================================ FILE: apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts ================================================ import path from "node:path"; import { isCancel, select } from "@clack/prompts"; import { Result } from "better-result"; import { $ } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { PackageManager, ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError, UserCancelledError, userCancelled } from "../../utils/errors"; import { getPackageRunnerPrefix } from "../../utils/package-runner"; import { cliLog, createSpinner } from "../../utils/terminal-output"; import { type DatabaseSetupCliOptions, type DbSetupMode, resolveDbSetupMode, } from "../core/db-setup-options"; type PrismaConfig = { databaseUrl: string; claimUrl?: string; }; type CreateDbResponse = { connectionString: string; directConnectionString: string; claimUrl: string; deletionDate: string; region: string; name: string; projectId: string; }; type PrismaSetupResult = Result; const AVAILABLE_REGIONS = [ { value: "ap-southeast-1", label: "Asia Pacific (Singapore)" }, { value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" }, { value: "eu-central-1", label: "Europe (Frankfurt)" }, { value: "eu-west-3", label: "Europe (Paris)" }, { value: "us-east-1", label: "US East (N. Virginia)" }, { value: "us-west-1", label: "US West (N. California)" }, ]; const CREATE_DB_USER_AGENT = "aman/better-t-stack"; async function setupWithCreateDb( serverDir: string, packageManager: PackageManager, regionId?: string, ): Promise> { cliLog.info("Starting Prisma Postgres setup with create-db."); let selectedRegion = regionId; if (!selectedRegion) { if (isSilent()) { selectedRegion = "ap-southeast-1"; } else { const promptedRegion = await select({ message: "Select your preferred region:", options: AVAILABLE_REGIONS, initialValue: "ap-southeast-1", }); if (isCancel(promptedRegion)) { return userCancelled("Operation cancelled"); } selectedRegion = promptedRegion; } } const createDbArgs = [ ...getPackageRunnerPrefix(packageManager), "create-db@latest", "create", "--json", "--region", selectedRegion, "--user-agent", CREATE_DB_USER_AGENT, ]; const s = createSpinner(); s.start("Creating Prisma Postgres database..."); const execResult = await Result.tryPromise({ try: async () => { const { stdout } = await $({ cwd: serverDir })`${createDbArgs}`; s.stop("Database created successfully!"); return stdout; }, catch: (e) => { s.stop(pc.red("Failed to create database")); return new DatabaseSetupError({ provider: "prisma-postgres", message: `Failed to create database: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); if (execResult.isErr()) { return execResult; } const parseResult = Result.try({ try: () => JSON.parse(execResult.value) as CreateDbResponse, catch: (e) => new DatabaseSetupError({ provider: "prisma-postgres", message: `Failed to parse create-db response: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (parseResult.isErr()) { return parseResult; } const createDbResponse = parseResult.value; return Result.ok({ databaseUrl: createDbResponse.connectionString, claimUrl: createDbResponse.claimUrl, }); } async function writeEnvFile( projectDir: string, backend: ProjectConfig["backend"], config?: PrismaConfig, ): Promise> { return Result.tryPromise({ try: async () => { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: config?.databaseUrl ?? "postgresql://postgres:postgres@localhost:5432/mydb?schema=public", condition: true, }, ]; if (config?.claimUrl) { variables.push({ key: "CLAIM_URL", value: config.claimUrl, condition: true, }); } await addEnvVariablesToFile(envPath, variables); }, catch: (e) => new DatabaseSetupError({ provider: "prisma-postgres", message: `Failed to update environment configuration: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } function displayManualSetupInstructions(target: "apps/web" | "apps/server") { cliLog.info(`Manual Prisma PostgreSQL Setup Instructions: 1. Visit https://console.prisma.io and create an account 2. Create a new PostgreSQL database from the dashboard 3. Get your database URL 4. Add the database URL to the .env file in ${target}/.env DATABASE_URL="your_database_url"`); } export async function setupPrismaPostgres( config: ProjectConfig, cliInput?: DatabaseSetupCliOptions, ): Promise { const { packageManager, projectDir, backend } = config; const setupMode = resolveDbSetupMode("prisma-postgres", { manualDb: cliInput?.manualDb, dbSetupOptions: cliInput?.dbSetupOptions ?? config.dbSetupOptions, }); const dbDir = path.join(projectDir, "packages/db"); const target: "apps/web" | "apps/server" = backend === "self" ? "apps/web" : "apps/server"; const ensureDirResult = await Result.tryPromise({ try: () => fs.ensureDir(dbDir), catch: (e) => new DatabaseSetupError({ provider: "prisma-postgres", message: `Failed to create directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (ensureDirResult.isErr()) { return ensureDirResult; } if (setupMode === "manual") { const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(target); return Result.ok(undefined); } let selectedSetupMode: DbSetupMode | undefined = setupMode; if (!selectedSetupMode) { if (isSilent()) { selectedSetupMode = "manual"; } else { const promptedSetupMode = await select({ message: "Prisma Postgres setup: choose mode", options: [ { label: "Automatic (create-db)", value: "auto", hint: "Provision a database via Prisma's create-db CLI", }, { label: "Manual", value: "manual", hint: "Add your own DATABASE_URL later", }, ], initialValue: "auto", }); if (isCancel(promptedSetupMode)) { return userCancelled("Operation cancelled"); } selectedSetupMode = promptedSetupMode; } } if (selectedSetupMode === "manual") { const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(target); return Result.ok(undefined); } const prismaConfigResult = await setupWithCreateDb( dbDir, packageManager, cliInput?.dbSetupOptions?.prismaPostgres?.regionId ?? config.dbSetupOptions?.prismaPostgres?.regionId, ); if (prismaConfigResult.isErr()) { // Check for user cancellation if (UserCancelledError.is(prismaConfigResult.error)) { return prismaConfigResult; } cliLog.error(pc.red(prismaConfigResult.error.message)); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(target); cliLog.info("Setup completed with manual configuration required."); return Result.ok(undefined); } const envResult = await writeEnvFile(projectDir, backend, prismaConfigResult.value); if (envResult.isErr()) { return envResult; } cliLog.success(pc.green("Prisma Postgres database configured successfully!")); if (prismaConfigResult.value.claimUrl) { cliLog.info(pc.blue(`Claim URL saved to .env: ${prismaConfigResult.value.claimUrl}`)); } return Result.ok(undefined); } ================================================ FILE: apps/cli/src/helpers/database-providers/supabase-setup.ts ================================================ import path from "node:path"; import { isCancel, select } from "@clack/prompts"; import { Result } from "better-result"; import { type ExecaError, execa } from "execa"; import fs from "fs-extra"; import pc from "picocolors"; import type { PackageManager, ProjectConfig } from "../../types"; import { isSilent } from "../../utils/context"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError, databaseSetupError, UserCancelledError, userCancelled, } from "../../utils/errors"; import { getPackageExecutionArgs } from "../../utils/package-runner"; import { cliLog } from "../../utils/terminal-output"; import { type DatabaseSetupCliOptions, type DbSetupMode, resolveDbSetupMode, } from "../core/db-setup-options"; type SupabaseSetupResult = Result; async function writeSupabaseEnvFile( projectDir: string, backend: ProjectConfig["backend"], databaseUrl: string, ): Promise> { return Result.tryPromise({ try: async () => { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); const dbUrlToUse = databaseUrl || "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: dbUrlToUse, condition: true, }, { key: "DIRECT_URL", value: dbUrlToUse, condition: true, }, ]; await addEnvVariablesToFile(envPath, variables); }, catch: (e) => new DatabaseSetupError({ provider: "supabase", message: `Failed to update .env file: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } function extractDbUrl(output: string): string | null { const dbUrlMatch = output.match(/DB URL:\s*(postgresql:\/\/[^\s]+)/); return dbUrlMatch?.[1] ?? null; } async function initializeSupabase( serverDir: string, packageManager: PackageManager, ): Promise> { cliLog.info("Initializing Supabase project..."); return Result.tryPromise({ try: async () => { const supabaseInitArgs = getPackageExecutionArgs(packageManager, "supabase init"); await execa(supabaseInitArgs[0], supabaseInitArgs.slice(1), { cwd: serverDir, stdio: "inherit", }); cliLog.success("Supabase project initialized"); }, catch: (e) => { const error = e as Error; const isNotFound = error.message?.includes("ENOENT"); const message = isNotFound ? "Supabase CLI not found. Please install it globally (npm install -g supabase) or ensure it's in your PATH." : `Failed to initialize Supabase project: ${error.message ?? String(e)}`; return new DatabaseSetupError({ provider: "supabase", message, cause: e, }); }, }); } async function startSupabase( serverDir: string, packageManager: PackageManager, ): Promise> { cliLog.info("Starting Supabase services (this may take a moment)..."); const supabaseStartArgs = getPackageExecutionArgs(packageManager, "supabase start"); return Result.tryPromise({ try: async () => { const subprocess = execa(supabaseStartArgs[0], supabaseStartArgs.slice(1), { cwd: serverDir, }); let stdoutData = ""; if (subprocess.stdout) { subprocess.stdout.on("data", (data) => { const text = data.toString(); if (!isSilent()) { process.stdout.write(text); } stdoutData += text; }); } if (subprocess.stderr) { subprocess.stderr.pipe(process.stderr); } await subprocess; await new Promise((resolve) => setTimeout(resolve, 100)); return stdoutData; }, catch: (e) => { const execaError = e as ExecaError; const isDockerError = execaError?.message?.includes("Docker is not running"); const message = isDockerError ? "Docker is not running. Please start Docker and try again." : `Failed to start Supabase services: ${execaError?.message ?? String(e)}`; return new DatabaseSetupError({ provider: "supabase", message, cause: e, }); }, }); } function displayManualSupabaseInstructions( targetApp: "apps/web" | "apps/server", output?: string | null, ) { cliLog.info( `"Manual Supabase Setup Instructions:" 1. Ensure Docker is installed and running. 2. Install the Supabase CLI (e.g., \`npm install -g supabase\`). 3. Run \`supabase init\` in your project's \`packages/db\` directory. 4. Run \`supabase start\` in your project's \`packages/db\` directory. 5. Copy the 'DB URL' from the output.${ output ? ` ${pc.bold("Relevant output from `supabase start`:")} ${pc.dim(output)}` : "" } 6. Add the DB URL to the .env file in \`${targetApp}/.env\` as \`DATABASE_URL\`: ${pc.gray('DATABASE_URL="your_supabase_db_url"')}`, ); } export async function setupSupabase( config: ProjectConfig, cliInput?: DatabaseSetupCliOptions, ): Promise { const { projectDir, packageManager, backend } = config; const targetApp: "apps/web" | "apps/server" = backend === "self" ? "apps/web" : "apps/server"; const setupMode = resolveDbSetupMode("supabase", { manualDb: cliInput?.manualDb, dbSetupOptions: cliInput?.dbSetupOptions ?? config.dbSetupOptions, }); const serverDir = path.join(projectDir, "packages", "db"); const ensureDirResult = await Result.tryPromise({ try: () => fs.ensureDir(serverDir), catch: (e) => new DatabaseSetupError({ provider: "supabase", message: `Failed to create directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (ensureDirResult.isErr()) { return ensureDirResult; } if (setupMode === "manual") { displayManualSupabaseInstructions(targetApp); return writeSupabaseEnvFile(projectDir, backend, ""); } let mode: DbSetupMode | undefined = setupMode; if (!mode) { if (isSilent()) { mode = "manual"; } else { const promptedMode = await select({ message: "Supabase setup: choose mode", options: [ { label: "Automatic", value: "auto", hint: "Automated setup with provider CLI, sets .env", }, { label: "Manual", value: "manual", hint: "Manual setup, add env vars yourself", }, ], initialValue: "auto", }); if (isCancel(promptedMode)) { return userCancelled("Operation cancelled"); } mode = promptedMode; } } if (mode === "manual") { displayManualSupabaseInstructions(targetApp); return writeSupabaseEnvFile(projectDir, backend, ""); } const initResult = await initializeSupabase(serverDir, packageManager); if (initResult.isErr()) { cliLog.error(pc.red(initResult.error.message)); displayManualSupabaseInstructions(targetApp); return writeSupabaseEnvFile(projectDir, backend, ""); } const startResult = await startSupabase(serverDir, packageManager); if (startResult.isErr()) { cliLog.error(pc.red(startResult.error.message)); displayManualSupabaseInstructions(targetApp); return writeSupabaseEnvFile(projectDir, backend, ""); } const supabaseOutput = startResult.value; const dbUrl = extractDbUrl(supabaseOutput); if (dbUrl) { const envResult = await writeSupabaseEnvFile(projectDir, backend, dbUrl); if (envResult.isOk()) { cliLog.success(pc.green("Supabase local development setup ready!")); } else { cliLog.error(pc.red("Supabase setup completed, but failed to update .env automatically.")); displayManualSupabaseInstructions(targetApp, supabaseOutput); } return envResult; } cliLog.error(pc.yellow("Supabase started, but could not extract DB URL automatically.")); displayManualSupabaseInstructions(targetApp, supabaseOutput); return databaseSetupError( "supabase", "Could not extract database URL from Supabase output. Please configure manually.", ); } ================================================ FILE: apps/cli/src/helpers/database-providers/turso-setup.ts ================================================ import os from "node:os"; import path from "node:path"; import { confirm, isCancel, select, text } from "@clack/prompts"; import { Result } from "better-result"; import { $ } from "execa"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { commandExists } from "../../utils/command-exists"; import { isSilent } from "../../utils/context"; import { addEnvVariablesToFile, type EnvVariable } from "../../utils/env-utils"; import { DatabaseSetupError, UserCancelledError, userCancelled } from "../../utils/errors"; import { cliLog, createSpinner } from "../../utils/terminal-output"; import { type DatabaseSetupCliOptions, type DbSetupMode, resolveDbSetupMode, } from "../core/db-setup-options"; type TursoConfig = { dbUrl: string; authToken: string; }; type TursoSetupResult = Result; async function isTursoInstalled(): Promise { return commandExists("turso"); } async function isTursoLoggedIn(): Promise { const result = await Result.tryPromise({ try: async () => { const output = await $`turso auth whoami`; return !output.stdout.includes("You are not logged in"); }, catch: () => false, }); return result.isOk() ? result.value : false; } async function loginToTurso(): Promise> { const s = createSpinner(); s.start("Logging in to Turso..."); return Result.tryPromise({ try: async () => { await $`turso auth login`; s.stop("Logged into Turso"); }, catch: (e) => { s.stop(pc.red("Failed to log in to Turso")); return new DatabaseSetupError({ provider: "turso", message: `Failed to log in to Turso: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); } async function installTursoCLI(isMac: boolean): Promise> { const s = createSpinner(); s.start("Installing Turso CLI..."); return Result.tryPromise({ try: async () => { if (isMac) { await $`brew install tursodatabase/tap/turso`; } else { const { stdout: installScript } = await $`curl -sSfL https://get.tur.so/install.sh`; await $`bash -c '${installScript}'`; } s.stop("Turso CLI installed"); }, catch: (e) => { const error = e as Error; const isCancelled = error.message?.includes("User force closed"); s.stop( isCancelled ? "Turso CLI installation cancelled" : pc.red("Failed to install Turso CLI"), ); return new DatabaseSetupError({ provider: "turso", message: isCancelled ? "Installation cancelled by user" : `Failed to install Turso CLI: ${error.message ?? String(e)}`, cause: e, }); }, }); } type TursoGroup = { name: string; locations: string; version: string; status: string; }; async function getTursoGroups(): Promise { const s = createSpinner(); s.start("Fetching Turso groups..."); const result = await Result.tryPromise({ try: async () => { const { stdout } = await $`turso group list`; const lines = stdout.trim().split("\n"); if (lines.length <= 1) { s.stop("No Turso groups found"); return []; } const groups = lines.slice(1).map((line) => { const [name, locations, version, status] = line.trim().split(/\s{2,}/); return { name, locations, version, status }; }); s.stop(`Found ${groups.length} Turso groups`); return groups; }, catch: () => { s.stop(pc.red("Error fetching Turso groups")); return [] as TursoGroup[]; }, }); return result.isOk() ? result.value : []; } async function selectTursoGroup(): Promise> { const groups = await getTursoGroups(); if (groups.length === 0) { return Result.ok(null); } if (groups.length === 1) { cliLog.info(`Using the only available group: ${pc.blue(groups[0].name)}`); return Result.ok(groups[0].name); } const groupOptions = groups.map((group) => ({ value: group.name, label: `${group.name} (${group.locations})`, })); const selectedGroup = await select({ message: "Select a Turso database group:", options: groupOptions, }); if (isCancel(selectedGroup)) { return userCancelled("Operation cancelled"); } return Result.ok(selectedGroup as string); } async function createTursoDatabase( dbName: string, groupName: string | null, ): Promise> { const s = createSpinner(); s.start(`Creating Turso database "${dbName}"${groupName ? ` in group "${groupName}"` : ""}...`); const createResult = await Result.tryPromise({ try: async () => { if (groupName) { await $`turso db create ${dbName} --group ${groupName}`; } else { await $`turso db create ${dbName}`; } s.stop(`Turso database "${dbName}" created`); }, catch: (e) => { const error = e as Error; s.stop(pc.red(`Failed to create database "${dbName}"`)); if (error.message?.includes("already exists")) { return new DatabaseSetupError({ provider: "turso", message: "DATABASE_EXISTS", cause: e, }); } return new DatabaseSetupError({ provider: "turso", message: `Failed to create database: ${error.message ?? String(e)}`, cause: e, }); }, }); if (createResult.isErr()) { return createResult; } s.start("Retrieving database connection details..."); return Result.tryPromise({ try: async () => { const { stdout: dbUrl } = await $`turso db show ${dbName} --url`; const { stdout: authToken } = await $`turso db tokens create ${dbName}`; s.stop("Database connection details retrieved"); return { dbUrl: dbUrl.trim(), authToken: authToken.trim(), }; }, catch: (e) => { s.stop(pc.red("Failed to retrieve database connection details")); return new DatabaseSetupError({ provider: "turso", message: `Failed to retrieve connection details: ${e instanceof Error ? e.message : String(e)}`, cause: e, }); }, }); } async function writeEnvFile( projectDir: string, backend: ProjectConfig["backend"], config?: TursoConfig, ): Promise> { return Result.tryPromise({ try: async () => { const targetApp = backend === "self" ? "apps/web" : "apps/server"; const envPath = path.join(projectDir, targetApp, ".env"); const variables: EnvVariable[] = [ { key: "DATABASE_URL", value: config?.dbUrl ?? "", condition: true, }, { key: "DATABASE_AUTH_TOKEN", value: config?.authToken ?? "", condition: true, }, ]; await addEnvVariablesToFile(envPath, variables); }, catch: (e) => new DatabaseSetupError({ provider: "turso", message: `Failed to update .env file: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } function displayManualSetupInstructions(targetApp: "apps/web" | "apps/server") { cliLog.info(`Manual Turso Setup Instructions: 1. Visit https://turso.tech and create an account 2. Create a new database from the dashboard 3. Get your database URL and authentication token 4. Add these credentials to the .env file in ${targetApp}/.env DATABASE_URL=your_database_url DATABASE_AUTH_TOKEN=your_auth_token`); } export async function setupTurso( config: ProjectConfig, cliInput?: DatabaseSetupCliOptions, ): Promise { const { projectDir, backend } = config; const targetApp: "apps/web" | "apps/server" = backend === "self" ? "apps/web" : "apps/server"; const setupMode = resolveDbSetupMode("turso", { manualDb: cliInput?.manualDb, dbSetupOptions: cliInput?.dbSetupOptions ?? config.dbSetupOptions, }); const setupSpinner = createSpinner(); if (setupMode === "manual") { const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); return Result.ok(undefined); } let mode: DbSetupMode | undefined = setupMode; if (!mode) { if (isSilent()) { mode = "manual"; } else { const promptedMode = await select({ message: "Turso setup: choose mode", options: [ { label: "Automatic", value: "auto", hint: "Automated setup with provider CLI, sets .env", }, { label: "Manual", value: "manual", hint: "Manual setup, add env vars yourself", }, ], initialValue: "auto", }); if (isCancel(promptedMode)) { return userCancelled("Operation cancelled"); } mode = promptedMode; } } if (mode === "manual") { const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); return Result.ok(undefined); } setupSpinner.start("Checking Turso CLI availability..."); const platform = os.platform(); const isMac = platform === "darwin"; const isWindows = platform === "win32"; if (isWindows) { setupSpinner.stop(pc.yellow("Turso setup not supported on Windows")); cliLog.warn(pc.yellow("Automatic Turso setup is not supported on Windows.")); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); return Result.ok(undefined); } setupSpinner.stop("Turso CLI availability checked"); const isCliInstalled = await isTursoInstalled(); if (!isCliInstalled) { let shouldInstall = cliInput?.dbSetupOptions?.turso?.installCli; if (shouldInstall === undefined) { if (isSilent()) { shouldInstall = false; } else { const promptedInstall = await confirm({ message: "Would you like to install Turso CLI?", initialValue: true, }); if (isCancel(promptedInstall)) { return userCancelled("Operation cancelled"); } shouldInstall = promptedInstall; } } if (!shouldInstall) { const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); return Result.ok(undefined); } const installResult = await installTursoCLI(isMac); if (installResult.isErr()) { cliLog.error(pc.red(installResult.error.message)); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); return Result.ok(undefined); } } const isLoggedIn = await isTursoLoggedIn(); if (!isLoggedIn) { if (isSilent()) { cliLog.warn(pc.yellow("Turso CLI is not logged in. Falling back to manual setup.")); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); return Result.ok(undefined); } const loginResult = await loginToTurso(); if (loginResult.isErr()) { cliLog.error(pc.red(loginResult.error.message)); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); return Result.ok(undefined); } } let selectedGroup = cliInput?.dbSetupOptions?.turso?.groupName ?? config.dbSetupOptions?.turso?.groupName ?? null; if (!selectedGroup) { if (isSilent()) { const groups = await getTursoGroups(); selectedGroup = groups[0]?.name ?? null; } else { const groupResult = await selectTursoGroup(); if (groupResult.isErr()) { return groupResult; } selectedGroup = groupResult.value; } } let suggestedName = cliInput?.dbSetupOptions?.turso?.databaseName ?? config.dbSetupOptions?.turso?.databaseName ?? path.basename(projectDir); if (isSilent()) { const createResult = await createTursoDatabase(suggestedName, selectedGroup); if (createResult.isErr()) { cliLog.error(pc.red(createResult.error.message)); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); cliLog.success("Setup completed with manual configuration required."); return Result.ok(undefined); } const envResult = await writeEnvFile(projectDir, backend, createResult.value); if (envResult.isErr()) { return envResult; } cliLog.success("Turso database setup completed successfully!"); return Result.ok(undefined); } while (true) { const dbNameResponse = await text({ message: "Enter a name for your database:", defaultValue: suggestedName, initialValue: suggestedName, placeholder: suggestedName, }); if (isCancel(dbNameResponse)) { return userCancelled("Operation cancelled"); } const dbName = dbNameResponse as string; const createResult = await createTursoDatabase(dbName, selectedGroup); if (createResult.isErr()) { if (createResult.error.message === "DATABASE_EXISTS") { cliLog.warn(pc.yellow(`Database "${pc.red(dbName)}" already exists`)); suggestedName = `${dbName}-${Math.floor(Math.random() * 1000)}`; continue; } cliLog.error(pc.red(createResult.error.message)); const envResult = await writeEnvFile(projectDir, backend); if (envResult.isErr()) { return envResult; } displayManualSetupInstructions(targetApp); cliLog.success("Setup completed with manual configuration required."); return Result.ok(undefined); } const envResult = await writeEnvFile(projectDir, backend, createResult.value); if (envResult.isErr()) { return envResult; } cliLog.success("Turso database setup completed successfully!"); return Result.ok(undefined); } } ================================================ FILE: apps/cli/src/index.ts ================================================ import { getAllJsonSchemas } from "@better-t-stack/types/json-schema"; import { initTRPC } from "@trpc/server"; import { Result } from "better-result"; import { createCli, type TrpcCliMeta } from "trpc-cli"; import z from "zod"; import { historyHandler } from "./commands/history"; import { openBuilderCommand, openDocsCommand, showSponsorsCommand } from "./commands/meta"; import { addHandler, type AddResult } from "./helpers/core/add-handler"; import { createProjectHandler } from "./helpers/core/command-handlers"; import { type Addons, AddonsSchema, type AddonOptions, type DbSetupOptions, DbSetupOptionsSchema, AddInputSchema, type API, APISchema, type Auth, AuthSchema, type Backend, BackendSchema, type BetterTStackConfig, type CLIInput, type CreateInput, CreateInputSchema, type Database, DatabaseSchema, type DatabaseSetup, DatabaseSetupSchema, type DirectoryConflict, DirectoryConflictSchema, type Examples, ExamplesSchema, type Frontend, FrontendSchema, type InitResult, type ORM, ORMSchema, type PackageManager, PackageManagerSchema, type Payments, PaymentsSchema, type ProjectConfig, ProjectNameSchema, type Runtime, RuntimeSchema, type ServerDeploy, ServerDeploySchema, type Template, TemplateSchema, type WebDeploy, WebDeploySchema, } from "./types"; import { CLIError, ProjectCreationError, UserCancelledError } from "./utils/errors"; import { getLatestCLIVersion } from "./utils/get-latest-cli-version"; import { validateConfigCompatibility } from "./validation"; export const SchemaNameSchema = z .enum([ "all", "cli", "database", "orm", "backend", "runtime", "frontend", "addons", "examples", "packageManager", "databaseSetup", "api", "auth", "payments", "webDeploy", "serverDeploy", "directoryConflict", "template", "addonOptions", "dbSetupOptions", "createInput", "addInput", "projectConfig", "betterTStackConfig", "initResult", ]) .default("all"); export type SchemaName = z.infer; const t = initTRPC.meta().create(); function getCliSchemaJson(): unknown { return createCli({ router, name: "create-better-t-stack", version: getLatestCLIVersion(), }).toJSON(); } export function getSchemaResult(name: SchemaName): unknown { const schemas = getAllJsonSchemas(); if (name === "all") { return { cli: getCliSchemaJson(), schemas, }; } if (name === "cli") { return getCliSchemaJson(); } return schemas[name]; } export const router = t.router({ create: t.procedure .meta({ description: "Create a new Better-T-Stack project", default: true, negateBooleans: true, }) .input( z.tuple([ ProjectNameSchema.optional(), z.object({ template: TemplateSchema.optional().describe("Use a predefined template"), yes: z.boolean().optional().default(false).describe("Use default configuration"), yolo: z .boolean() .optional() .default(false) .describe("(WARNING - NOT RECOMMENDED) Bypass validations and compatibility checks"), dryRun: z .boolean() .optional() .default(false) .describe("Validate setup without writing files"), verbose: z .boolean() .optional() .default(false) .describe("Show detailed result information"), database: DatabaseSchema.optional(), orm: ORMSchema.optional(), auth: AuthSchema.optional(), payments: PaymentsSchema.optional(), frontend: z.array(FrontendSchema).optional(), addons: z.array(AddonsSchema).optional(), examples: z.array(ExamplesSchema).optional(), git: z.boolean().optional(), packageManager: PackageManagerSchema.optional(), install: z.boolean().optional(), dbSetup: DatabaseSetupSchema.optional(), backend: BackendSchema.optional(), runtime: RuntimeSchema.optional(), api: APISchema.optional(), webDeploy: WebDeploySchema.optional(), serverDeploy: ServerDeploySchema.optional(), directoryConflict: DirectoryConflictSchema.optional(), renderTitle: z.boolean().optional(), disableAnalytics: z.boolean().optional().default(false).describe("Disable analytics"), manualDb: z .boolean() .optional() .default(false) .describe("Skip automatic/manual database setup prompt and use manual setup"), dbSetupOptions: DbSetupOptionsSchema.optional().describe( "Structured database setup options", ), }), ]), ) .mutation(async ({ input }) => { const [projectName, options] = input; const combinedInput = { projectName, ...options, }; const result = await createProjectHandler(combinedInput); if (options.verbose || options.dryRun) { return result; } }), createJson: t.procedure .meta({ description: "Create a project from a raw JSON payload (agent-friendly)", jsonInput: true, }) .input(CreateInputSchema) .mutation(async ({ input }) => { const result = await createProjectHandler(input, { silent: true }); if (!result) { throw new UserCancelledError({ message: "Operation cancelled" }); } if (!result.success) { throw new CLIError({ message: result.error || "Unknown error occurred", }); } return result; }), schema: t.procedure .meta({ description: "Show runtime CLI and input schemas as JSON" }) .input( z.object({ name: SchemaNameSchema.describe("Schema name to inspect"), }), ) .query(({ input }) => getSchemaResult(input.name)), sponsors: t.procedure .meta({ description: "Show Better-T-Stack sponsors" }) .mutation(() => showSponsorsCommand()), docs: t.procedure .meta({ description: "Open Better-T-Stack documentation" }) .mutation(() => openDocsCommand()), builder: t.procedure .meta({ description: "Open the web-based stack builder" }) .mutation(() => openBuilderCommand()), add: t.procedure .meta({ description: "Add addons to an existing Better-T-Stack project" }) .input( z.object({ addons: z.array(AddonsSchema).optional().describe("Addons to add"), install: z .boolean() .optional() .default(false) .describe("Install dependencies after adding"), packageManager: PackageManagerSchema.optional().describe("Package manager to use"), projectDir: z.string().optional().describe("Project directory (defaults to current)"), }), ) .mutation(async ({ input }) => { await addHandler(input); }), addJson: t.procedure .meta({ description: "Add addons from a raw JSON payload (agent-friendly)", jsonInput: true, }) .input(AddInputSchema) .mutation(async ({ input }) => { const result = await addHandler(input, { silent: true }); if (!result) { throw new UserCancelledError({ message: "Operation cancelled" }); } if (!result.success) { throw new CLIError({ message: result.error || "Unknown error occurred", }); } return result; }), history: t.procedure .meta({ description: "Show project creation history" }) .input( z.object({ limit: z.number().optional().default(10).describe("Number of entries to show"), clear: z.boolean().optional().default(false).describe("Clear all history"), json: z.boolean().optional().default(false).describe("Output as JSON"), }), ) .mutation(async ({ input }) => { await historyHandler(input); }), }); export function createBtsCli(): ReturnType { return createCli({ router, name: "create-better-t-stack", version: getLatestCLIVersion(), }); } // Re-export Result type from better-result for programmatic API consumers export { Result } from "better-result"; /** * Error types that can be returned from create/createVirtual */ export type CreateError = UserCancelledError | CLIError | ProjectCreationError; /** * Programmatic API to create a new Better-T-Stack project. * Returns a Result type - no console output, no interactive prompts. * * @example * ```typescript * import { create, Result } from "create-better-t-stack"; * * const result = await create("my-app", { * frontend: ["tanstack-router"], * backend: "hono", * runtime: "bun", * database: "sqlite", * orm: "drizzle", * }); * * result.match({ * ok: (data) => console.log(`Project created at: ${data.projectDirectory}`), * err: (error) => console.error(`Failed: ${error.message}`), * }); * * // Or use unwrapOr for a default value * const data = result.unwrapOr(null); * ``` */ export async function create( projectName?: string, options?: Partial, ): Promise> { const input = { ...options, projectName, renderTitle: false, verbose: true, disableAnalytics: options?.disableAnalytics ?? true, directoryConflict: options?.directoryConflict ?? "error", } as CreateInput & { projectName?: string }; return Result.tryPromise({ try: async () => { const result = await createProjectHandler(input, { silent: true }); if (!result) { // User cancelled (undefined return means cancellation in CLI mode) throw new UserCancelledError({ message: "Operation cancelled" }); } if (!result.success) { throw new CLIError({ message: result.error || "Unknown error occurred", }); } return result as InitResult; }, catch: (e: unknown) => { if (e instanceof UserCancelledError) return e; if (e instanceof CLIError) return e; if (e instanceof ProjectCreationError) return e; return new CLIError({ message: e instanceof Error ? e.message : String(e), cause: e, }); }, }); } export async function sponsors() { return showSponsorsCommand(); } export async function docs() { return openDocsCommand(); } export async function builder() { return openBuilderCommand(); } // Re-export virtual filesystem types for programmatic usage export { VirtualFileSystem, type VirtualFileTree, type VirtualFile, type VirtualDirectory, type VirtualNode, type GeneratorOptions, GeneratorError, generate, EMBEDDED_TEMPLATES, TEMPLATE_COUNT, } from "@better-t-stack/template-generator"; // Import for createVirtual import { generate, GeneratorError, type VirtualFileTree, EMBEDDED_TEMPLATES, } from "@better-t-stack/template-generator"; /** * Programmatic API to generate a project in-memory (virtual filesystem). * Returns a Result with a VirtualFileTree without writing to disk. * Useful for web previews and testing. * * @example * ```typescript * import { createVirtual, EMBEDDED_TEMPLATES, Result } from "create-better-t-stack"; * * const result = await createVirtual({ * frontend: ["tanstack-router"], * backend: "hono", * runtime: "bun", * database: "sqlite", * orm: "drizzle", * }); * * result.match({ * ok: (tree) => console.log(`Generated ${tree.fileCount} files`), * err: (error) => console.error(`Failed: ${error.message}`), * }); * ``` */ export async function createVirtual( options: Partial>, ): Promise> { const config: ProjectConfig = { projectName: options.projectName || "my-project", projectDir: "/virtual", relativePath: "./virtual", addonOptions: options.addonOptions, dbSetupOptions: options.dbSetupOptions, database: options.database || "none", orm: options.orm || "none", backend: options.backend || "hono", runtime: options.runtime || "bun", frontend: options.frontend || ["tanstack-router"], addons: options.addons || [], examples: options.examples || [], auth: options.auth || "none", payments: options.payments || "none", git: options.git ?? false, packageManager: options.packageManager || "bun", install: false, dbSetup: options.dbSetup || "none", api: options.api || "trpc", webDeploy: options.webDeploy || "none", serverDeploy: options.serverDeploy || "none", }; const providedFlags = new Set([ "database", "orm", "backend", "runtime", "frontend", "addons", "examples", "auth", "dbSetup", "payments", "api", "webDeploy", "serverDeploy", ]); const validationResult = validateConfigCompatibility( config, providedFlags, config as unknown as CLIInput, ); if (validationResult.isErr()) { return Result.err( new GeneratorError({ message: validationResult.error.message, phase: "validation", cause: validationResult.error, }), ); } return generate({ config, templates: EMBEDDED_TEMPLATES, }); } export type { CreateInput, InitResult, BetterTStackConfig, Database, ORM, Backend, Runtime, Frontend, Addons, AddonOptions, DbSetupOptions, Examples, PackageManager, DatabaseSetup, API, Auth, Payments, WebDeploy, ServerDeploy, Template, DirectoryConflict, }; export type { AddResult }; /** * Programmatic API to add addons to an existing Better-T-Stack project. * * @example * ```typescript * import { add } from "create-better-t-stack"; * * const result = await add({ * addons: ["biome", "husky"], * install: true, * }); * * if (result?.success) { * console.log(`Added: ${result.addedAddons.join(", ")}`); * } * ``` */ export async function add( options: { addons?: Addons[]; addonOptions?: AddonOptions; install?: boolean; packageManager?: PackageManager; projectDir?: string; dryRun?: boolean; } = {}, ): Promise { return addHandler(options, { silent: true }); } // Re-export error types for consumers export { UserCancelledError, CLIError, ProjectCreationError, ValidationError, CompatibilityError, DirectoryConflictError, DatabaseSetupError, } from "./utils/errors"; ================================================ FILE: apps/cli/src/mcp.ts ================================================ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import z from "zod"; import { add, create, type SchemaName, SchemaNameSchema, getSchemaResult } from "./index"; import { AddInputSchema, AddonOptionsSchema, AddonsSchema, APISchema, AuthSchema, BackendSchema, CreateInputSchema, DatabaseSchema, DatabaseSetupSchema, DbSetupOptionsSchema, DirectoryConflictSchema, ExamplesSchema, FrontendSchema, ORMSchema, PackageManagerSchema, PaymentsSchema, RuntimeSchema, ServerDeploySchema, WebDeploySchema, } from "./types"; import { getLatestCLIVersion } from "./utils/get-latest-cli-version"; const ToolResponseSchema = z.object({ ok: z.boolean(), data: z.any().optional(), error: z.string().optional(), }); const McpCreateProjectInputSchema = CreateInputSchema.safeExtend({ projectName: z.string().describe("Project name or relative path"), frontend: z .array(FrontendSchema) .describe("Explicit frontend app surfaces. Do not use native frontends as styling options."), backend: BackendSchema.describe("Explicit backend framework"), runtime: RuntimeSchema.describe("Explicit runtime environment"), database: DatabaseSchema.describe("Explicit database choice"), orm: ORMSchema.describe("Explicit ORM choice"), api: APISchema.describe("Explicit API layer"), auth: AuthSchema.describe("Explicit authentication provider"), payments: PaymentsSchema.describe("Explicit payments provider"), addons: z.array(AddonsSchema).describe("Explicit addon list. Use [] when no addons are needed."), examples: z .array(ExamplesSchema) .describe("Explicit example list. Use [] when no examples are needed."), git: z.boolean().describe("Whether to initialize a git repository"), packageManager: PackageManagerSchema.describe("Explicit package manager"), install: z.boolean().describe("Whether to install dependencies"), dbSetup: DatabaseSetupSchema.describe("Explicit database setup/provisioning choice"), webDeploy: WebDeploySchema.describe("Explicit web deployment choice"), serverDeploy: ServerDeploySchema.describe("Explicit server deployment choice"), addonOptions: AddonOptionsSchema.optional(), dbSetupOptions: DbSetupOptionsSchema.optional(), directoryConflict: DirectoryConflictSchema.optional(), }).describe( "Explicit Better T Stack project configuration for MCP use. Provide the full stack config instead of relying on inferred defaults.", ); function formatToolSuccess(data: unknown) { return { content: [ { type: "text" as const, text: JSON.stringify(data, null, 2), }, ], structuredContent: { ok: true, data, }, }; } function formatToolError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return { content: [ { type: "text" as const, text: message, }, ], structuredContent: { ok: false, error: message, }, isError: true, }; } function getProjectToolAnnotations() { return { destructiveHint: true, idempotentHint: false, openWorldHint: true, }; } function getMcpInstallTimeoutMessage(packageManager: string) { return [ "MCP project creation requires `install: false`.", "Dependency installation can exceed common MCP client request timeouts and cause the connection to close before the tool returns.", `Scaffold the project first, then run \`${packageManager} install\` in the generated project directory from a terminal.`, ].join(" "); } function getStackGuidance() { return { workflow: [ "Call bts_get_schema or bts_get_stack_guidance before constructing a config if the request is ambiguous.", "For project creation, build a full explicit config before calling bts_plan_project.", "Always call bts_plan_project before bts_create_project.", "Only call bts_create_project after the plan succeeds and matches the user's intent.", "Use bts_plan_addons before bts_add_addons for existing projects.", ], createContract: { requiresExplicitFields: [ "projectName", "frontend", "backend", "runtime", "database", "orm", "api", "auth", "payments", "addons", "examples", "git", "packageManager", "install", "dbSetup", "webDeploy", "serverDeploy", ], optionalFields: ["addonOptions", "dbSetupOptions", "directoryConflict"], rule: "Do not call bts_plan_project or bts_create_project with a partial payload. MCP project creation requires the full explicit stack config.", }, fieldNotes: { frontend: "frontend is for app surfaces only. Choose explicit app targets such as next, react-router, tanstack-router, native-bare, native-uniwind, or native-unistyles.", addons: "addons must be an explicit array. Use [] when no addons are requested.", examples: "examples must be an explicit array. Use [] when no examples are requested.", dbSetup: "dbSetup is always required. Use 'none' when no managed database provisioning is requested.", webDeploy: "webDeploy is always required. Use 'none' when no web deployment target is requested.", serverDeploy: "serverDeploy is always required. Use 'none' when no server deployment target is requested.", packageManager: "packageManager is always required because installation and reproducible commands depend on it.", install: "install is always required. For MCP project creation, prefer false because many clients enforce request timeouts around long-running dependency installs.", git: "git is always required. Set it to true or false explicitly instead of relying on defaults.", }, ambiguityRules: [ "If the user request leaves major stack choices unspecified, stop and resolve them before calling bts_plan_project.", "Do not infer extra app surfaces, addons, examples, or provisioning choices from a template name or styling preference.", "If the user wants the smallest valid stack, still send the full config with explicit 'none', [] , true, or false values where appropriate.", "For MCP execution, scaffold with install=false and let the user or agent run dependency installation separately from a terminal session.", ], }; } export function createBtsMcpServer() { const server = new McpServer( { name: "create-better-t-stack", version: getLatestCLIVersion(), }, { capabilities: { logging: {}, }, }, ); server.registerTool( "bts_get_stack_guidance", { title: "Get Better T Stack MCP Guidance", description: "Read MCP-specific guidance for choosing valid Better T Stack configurations. Use this before planning when user intent is ambiguous. This explains the full explicit config required by MCP project creation, plus important field semantics and ambiguity rules.", inputSchema: z.object({}), outputSchema: ToolResponseSchema, annotations: { title: "Get Better T Stack MCP Guidance", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async () => { try { return formatToolSuccess(getStackGuidance()); } catch (error) { return formatToolError(error); } }, ); server.registerTool( "bts_get_schema", { title: "Get Better T Stack Schemas", description: "Inspect Better T Stack CLI and input schemas so agents can plan valid create/add requests. Use this together with bts_get_stack_guidance before creating a project if any part of the request is ambiguous.", inputSchema: z.object({ name: SchemaNameSchema.optional().describe("Schema name to inspect. Defaults to all."), }), outputSchema: ToolResponseSchema, annotations: { title: "Get Better T Stack Schemas", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async ({ name }) => { try { return formatToolSuccess(getSchemaResult((name ?? "all") as SchemaName)); } catch (error) { return formatToolError(error); } }, ); server.registerTool( "bts_plan_project", { title: "Plan Better T Stack Project", description: "Validate and preview a Better T Stack project creation without writing files or provisioning resources. Always use this before bts_create_project. This tool requires an explicit full stack config rather than a partial payload with inferred defaults.", inputSchema: McpCreateProjectInputSchema, outputSchema: ToolResponseSchema, annotations: { title: "Plan Better T Stack Project", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async (input) => { try { const result = await create(input.projectName, { ...input, dryRun: true, disableAnalytics: true, }); if (result.isErr()) { return formatToolError(result.error); } const planningData = input.install ? { ...result.value, warnings: [getMcpInstallTimeoutMessage(input.packageManager)], recommendedMcpExecution: { ...input, install: false, }, } : result.value; return formatToolSuccess(planningData); } catch (error) { return formatToolError(error); } }, ); server.registerTool( "bts_create_project", { title: "Create Better T Stack Project", description: "Create a Better T Stack project on disk using the same silent programmatic flow as the CLI JSON API. Call this only after bts_plan_project succeeds and the plan clearly matches the user's intent. This tool requires an explicit full stack config.", inputSchema: McpCreateProjectInputSchema, outputSchema: ToolResponseSchema, annotations: { title: "Create Better T Stack Project", ...getProjectToolAnnotations(), }, }, async (input) => { try { if (input.install) { return formatToolError(getMcpInstallTimeoutMessage(input.packageManager)); } const result = await create(input.projectName, { ...input, disableAnalytics: true, }); if (result.isErr()) { return formatToolError(result.error); } return formatToolSuccess(result.value); } catch (error) { return formatToolError(error); } }, ); server.registerTool( "bts_plan_addons", { title: "Plan Better T Stack Addons", description: "Validate and preview addon installation for an existing Better T Stack project without writing files. Always use this before bts_add_addons when the addon set or nested options are uncertain.", inputSchema: AddInputSchema, outputSchema: ToolResponseSchema, annotations: { title: "Plan Better T Stack Addons", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, async (input) => { try { const result = await add({ ...input, dryRun: true, }); if (!result?.success) { return formatToolError(result?.error ?? "Failed to plan addon installation"); } return formatToolSuccess(result); } catch (error) { return formatToolError(error); } }, ); server.registerTool( "bts_add_addons", { title: "Add Better T Stack Addons", description: "Install addons into an existing Better T Stack project using the same silent flow as add-json. Call this only after bts_plan_addons succeeds and the planned changes match the user's intent.", inputSchema: AddInputSchema, outputSchema: ToolResponseSchema, annotations: { title: "Add Better T Stack Addons", destructiveHint: true, idempotentHint: false, openWorldHint: true, }, }, async (input) => { try { const result = await add(input); if (!result?.success) { return formatToolError(result?.error ?? "Failed to add addons"); } return formatToolSuccess(result); } catch (error) { return formatToolError(error); } }, ); return server; } export async function startBtsMcpServer() { const server = createBtsMcpServer(); const transport = new StdioServerTransport(); await server.connect(transport); } ================================================ FILE: apps/cli/src/prompts/addons.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import { type Addons, AddonsSchema, type Auth, type Backend, type Frontend, type ProjectConfig, type Runtime, } from "../types"; import { getCompatibleAddons, validateAddonCompatibility } from "../utils/compatibility-rules"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableGroupMultiselect } from "./navigable"; type AddonOption = { value: Addons; label: string; hint: string; }; type AddonProjectConfig = Pick< ProjectConfig, "frontend" | "addons" | "auth" | "backend" | "runtime" >; function getAddonDisplay(addon: Addons): { label: string; hint: string } { let label: string; let hint: string; switch (addon) { case "turborepo": label = "Turborepo"; hint = "High-performance build system"; break; case "nx": label = "Nx"; hint = "Smart monorepo orchestration and task graph"; break; case "pwa": label = "PWA"; hint = "Make your app installable and work offline"; break; case "tauri": label = "Tauri"; hint = "Build native desktop apps from your web frontend"; break; case "electrobun": label = "Electrobun"; hint = "Wrap web frontends in a lightweight desktop shell"; break; case "biome": label = "Biome"; hint = "Format, lint, and more"; break; case "oxlint": label = "Oxlint"; hint = "Oxlint + Oxfmt (linting & formatting)"; break; case "ultracite": label = "Ultracite"; hint = "Zero-config Biome preset with AI integration"; break; case "lefthook": label = "Lefthook"; hint = "Fast and powerful Git hooks manager"; break; case "husky": label = "Husky"; hint = "Modern native Git hooks made easy"; break; case "starlight": label = "Starlight"; hint = "Build stellar docs with astro"; break; case "fumadocs": label = "Fumadocs"; hint = "Build excellent documentation site"; break; case "opentui": label = "OpenTUI"; hint = "Build terminal user interfaces"; break; case "wxt": label = "WXT"; hint = "Build browser extensions"; break; case "skills": label = "Skills"; hint = "AI coding agent skills for your stack"; break; case "mcp": label = "MCP"; hint = "Install MCP servers, including Better T Stack, via add-mcp"; break; case "evlog": label = "evlog"; hint = "Request logging with Better Auth context and AI SDK telemetry"; break; default: label = addon; hint = `Add ${addon}`; } return { label, hint }; } const ADDON_GROUPS = { "Monorepo & Tasks": ["turborepo", "nx"], "Code Quality": ["biome", "oxlint", "ultracite", "husky", "lefthook"], Documentation: ["starlight", "fumadocs"], "Platform Extensions": ["pwa", "tauri", "electrobun", "opentui", "wxt"], Observability: ["evlog"], "AI & Agent Tools": ["skills", "mcp"], }; function createGroupedOptions(): Record { return Object.fromEntries(Object.keys(ADDON_GROUPS).map((group) => [group, [] as AddonOption[]])); } function addOptionToGroup(groupedOptions: Record, option: AddonOption) { for (const [group, addons] of Object.entries(ADDON_GROUPS)) { if (addons.includes(option.value)) { groupedOptions[group]?.push(option); return; } } } function sortAndPruneGroupedOptions(groupedOptions: Record) { Object.keys(groupedOptions).forEach((group) => { if (groupedOptions[group].length === 0) { delete groupedOptions[group]; return; } const groupOrder = ADDON_GROUPS[group as keyof typeof ADDON_GROUPS] || []; groupedOptions[group].sort((a, b) => { const indexA = groupOrder.indexOf(a.value); const indexB = groupOrder.indexOf(b.value); return indexA - indexB; }); }); } function validateAddonSelection(selected: Addons[] | undefined) { if (selected?.includes("turborepo") && selected.includes("nx")) { return "Choose either Turborepo or Nx as your monorepo tool, not both."; } } export async function getAddonsChoice( addons?: Addons[], frontends?: Frontend[], auth?: Auth, backend?: Backend, runtime?: Runtime, ) { if (addons !== undefined) return addons; const allAddons = AddonsSchema.options.filter((addon) => addon !== "none"); const groupedOptions = createGroupedOptions(); const frontendsArray = frontends || []; for (const addon of allAddons) { const { isCompatible } = validateAddonCompatibility( addon, frontendsArray, auth, backend, runtime, ); if (!isCompatible) continue; const { label, hint } = getAddonDisplay(addon); const option = { value: addon, label, hint }; addOptionToGroup(groupedOptions, option); } sortAndPruneGroupedOptions(groupedOptions); const initialValues = DEFAULT_CONFIG.addons.filter((addonValue) => Object.values(groupedOptions).some((options) => options.some((opt) => opt.value === addonValue), ), ); const response = await navigableGroupMultiselect({ message: "Select addons", options: groupedOptions, initialValues: initialValues, required: false, validate: validateAddonSelection, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } export async function getAddonsToAdd(config: AddonProjectConfig) { const groupedOptions = createGroupedOptions(); const frontendArray = config.frontend || []; const compatibleAddons = getCompatibleAddons( AddonsSchema.options.filter((addon) => addon !== "none"), frontendArray, config.addons, config.auth, config.backend, config.runtime, ); for (const addon of compatibleAddons) { const { label, hint } = getAddonDisplay(addon); const option = { value: addon, label, hint }; addOptionToGroup(groupedOptions, option); } sortAndPruneGroupedOptions(groupedOptions); if (Object.keys(groupedOptions).length === 0) { return []; } const response = await navigableGroupMultiselect({ message: "Select addons to add", options: groupedOptions, required: false, validate: validateAddonSelection, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/api.ts ================================================ import type { API, Backend, Frontend } from "../types"; import { allowedApisForFrontends, validateApiFrontendCompatibility, } from "../utils/compatibility-rules"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; export async function getApiChoice( Api?: API | undefined, frontend?: Frontend[], backend?: Backend, ) { if (backend === "convex" || backend === "none") { return "none"; } const allowed = allowedApisForFrontends(frontend ?? []); if (Api) { const compat = validateApiFrontendCompatibility(Api, frontend ?? []); if (compat.isErr()) throw compat.error; return Api; } const apiOptions = allowed.map((a) => a === "trpc" ? { value: "trpc" as const, label: "tRPC", hint: "End-to-end typesafe APIs made easy", } : a === "orpc" ? { value: "orpc" as const, label: "oRPC", hint: "End-to-end type-safe APIs that adhere to OpenAPI standards", } : { value: "none" as const, label: "None", hint: "No API layer (e.g. for full-stack frameworks like Next.js with Route Handlers)", }, ); const apiType = await navigableSelect({ message: "Select API type", options: apiOptions, initialValue: apiOptions[0].value, }); if (isCancel(apiType)) throw new UserCancelledError({ message: "Operation cancelled" }); return apiType; } ================================================ FILE: apps/cli/src/prompts/auth.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Auth, Backend, Frontend } from "../types"; import { supportsConvexBetterAuth } from "../utils/compatibility-rules"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; export function getAvailableAuthProviders( backend?: Backend, frontend: readonly Frontend[] = [], ): Auth[] { if (backend === "none") { return ["none"]; } const hasClerkCompatibleFrontends = frontend.some((f) => [ "react-router", "tanstack-router", "tanstack-start", "next", "native-bare", "native-uniwind", "native-unistyles", ].includes(f), ); const options: Auth[] = []; if (backend === "convex") { if (supportsConvexBetterAuth(frontend)) { options.push("better-auth"); } } else { options.push("better-auth"); } if (hasClerkCompatibleFrontends) { options.push("clerk"); } if (options.length === 0) { return ["none"]; } return [...options, "none"]; } export async function getAuthChoice( auth: Auth | undefined, backend?: Backend, frontend: readonly Frontend[] = [], ) { if (auth !== undefined) return auth; const availableProviders = getAvailableAuthProviders(backend, frontend); if (availableProviders.length === 1 && availableProviders[0] === "none") { return "none" as Auth; } const options = availableProviders.map((provider) => { switch (provider) { case "better-auth": return { value: "better-auth", label: "Better-Auth", hint: "comprehensive auth framework for TypeScript", }; case "clerk": return { value: "clerk", label: "Clerk", hint: "More than auth, Complete User Management", }; default: return { value: "none", label: "None", hint: "No auth" }; } }); const response = await navigableSelect({ message: "Select authentication provider", options, initialValue: options.some((option) => option.value === DEFAULT_CONFIG.auth) ? DEFAULT_CONFIG.auth : "none", }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response as Auth; } ================================================ FILE: apps/cli/src/prompts/backend.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Frontend } from "../types"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; // Frontends that support backend="self" (fullstack mode with built-in server routes) const FULLSTACK_FRONTENDS: readonly Frontend[] = [ "next", "tanstack-start", "nuxt", "svelte", "astro", ] as const; export async function getBackendFrameworkChoice( backendFramework?: Backend, frontends?: Frontend[], ) { if (backendFramework !== undefined) return backendFramework; const hasIncompatibleFrontend = frontends?.some((f) => f === "solid" || f === "astro"); const hasFullstackFrontend = frontends?.some((f) => FULLSTACK_FRONTENDS.includes(f)); const backendOptions: Array<{ value: Backend; label: string; hint: string; }> = []; if (hasFullstackFrontend) { backendOptions.push({ value: "self" as const, label: "Self (Fullstack)", hint: "Use frontend's built-in api routes", }); } backendOptions.push( { value: "hono" as const, label: "Hono", hint: "Lightweight, ultrafast web framework", }, { value: "express" as const, label: "Express", hint: "Fast, unopinionated, minimalist web framework for Node.js", }, { value: "fastify" as const, label: "Fastify", hint: "Fast, low-overhead web framework for Node.js", }, { value: "elysia" as const, label: "Elysia", hint: "Ergonomic web framework for building backend servers", }, ); if (!hasIncompatibleFrontend) { backendOptions.push({ value: "convex" as const, label: "Convex", hint: "Reactive backend-as-a-service platform", }); } backendOptions.push({ value: "none" as const, label: "None", hint: "No backend server", }); const response = await navigableSelect({ message: "Select backend", options: backendOptions, initialValue: hasFullstackFrontend ? "self" : DEFAULT_CONFIG.backend, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/config-prompts.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Addons, API, Auth, Backend, Database, DatabaseSetup, Examples, Frontend, ORM, PackageManager, Payments, ProjectConfig, Runtime, ServerDeploy, WebDeploy, } from "../types"; import { isSilent } from "../utils/context"; import { UserCancelledError } from "../utils/errors"; import { getAddonsChoice } from "./addons"; import { getApiChoice } from "./api"; import { getAuthChoice } from "./auth"; import { getBackendFrameworkChoice } from "./backend"; import { getDatabaseChoice } from "./database"; import { getDBSetupChoice } from "./database-setup"; import { getExamplesChoice } from "./examples"; import { getFrontendChoice } from "./frontend"; import { getGitChoice } from "./git"; import { getinstallChoice } from "./install"; import { navigableGroup } from "./navigable-group"; import { getORMChoice } from "./orm"; import { getPackageManagerChoice } from "./package-manager"; import { getPaymentsChoice } from "./payments"; import { getRuntimeChoice } from "./runtime"; import { getServerDeploymentChoice } from "./server-deploy"; import { getDeploymentChoice } from "./web-deploy"; type PromptGroupResults = { frontend: Frontend[]; backend: Backend; runtime: Runtime; database: Database; orm: ORM; api: API; auth: Auth; payments: Payments; addons: Addons[]; examples: Examples[]; dbSetup: DatabaseSetup; git: boolean; packageManager: PackageManager; install: boolean; webDeploy: WebDeploy; serverDeploy: ServerDeploy; }; export async function gatherConfig( flags: Partial, projectName: string, projectDir: string, relativePath: string, ) { if (isSilent()) { return { projectName, projectDir, relativePath, addonOptions: flags.addonOptions, dbSetupOptions: flags.dbSetupOptions, frontend: flags.frontend ?? [...DEFAULT_CONFIG.frontend], backend: flags.backend ?? DEFAULT_CONFIG.backend, runtime: flags.runtime ?? DEFAULT_CONFIG.runtime, database: flags.database ?? DEFAULT_CONFIG.database, orm: flags.orm ?? DEFAULT_CONFIG.orm, auth: flags.auth ?? DEFAULT_CONFIG.auth, payments: flags.payments ?? DEFAULT_CONFIG.payments, addons: flags.addons ?? [...DEFAULT_CONFIG.addons], examples: flags.examples ?? [...DEFAULT_CONFIG.examples], git: flags.git ?? DEFAULT_CONFIG.git, packageManager: flags.packageManager ?? DEFAULT_CONFIG.packageManager, install: flags.install ?? DEFAULT_CONFIG.install, dbSetup: flags.dbSetup ?? DEFAULT_CONFIG.dbSetup, api: flags.api ?? DEFAULT_CONFIG.api, webDeploy: flags.webDeploy ?? DEFAULT_CONFIG.webDeploy, serverDeploy: flags.serverDeploy ?? DEFAULT_CONFIG.serverDeploy, }; } const result = await navigableGroup( { frontend: () => getFrontendChoice(flags.frontend, flags.backend, flags.auth), backend: ({ results }) => getBackendFrameworkChoice(flags.backend, results.frontend), runtime: ({ results }) => getRuntimeChoice(flags.runtime, results.backend), database: ({ results }) => getDatabaseChoice(flags.database, results.backend, results.runtime), orm: ({ results }) => getORMChoice( flags.orm, results.database !== "none", results.database, results.backend, results.runtime, ), api: ({ results }) => getApiChoice(flags.api, results.frontend, results.backend) as Promise, auth: ({ results }) => getAuthChoice(flags.auth, results.backend, results.frontend), payments: ({ results }) => getPaymentsChoice(flags.payments, results.auth, results.backend, results.frontend), addons: ({ results }) => getAddonsChoice( flags.addons, results.frontend, results.auth, results.backend, results.runtime, ), examples: ({ results }) => getExamplesChoice( flags.examples, results.database, results.frontend, results.backend, results.api, ) as Promise, dbSetup: ({ results }) => getDBSetupChoice( results.database ?? "none", flags.dbSetup, results.orm, results.backend, results.runtime, ), webDeploy: ({ results }) => getDeploymentChoice( flags.webDeploy, results.runtime, results.backend, results.frontend, results.dbSetup, ), serverDeploy: ({ results }) => getServerDeploymentChoice( flags.serverDeploy, results.runtime, results.backend, results.webDeploy, ), git: () => getGitChoice(flags.git), packageManager: () => getPackageManagerChoice(flags.packageManager), install: () => getinstallChoice(flags.install), }, { onCancel: () => { throw new UserCancelledError({ message: "Operation cancelled" }); }, }, ); return { projectName: projectName, projectDir: projectDir, relativePath: relativePath, addonOptions: flags.addonOptions, dbSetupOptions: flags.dbSetupOptions, frontend: result.frontend, backend: result.backend, runtime: result.runtime, database: result.database, orm: result.orm, auth: result.auth, payments: result.payments, addons: result.addons, examples: result.examples, git: result.git, packageManager: result.packageManager, install: result.install, dbSetup: result.dbSetup, api: result.api, webDeploy: result.webDeploy, serverDeploy: result.serverDeploy, }; } ================================================ FILE: apps/cli/src/prompts/database-setup.ts ================================================ import type { Backend, DatabaseSetup, ORM, Runtime } from "../types"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; export async function getDBSetupChoice( databaseType: string, dbSetup: DatabaseSetup | undefined, _orm?: ORM, backend?: Backend, runtime?: Runtime, ) { if (backend === "convex") { return "none"; } if (dbSetup !== undefined) return dbSetup as DatabaseSetup; if (databaseType === "none") { return "none"; } let options: Array<{ value: DatabaseSetup; label: string; hint: string }> = []; if (databaseType === "sqlite") { options = [ { value: "turso" as const, label: "Turso", hint: "SQLite for Production. Powered by libSQL", }, ...(runtime === "workers" || backend === "self" ? [ { value: "d1" as const, label: "Cloudflare D1", hint: "Cloudflare's managed, serverless database with SQLite's SQL semantics", }, ] : []), { value: "none" as const, label: "None", hint: "Manual setup" }, ]; } else if (databaseType === "postgres") { options = [ { value: "neon" as const, label: "Neon Postgres", hint: "Serverless Postgres with branching capability", }, { value: "planetscale" as const, label: "PlanetScale", hint: "Postgres & Vitess (MySQL) on NVMe", }, { value: "supabase" as const, label: "Supabase", hint: "Local Supabase stack (requires Docker)", }, { value: "prisma-postgres" as const, label: "Prisma Postgres", hint: "Instant Postgres for Global Applications", }, { value: "docker" as const, label: "Docker", hint: "Run locally with docker compose", }, { value: "none" as const, label: "None", hint: "Manual setup" }, ]; } else if (databaseType === "mysql") { options = [ { value: "planetscale" as const, label: "PlanetScale", hint: "MySQL on Vitess (NVMe, HA)", }, { value: "docker" as const, label: "Docker", hint: "Run locally with docker compose", }, { value: "none" as const, label: "None", hint: "Manual setup" }, ]; } else if (databaseType === "mongodb") { options = [ { value: "mongodb-atlas" as const, label: "MongoDB Atlas", hint: "The most effective way to deploy MongoDB", }, { value: "docker" as const, label: "Docker", hint: "Run locally with docker compose", }, { value: "none" as const, label: "None", hint: "Manual setup" }, ]; } else { return "none"; } const response = await navigableSelect({ message: `Select ${databaseType} setup option`, options, initialValue: "none", }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/database.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Database, Runtime } from "../types"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; export async function getDatabaseChoice(database?: Database, backend?: Backend, runtime?: Runtime) { if (backend === "convex" || backend === "none") { return "none"; } if (database !== undefined) return database; const databaseOptions: Array<{ value: Database; label: string; hint: string; }> = [ { value: "none", label: "None", hint: "No database setup", }, { value: "sqlite", label: "SQLite", hint: "lightweight, server-less, embedded relational database", }, { value: "postgres", label: "PostgreSQL", hint: "powerful, open source object-relational database system", }, { value: "mysql", label: "MySQL", hint: "popular open-source relational database system", }, ]; if (runtime !== "workers") { databaseOptions.push({ value: "mongodb", label: "MongoDB", hint: "open-source NoSQL database that stores data in JSON-like documents called BSON", }); } const response = await navigableSelect({ message: "Select database", options: databaseOptions, initialValue: DEFAULT_CONFIG.database, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/examples.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { API, Backend, Database, Examples, Frontend } from "../types"; import { isExampleAIAllowed, isExampleTodoAllowed } from "../utils/compatibility-rules"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableMultiselect } from "./navigable"; export async function getExamplesChoice( examples?: Examples[], database?: Database, frontends?: Frontend[], backend?: Backend, api?: API, ) { if (examples !== undefined) return examples; if (backend === "none") { return []; } let response: Examples[] | symbol = []; const options: { value: Examples; label: string; hint: string }[] = []; if (isExampleTodoAllowed(backend, database, api)) { options.push({ value: "todo" as const, label: "Todo App", hint: "A simple CRUD example app", }); } if (isExampleAIAllowed(backend, frontends ?? [])) { options.push({ value: "ai" as const, label: "AI Chat", hint: "A simple AI chat interface using AI SDK", }); } if (options.length === 0) return []; response = await navigableMultiselect({ message: "Include examples", options: options, required: false, initialValues: DEFAULT_CONFIG.examples?.filter((ex) => options.some((o) => o.value === ex)), }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/frontend.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Frontend } from "../types"; import { isFrontendAllowedWithBackend } from "../utils/compatibility-rules"; import { isFirstPrompt } from "../utils/context"; import { UserCancelledError } from "../utils/errors"; import { GO_BACK_SYMBOL, isCancel, isGoBack, navigableMultiselect, navigableSelect, setIsFirstPrompt, } from "./navigable"; export async function getFrontendChoice( frontendOptions?: Frontend[], backend?: Backend, auth?: string, ): Promise { if (frontendOptions !== undefined) return frontendOptions; while (true) { const wasFirstPrompt = isFirstPrompt(); const frontendTypes = await navigableMultiselect({ message: "Select project type", options: [ { value: "web", label: "Web", hint: "React, Vue or Svelte Web Application", }, { value: "native", label: "Native", hint: "Create a React Native/Expo app", }, ], required: false, initialValues: ["web"], }); if (isGoBack(frontendTypes)) return GO_BACK_SYMBOL; if (isCancel(frontendTypes)) throw new UserCancelledError({ message: "Operation cancelled" }); setIsFirstPrompt(false); const result: Frontend[] = []; let shouldRestart = false; if (frontendTypes.includes("web")) { const allWebOptions = [ { value: "tanstack-router" as const, label: "TanStack Router", hint: "Modern and scalable routing for React Applications", }, { value: "react-router" as const, label: "React Router", hint: "A user‑obsessed, standards‑focused, multi‑strategy router", }, { value: "next" as const, label: "Next.js", hint: "The React Framework for the Web", }, { value: "nuxt" as const, label: "Nuxt", hint: "The Progressive Web Framework for Vue.js", }, { value: "svelte" as const, label: "Svelte", hint: "web development for the rest of us", }, { value: "solid" as const, label: "Solid", hint: "Simple and performant reactivity for building user interfaces", }, { value: "astro" as const, label: "Astro", hint: "The web framework for content-driven websites", }, { value: "tanstack-start" as const, label: "TanStack Start", hint: "SSR, Server Functions, API Routes and more with TanStack Router", }, ]; const webOptions = allWebOptions.filter((option) => isFrontendAllowedWithBackend(option.value, backend, auth), ); const webFramework = await navigableSelect({ message: "Choose web", options: webOptions, initialValue: DEFAULT_CONFIG.frontend[0], }); if (isGoBack(webFramework)) { shouldRestart = true; } else if (isCancel(webFramework)) { throw new UserCancelledError({ message: "Operation cancelled" }); } else { result.push(webFramework as Frontend); } } if (shouldRestart) { setIsFirstPrompt(wasFirstPrompt); continue; } if (frontendTypes.includes("native")) { const nativeFramework = await navigableSelect({ message: "Choose native", options: [ { value: "native-bare" as const, label: "Bare", hint: "Bare Expo without styling library", }, { value: "native-uniwind" as const, label: "Uniwind", hint: "Fastest Tailwind bindings for React Native with HeroUI Native", }, { value: "native-unistyles" as const, label: "Unistyles", hint: "Consistent styling for React Native", }, ], initialValue: "native-bare", }); if (isGoBack(nativeFramework)) { if (frontendTypes.includes("web")) { shouldRestart = true; } else { setIsFirstPrompt(wasFirstPrompt); continue; } } else if (isCancel(nativeFramework)) { throw new UserCancelledError({ message: "Operation cancelled" }); } else { result.push(nativeFramework as Frontend); } } if (shouldRestart) { setIsFirstPrompt(wasFirstPrompt); continue; } return result; } } ================================================ FILE: apps/cli/src/prompts/git.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableConfirm } from "./navigable"; export async function getGitChoice(git?: boolean) { if (git !== undefined) return git; const response = await navigableConfirm({ message: "Initialize git repository?", initialValue: DEFAULT_CONFIG.git, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/install.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableConfirm } from "./navigable"; export async function getinstallChoice(install?: boolean) { if (install !== undefined) return install; const response = await navigableConfirm({ message: "Install dependencies?", initialValue: DEFAULT_CONFIG.install, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/navigable-group.ts ================================================ /** * Navigable group - a group of prompts that allows going back */ import { didLastPromptShowUI, setIsFirstPrompt, setLastPromptShownUI } from "../utils/context"; import { isGoBack } from "../utils/navigation"; import { isCancel } from "./navigable"; type Prettify = { [P in keyof T]: T[P]; } & {}; export type PromptGroupAwaitedReturn = { [P in keyof T]: Exclude, symbol>; }; export interface NavigablePromptGroupOptions { /** * Control how the group can be canceled * if one of the prompts is canceled. */ onCancel?: (opts: { results: Prettify>> }) => void; } export type NavigablePromptGroup = { [P in keyof T]: (opts: { results: Prettify>>>; }) => undefined | Promise; }; /** * Define a group of prompts that supports going back to previous prompts. * Returns a result object with all the values, or handles cancel/go-back navigation. */ export async function navigableGroup( prompts: NavigablePromptGroup, opts?: NavigablePromptGroupOptions, ): Promise>> { const results = {} as any; const promptNames = Object.keys(prompts) as (keyof T)[]; let currentIndex = 0; let goingBack = false; while (currentIndex < promptNames.length) { const name = promptNames[currentIndex]; const prompt = prompts[name]; setIsFirstPrompt(currentIndex === 0); setLastPromptShownUI(false); const result = await prompt({ results })?.catch((e) => { throw e; }); if (isGoBack(result)) { goingBack = true; if (currentIndex > 0) { const prevName = promptNames[currentIndex - 1]; delete results[prevName]; currentIndex--; continue; } goingBack = false; continue; } if (isCancel(result)) { if (typeof opts?.onCancel === "function") { results[name] = "canceled"; opts.onCancel({ results }); } setIsFirstPrompt(false); return results; } if (goingBack && !didLastPromptShowUI()) { if (currentIndex > 0) { const prevName = promptNames[currentIndex - 1]; delete results[prevName]; currentIndex--; continue; } } goingBack = false; results[name] = result; currentIndex++; } setIsFirstPrompt(false); return results; } ================================================ FILE: apps/cli/src/prompts/navigable.ts ================================================ /** * Navigable prompt wrappers using @clack/core * These prompts return GO_BACK_SYMBOL when 'b' is pressed (instead of canceling) */ import { ConfirmPrompt, type State, GroupMultiSelectPrompt, MultiSelectPrompt, SelectPrompt, TextPrompt, isCancel, } from "@clack/core"; import pc from "picocolors"; import { didLastPromptShowUI as ctxDidLastPromptShowUI, isFirstPrompt as ctxIsFirstPrompt, setIsFirstPrompt as ctxSetIsFirstPrompt, setLastPromptShownUI as ctxSetLastPromptShownUI, } from "../utils/context"; import { GO_BACK_SYMBOL } from "../utils/navigation"; const unicode = process.platform !== "win32"; const S_STEP_ACTIVE = unicode ? "◆" : "*"; const S_STEP_CANCEL = unicode ? "■" : "x"; const S_STEP_ERROR = unicode ? "▲" : "x"; const S_STEP_SUBMIT = unicode ? "◇" : "o"; const S_BAR = unicode ? "│" : "|"; const S_BAR_END = unicode ? "└" : "—"; const S_RADIO_ACTIVE = unicode ? "●" : ">"; const S_RADIO_INACTIVE = unicode ? "○" : " "; const S_CHECKBOX_ACTIVE = unicode ? "◻" : "[•]"; const S_CHECKBOX_SELECTED = unicode ? "◼" : "[+]"; const S_CHECKBOX_INACTIVE = unicode ? "◻" : "[ ]"; function symbol(state: State) { switch (state) { case "initial": case "active": return pc.cyan(S_STEP_ACTIVE); case "cancel": return pc.red(S_STEP_CANCEL); case "error": return pc.yellow(S_STEP_ERROR); case "submit": return pc.green(S_STEP_SUBMIT); } } const KEYBOARD_HINT = pc.dim( `${pc.gray("↑/↓")} navigate • ${pc.gray("enter")} confirm • ${pc.gray("b")} back • ${pc.gray("ctrl+c")} cancel`, ); const KEYBOARD_HINT_FIRST = pc.dim( `${pc.gray("↑/↓")} navigate • ${pc.gray("enter")} confirm • ${pc.gray("ctrl+c")} cancel`, ); const KEYBOARD_HINT_MULTI = pc.dim( `${pc.gray("↑/↓")} navigate • ${pc.gray("space")} select • ${pc.gray("enter")} confirm • ${pc.gray("b")} back • ${pc.gray("ctrl+c")} cancel`, ); const KEYBOARD_HINT_MULTI_FIRST = pc.dim( `${pc.gray("↑/↓")} navigate • ${pc.gray("space")} select • ${pc.gray("enter")} confirm • ${pc.gray("ctrl+c")} cancel`, ); export const setIsFirstPrompt = ctxSetIsFirstPrompt; export const setLastPromptShownUI = ctxSetLastPromptShownUI; export const didLastPromptShowUI = ctxDidLastPromptShowUI; function getHint(): string { return ctxIsFirstPrompt() ? KEYBOARD_HINT_FIRST : KEYBOARD_HINT; } function getMultiHint(): string { return ctxIsFirstPrompt() ? KEYBOARD_HINT_MULTI_FIRST : KEYBOARD_HINT_MULTI; } function normalizeValidationMessage( validationMessage: string | Error | undefined, ): string | undefined { return validationMessage instanceof Error ? validationMessage.message : validationMessage; } async function runWithNavigation(prompt: any): Promise { let goBack = false; prompt.on("key", (char: string | undefined) => { if (char === "b" && !ctxIsFirstPrompt()) { goBack = true; prompt.state = "cancel"; } }); ctxSetLastPromptShownUI(true); const result = await prompt.prompt(); return goBack ? GO_BACK_SYMBOL : result; } interface SelectOption { value: T; label?: string; hint?: string; disabled?: boolean; } export interface NavigableSelectOptions { message: string; options: SelectOption[]; initialValue?: T; } export async function navigableSelect(opts: NavigableSelectOptions): Promise { const opt = ( option: SelectOption, state: "inactive" | "active" | "selected" | "cancelled" | "disabled", ) => { const label = option.label ?? String(option.value); switch (state) { case "disabled": return `${pc.gray(S_RADIO_INACTIVE)} ${pc.gray(label)}${option.hint ? ` ${pc.dim(`(${option.hint ?? "disabled"})`)}` : ""}`; case "selected": return `${pc.dim(label)}`; case "active": return `${pc.green(S_RADIO_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; case "cancelled": return `${pc.strikethrough(pc.dim(label))}`; default: return `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(label)}`; } }; const prompt = new SelectPrompt({ options: opts.options, initialValue: opts.initialValue, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; switch (this.state) { case "submit": { return `${title}${pc.gray(S_BAR)} ${opt(this.options[this.cursor], "selected")}`; } case "cancel": { return `${title}${pc.gray(S_BAR)} ${opt(this.options[this.cursor], "cancelled")}\n${pc.gray(S_BAR)}`; } default: { const optionsText = this.options .map((option, i) => opt(option, option.disabled ? "disabled" : i === this.cursor ? "active" : "inactive"), ) .join(`\n${pc.cyan(S_BAR)} `); const hint = `\n${pc.gray(S_BAR)} ${getHint()}`; return `${title}${pc.cyan(S_BAR)} ${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`; } } }, }); return runWithNavigation(prompt) as Promise; } export interface NavigableMultiselectOptions { message: string; options: SelectOption[]; initialValues?: T[]; required?: boolean; validate?: (selected: T[] | undefined) => string | Error | undefined; } export async function navigableMultiselect( opts: NavigableMultiselectOptions, ): Promise { const required = opts.required ?? true; const opt = ( option: SelectOption, state: | "inactive" | "active" | "selected" | "active-selected" | "submitted" | "cancelled" | "disabled", ) => { const label = option.label ?? String(option.value); if (state === "disabled") { return `${pc.gray(S_CHECKBOX_INACTIVE)} ${pc.strikethrough(pc.gray(label))}${option.hint ? ` ${pc.dim(`(${option.hint ?? "disabled"})`)}` : ""}`; } if (state === "active") { return `${pc.cyan(S_CHECKBOX_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; } if (state === "selected") { return `${pc.green(S_CHECKBOX_SELECTED)} ${pc.dim(label)}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; } if (state === "cancelled") { return `${pc.strikethrough(pc.dim(label))}`; } if (state === "active-selected") { return `${pc.green(S_CHECKBOX_SELECTED)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; } if (state === "submitted") { return `${pc.dim(label)}`; } return `${pc.dim(S_CHECKBOX_INACTIVE)} ${pc.dim(label)}`; }; const prompt = new MultiSelectPrompt({ options: opts.options, initialValues: opts.initialValues, required, validate(selected: T[] | undefined) { if (required && (selected === undefined || selected.length === 0)) { return `Please select at least one option.\n${pc.reset(pc.dim(`Press ${pc.gray(pc.bgWhite(pc.inverse(" space ")))} to select, ${pc.gray(pc.bgWhite(pc.inverse(" enter ")))} to submit`))}`; } return normalizeValidationMessage(opts.validate?.(selected)); }, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const value = this.value ?? []; const styleOption = (option: SelectOption, active: boolean) => { if (option.disabled) { return opt(option, "disabled"); } const selected = value.includes(option.value); if (active && selected) { return opt(option, "active-selected"); } if (selected) { return opt(option, "selected"); } return opt(option, active ? "active" : "inactive"); }; switch (this.state) { case "submit": { const submitText = this.options .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, "submitted")) .join(pc.dim(", ")) || pc.dim("none"); return `${title}${pc.gray(S_BAR)} ${submitText}`; } case "cancel": { const label = this.options .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, "cancelled")) .join(pc.dim(", ")); return `${title}${pc.gray(S_BAR)} ${label}\n${pc.gray(S_BAR)}`; } case "error": { const footer = this.error .split("\n") .map((ln, i) => (i === 0 ? `${pc.yellow(S_BAR_END)} ${pc.yellow(ln)}` : ` ${ln}`)) .join("\n"); const optionsText = this.options .map((option, i) => styleOption(option, i === this.cursor)) .join(`\n${pc.yellow(S_BAR)} `); return `${title}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`; } default: { const optionsText = this.options .map((option, i) => styleOption(option, i === this.cursor)) .join(`\n${pc.cyan(S_BAR)} `); const hint = `\n${pc.gray(S_BAR)} ${getMultiHint()}`; return `${title}${pc.cyan(S_BAR)} ${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`; } } }, }); return runWithNavigation(prompt) as Promise; } export interface NavigableConfirmOptions { message: string; active?: string; inactive?: string; initialValue?: boolean; } export async function navigableConfirm(opts: NavigableConfirmOptions): Promise { const active = opts.active ?? "Yes"; const inactive = opts.inactive ?? "No"; const prompt = new ConfirmPrompt({ active, inactive, initialValue: opts.initialValue ?? true, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const value = this.value ? active : inactive; switch (this.state) { case "submit": return `${title}${pc.gray(S_BAR)} ${pc.dim(value)}`; case "cancel": return `${title}${pc.gray(S_BAR)} ${pc.strikethrough(pc.dim(value))}\n${pc.gray(S_BAR)}`; default: { const hint = `\n${pc.gray(S_BAR)} ${getHint()}`; return `${title}${pc.cyan(S_BAR)} ${ this.value ? `${pc.green(S_RADIO_ACTIVE)} ${active}` : `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(active)}` } ${pc.dim("/")} ${ !this.value ? `${pc.green(S_RADIO_ACTIVE)} ${inactive}` : `${pc.dim(S_RADIO_INACTIVE)} ${pc.dim(inactive)}` }\n${pc.cyan(S_BAR_END)}${hint}\n`; } } }, }); return runWithNavigation(prompt) as Promise; } export interface NavigableTextOptions { message: string; placeholder?: string; defaultValue?: string; initialValue?: string; validate?: (value: string | undefined) => string | Error | undefined; } export async function navigableText(opts: NavigableTextOptions): Promise { const prompt = new TextPrompt({ validate: opts.validate, placeholder: opts.placeholder, defaultValue: opts.defaultValue, initialValue: opts.initialValue, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const placeholder = opts.placeholder ? pc.inverse(opts.placeholder[0]) + pc.dim(opts.placeholder.slice(1)) : pc.inverse(pc.hidden("_")); // biome-ignore lint/suspicious/noExplicitAny: TextPrompt has userInput but types don't expose it const self = this as any; const userInput = !self.userInput ? placeholder : self.userInputWithCursor; const value = this.value ?? ""; switch (this.state) { case "error": { const errorText = this.error ? ` ${pc.yellow(this.error)}` : ""; return `${title.trim()}\n${pc.yellow(S_BAR)} ${userInput}\n${pc.yellow(S_BAR_END)}${errorText}\n`; } case "submit": { const valueText = value ? ` ${pc.dim(value)}` : ""; return `${title}${pc.gray(S_BAR)}${valueText}`; } case "cancel": { const valueText = value ? ` ${pc.strikethrough(pc.dim(value))}` : ""; return `${title}${pc.gray(S_BAR)}${valueText}${value.trim() ? `\n${pc.gray(S_BAR)}` : ""}`; } default: { const hint = `\n${pc.gray(S_BAR)} ${getHint()}`; return `${title}${pc.cyan(S_BAR)} ${userInput}\n${pc.cyan(S_BAR_END)}${hint}\n`; } } }, }); return runWithNavigation(prompt) as Promise; } export interface GroupMultiSelectOption { value: T; label?: string; hint?: string; disabled?: boolean; } export interface NavigableGroupMultiselectOptions { message: string; options: Record[]>; initialValues?: T[]; required?: boolean; validate?: (selected: T[] | undefined) => string | Error | undefined; } export async function navigableGroupMultiselect( opts: NavigableGroupMultiselectOptions, ): Promise { const required = opts.required ?? true; const opt = ( option: GroupMultiSelectOption & { group: string | boolean }, state: | "inactive" | "active" | "selected" | "active-selected" | "group-active" | "group-active-selected" | "submitted" | "cancelled", options: (GroupMultiSelectOption & { group: string | boolean })[] = [], ) => { const label = option.label ?? String(option.value); const isItem = typeof option.group === "string"; const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); const isLast = isItem && next && next.group === true; const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ""; if (state === "active") { return `${pc.dim(prefix)}${pc.cyan(S_CHECKBOX_ACTIVE)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; } if (state === "group-active") { return `${prefix}${pc.cyan(S_CHECKBOX_ACTIVE)} ${pc.dim(label)}`; } if (state === "group-active-selected") { return `${prefix}${pc.green(S_CHECKBOX_SELECTED)} ${pc.dim(label)}`; } if (state === "selected") { const selectedCheckbox = isItem ? pc.green(S_CHECKBOX_SELECTED) : ""; return `${pc.dim(prefix)}${selectedCheckbox} ${pc.dim(label)}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; } if (state === "cancelled") { return `${pc.strikethrough(pc.dim(label))}`; } if (state === "active-selected") { return `${pc.dim(prefix)}${pc.green(S_CHECKBOX_SELECTED)} ${label}${option.hint ? ` ${pc.dim(`(${option.hint})`)}` : ""}`; } if (state === "submitted") { return `${pc.dim(label)}`; } const unselectedCheckbox = isItem ? pc.dim(S_CHECKBOX_INACTIVE) : ""; return `${pc.dim(prefix)}${unselectedCheckbox} ${pc.dim(label)}`; }; const prompt = new GroupMultiSelectPrompt>({ options: opts.options, initialValues: opts.initialValues, required, selectableGroups: true, validate(selected: T[] | undefined) { if (required && (selected === undefined || selected.length === 0)) { return `Please select at least one option.\n${pc.reset(pc.dim(`Press ${pc.gray(pc.bgWhite(pc.inverse(" space ")))} to select, ${pc.gray(pc.bgWhite(pc.inverse(" enter ")))} to submit`))}`; } return normalizeValidationMessage(opts.validate?.(selected)); }, render() { const title = `${pc.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; const value = this.value ?? []; switch (this.state) { case "submit": { const selectedOptions = this.options .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, "submitted")); const optionsText = selectedOptions.length === 0 ? "" : ` ${selectedOptions.join(pc.dim(", "))}`; return `${title}${pc.gray(S_BAR)}${optionsText}`; } case "cancel": { const label = this.options .filter(({ value: optionValue }) => value.includes(optionValue)) .map((option) => opt(option, "cancelled")) .join(pc.dim(", ")); return `${title}${pc.gray(S_BAR)} ${label.trim() ? `${label}\n${pc.gray(S_BAR)}` : ""}`; } case "error": { const footer = this.error .split("\n") .map((ln, i) => (i === 0 ? `${pc.yellow(S_BAR_END)} ${pc.yellow(ln)}` : ` ${ln}`)) .join("\n"); const optionsText = this.options .map((option, i, options) => { const selected = value.includes(option.value) || (option.group === true && this.isGroupSelected(`${option.value}`)); const active = i === this.cursor; const groupActive = !active && typeof option.group === "string" && this.options[this.cursor].value === option.group; if (groupActive) { return opt(option, selected ? "group-active-selected" : "group-active", options); } if (active && selected) { return opt(option, "active-selected", options); } if (selected) { return opt(option, "selected", options); } return opt(option, active ? "active" : "inactive", options); }) .join(`\n${pc.yellow(S_BAR)} `); return `${title}${pc.yellow(S_BAR)} ${optionsText}\n${footer}\n`; } default: { const optionsText = this.options .map((option, i, options) => { const selected = value.includes(option.value) || (option.group === true && this.isGroupSelected(`${option.value}`)); const active = i === this.cursor; const groupActive = !active && typeof option.group === "string" && this.options[this.cursor].value === option.group; let optionText = ""; if (groupActive) { optionText = opt( option, selected ? "group-active-selected" : "group-active", options, ); } else if (active && selected) { optionText = opt(option, "active-selected", options); } else if (selected) { optionText = opt(option, "selected", options); } else { optionText = opt(option, active ? "active" : "inactive", options); } const optPrefix = i !== 0 && !optionText.startsWith("\n") ? " " : ""; return `${optPrefix}${optionText}`; }) .join(`\n${pc.cyan(S_BAR)}`); const optionsPrefix = optionsText.startsWith("\n") ? "" : " "; const hint = `\n${pc.gray(S_BAR)} ${getMultiHint()}`; return `${title}${pc.cyan(S_BAR)}${optionsPrefix}${optionsText}\n${pc.cyan(S_BAR_END)}${hint}\n`; } } }, }); return runWithNavigation(prompt) as Promise; } export { isCancel }; export { isGoBack, GO_BACK_SYMBOL } from "../utils/navigation"; ================================================ FILE: apps/cli/src/prompts/orm.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Database, ORM, Runtime } from "../types"; import { validateOrmDatabaseCompat } from "../utils/config-validation"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; const ormOptions = { prisma: { value: "prisma" as const, label: "Prisma", hint: "Powerful, feature-rich ORM", }, mongoose: { value: "mongoose" as const, label: "Mongoose", hint: "Elegant object modeling tool", }, drizzle: { value: "drizzle" as const, label: "Drizzle", hint: "Lightweight and performant TypeScript ORM", }, }; export async function getORMChoice( orm: ORM | undefined, hasDatabase: boolean, database?: Database, backend?: Backend, runtime?: Runtime, ) { if (backend === "convex") { return "none"; } if (!hasDatabase) return "none"; if (orm !== undefined) { const compat = validateOrmDatabaseCompat(orm, database); if (compat.isErr()) throw compat.error; return orm; } const options = database === "mongodb" ? [ormOptions.prisma, ormOptions.mongoose] : [ormOptions.drizzle, ormOptions.prisma]; const response = await navigableSelect({ message: "Select ORM", options, initialValue: database === "mongodb" ? "prisma" : runtime === "workers" ? "drizzle" : DEFAULT_CONFIG.orm, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/package-manager.ts ================================================ import type { PackageManager } from "../types"; import { UserCancelledError } from "../utils/errors"; import { getUserPkgManager } from "../utils/get-package-manager"; import { isCancel, navigableSelect } from "./navigable"; export async function getPackageManagerChoice(packageManager?: PackageManager) { if (packageManager !== undefined) return packageManager; const detectedPackageManager = getUserPkgManager(); const response = await navigableSelect({ message: "Choose package manager", options: [ { value: "npm", label: "npm", hint: "not recommended" }, { value: "pnpm", label: "pnpm", hint: "Fast, disk space efficient package manager", }, { value: "bun", label: "bun", hint: "All-in-one JavaScript runtime & toolkit", }, ], initialValue: detectedPackageManager, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/payments.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Auth, Backend, Frontend, Payments } from "../types"; import { splitFrontends } from "../utils/compatibility-rules"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; export async function getPaymentsChoice( payments?: Payments, auth?: Auth, backend?: Backend, frontends?: Frontend[], ) { if (payments !== undefined) return payments; if (backend === "none") { return "none" as Payments; } const isPolarCompatible = auth === "better-auth" && backend !== "convex" && (frontends?.length === 0 || splitFrontends(frontends).web.length > 0); if (!isPolarCompatible) { return "none" as Payments; } const options = [ { value: "polar" as Payments, label: "Polar", hint: "Turn your software into a business. 6 lines of code.", }, { value: "none" as Payments, label: "None", hint: "No payments integration", }, ]; const response = await navigableSelect({ message: "Select payments provider", options, initialValue: DEFAULT_CONFIG.payments, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/project-name.ts ================================================ import path from "node:path"; import { isCancel, text } from "@clack/prompts"; import fs from "fs-extra"; import pc from "picocolors"; import { DEFAULT_CONFIG } from "../constants"; import { ProjectNameSchema } from "../types"; import { UserCancelledError } from "../utils/errors"; import { cliConsola } from "../utils/terminal-output"; function isPathWithinCwd(targetPath: string) { const resolved = path.resolve(targetPath); const rel = path.relative(process.cwd(), resolved); return !rel.startsWith("..") && !path.isAbsolute(rel); } function validateDirectoryName(name: string) { if (name === ".") return undefined; const result = ProjectNameSchema.safeParse(name); if (!result.success) { return result.error.issues[0]?.message || "Invalid project name"; } return undefined; } export async function getProjectName(initialName?: string): Promise { if (initialName) { if (initialName === ".") { return initialName; } const finalDirName = path.basename(initialName); const validationError = validateDirectoryName(finalDirName); if (!validationError) { const projectDir = path.resolve(process.cwd(), initialName); if (isPathWithinCwd(projectDir)) { return initialName; } cliConsola.error(pc.red("Project path must be within current directory")); } } let isValid = false; let projectPath = ""; let defaultName: string = DEFAULT_CONFIG.projectName; let counter = 1; while ( (await fs.pathExists(path.resolve(process.cwd(), defaultName))) && (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0 ) { defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`; counter++; } while (!isValid) { const response = await text({ message: "Enter your project name or path (relative to current directory)", placeholder: defaultName, initialValue: initialName, defaultValue: defaultName, validate: (value) => { const nameToUse = String(value ?? "").trim() || defaultName; const finalDirName = path.basename(nameToUse); const validationError = validateDirectoryName(finalDirName); if (validationError) return validationError; if (nameToUse !== ".") { const projectDir = path.resolve(process.cwd(), nameToUse); if (!isPathWithinCwd(projectDir)) { return "Project path must be within current directory"; } } return undefined; }, }); if (isCancel(response)) { throw new UserCancelledError({ message: "Operation cancelled." }); } projectPath = response || defaultName; isValid = true; } return projectPath; } ================================================ FILE: apps/cli/src/prompts/runtime.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Runtime } from "../types"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; export async function getRuntimeChoice(runtime?: Runtime, backend?: Backend) { if (backend === "convex" || backend === "none" || backend === "self") { return "none"; } if (runtime !== undefined) return runtime; const runtimeOptions: Array<{ value: Runtime; label: string; hint: string; }> = [ { value: "bun", label: "Bun", hint: "Fast all-in-one JavaScript runtime", }, { value: "node", label: "Node.js", hint: "Traditional Node.js runtime", }, ]; if (backend === "hono") { runtimeOptions.push({ value: "workers", label: "Cloudflare Workers", hint: "Edge runtime on Cloudflare's global network", }); } const response = await navigableSelect({ message: "Select runtime", options: runtimeOptions, initialValue: DEFAULT_CONFIG.runtime, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/server-deploy.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Backend, Runtime, ServerDeploy, WebDeploy } from "../types"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; type DeploymentOption = { value: ServerDeploy; label: string; hint: string; }; function getDeploymentDisplay(deployment: ServerDeploy): { label: string; hint: string; } { if (deployment === "cloudflare") { return { label: "Cloudflare", hint: "Deploy to Cloudflare Workers using Alchemy", }; } return { label: deployment, hint: `Add ${deployment} deployment`, }; } export async function getServerDeploymentChoice( deployment?: ServerDeploy, runtime?: Runtime, backend?: Backend, _webDeploy?: WebDeploy, ) { if (deployment !== undefined) return deployment; if (backend === "none" || backend === "convex") { return "none"; } if (backend !== "hono") { return "none"; } // Auto-select cloudflare for workers runtime since it's the only valid option if (runtime === "workers") { return "cloudflare"; } return "none"; } export async function getServerDeploymentToAdd( runtime?: Runtime, existingDeployment?: ServerDeploy, backend?: Backend, ) { if (backend !== "hono") { return "none"; } const options: DeploymentOption[] = []; if (runtime === "workers") { if (existingDeployment !== "cloudflare") { const { label, hint } = getDeploymentDisplay("cloudflare"); options.push({ value: "cloudflare", label, hint, }); } } if (existingDeployment && existingDeployment !== "none") { return "none"; } if (options.length === 0) { return "none"; } const response = await navigableSelect({ message: "Select server deployment", options, initialValue: DEFAULT_CONFIG.serverDeploy, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/prompts/web-deploy.ts ================================================ import { DEFAULT_CONFIG } from "../constants"; import type { Backend, DatabaseSetup, Frontend, Runtime, WebDeploy } from "../types"; import { WEB_FRAMEWORKS } from "../utils/compatibility"; import { UserCancelledError } from "../utils/errors"; import { isCancel, navigableSelect } from "./navigable"; function hasWebFrontend(frontends: Frontend[]) { return frontends.some((f) => WEB_FRAMEWORKS.includes(f)); } type DeploymentOption = { value: WebDeploy; label: string; hint: string; }; function getDeploymentDisplay(deployment: WebDeploy): { label: string; hint: string; } { if (deployment === "cloudflare") { return { label: "Cloudflare", hint: "Deploy to Cloudflare Workers using Alchemy", }; } return { label: deployment, hint: `Add ${deployment} deployment`, }; } export async function getDeploymentChoice( deployment?: WebDeploy, _runtime?: Runtime, backend?: Backend, frontend: Frontend[] = [], dbSetup?: DatabaseSetup, ) { if (deployment !== undefined) return deployment; if (!hasWebFrontend(frontend)) { return "none"; } if (backend === "self" && dbSetup === "d1") { return "cloudflare"; } const availableDeployments = ["cloudflare", "none"]; const options: DeploymentOption[] = availableDeployments.map((deploy) => { const { label, hint } = getDeploymentDisplay(deploy as WebDeploy); return { value: deploy as WebDeploy, label, hint, }; }); const response = await navigableSelect({ message: "Select web deployment", options, initialValue: DEFAULT_CONFIG.webDeploy, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } export async function getDeploymentToAdd(frontend: Frontend[], existingDeployment?: WebDeploy) { if (!hasWebFrontend(frontend)) { return "none"; } const options: DeploymentOption[] = []; if (existingDeployment !== "cloudflare") { const { label, hint } = getDeploymentDisplay("cloudflare"); options.push({ value: "cloudflare", label, hint, }); } if (existingDeployment && existingDeployment !== "none") { return "none"; } if (options.length > 0) { options.push({ value: "none", label: "None", hint: "Skip deployment setup", }); } if (options.length === 0) { return "none"; } const response = await navigableSelect({ message: "Select web deployment", options, initialValue: DEFAULT_CONFIG.webDeploy, }); if (isCancel(response)) throw new UserCancelledError({ message: "Operation cancelled" }); return response; } ================================================ FILE: apps/cli/src/types.ts ================================================ export * from "@better-t-stack/types"; ================================================ FILE: apps/cli/src/utils/add-package-deps.ts ================================================ import path from "node:path"; import fs from "fs-extra"; import { type AvailableDependencies, dependencyVersionMap } from "../constants"; export const addPackageDependency = async (opts: { dependencies?: AvailableDependencies[]; devDependencies?: AvailableDependencies[]; customDependencies?: Record; customDevDependencies?: Record; projectDir: string; }) => { const { dependencies = [], devDependencies = [], customDependencies = {}, customDevDependencies = {}, projectDir, } = opts; const pkgJsonPath = path.join(projectDir, "package.json"); const pkgJson = await fs.readJson(pkgJsonPath); if (!pkgJson.dependencies) pkgJson.dependencies = {}; if (!pkgJson.devDependencies) pkgJson.devDependencies = {}; for (const pkgName of dependencies) { const version = dependencyVersionMap[pkgName]; if (version) { pkgJson.dependencies[pkgName] = version; } else { console.warn(`Warning: Dependency ${pkgName} not found in version map.`); } } for (const pkgName of devDependencies) { const version = dependencyVersionMap[pkgName]; if (version) { pkgJson.devDependencies[pkgName] = version; } else { console.warn(`Warning: Dev dependency ${pkgName} not found in version map.`); } } for (const [pkgName, version] of Object.entries(customDependencies)) { pkgJson.dependencies[pkgName] = version; } for (const [pkgName, version] of Object.entries(customDevDependencies)) { pkgJson.devDependencies[pkgName] = version; } await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2, }); }; ================================================ FILE: apps/cli/src/utils/analytics.ts ================================================ import { Result } from "better-result"; import type { ProjectConfig } from "../types"; import { getLatestCLIVersion } from "./get-latest-cli-version"; import { isTelemetryEnabled } from "./telemetry"; const CONVEX_INGEST_URL = process.env.CONVEX_INGEST_URL; async function sendConvexEvent(payload: Record): Promise { if (!CONVEX_INGEST_URL) return; await Result.tryPromise({ try: () => fetch(CONVEX_INGEST_URL, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }), catch: () => undefined, // Silent failure }); } export async function trackProjectCreation( config: ProjectConfig, disableAnalytics = false, ): Promise { if (!isTelemetryEnabled() || disableAnalytics) return; const { projectName: _projectName, projectDir: _projectDir, relativePath: _relativePath, ...safeConfig } = config; await Result.tryPromise({ try: () => sendConvexEvent({ ...safeConfig, cli_version: getLatestCLIVersion(), node_version: typeof process !== "undefined" ? process.version : "", platform: typeof process !== "undefined" ? process.platform : "", }), catch: () => undefined, // Silent failure }); } ================================================ FILE: apps/cli/src/utils/bts-config.ts ================================================ import path from "node:path"; import type { BetterTStackConfig } from "@better-t-stack/types"; import fs from "fs-extra"; import { applyEdits, modify, parse } from "jsonc-parser"; const BTS_CONFIG_FILE = "bts.jsonc"; /** * Reads the BTS configuration file from the project directory. */ export async function readBtsConfig(projectDir: string): Promise { try { const configPath = path.join(projectDir, BTS_CONFIG_FILE); if (!(await fs.pathExists(configPath))) { return null; } const configContent = await fs.readFile(configPath, "utf-8"); const config = parse(configContent) as BetterTStackConfig; return config; } catch { return null; } } /** * Updates specific fields in the BTS configuration file. */ export async function updateBtsConfig( projectDir: string, updates: Partial< Pick< BetterTStackConfig, "addons" | "addonOptions" | "dbSetupOptions" | "webDeploy" | "serverDeploy" > >, ): Promise { try { const configPath = path.join(projectDir, BTS_CONFIG_FILE); if (!(await fs.pathExists(configPath))) { return; } let content = await fs.readFile(configPath, "utf-8"); // Apply each update using jsonc-parser's modify (preserves comments) for (const [key, value] of Object.entries(updates)) { const edits = modify(content, [key], value, { formattingOptions: { tabSize: 2 } }); content = applyEdits(content, edits); } await fs.writeFile(configPath, content, "utf-8"); } catch { // Silent failure } } ================================================ FILE: apps/cli/src/utils/command-exists.ts ================================================ import { Result } from "better-result"; import { $ } from "execa"; export async function commandExists(command: string): Promise { const result = await Result.tryPromise({ try: async () => { const isWindows = process.platform === "win32"; if (isWindows) { const execResult = await $({ reject: false })`where ${command}`; return execResult.exitCode === 0; } const execResult = await $({ reject: false })`which ${command}`; return execResult.exitCode === 0; }, catch: () => false, }); return result.isOk() ? result.value : false; } ================================================ FILE: apps/cli/src/utils/compatibility-rules.ts ================================================ import { Result } from "better-result"; import { ADDON_COMPATIBILITY } from "../constants"; import type { Addons, API, Auth, Backend, CLIInput, Frontend, Payments, ProjectConfig, Runtime, ServerDeploy, WebDeploy, } from "../types"; import { WEB_FRAMEWORKS } from "./compatibility"; import { ValidationError } from "./errors"; type ValidationResult = Result; type AddonCompatibilityConfig = Pick; export const CONVEX_BETTER_AUTH_INCOMPATIBLE_FRONTENDS = [ "nuxt", "svelte", "solid", "astro", ] as const; export const CONVEX_BETTER_AUTH_SUPPORTED_FRONTENDS = [ "tanstack-router", "react-router", "tanstack-start", "next", "native-bare", "native-uniwind", "native-unistyles", ] as const; function validationErr(message: string): ValidationResult { return Result.err(new ValidationError({ message })); } export function isWebFrontend(value: Frontend) { return WEB_FRAMEWORKS.includes(value); } export function splitFrontends(values: Frontend[] = []): { web: Frontend[]; native: Frontend[]; } { const web = values.filter((f) => isWebFrontend(f)); const native = values.filter( (f) => f === "native-bare" || f === "native-uniwind" || f === "native-unistyles", ); return { web, native }; } export function ensureSingleWebAndNative(frontends: Frontend[]): ValidationResult { const { web, native } = splitFrontends(frontends); if (web.length > 1) { return validationErr( "Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid, astro", ); } if (native.length > 1) { return validationErr( "Cannot select multiple native frameworks. Choose only one of: native-bare, native-uniwind, native-unistyles", ); } return Result.ok(undefined); } // Frontends that support backend="self" (fullstack mode with built-in server routes) const FULLSTACK_FRONTENDS: readonly Frontend[] = [ "next", "tanstack-start", "nuxt", "svelte", "astro", ] as const; const EVLOG_SERVER_BACKENDS: readonly Backend[] = ["hono", "express", "fastify", "elysia"]; const EVLOG_FULLSTACK_FRONTENDS: readonly Frontend[] = FULLSTACK_FRONTENDS; const evlogCompatibilityMessage = "evlog addon supports Hono, Express, Fastify, Elysia, or backend self with Next.js, TanStack Start, Nuxt, SvelteKit, or Astro. Convex and backend none are not supported yet."; export function supportsEvlogAddon( frontend: Frontend[] = [], backend?: Backend, _runtime?: Runtime, ) { if (!backend) return true; if (EVLOG_SERVER_BACKENDS.includes(backend)) { return true; } if (backend === "self") { if (frontend.length === 0) return true; return frontend.some((f) => EVLOG_FULLSTACK_FRONTENDS.includes(f)); } return false; } export function validateSelfBackendCompatibility( providedFlags: Set, options: CLIInput, config: Partial, ): ValidationResult { const backend = config.backend || options.backend; const frontends = config.frontend || options.frontend || []; if (backend === "self") { const { web, native } = splitFrontends(frontends); const hasSupportedWeb = web.length === 1 && FULLSTACK_FRONTENDS.includes(web[0]); if (!hasSupportedWeb) { return validationErr( "Backend 'self' (fullstack) currently only supports Next.js, TanStack Start, Nuxt, SvelteKit, and Astro frontends. Please use --frontend next, --frontend tanstack-start, --frontend nuxt, --frontend svelte, or --frontend astro.", ); } if (native.length > 1) { return validationErr( "Cannot select multiple native frameworks. Choose only one of: native-bare, native-uniwind, native-unistyles", ); } } const hasFullstackFrontend = frontends.some((f) => FULLSTACK_FRONTENDS.includes(f)); if (providedFlags.has("backend") && !hasFullstackFrontend && backend === "self") { return validationErr( "Backend 'self' (fullstack) currently only supports Next.js, TanStack Start, Nuxt, SvelteKit, and Astro frontends. Please use --frontend next, --frontend tanstack-start, --frontend nuxt, --frontend svelte, --frontend astro, or choose a different backend.", ); } return Result.ok(undefined); } export function validateWorkersCompatibility( providedFlags: Set, options: CLIInput, config: Partial, ): ValidationResult { if ( providedFlags.has("runtime") && options.runtime === "workers" && config.backend && config.backend !== "hono" ) { return validationErr( `Cloudflare Workers runtime (--runtime workers) is only supported with Hono backend (--backend hono). Current backend: ${config.backend}. Please use '--backend hono' or choose a different runtime.`, ); } if ( providedFlags.has("backend") && config.backend && config.backend !== "hono" && config.runtime === "workers" ) { return validationErr( `Backend '${config.backend}' is not compatible with Cloudflare Workers runtime. Cloudflare Workers runtime is only supported with Hono backend. Please use '--backend hono' or choose a different runtime.`, ); } if ( providedFlags.has("runtime") && options.runtime === "workers" && config.database === "mongodb" ) { return validationErr( "Cloudflare Workers runtime (--runtime workers) is not compatible with MongoDB database. MongoDB requires Prisma or Mongoose ORM, but Workers runtime only supports Drizzle or Prisma ORM. Please use a different database or runtime.", ); } if ( providedFlags.has("database") && config.database === "mongodb" && config.runtime === "workers" ) { return validationErr( "MongoDB database is not compatible with Cloudflare Workers runtime. MongoDB requires Prisma or Mongoose ORM, but Workers runtime only supports Drizzle or Prisma ORM. Please use a different database or runtime.", ); } return Result.ok(undefined); } export function validateApiFrontendCompatibility( api: API | undefined, frontends: Frontend[] = [], ): ValidationResult { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); const includesSolid = frontends.includes("solid"); const includesAstro = frontends.includes("astro"); if ((includesNuxt || includesSvelte || includesSolid || includesAstro) && api === "trpc") { return validationErr( `tRPC API is not supported with '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : includesSolid ? "solid" : "astro"}' frontend. Please use --api orpc or --api none or remove '${includesNuxt ? "nuxt" : includesSvelte ? "svelte" : includesSolid ? "solid" : "astro"}' from --frontend.`, ); } return Result.ok(undefined); } export function isFrontendAllowedWithBackend( frontend: Frontend, backend?: ProjectConfig["backend"], auth?: string, ) { if (backend === "convex") { if ( auth === "better-auth" && CONVEX_BETTER_AUTH_INCOMPATIBLE_FRONTENDS.includes( frontend as (typeof CONVEX_BETTER_AUTH_INCOMPATIBLE_FRONTENDS)[number], ) ) { return false; } if (frontend === "solid" || frontend === "astro") return false; } if (auth === "clerk") { const incompatibleFrontends = ["nuxt", "svelte", "solid", "astro"]; if (incompatibleFrontends.includes(frontend)) return false; } return true; } export function supportsConvexBetterAuth(frontends: readonly Frontend[] = []) { return frontends.some((frontend) => CONVEX_BETTER_AUTH_SUPPORTED_FRONTENDS.includes( frontend as (typeof CONVEX_BETTER_AUTH_SUPPORTED_FRONTENDS)[number], ), ); } export function allowedApisForFrontends(frontends: Frontend[] = []) { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); const includesSolid = frontends.includes("solid"); const includesAstro = frontends.includes("astro"); const base: API[] = ["trpc", "orpc", "none"]; if (includesNuxt || includesSvelte || includesSolid || includesAstro) { return ["orpc", "none"]; } return base; } export function isExampleTodoAllowed( backend?: ProjectConfig["backend"], database?: ProjectConfig["database"], api?: API, ) { // Convex handles its own data layer, no need for database or API if (backend === "convex") return true; // Todo requires both database and API to communicate if (database === "none" || api === "none") return false; return true; } export function isExampleAIAllowed(backend?: ProjectConfig["backend"], frontends: Frontend[] = []) { const includesSolid = frontends.includes("solid"); const includesAstro = frontends.includes("astro"); if (includesSolid || includesAstro) return false; // Convex AI example only supports React-based frontends (not Svelte or Nuxt) if (backend === "convex") { const includesNuxt = frontends.includes("nuxt"); const includesSvelte = frontends.includes("svelte"); if (includesNuxt || includesSvelte) return false; } return true; } export function validateWebDeployRequiresWebFrontend( webDeploy: WebDeploy | undefined, hasWebFrontendFlag: boolean, ): ValidationResult { if (webDeploy && webDeploy !== "none" && !hasWebFrontendFlag) { return validationErr( "'--web-deploy' requires a web frontend. Please select a web frontend or set '--web-deploy none'.", ); } return Result.ok(undefined); } export function validateServerDeployRequiresBackend( serverDeploy: ServerDeploy | undefined, backend: Backend | undefined, ): ValidationResult { if (serverDeploy && serverDeploy !== "none" && (!backend || backend === "none")) { return validationErr( "'--server-deploy' requires a backend. Please select a backend or set '--server-deploy none'.", ); } return Result.ok(undefined); } export function validateAddonCompatibility( addon: Addons, frontend: Frontend[], _auth?: Auth, backend?: Backend, runtime?: Runtime, ): { isCompatible: boolean; reason?: string } { if (addon === "evlog" && !supportsEvlogAddon(frontend, backend, runtime)) { return { isCompatible: false, reason: evlogCompatibilityMessage, }; } const compatibleFrontends = ADDON_COMPATIBILITY[addon]; if (compatibleFrontends.length > 0) { const hasCompatibleFrontend = frontend.some((f) => (compatibleFrontends as readonly string[]).includes(f), ); if (!hasCompatibleFrontend) { const frontendList = compatibleFrontends.join(", "); return { isCompatible: false, reason: `${addon} addon requires one of these frontends: ${frontendList}`, }; } } return { isCompatible: true }; } export function getCompatibleAddons( allAddons: Addons[], frontend: Frontend[], existingAddons: Addons[] = [], auth?: Auth, backend?: Backend, runtime?: Runtime, ) { return allAddons.filter((addon) => { if (existingAddons.includes(addon)) return false; if (addon === "none") return false; const { isCompatible } = validateAddonCompatibility(addon, frontend, auth, backend, runtime); return isCompatible; }); } export function validateAddonsAgainstFrontends( addons: Addons[] = [], frontends: Frontend[] = [], auth?: Auth, backend?: Backend, runtime?: Runtime, ): ValidationResult { if (addons.includes("turborepo") && addons.includes("nx")) { return validationErr("Cannot combine 'turborepo' and 'nx' addons. Choose one monorepo tool."); } for (const addon of addons) { if (addon === "none") continue; const { isCompatible, reason } = validateAddonCompatibility( addon, frontends, auth, backend, runtime, ); if (!isCompatible) { return validationErr(`Incompatible addon/frontend combination: ${reason}`); } } return Result.ok(undefined); } export function validateAddonsAgainstConfig( addons: Addons[] = [], config: Partial, ): ValidationResult { return validateAddonsAgainstFrontends( addons, config.frontend ?? [], config.auth, config.backend, config.runtime, ); } export function validatePaymentsCompatibility( payments: Payments | undefined, auth: Auth | undefined, _backend: Backend | undefined, frontends: Frontend[] = [], ): ValidationResult { if (!payments || payments === "none") return Result.ok(undefined); if (payments === "polar") { if (!auth || auth === "none" || auth !== "better-auth") { return validationErr( "Polar payments requires Better Auth. Please use '--auth better-auth' or choose a different payments provider.", ); } const { web } = splitFrontends(frontends); if (web.length === 0 && frontends.length > 0) { return validationErr( "Polar payments requires a web frontend or no frontend. Please select a web frontend or choose a different payments provider.", ); } } return Result.ok(undefined); } export function validateExamplesCompatibility( examples: string[] | undefined, backend: ProjectConfig["backend"] | undefined, database: ProjectConfig["database"] | undefined, frontend?: Frontend[], api?: API, ): ValidationResult { const examplesArr = examples ?? []; if (examplesArr.length === 0 || examplesArr.includes("none")) return Result.ok(undefined); if (examplesArr.includes("todo") && backend !== "convex") { if (database === "none") { return validationErr( "The 'todo' example requires a database. Cannot use --examples todo when database is 'none'.", ); } if (api === "none") { return validationErr( "The 'todo' example requires an API layer (tRPC or oRPC). Cannot use --examples todo when api is 'none'.", ); } } if (examplesArr.includes("ai") && (frontend ?? []).includes("solid")) { return validationErr("The 'ai' example is not compatible with the Solid frontend."); } // Convex AI example only supports React-based frontends if (examplesArr.includes("ai") && backend === "convex") { const frontendArr = frontend ?? []; const includesNuxt = frontendArr.includes("nuxt"); const includesSvelte = frontendArr.includes("svelte"); if (includesNuxt || includesSvelte) { return validationErr( "The 'ai' example with Convex backend only supports React-based frontends (Next.js, TanStack Router, TanStack Start, React Router). Svelte and Nuxt are not supported with Convex AI.", ); } } return Result.ok(undefined); } ================================================ FILE: apps/cli/src/utils/compatibility.ts ================================================ import type { Frontend } from "../types"; export const WEB_FRAMEWORKS: readonly Frontend[] = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "astro", ] as const; ================================================ FILE: apps/cli/src/utils/config-processing.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import type { API, Auth, Backend, CLIInput, Database, DatabaseSetup, ORM, PackageManager, Payments, ProjectConfig, Runtime, ServerDeploy, WebDeploy, } from "../types"; import { ValidationError } from "./errors"; export function processArrayOption(options: (T | "none")[] | undefined) { if (!options || options.length === 0) return []; if (options.includes("none" as T | "none")) return []; return options.filter((item): item is T => item !== "none"); } export function deriveProjectName(projectName?: string, projectDirectory?: string) { if (projectName) { return projectName; } if (projectDirectory) { return path.basename(path.resolve(process.cwd(), projectDirectory)); } return ""; } export function processFlags(options: CLIInput, projectName?: string) { const config: Partial = {}; if (options.api) { config.api = options.api as API; } if (options.addonOptions) { config.addonOptions = options.addonOptions; } if (options.dbSetupOptions) { config.dbSetupOptions = options.dbSetupOptions; } if (options.backend) { config.backend = options.backend as Backend; } if (options.database) { config.database = options.database as Database; } if (options.orm) { config.orm = options.orm as ORM; } if (options.auth !== undefined) { config.auth = options.auth as Auth; } if (options.payments !== undefined) { config.payments = options.payments as Payments; } if (options.git !== undefined) { config.git = options.git; } if (options.install !== undefined) { config.install = options.install; } if (options.runtime) { config.runtime = options.runtime as Runtime; } if (options.dbSetup) { config.dbSetup = options.dbSetup as DatabaseSetup; } if (options.packageManager) { config.packageManager = options.packageManager as PackageManager; } if (options.webDeploy) { config.webDeploy = options.webDeploy as WebDeploy; } if (options.serverDeploy) { config.serverDeploy = options.serverDeploy as ServerDeploy; } const derivedName = deriveProjectName(projectName, options.projectDirectory); if (derivedName) { config.projectName = projectName || derivedName; } if (options.frontend && options.frontend.length > 0) { config.frontend = processArrayOption(options.frontend); } if (options.addons && options.addons.length > 0) { config.addons = processArrayOption(options.addons); } if (options.examples && options.examples.length > 0) { config.examples = processArrayOption(options.examples); } return config; } export function getProvidedFlags(options: CLIInput) { return new Set( Object.keys(options).filter((key) => options[key as keyof CLIInput] !== undefined), ); } function validateNoneExclusivity( options: (T | "none")[] | undefined, optionName: string, ): Result { if (!options || options.length === 0) return Result.ok(undefined); if (options.includes("none" as T | "none") && options.length > 1) { return Result.err( new ValidationError({ message: `Cannot combine 'none' with other ${optionName}.`, }), ); } return Result.ok(undefined); } export function validateArrayOptions(options: CLIInput): Result { const frontendResult = validateNoneExclusivity(options.frontend, "frontend options"); if (frontendResult.isErr()) return frontendResult; const addonsResult = validateNoneExclusivity(options.addons, "addons"); if (addonsResult.isErr()) return addonsResult; const examplesResult = validateNoneExclusivity(options.examples, "examples"); if (examplesResult.isErr()) return examplesResult; return Result.ok(undefined); } ================================================ FILE: apps/cli/src/utils/config-validation.ts ================================================ import { Result } from "better-result"; import type { CLIInput, Database, DatabaseSetup, ProjectConfig, Runtime } from "../types"; import { CONVEX_BETTER_AUTH_INCOMPATIBLE_FRONTENDS, CONVEX_BETTER_AUTH_SUPPORTED_FRONTENDS, ensureSingleWebAndNative, isWebFrontend, supportsConvexBetterAuth, validateAddonsAgainstFrontends, validateApiFrontendCompatibility, validateExamplesCompatibility, validatePaymentsCompatibility, validateSelfBackendCompatibility, validateServerDeployRequiresBackend, validateWebDeployRequiresWebFrontend, validateWorkersCompatibility, } from "./compatibility-rules"; import { ValidationError } from "./errors"; type ValidationResult = Result; function validationErr(message: string): ValidationResult { return Result.err(new ValidationError({ message })); } function hasResolvedWorkersD1Target(config: Partial) { return ( config.backend === "hono" && config.runtime === "workers" && config.serverDeploy === "cloudflare" ); } function hasResolvedSelfCloudflareD1Target(config: Partial) { return ( config.backend === "self" && config.runtime === "none" && config.webDeploy === "cloudflare" ); } function canResolveWorkersD1Target(config: Partial) { return ( (config.backend === undefined || config.backend === "hono") && (config.runtime === undefined || config.runtime === "workers") && (config.serverDeploy === undefined || config.serverDeploy === "cloudflare") ); } function canResolveSelfCloudflareD1Target(config: Partial) { return ( (config.backend === undefined || config.backend === "self") && (config.runtime === undefined || config.runtime === "none") && (config.webDeploy === undefined || config.webDeploy === "cloudflare") ); } /** * Pure ORM + database compatibility check. Used by the flag-path * validator below and by the orm prompt directly (for flag+prompt * combos where one value came from a flag and the other from a * prompt). */ export function validateOrmDatabaseCompat( orm: ProjectConfig["orm"] | undefined, database: ProjectConfig["database"] | undefined, ): ValidationResult { if (orm === "mongoose" && database && database !== "mongodb") { return validationErr( "Mongoose ORM requires MongoDB database. Please use '--database mongodb' or choose a different ORM.", ); } if (orm === "drizzle" && database === "mongodb") { return validationErr( "Drizzle ORM does not support MongoDB. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", ); } if (database === "mongodb" && orm && orm !== "mongoose" && orm !== "prisma" && orm !== "none") { return validationErr( "MongoDB database requires Mongoose or Prisma ORM. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", ); } if (database && database !== "none" && orm === "none") { return validationErr( "Database selection requires an ORM. Please choose '--orm drizzle', '--orm prisma', or '--orm mongoose'.", ); } if (orm && orm !== "none" && database === "none") { return validationErr( "ORM selection requires a database. Please choose a database or set '--orm none'.", ); } return Result.ok(undefined); } export function validateDatabaseOrmAuth( cfg: Partial, flags?: Set, ): ValidationResult { const has = (k: string) => (flags ? flags.has(k) : true); if (!has("orm") || !has("database")) return Result.ok(undefined); return validateOrmDatabaseCompat(cfg.orm, cfg.database); } export function validateDatabaseSetup( config: Partial, providedFlags: Set, ): ValidationResult { const { dbSetup, database, runtime } = config; if ( providedFlags.has("dbSetup") && providedFlags.has("database") && dbSetup && dbSetup !== "none" && database === "none" ) { return validationErr( "Database setup requires a database. Please choose a database or set '--db-setup none'.", ); } const setupValidations: Record< DatabaseSetup, { database?: Database; runtime?: Runtime; errorMessage: string } > = { turso: { database: "sqlite", errorMessage: "Turso setup requires SQLite database. Please use '--database sqlite' or choose a different setup.", }, neon: { database: "postgres", errorMessage: "Neon setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", }, "prisma-postgres": { database: "postgres", errorMessage: "Prisma PostgreSQL setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", }, planetscale: { errorMessage: "PlanetScale setup requires PostgreSQL or MySQL database. Please use '--database postgres' or '--database mysql' or choose a different setup.", }, "mongodb-atlas": { database: "mongodb", errorMessage: "MongoDB Atlas setup requires MongoDB database. Please use '--database mongodb' or choose a different setup.", }, supabase: { database: "postgres", errorMessage: "Supabase setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", }, d1: { database: "sqlite", errorMessage: "Cloudflare D1 setup requires SQLite database.", }, docker: { errorMessage: "Docker setup is not compatible with SQLite database or Cloudflare Workers runtime.", }, none: { errorMessage: "" }, }; if (dbSetup && dbSetup !== "none") { const validation = setupValidations[dbSetup]; if (dbSetup === "planetscale") { if (database !== "postgres" && database !== "mysql") { return validationErr(validation.errorMessage); } } else { if (validation.database && database !== validation.database) { return validationErr(validation.errorMessage); } } if (validation.runtime && runtime !== validation.runtime) { return validationErr(validation.errorMessage); } if (dbSetup === "d1") { const isWorkersTarget = hasResolvedWorkersD1Target(config); const isSelfCloudflareTarget = hasResolvedSelfCloudflareD1Target(config); const canResolveWorkersTarget = canResolveWorkersD1Target(config); const canResolveSelfCloudflareTarget = canResolveSelfCloudflareD1Target(config); if ( !isWorkersTarget && !isSelfCloudflareTarget && !canResolveWorkersTarget && !canResolveSelfCloudflareTarget ) { return validationErr( "Cloudflare D1 setup requires SQLite database and either Cloudflare Workers runtime with server deployment or backend 'self' with Cloudflare web deployment.", ); } } if (dbSetup === "docker") { if (database === "sqlite") { return validationErr( "Docker setup is not compatible with SQLite database. SQLite is file-based and doesn't require Docker. Please use '--database postgres', '--database mysql', '--database mongodb', or choose a different setup.", ); } if (runtime === "workers") { return validationErr( "Docker setup is not compatible with Cloudflare Workers runtime. Workers runtime uses serverless databases (D1) and doesn't support local Docker containers. Please use '--db-setup d1' for SQLite or choose a different runtime.", ); } } } return Result.ok(undefined); } export function validateConvexConstraints( config: Partial, providedFlags: Set, ): ValidationResult { const { backend } = config; if (backend !== "convex") { return Result.ok(undefined); } const has = (k: string) => providedFlags.has(k); if (has("runtime") && config.runtime !== "none") { return validationErr( "Convex backend requires '--runtime none'. Please remove the --runtime flag or set it to 'none'.", ); } if (has("database") && config.database !== "none") { return validationErr( "Convex backend requires '--database none'. Please remove the --database flag or set it to 'none'.", ); } if (has("orm") && config.orm !== "none") { return validationErr( "Convex backend requires '--orm none'. Please remove the --orm flag or set it to 'none'.", ); } if (has("api") && config.api !== "none") { return validationErr( "Convex backend requires '--api none'. Please remove the --api flag or set it to 'none'.", ); } if (has("dbSetup") && config.dbSetup !== "none") { return validationErr( "Convex backend requires '--db-setup none'. Please remove the --db-setup flag or set it to 'none'.", ); } if (has("serverDeploy") && config.serverDeploy !== "none") { return validationErr( "Convex backend requires '--server-deploy none'. Please remove the --server-deploy flag or set it to 'none'.", ); } if (has("auth") && config.auth === "better-auth") { const incompatibleFrontends = config.frontend?.filter((f) => CONVEX_BETTER_AUTH_INCOMPATIBLE_FRONTENDS.includes( f as (typeof CONVEX_BETTER_AUTH_INCOMPATIBLE_FRONTENDS)[number], ), ) ?? []; const hasSupportedFrontend = supportsConvexBetterAuth(config.frontend); if (incompatibleFrontends.length > 0) { return validationErr( `Better Auth with '--backend convex' is not compatible with the following frontends: ${incompatibleFrontends.join( ", ", )}. Please use a React-based web frontend (next, tanstack-start, tanstack-router, react-router), a supported native frontend, or choose a different auth provider.`, ); } if (!hasSupportedFrontend) { return validationErr( `Better Auth with '--backend convex' requires a supported frontend (${CONVEX_BETTER_AUTH_SUPPORTED_FRONTENDS.join( ", ", )}).`, ); } } return Result.ok(undefined); } export function validateBackendNoneConstraints( config: Partial, providedFlags: Set, ): ValidationResult { const { backend } = config; if (backend !== "none") { return Result.ok(undefined); } const has = (k: string) => providedFlags.has(k); if (has("runtime") && config.runtime !== "none") { return validationErr( "Backend 'none' requires '--runtime none'. Please remove the --runtime flag or set it to 'none'.", ); } if (has("database") && config.database !== "none") { return validationErr( "Backend 'none' requires '--database none'. Please remove the --database flag or set it to 'none'.", ); } if (has("orm") && config.orm !== "none") { return validationErr( "Backend 'none' requires '--orm none'. Please remove the --orm flag or set it to 'none'.", ); } if (has("api") && config.api !== "none") { return validationErr( "Backend 'none' requires '--api none'. Please remove the --api flag or set it to 'none'.", ); } if (has("auth") && config.auth !== "none") { return validationErr( "Backend 'none' requires '--auth none'. Please remove the --auth flag or set it to 'none'.", ); } if (has("payments") && config.payments !== "none") { return validationErr( "Backend 'none' requires '--payments none'. Please remove the --payments flag or set it to 'none'.", ); } if (has("dbSetup") && config.dbSetup !== "none") { return validationErr( "Backend 'none' requires '--db-setup none'. Please remove the --db-setup flag or set it to 'none'.", ); } if (has("serverDeploy") && config.serverDeploy !== "none") { return validationErr( "Backend 'none' requires '--server-deploy none'. Please remove the --server-deploy flag or set it to 'none'.", ); } return Result.ok(undefined); } export function validateSelfBackendConstraints( config: Partial, providedFlags: Set, ): ValidationResult { const { backend } = config; if (backend !== "self") { return Result.ok(undefined); } const has = (k: string) => providedFlags.has(k); if (has("runtime") && config.runtime !== "none") { return validationErr( "Backend 'self' (fullstack) requires '--runtime none'. Please remove the --runtime flag or set it to 'none'.", ); } return Result.ok(undefined); } export function validateBackendConstraints( config: Partial, providedFlags: Set, options: CLIInput, ): ValidationResult { const { backend } = config; if (config.auth === "clerk" && config.frontend) { const incompatibleFrontends = config.frontend.filter((f) => ["nuxt", "svelte", "solid", "astro"].includes(f), ); if (incompatibleFrontends.length > 0) { return validationErr( `Clerk authentication is not compatible with the following frontends: ${incompatibleFrontends.join( ", ", )}. Please choose a different frontend or auth provider.`, ); } } if ( providedFlags.has("backend") && backend && backend !== "convex" && backend !== "none" && backend !== "self" ) { if (providedFlags.has("runtime") && options.runtime === "none") { return validationErr( "'--runtime none' is only supported with '--backend convex', '--backend none', or '--backend self'. Please choose 'bun', 'node', or remove the --runtime flag.", ); } } if (backend === "convex" && providedFlags.has("frontend") && options.frontend) { const incompatibleFrontends = options.frontend.filter((f) => f === "solid" || f === "astro"); if (incompatibleFrontends.length > 0) { return validationErr( `The following frontends are not compatible with '--backend convex': ${incompatibleFrontends.join( ", ", )}. Please choose a different frontend or backend.`, ); } } return Result.ok(undefined); } export function validateFrontendConstraints( config: Partial, providedFlags: Set, ): ValidationResult { const { frontend } = config; if (frontend && frontend.length > 0) { const singleWebNativeResult = ensureSingleWebAndNative(frontend); if (singleWebNativeResult.isErr()) { return singleWebNativeResult; } if (providedFlags.has("api") && providedFlags.has("frontend") && config.api) { const apiResult = validateApiFrontendCompatibility(config.api, frontend); if (apiResult.isErr()) { return apiResult; } } } const hasWebFrontendFlag = (frontend ?? []).some((f) => isWebFrontend(f)); const webDeployResult = validateWebDeployRequiresWebFrontend( config.webDeploy, hasWebFrontendFlag, ); if (webDeployResult.isErr()) { return webDeployResult; } return Result.ok(undefined); } export function validateApiConstraints( config: Partial, options: CLIInput, ): ValidationResult { if (config.api === "none") { if ( options.examples?.includes("todo") && options.backend !== "convex" && options.backend !== "none" ) { return validationErr( "Cannot use '--examples todo' when '--api' is set to 'none'. The todo example requires an API layer. Please remove 'todo' from --examples or choose an API type.", ); } } return Result.ok(undefined); } export function validateFullConfig( config: Partial, providedFlags: Set, options: CLIInput, ): ValidationResult { return Result.gen(function* () { yield* validateDatabaseOrmAuth(config, providedFlags); yield* validateDatabaseSetup(config, providedFlags); yield* validateConvexConstraints(config, providedFlags); yield* validateBackendNoneConstraints(config, providedFlags); yield* validateSelfBackendConstraints(config, providedFlags); yield* validateBackendConstraints(config, providedFlags, options); yield* validateFrontendConstraints(config, providedFlags); yield* validateApiConstraints(config, options); yield* validateServerDeployRequiresBackend(config.serverDeploy, config.backend); yield* validateSelfBackendCompatibility(providedFlags, options, config); yield* validateWorkersCompatibility(providedFlags, options, config); if (config.runtime === "workers" && config.serverDeploy === "none") { yield* validationErr( "Cloudflare Workers runtime requires a server deployment. Please choose 'cloudflare' for --server-deploy.", ); } if ( providedFlags.has("serverDeploy") && config.serverDeploy === "cloudflare" && config.runtime !== "workers" ) { yield* validationErr( `Server deployment '${config.serverDeploy}' requires '--runtime workers'. Please use '--runtime workers' or choose a different server deployment.`, ); } if (config.addons && config.addons.length > 0) { yield* validateAddonsAgainstFrontends( config.addons, config.frontend, config.auth, config.backend, config.runtime, ); config.addons = [...new Set(config.addons)]; } yield* validateExamplesCompatibility( config.examples ?? [], config.backend, config.database, config.frontend ?? [], config.api, ); yield* validatePaymentsCompatibility( config.payments, config.auth, config.backend, config.frontend ?? [], ); return Result.ok(undefined); }); } export function validateConfigForProgrammaticUse(config: Partial): ValidationResult { return Result.gen(function* () { yield* validateDatabaseOrmAuth(config); if (config.frontend && config.frontend.length > 0) { yield* ensureSingleWebAndNative(config.frontend); } yield* validateApiFrontendCompatibility(config.api, config.frontend); yield* validatePaymentsCompatibility( config.payments, config.auth, config.backend, config.frontend, ); if (config.addons && config.addons.length > 0) { yield* validateAddonsAgainstFrontends( config.addons, config.frontend, config.auth, config.backend, config.runtime, ); } yield* validateExamplesCompatibility( config.examples ?? [], config.backend, config.database, config.frontend ?? [], config.api, ); return Result.ok(undefined); }); } ================================================ FILE: apps/cli/src/utils/context.ts ================================================ import { AsyncLocalStorage } from "node:async_hooks"; import type { PackageManager } from "../types"; export type NavigationState = { isFirstPrompt: boolean; lastPromptShownUI: boolean; }; export type CLIContext = { navigation: NavigationState; silent: boolean; verbose: boolean; projectDir?: string; projectName?: string; packageManager?: PackageManager; }; const cliStorage = new AsyncLocalStorage(); function defaultContext(): CLIContext { return { navigation: { isFirstPrompt: false, lastPromptShownUI: false, }, silent: false, verbose: false, }; } export function getContext(): CLIContext { const ctx = cliStorage.getStore(); if (!ctx) { return defaultContext(); } return ctx; } export function tryGetContext(): CLIContext | undefined { return cliStorage.getStore(); } export function isSilent(): boolean { return getContext().silent; } export function isVerbose(): boolean { return getContext().verbose; } export function getNavigation(): NavigationState { return getContext().navigation; } export function isFirstPrompt(): boolean { return getContext().navigation.isFirstPrompt; } export function didLastPromptShowUI(): boolean { return getContext().navigation.lastPromptShownUI; } export function getProjectDir(): string | undefined { return getContext().projectDir; } export function getPackageManager(): PackageManager | undefined { return getContext().packageManager; } export function setIsFirstPrompt(value: boolean): void { const ctx = tryGetContext(); if (ctx) { ctx.navigation.isFirstPrompt = value; } } export function setLastPromptShownUI(value: boolean): void { const ctx = tryGetContext(); if (ctx) { ctx.navigation.lastPromptShownUI = value; } } export function setProjectInfo(info: { projectDir?: string; projectName?: string; packageManager?: PackageManager; }): void { const ctx = tryGetContext(); if (ctx) { if (info.projectDir !== undefined) ctx.projectDir = info.projectDir; if (info.projectName !== undefined) ctx.projectName = info.projectName; if (info.packageManager !== undefined) ctx.packageManager = info.packageManager; } } export type ContextOptions = { silent?: boolean; verbose?: boolean; projectDir?: string; projectName?: string; packageManager?: PackageManager; }; export function runWithContext(options: ContextOptions, fn: () => T): T { const ctx: CLIContext = { navigation: { isFirstPrompt: false, lastPromptShownUI: false, }, silent: options.silent ?? false, verbose: options.verbose ?? false, projectDir: options.projectDir, projectName: options.projectName, packageManager: options.packageManager, }; return cliStorage.run(ctx, fn); } export async function runWithContextAsync( options: ContextOptions, fn: () => Promise, ): Promise { const ctx: CLIContext = { navigation: { isFirstPrompt: false, lastPromptShownUI: false, }, silent: options.silent ?? false, verbose: options.verbose ?? false, projectDir: options.projectDir, projectName: options.projectName, packageManager: options.packageManager, }; return cliStorage.run(ctx, fn); } ================================================ FILE: apps/cli/src/utils/display-config.ts ================================================ import pc from "picocolors"; import type { ProjectConfig } from "../types"; export function displayConfig(config: Partial) { const configDisplay: string[] = []; if (config.projectName) { configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`); } if (config.frontend !== undefined) { const frontend = Array.isArray(config.frontend) ? config.frontend : [config.frontend]; const frontendText = frontend.length > 0 && frontend[0] !== undefined ? frontend.join(", ") : "none"; configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`); } if (config.backend !== undefined) { configDisplay.push(`${pc.blue("Backend:")} ${String(config.backend)}`); } if (config.runtime !== undefined) { configDisplay.push(`${pc.blue("Runtime:")} ${String(config.runtime)}`); } if (config.api !== undefined) { configDisplay.push(`${pc.blue("API:")} ${String(config.api)}`); } if (config.database !== undefined) { configDisplay.push(`${pc.blue("Database:")} ${String(config.database)}`); } if (config.orm !== undefined) { configDisplay.push(`${pc.blue("ORM:")} ${String(config.orm)}`); } if (config.auth !== undefined) { configDisplay.push(`${pc.blue("Auth:")} ${String(config.auth)}`); } if (config.payments !== undefined) { configDisplay.push(`${pc.blue("Payments:")} ${String(config.payments)}`); } if (config.addons !== undefined) { const addons = Array.isArray(config.addons) ? config.addons : [config.addons]; const addonsText = addons.length > 0 && addons[0] !== undefined ? addons.join(", ") : "none"; configDisplay.push(`${pc.blue("Addons:")} ${addonsText}`); } if (config.examples !== undefined) { const examples = Array.isArray(config.examples) ? config.examples : [config.examples]; const examplesText = examples.length > 0 && examples[0] !== undefined ? examples.join(", ") : "none"; configDisplay.push(`${pc.blue("Examples:")} ${examplesText}`); } if (config.git !== undefined) { const gitText = typeof config.git === "boolean" ? (config.git ? "Yes" : "No") : String(config.git); configDisplay.push(`${pc.blue("Git Init:")} ${gitText}`); } if (config.packageManager !== undefined) { configDisplay.push(`${pc.blue("Package Manager:")} ${String(config.packageManager)}`); } if (config.install !== undefined) { const installText = typeof config.install === "boolean" ? config.install ? "Yes" : "No" : String(config.install); configDisplay.push(`${pc.blue("Install Dependencies:")} ${installText}`); } if (config.dbSetup !== undefined) { configDisplay.push(`${pc.blue("Database Setup:")} ${String(config.dbSetup)}`); } if (config.webDeploy !== undefined) { configDisplay.push(`${pc.blue("Web Deployment:")} ${String(config.webDeploy)}`); } if (config.serverDeploy !== undefined) { configDisplay.push(`${pc.blue("Server Deployment:")} ${String(config.serverDeploy)}`); } if (configDisplay.length === 0) { return pc.yellow("No configuration selected."); } return configDisplay.join("\n"); } ================================================ FILE: apps/cli/src/utils/docker-utils.ts ================================================ import os from "node:os"; import { Result } from "better-result"; import { $ } from "execa"; import pc from "picocolors"; import type { Database } from "../types"; import { commandExists } from "./command-exists"; export async function isDockerInstalled() { return commandExists("docker"); } export async function isDockerRunning(): Promise { const result = await Result.tryPromise({ try: async () => { await $`docker info`; return true; }, catch: () => false, }); return result.isOk() ? result.value : false; } export function getDockerInstallInstructions(platform: string, database: Database) { const isMac = platform === "darwin"; const isWindows = platform === "win32"; const isLinux = platform === "linux"; let installUrl = ""; let platformName = ""; if (isMac) { installUrl = "https://docs.docker.com/desktop/setup/install/mac-install/"; platformName = "macOS"; } else if (isWindows) { installUrl = "https://docs.docker.com/desktop/setup/install/windows-install/"; platformName = "Windows"; } else if (isLinux) { installUrl = "https://docs.docker.com/desktop/setup/install/linux/"; platformName = "Linux"; } const databaseName = database === "mongodb" ? "MongoDB" : database === "mysql" ? "MySQL" : "PostgreSQL"; return `${pc.yellow("IMPORTANT:")} Docker required for ${databaseName}. Install for ${platformName}:\n${pc.blue(installUrl)}`; } export async function getDockerStatus(database: Database) { const platform = os.platform(); const installed = await isDockerInstalled(); if (!installed) { return { installed: false, running: false, message: getDockerInstallInstructions(platform, database), }; } const running = await isDockerRunning(); if (!running) { return { installed: true, running: false, message: `${pc.yellow("IMPORTANT:")} Docker is installed but not running.`, }; } return { installed: true, running: true, }; } ================================================ FILE: apps/cli/src/utils/env-utils.ts ================================================ import fs from "fs-extra"; export interface EnvVariable { key: string; value: string; condition?: boolean; } export async function addEnvVariablesToFile( envPath: string, variables: EnvVariable[], ): Promise { let content = ""; if (fs.existsSync(envPath)) { content = await fs.readFile(envPath, "utf-8"); } else { // If file doesn't exist, create it await fs.ensureFile(envPath); } const existingLines = content.split("\n"); const newLines: string[] = []; const keysToAdd = new Map(); for (const variable of variables) { if (variable.condition === false || !variable.key) { continue; } keysToAdd.set(variable.key, variable.value); } let foundKeys = new Set(); for (const line of existingLines) { const trimmedLine = line.trim(); let lineProcessed = false; for (const [key, value] of keysToAdd) { if (trimmedLine.startsWith(`${key}=`)) { newLines.push(`${key}=${value}`); foundKeys.add(key); lineProcessed = true; break; } } if (!lineProcessed) { newLines.push(line); } } for (const [key, value] of keysToAdd) { if (!foundKeys.has(key)) { newLines.push(`${key}=${value}`); } } // Remove empty line at the end if it exists if (newLines.length > 0 && newLines[newLines.length - 1] === "") { newLines.pop(); } const hasChanges = foundKeys.size > 0 || keysToAdd.size > foundKeys.size; if (hasChanges) { await fs.writeFile(envPath, newLines.join("\n") + "\n"); } } ================================================ FILE: apps/cli/src/utils/errors.ts ================================================ import { cancel } from "@clack/prompts"; import { Result, TaggedError } from "better-result"; import pc from "picocolors"; import { cliConsola } from "./terminal-output"; // ============================================================================ // Tagged Error Classes // ============================================================================ /** * User cancelled the operation (e.g., Ctrl+C in prompts) */ export class UserCancelledError extends TaggedError("UserCancelledError")<{ message: string; }>() { constructor(args?: { message?: string }) { super({ message: args?.message ?? "Operation cancelled" }); } } /** * General CLI error for validation failures, invalid flags, etc. */ export class CLIError extends TaggedError("CLIError")<{ message: string; cause?: unknown; }>() {} /** * Validation error for config/flag validation failures */ export class ValidationError extends TaggedError("ValidationError")<{ field?: string; value?: unknown; message: string; }>() { constructor(args: { field?: string; value?: unknown; message: string }) { super(args); } } /** * Compatibility error for incompatible option combinations */ export class CompatibilityError extends TaggedError("CompatibilityError")<{ options: string[]; message: string; }>() { constructor(args: { options: string[]; message: string }) { super(args); } } /** * Directory conflict error when target directory exists and is not empty */ export class DirectoryConflictError extends TaggedError("DirectoryConflictError")<{ directory: string; message: string; }>() { constructor(args: { directory: string }) { super({ directory: args.directory, message: `Directory "${args.directory}" already exists and is not empty. Use directoryConflict: "overwrite", "merge", or "increment" to handle this.`, }); } } /** * Project creation error for failures during scaffolding */ export class ProjectCreationError extends TaggedError("ProjectCreationError")<{ phase: string; message: string; cause?: unknown; }>() { constructor(args: { phase: string; message: string; cause?: unknown }) { super(args); } } /** * Database setup error for failures during database configuration */ export class DatabaseSetupError extends TaggedError("DatabaseSetupError")<{ provider: string; message: string; cause?: unknown; }>() { constructor(args: { provider: string; message: string; cause?: unknown }) { super(args); } } /** * Addon setup error for failures during addon configuration */ export class AddonSetupError extends TaggedError("AddonSetupError")<{ addon: string; message: string; cause?: unknown; }>() { constructor(args: { addon: string; message: string; cause?: unknown }) { super(args); } } // ============================================================================ // Error Type Unions // ============================================================================ /** * All possible CLI errors */ export type AppError = | UserCancelledError | CLIError | ValidationError | CompatibilityError | DirectoryConflictError | ProjectCreationError | DatabaseSetupError | AddonSetupError; // ============================================================================ // Result Helper Functions // ============================================================================ /** * Create an error Result from a message string */ export function cliError(message: string): Result { return Result.err(new CLIError({ message })); } /** * Create a validation error Result */ export function validationError( message: string, field?: string, value?: unknown, ): Result { return Result.err(new ValidationError({ message, field, value })); } /** * Create a compatibility error Result */ export function compatibilityError( message: string, options: string[], ): Result { return Result.err(new CompatibilityError({ message, options })); } /** * Create a user cancelled error Result */ export function userCancelled(message?: string): Result { return Result.err(new UserCancelledError({ message })); } /** * Create a directory conflict error Result */ export function directoryConflict(directory: string): Result { return Result.err(new DirectoryConflictError({ directory })); } /** * Create a project creation error Result */ export function projectCreationError( phase: string, message: string, cause?: unknown, ): Result { return Result.err(new ProjectCreationError({ phase, message, cause })); } /** * Create a database setup error Result */ export function databaseSetupError( provider: string, message: string, cause?: unknown, ): Result { return Result.err(new DatabaseSetupError({ provider, message, cause })); } /** * Create an addon setup error Result */ export function addonSetupError( addon: string, message: string, cause?: unknown, ): Result { return Result.err(new AddonSetupError({ addon, message, cause })); } // ============================================================================ // Error Display Utilities // ============================================================================ /** * Display an error to the user (for CLI mode) */ export function displayError(error: AppError): void { if (UserCancelledError.is(error)) { cancel(pc.red(error.message)); } else { cliConsola.error(pc.red(error.message)); } } /** * Handle a Result error by displaying it and exiting (for CLI mode) */ export function handleResultError(error: AppError): never { displayError(error); process.exit(1); } ================================================ FILE: apps/cli/src/utils/external-commands.ts ================================================ export function shouldSkipExternalCommands(): boolean { return process.env.BTS_SKIP_EXTERNAL_COMMANDS === "1" || process.env.BTS_TEST_MODE === "1"; } ================================================ FILE: apps/cli/src/utils/file-formatter.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import fs from "fs-extra"; import { format, type FormatOptions } from "oxfmt"; import { ProjectCreationError } from "./errors"; const formatOptions: FormatOptions = { experimentalSortPackageJson: true, experimentalSortImports: { order: "asc", }, }; export async function formatCode(filePath: string, content: string): Promise { const result = await Result.tryPromise({ try: async () => { const formatResult = await format(path.basename(filePath), content, formatOptions); if (formatResult.errors && formatResult.errors.length > 0) { return null; } return formatResult.code; }, catch: () => null, }); return result.isOk() ? result.value : null; } export async function formatProject( projectDir: string, ): Promise> { return Result.tryPromise({ try: async () => { async function formatDirectory(dir: string) { const entries = await fs.readdir(dir, { withFileTypes: true }); await Promise.all( entries.map(async (entry) => { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { await formatDirectory(fullPath); } else if (entry.isFile()) { const fileResult = await Result.tryPromise({ try: async () => { const content = await fs.readFile(fullPath, "utf-8"); const formatted = await formatCode(fullPath, content); if (formatted && formatted !== content) { await fs.writeFile(fullPath, formatted, "utf-8"); } }, catch: () => undefined, // Ignore individual file formatting errors }); // Result is intentionally unused - we silently ignore errors void fileResult; } }), ); } await formatDirectory(projectDir); }, catch: (e) => new ProjectCreationError({ phase: "formatting", message: `Failed to format project: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } ================================================ FILE: apps/cli/src/utils/get-latest-cli-version.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import fs from "fs-extra"; import { PKG_ROOT } from "../constants"; import { CLIError } from "./errors"; export function getLatestCLIVersionResult(): Result { const packageJsonPath = path.join(PKG_ROOT, "package.json"); return Result.try({ try: () => { const packageJsonContent = fs.readJSONSync(packageJsonPath); return String(packageJsonContent.version ?? "1.0.0"); }, catch: (e) => new CLIError({ message: `Failed to read CLI version from package.json: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } export function getLatestCLIVersion(): string { return getLatestCLIVersionResult().unwrapOr("1.0.0"); } ================================================ FILE: apps/cli/src/utils/get-package-manager.ts ================================================ import type { PackageManager } from "../types"; export const getUserPkgManager: () => PackageManager = () => { const userAgent = process.env.npm_config_user_agent; if (userAgent?.startsWith("pnpm")) { return "pnpm"; } if (userAgent?.startsWith("bun")) { return "bun"; } return "npm"; }; ================================================ FILE: apps/cli/src/utils/input-hardening.ts ================================================ import { Result } from "better-result"; import { ValidationError } from "./errors"; type ValidationResult = Result; function hasControlCharacters(value: string): boolean { for (const char of value) { const charCode = char.charCodeAt(0); if (charCode < 0x20 || charCode === 0x7f) { return true; } } return false; } function hardeningError(field: string, value: string, message: string): ValidationResult { return Result.err( new ValidationError({ field, value, message, }), ); } export function validateAgentSafePathInput(value: string, field: string): ValidationResult { if (hasControlCharacters(value)) { return hardeningError(field, value, `Invalid ${field}: control characters are not allowed.`); } return Result.ok(undefined); } ================================================ FILE: apps/cli/src/utils/navigation.ts ================================================ export const GO_BACK_SYMBOL = Symbol("clack:goBack"); export function isGoBack(value: unknown): value is symbol { return value === GO_BACK_SYMBOL; } ================================================ FILE: apps/cli/src/utils/open-url.ts ================================================ import { $ } from "execa"; export async function openUrl(url: string): Promise { const platform = process.platform; if (platform === "darwin") { await $({ stdio: "ignore" })`open ${url}`; return; } if (platform === "win32") { // Windows needs special handling for ampersands const escapedUrl = url.replace(/&/g, "^&"); await $({ stdio: "ignore" })`cmd /c start "" ${escapedUrl}`; return; } await $({ stdio: "ignore" })`xdg-open ${url}`; } ================================================ FILE: apps/cli/src/utils/package-runner.ts ================================================ import type { PackageManager } from "../types"; function splitCommandArgs(commandWithArgs: string): string[] { const args: string[] = []; let current = ""; let quote: "'" | '"' | null = null; for (let i = 0; i < commandWithArgs.length; i += 1) { const char = commandWithArgs[i]; if (quote) { if (char === quote) { quote = null; continue; } if (char === "\\" && i + 1 < commandWithArgs.length) { const nextChar = commandWithArgs[i + 1]; if (nextChar === quote || nextChar === "\\") { current += nextChar; i += 1; continue; } } current += char; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (/\s/.test(char)) { if (current.length > 0) { args.push(current); current = ""; } continue; } current += char; } if (current.length > 0) { args.push(current); } return args; } /** * Returns the appropriate command for running a package without installing it globally, * based on the selected package manager. * * @param packageManager - The selected package manager (e.g., 'npm', 'yarn', 'pnpm', 'bun'). * @param commandWithArgs - The command to run, including arguments (e.g., "prisma generate --schema=./prisma/schema.prisma"). * @returns The full command string (e.g., "npx prisma generate --schema=./prisma/schema.prisma"). */ export function getPackageExecutionCommand( packageManager: PackageManager | null | undefined, commandWithArgs: string, ) { switch (packageManager) { case "pnpm": return `pnpm dlx ${commandWithArgs}`; case "bun": return `bunx ${commandWithArgs}`; default: return `npx ${commandWithArgs}`; } } /** * Returns the command and arguments as an array for use with execa's $ template syntax. * This avoids the need for shell: true and provides better escaping. * * @param packageManager - The selected package manager (e.g., 'npm', 'yarn', 'pnpm', 'bun'). * @param commandWithArgs - The command to run, including arguments (e.g., "prisma generate"). * @returns An array of [command, ...args] (e.g., ["npx", "prisma", "generate"]). */ export function getPackageExecutionArgs( packageManager: PackageManager | null | undefined, commandWithArgs: string, ): string[] { const args = splitCommandArgs(commandWithArgs); switch (packageManager) { case "pnpm": return ["pnpm", "dlx", ...args]; case "bun": return ["bunx", ...args]; default: return ["npx", ...args]; } } /** * Returns just the runner prefix as an array, for when you already have args built. * Use this when you have complex arguments that shouldn't be split by spaces. * * @param packageManager - The selected package manager. * @returns The runner prefix as an array (e.g., ["npx"] or ["pnpm", "dlx"]). * * @example * const prefix = getPackageRunnerPrefix("bun"); * const args = ["@tauri-apps/cli@latest", "init", "--app-name=foo"]; * await $`${[...prefix, ...args]}`; */ export function getPackageRunnerPrefix( packageManager: PackageManager | null | undefined, ): string[] { switch (packageManager) { case "pnpm": return ["pnpm", "dlx"]; case "bun": return ["bunx"]; default: return ["npx"]; } } ================================================ FILE: apps/cli/src/utils/project-directory.ts ================================================ import path from "node:path"; import { isCancel, log, select, spinner } from "@clack/prompts"; import { Result } from "better-result"; import fs from "fs-extra"; import pc from "picocolors"; import { getProjectName } from "../prompts/project-name"; import { isSilent } from "./context"; import { CLIError, UserCancelledError } from "./errors"; export async function handleDirectoryConflict(currentPathInput: string): Promise<{ finalPathInput: string; shouldClearDirectory: boolean; }> { while (true) { const resolvedPath = path.resolve(process.cwd(), currentPathInput); const dirExists = await fs.pathExists(resolvedPath); const dirIsNotEmpty = dirExists && (await fs.readdir(resolvedPath)).length > 0; if (!dirIsNotEmpty) { return { finalPathInput: currentPathInput, shouldClearDirectory: false }; } if (isSilent()) { throw new CLIError({ message: `Directory "${currentPathInput}" already exists and is not empty. In silent mode, please provide a different project name or clear the directory manually.`, }); } log.warn(`Directory "${pc.yellow(currentPathInput)}" already exists and is not empty.`); const action = await select<"overwrite" | "merge" | "rename" | "cancel">({ message: "What would you like to do?", options: [ { value: "overwrite", label: "Overwrite", hint: "Empty the directory and create the project", }, { value: "merge", label: "Merge", hint: "Create project files inside, potentially overwriting conflicts", }, { value: "rename", label: "Choose a different name/path", hint: "Keep the existing directory and create a new one", }, { value: "cancel", label: "Cancel", hint: "Abort the process" }, ], initialValue: "rename", }); if (isCancel(action)) { throw new UserCancelledError({ message: "Operation cancelled." }); } switch (action) { case "overwrite": return { finalPathInput: currentPathInput, shouldClearDirectory: true }; case "merge": log.info( `Proceeding into existing directory "${pc.yellow( currentPathInput, )}". Files may be overwritten.`, ); return { finalPathInput: currentPathInput, shouldClearDirectory: false, }; case "rename": { log.info("Please choose a different project name or path."); const newPathInput = await getProjectName(undefined); return await handleDirectoryConflict(newPathInput); } case "cancel": throw new UserCancelledError({ message: "Operation cancelled." }); } } } export async function setupProjectDirectory( finalPathInput: string, shouldClearDirectory: boolean, ): Promise<{ finalResolvedPath: string; finalBaseName: string }> { let finalResolvedPath: string; let finalBaseName: string; if (finalPathInput === ".") { finalResolvedPath = process.cwd(); finalBaseName = path.basename(finalResolvedPath); } else { finalResolvedPath = path.resolve(process.cwd(), finalPathInput); finalBaseName = path.basename(finalResolvedPath); } if (shouldClearDirectory) { const s = spinner(); s.start(`Clearing directory "${finalResolvedPath}"...`); const clearResult = await Result.tryPromise({ try: () => fs.emptyDir(finalResolvedPath), catch: (error) => new CLIError({ message: `Failed to clear directory "${finalResolvedPath}".`, cause: error, }), }); if (clearResult.isErr()) { s.stop(pc.red(`Failed to clear directory "${finalResolvedPath}".`)); throw clearResult.error; } s.stop(`Directory "${finalResolvedPath}" cleared.`); } else { await fs.ensureDir(finalResolvedPath); } return { finalResolvedPath, finalBaseName }; } ================================================ FILE: apps/cli/src/utils/project-history.ts ================================================ import path from "node:path"; import { Result, TaggedError } from "better-result"; import envPaths from "env-paths"; import fs from "fs-extra"; import type { ProjectConfig } from "../types"; import { getLatestCLIVersion } from "./get-latest-cli-version"; const paths = envPaths("better-t-stack", { suffix: "" }); const HISTORY_FILE = "history.json"; export class HistoryError extends TaggedError("HistoryError")<{ message: string; cause?: unknown; }>() {} export type ProjectHistoryEntry = { id: string; projectName: string; projectDir: string; createdAt: string; stack: { frontend: string[]; backend: string; database: string; orm: string; runtime: string; auth: string; payments: string; api: string; addons: string[]; examples: string[]; dbSetup: string; packageManager: string; }; cliVersion: string; reproducibleCommand: string; }; type HistoryData = { version: number; entries: ProjectHistoryEntry[]; }; function getHistoryDir(): string { return paths.data; } function getHistoryPath(): string { return path.join(paths.data, HISTORY_FILE); } function generateId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } function emptyHistory(): HistoryData { return { version: 1, entries: [] }; } async function ensureHistoryDir(): Promise> { return Result.tryPromise({ try: async () => { await fs.ensureDir(getHistoryDir()); }, catch: (e) => new HistoryError({ message: `Failed to create history directory: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } export async function readHistory(): Promise> { const historyPath = getHistoryPath(); const existsResult = await Result.tryPromise({ try: async () => await fs.pathExists(historyPath), catch: (e) => new HistoryError({ message: `Failed to check history file: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); if (existsResult.isErr()) { return existsResult; } if (!existsResult.value) { return Result.ok(emptyHistory()); } const readResult = await Result.tryPromise({ try: async () => (await fs.readJson(historyPath)) as HistoryData, catch: (e) => new HistoryError({ message: `Failed to read history file: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); // If the file is corrupted/unreadable JSON, fall back to empty history. if (readResult.isErr()) { return Result.ok(emptyHistory()); } return Result.ok(readResult.value); } async function writeHistory(history: HistoryData): Promise> { const ensureDirResult = await ensureHistoryDir(); if (ensureDirResult.isErr()) { return ensureDirResult; } return Result.tryPromise({ try: async () => { await fs.writeJson(getHistoryPath(), history, { spaces: 2 }); }, catch: (e) => new HistoryError({ message: `Failed to write history file: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } export async function addToHistory( config: ProjectConfig, reproducibleCommand: string, ): Promise> { const historyResult = await readHistory(); if (historyResult.isErr()) { return historyResult; } const history = historyResult.value; const entry: ProjectHistoryEntry = { id: generateId(), projectName: config.projectName, projectDir: config.projectDir, createdAt: new Date().toISOString(), stack: { frontend: config.frontend, backend: config.backend, database: config.database, orm: config.orm, runtime: config.runtime, auth: config.auth, payments: config.payments, api: config.api, addons: config.addons, examples: config.examples, dbSetup: config.dbSetup, packageManager: config.packageManager, }, cliVersion: getLatestCLIVersion(), reproducibleCommand, }; // Add new entry at the beginning (newest first) history.entries.unshift(entry); // Keep only the last 100 entries to prevent file from growing too large if (history.entries.length > 100) { history.entries = history.entries.slice(0, 100); } return await writeHistory(history); } export async function getHistory(limit = 10): Promise> { const historyResult = await readHistory(); if (historyResult.isErr()) { return historyResult; } return Result.ok(historyResult.value.entries.slice(0, limit)); } export async function clearHistory(): Promise> { const historyPath = getHistoryPath(); return Result.tryPromise({ try: async () => { if (await fs.pathExists(historyPath)) { await fs.remove(historyPath); } }, catch: (e) => new HistoryError({ message: `Failed to clear history: ${e instanceof Error ? e.message : String(e)}`, cause: e, }), }); } export async function removeFromHistory(id: string): Promise> { const historyResult = await readHistory(); if (historyResult.isErr()) { return historyResult; } const history = historyResult.value; const initialLength = history.entries.length; history.entries = history.entries.filter((entry) => entry.id !== id); if (history.entries.length < initialLength) { const writeResult = await writeHistory(history); if (writeResult.isErr()) { return writeResult; } return Result.ok(true); } return Result.ok(false); } ================================================ FILE: apps/cli/src/utils/project-name-validation.ts ================================================ import path from "node:path"; import { Result } from "better-result"; import { ProjectNameSchema } from "../types"; import { ValidationError } from "./errors"; import { validateAgentSafePathInput } from "./input-hardening"; type ValidationResult = Result; export function validateProjectName(name: string): ValidationResult { const hardeningResult = validateAgentSafePathInput(name, "projectName"); if (hardeningResult.isErr()) { return Result.err(hardeningResult.error); } const result = ProjectNameSchema.safeParse(name); if (!result.success) { return Result.err( new ValidationError({ field: "projectName", value: name, message: `Invalid project name: ${result.error.issues[0]?.message || "Invalid project name"}`, }), ); } return Result.ok(undefined); } export function extractAndValidateProjectName( projectName?: string, projectDirectory?: string, ): ValidationResult { if (projectName) { const projectNameInputResult = validateAgentSafePathInput(projectName, "projectName"); if (projectNameInputResult.isErr()) { return Result.err(projectNameInputResult.error); } } if (projectDirectory) { const projectDirInputResult = validateAgentSafePathInput(projectDirectory, "projectDirectory"); if (projectDirInputResult.isErr()) { return Result.err(projectDirInputResult.error); } } const derivedName = projectName || (projectDirectory ? path.basename(path.resolve(process.cwd(), projectDirectory)) : ""); if (!derivedName) { return Result.ok(""); } const nameToValidate = projectName ? path.basename(projectName) : derivedName; const validationResult = validateProjectName(nameToValidate); if (validationResult.isErr()) { return Result.err(validationResult.error); } return Result.ok(projectName || derivedName); } ================================================ FILE: apps/cli/src/utils/render-title.ts ================================================ import gradient from "gradient-string"; export const TITLE_TEXT = ` ██████╗ ███████╗████████╗████████╗███████╗██████╗ ██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ ██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ ██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ ██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗ ╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ ██║ ███████╗ ██║ ███████║██║ █████╔╝ ██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ ██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ `; const catppuccinTheme = { pink: "#F5C2E7", mauve: "#CBA6F7", red: "#F38BA8", maroon: "#E78284", peach: "#FAB387", yellow: "#F9E2AF", green: "#A6E3A1", teal: "#94E2D5", sky: "#89DCEB", sapphire: "#74C7EC", lavender: "#B4BEFE", }; export const renderTitle = () => { const terminalWidth = process.stdout.columns || 80; const titleLines = TITLE_TEXT.split("\n"); const titleWidth = Math.max(...titleLines.map((line) => line.length)); if (terminalWidth < titleWidth) { const simplifiedTitle = `Better T Stack`; console.log(gradient(Object.values(catppuccinTheme)).multiline(simplifiedTitle)); } else { console.log(gradient(Object.values(catppuccinTheme)).multiline(TITLE_TEXT)); } }; ================================================ FILE: apps/cli/src/utils/sponsors.ts ================================================ import { log, outro, spinner } from "@clack/prompts"; import { Result } from "better-result"; import pc from "picocolors"; import z from "zod"; import { CLIError } from "./errors"; import { cliConsola } from "./terminal-output"; export const SPONSORS_JSON_URL = "https://sponsors.better-t-stack.dev/sponsors.json"; export const GITHUB_SPONSOR_URL = "https://github.com/sponsors/AmanVarshney01"; export type SponsorSummary = { total_sponsors: number; total_lifetime_amount: number; total_current_monthly: number; special_sponsors: number; current_sponsors: number; past_sponsors: number; backers: number; top_sponsor?: { name: string; amount: number; }; }; export type Sponsor = { name?: string; githubId: string; avatarUrl: string; websiteUrl?: string; githubUrl: string; tierName?: string; sinceWhen: string; transactionCount: number; totalProcessedAmount?: number; formattedAmount?: string; }; export type SponsorEntry = { generated_at: string; summary: SponsorSummary; specialSponsors: Sponsor[]; sponsors: Sponsor[]; pastSponsors: Sponsor[]; backers: Sponsor[]; }; type FetchSponsorsOptions = { url?: string; withSpinner?: boolean; timeoutMs?: number; }; const nullableString = z .string() .nullish() .transform((value) => value ?? undefined); const nullableNumber = z .number() .nullish() .transform((value) => value ?? undefined); const sponsorSchema = z.object({ name: nullableString, githubId: z.string(), avatarUrl: z.string(), websiteUrl: nullableString, githubUrl: z.string(), tierName: nullableString, sinceWhen: z.string(), transactionCount: z.number(), totalProcessedAmount: nullableNumber, formattedAmount: nullableString, }); const sponsorSummarySchema = z.object({ total_sponsors: z.number(), total_lifetime_amount: z.number(), total_current_monthly: z.number(), special_sponsors: z.number(), current_sponsors: z.number(), past_sponsors: z.number(), backers: z.number(), top_sponsor: z .object({ name: z.string(), amount: z.number(), }) .nullish() .transform((value) => value ?? undefined), }); const sponsorEntrySchema = z.object({ generated_at: z.string(), summary: sponsorSummarySchema, specialSponsors: z.array(sponsorSchema), sponsors: z.array(sponsorSchema), pastSponsors: z.array(sponsorSchema), backers: z.array(sponsorSchema), }); export async function fetchSponsors(url: string = SPONSORS_JSON_URL) { return fetchSponsorsData({ url, withSpinner: true }); } export async function fetchSponsorsQuietly({ url = SPONSORS_JSON_URL, timeoutMs = 1500, }: Pick = {}) { return fetchSponsorsData({ url, withSpinner: false, timeoutMs }); } export function displaySponsors(sponsors: SponsorEntry) { const { total_sponsors } = sponsors.summary; if (total_sponsors === 0) { log.info("No sponsors found. You can be the first one! ✨"); outro(pc.cyan(`Visit ${GITHUB_SPONSOR_URL} to become a sponsor.`)); return; } displaySponsorsBox(sponsors); if (total_sponsors - sponsors.specialSponsors.length > 0) { log.message( pc.blue(`+${total_sponsors - sponsors.specialSponsors.length} more amazing sponsors.\n`), ); } outro(pc.magenta(`Visit ${GITHUB_SPONSOR_URL} to become a sponsor.`)); } function displaySponsorsBox(sponsors: SponsorEntry) { if (sponsors.specialSponsors.length === 0) { return; } let output = `${pc.bold(pc.cyan("-> Special Sponsors"))}\n\n`; sponsors.specialSponsors.forEach((sponsor: Sponsor, idx: number) => { const displayName = sponsor.name ?? sponsor.githubId; const tier = sponsor.tierName ? ` ${pc.yellow(`(${sponsor.tierName})`)}` : ""; output += `${pc.green(`• ${displayName}`)}${tier}\n`; output += ` ${pc.dim("GitHub:")} https://github.com/${sponsor.githubId}\n`; const website = sponsor.websiteUrl ?? sponsor.githubUrl; if (website) { output += ` ${pc.dim("Website:")} ${website}\n`; } if (idx < sponsors.specialSponsors.length - 1) { output += "\n"; } }); cliConsola.box(output); } export function formatPostInstallSpecialSponsorsSection(sponsors: SponsorEntry): string { if (sponsors.specialSponsors.length === 0) { return ""; } const sponsorTokens = sponsors.specialSponsors.map((sponsor) => { const displayName = sponsor.name ?? sponsor.githubId; return `• ${displayName}`; }); const wrappedSponsorLines = wrapSponsorTokens(sponsorTokens, getPostInstallSponsorLineWidth()); let output = `${pc.bold("Special sponsors")}\n`; wrappedSponsorLines.forEach((line) => { output += `${line}\n`; }); return output.trimEnd(); } function getPostInstallSponsorLineWidth(): number { const terminalWidth = process.stdout.columns; if (!terminalWidth || terminalWidth <= 0) { return 72; } // Keep room for the surrounding box border/padding and avoid edge wrapping. const availableWidth = Math.max(8, terminalWidth - 24); return Math.min(72, availableWidth); } function wrapSponsorTokens(tokens: string[], maxLineWidth: number): string[] { const lines: string[] = []; const separator = " "; let currentLine = ""; tokens.forEach((token) => { const candidateLine = currentLine ? `${currentLine}${separator}${token}` : token; if (candidateLine.length <= maxLineWidth) { currentLine = candidateLine; return; } if (currentLine) { lines.push(currentLine); currentLine = token; return; } lines.push(token); }); if (currentLine) { lines.push(currentLine); } return lines; } async function fetchSponsorsData({ url = SPONSORS_JSON_URL, withSpinner = false, timeoutMs, }: FetchSponsorsOptions): Promise> { const s = withSpinner ? spinner() : null; if (s) { s.start("Fetching sponsors…"); } const controller = timeoutMs ? new AbortController() : null; const timeout = timeoutMs ? setTimeout(() => { controller?.abort(); }, timeoutMs) : null; try { const response = await fetch(url, controller ? { signal: controller.signal } : undefined); if (!response.ok) { const message = `Failed to fetch sponsors: ${response.statusText || String(response.status)}`; if (s) { s.stop(pc.red(message)); } return Result.err(new CLIError({ message })); } const rawSponsors = await response.json(); const parseResult = sponsorEntrySchema.safeParse(rawSponsors); if (!parseResult.success) { const firstIssue = parseResult.error.issues[0]; const path = firstIssue?.path?.join(".") || "unknown"; const message = `Failed to fetch sponsors: invalid response format at "${path}"`; if (s) { s.stop(pc.red(message)); } return Result.err(new CLIError({ message, cause: parseResult.error })); } if (s) { s.stop("Sponsors fetched successfully!"); } return Result.ok(parseResult.data); } catch (error) { const normalizedError = normalizeSponsorFetchError(error); if (s) { s.stop(pc.red(normalizedError.message)); } return Result.err(normalizedError); } finally { if (timeout) { clearTimeout(timeout); } } } function normalizeSponsorFetchError(error: unknown): CLIError { if (error instanceof Error && error.name === "AbortError") { return new CLIError({ message: "Failed to fetch sponsors: request timed out", cause: error, }); } if (CLIError.is(error)) { return error; } if (error instanceof Error) { return new CLIError({ message: error.message.startsWith("Failed to fetch sponsors:") ? error.message : `Failed to fetch sponsors: ${error.message}`, cause: error, }); } return new CLIError({ message: `Failed to fetch sponsors: ${String(error)}`, cause: error, }); } ================================================ FILE: apps/cli/src/utils/telemetry.ts ================================================ /** * Returns true if telemetry/analytics should be enabled, false otherwise. * * - If BTS_TELEMETRY_DISABLED is present and "1", disables analytics. * - Otherwise, BTS_TELEMETRY: "0" disables, "1" enables (default: enabled). */ export function isTelemetryEnabled() { const BTS_TELEMETRY_DISABLED = process.env.BTS_TELEMETRY_DISABLED; const BTS_TELEMETRY = process.env.BTS_TELEMETRY; if (BTS_TELEMETRY_DISABLED !== undefined) { return BTS_TELEMETRY_DISABLED !== "1"; } if (BTS_TELEMETRY !== undefined) { return BTS_TELEMETRY === "1"; } // Default: enabled return true; } ================================================ FILE: apps/cli/src/utils/templates.ts ================================================ import type { CreateInput, Template } from "../types"; export const TEMPLATE_PRESETS: Record = { mern: { database: "mongodb", orm: "mongoose", backend: "express", runtime: "node", frontend: ["react-router"], api: "orpc", auth: "better-auth", payments: "none", addons: ["turborepo"], examples: ["todo"], dbSetup: "mongodb-atlas", webDeploy: "none", serverDeploy: "none", }, pern: { database: "postgres", orm: "drizzle", backend: "express", runtime: "node", frontend: ["tanstack-router"], api: "trpc", auth: "better-auth", payments: "none", addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", }, t3: { database: "postgres", orm: "prisma", backend: "self", runtime: "none", frontend: ["next"], api: "trpc", auth: "better-auth", payments: "none", addons: ["biome", "turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", }, uniwind: { database: "none", orm: "none", backend: "none", runtime: "none", frontend: ["native-uniwind"], api: "none", auth: "none", payments: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", }, none: null, }; export function getTemplateConfig(template: Template) { if (template === "none" || !template) { return null; } const config = TEMPLATE_PRESETS[template]; if (!config) { throw new Error(`Unknown template: ${template}`); } return config; } export function getTemplateDescription(template: Template) { const descriptions: Record = { mern: "MongoDB + Express + React + Node.js - Classic MERN stack", pern: "PostgreSQL + Express + React + Node.js - Popular PERN stack", t3: "T3 Stack - Next.js + tRPC + Prisma + PostgreSQL + Better Auth", uniwind: "Expo + Uniwind native app with no backend services", none: "No template - Full customization", }; return descriptions[template] || ""; } export function listAvailableTemplates() { return Object.keys(TEMPLATE_PRESETS).filter((t) => t !== "none") as Template[]; } ================================================ FILE: apps/cli/src/utils/terminal-output.ts ================================================ import { log, spinner } from "@clack/prompts"; import { consola, createConsola } from "consola"; import { isSilent } from "./context"; type SpinnerLike = { start(message: string): void; stop(message?: string): void; message(message: string): void; }; const noopSpinner: SpinnerLike = { start() {}, stop() {}, message() {}, }; export function createSpinner(): SpinnerLike { return isSilent() ? noopSpinner : spinner(); } const baseConsola = createConsola({ ...consola.options, formatOptions: { ...consola.options.formatOptions, date: false, }, }); export const cliLog = { info(message: string) { if (!isSilent()) log.info(message); }, warn(message: string) { if (!isSilent()) log.warn(message); }, success(message: string) { if (!isSilent()) log.success(message); }, error(message: string) { if (!isSilent()) log.error(message); }, message(message: string) { if (!isSilent()) log.message(message); }, }; export const cliConsola = { error(message: string) { if (!isSilent()) baseConsola.error(message); }, warn(message: string) { if (!isSilent()) baseConsola.warn(message); }, info(message: string) { if (!isSilent()) baseConsola.info(message); }, fatal(message: string) { if (!isSilent()) baseConsola.fatal(message); }, box(message: string) { if (!isSilent()) baseConsola.box(message); }, }; ================================================ FILE: apps/cli/src/utils/ts-morph.ts ================================================ import { type ArrayLiteralExpression, IndentationText, type ObjectLiteralExpression, Project, QuoteKind, SyntaxKind, } from "ts-morph"; export const tsProject = new Project({ useInMemoryFileSystem: false, skipAddingFilesFromTsConfig: true, manipulationSettings: { quoteKind: QuoteKind.Single, indentationText: IndentationText.TwoSpaces, }, }); export function ensureArrayProperty(obj: ObjectLiteralExpression, name: string) { return (obj.getProperty(name)?.getFirstDescendantByKind(SyntaxKind.ArrayLiteralExpression) ?? obj .addPropertyAssignment({ name, initializer: "[]" }) .getFirstDescendantByKindOrThrow( SyntaxKind.ArrayLiteralExpression, )) as ArrayLiteralExpression; } ================================================ FILE: apps/cli/src/validation.ts ================================================ import { Result } from "better-result"; import type { CLIInput, ProjectConfig } from "./types"; import { getProvidedFlags, processFlags, validateArrayOptions } from "./utils/config-processing"; import { validateConfigForProgrammaticUse, validateFullConfig } from "./utils/config-validation"; import { ValidationError } from "./utils/errors"; import { extractAndValidateProjectName } from "./utils/project-name-validation"; type ValidationResult = Result; const CORE_STACK_FLAGS = new Set([ "database", "orm", "backend", "runtime", "frontend", "addons", "examples", "auth", "dbSetup", "payments", "api", "webDeploy", "serverDeploy", ]); function validateYesFlagCombination( options: CLIInput, providedFlags: Set, ): ValidationResult { if (!options.yes) return Result.ok(undefined); if (options.template && options.template !== "none") { return Result.ok(undefined); } const coreStackFlagsProvided = Array.from(providedFlags).filter((flag) => CORE_STACK_FLAGS.has(flag), ); if (coreStackFlagsProvided.length > 0) { return Result.err( new ValidationError({ message: `Cannot combine --yes with core stack configuration flags: ${coreStackFlagsProvided.map((f) => `--${f}`).join(", ")}. ` + "The --yes flag uses default configuration. Remove these flags or use --yes without them.", }), ); } return Result.ok(undefined); } export function processAndValidateFlags( options: CLIInput, providedFlags: Set, projectName?: string, ): ValidationResult> { if (options.yolo) { const cfg = processFlags(options, projectName); const validatedProjectNameResult = extractAndValidateProjectName( projectName, options.projectDirectory, ); if (validatedProjectNameResult.isOk() && validatedProjectNameResult.value) { cfg.projectName = validatedProjectNameResult.value; } return Result.ok(cfg); } const yesFlagResult = validateYesFlagCombination(options, providedFlags); if (yesFlagResult.isErr()) { return Result.err(yesFlagResult.error); } const arrayOptionsResult = validateArrayOptions(options); if (arrayOptionsResult.isErr()) { return Result.err(arrayOptionsResult.error); } const config = processFlags(options, projectName); const validatedProjectNameResult = extractAndValidateProjectName( projectName, options.projectDirectory, ); if (validatedProjectNameResult.isErr()) { return Result.err(validatedProjectNameResult.error); } if (validatedProjectNameResult.value) { config.projectName = validatedProjectNameResult.value; } const fullConfigResult = validateFullConfig(config, providedFlags, options); if (fullConfigResult.isErr()) { return Result.err(fullConfigResult.error); } return Result.ok(config); } export function processProvidedFlagsWithoutValidation( options: CLIInput, projectName?: string, ): ValidationResult> { if (!options.yolo) { const providedFlags = getProvidedFlags(options); const yesFlagResult = validateYesFlagCombination(options, providedFlags); if (yesFlagResult.isErr()) { return Result.err(yesFlagResult.error); } } const config = processFlags(options, projectName); const validatedProjectNameResult = extractAndValidateProjectName( projectName, options.projectDirectory, ); if (validatedProjectNameResult.isErr()) { return Result.err(validatedProjectNameResult.error); } if (validatedProjectNameResult.value) { config.projectName = validatedProjectNameResult.value; } return Result.ok(config); } export function validateConfigCompatibility( config: Partial, providedFlags?: Set, options?: CLIInput, ): ValidationResult { if (options?.yolo) return Result.ok(undefined); if (options && providedFlags) { return validateFullConfig(config, providedFlags, options); } else { return validateConfigForProgrammaticUse(config); } } export { getProvidedFlags }; ================================================ FILE: apps/cli/src/virtual.ts ================================================ /** * Virtual filesystem export for web preview * Re-exports from @better-t-stack/template-generator for browser-compatible usage */ // Re-export everything from template-generator for web/programmatic usage export { // Generator functions generate, // Virtual file system types VirtualFileSystem, type VirtualFileTree, type VirtualFile, type VirtualDirectory, type VirtualNode, // Generator types type GeneratorOptions, // Error types GeneratorError, // Embedded templates for browser usage EMBEDDED_TEMPLATES, TEMPLATE_COUNT, } from "@better-t-stack/template-generator"; export { Result } from "better-result"; // Re-export types needed for configuration options export type { Database, ORM, Backend, Runtime, Frontend, Addons, Examples, PackageManager, DatabaseSetup, API, Auth, Payments, WebDeploy, ServerDeploy, ProjectConfig, } from "@better-t-stack/types"; ================================================ FILE: apps/cli/test/add-handler.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { add } from "../src/index"; import { SMOKE_DIR } from "./setup"; describe("add()", () => { it("returns an error in silent mode instead of exiting when the project config is missing", async () => { const projectDir = join(SMOKE_DIR, "missing-bts-config"); await mkdir(projectDir, { recursive: true }); const result = await add({ projectDir, addons: ["biome"], install: false, }); expect(result).toBeDefined(); expect(result?.success).toBe(false); expect(result?.error).toContain("No Better-T-Stack project found"); }); }); ================================================ FILE: apps/cli/test/addon-options.test.ts ================================================ import { beforeEach, describe, expect, it } from "bun:test"; import path from "node:path"; import fs from "fs-extra"; import { add, create } from "../src/index"; import { readBtsConfig } from "../src/utils/bts-config"; const SMOKE_DIR_PATH = path.join(import.meta.dir, "..", ".smoke"); describe("Addon options", () => { beforeEach(() => { process.env.BTS_SKIP_EXTERNAL_COMMANDS = "1"; process.env.BTS_TEST_MODE = "1"; }); it("persists addonOptions during create and keeps reproducible command on normal flags", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "addon-options-create"); await fs.remove(projectPath); const addonOptions = { wxt: { template: "react" as const, devPort: 5555 }, opentui: { template: "react" as const }, fumadocs: { template: "next-mdx" as const, devPort: 4000 }, mcp: { scope: "project" as const, servers: ["context7"] as const, agents: ["cursor", "codex"] as const, }, skills: { scope: "project" as const, agents: ["cursor", "codex"] as const, selections: [ { source: "vercel-labs/agent-skills" as const, skills: ["web-design-guidelines"], }, ], }, ultracite: { linter: "biome" as const, editors: ["vscode", "cursor"] as const, agents: ["claude", "codex"] as const, hooks: ["claude"] as const, }, }; const result = await create(projectPath, { frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", payments: "none", api: "trpc", addons: ["wxt", "opentui", "fumadocs", "mcp", "skills", "ultracite"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, addonOptions, }); expect(result.isOk()).toBe(true); if (result.isErr()) return; expect(result.value.projectConfig.addonOptions).toEqual(addonOptions); expect(result.value.reproducibleCommand).toContain("--frontend tanstack-router"); expect(result.value.reproducibleCommand).toContain( "--addons wxt opentui fumadocs mcp skills ultracite", ); expect(result.value.reproducibleCommand).not.toContain("create-json --input"); const btsConfig = await readBtsConfig(projectPath); expect(btsConfig?.addonOptions).toEqual(addonOptions); }); it("persists addonOptions during add", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "addon-options-add"); await fs.remove(projectPath); const createResult = await create(projectPath, { yes: true, install: false, disableAnalytics: true, }); expect(createResult.isOk()).toBe(true); if (createResult.isErr()) return; const addonOptions = { wxt: { template: "react" as const }, mcp: { scope: "project" as const, servers: ["context7"] as const, agents: ["cursor"] as const, }, }; const addResult = await add({ projectDir: projectPath, addons: ["wxt", "mcp"], addonOptions, install: false, packageManager: "bun", }); expect(addResult?.success).toBe(true); const btsConfig = await readBtsConfig(projectPath); expect(btsConfig?.addonOptions).toEqual(addonOptions); expect(btsConfig?.addons).toEqual(expect.arrayContaining(["turborepo", "wxt", "mcp"])); }); it("deep merges nested addonOptions during add", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "addon-options-deep-merge"); await fs.remove(projectPath); const createResult = await create(projectPath, { yes: true, install: false, disableAnalytics: true, }); expect(createResult.isOk()).toBe(true); if (createResult.isErr()) return; const firstAddResult = await add({ projectDir: projectPath, addons: ["mcp"], addonOptions: { mcp: { scope: "project", servers: ["context7"], }, }, install: false, packageManager: "bun", }); expect(firstAddResult?.success).toBe(true); const secondAddResult = await add({ projectDir: projectPath, addons: ["wxt"], addonOptions: { mcp: { agents: ["codex"], }, wxt: { template: "react", }, }, install: false, packageManager: "bun", }); expect(secondAddResult?.success).toBe(true); const btsConfig = await readBtsConfig(projectPath); expect(btsConfig?.addonOptions).toEqual({ mcp: { scope: "project", servers: ["context7"], agents: ["codex"], }, wxt: { template: "react", }, }); }); }); ================================================ FILE: apps/cli/test/addon-setup-regressions.test.ts ================================================ import { describe, expect, it } from "bun:test"; import path from "node:path"; import fs from "fs-extra"; import { setupMcp, getRecommendedMcpServers } from "../src/helpers/addons/mcp-setup"; import { setupSkills } from "../src/helpers/addons/skills-setup"; import type { ProjectConfig } from "../src/types"; import { runWithContextAsync } from "../src/utils/context"; import { SMOKE_DIR } from "./setup"; function createProjectConfig(overrides: Partial = {}): ProjectConfig { return { projectName: "test-app", projectDir: path.join(SMOKE_DIR, "addon-setup-regressions"), relativePath: ".", database: "sqlite", orm: "drizzle", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], auth: "none", payments: "none", git: false, packageManager: "bun", install: false, dbSetup: "none", api: "trpc", webDeploy: "none", serverDeploy: "none", ...overrides, }; } async function writeFakeBunx(binDir: string, markerFile: string, exitCode = 99) { const bunxPath = path.join(binDir, "bunx"); await fs.ensureDir(binDir); await fs.writeFile( bunxPath, `#!/bin/sh printf '%s\n' "$*" >> "${markerFile}" exit ${exitCode} `, ); await fs.chmod(bunxPath, 0o755); } async function runWithFakeBunx( projectDir: string, callback: () => Promise, exitCode = 99, ): Promise<{ markerFile: string; result: T }> { const binDir = path.join(projectDir, ".fake-bin"); const markerFile = path.join(projectDir, "runner.log"); await fs.ensureDir(projectDir); await writeFakeBunx(binDir, markerFile, exitCode); const previousPath = process.env.PATH; const previousSkipExternal = process.env.BTS_SKIP_EXTERNAL_COMMANDS; const previousTestMode = process.env.BTS_TEST_MODE; process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`; delete process.env.BTS_SKIP_EXTERNAL_COMMANDS; delete process.env.BTS_TEST_MODE; try { const result = await callback(); return { markerFile, result }; } finally { if (previousPath === undefined) { delete process.env.PATH; } else { process.env.PATH = previousPath; } if (previousSkipExternal === undefined) { delete process.env.BTS_SKIP_EXTERNAL_COMMANDS; } else { process.env.BTS_SKIP_EXTERNAL_COMMANDS = previousSkipExternal; } if (previousTestMode === undefined) { delete process.env.BTS_TEST_MODE; } else { process.env.BTS_TEST_MODE = previousTestMode; } } } describe("Addon setup regressions", () => { it("uses a package execution command for the Better T Stack MCP server target", () => { const servers = getRecommendedMcpServers(createProjectConfig(), "project"); const betterTStackServer = servers.find((server) => server.key === "better-t-stack"); expect(betterTStackServer?.target).toBe("bunx create-better-t-stack@latest mcp"); }); it("preserves explicit empty MCP selections in silent mode", async () => { const projectDir = path.join(SMOKE_DIR, "mcp-explicit-empty"); await fs.remove(projectDir); const config = createProjectConfig({ projectDir, addons: ["mcp"], addonOptions: { mcp: { scope: "project", servers: [], agents: [], }, }, }); const { markerFile, result } = await runWithFakeBunx(projectDir, () => runWithContextAsync({ silent: true }, () => setupMcp(config)), ); expect(result.isOk()).toBe(true); expect(await fs.pathExists(markerFile)).toBe(false); }); it("installs explicitly configured MCP servers even when they are not recommended", async () => { const projectDir = path.join(SMOKE_DIR, "mcp-nonrecommended-server"); await fs.remove(projectDir); const config = createProjectConfig({ projectDir, addons: ["mcp"], addonOptions: { mcp: { scope: "project", servers: ["next-devtools"], agents: ["cursor"], }, }, }); const { markerFile, result } = await runWithFakeBunx( projectDir, () => runWithContextAsync({ silent: true }, () => setupMcp(config)), 0, ); expect(result.isOk()).toBe(true); expect(await fs.readFile(markerFile, "utf8")).toContain("--name next-devtools"); }); it("returns an error when every requested MCP install fails", async () => { const projectDir = path.join(SMOKE_DIR, "mcp-all-installs-fail"); await fs.remove(projectDir); const config = createProjectConfig({ projectDir, addons: ["mcp"], addonOptions: { mcp: { scope: "project", servers: ["context7"], agents: ["cursor"], }, }, }); const { markerFile, result } = await runWithFakeBunx(projectDir, () => runWithContextAsync({ silent: true }, () => setupMcp(config)), ); expect(result.isErr()).toBe(true); expect(await fs.readFile(markerFile, "utf8")).toContain("context7"); }); it("preserves an explicit empty skills agent list in silent mode", async () => { const projectDir = path.join(SMOKE_DIR, "skills-explicit-empty-agents"); await fs.remove(projectDir); const config = createProjectConfig({ projectDir, frontend: ["next"], addons: ["skills"], addonOptions: { skills: { scope: "project", agents: [], selections: [ { source: "vercel-labs/agent-skills", skills: ["web-design-guidelines"], }, ], }, }, }); const { markerFile, result } = await runWithFakeBunx(projectDir, () => runWithContextAsync({ silent: true }, () => setupSkills(config)), ); expect(result.isOk()).toBe(true); expect(await fs.pathExists(markerFile)).toBe(false); }); it("uses persisted skills options and preserves explicit non-recommended selections", async () => { const projectDir = path.join(SMOKE_DIR, "skills-persisted-selections"); await fs.remove(projectDir); await fs.ensureDir(projectDir); await fs.writeFile( path.join(projectDir, "bts.jsonc"), JSON.stringify({ version: "0.0.0-test", createdAt: new Date(0).toISOString(), projectName: "test-app", database: "sqlite", orm: "drizzle", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], addons: ["skills"], examples: ["none"], auth: "none", payments: "none", packageManager: "bun", dbSetup: "none", api: "trpc", webDeploy: "none", serverDeploy: "none", addonOptions: { skills: { scope: "project", agents: ["codex"], selections: [ { source: "vercel/turborepo", skills: ["turborepo"], }, ], }, }, }), ); const config = createProjectConfig({ projectDir, frontend: ["tanstack-router"], addons: ["skills"], }); const { markerFile, result } = await runWithFakeBunx( projectDir, () => runWithContextAsync({ silent: true }, () => setupSkills(config)), 0, ); expect(result.isOk()).toBe(true); const commandLog = await fs.readFile(markerFile, "utf8"); expect(commandLog).toContain("skills@latest add vercel/turborepo"); expect(commandLog).toContain("--agent codex"); expect(commandLog).toContain("--skill turborepo"); }); it("recommends evlog skills when evlog addon is selected", async () => { const projectDir = path.join(SMOKE_DIR, "skills-evlog-recommended"); await fs.remove(projectDir); const config = createProjectConfig({ projectDir, addons: ["skills", "evlog"], addonOptions: { skills: { scope: "project", agents: ["codex"], }, }, }); const { markerFile, result } = await runWithFakeBunx( projectDir, () => runWithContextAsync({ silent: true }, () => setupSkills(config)), 0, ); expect(result.isOk()).toBe(true); const commandLog = await fs.readFile(markerFile, "utf8"); expect(commandLog).toContain("skills@latest add https://www.evlog.dev"); expect(commandLog).toContain("--skill review-logging-patterns analyze-logs"); expect(commandLog).toContain("--agent codex"); }); it("does not install upgrade skills from the curated skills addon", async () => { const projectDir = path.join(SMOKE_DIR, "skills-no-upgrade-skills"); await fs.remove(projectDir); const config = createProjectConfig({ projectDir, frontend: ["native-bare"], addons: ["skills"], addonOptions: { skills: { scope: "project", agents: ["codex"], }, }, }); const { markerFile, result } = await runWithFakeBunx( projectDir, () => runWithContextAsync({ silent: true }, () => setupSkills(config)), 0, ); expect(result.isOk()).toBe(true); const commandLog = await fs.readFile(markerFile, "utf8"); expect(commandLog).toContain("skills@latest add expo/skills"); expect(commandLog).not.toContain("upgrading-expo"); expect(commandLog).not.toContain("upgrade"); }); }); ================================================ FILE: apps/cli/test/addons.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { DiagnosticCategory, flattenDiagnosticMessageText, ModuleKind, ScriptTarget, transpileModule, } from "typescript"; import { add, type Addons, type Backend, type Frontend } from "../src"; import { getCompatibleAddons } from "../src/utils/compatibility-rules"; import { expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; async function readSourceFiles(dir: string): Promise<{ path: string; content: string }[]> { if (!existsSync(dir)) return []; const entries = await readdir(dir, { withFileTypes: true }); const files = await Promise.all( entries.map(async (entry) => { const entryPath = join(dir, entry.name); if (entry.isDirectory()) return readSourceFiles(entryPath); if (!/\.(?:cjs|js|mjs|ts|tsx|vue)$/.test(entry.name)) return []; return [{ path: entryPath, content: await readFile(entryPath, "utf-8") }]; }), ); return files.flat(); } function expectParseableTypeScript(content: string) { const diagnostics = transpileModule(content, { compilerOptions: { module: ModuleKind.ESNext, target: ScriptTarget.ESNext, }, reportDiagnostics: true, }).diagnostics?.filter((diagnostic) => diagnostic.category === DiagnosticCategory.Error) ?? []; expect( diagnostics.map((diagnostic) => flattenDiagnosticMessageText(diagnostic.messageText, "\n")), ).toEqual([]); } function expectDocsShapedEvlogAuth(content: string) { expect(content).not.toContain("createEvlogAuth"); expect(content).not.toContain("toHeaders"); expect(content).not.toContain("GetSessionInput"); expect(content).not.toContain("GetSessionResult"); expect(content).not.toContain("toEvlogAuthEvent"); expect(content).not.toContain("await identifyUser(event);"); expect(content).not.toContain('declare module "h3"'); expect(content).not.toContain("H3EventContext"); expect(content).not.toContain("as unknown as BetterAuthInstance"); } describe("Addon Configurations", () => { describe("Universal Addons (no frontend restrictions)", () => { const universalAddons = ["biome", "lefthook", "husky", "turborepo", "mcp"]; for (const addon of universalAddons) { it(`should work with ${addon} addon on any frontend`, async () => { const result = await runTRPCTest({ projectName: `${addon}-universal`, addons: [addon as Addons], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); describe("Frontend-Specific Addons", () => { describe("PWA Addon", () => { const pwaCompatibleFrontends = ["tanstack-router", "react-router", "solid", "next"]; for (const frontend of pwaCompatibleFrontends) { it(`should work with PWA + ${frontend}`, async () => { const config: TestConfig = { projectName: `pwa-${frontend}`, addons: ["pwa"], frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; // Handle special frontend requirements if (frontend === "solid") { config.api = "orpc"; // tRPC not supported with solid } else { config.api = "trpc"; } const result = await runTRPCTest(config); expectSuccess(result); }); } const pwaIncompatibleFrontends = [ "nuxt", "svelte", "native-bare", "native-uniwind", "native-unistyles", ]; for (const frontend of pwaIncompatibleFrontends) { it(`should fail with PWA + ${frontend}`, async () => { const config: TestConfig = { projectName: `pwa-${frontend}-fail`, addons: ["pwa"], frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }; if (["nuxt", "svelte"].includes(frontend)) { config.api = "orpc"; } else { config.api = "trpc"; } const result = await runTRPCTest(config); expectError( result, "pwa addon requires one of these frontends: tanstack-router, react-router, solid, next", ); }); } }); describe("Tauri Addon", () => { const tauriCompatibleFrontends = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "astro", ]; for (const frontend of tauriCompatibleFrontends) { it(`should work with Tauri + ${frontend}`, async () => { const config: TestConfig = { projectName: `tauri-${frontend}`, addons: ["tauri"], frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; if (["nuxt", "svelte", "solid", "astro"].includes(frontend)) { config.api = "orpc"; } else { config.api = "trpc"; } const result = await runTRPCTest(config); expectSuccess(result); }); } const tauriIncompatibleFrontends = ["native-bare", "native-uniwind", "native-unistyles"]; for (const frontend of tauriIncompatibleFrontends) { it(`should fail with Tauri + ${frontend}`, async () => { const result = await runTRPCTest({ projectName: `tauri-${frontend}-fail`, addons: ["tauri"], frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tauri addon requires one of these frontends"); }); } }); describe("Electrobun Addon", () => { const electrobunCompatibleFrontends = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "astro", ]; for (const frontend of electrobunCompatibleFrontends) { it(`should work with Electrobun + ${frontend}`, async () => { const config: TestConfig = { projectName: `electrobun-${frontend}`, addons: ["electrobun"], frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; config.api = ["nuxt", "svelte", "solid", "astro"].includes(frontend) ? "orpc" : "trpc"; const result = await runTRPCTest(config); expectSuccess(result); }); } const electrobunIncompatibleFrontends = ["native-bare", "native-uniwind", "native-unistyles"]; for (const frontend of electrobunIncompatibleFrontends) { it(`should fail with Electrobun + ${frontend}`, async () => { const config: TestConfig = { projectName: `electrobun-${frontend}-fail`, addons: ["electrobun"], frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }; config.api = "trpc"; const result = await runTRPCTest(config); expectError(result, "electrobun addon requires one of these frontends"); }); } }); }); describe("Multiple Addons", () => { it("should work with multiple compatible addons", async () => { const result = await runTRPCTest({ projectName: "multiple-addons", addons: ["biome", "husky", "turborepo", "pwa"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with lefthook and husky together", async () => { const result = await runTRPCTest({ projectName: "both-git-hooks", addons: ["lefthook", "husky"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with incompatible addon combination", async () => { const result = await runTRPCTest({ projectName: "incompatible-addons-fail", addons: ["pwa"], // PWA not compatible with nuxt frontend: ["nuxt"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "pwa addon requires one of these frontends"); }); it("should fail when turborepo and nx are combined", async () => { const result = await runTRPCTest({ projectName: "monorepo-addon-conflict", addons: ["turborepo", "nx"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot combine 'turborepo' and 'nx' addons"); }); it("should deduplicate addons", async () => { const result = await runTRPCTest({ projectName: "duplicate-addons", addons: ["biome", "biome", "turborepo"], // Duplicate biome frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Evlog Addon", () => { it("should not offer evlog for Convex projects", () => { const compatibleAddons = getCompatibleAddons( ["evlog", "mcp"] as Addons[], ["tanstack-start", "native-uniwind"] as Frontend[], [], "better-auth", "convex", "none", ); expect(compatibleAddons).not.toContain("evlog"); expect(compatibleAddons).toContain("mcp"); }); const backendSnippets: Record = { hono: 'import { evlog, type EvlogVariables } from "evlog/hono";', express: 'import { evlog } from "evlog/express";', fastify: 'import { evlog } from "evlog/fastify";', elysia: 'import { evlog } from "evlog/elysia";', convex: "", self: "", none: "", }; for (const backend of ["hono", "express", "fastify", "elysia"] as const) { it(`should wire evlog middleware for ${backend}`, async () => { const result = await runTRPCTest({ projectName: `evlog-${backend}`, addons: ["evlog"], frontend: ["tanstack-router"], backend, runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const serverIndex = await readFile(join(projectDir, "apps/server/src/index.ts"), "utf-8"); const serverPackageJson = await readFile( join(projectDir, "apps/server/package.json"), "utf-8", ); expect(serverIndex).toContain('import { initLogger } from "evlog";'); expect(serverIndex).toContain(backendSnippets[backend]); expect(serverIndex).toContain(`env: { service: "evlog-${backend}-server" }`); expect(serverPackageJson).toContain('"evlog": "^2.14.1"'); }); } const webCases = [ { frontend: "next", api: "trpc", files: [ ["apps/web/src/lib/evlog.ts", "createEvlog"], ["apps/web/instrumentation.ts", "defineNodeInstrumentation"], ["apps/web/src/proxy.ts", "evlogMiddleware"], ["apps/web/src/app/api/trpc/[trpc]/route.ts", "withEvlog(handler)"], ], }, { frontend: "nuxt", api: "orpc", files: [["apps/web/nuxt.config.ts", '"evlog/nuxt"']], }, { frontend: "svelte", api: "orpc", files: [ ["apps/web/vite.config.ts", "evlog({ service:"], ["apps/web/src/hooks.server.ts", "createEvlogHooks"], ["apps/web/src/app.d.ts", "log: RequestLogger"], ], }, { frontend: "tanstack-start", api: "trpc", files: [ ["apps/web/nitro.config.ts", 'evlog from "evlog/nitro/v3"'], ["apps/web/src/routes/__root.tsx", "evlogErrorHandler"], ], }, { frontend: "astro", api: "orpc", files: [ ["apps/web/src/middleware.ts", "createRequestLogger"], ["apps/web/src/env.d.ts", "log: RequestLogger"], ], }, ] as const; for (const webCase of webCases) { it(`should wire evlog for ${webCase.frontend} fullstack projects`, async () => { const result = await runTRPCTest({ projectName: `evlog-${webCase.frontend}-web`, addons: ["evlog"], frontend: [webCase.frontend as Frontend], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "none", api: webCase.api, examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); for (const [filePath, snippet] of webCase.files) { const file = await readFile(join(projectDir, filePath), "utf-8"); expect(file).toContain(snippet); } const webPackageJson = await readFile(join(projectDir, "apps/web/package.json"), "utf-8"); expect(webPackageJson).toContain('"evlog": "^2.14.1"'); if (webCase.frontend === "tanstack-start") { expect(webPackageJson).toContain('"nitro": "^3.0.260429-beta"'); } }); } it("should keep Nuxt config parseable with Cloudflare web deploy", async () => { const result = await runTRPCTest({ projectName: "evlog-nuxt-cloudflare-web", addons: ["evlog"], frontend: ["nuxt"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", examples: ["none"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const nuxtConfig = await readFile(join(projectDir, "apps/web/nuxt.config.ts"), "utf-8"); expect(nuxtConfig).toContain('"evlog/nuxt"'); expect(nuxtConfig).toContain("nitro:"); expect(nuxtConfig).toContain('import { existsSync } from "node:fs";'); expect(nuxtConfig).toContain('import { fileURLToPath } from "node:url";'); expect(nuxtConfig).toContain("const alchemyConfigPath = fileURLToPath"); expect(nuxtConfig).toContain("const hasAlchemyConfig = existsSync(alchemyConfigPath);"); expect(nuxtConfig).toContain("const shouldUseAlchemy = !isNuxtPrepare && hasAlchemyConfig;"); expect(nuxtConfig).toContain("alchemy({ dev: { configPath: alchemyConfigPath } })"); expect(nuxtConfig).toContain("isNuxtDev"); expect(nuxtConfig).toContain("const cloudflareWorkersShimPath = fileURLToPath"); expect(nuxtConfig).toContain('"cloudflare:workers"'); expect(nuxtConfig).toContain("cloudflareWorkersShimPath"); expect(nuxtConfig).toContain("evlog:"); expectParseableTypeScript(nuxtConfig); }); it("should type Nitro Better Auth events for Nuxt Cloudflare projects", async () => { const result = await runTRPCTest({ projectName: "evlog-nuxt-cloudflare-auth", addons: ["evlog"], frontend: ["nuxt"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "orpc", examples: ["none"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const authMiddleware = await readFile( join(projectDir, "apps/web/server/middleware/evlog-auth.ts"), "utf-8", ); const authClient = await readFile( join(projectDir, "apps/web/app/plugins/auth-client.ts"), "utf-8", ); const envServer = await readFile(join(projectDir, "packages/env/src/server.ts"), "utf-8"); expect(existsSync(join(projectDir, "apps/web/server/plugins/evlog-auth.ts"))).toBe(false); expect(authMiddleware).toContain( 'import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth";', ); expect(authMiddleware).toContain( "const identify = createAuthMiddleware(createAuth() as BetterAuthInstance, {", ); expect(authMiddleware).toContain('exclude: ["/api/auth/**"]'); expect(authMiddleware).toContain("maskEmail: true"); expect(authMiddleware).toContain("export default defineEventHandler(async (event) => {"); expect(authMiddleware).toContain( "await identify(event.context.log, event.headers, event.path);", ); expect(authMiddleware).not.toContain("createAuthIdentifier("); expectDocsShapedEvlogAuth(authMiddleware); expectParseableTypeScript(authMiddleware); expect(authClient).not.toContain("baseURL:"); expect(authClient).not.toContain("as string"); expectParseableTypeScript(authClient); expect(envServer).toContain('/// '); expectParseableTypeScript(envServer); }); const fullstackBetterAuthEvlogCases = [ { frontend: "next", api: "trpc", path: "apps/web/src/lib/evlog-auth.ts", expected: "createAuthMiddleware(auth as BetterAuthInstance", }, { frontend: "nuxt", api: "orpc", path: "apps/web/server/middleware/evlog-auth.ts", expected: "createAuthMiddleware(auth as BetterAuthInstance", }, { frontend: "svelte", api: "orpc", path: "apps/web/src/hooks.server.ts", expected: "createAuthMiddleware(auth as BetterAuthInstance", }, { frontend: "tanstack-start", api: "trpc", path: "apps/web/server/plugins/evlog-auth.ts", expected: "createAuthIdentifier(auth as BetterAuthInstance", }, { frontend: "astro", api: "orpc", path: "apps/web/src/middleware.ts", expected: "createAuthMiddleware(auth as BetterAuthInstance", }, ] as const; for (const webCase of fullstackBetterAuthEvlogCases) { it(`should generate docs-shaped evlog Better Auth wiring for ${webCase.frontend} fullstack projects`, async () => { const result = await runTRPCTest({ projectName: `evlog-${webCase.frontend}-fullstack-auth`, addons: ["evlog"], frontend: [webCase.frontend as Frontend], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: webCase.api, examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const authFile = await readFile(join(projectDir, webCase.path), "utf-8"); if (webCase.frontend === "tanstack-start") { expect(authFile).toContain( 'import { createAuthIdentifier, type BetterAuthInstance } from "evlog/better-auth";', ); expect(authFile).not.toContain("createAuthMiddleware("); } else { expect(authFile).toContain( 'import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth";', ); } expect(authFile).toContain(webCase.expected); expect(authFile).toContain('exclude: ["/api/auth/**"]'); expect(authFile).toContain("maskEmail: true"); expectDocsShapedEvlogAuth(authFile); expectParseableTypeScript(authFile); }); } const fullstackBetterAuthFactoryEvlogCases = [ { frontend: "next", api: "trpc", path: "apps/web/src/lib/evlog-auth.ts", expected: "createAuthMiddleware(createAuth() as BetterAuthInstance", insideMarker: "export async function identifyEvlogUser", }, { frontend: "nuxt", api: "orpc", path: "apps/web/server/middleware/evlog-auth.ts", expected: "createAuthMiddleware(createAuth() as BetterAuthInstance", insideMarker: "export default defineEventHandler", }, { frontend: "svelte", api: "orpc", path: "apps/web/src/hooks.server.ts", expected: "createAuthMiddleware(createAuth(authEnv) as BetterAuthInstance", insideMarker: "const evlogAuthHandle", }, { frontend: "tanstack-start", api: "trpc", path: "apps/web/server/plugins/evlog-auth.ts", expected: "createAuthIdentifier(createAuth() as BetterAuthInstance", insideMarker: 'nitroApp.hooks.hook("request", async (event) => {', }, { frontend: "astro", api: "orpc", path: "apps/web/src/middleware.ts", expected: "createAuthMiddleware(createAuth() as BetterAuthInstance", insideMarker: "export const onRequest", }, ] as const; for (const webCase of fullstackBetterAuthFactoryEvlogCases) { it(`should keep factory-based evlog auth wiring inside the request path for ${webCase.frontend}`, async () => { const result = await runTRPCTest({ projectName: `evlog-${webCase.frontend}-cloudflare-auth`, addons: ["evlog"], frontend: [webCase.frontend as Frontend], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: webCase.api, examples: ["none"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const authFile = await readFile(join(projectDir, webCase.path), "utf-8"); expect(authFile).toContain(webCase.expected); expect(authFile.indexOf(webCase.insideMarker)).toBeLessThan( authFile.indexOf(webCase.expected), ); expect(authFile).toContain('exclude: ["/api/auth/**"]'); expect(authFile).toContain("maskEmail: true"); expectDocsShapedEvlogAuth(authFile); expectParseableTypeScript(authFile); }); } it("should reject evlog for Convex backend projects", async () => { const result = await runTRPCTest({ projectName: "evlog-convex-fail", addons: ["evlog"], frontend: ["tanstack-start", "native-uniwind"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "better-auth", api: "none", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, expectError: true, }); expectError(result, "Convex and backend none are not supported yet"); }); it("should wire evlog Better Auth and AI SDK helpers for server projects", async () => { const result = await runTRPCTest({ projectName: "evlog-hono-auth-ai", addons: ["evlog"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", examples: ["ai"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const serverIndex = await readFile(join(projectDir, "apps/server/src/index.ts"), "utf-8"); expect(serverIndex).toContain( 'import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth";', ); expect(serverIndex).toContain( "const identifyUser = createAuthMiddleware(auth as BetterAuthInstance", ); expect(serverIndex).toContain( 'await identifyUser(c.get("log"), c.req.raw.headers, c.req.path);', ); expectDocsShapedEvlogAuth(serverIndex); expect(serverIndex).toContain( 'import { createAILogger, createEvlogIntegration } from "evlog/ai";', ); expect(serverIndex).toContain('const ai = createAILogger(c.get("log"));'); expect(serverIndex).toContain("model: ai.wrap(model)"); expect(serverIndex).toContain("integrations: [createEvlogIntegration(ai)]"); }); it("should wire evlog AI SDK helpers for Express server projects", async () => { const result = await runTRPCTest({ projectName: "evlog-express-ai", addons: ["evlog"], frontend: ["nuxt"], backend: "express", runtime: "node", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "orpc", examples: ["ai"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const serverIndex = await readFile(join(projectDir, "apps/server/src/index.ts"), "utf-8"); expect(serverIndex).toContain( 'import { createAILogger, createEvlogIntegration } from "evlog/ai";', ); expect(serverIndex).toContain("const ai = createAILogger(req.log);"); expect(serverIndex).toContain("model: ai.wrap(model)"); expect(serverIndex).toContain("integrations: [createEvlogIntegration(ai)]"); }); it("should wire evlog request and auth helpers for Next fullstack AI projects", async () => { const result = await runTRPCTest({ projectName: "evlog-next-auth-ai", addons: ["evlog"], frontend: ["next"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", examples: ["ai"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const evlogAuth = await readFile(join(projectDir, "apps/web/src/lib/evlog-auth.ts"), "utf-8"); const trpcRoute = await readFile( join(projectDir, "apps/web/src/app/api/trpc/[trpc]/route.ts"), "utf-8", ); const aiRoute = await readFile(join(projectDir, "apps/web/src/app/api/ai/route.ts"), "utf-8"); expect(evlogAuth).toContain( 'import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth";', ); expect(evlogAuth).toContain("createAuthMiddleware(auth as BetterAuthInstance"); expectDocsShapedEvlogAuth(evlogAuth); expect(trpcRoute).toContain("withEvlog(handler)"); expect(trpcRoute).toContain("await identifyEvlogUser(req);"); expect(aiRoute).toContain("withEvlog(async (req: Request)"); expect(aiRoute).toContain("await identifyEvlogUser(req);"); expect(aiRoute).not.toContain("createAILogger"); expect(aiRoute).not.toContain("model: ai.wrap(model)"); expect(aiRoute).not.toContain("createEvlogIntegration(ai)"); }); const separateBackendWebAuthCases = [ { frontend: "next", api: "trpc" }, { frontend: "nuxt", api: "orpc" }, { frontend: "svelte", api: "orpc" }, { frontend: "tanstack-start", api: "trpc" }, { frontend: "astro", api: "orpc" }, ] as const; for (const webCase of separateBackendWebAuthCases) { it(`should keep Better Auth identifiers in the server for ${webCase.frontend} + separate backend projects`, async () => { const projectName = `evlog-${webCase.frontend}-express-auth-ai`; const result = await runTRPCTest({ projectName, addons: ["evlog"], frontend: [webCase.frontend as Frontend], backend: "express", runtime: "node", database: "sqlite", orm: "drizzle", auth: "better-auth", api: webCase.api, examples: ["todo", "ai"], dbSetup: "turso", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); const projectDir = result.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const serverIndex = await readFile(join(projectDir, "apps/server/src/index.ts"), "utf-8"); const webPackageJson = await readFile(join(projectDir, "apps/web/package.json"), "utf-8"); const webFiles = await readSourceFiles(join(projectDir, "apps/web")); const webContent = webFiles.map((file) => file.content).join("\n"); expect(serverIndex).toContain( 'import { createAuthMiddleware, type BetterAuthInstance } from "evlog/better-auth";', ); expect(serverIndex).toContain("createAuthMiddleware(auth as BetterAuthInstance"); expectDocsShapedEvlogAuth(serverIndex); expect(serverIndex).toContain("maskEmail: true"); expect(webPackageJson).not.toContain(`"@${projectName}/auth"`); expect(webPackageJson).not.toContain('"@libsql/client"'); expect(webPackageJson).not.toContain('"libsql"'); expect(webContent).not.toContain(`@${projectName}/auth`); expect(webContent).not.toContain(`@${projectName}/db`); expect(webContent).not.toContain(`@${projectName}/env/server`); expect(webContent).not.toContain("createAuthMiddleware"); expect(webContent).not.toContain("createAuthIdentifier"); expect(webContent).not.toContain("identifyEvlogUser"); expect(webContent).not.toContain('serverExternalPackages: ["libsql", "@libsql/client"]'); expect(webContent).not.toContain("BETTER_AUTH_SECRET"); expect(webContent).not.toContain("DATABASE_URL"); }); } it("should patch an existing server when evlog is added later", async () => { const created = await runTRPCTest({ projectName: "evlog-add-existing", addons: ["none"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(created); const projectDir = created.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const addResult = await add({ projectDir, addons: ["evlog"], install: false, }); expect(addResult?.success).toBe(true); const serverIndex = await readFile(join(projectDir, "apps/server/src/index.ts"), "utf-8"); const serverPackageJson = await readFile( join(projectDir, "apps/server/package.json"), "utf-8", ); expect(serverIndex).toContain('import { evlog, type EvlogVariables } from "evlog/hono";'); expect(serverIndex).toContain("app.use(evlog());"); expect(serverPackageJson).toContain('"evlog": "^2.14.1"'); }); it("should reject evlog when added later to a Convex project", async () => { const created = await runTRPCTest({ projectName: "evlog-add-convex-fail", addons: ["none"], frontend: ["tanstack-start", "native-uniwind"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "better-auth", api: "none", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(created); const projectDir = created.result?.projectDirectory; if (!projectDir) throw new Error("Expected generated project directory"); const addResult = await add({ projectDir, addons: ["evlog"], install: false, }); expect(addResult?.success).toBe(false); expect(addResult?.error).toContain("Convex and backend none are not supported yet"); }); }); describe("Addons with None Option", () => { it("should work with addons none", async () => { const result = await runTRPCTest({ projectName: "no-addons", addons: ["none"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with none + other addons", async () => { const result = await runTRPCTest({ projectName: "none-with-other-addons-fail", addons: ["none", "biome"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot combine 'none' with other addons"); }); }); describe("All Available Addons", () => { const testableAddons = [ "pwa", "tauri", "electrobun", "biome", "husky", "turborepo", "nx", "oxlint", "evlog", // Note: starlight, ultracite, fumadocs are prompt-controlled only ]; for (const addon of testableAddons) { it(`should work with ${addon} addon in appropriate setup`, async () => { const config: TestConfig = { projectName: `test-${addon}`, addons: [addon as Addons], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; // Choose compatible frontend for each addon if (["pwa"].includes(addon)) { config.frontend = ["tanstack-router"]; // PWA compatible } else if (["tauri"].includes(addon)) { config.frontend = ["tanstack-router"]; // Tauri compatible } else if (["electrobun"].includes(addon)) { config.frontend = ["tanstack-router"]; // Electrobun compatible } else { config.frontend = ["tanstack-router"]; // Universal addons } const result = await runTRPCTest(config); expectSuccess(result); }); } }); }); ================================================ FILE: apps/cli/test/api.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { createVirtual } from "../src/index"; import type { API, Backend, Database, Examples, Frontend, ORM, Runtime } from "../src/types"; import { collectFiles } from "./setup"; import { expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; describe("API Configurations", () => { describe("tRPC API", () => { const reactFrontends = ["tanstack-router", "react-router", "tanstack-start", "next"]; for (const frontend of reactFrontends) { it(`should work with tRPC + ${frontend}`, async () => { const result = await runTRPCTest({ projectName: `trpc-${frontend}`, api: "trpc", frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } const nativeFrontends = ["native-bare", "native-uniwind", "native-unistyles"]; for (const frontend of nativeFrontends) { it(`should work with tRPC + ${frontend}`, async () => { const result = await runTRPCTest({ projectName: `trpc-${frontend}`, api: "trpc", frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } it("should fail with tRPC + Nuxt", async () => { const result = await runTRPCTest({ projectName: "trpc-nuxt-fail", api: "trpc", frontend: ["nuxt"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'nuxt' frontend"); }); it("should fail with tRPC + Svelte", async () => { const result = await runTRPCTest({ projectName: "trpc-svelte-fail", api: "trpc", frontend: ["svelte"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'svelte' frontend"); }); it("should fail with tRPC + Solid", async () => { const result = await runTRPCTest({ projectName: "trpc-solid-fail", api: "trpc", frontend: ["solid"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'solid' frontend"); }); const backends = ["hono", "express", "fastify", "elysia"]; for (const backend of backends) { it(`should work with tRPC + ${backend}`, async () => { const config: TestConfig = { projectName: `trpc-${backend}`, api: "trpc", backend: backend as Backend, frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; if (backend === "elysia") { config.runtime = "bun"; } else { config.runtime = "bun"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("oRPC API", () => { const frontends = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "native-bare", "native-uniwind", "native-unistyles", ]; for (const frontend of frontends) { it(`should work with oRPC + ${frontend}`, async () => { const result = await runTRPCTest({ projectName: `orpc-${frontend}`, api: "orpc", frontend: [frontend as Frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } const backends = ["hono", "express", "fastify", "elysia"]; for (const backend of backends) { it(`should work with oRPC + ${backend}`, async () => { const config: TestConfig = { projectName: `orpc-${backend}`, api: "orpc", backend: backend as Backend, frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; if (backend === "elysia") { config.runtime = "bun"; } else { config.runtime = "bun"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("No API", () => { it("should work with API none + basic setup", async () => { const result = await runTRPCTest({ projectName: "api-none-basic", api: "none", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with API none + frontend only", async () => { const result = await runTRPCTest({ projectName: "api-none-frontend-only", api: "none", frontend: ["tanstack-router"], backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with API none + convex", async () => { const result = await runTRPCTest({ projectName: "api-none-convex", api: "none", frontend: ["tanstack-router"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with API none + examples (non-convex backend)", async () => { const result = await runTRPCTest({ projectName: "api-none-examples-fail", api: "none", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result); }); it("should work with API none + examples + convex backend", async () => { const result = await runTRPCTest({ projectName: "api-none-examples-convex", api: "none", frontend: ["tanstack-router"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", addons: ["none"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("API with Different Database Combinations", () => { const apiDatabaseCombinations = [ { api: "trpc", database: "sqlite", orm: "drizzle" }, { api: "trpc", database: "postgres", orm: "drizzle" }, { api: "trpc", database: "mysql", orm: "prisma" }, { api: "trpc", database: "mongodb", orm: "mongoose" }, { api: "orpc", database: "sqlite", orm: "drizzle" }, { api: "orpc", database: "postgres", orm: "prisma" }, { api: "orpc", database: "mysql", orm: "drizzle" }, { api: "orpc", database: "mongodb", orm: "prisma" }, ]; for (const { api, database, orm } of apiDatabaseCombinations) { it(`should work with ${api} + ${database} + ${orm}`, async () => { const result = await runTRPCTest({ projectName: `${api}-${database}-${orm}`, api: api as API, database: database as Database, orm: orm as ORM, frontend: ["tanstack-router"], backend: "hono", runtime: "bun", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); describe("API with Authentication", () => { it("should work with tRPC + better-auth", async () => { const result = await runTRPCTest({ projectName: "trpc-better-auth", api: "trpc", auth: "better-auth", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with oRPC + better-auth", async () => { const result = await runTRPCTest({ projectName: "orpc-better-auth", api: "orpc", auth: "better-auth", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with API none + convex + clerk", async () => { const result = await runTRPCTest({ projectName: "api-none-convex-clerk", api: "none", auth: "clerk", frontend: ["tanstack-router"], backend: "convex", runtime: "none", database: "none", orm: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("API with Examples", () => { it("should work with tRPC + todo example", async () => { const result = await runTRPCTest({ projectName: "trpc-todo", api: "trpc", examples: ["todo"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with oRPC + AI example", async () => { const result = await runTRPCTest({ projectName: "orpc-ai", api: "orpc", examples: ["ai"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); const apiExampleCombinations = [ { api: "trpc", examples: ["todo", "ai"] }, { api: "orpc", examples: ["todo", "ai"] }, ]; for (const { api, examples } of apiExampleCombinations) { it(`should work with ${api} + both examples`, async () => { const result = await runTRPCTest({ projectName: `${api}-both-examples`, api: api as API, examples: examples as Examples[], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); describe("All API Types", () => { const apis = ["trpc", "orpc", "none"]; for (const api of apis) { it(`should work with ${api} API`, async () => { const config: TestConfig = { projectName: `test-api-${api}`, api: api as API, addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; if (api === "none") { config.backend = "none"; config.runtime = "none"; config.database = "none"; config.orm = "none"; config.auth = "none"; config.frontend = ["tanstack-router"]; } else { config.backend = "hono"; config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.frontend = ["tanstack-router"]; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("API Edge Cases", () => { it("should scaffold Fastify oRPC context with matching request shapes", async () => { const result = await createVirtual({ projectName: "fastify-orpc-request-shape", api: "orpc", frontend: ["tanstack-router"], backend: "fastify", runtime: "node", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", }); if (result.isErr()) { throw result.error; } const files = collectFiles(result.value.root, result.value.root.path); const serverFile = files.get("apps/server/src/index.ts"); const contextFile = files.get("packages/api/src/context.ts"); expect(serverFile).toContain("context: await createContext(request.headers)"); expect(contextFile).toContain('import type { IncomingHttpHeaders } from "node:http";'); expect(contextFile).toContain( "export async function createContext(req: IncomingHttpHeaders)", ); }); it("should handle API with complex frontend combinations", async () => { const result = await runTRPCTest({ projectName: "api-complex-frontend", api: "trpc", frontend: ["tanstack-router", "native-bare"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should handle API with workers runtime", async () => { const result = await runTRPCTest({ projectName: "api-workers", api: "trpc", frontend: ["tanstack-router"], backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); const runtimeApiCombinations = [ { runtime: "bun", api: "trpc" }, { runtime: "node", api: "orpc" }, { runtime: "workers", api: "trpc" }, ]; for (const { runtime, api } of runtimeApiCombinations) { it(`should handle ${api} with ${runtime} runtime`, async () => { const config: TestConfig = { projectName: `${runtime}-${api}`, api: api as API, runtime: runtime as Runtime, frontend: ["tanstack-router"], backend: "hono", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; if (runtime === "workers") { config.serverDeploy = "cloudflare"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); }); ================================================ FILE: apps/cli/test/auth.test.ts ================================================ import { describe, expect, it } from "bun:test"; import path from "node:path"; import fs from "fs-extra"; import type { Backend, Database, Frontend, ORM } from "../src/types"; import { AUTH_PROVIDERS, expectError, expectSuccess, runTRPCTest, type TestConfig, } from "./test-utils"; describe("Authentication Configurations", () => { describe("Better-Auth Provider", () => { it("should work with better-auth + database", async () => { const result = await runTRPCTest({ projectName: "better-auth-db", auth: "better-auth", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); const databases = ["sqlite", "postgres", "mysql"]; for (const database of databases) { it(`should work with better-auth + ${database}`, async () => { const result = await runTRPCTest({ projectName: `better-auth-${database}`, auth: "better-auth", backend: "hono", runtime: "bun", database: database as Database, orm: "drizzle", api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } it("should work with better-auth + mongodb + mongoose", async () => { const result = await runTRPCTest({ projectName: "better-auth-mongodb", auth: "better-auth", backend: "hono", runtime: "bun", database: "mongodb", orm: "mongoose", api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should add nextCookies plugin for Next.js self backend", async () => { const result = await runTRPCTest({ projectName: "better-auth-next-self-plugins", auth: "better-auth", backend: "self", runtime: "none", database: "postgres", orm: "drizzle", api: "trpc", frontend: ["next"], addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); if (!result.projectDir) { throw new Error("Expected projectDir to be defined"); } const authFile = await fs.readFile( path.join(result.projectDir, "packages/auth/src/index.ts"), "utf8", ); expect(authFile).toContain('import { nextCookies } from "better-auth/next-js";'); expect(authFile).toContain("nextCookies()"); }); it("should add tanstackStartCookies plugin for TanStack Start self backend", async () => { const result = await runTRPCTest({ projectName: "better-auth-tanstack-start-self-plugins", auth: "better-auth", backend: "self", runtime: "none", database: "postgres", orm: "drizzle", api: "trpc", frontend: ["tanstack-start"], addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); if (!result.projectDir) { throw new Error("Expected projectDir to be defined"); } const authFile = await fs.readFile( path.join(result.projectDir, "packages/auth/src/index.ts"), "utf8", ); expect(authFile).toContain( 'import { tanstackStartCookies } from "better-auth/tanstack-start";', ); expect(authFile).toContain("tanstackStartCookies()"); }); it("should fail with better-auth + no database (non-convex)", async () => { const result = await runTRPCTest({ projectName: "better-auth-no-db-fail", auth: "better-auth", backend: "hono", runtime: "bun", database: "none", orm: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); // This should actually succeed - better-auth can work without a database // if no examples require one expectSuccess(result); }); it("should work with better-auth + convex backend (tanstack-router)", async () => { const result = await runTRPCTest({ projectName: "better-auth-convex-success", auth: "better-auth", backend: "convex", runtime: "none", database: "none", orm: "none", api: "none", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", }); expectSuccess(result); }); it("should scaffold react-router with Convex Better Auth wiring", async () => { const result = await runTRPCTest({ projectName: "better-auth-convex-react-router", auth: "better-auth", backend: "convex", runtime: "none", database: "none", orm: "none", api: "none", frontend: ["react-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); if (!result.projectDir) { throw new Error("Expected projectDir to be defined"); } const rootFile = await fs.readFile( path.join(result.projectDir, "apps/web/src/root.tsx"), "utf8", ); const authClientFile = await fs.readFile( path.join(result.projectDir, "apps/web/src/lib/auth-client.ts"), "utf8", ); const dashboardFile = await fs.readFile( path.join(result.projectDir, "apps/web/src/routes/dashboard.tsx"), "utf8", ); expect(rootFile).toContain("ConvexBetterAuthProvider"); expect(rootFile).toContain('import { authClient } from "@/lib/auth-client";'); expect(authClientFile).toContain("crossDomainClient(), convexClient()"); expect(dashboardFile).toContain("Authenticated"); expect(dashboardFile).toContain("Unauthenticated"); }); const convexUnsupportedFrontends = ["nuxt", "svelte", "solid", "astro"] as const; for (const frontend of convexUnsupportedFrontends) { it(`should fail with Convex Better Auth + ${frontend}`, async () => { const result = await runTRPCTest({ projectName: `better-auth-convex-${frontend}-fail`, auth: "better-auth", backend: "convex", runtime: "none", database: "none", orm: "none", api: "none", frontend: [frontend], addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, expectError: true, }); expectError(result, "Better Auth with '--backend convex' is not compatible"); }); } const compatibleFrontends = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "native-bare", "native-uniwind", "native-unistyles", ]; for (const frontend of compatibleFrontends) { it(`should work with better-auth + ${frontend}`, async () => { const config: TestConfig = { projectName: `better-auth-${frontend}`, auth: "better-auth", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", frontend: [frontend as Frontend], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; // Handle API compatibility if (["nuxt", "svelte", "solid"].includes(frontend)) { config.api = "orpc"; } else { config.api = "trpc"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Clerk Provider", () => { it("should work with clerk + convex", async () => { const result = await runTRPCTest({ projectName: "clerk-convex", auth: "clerk", backend: "convex", runtime: "none", database: "none", orm: "none", api: "none", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with clerk + hono backend", async () => { const result = await runTRPCTest({ projectName: "clerk-hono-success", auth: "clerk", backend: "hono", runtime: "bun", database: "sqlite", examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", addons: ["turborepo"], orm: "drizzle", api: "trpc", frontend: ["tanstack-router"], install: false, }); expectSuccess(result); }); it("should work with clerk + self backend", async () => { const result = await runTRPCTest({ projectName: "clerk-self-success", auth: "clerk", backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["next"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should scaffold Next.js Clerk middleware without importing shared server env", async () => { const result = await runTRPCTest({ projectName: "clerk-next-hono-current", auth: "clerk", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["next"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); if (!result.projectDir) { throw new Error("Expected projectDir to be defined"); } const proxyFile = await fs.readFile( path.join(result.projectDir, "apps/web/src/proxy.ts"), "utf8", ); const dashboardFile = await fs.readFile( path.join(result.projectDir, "apps/web/src/app/dashboard/page.tsx"), "utf8", ); const apiContextFile = await fs.readFile( path.join(result.projectDir, "packages/api/src/context.ts"), "utf8", ); const serverEnvPackageFile = await fs.readFile( path.join(result.projectDir, "packages/env/src/server.ts"), "utf8", ); const serverEnvFile = await fs.readFile( path.join(result.projectDir, "apps/server/.env"), "utf8", ); expect(proxyFile).not.toContain('/env/server"'); expect(proxyFile).not.toContain("env.CLERK_SECRET_KEY"); expect(dashboardFile).not.toContain("SignedIn"); expect(dashboardFile).not.toContain("SignedOut"); expect(dashboardFile).toContain("useUser"); expect(dashboardFile).toContain("privateData.queryOptions()"); expect(apiContextFile).toContain("type ClerkContextAuth"); expect(apiContextFile).toContain("type ClerkRequestContext"); expect(apiContextFile).toContain("function toClerkContextAuth"); expect(apiContextFile).toContain("Promise"); expect(apiContextFile).toContain("publishableKey: env.CLERK_PUBLISHABLE_KEY"); expect(apiContextFile).toContain("authorizedParties: [env.CORS_ORIGIN]"); expect(serverEnvPackageFile).toContain("CLERK_PUBLISHABLE_KEY"); expect(serverEnvPackageFile).toContain("CLERK_SECRET_KEY"); expect(serverEnvFile).toContain("CLERK_PUBLISHABLE_KEY="); expect(serverEnvFile).toContain("CLERK_SECRET_KEY="); }); it("should scaffold TanStack Start Clerk templates without stale control components", async () => { const result = await runTRPCTest({ projectName: "clerk-tanstack-start-hono-current", auth: "clerk", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["tanstack-start"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); if (!result.projectDir) { throw new Error("Expected projectDir to be defined"); } const startFile = await fs.readFile( path.join(result.projectDir, "apps/web/src/start.ts"), "utf8", ); const dashboardFile = await fs.readFile( path.join(result.projectDir, "apps/web/src/routes/dashboard.tsx"), "utf8", ); expect(startFile).not.toContain('/env/server"'); expect(startFile).not.toContain("env.CLERK_SECRET_KEY"); expect(dashboardFile).not.toContain("SignedIn"); expect(dashboardFile).not.toContain("SignedOut"); expect(dashboardFile).toContain("useUser"); expect(dashboardFile).toContain("privateData.queryOptions()"); }); it("should scaffold Clerk native auth with the current Expo SDK flow", async () => { const result = await runTRPCTest({ projectName: "clerk-native-hono-current", auth: "clerk", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["native-uniwind"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); if (!result.projectDir) { throw new Error("Expected projectDir to be defined"); } const nativePackageFile = await fs.readFile( path.join(result.projectDir, "apps/native/package.json"), "utf8", ); const signInFile = await fs.readFile( path.join(result.projectDir, "apps/native/app/(auth)/sign-in.tsx"), "utf8", ); const signUpFile = await fs.readFile( path.join(result.projectDir, "apps/native/app/(auth)/sign-up.tsx"), "utf8", ); expect(nativePackageFile).toContain('"@clerk/expo": "^3.1.3"'); expect(signInFile).not.toContain("setActive"); expect(signInFile).not.toContain("signIn.create"); expect(signInFile).toContain("const { signIn, errors, fetchStatus } = useSignIn()"); expect(signInFile).toContain("await signIn.password"); expect(signInFile).toContain("await signIn.finalize"); expect(signUpFile).not.toContain("setActive"); expect(signUpFile).not.toContain("prepareEmailAddressVerification"); expect(signUpFile).not.toContain("attemptEmailAddressVerification"); expect(signUpFile).toContain("const { signUp, errors, fetchStatus } = useSignUp()"); expect(signUpFile).toContain("await signUp.password"); expect(signUpFile).toContain("await signUp.verifications.sendEmailCode()"); expect(signUpFile).toContain("await signUp.verifications.verifyEmailCode"); expect(signUpFile).toContain("await signUp.finalize"); expect(signUpFile).toContain('nativeID="clerk-captcha"'); }); const compatibleFrontends = [ "tanstack-router", "react-router", "tanstack-start", "next", "native-bare", "native-uniwind", "native-unistyles", ]; for (const frontend of compatibleFrontends) { it(`should work with clerk + ${frontend}`, async () => { const result = await runTRPCTest({ projectName: `clerk-${frontend}`, auth: "clerk", backend: "convex", runtime: "none", database: "none", webDeploy: "none", serverDeploy: "none", addons: ["turborepo"], dbSetup: "none", examples: ["todo"], orm: "none", api: "none", frontend: [frontend as Frontend], install: false, }); expectSuccess(result); }); } const incompatibleFrontends = ["nuxt", "svelte", "solid", "astro"]; for (const frontend of incompatibleFrontends) { it(`should fail with clerk + ${frontend}`, async () => { const result = await runTRPCTest({ projectName: `clerk-${frontend}-fail`, auth: "clerk", backend: "convex", runtime: "none", database: "none", orm: "none", api: "none", frontend: [frontend as Frontend], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Clerk authentication is not compatible"); }); } }); describe("No Authentication", () => { it("should work with auth none", async () => { const result = await runTRPCTest({ projectName: "no-auth", auth: "none", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with auth none + no database", async () => { // When backend is 'none', examples are automatically cleared const result = await runTRPCTest({ projectName: "no-auth-no-db", auth: "none", backend: "none", runtime: "none", database: "none", orm: "none", api: "none", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with auth none + convex", async () => { const result = await runTRPCTest({ projectName: "no-auth-convex", auth: "none", backend: "convex", runtime: "none", database: "none", orm: "none", api: "none", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Authentication with Different Backends", () => { const backends = ["hono", "express", "fastify", "elysia", "self"]; for (const backend of backends) { it(`should work with better-auth + ${backend}`, async () => { const config: TestConfig = { projectName: `better-auth-${backend}`, auth: "better-auth", backend: backend as Backend, database: "sqlite", orm: "drizzle", api: "trpc", frontend: backend === "self" ? ["next"] : ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; // Set appropriate runtime if (backend === "elysia") { config.runtime = "bun"; } else if (backend === "self") { config.runtime = "none"; } else { config.runtime = "bun"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Authentication with Different ORMs", () => { const ormCombinations = [ { database: "sqlite", orm: "drizzle" }, { database: "sqlite", orm: "prisma" }, { database: "postgres", orm: "drizzle" }, { database: "postgres", orm: "prisma" }, { database: "mysql", orm: "drizzle" }, { database: "mysql", orm: "prisma" }, { database: "mongodb", orm: "mongoose" }, { database: "mongodb", orm: "prisma" }, ]; for (const { database, orm } of ormCombinations) { it(`should work with better-auth + ${database} + ${orm}`, async () => { const result = await runTRPCTest({ projectName: `better-auth-${database}-${orm}`, auth: "better-auth", backend: "hono", runtime: "bun", database: database as Database, orm: orm as ORM, api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); describe("All Auth Providers", () => { for (const auth of AUTH_PROVIDERS) { it(`should work with ${auth} in appropriate setup`, async () => { const config: TestConfig = { projectName: `test-${auth}`, auth, frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; // Set appropriate setup for each auth provider if (auth === "clerk") { config.backend = "convex"; config.runtime = "none"; config.database = "none"; config.orm = "none"; config.api = "none"; } else if (auth === "better-auth") { config.backend = "hono"; config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.api = "trpc"; } else { // none config.backend = "hono"; config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.api = "trpc"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Auth Edge Cases", () => { it("should handle auth with complex frontend combinations", async () => { const result = await runTRPCTest({ projectName: "auth-web-native-combo", auth: "better-auth", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["tanstack-router", "native-bare"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should handle auth constraints with workers runtime", async () => { const result = await runTRPCTest({ projectName: "auth-workers", auth: "better-auth", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); }); }); ================================================ FILE: apps/cli/test/backend-runtime.test.ts ================================================ import { describe, it } from "bun:test"; import type { Backend, Frontend, Runtime } from "../src/types"; import { expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; describe("Backend and Runtime Combinations", () => { describe("Valid Backend-Runtime Combinations", () => { const validCombinations = [ // Standard backend-runtime combinations { backend: "hono" as const, runtime: "bun" as const }, { backend: "hono" as const, runtime: "node" as const }, { backend: "hono" as const, runtime: "workers" as const }, { backend: "express" as const, runtime: "bun" as const }, { backend: "express" as const, runtime: "node" as const }, { backend: "fastify" as const, runtime: "bun" as const }, { backend: "fastify" as const, runtime: "node" as const }, { backend: "elysia" as const, runtime: "bun" as const }, // Special cases { backend: "convex" as const, runtime: "none" as const }, { backend: "none" as const, runtime: "none" as const }, { backend: "self" as const, runtime: "none" as const }, ]; for (const { backend, runtime } of validCombinations) { it(`should work with ${backend} + ${runtime}`, async () => { const config: TestConfig = { projectName: `${backend}-${runtime}`, backend, runtime, frontend: ["tanstack-router"], webDeploy: "none", serverDeploy: "none", addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }; // Set appropriate defaults based on backend if (backend === "convex") { config.database = "none"; config.orm = "none"; config.auth = "clerk"; config.api = "none"; } else if (backend === "none") { config.database = "none"; config.orm = "none"; config.auth = "none"; config.api = "none"; } else if (backend === "self") { config.frontend = ["next"]; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "better-auth"; config.api = "trpc"; } else { config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "trpc"; } // Set server deployment for workers runtime if (runtime === "workers") { config.serverDeploy = "cloudflare"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Invalid Backend-Runtime Combinations", () => { const invalidCombinations = [ // Workers runtime only works with Hono { backend: "express" as const, runtime: "workers" as const, error: "Cloudflare Workers runtime (--runtime workers) is only supported with Hono backend", }, { backend: "fastify", runtime: "workers", error: "Cloudflare Workers runtime (--runtime workers) is only supported with Hono backend", }, { backend: "elysia", runtime: "workers", error: "Cloudflare Workers runtime (--runtime workers) is only supported with Hono backend", }, // Convex backend requires runtime none { backend: "convex", runtime: "bun", error: "Convex backend requires '--runtime none'", }, { backend: "convex", runtime: "node", error: "Convex backend requires '--runtime none'", }, { backend: "convex", runtime: "workers", error: "Convex backend requires '--runtime none'", }, // Backend none requires runtime none { backend: "none", runtime: "bun", error: "Backend 'none' requires '--runtime none'", }, { backend: "none", runtime: "node", error: "Backend 'none' requires '--runtime none'", }, { backend: "none", runtime: "workers", error: "Backend 'none' requires '--runtime none'", }, // Self backend requires runtime none { backend: "self", runtime: "bun", error: "Backend 'self' (fullstack) requires '--runtime none'", frontend: ["next"], // Need to specify Next.js frontend for self backend }, { backend: "self", runtime: "node", error: "Backend 'self' (fullstack) requires '--runtime none'", frontend: ["next"], // Need to specify Next.js frontend for self backend }, { backend: "self", runtime: "workers", error: "Backend 'self' (fullstack) requires '--runtime none'", frontend: ["next"], // Need to specify Next.js frontend for self backend }, // Runtime none only works with convex, none, or self backend { backend: "hono", runtime: "none", error: "'--runtime none' is only supported with '--backend convex', '--backend none', or '--backend self'", }, { backend: "express", runtime: "none", error: "'--runtime none' is only supported with '--backend convex', '--backend none', or '--backend self'", }, ]; for (const { backend, runtime, error, frontend } of invalidCombinations) { it(`should fail with ${backend} + ${runtime}`, async () => { const config: TestConfig = { projectName: `invalid-${backend}-${runtime}`, backend: backend as Backend, runtime: runtime as Runtime, frontend: (frontend || ["tanstack-router"]) as Frontend[], auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }; // Set appropriate defaults based on backend if (backend === "convex") { config.database = "none"; config.orm = "none"; config.auth = "clerk"; config.api = "none"; } else if (backend === "none") { config.database = "none"; config.orm = "none"; config.auth = "none"; config.api = "none"; } else if (backend === "self") { config.database = "sqlite"; config.orm = "drizzle"; config.auth = "better-auth"; config.api = "trpc"; } else { config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "trpc"; } const result = await runTRPCTest(config); expectError(result, error); }); } }); describe("Convex Backend Constraints", () => { it("should enforce all convex constraints", async () => { const result = await runTRPCTest({ projectName: "convex-app", backend: "convex", runtime: "none", database: "none", orm: "none", auth: "clerk", api: "none", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work convex with better-auth (tanstack-router)", async () => { const result = await runTRPCTest({ projectName: "convex-better-auth-success", backend: "convex", runtime: "none", database: "none", orm: "none", auth: "better-auth", api: "none", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", }); expectSuccess(result); }); it("should fail convex with database", async () => { const result = await runTRPCTest({ projectName: "convex-with-db", backend: "convex", runtime: "none", database: "postgres", orm: "drizzle", auth: "clerk", api: "none", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Convex backend requires '--database none'"); }); }); describe("Workers Runtime Constraints", () => { it("should work with workers + hono + compatible database", async () => { const result = await runTRPCTest({ projectName: "workers-compatible", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "cloudflare", // Workers requires server deployment install: false, }); expectSuccess(result); }); it("should fail workers with mongodb", async () => { const result = await runTRPCTest({ projectName: "workers-mongodb", backend: "hono", runtime: "workers", database: "mongodb", orm: "prisma", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "Cloudflare Workers runtime (--runtime workers) is not compatible with MongoDB database", ); }); it("should fail workers without server deployment", async () => { const result = await runTRPCTest({ projectName: "workers-no-deploy", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cloudflare Workers runtime requires a server deployment"); }); }); describe("All Backend Types", () => { const backends = ["hono", "express", "fastify", "elysia", "convex", "none", "self"] as const; for (const backend of backends) { it(`should work with appropriate defaults for ${backend}`, async () => { const config: TestConfig = { projectName: `test-${backend}`, backend: backend as Backend, frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; // Set appropriate defaults for each backend switch (backend) { case "convex": config.runtime = "none"; config.database = "none"; config.orm = "none"; config.auth = "clerk"; config.api = "none"; break; case "none": config.runtime = "none"; config.database = "none"; config.orm = "none"; config.auth = "none"; config.api = "none"; break; case "self": config.frontend = ["next"]; // Self backend only works with Next.js config.runtime = "none"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "better-auth"; config.api = "trpc"; break; case "elysia": config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "trpc"; break; default: config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "trpc"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Self Backend Constraints", () => { it("should work with self backend and Next.js frontend", async () => { const result = await runTRPCTest({ projectName: "self-backend-success", backend: "self", runtime: "none", frontend: ["next"], database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail self backend with non-Next.js frontend", async () => { const result = await runTRPCTest({ projectName: "self-backend-invalid-frontend", backend: "self", runtime: "none", frontend: ["tanstack-router"], // Invalid frontend for self backend database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, install: false, }); expectError( result, "Backend 'self' (fullstack) currently only supports Next.js, TanStack Start, Nuxt, SvelteKit, and Astro frontends. Please use --frontend next, --frontend tanstack-start, --frontend nuxt, --frontend svelte, or --frontend astro.", ); }); it("should fail self backend with non-none runtime", async () => { const result = await runTRPCTest({ projectName: "self-backend-invalid-runtime", backend: "self", runtime: "bun", // Invalid runtime for self backend frontend: ["next"], database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, install: false, }); expectError(result, "Backend 'self' (fullstack) requires '--runtime none'"); }); }); }); ================================================ FILE: apps/cli/test/basic-configurations.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { expectError, expectSuccess, PACKAGE_MANAGERS, runTRPCTest } from "./test-utils"; describe("Basic Configurations", () => { describe("Default Configuration", () => { it("should create project with --yes flag (default config)", async () => { const result = await runTRPCTest({ projectName: "default-app", yes: true, install: false, }); expectSuccess(result); expect(result.result?.projectConfig.projectName).toBe("default-app"); }); it("should create project with explicit default values", async () => { const result = await runTRPCTest({ projectName: "explicit-defaults", database: "sqlite", orm: "drizzle", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], auth: "better-auth", api: "trpc", addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, // Skip installation for faster tests }); expectSuccess(result); expect(result.result?.projectConfig.projectName).toBe("explicit-defaults"); }); it("should create Next.js fullstack project with self backend", async () => { const result = await runTRPCTest({ projectName: "nextjs-fullstack-defaults", database: "sqlite", orm: "drizzle", backend: "self", runtime: "none", frontend: ["next"], auth: "better-auth", api: "trpc", addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, // Skip installation for faster tests }); expectSuccess(result); expect(result.result?.projectConfig.projectName).toBe("nextjs-fullstack-defaults"); expect(result.result?.projectConfig.backend).toBe("self"); expect(result.result?.projectConfig.runtime).toBe("none"); expect(result.result?.projectConfig.frontend).toEqual(["next"]); }); }); describe("Package Managers", () => { for (const packageManager of PACKAGE_MANAGERS) { it(`should work with ${packageManager}`, async () => { const result = await runTRPCTest({ projectName: `${packageManager}-app`, packageManager, yes: true, install: false, }); expectSuccess(result); expect(result.result?.projectConfig.packageManager).toBe(packageManager); }); } }); describe("Git Options", () => { it("should work with git enabled", async () => { const result = await runTRPCTest({ projectName: "git-enabled", yes: true, git: true, install: false, }); expectSuccess(result); expect(result.result?.projectConfig.git).toBe(true); }); it("should work with git disabled", async () => { const result = await runTRPCTest({ projectName: "git-disabled", yes: true, git: false, install: false, }); expectSuccess(result); expect(result.result?.projectConfig.git).toBe(false); }); }); describe("Installation Options", () => { // Skip install test in CI to avoid timeouts const runInstallTest = process.env.CI ? it.skip : it; runInstallTest( "should work with install enabled", async () => { const result = await runTRPCTest({ projectName: "install-enabled", yes: true, install: true, }); expectSuccess(result); expect(result.result?.projectConfig.install).toBe(true); }, 300000, ); // 5 minute timeout for install test it("should work with install disabled", async () => { const result = await runTRPCTest({ projectName: "install-disabled", yes: true, install: false, }); expectSuccess(result); expect(result.result?.projectConfig.install).toBe(false); }); }); describe("YOLO Mode", () => { it("should bypass validations with --yolo flag", async () => { // This would normally fail validation but should pass with yolo const result = await runTRPCTest({ projectName: "yolo-app", yolo: true, frontend: ["tanstack-router"], backend: "hono", runtime: "bun", api: "trpc", database: "mongodb", orm: "drizzle", // Incompatible combination auth: "better-auth", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); expect(result.result?.projectConfig.projectName).toBe("yolo-app"); }); }); describe("Error Handling", () => { it("should fail with invalid project name", async () => { const result = await runTRPCTest({ projectName: "", expectError: true, }); expectError(result, "Invalid project name"); }); it("should fail when combining --yes with configuration flags", async () => { const result = await runTRPCTest({ projectName: "yes-with-flags", yes: true, // Explicitly set yes flag database: "postgres", orm: "drizzle", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], auth: "better-auth", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot combine --yes with core stack configuration flags"); }); }); }); ================================================ FILE: apps/cli/test/benchmark.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; describe("CLI Performance Benchmarks", () => { describe("Basic Project Creation Benchmarks", () => { it("should benchmark default configuration creation", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-default", yes: true, install: false, // Skip install for faster benchmarking }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(10000); // Should complete within 10 seconds console.log(`✅ Default configuration: ${duration.toFixed(2)}ms`); }); it("should benchmark minimal configuration creation", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-minimal", frontend: ["none"], backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", addons: ["none"], examples: ["none"], dbSetup: "none", serverDeploy: "none", webDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(8000); console.log(`✅ Minimal configuration: ${duration.toFixed(2)}ms`); }); it("should benchmark full-stack configuration creation", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-fullstack", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "postgres", orm: "drizzle", auth: "better-auth", api: "trpc", addons: ["turborepo", "biome"], examples: ["todo"], dbSetup: "neon", webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(15000); // Should complete within 15 seconds console.log(`✅ Full-stack configuration: ${duration.toFixed(2)}ms`); }); }); describe("Database Setup Benchmarks", () => { it("should benchmark SQLite with Drizzle setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-sqlite-drizzle", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ SQLite + Drizzle: ${duration.toFixed(2)}ms`); }); it("should benchmark PostgreSQL with Prisma setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-postgres-prisma", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "postgres", orm: "prisma", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ PostgreSQL + Prisma: ${duration.toFixed(2)}ms`); }); it("should benchmark MongoDB with Mongoose setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-mongodb-mongoose", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "mongodb", orm: "mongoose", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ MongoDB + Mongoose: ${duration.toFixed(2)}ms`); }); }); describe("Frontend Framework Benchmarks", () => { it("should benchmark TanStack Router setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-tanstack-router", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ TanStack Router: ${duration.toFixed(2)}ms`); }); it("should benchmark Next.js setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-nextjs", frontend: ["next"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ Next.js: ${duration.toFixed(2)}ms`); }); it("should benchmark Nuxt setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-nuxt", frontend: ["nuxt"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ Nuxt: ${duration.toFixed(2)}ms`); }); }); describe("Backend Framework Benchmarks", () => { it("should benchmark Hono setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-hono", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ Hono: ${duration.toFixed(2)}ms`); }); it("should benchmark Express setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-express", frontend: ["tanstack-router"], backend: "express", runtime: "node", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ Express: ${duration.toFixed(2)}ms`); }); it("should benchmark Convex setup", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-convex", frontend: ["tanstack-router"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ Convex: ${duration.toFixed(2)}ms`); }); }); describe("Addon Benchmarks", () => { it("should benchmark Turborepo addon", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-turborepo", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ Turborepo addon: ${duration.toFixed(2)}ms`); }); it("should benchmark Biome addon", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-biome", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["biome"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(12000); console.log(`✅ Biome addon: ${duration.toFixed(2)}ms`); }); it("should benchmark multiple addons", async () => { const startTime = performance.now(); const result = await runTRPCTest({ projectName: "benchmark-multiple-addons", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["turborepo", "biome", "husky"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(15000); console.log(`✅ Multiple addons: ${duration.toFixed(2)}ms`); }); }); describe("Performance Regression Tests", () => { const configurations = [ { name: "Minimal", config: { projectName: "perf-minimal", frontend: ["none"], backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }, threshold: 5000, // 5 seconds }, { name: "Default", config: { projectName: "perf-default", yes: true, install: false, }, threshold: 8000, // 8 seconds }, { name: "Complex", config: { projectName: "perf-complex", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "postgres", orm: "prisma", auth: "better-auth", api: "trpc", addons: ["turborepo", "biome"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }, threshold: 12000, // 12 seconds }, ]; for (const { name, config, threshold } of configurations) { it(`should not exceed performance thresholds for ${name}`, async () => { const startTime = performance.now(); const result = await runTRPCTest(config as TestConfig); const endTime = performance.now(); const duration = endTime - startTime; expectSuccess(result); expect(duration).toBeLessThan(threshold); console.log(`✅ ${name} performance: ${duration.toFixed(2)}ms (threshold: ${threshold}ms)`); }); } }); }); ================================================ FILE: apps/cli/test/clerk-matrix.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { createVirtual } from "../src/index"; import { validateConfigCompatibility } from "../src/validation"; import { collectFiles } from "./setup"; const standardBackends = [ { backend: "hono", runtime: "bun" }, { backend: "hono", runtime: "node" }, { backend: "hono", runtime: "workers" }, { backend: "express", runtime: "bun" }, { backend: "express", runtime: "node" }, { backend: "fastify", runtime: "bun" }, { backend: "fastify", runtime: "node" }, { backend: "elysia", runtime: "bun" }, ] as const; const standardWeb = [ undefined, "next", "react-router", "tanstack-router", "tanstack-start", ] as const; const selfWeb = ["next", "tanstack-start"] as const; const nativeOptions = [undefined, "native-bare", "native-uniwind", "native-unistyles"] as const; const apiOptions = ["trpc", "orpc", "none"] as const; function buildFrontendCombos( webOptions: readonly (typeof standardWeb)[number][], { requireWeb = false }: { requireWeb?: boolean } = {}, ) { const combos: string[][] = []; for (const web of webOptions) { for (const native of nativeOptions) { const frontend = [web, native].filter(Boolean) as string[]; if (frontend.length === 0) continue; if (requireWeb && !web) continue; combos.push(frontend); } } return combos; } function expectedContextImport(backend: string) { if (backend === "express") return "@clerk/express"; if (backend === "fastify") return "@clerk/fastify"; return "@clerk/backend"; } function usesBackendClerkClient(backend: string, api: string) { return api !== "none" && (backend === "self" || backend === "hono" || backend === "elysia"); } function needsServerClerkPublishableKey(backend: string, api: string) { return ( backend === "express" || backend === "fastify" || (api !== "none" && (backend === "self" || backend === "hono" || backend === "elysia")) ); } describe("Clerk matrix", () => { it("should generate every supported Clerk combination", { timeout: 30_000 }, async () => { const standardFrontendCombos = buildFrontendCombos(standardWeb); const selfFrontendCombos = buildFrontendCombos(selfWeb, { requireWeb: true }); const combos = [ ...standardBackends.flatMap((pair) => standardFrontendCombos.flatMap((frontend) => apiOptions.map((api) => ({ backend: pair.backend, runtime: pair.runtime, frontend, api, })), ), ), ...selfFrontendCombos.flatMap((frontend) => apiOptions.map((api) => ({ backend: "self", runtime: "none", frontend, api, })), ), ...standardFrontendCombos.map((frontend) => ({ backend: "convex", runtime: "none", frontend, api: "none", })), ]; const failures: string[] = []; for (const [index, combo] of combos.entries()) { const config = { projectName: `clerk-matrix-${index}`, frontend: combo.frontend, backend: combo.backend, runtime: combo.runtime, database: combo.backend === "convex" || combo.api === "none" ? "none" : "sqlite", orm: combo.backend === "convex" || combo.api === "none" ? "none" : "drizzle", auth: "clerk" as const, api: combo.api, addons: ["none"] as const, examples: ["none"] as const, dbSetup: "none" as const, webDeploy: "none" as const, serverDeploy: combo.runtime === "workers" ? ("cloudflare" as const) : ("none" as const), install: false, git: false, packageManager: "bun" as const, payments: "none" as const, }; const validation = validateConfigCompatibility(config); if (validation.isErr()) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: ${validation.error.message}`, ); continue; } const result = await createVirtual(config); if (result.isErr()) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: ${result.error.message}`, ); continue; } const files = collectFiles(result.value.root, result.value.root.path); if (combo.backend !== "convex" && combo.runtime !== "workers") { const serverEnv = files.get("packages/env/src/server.ts"); if (!serverEnv?.includes("CLERK_SECRET_KEY")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing CLERK_SECRET_KEY in packages/env/src/server.ts`, ); } if ( needsServerClerkPublishableKey(combo.backend, combo.api) && !serverEnv?.includes("CLERK_PUBLISHABLE_KEY") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing CLERK_PUBLISHABLE_KEY in packages/env/src/server.ts`, ); } } if (combo.backend !== "convex" && combo.api !== "none") { const contextFile = files.get("packages/api/src/context.ts"); const expectedImport = expectedContextImport(combo.backend); if (!contextFile?.includes(expectedImport)) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing ${expectedImport} in packages/api/src/context.ts`, ); } if (!contextFile?.includes("type ClerkContextAuth")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing ClerkContextAuth in packages/api/src/context.ts`, ); } if (!contextFile?.includes("type ClerkRequestContext")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing ClerkRequestContext in packages/api/src/context.ts`, ); } if ( usesBackendClerkClient(combo.backend, combo.api) && !contextFile?.includes("publishableKey: env.CLERK_PUBLISHABLE_KEY") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing publishableKey in packages/api/src/context.ts`, ); } if ( usesBackendClerkClient(combo.backend, combo.api) && !contextFile?.includes("authorizedParties: [env.CORS_ORIGIN]") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing authorizedParties in packages/api/src/context.ts`, ); } } if (needsServerClerkPublishableKey(combo.backend, combo.api)) { const appEnvPath = combo.backend === "self" ? "apps/web/.env" : "apps/server/.env"; const appEnvFile = files.get(appEnvPath); if (!appEnvFile?.includes("CLERK_PUBLISHABLE_KEY=")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing CLERK_PUBLISHABLE_KEY in ${appEnvPath}`, ); } } if (combo.frontend.includes("next")) { const dashboard = files.get("apps/web/src/app/dashboard/page.tsx"); if (!dashboard) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing Next dashboard page`, ); } else if (dashboard.includes("SignedIn") || dashboard.includes("SignedOut")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: Next dashboard still uses SignedIn/SignedOut`, ); } else if ( combo.backend !== "convex" && combo.api !== "none" && !dashboard.includes("privateData.queryOptions()") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: Next dashboard is missing protected privateData query`, ); } if (combo.backend !== "convex") { const proxyFile = files.get("apps/web/src/proxy.ts"); if (!proxyFile) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing Next proxy file`, ); } else if ( proxyFile.includes('/env/server"') || proxyFile.includes("env.CLERK_SECRET_KEY") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: Next proxy still imports shared server env`, ); } } } if (combo.frontend.includes("react-router")) { const dashboard = files.get("apps/web/src/routes/dashboard.tsx"); if (!dashboard) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing React Router dashboard route`, ); } else if (dashboard.includes("SignedIn") || dashboard.includes("SignedOut")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: React Router dashboard still uses SignedIn/SignedOut`, ); } else if ( combo.backend !== "convex" && combo.api !== "none" && !dashboard.includes("privateData.queryOptions()") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: React Router dashboard is missing protected privateData query`, ); } } if (combo.frontend.includes("tanstack-router")) { const dashboard = files.get("apps/web/src/routes/dashboard.tsx"); if (!dashboard) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing TanStack Router dashboard route`, ); } else if (dashboard.includes("SignedIn") || dashboard.includes("SignedOut")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: TanStack Router dashboard still uses SignedIn/SignedOut`, ); } else if ( combo.backend !== "convex" && combo.api !== "none" && !dashboard.includes("privateData.queryOptions()") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: TanStack Router dashboard is missing protected privateData query`, ); } } if (combo.frontend.includes("tanstack-start")) { const dashboard = files.get("apps/web/src/routes/dashboard.tsx"); if (!dashboard) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing TanStack Start dashboard route`, ); } else if (dashboard.includes("SignedIn") || dashboard.includes("SignedOut")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: TanStack Start dashboard still uses SignedIn/SignedOut`, ); } else if ( combo.backend !== "convex" && combo.api !== "none" && !dashboard.includes("privateData.queryOptions()") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: TanStack Start dashboard is missing protected privateData query`, ); } if (combo.backend !== "convex") { const startFile = files.get("apps/web/src/start.ts"); if (!startFile) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing TanStack Start entry file`, ); } else if ( startFile.includes('/env/server"') || startFile.includes("env.CLERK_SECRET_KEY") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: TanStack Start entry still imports shared server env`, ); } } } const nativeFrontend = combo.frontend.find((entry) => entry.startsWith("native-")); if (nativeFrontend) { const nativePackage = files.get("apps/native/package.json"); const nativeSignIn = files.get("apps/native/app/(auth)/sign-in.tsx"); const nativeSignUp = files.get("apps/native/app/(auth)/sign-up.tsx"); if (!nativePackage?.includes('"@clerk/expo": "^3.1.3"')) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: native package is missing @clerk/expo ^3.1.3`, ); } if (!nativeSignIn) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing native sign-in screen`, ); } else { if (nativeSignIn.includes("setActive") || nativeSignIn.includes("signIn.create")) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: native sign-in still uses the legacy Clerk Expo API`, ); } if ( !nativeSignIn.includes("const { signIn, errors, fetchStatus } = useSignIn()") || !nativeSignIn.includes("await signIn.password") || !nativeSignIn.includes("await signIn.finalize") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: native sign-in is missing the current Clerk Expo flow`, ); } } if (!nativeSignUp) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: missing native sign-up screen`, ); } else { if ( nativeSignUp.includes("setActive") || nativeSignUp.includes("prepareEmailAddressVerification") || nativeSignUp.includes("attemptEmailAddressVerification") ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: native sign-up still uses the legacy Clerk Expo API`, ); } if ( !nativeSignUp.includes("const { signUp, errors, fetchStatus } = useSignUp()") || !nativeSignUp.includes("await signUp.password") || !nativeSignUp.includes("await signUp.verifications.sendEmailCode()") || !nativeSignUp.includes("await signUp.verifications.verifyEmailCode") || !nativeSignUp.includes("await signUp.finalize") || !nativeSignUp.includes('nativeID="clerk-captcha"') ) { failures.push( `${combo.backend}/${combo.runtime}/${combo.frontend.join("+")}/${combo.api}: native sign-up is missing the current Clerk Expo flow`, ); } } } } expect(combos).toHaveLength(499); expect(failures).toEqual([]); }); }); ================================================ FILE: apps/cli/test/cli-validation.test.ts ================================================ import { expect, test } from "bun:test"; import { FailedToExitError } from "trpc-cli"; import { createBtsCli } from "../src/index"; import { getProvidedFlags, processAndValidateFlags } from "../src/validation"; test("surfaces a friendly validation error for invalid addons", async () => { const logs: string[] = []; const result = await createBtsCli() .run({ argv: ["create", "ryu", "--addons", "ruler"], logger: { error: (...args) => logs.push(args.map(String).join(" ")), }, process: { exit: () => 0 as never }, }) .catch((error) => error); expect(result).toBeInstanceOf(FailedToExitError); expect(result.exitCode).toBe(1); const output = logs.join("\n"); expect(output).toContain("Invalid option"); expect(output).toContain("at [1].addons[0]"); expect(output).not.toContain("ORPCError"); expect(output).not.toContain("Input validation failed"); }); test("allows self + D1 flags before web deploy is resolved by prompts", () => { const options = { backend: "self", frontend: ["next"], database: "sqlite", orm: "drizzle", dbSetup: "d1", api: "trpc", auth: "better-auth", payments: "none", addons: ["none"], examples: ["none"], runtime: "none", } as const; const result = processAndValidateFlags(options, getProvidedFlags(options), "my-app"); expect(result.isOk()).toBe(true); }); test("allows workers + D1 flags before server deploy is resolved by prompts", () => { const options = { backend: "hono", frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", dbSetup: "d1", api: "trpc", auth: "none", payments: "none", addons: ["none"], examples: ["none"], runtime: "workers", } as const; const result = processAndValidateFlags(options, getProvidedFlags(options), "my-app"); expect(result.isOk()).toBe(true); }); test("still rejects D1 when the remaining prompt flow cannot resolve it to a valid target", () => { const options = { backend: "hono", frontend: ["tanstack-router"], database: "sqlite", orm: "drizzle", dbSetup: "d1", api: "trpc", auth: "none", payments: "none", addons: ["none"], examples: ["none"], runtime: "node", } as const; const result = processAndValidateFlags(options, getProvidedFlags(options), "my-app"); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain( "Cloudflare D1 setup requires SQLite database and either Cloudflare Workers runtime with server deployment or backend 'self' with Cloudflare web deployment.", ); } }); ================================================ FILE: apps/cli/test/cloudflare-db-clients.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { createVirtual } from "../src/index"; import { collectFiles } from "./setup"; async function createVirtualFiles(config: Parameters[0]) { const result = await createVirtual(config); if (result.isErr()) { throw result.error; } return collectFiles(result.value.root, result.value.root.path); } describe("Cloudflare DB client generation", () => { it("uses request-scoped db/auth factories for Workers templates", async () => { const files = await createVirtualFiles({ projectName: "workers-request-scoped-db", frontend: ["tanstack-router"], backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "better-auth", addons: ["none"], examples: ["todo"], dbSetup: "turso", webDeploy: "none", serverDeploy: "cloudflare", install: false, git: false, packageManager: "bun", payments: "none", api: "trpc", }); const dbFile = files.get("packages/db/src/index.ts"); const authFile = files.get("packages/auth/src/index.ts"); const envFile = files.get("packages/env/src/server.ts"); const serverFile = files.get("apps/server/src/index.ts"); const contextFile = files.get("packages/api/src/context.ts"); const todoRouterFile = files.get("packages/api/src/routers/todo.ts"); expect(dbFile).toContain("export function createDb()"); expect(dbFile).not.toContain("export const db = createDb();"); expect(authFile).toContain("export function createAuth()"); expect(authFile).not.toContain("export const auth = createAuth();"); expect(envFile).toContain('export { env } from "cloudflare:workers";'); expect(serverFile).toContain("createAuth().handler(c.req.raw)"); expect(contextFile).toContain("createAuth().api.getSession"); expect(todoRouterFile).toContain("const db = createDb();"); }); it("uses request-scoped db/auth factories for Next on Cloudflare", async () => { const files = await createVirtualFiles({ projectName: "next-cloudflare-request-scoped-db", frontend: ["next"], backend: "self", runtime: "none", database: "postgres", orm: "prisma", auth: "better-auth", addons: ["none"], examples: ["todo"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", api: "trpc", }); const dbFile = files.get("packages/db/src/index.ts"); const authFile = files.get("packages/auth/src/index.ts"); const envFile = files.get("packages/env/src/server.ts"); const envPackageFile = files.get("packages/env/package.json"); const routeFile = files.get("apps/web/src/app/api/auth/[...all]/route.ts"); const dashboardFile = files.get("apps/web/src/app/dashboard/page.tsx"); const contextFile = files.get("packages/api/src/context.ts"); expect(dbFile).toContain("export function createPrismaClient()"); expect(dbFile).not.toContain("export default prisma;"); expect(authFile).toContain("const prisma = createPrismaClient();"); expect(authFile).not.toContain("export const auth = createAuth();"); expect(envFile).toContain('import { getCloudflareContext } from "@opennextjs/cloudflare";'); expect(envFile).toContain("type EnvValue = Env[keyof Env];"); expect(envFile).toContain( "function resolveEnvValue(key: keyof Env & string): EnvValue | undefined", ); expect(envFile).toContain("export async function getEnvAsync()"); expect(envFile).toContain("getCloudflareContext({ async: true })"); expect(envFile).toContain("export const env = createEnvProxy(resolveEnvValue);"); expect(envFile).not.toContain('export { env } from "cloudflare:workers";'); expect(envPackageFile).toContain('"@opennextjs/cloudflare"'); expect(dbFile).toContain("maxUses: 1"); expect(routeFile).toContain("toNextJsHandler(createAuth()).GET(request)"); expect(routeFile).toContain("toNextJsHandler(createAuth()).POST(request)"); expect(dashboardFile).toContain("createAuth().api.getSession"); expect(dashboardFile).not.toContain('import { authClient } from "@/lib/auth-client";'); expect(contextFile).toContain("createAuth().api.getSession"); }); const selfCloudflareD1Scenarios = [ { name: "Next.js", frontend: "next", api: "trpc", routePath: "apps/web/src/app/api/auth/[...all]/route.ts", routeNeedles: [ "toNextJsHandler(createAuth()).GET(request)", "toNextJsHandler(createAuth()).POST(request)", ], envNeedle: 'import { getCloudflareContext } from "@opennextjs/cloudflare";', envAbsentNeedle: 'export { env } from "cloudflare:workers";', }, { name: "TanStack Start", frontend: "tanstack-start", api: "trpc", routePath: "apps/web/src/routes/api/auth/$.ts", routeNeedles: ["const auth = createAuth()", "return auth.handler(request)"], envNeedle: 'export { env } from "cloudflare:workers";', }, { name: "Nuxt", frontend: "nuxt", api: "orpc", routePath: "apps/web/server/api/auth/[...all].ts", routeNeedles: ["const auth = createAuth();", "return auth.handler(toWebRequest(event));"], envNeedle: 'export { env } from "cloudflare:workers";', }, { name: "Astro", frontend: "astro", api: "orpc", routePath: "apps/web/src/pages/api/auth/[...all].ts", routeNeedles: ["const auth = createAuth();", "return auth.handler(ctx.request);"], envNeedle: 'export { env } from "cloudflare:workers";', }, ] as const; for (const scenario of selfCloudflareD1Scenarios) { it(`uses request-scoped D1 db/auth factories for ${scenario.name} with self backend on Cloudflare`, async () => { const files = await createVirtualFiles({ projectName: `${scenario.frontend}-self-cloudflare-d1`, frontend: [scenario.frontend], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", addons: ["none"], examples: ["todo"], dbSetup: "d1", webDeploy: "cloudflare", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", api: scenario.api, }); const dbFile = files.get("packages/db/src/index.ts"); const authFile = files.get("packages/auth/src/index.ts"); const envFile = files.get("packages/env/src/server.ts"); const routeFile = files.get(scenario.routePath); const contextFile = files.get("packages/api/src/context.ts"); const todoRouterFile = files.get("packages/api/src/routers/todo.ts"); expect(dbFile).toContain('import { drizzle } from "drizzle-orm/d1";'); expect(dbFile).toContain("return drizzle(env.DB, { schema });"); expect(dbFile).not.toContain('import { drizzle } from "drizzle-orm/libsql";'); expect(dbFile).not.toContain("export const db = createDb();"); expect(authFile).toContain("export function createAuth()"); expect(authFile).not.toContain("export const auth = createAuth();"); expect(envFile).toContain(scenario.envNeedle); if (scenario.envAbsentNeedle) { expect(envFile).not.toContain(scenario.envAbsentNeedle); } for (const needle of scenario.routeNeedles) { expect(routeFile).toContain(needle); } expect(contextFile).toContain("createAuth().api.getSession"); expect(todoRouterFile).toContain("const db = createDb();"); }); } it("uses Prisma D1 request-scoped factories for Next self backend on Cloudflare", async () => { const files = await createVirtualFiles({ projectName: "next-self-cloudflare-prisma-d1", frontend: ["next"], backend: "self", runtime: "none", database: "sqlite", orm: "prisma", auth: "better-auth", addons: ["none"], examples: ["todo"], dbSetup: "d1", webDeploy: "cloudflare", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", api: "trpc", }); const dbFile = files.get("packages/db/src/index.ts"); const authFile = files.get("packages/auth/src/index.ts"); const envFile = files.get("packages/env/src/server.ts"); const routeFile = files.get("apps/web/src/app/api/auth/[...all]/route.ts"); const contextFile = files.get("packages/api/src/context.ts"); expect(dbFile).toContain('import { PrismaD1 } from "@prisma/adapter-d1";'); expect(dbFile).toContain("const adapter = new PrismaD1(env.DB);"); expect(dbFile).not.toContain("export default prisma;"); expect(authFile).toContain("const prisma = createPrismaClient();"); expect(authFile).not.toContain("export const auth = createAuth();"); expect(envFile).toContain('import { getCloudflareContext } from "@opennextjs/cloudflare";'); expect(envFile).toContain("type EnvValue = Env[keyof Env];"); expect(routeFile).toContain("toNextJsHandler(createAuth()).GET(request)"); expect(routeFile).toContain("toNextJsHandler(createAuth()).POST(request)"); expect(contextFile).toContain("createAuth().api.getSession"); }); it("uses maxUses=1 for Cloudflare-targeted Postgres pools", async () => { const files = await createVirtualFiles({ projectName: "workers-postgres-pool-config", frontend: ["tanstack-router"], backend: "hono", runtime: "workers", database: "postgres", orm: "drizzle", auth: "better-auth", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "cloudflare", install: false, git: false, packageManager: "bun", payments: "none", api: "trpc", }); const dbFile = files.get("packages/db/src/index.ts"); expect(dbFile).toContain('import { Pool } from "pg";'); expect(dbFile).toContain("maxUses: 1"); expect(dbFile).toContain("return drizzle({ client: pool, schema });"); }); it("keeps Better Auth MongoDB templates factory-only for Cloudflare Next deployments", async () => { const files = await createVirtualFiles({ projectName: "next-cloudflare-mongodb-auth", frontend: ["next"], backend: "self", runtime: "none", database: "mongodb", orm: "mongoose", auth: "better-auth", addons: ["none"], examples: ["none"], dbSetup: "mongodb-atlas", webDeploy: "cloudflare", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", api: "trpc", }); const authFile = files.get("packages/auth/src/index.ts"); const routeFile = files.get("apps/web/src/app/api/auth/[...all]/route.ts"); expect(authFile).toContain("export function createAuth()"); expect(authFile).not.toContain("export const auth = createAuth();"); expect(routeFile).toContain("toNextJsHandler(createAuth()).GET(request)"); }); it("keeps singleton exports for non-Cloudflare runtimes", async () => { const files = await createVirtualFiles({ projectName: "bun-singleton-db", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "better-auth", addons: ["none"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", api: "trpc", }); const dbFile = files.get("packages/db/src/index.ts"); const authFile = files.get("packages/auth/src/index.ts"); const serverFile = files.get("apps/server/src/index.ts"); expect(dbFile).toContain("export const db = createDb();"); expect(authFile).toContain("export const auth = createAuth();"); expect(serverFile).toContain("auth.handler(c.req.raw)"); }); it("keeps singleton auth handlers for Next outside Cloudflare", async () => { const files = await createVirtualFiles({ projectName: "next-singleton-auth", frontend: ["next"], backend: "self", runtime: "none", database: "postgres", orm: "prisma", auth: "better-auth", addons: ["none"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", api: "trpc", }); const authFile = files.get("packages/auth/src/index.ts"); const routeFile = files.get("apps/web/src/app/api/auth/[...all]/route.ts"); expect(authFile).toContain("export const auth = createAuth();"); expect(routeFile).toContain("export const { GET, POST } = toNextJsHandler(auth);"); expect(routeFile).not.toContain("createAuth()"); }); }); ================================================ FILE: apps/cli/test/database-orm.test.ts ================================================ import { describe, it } from "bun:test"; import type { Database, ORM } from "../src/types"; import { DATABASES, expectError, expectSuccess, runTRPCTest } from "./test-utils"; describe("Database and ORM Combinations", () => { describe("Valid Database-ORM Combinations", () => { const validCombinations: Array<{ database: Database; orm: ORM }> = [ // SQLite combinations { database: "sqlite" as Database, orm: "drizzle" as ORM }, { database: "sqlite" as Database, orm: "prisma" as ORM }, // PostgreSQL combinations { database: "postgres" as Database, orm: "drizzle" as ORM }, { database: "postgres" as Database, orm: "prisma" as ORM }, // MySQL combinations { database: "mysql" as Database, orm: "drizzle" as ORM }, { database: "mysql" as Database, orm: "prisma" as ORM }, // MongoDB combinations { database: "mongodb" as Database, orm: "mongoose" as ORM }, { database: "mongodb" as Database, orm: "prisma" as ORM }, // None combinations { database: "none" as Database, orm: "none" as ORM }, ]; for (const { database, orm } of validCombinations) { it(`should work with ${database} + ${orm}`, async () => { const result = await runTRPCTest({ projectName: `${database}-${orm}`, database, orm, backend: "hono", runtime: "bun", frontend: ["tanstack-router"], auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); describe("Invalid Database-ORM Combinations", () => { const invalidCombinations: Array<{ database: Database; orm: ORM; error: string; }> = [ // MongoDB with Drizzle (not supported) { database: "mongodb" as Database, orm: "drizzle" as ORM, error: "Drizzle ORM does not support MongoDB", }, // Mongoose with non-MongoDB { database: "sqlite" as Database, orm: "mongoose" as ORM, error: "Mongoose ORM requires MongoDB database", }, { database: "postgres" as Database, orm: "mongoose" as ORM, error: "Mongoose ORM requires MongoDB database", }, { database: "mysql" as Database, orm: "mongoose" as ORM, error: "Mongoose ORM requires MongoDB database", }, // Database without ORM { database: "sqlite" as Database, orm: "none" as ORM, error: "Database selection requires an ORM", }, { database: "postgres" as Database, orm: "none" as ORM, error: "Database selection requires an ORM", }, // ORM without database { database: "none" as Database, orm: "drizzle" as ORM, error: "ORM selection requires a database", }, { database: "none" as Database, orm: "prisma" as ORM, error: "ORM selection requires a database", }, ]; for (const { database, orm, error } of invalidCombinations) { it(`should fail with ${database} + ${orm}`, async () => { const result = await runTRPCTest({ projectName: `invalid-${database}-${orm}`, database, orm, backend: "hono", runtime: "bun", frontend: ["tanstack-router"], auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, error); }); } }); describe("Database-ORM with Authentication", () => { it("should work with database + auth", async () => { const result = await runTRPCTest({ projectName: "db-auth", database: "sqlite", orm: "drizzle", auth: "better-auth", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with auth but no database (non-convex backend)", async () => { const result = await runTRPCTest({ projectName: "auth-no-db", database: "none", orm: "none", auth: "better-auth", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectSuccess(result); }); it("should work with auth but no database (convex backend)", async () => { const result = await runTRPCTest({ projectName: "convex-auth-no-db", database: "none", orm: "none", auth: "none", backend: "convex", runtime: "none", frontend: ["tanstack-router"], api: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("All Database Types", () => { for (const database of DATABASES) { if (database === "none") continue; it(`should have valid ORM options for ${database}`, async () => { // Test with the most compatible ORM for each database const ormMap = { sqlite: "drizzle", postgres: "drizzle", mysql: "drizzle", mongodb: "mongoose", }; const orm = ormMap[database as keyof typeof ormMap]; const result = await runTRPCTest({ projectName: `test-${database}`, database: database as Database, orm: orm as ORM, backend: "hono", runtime: "bun", frontend: ["tanstack-router"], auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); }); ================================================ FILE: apps/cli/test/database-setup.test.ts ================================================ import { describe, it } from "bun:test"; import { DB_SETUPS, expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; describe("Database Setup Configurations", () => { describe("SQLite Database Setups", () => { it("should work with Turso + SQLite", async () => { const result = await runTRPCTest({ projectName: "turso-sqlite", database: "sqlite", orm: "drizzle", dbSetup: "turso", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should work with D1 + SQLite + Workers", async () => { const result = await runTRPCTest({ projectName: "d1-sqlite-workers", database: "sqlite", orm: "drizzle", dbSetup: "d1", backend: "hono", runtime: "workers", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "cloudflare", manualDb: true, install: false, }); expectSuccess(result); }); it("should fail with Turso + non-SQLite database", async () => { const result = await runTRPCTest({ projectName: "turso-postgres-fail", database: "postgres", orm: "drizzle", dbSetup: "turso", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, expectError: true, }); expectError(result, "Turso setup requires SQLite database"); }); }); describe("PostgreSQL Database Setups", () => { it("should work with Neon + PostgreSQL", async () => { const result = await runTRPCTest({ projectName: "neon-postgres", database: "postgres", orm: "drizzle", dbSetup: "neon", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should work with Supabase + PostgreSQL", async () => { const result = await runTRPCTest({ projectName: "supabase-postgres", database: "postgres", orm: "drizzle", dbSetup: "supabase", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should work with Prisma PostgreSQL setup", async () => { const result = await runTRPCTest({ projectName: "prisma-postgres-setup", database: "postgres", orm: "prisma", dbSetup: "prisma-postgres", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should fail with Neon + non-PostgreSQL database", async () => { const result = await runTRPCTest({ projectName: "neon-mysql-fail", database: "mysql", orm: "drizzle", dbSetup: "neon", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, expectError: true, }); expectError(result, "Neon setup requires PostgreSQL database"); }); }); describe("MySQL Database Setups", () => { it("should work with PlanetScale + MySQL", async () => { const result = await runTRPCTest({ projectName: "planetscale-mysql", database: "mysql", orm: "drizzle", dbSetup: "planetscale", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should work with PlanetScale + PostgreSQL", async () => { const result = await runTRPCTest({ projectName: "planetscale-postgres", database: "postgres", orm: "drizzle", dbSetup: "planetscale", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); }); describe("MongoDB Database Setups", () => { it("should work with MongoDB Atlas + MongoDB", async () => { const result = await runTRPCTest({ projectName: "mongodb-atlas", database: "mongodb", orm: "mongoose", dbSetup: "mongodb-atlas", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should fail with MongoDB Atlas + non-MongoDB database", async () => { const result = await runTRPCTest({ projectName: "mongodb-atlas-sqlite-fail", database: "sqlite", orm: "drizzle", dbSetup: "mongodb-atlas", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, expectError: true, }); expectError(result, "MongoDB Atlas setup requires MongoDB database"); }); }); describe("Docker Database Setup", () => { it("should work with Docker + PostgreSQL", async () => { const result = await runTRPCTest({ projectName: "docker-postgres", database: "postgres", orm: "drizzle", dbSetup: "docker", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should work with Docker + MySQL", async () => { const result = await runTRPCTest({ projectName: "docker-mysql", database: "mysql", orm: "drizzle", dbSetup: "docker", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should work with Docker + MongoDB", async () => { const result = await runTRPCTest({ projectName: "docker-mongodb", database: "mongodb", orm: "mongoose", dbSetup: "docker", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }); expectSuccess(result); }); it("should fail with Docker + SQLite", async () => { const result = await runTRPCTest({ projectName: "docker-sqlite-fail", database: "sqlite", orm: "drizzle", dbSetup: "docker", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, expectError: true, }); expectError(result, "Docker setup is not compatible with SQLite database"); }); }); describe("No Database Setup", () => { it("should work with dbSetup none", async () => { const result = await runTRPCTest({ projectName: "no-db-setup", database: "sqlite", orm: "drizzle", dbSetup: "none", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with dbSetup but no database", async () => { const result = await runTRPCTest({ projectName: "db-setup-no-db-fail", database: "none", orm: "none", dbSetup: "turso", backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "Database setup requires a database. Please choose a database or set '--db-setup none'.", ); }); }); describe("Special Runtime Constraints", () => { it("should work with D1 + Workers runtime", async () => { const result = await runTRPCTest({ projectName: "d1-workers-valid", database: "sqlite", orm: "drizzle", dbSetup: "d1", backend: "hono", runtime: "workers", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); it("should work with D1 + self backend + Cloudflare web deploy", async () => { const result = await runTRPCTest({ projectName: "d1-self-cloudflare-valid", database: "sqlite", orm: "drizzle", dbSetup: "d1", backend: "self", runtime: "none", auth: "none", api: "trpc", frontend: ["next"], addons: ["none"], examples: ["none"], webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with D1 + non-Workers runtime", async () => { const result = await runTRPCTest({ projectName: "d1-node-fail", database: "sqlite", orm: "drizzle", dbSetup: "d1", backend: "hono", runtime: "node", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "Cloudflare D1 setup requires SQLite database and either Cloudflare Workers runtime with server deployment or backend 'self' with Cloudflare web deployment.", ); }); it("should fail with D1 + self backend without Cloudflare web deploy", async () => { const result = await runTRPCTest({ projectName: "d1-self-no-cloudflare-fail", database: "sqlite", orm: "drizzle", dbSetup: "d1", backend: "self", runtime: "none", auth: "none", api: "trpc", frontend: ["next"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "Cloudflare D1 setup requires SQLite database and either Cloudflare Workers runtime with server deployment or backend 'self' with Cloudflare web deployment.", ); }); }); describe("All Database Setup Types", () => { for (const dbSetup of DB_SETUPS) { if (dbSetup === "none") continue; it(`should work with ${dbSetup} in appropriate setup`, async () => { const config: TestConfig = { projectName: `test-${dbSetup}`, dbSetup, backend: "hono", runtime: "bun", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], webDeploy: "none", serverDeploy: "none", manualDb: true, install: false, }; // Set appropriate database and ORM for each setup switch (dbSetup) { case "turso": config.database = "sqlite"; config.orm = "drizzle"; break; case "neon": case "supabase": case "prisma-postgres": config.database = "postgres"; config.orm = "drizzle"; break; case "planetscale": config.database = "mysql"; config.orm = "drizzle"; break; case "mongodb-atlas": config.database = "mongodb"; config.orm = "mongoose"; break; case "d1": config.database = "sqlite"; config.orm = "drizzle"; config.runtime = "workers"; config.serverDeploy = "cloudflare"; break; case "docker": config.database = "postgres"; config.orm = "drizzle"; break; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); }); ================================================ FILE: apps/cli/test/db-setup-mode-resolution.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { mergeResolvedDbSetupOptions, resolveDbSetupMode, } from "../src/helpers/core/db-setup-options"; import { runWithContext } from "../src/utils/context"; describe("DB setup mode resolution", () => { it("does not force auto mode when manualDb is explicitly false", () => { const mode = runWithContext({ silent: false }, () => resolveDbSetupMode("neon", { manualDb: false }), ); expect(mode).toBeUndefined(); }); it("defaults remote provisioning setups to manual in silent mode", () => { const mode = runWithContext({ silent: true }, () => resolveDbSetupMode("supabase")); expect(mode).toBe("manual"); }); it("drops dbSetupOptions when dbSetup is none", () => { const merged = runWithContext({ silent: false }, () => mergeResolvedDbSetupOptions("none", { mode: "manual" }), ); expect(merged).toBeUndefined(); }); }); ================================================ FILE: apps/cli/test/db-setup-options.test.ts ================================================ import { beforeEach, describe, expect, it } from "bun:test"; import path from "node:path"; import fs from "fs-extra"; import { create } from "../src/index"; import { readBtsConfig } from "../src/utils/bts-config"; const SMOKE_DIR_PATH = path.join(import.meta.dir, "..", ".smoke"); describe("Database setup options", () => { beforeEach(() => { process.env.BTS_SKIP_EXTERNAL_COMMANDS = "1"; process.env.BTS_TEST_MODE = "1"; }); it("defaults remote provider setup to manual in silent mode and uses flags when mode is representable", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "db-setup-neon-default-manual"); await fs.remove(projectPath); const result = await create(projectPath, { frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "postgres", orm: "drizzle", auth: "none", payments: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "neon", webDeploy: "none", serverDeploy: "none", install: false, disableAnalytics: true, }); expect(result.isOk()).toBe(true); if (result.isErr()) return; expect(result.value.projectConfig.dbSetupOptions).toEqual({ mode: "manual" }); expect(result.value.reproducibleCommand).toContain("--db-setup neon"); expect(result.value.reproducibleCommand).toContain("--manual-db"); expect(result.value.reproducibleCommand).not.toContain("create-json --input"); const btsConfig = await readBtsConfig(projectPath); expect(btsConfig?.dbSetupOptions).toEqual({ mode: "manual" }); }); it("uses flags when dbSetupOptions only contains auto mode", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "db-setup-neon-auto-flags"); await fs.remove(projectPath); const result = await create(projectPath, { frontend: ["react-router"], backend: "elysia", runtime: "node", database: "postgres", orm: "drizzle", auth: "better-auth", payments: "none", api: "trpc", addons: ["nx"], examples: ["todo"], dbSetup: "neon", webDeploy: "none", serverDeploy: "none", git: true, packageManager: "bun", install: true, dbSetupOptions: { mode: "auto" }, disableAnalytics: true, }); expect(result.isOk()).toBe(true); if (result.isErr()) return; expect(result.value.reproducibleCommand).toContain("--db-setup neon"); expect(result.value.reproducibleCommand).not.toContain("create-json --input"); expect(result.value.reproducibleCommand).not.toContain("--manual-db"); }); it("keeps reproducible command on normal flags when dbSetupOptions include provider-specific nested options", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "db-setup-neon-structured-options"); await fs.remove(projectPath); const result = await create(projectPath, { frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "postgres", orm: "drizzle", auth: "none", payments: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "neon", webDeploy: "none", serverDeploy: "none", dryRun: true, install: false, dbSetupOptions: { mode: "auto", neon: { method: "get-db", }, }, disableAnalytics: true, }); expect(result.isOk()).toBe(true); if (result.isErr()) return; expect(result.value.reproducibleCommand).toContain("--db-setup neon"); expect(result.value.reproducibleCommand).not.toContain("create-json --input"); }); it("does not inject manual dbSetupOptions for non-provisioning setups", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "db-setup-d1-no-manual-default"); await fs.remove(projectPath); const result = await create(projectPath, { frontend: ["tanstack-router"], backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", payments: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "d1", webDeploy: "none", serverDeploy: "cloudflare", install: false, disableAnalytics: true, }); expect(result.isOk()).toBe(true); if (result.isErr()) return; expect(result.value.projectConfig.dbSetupOptions).toBeUndefined(); const btsConfig = await readBtsConfig(projectPath); expect(btsConfig?.dbSetupOptions).toBeUndefined(); }); it("does not persist dbSetupOptions or force create-json when dbSetup is none", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "db-setup-none-no-structured-options"); await fs.remove(projectPath); const result = await create(projectPath, { frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "better-auth", payments: "none", api: "trpc", addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", git: true, packageManager: "bun", install: true, manualDb: false, disableAnalytics: true, }); expect(result.isOk()).toBe(true); if (result.isErr()) return; expect(result.value.projectConfig.dbSetupOptions).toBeUndefined(); expect(result.value.reproducibleCommand).not.toContain("create-json --input"); const btsConfig = await readBtsConfig(projectPath); expect(btsConfig?.dbSetupOptions).toBeUndefined(); }); }); ================================================ FILE: apps/cli/test/deployment.test.ts ================================================ import { describe, it } from "bun:test"; import { expectError, expectSuccess, runTRPCTest, SERVER_DEPLOYS, type TestConfig, WEB_DEPLOYS, } from "./test-utils"; describe("Deployment Configurations", () => { describe("Web Deployment", () => { describe("Valid Web Deploy Configurations", () => { for (const webDeploy of WEB_DEPLOYS) { if (webDeploy === "none") continue; it(`should work with ${webDeploy} web deploy + web frontend`, async () => { const result = await runTRPCTest({ projectName: `${webDeploy}-web-deploy`, webDeploy: webDeploy, serverDeploy: "none", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); } }); it("should work with web deploy none", async () => { const result = await runTRPCTest({ projectName: "no-web-deploy", webDeploy: "none", serverDeploy: "none", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); it("should fail with web deploy but no web frontend", async () => { const result = await runTRPCTest({ projectName: "web-deploy-no-web-frontend-fail", webDeploy: "cloudflare", serverDeploy: "none", frontend: ["native-bare"], // Native frontend only backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", expectError: true, }); expectError(result, "'--web-deploy' requires a web frontend"); }); it("should work with web deploy + mixed web and native frontends", async () => { const result = await runTRPCTest({ projectName: "web-deploy-mixed-frontends", webDeploy: "cloudflare", serverDeploy: "none", frontend: ["tanstack-router", "native-bare"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); it("should work with web deploy + all web frontends", async () => { const webFrontends = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "astro", ] as const; for (const frontend of webFrontends) { const config: TestConfig = { projectName: `web-deploy-${frontend}`, webDeploy: "cloudflare", serverDeploy: "none", frontend: [frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }; // Handle API compatibility if (["nuxt", "svelte", "solid", "astro"].includes(frontend)) { config.api = "orpc"; } else { config.api = "trpc"; } const result = await runTRPCTest(config); expectSuccess(result); } }); }); describe("Server Deployment", () => { describe("Valid Server Deploy Configurations", () => { for (const serverDeploy of SERVER_DEPLOYS) { if (serverDeploy === "none") continue; it(`should work with ${serverDeploy} server deploy + backend`, async () => { const result = await runTRPCTest({ projectName: `${serverDeploy}-server-deploy`, webDeploy: "none", serverDeploy: serverDeploy, backend: "hono", runtime: serverDeploy === "cloudflare" ? "workers" : "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); } }); it("should work with server deploy none", async () => { const result = await runTRPCTest({ projectName: "no-server-deploy", webDeploy: "none", serverDeploy: "none", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); it("should fail with server deploy but no backend", async () => { const result = await runTRPCTest({ projectName: "server-deploy-no-backend-fail", webDeploy: "none", serverDeploy: "cloudflare", backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", expectError: true, }); expectError( result, "Backend 'none' requires '--server-deploy none'. Please remove the --server-deploy flag or set it to 'none'.", ); }); it("should work with server deploy + all compatible backends", async () => { const backends = ["hono", "express", "fastify", "elysia"] as const; for (const backend of backends) { const config: TestConfig = { projectName: `server-deploy-${backend}`, webDeploy: "none", backend, database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, runtime: "workers", }; // Set appropriate runtime if (backend === "hono") { config.runtime = "workers"; config.serverDeploy = "cloudflare"; } else { config.runtime = "bun"; config.serverDeploy = "none"; } const result = await runTRPCTest(config); expectSuccess(result); } }); it("should fail with server deploy + convex backend", async () => { const result = await runTRPCTest({ projectName: "server-deploy-convex-fail", webDeploy: "none", serverDeploy: "cloudflare", backend: "convex", runtime: "none", database: "none", orm: "none", auth: "clerk", api: "none", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", expectError: true, }); expectError(result, "Convex backend requires '--server-deploy none'"); }); }); describe("Workers Runtime Deployment Constraints", () => { it("should work with workers runtime + server deploy", async () => { const result = await runTRPCTest({ projectName: "workers-server-deploy", webDeploy: "none", runtime: "workers", serverDeploy: "cloudflare", backend: "hono", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "d1", install: false, }); expectSuccess(result); }); it("should fail with workers runtime + no server deploy", async () => { const result = await runTRPCTest({ projectName: "workers-no-server-deploy-fail", runtime: "workers", serverDeploy: "none", backend: "hono", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", expectError: true, }); expectError(result, "Cloudflare Workers runtime requires a server deployment"); }); }); describe("Combined Web and Server Deployment", () => { it("should work with both web and server deploy", async () => { const result = await runTRPCTest({ projectName: "web-server-deploy-combo", webDeploy: "cloudflare", serverDeploy: "cloudflare", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); it("should work with different deploy providers", async () => { const result = await runTRPCTest({ projectName: "different-deploy-providers", webDeploy: "cloudflare", serverDeploy: "cloudflare", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); it("should work with web deploy only", async () => { const result = await runTRPCTest({ projectName: "web-deploy-only", webDeploy: "cloudflare", serverDeploy: "none", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); it("should work with server deploy only", async () => { const result = await runTRPCTest({ projectName: "server-deploy-only", webDeploy: "none", serverDeploy: "cloudflare", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); }); describe("Deployment with Special Backend Constraints", () => { it("should work with deployment + self backend", async () => { const result = await runTRPCTest({ projectName: "deploy-self-backend", webDeploy: "cloudflare", serverDeploy: "none", // Self backend doesn't use server deployment backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", frontend: ["next"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); it("should work with deployment + fullstack setup", async () => { const result = await runTRPCTest({ projectName: "deploy-fullstack", webDeploy: "cloudflare", serverDeploy: "cloudflare", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }); expectSuccess(result); }); }); describe("All Deployment Options", () => { const deployOptions: ReadonlyArray<{ webDeploy: TestConfig["webDeploy"]; serverDeploy: TestConfig["serverDeploy"]; }> = [ { webDeploy: "cloudflare", serverDeploy: "cloudflare" }, { webDeploy: "none", serverDeploy: "cloudflare" }, { webDeploy: "none", serverDeploy: "none" }, ]; for (const { webDeploy, serverDeploy } of deployOptions) { it(`should work with webDeploy: ${webDeploy}, serverDeploy: ${serverDeploy}`, async () => { const config: TestConfig = { projectName: `deploy-${webDeploy}-${serverDeploy}`, webDeploy, serverDeploy, backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", install: false, }; // Handle special cases if ( webDeploy !== "none" && !config.frontend?.some((f) => [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", "astro", ].includes(f), ) ) { config.frontend = ["tanstack-router"]; // Ensure web frontend for web deploy } if (serverDeploy !== "none" && config.backend === "none") { config.backend = "hono"; // Ensure backend for server deploy } if (serverDeploy === "none" && webDeploy === "none") { config.runtime = "bun"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Deployment Edge Cases", () => { it("should handle deployment with complex configurations", async () => { const result = await runTRPCTest({ projectName: "complex-deployment", webDeploy: "cloudflare", serverDeploy: "cloudflare", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], // Single web frontend (compatible with PWA) addons: ["pwa", "turborepo"], examples: ["todo"], install: false, }); expectSuccess(result); }); it("should handle deployment constraints properly", async () => { // This should fail because we have web deploy but only native frontend const result = await runTRPCTest({ projectName: "deployment-constraints-fail", webDeploy: "cloudflare", serverDeploy: "none", backend: "none", // No backend but we have server deploy runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["native-bare"], // Only native frontend addons: ["none"], examples: ["none"], dbSetup: "none", expectError: true, }); expectError(result, "'--web-deploy' requires a web frontend"); }); }); }); ================================================ FILE: apps/cli/test/dry-run.test.ts ================================================ import { describe, expect, it } from "bun:test"; import path from "node:path"; import fs from "fs-extra"; import { create } from "../src/index"; const SMOKE_DIR_PATH = path.join(import.meta.dir, "..", ".smoke"); describe("Dry run", () => { it("does not create project directory on dry run", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "dry-run-no-write"); await fs.remove(projectPath); const result = await create(projectPath, { yes: true, dryRun: true, disableAnalytics: true, directoryConflict: "overwrite", }); expect(result.isOk()).toBe(true); expect(await fs.pathExists(projectPath)).toBe(false); }); it("does not clear existing directory with overwrite strategy on dry run", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "dry-run-overwrite-protected"); const sentinelPath = path.join(projectPath, "do-not-delete.txt"); await fs.ensureDir(projectPath); await fs.writeFile(sentinelPath, "keep-me", "utf8"); const result = await create(projectPath, { yes: true, dryRun: true, disableAnalytics: true, directoryConflict: "overwrite", }); expect(result.isOk()).toBe(true); expect(await fs.pathExists(sentinelPath)).toBe(true); expect(await fs.readFile(sentinelPath, "utf8")).toBe("keep-me"); }); }); ================================================ FILE: apps/cli/test/electrobun-addon.test.ts ================================================ import { describe, expect, it } from "bun:test"; import path from "node:path"; import fs from "fs-extra"; import { runTRPCTest } from "./test-utils"; describe("Electrobun addon scaffolding", () => { it("scaffolds the desktop workspace for TanStack Router", async () => { const result = await runTRPCTest({ projectName: "electrobun-files-tanstack-router-static-v2", addons: ["electrobun"], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expect(result.success).toBe(true); expect(result.projectDir).toBeDefined(); if (!result.projectDir) return; const rootPackageJson = await fs.readJson(path.join(result.projectDir, "package.json")); const desktopPackageJson = await fs.readJson( path.join(result.projectDir, "apps", "desktop", "package.json"), ); const desktopConfig = await fs.readFile( path.join(result.projectDir, "apps", "desktop", "electrobun.config.ts"), "utf8", ); const desktopEntry = await fs.readFile( path.join(result.projectDir, "apps", "desktop", "src", "bun", "index.ts"), "utf8", ); const fallbackHtmlExists = await fs.pathExists( path.join(result.projectDir, "apps", "desktop", "src", "fallback", "index.html"), ); expect(rootPackageJson.scripts["dev:desktop"]).toBeDefined(); expect(rootPackageJson.scripts["build:desktop"]).toBeDefined(); expect(rootPackageJson.scripts["build:desktop:canary"]).toBeDefined(); expect(desktopPackageJson.scripts.start).toBeDefined(); expect(desktopPackageJson.scripts.dev).toBeDefined(); expect(desktopPackageJson.scripts["dev:hmr"]).toBeDefined(); expect(desktopPackageJson.scripts.hmr).toContain("bun run --filter web dev"); expect(desktopPackageJson.scripts["build:stable"]).toContain("--env=stable"); expect(desktopPackageJson.scripts["build:canary"]).toContain("--env=canary"); expect(desktopPackageJson.scripts.start).toContain("bun run --filter web build"); expect(desktopConfig).toContain('const webBuildDir = "../web/dist";'); expect(desktopConfig).toContain('[webBuildDir]: "views/mainview"'); expect(desktopConfig).toContain("watchIgnore: [`${webBuildDir}/**`]"); expect(desktopConfig).toContain("bundleCEF: true"); expect(desktopConfig).toContain('defaultRenderer: "cef"'); expect(desktopEntry).toContain("const DEV_SERVER_PORT = 3001;"); expect(desktopConfig).not.toContain("views/fallback"); expect(fallbackHtmlExists).toBe(false); }); it("uses the React Router client build output for packaged desktop assets", async () => { const result = await runTRPCTest({ projectName: "electrobun-files-react-router-static-v2", addons: ["electrobun"], frontend: ["react-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expect(result.success).toBe(true); expect(result.projectDir).toBeDefined(); if (!result.projectDir) return; const desktopConfig = await fs.readFile( path.join(result.projectDir, "apps", "desktop", "electrobun.config.ts"), "utf8", ); const desktopEntry = await fs.readFile( path.join(result.projectDir, "apps", "desktop", "src", "bun", "index.ts"), "utf8", ); expect(desktopConfig).toContain('const webBuildDir = "../web/build/client";'); expect(desktopEntry).toContain("const DEV_SERVER_PORT = 5173;"); }); it("maps desktop asset output and dev ports for non-Vite frontends", async () => { const cases = [ { frontend: "tanstack-start", api: "trpc", expectedOutputDir: 'const webBuildDir = "../web/dist/client";', expectedPort: "const DEV_SERVER_PORT = 3001;", }, { frontend: "next", api: "trpc", expectedOutputDir: 'const webBuildDir = "../web/out";', expectedPort: "const DEV_SERVER_PORT = 3001;", }, { frontend: "nuxt", api: "orpc", expectedOutputDir: 'const webBuildDir = "../web/.output/public";', expectedPort: "const DEV_SERVER_PORT = 3001;", expectedBuildCommand: "bun run --filter web generate", }, { frontend: "svelte", api: "orpc", expectedOutputDir: 'const webBuildDir = "../web/build";', expectedPort: "const DEV_SERVER_PORT = 5173;", }, { frontend: "solid", api: "orpc", expectedOutputDir: 'const webBuildDir = "../web/dist";', expectedPort: "const DEV_SERVER_PORT = 3001;", }, { frontend: "astro", api: "orpc", expectedOutputDir: 'const webBuildDir = "../web/dist";', expectedPort: "const DEV_SERVER_PORT = 4321;", }, ] as const; for (const testCase of cases) { const result = await runTRPCTest({ projectName: `electrobun-files-${testCase.frontend}-static-v2`, addons: ["electrobun"], frontend: [testCase.frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: testCase.api, examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expect(result.success).toBe(true); expect(result.projectDir).toBeDefined(); if (!result.projectDir) continue; const desktopConfig = await fs.readFile( path.join(result.projectDir, "apps", "desktop", "electrobun.config.ts"), "utf8", ); const desktopEntry = await fs.readFile( path.join(result.projectDir, "apps", "desktop", "src", "bun", "index.ts"), "utf8", ); const desktopPackageJson = await fs.readJson( path.join(result.projectDir, "apps", "desktop", "package.json"), ); expect(desktopConfig).toContain(testCase.expectedOutputDir); expect(desktopEntry).toContain(testCase.expectedPort); if ("expectedBuildCommand" in testCase) { expect(desktopPackageJson.scripts.start).toContain(testCase.expectedBuildCommand); expect(desktopPackageJson.scripts["build:stable"]).toContain(testCase.expectedBuildCommand); } } }); it("uses the configured monorepo runner for desktop web commands", async () => { const cases = [ { projectName: "electrobun-turbo-runner-static-v2", addons: ["turborepo", "electrobun"] as const, expectedRunner: "turbo -F web build", expectedHmr: "turbo -F web dev", }, { projectName: "electrobun-nx-runner-static-v2", addons: ["nx", "electrobun"] as const, expectedRunner: "nx run-many -t build --projects=web", expectedHmr: "nx run-many -t dev --projects=web", }, ]; for (const testCase of cases) { const result = await runTRPCTest({ projectName: testCase.projectName, addons: [...testCase.addons], frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expect(result.success).toBe(true); expect(result.projectDir).toBeDefined(); if (!result.projectDir) continue; const desktopPackageJson = await fs.readJson( path.join(result.projectDir, "apps", "desktop", "package.json"), ); expect(desktopPackageJson.scripts.start).toContain(testCase.expectedRunner); expect(desktopPackageJson.scripts.hmr).toBe(testCase.expectedHmr); expect(desktopPackageJson.scripts["build:stable"]).toContain(testCase.expectedRunner); } }); }); ================================================ FILE: apps/cli/test/examples.test.ts ================================================ import { describe, it } from "bun:test"; import { EXAMPLES, expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; describe("Example Configurations", () => { describe("Todo Example", () => { it("should work with todo example + database + backend", async () => { const result = await runTRPCTest({ projectName: "todo-with-db", examples: ["todo"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with todo example + convex backend", async () => { const result = await runTRPCTest({ projectName: "todo-convex", examples: ["todo"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "clerk", api: "none", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with todo example + no backend", async () => { const result = await runTRPCTest({ projectName: "todo-no-backend", examples: ["none"], backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with todo example + backend + no database", async () => { const result = await runTRPCTest({ projectName: "todo-backend-no-db-fail", examples: ["todo"], backend: "hono", runtime: "bun", database: "none", orm: "none", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "The 'todo' example requires a database"); }); }); describe("AI Example", () => { it("should work with AI example + React frontend", async () => { const result = await runTRPCTest({ projectName: "ai-react", examples: ["ai"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with AI example + Next.js", async () => { const result = await runTRPCTest({ projectName: "ai-next", examples: ["ai"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", frontend: ["next"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with AI example + Nuxt", async () => { const result = await runTRPCTest({ projectName: "ai-nuxt", examples: ["ai"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", // tRPC not supported with Nuxt frontend: ["nuxt"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with AI example + Svelte", async () => { const result = await runTRPCTest({ projectName: "ai-svelte", examples: ["ai"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", // tRPC not supported with Svelte frontend: ["svelte"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with AI example + Solid frontend", async () => { const result = await runTRPCTest({ projectName: "ai-solid-fail", examples: ["ai"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", frontend: ["solid"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "The 'ai' example is not compatible with the Solid frontend"); }); it("should work with AI example + Convex + React frontend", async () => { const result = await runTRPCTest({ projectName: "ai-convex-react", examples: ["ai"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "clerk", api: "none", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with AI example + Convex + Next.js", async () => { const result = await runTRPCTest({ projectName: "ai-convex-next", examples: ["ai"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "better-auth", api: "none", frontend: ["next"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with AI example + Convex + Svelte", async () => { const result = await runTRPCTest({ projectName: "ai-convex-svelte-fail", examples: ["ai"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["svelte"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "The 'ai' example with Convex backend only supports React-based frontends (Next.js, TanStack Router, TanStack Start, React Router). Svelte and Nuxt are not supported with Convex AI.", ); }); it("should fail with AI example + Convex + Nuxt", async () => { const result = await runTRPCTest({ projectName: "ai-convex-nuxt-fail", examples: ["ai"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["nuxt"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "The 'ai' example with Convex backend only supports React-based frontends (Next.js, TanStack Router, TanStack Start, React Router). Svelte and Nuxt are not supported with Convex AI.", ); }); it("should fail with Convex + Solid (blocked at backend level)", async () => { const result = await runTRPCTest({ projectName: "convex-solid-fail", examples: ["none"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["solid"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "The following frontends are not compatible with '--backend convex': solid", ); }); }); describe("Multiple Examples", () => { it("should work with both todo and AI examples", async () => { const result = await runTRPCTest({ projectName: "todo-ai-combo", examples: ["todo", "ai"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with both examples if one is incompatible", async () => { const result = await runTRPCTest({ projectName: "todo-ai-solid-fail", examples: ["todo", "ai"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", frontend: ["solid"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "The 'ai' example is not compatible with the Solid frontend"); }); }); describe("Examples with None Option", () => { it("should work with examples none", async () => { const result = await runTRPCTest({ projectName: "no-examples", examples: ["none"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with none + other examples", async () => { const result = await runTRPCTest({ projectName: "none-with-examples-fail", examples: ["none", "todo"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot combine 'none' with other examples"); }); }); describe("Examples with API None", () => { it("should fail with examples when API is none (non-convex backend)", async () => { const result = await runTRPCTest({ projectName: "examples-api-none-fail", examples: ["todo"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "none", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot use '--examples todo' when '--api' is set to 'none'"); }); it("should work with examples when API is none (convex backend)", async () => { const result = await runTRPCTest({ projectName: "examples-api-none-convex", examples: ["todo"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "clerk", api: "none", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("All Example Types", () => { for (const example of EXAMPLES) { if (example === "none") continue; it(`should work with ${example} example in appropriate setup`, async () => { const config: TestConfig = { projectName: `test-${example}`, examples: [example], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }; const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Example Edge Cases", () => { it("should work with empty examples array", async () => { const result = await runTRPCTest({ projectName: "empty-examples", examples: ["none"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should handle complex example constraints", async () => { // Todo example with backend but no database should fail const result = await runTRPCTest({ projectName: "complex-example-constraints", examples: ["todo"], backend: "express", // Non-convex backend runtime: "bun", database: "none", // No database orm: "none", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "The 'todo' example requires a database"); }); }); }); ================================================ FILE: apps/cli/test/external-commands.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { setupOxlint } from "../src/helpers/addons/oxlint-setup"; import { installDependencies } from "../src/helpers/core/install-dependencies"; import { getPackageExecutionArgs } from "../src/utils/package-runner"; import { SMOKE_DIR } from "./setup"; describe("External Command Guards", () => { it("should split quoted args correctly", () => { const args = getPackageExecutionArgs( "bun", `get-db@latest --yes --ref "sbA3tIe" --name test-db`, ); expect(args).toEqual([ "bunx", "get-db@latest", "--yes", "--ref", "sbA3tIe", "--name", "test-db", ]); }); it("should skip dependency installation when test mode is enabled", async () => { const result = await installDependencies({ projectDir: SMOKE_DIR, packageManager: "bun", }); expect(result.isOk()).toBe(true); }); it("should update package.json without running oxlint init in test mode", async () => { const projectDir = join(SMOKE_DIR, "oxlint-skip"); await mkdir(projectDir, { recursive: true }); const pkgJsonPath = join(projectDir, "package.json"); await writeFile( pkgJsonPath, JSON.stringify( { name: "oxlint-skip", version: "0.0.0", scripts: {}, devDependencies: {}, }, null, 2, ), ); const result = await setupOxlint(projectDir, "bun"); expect(result.isOk()).toBe(true); const updated = await Bun.file(pkgJsonPath).json(); expect(updated.scripts?.check).toBe("oxlint && oxfmt --write"); expect(updated.devDependencies?.oxlint).toBeDefined(); expect(updated.devDependencies?.oxfmt).toBeDefined(); }); }); ================================================ FILE: apps/cli/test/frontend.test.ts ================================================ import { describe, it } from "bun:test"; import { expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; describe("Frontend Configurations", () => { describe("Single Frontend Options", () => { const singleFrontends = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "native-bare", "native-uniwind", "native-unistyles", "svelte", "solid", "astro", ] satisfies ReadonlyArray< | "tanstack-router" | "react-router" | "tanstack-start" | "next" | "nuxt" | "native-bare" | "native-uniwind" | "native-unistyles" | "svelte" | "solid" | "astro" >; for (const frontend of singleFrontends) { it(`should work with ${frontend}`, async () => { const config: TestConfig = { projectName: `${frontend}-app`, frontend: [frontend], install: false, }; // Set compatible defaults based on frontend if (frontend === "solid") { // Solid is not compatible with Convex backend config.backend = "hono"; config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "orpc"; // tRPC not supported with solid config.addons = ["none"]; config.examples = ["none"]; config.dbSetup = "none"; config.webDeploy = "none"; config.serverDeploy = "none"; } else if (frontend === "next") { // Next.js can use self backend (fullstack) config.backend = "self"; config.runtime = "none"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "better-auth"; config.api = "trpc"; config.addons = ["none"]; config.examples = ["none"]; config.dbSetup = "none"; config.webDeploy = "none"; config.serverDeploy = "none"; } else if (["nuxt", "svelte"].includes(frontend)) { config.backend = "hono"; config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "orpc"; // tRPC not supported with nuxt/svelte config.addons = ["none"]; config.examples = ["none"]; config.dbSetup = "none"; config.webDeploy = "none"; config.serverDeploy = "none"; } else if (frontend === "astro") { // Astro uses oRPC, not Convex compatible config.backend = "hono"; config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "orpc"; // tRPC not supported with astro config.addons = ["none"]; config.examples = ["none"]; config.dbSetup = "none"; config.webDeploy = "none"; config.serverDeploy = "none"; } else { config.backend = "hono"; config.runtime = "bun"; config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "trpc"; config.addons = ["none"]; config.examples = ["none"]; config.dbSetup = "none"; config.webDeploy = "none"; config.serverDeploy = "none"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); describe("Frontend Compatibility with API", () => { it("should work with React frontends + tRPC", async () => { const result = await runTRPCTest({ projectName: "react-trpc", frontend: ["tanstack-router"], api: "trpc", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with Nuxt + tRPC", async () => { const result = await runTRPCTest({ projectName: "nuxt-trpc-fail", frontend: ["nuxt"], api: "trpc", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'nuxt' frontend"); }); it("should fail with Svelte + tRPC", async () => { const result = await runTRPCTest({ projectName: "svelte-trpc-fail", frontend: ["svelte"], api: "trpc", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'svelte' frontend"); }); it("should fail with Solid + tRPC", async () => { const result = await runTRPCTest({ projectName: "solid-trpc-fail", frontend: ["solid"], api: "trpc", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'solid' frontend"); }); it("should fail with Astro + tRPC", async () => { const result = await runTRPCTest({ projectName: "astro-trpc-fail", frontend: ["astro"], api: "trpc", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'astro' frontend"); }); const frontends = ["nuxt", "svelte", "solid", "astro"] as const; for (const frontend of frontends) { it(`should work with ${frontend} + oRPC`, async () => { const result = await runTRPCTest({ projectName: `${frontend}-orpc`, frontend: [frontend], api: "orpc", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); describe("Frontend Compatibility with Backend", () => { it("should fail Solid + Convex", async () => { const result = await runTRPCTest({ projectName: "solid-convex-fail", frontend: ["solid"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "The following frontends are not compatible with '--backend convex': solid. Please choose a different frontend or backend.", ); }); it("should fail Astro + Convex", async () => { const result = await runTRPCTest({ projectName: "astro-convex-fail", frontend: ["astro"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "The following frontends are not compatible with '--backend convex': astro. Please choose a different frontend or backend.", ); }); it("should work with React frontends + Convex", async () => { const result = await runTRPCTest({ projectName: "react-convex", frontend: ["tanstack-router"], backend: "convex", runtime: "none", database: "none", orm: "none", auth: "clerk", api: "none", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Frontend Compatibility with Auth", () => { const incompatibleFrontends = ["nuxt", "svelte", "solid", "astro"] as const; for (const frontend of incompatibleFrontends) { it(`should fail incompatible ${frontend} with Clerk`, async () => { const result = await runTRPCTest({ projectName: `${frontend}-clerk-fail`, frontend: [frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "clerk", api: "orpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Clerk authentication is not compatible"); }); } const compatibleFrontends = [ "tanstack-router", "react-router", "tanstack-start", "next", ] as const; for (const frontend of compatibleFrontends) { it(`should work with compatible ${frontend} + Clerk`, async () => { const result = await runTRPCTest({ projectName: `${frontend}-clerk`, frontend: [frontend], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "clerk", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } }); describe("Multiple Frontend Constraints", () => { it("should fail with multiple web frontends", async () => { const result = await runTRPCTest({ projectName: "multiple-web-fail", frontend: ["tanstack-router", "react-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot select multiple web frameworks"); }); it("should fail with multiple native frontends", async () => { const result = await runTRPCTest({ projectName: "multiple-native-fail", frontend: ["native-bare", "native-unistyles"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot select multiple native frameworks"); }); it("should work with one web + one native frontend", async () => { const result = await runTRPCTest({ projectName: "web-native-combo", frontend: ["tanstack-router", "native-bare"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Frontend with None Option", () => { it("should work with frontend none", async () => { const result = await runTRPCTest({ projectName: "no-frontend", frontend: ["none"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with none + other frontends", async () => { const result = await runTRPCTest({ projectName: "none-with-other-fail", frontend: ["none", "tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Cannot combine 'none' with other frontend options"); }); }); describe("Next.js with Self Backend", () => { it("should work with Next.js and self backend", async () => { const result = await runTRPCTest({ projectName: "nextjs-self-backend", frontend: ["next"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with Next.js and traditional backend", async () => { const result = await runTRPCTest({ projectName: "nextjs-traditional-backend", frontend: ["next"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Nuxt with Self Backend", () => { it("should work with Nuxt and self backend", async () => { const result = await runTRPCTest({ projectName: "nuxt-self-backend", frontend: ["nuxt"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "orpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with Nuxt and traditional backend", async () => { const result = await runTRPCTest({ projectName: "nuxt-traditional-backend", frontend: ["nuxt"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Astro with Self Backend", () => { it("should work with Astro and self backend", async () => { const result = await runTRPCTest({ projectName: "astro-self-backend", frontend: ["astro"], backend: "self", runtime: "none", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "orpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should work with Astro and traditional backend", async () => { const result = await runTRPCTest({ projectName: "astro-traditional-backend", frontend: ["astro"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Web Deploy Constraints", () => { it("should work with web frontend + web deploy", async () => { const result = await runTRPCTest({ projectName: "web-deploy", frontend: ["tanstack-router"], webDeploy: "cloudflare", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should fail with web deploy but no web frontend", async () => { const result = await runTRPCTest({ projectName: "web-deploy-no-frontend-fail", frontend: ["native-bare"], webDeploy: "cloudflare", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", addons: ["none"], examples: ["none"], dbSetup: "none", serverDeploy: "none", expectError: true, }); expectError(result, "'--web-deploy' requires a web frontend"); }); }); }); ================================================ FILE: apps/cli/test/index.test.ts ================================================ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { expectSuccess, runTRPCTest } from "./test-utils"; describe("CLI Test Suite", () => { beforeAll(async () => { // Ensure CLI is built before running tests console.log("Setting up CLI tests..."); }); afterAll(async () => { console.log("CLI tests completed."); }); describe("Smoke Tests", () => { it("should create a basic project successfully", async () => { const result = await runTRPCTest({ projectName: "smoke-test-basic", yes: true, install: false, }); expectSuccess(result); }); it("should handle help command", async () => { // This test would need to be implemented differently since it's not a project creation // For now, we'll just test that the basic functionality works expect(true).toBe(true); }); it("should validate project name requirements", async () => { const result = await runTRPCTest({ projectName: "valid-project-name", yes: true, install: false, }); expectSuccess(result); }); }); describe("Performance Tests", () => { it("should complete project creation within reasonable time", async () => { const startTime = Date.now(); const result = await runTRPCTest({ projectName: "performance-test", yes: true, install: false, }); const endTime = Date.now(); const duration = endTime - startTime; expectSuccess(result); // Should complete within 30 seconds (without installation) expect(duration).toBeLessThan(30000); }); }); describe("Stability Tests", () => { it("should handle multiple rapid project creations", async () => { const promises = []; for (let i = 0; i < 3; i++) { promises.push( runTRPCTest({ projectName: `stability-test-${i}`, yes: true, install: false, }), ); } const results = await Promise.all(promises); for (const result of results) { expectSuccess(result); } }, 60000); }); }); ================================================ FILE: apps/cli/test/input-schemas.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { AddInputSchema, BetterTStackConfigFileSchema, CLIInputSchema, CreateInputSchema, } from "../../../packages/types/src/schemas"; describe("Input schemas", () => { it("rejects conflicting manualDb and dbSetupOptions.mode inputs", () => { const result = CreateInputSchema.safeParse({ projectName: "app", manualDb: true, dbSetupOptions: { mode: "manual" }, }); expect(result.success).toBe(false); }); it("rejects conflicting nx and turborepo addon combinations", () => { const result = AddInputSchema.safeParse({ addons: ["nx", "turborepo"], }); expect(result.success).toBe(false); }); it("rejects unknown keys in JSON-first create input", () => { const result = CreateInputSchema.safeParse({ projectName: "app", pakageManager: "bun", }); expect(result.success).toBe(false); }); it("rejects unknown keys in bts.jsonc config payloads", () => { const result = BetterTStackConfigFileSchema.safeParse({ version: "0.0.0", createdAt: new Date(0).toISOString(), projectName: "app", database: "sqlite", orm: "drizzle", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], auth: "none", payments: "none", packageManager: "bun", dbSetup: "none", api: "trpc", webDeploy: "none", serverDeploy: "none", unexpected: true, }); expect(result.success).toBe(false); }); it("rejects unknown nested addon option keys", () => { const result = CreateInputSchema.safeParse({ projectName: "app", addonOptions: { skills: { agent: ["cursor"], }, }, }); expect(result.success).toBe(false); }); it("rejects unknown nested db setup option keys", () => { const result = CreateInputSchema.safeParse({ projectName: "app", dbSetupOptions: { neon: { region: "aws-us-east-1", }, }, }); expect(result.success).toBe(false); }); it("accepts the evlog agent skills source in addon options", () => { const result = CreateInputSchema.safeParse({ projectName: "app", addonOptions: { skills: { selections: [ { source: "https://www.evlog.dev", skills: ["review-logging-patterns", "analyze-logs"], }, ], }, }, }); expect(result.success).toBe(true); }); it("allows CLI input parsing on top of the refined create schema", () => { const result = CLIInputSchema.safeParse({ projectDirectory: ".", projectName: "app", addons: ["biome"], }); expect(result.success).toBe(true); }); it("imports the MCP module without schema-construction crashes", async () => { const module = await import("../src/mcp"); expect(typeof module.createBtsMcpServer).toBe("function"); }); }); ================================================ FILE: apps/cli/test/integration.test.ts ================================================ import { describe, it } from "bun:test"; import type { Backend, Runtime } from "../src/types"; import { expectError, expectSuccess, runTRPCTest, type TestConfig } from "./test-utils"; describe("Integration Tests - Real World Scenarios", () => { describe("Complete Stack Configurations", () => { it("should create full-stack React app with tRPC", async () => { const result = await runTRPCTest({ projectName: "fullstack-react-trpc", backend: "hono", runtime: "workers", database: "postgres", orm: "drizzle", auth: "better-auth", api: "trpc", frontend: ["tanstack-router"], addons: ["biome", "turborepo"], examples: ["todo", "ai"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); it("should create Nuxt app with oRPC", async () => { const result = await runTRPCTest({ projectName: "nuxt-orpc-app", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "orpc", frontend: ["nuxt"], addons: ["biome", "husky"], examples: ["ai"], // AI works with Nuxt dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); it("should create Next.js fullstack app with self backend", async () => { const result = await runTRPCTest({ projectName: "nextjs-fullstack-app", backend: "self", runtime: "none", database: "postgres", orm: "drizzle", auth: "better-auth", api: "trpc", frontend: ["next"], addons: ["biome", "turborepo"], examples: ["todo", "ai"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", // No server deployment for self backend install: false, }); expectSuccess(result); }); it("should create Svelte app with oRPC", async () => { const result = await runTRPCTest({ projectName: "svelte-orpc-app", backend: "hono", runtime: "bun", database: "mysql", orm: "prisma", auth: "better-auth", api: "orpc", frontend: ["svelte"], addons: ["turborepo", "oxlint"], examples: ["todo"], // Todo works with Svelte dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create Convex app with Clerk auth", async () => { const result = await runTRPCTest({ projectName: "convex-clerk-app", backend: "convex", runtime: "none", database: "none", orm: "none", auth: "clerk", api: "none", frontend: ["tanstack-router"], addons: ["biome", "turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create Hono app with Clerk auth", async () => { const result = await runTRPCTest({ projectName: "hono-clerk-app", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "clerk", api: "trpc", frontend: ["react-router"], addons: ["biome", "turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create Convex app with AI example + React frontend", async () => { const result = await runTRPCTest({ projectName: "convex-ai-react-app", backend: "convex", runtime: "none", database: "none", orm: "none", auth: "better-auth", api: "none", frontend: ["tanstack-router"], addons: ["biome"], examples: ["ai"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create Convex app with AI example + Next.js", async () => { const result = await runTRPCTest({ projectName: "convex-ai-next-app", backend: "convex", runtime: "none", database: "none", orm: "none", auth: "better-auth", api: "none", frontend: ["next"], addons: ["biome"], examples: ["ai"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create mobile app with React Native", async () => { const result = await runTRPCTest({ projectName: "mobile-app", backend: "hono", runtime: "bun", database: "postgres", orm: "drizzle", auth: "better-auth", api: "trpc", frontend: ["native-bare"], addons: ["biome", "turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create hybrid web + mobile app", async () => { const result = await runTRPCTest({ projectName: "hybrid-web-mobile", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "trpc", frontend: ["tanstack-router", "native-unistyles"], addons: ["biome", "turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create Cloudflare Workers app", async () => { const result = await runTRPCTest({ projectName: "cloudflare-workers-app", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["biome"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); it("should create MongoDB + Mongoose app", async () => { const result = await runTRPCTest({ projectName: "mongodb-mongoose-app", backend: "hono", runtime: "node", database: "mongodb", orm: "mongoose", auth: "better-auth", api: "trpc", frontend: ["react-router"], addons: ["husky", "turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create Next.js fullstack app", async () => { const result = await runTRPCTest({ projectName: "nextjs-fullstack", backend: "self", runtime: "none", database: "postgres", orm: "prisma", auth: "better-auth", api: "trpc", frontend: ["next"], addons: ["biome", "turborepo", "pwa"], examples: ["ai"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create Solid.js app with oRPC", async () => { const result = await runTRPCTest({ projectName: "solid-orpc-app", backend: "hono", runtime: "workers", database: "sqlite", orm: "drizzle", auth: "better-auth", api: "orpc", frontend: ["solid"], addons: ["biome", "pwa"], examples: ["todo"], // AI not compatible with Solid dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); }); describe("Frontend-only Configurations", () => { it("should create frontend-only React app", async () => { const result = await runTRPCTest({ projectName: "frontend-only-react", backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["tanstack-router"], addons: ["biome", "pwa"], examples: ["none"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); }); it("should create frontend-only Nuxt app", async () => { const result = await runTRPCTest({ projectName: "frontend-only-nuxt", backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["nuxt"], addons: ["biome", "husky"], examples: ["none"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "none", install: false, }); expectSuccess(result); }); }); describe("Complex Error Scenarios", () => { it("should fail with incompatible stack combination", async () => { // MongoDB + Drizzle is not supported const result = await runTRPCTest({ projectName: "incompatible-stack-fail", backend: "hono", runtime: "bun", database: "mongodb", orm: "drizzle", // Not compatible with MongoDB auth: "better-auth", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Drizzle ORM does not support MongoDB"); }); it("should fail with workers + incompatible database", async () => { const result = await runTRPCTest({ projectName: "workers-mongodb-fail", backend: "hono", runtime: "workers", database: "mongodb", // Not compatible with Workers orm: "mongoose", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "cloudflare", expectError: true, }); expectError( result, "Cloudflare Workers runtime (--runtime workers) is not compatible with MongoDB database", ); }); it("should fail with tRPC + incompatible frontend", async () => { const result = await runTRPCTest({ projectName: "trpc-nuxt-fail", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["nuxt"], // tRPC not compatible with Nuxt addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "tRPC API is not supported with 'nuxt' frontend"); }); it("should fail with Clerk + incompatible frontend", async () => { const result = await runTRPCTest({ projectName: "clerk-svelte-fail", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "clerk", api: "orpc", frontend: ["svelte"], // Clerk is not compatible with Svelte addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Clerk authentication is not compatible"); }); it("should fail with addon incompatibility", async () => { const result = await runTRPCTest({ projectName: "pwa-native-fail", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["native-bare"], addons: ["pwa"], // PWA not compatible with native-only examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "pwa addon requires one of these frontends"); }); it("should fail with example incompatibility", async () => { const result = await runTRPCTest({ projectName: "ai-solid-fail", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "orpc", frontend: ["solid"], addons: ["none"], examples: ["ai"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "The 'ai' example is not compatible with the Solid frontend"); }); it("should fail with Convex AI example + incompatible frontend", async () => { const result = await runTRPCTest({ projectName: "convex-ai-svelte-fail", backend: "convex", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["svelte"], addons: ["none"], examples: ["ai"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError( result, "The 'ai' example with Convex backend only supports React-based frontends (Next.js, TanStack Router, TanStack Start, React Router). Svelte and Nuxt are not supported with Convex AI.", ); }); it("should fail with payments incompatibility", async () => { const result = await runTRPCTest({ projectName: "polar-no-auth-fail", backend: "hono", runtime: "bun", database: "none", orm: "none", auth: "none", payments: "polar", api: "trpc", frontend: ["tanstack-router"], addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", expectError: true, }); expectError(result, "Polar payments requires Better Auth"); }); it("should fail with deployment constraint violation", async () => { const result = await runTRPCTest({ projectName: "web-deploy-no-frontend-fail", backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["native-bare"], // Only native, no web addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "cloudflare", // Requires web frontend serverDeploy: "none", expectError: true, }); expectError(result, "'--web-deploy' requires a web frontend"); }); }); describe("Edge Case Combinations", () => { it("should handle maximum complexity configuration", async () => { const result = await runTRPCTest({ projectName: "max-complexity", backend: "hono", runtime: "workers", database: "postgres", orm: "drizzle", auth: "better-auth", api: "trpc", frontend: ["tanstack-router", "native-bare"], addons: ["biome", "husky", "turborepo"], examples: ["todo", "ai"], dbSetup: "none", webDeploy: "cloudflare", serverDeploy: "cloudflare", install: false, }); expectSuccess(result); }); it("should handle minimal configuration", async () => { const result = await runTRPCTest({ projectName: "minimal-config", backend: "none", runtime: "none", database: "none", orm: "none", auth: "none", api: "none", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); const packageManagers = ["npm", "pnpm", "bun"]; for (const packageManager of packageManagers) { it(`should handle ${packageManager} package manager`, async () => { const result = await runTRPCTest({ projectName: `pkg-manager-${packageManager}`, backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "none", api: "trpc", frontend: ["tanstack-router"], addons: ["none"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, }); expectSuccess(result); }); } const runtimeConfigs = [ { runtime: "bun", backend: "hono" }, { runtime: "node", backend: "express" }, { runtime: "workers", backend: "hono" }, { runtime: "none", backend: "convex" }, ]; for (const { runtime, backend } of runtimeConfigs) { it(`should handle ${runtime} runtime with ${backend} backend`, async () => { const config: TestConfig = { projectName: `runtime-${runtime}-${backend}`, runtime: runtime as Runtime, backend: backend as Backend, frontend: ["tanstack-router"], install: false, }; // Set appropriate defaults if (backend === "convex") { config.database = "none"; config.orm = "none"; config.auth = "clerk"; config.api = "none"; config.addons = ["none"]; config.examples = ["none"]; config.dbSetup = "none"; config.webDeploy = "none"; config.serverDeploy = "none"; } else { config.database = "sqlite"; config.orm = "drizzle"; config.auth = "none"; config.api = "trpc"; config.addons = ["none"]; config.examples = ["none"]; config.dbSetup = "none"; config.webDeploy = "none"; config.serverDeploy = "none"; } // Handle workers runtime requirements if (runtime === "workers") { config.serverDeploy = "cloudflare"; } const result = await runTRPCTest(config); expectSuccess(result); }); } }); }); ================================================ FILE: apps/cli/test/mcp.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import path from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import fs from "fs-extra"; import { create } from "../src/index"; import { createBtsMcpServer } from "../src/mcp"; import { readBtsConfig } from "../src/utils/bts-config"; import { SMOKE_DIR } from "./setup"; async function connectInMemoryClient() { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = createBtsMcpServer(); await server.connect(serverTransport); const client = new Client({ name: "mcp-test-client", version: "0.0.0" }, { capabilities: {} }); await client.connect(clientTransport); return { client, cleanup: async () => { await client.close(); await server.close(); }, }; } function getExplicitCreateInput(projectPath: string) { return { projectName: projectPath, frontend: ["next"] as const, backend: "hono" as const, runtime: "bun" as const, database: "sqlite" as const, orm: "drizzle" as const, api: "trpc" as const, auth: "better-auth" as const, payments: "none" as const, addons: ["turborepo"] as const, examples: [] as const, git: true, packageManager: "bun" as const, install: false, dbSetup: "none" as const, webDeploy: "none" as const, serverDeploy: "none" as const, }; } describe("MCP server", () => { let cleanups: Array<() => Promise> = []; beforeEach(async () => { process.env.BTS_SKIP_EXTERNAL_COMMANDS = "1"; process.env.BTS_TEST_MODE = "1"; }); afterEach(async () => { for (const cleanup of cleanups.reverse()) { await Promise.race([ cleanup(), new Promise((resolve) => { setTimeout(resolve, 1000); }), ]); } cleanups = []; }); it("registers the expected MCP tools", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const result = await client.listTools(); const toolNames = result.tools.map((tool) => tool.name).sort(); expect(toolNames).toEqual([ "bts_add_addons", "bts_create_project", "bts_get_schema", "bts_get_stack_guidance", "bts_plan_addons", "bts_plan_project", ]); }); it("returns explicit create-contract guidance", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const result = await client.callTool({ name: "bts_get_stack_guidance", arguments: {}, }); const payload = result.structuredContent as { ok: boolean; data?: { createContract?: { requiresExplicitFields?: string[]; rule?: string; }; }; }; expect(payload.ok).toBe(true); expect(payload.data?.createContract?.requiresExplicitFields).toEqual( expect.arrayContaining([ "projectName", "frontend", "backend", "runtime", "database", "orm", "api", "auth", "payments", "addons", "examples", "git", "packageManager", "install", "dbSetup", "webDeploy", "serverDeploy", ]), ); expect(payload.data?.createContract?.rule).toContain("full explicit stack config"); }); it("returns CLI and input schemas through MCP", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const result = await client.callTool({ name: "bts_get_schema", arguments: { name: "createInput" }, }); const payload = result.structuredContent as { ok: boolean; data?: { type?: string; properties?: Record }; }; expect(payload.ok).toBe(true); expect(payload.data?.type).toBe("object"); expect(payload.data?.properties).toHaveProperty("frontend"); expect(payload.data?.properties).toHaveProperty("backend"); }); it("rejects partial project payloads before planning", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const result = await client.callTool({ name: "bts_plan_project", arguments: { projectName: "partial-app", frontend: ["next"], git: true, install: true, }, }); expect(result.isError).toBe(true); const text = result.content .filter((item): item is { type: "text"; text: string } => item.type === "text") .map((item) => item.text) .join("\n"); expect(text).toContain("Input validation error"); expect(text).toContain("database"); expect(text).toContain("backend"); expect(text).toContain("packageManager"); }); it("plans projects without writing files", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const projectPath = path.join(SMOKE_DIR, "mcp-plan-project"); await fs.remove(projectPath); const result = await client.callTool({ name: "bts_plan_project", arguments: getExplicitCreateInput(projectPath), }); const payload = result.structuredContent as { ok: boolean; data?: { projectDirectory?: string; success?: boolean }; }; expect(payload.ok).toBe(true); expect(payload.data?.success).toBe(true); expect(payload.data?.projectDirectory).toBe(projectPath); expect(await fs.pathExists(projectPath)).toBe(false); }); it("warns during planning when install=true would be slow for MCP execution", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const projectPath = path.join(SMOKE_DIR, "mcp-plan-install-warning"); await fs.remove(projectPath); const result = await client.callTool({ name: "bts_plan_project", arguments: { ...getExplicitCreateInput(projectPath), install: true, }, }); const payload = result.structuredContent as { ok: boolean; data?: { warnings?: string[]; recommendedMcpExecution?: { install?: boolean }; }; }; expect(payload.ok).toBe(true); expect(payload.data?.warnings?.[0]).toContain("install: false"); expect(payload.data?.recommendedMcpExecution?.install).toBe(false); }); it("creates projects on disk from explicit MCP input", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const projectPath = path.join(SMOKE_DIR, "mcp-create-project"); await fs.remove(projectPath); const result = await client.callTool({ name: "bts_create_project", arguments: getExplicitCreateInput(projectPath), }); const payload = result.structuredContent as { ok: boolean; data?: { success?: boolean; projectDirectory?: string }; }; expect(payload.ok).toBe(true); expect(payload.data?.success).toBe(true); expect(payload.data?.projectDirectory).toBe(projectPath); expect(await fs.pathExists(projectPath)).toBe(true); const btsConfig = await readBtsConfig(projectPath); expect(btsConfig?.frontend).toEqual(["next"]); }); it("rejects install=true during MCP project creation with an actionable error", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const projectPath = path.join(SMOKE_DIR, "mcp-create-install-rejected"); await fs.remove(projectPath); const result = await client.callTool({ name: "bts_create_project", arguments: { ...getExplicitCreateInput(projectPath), install: true, }, }); const payload = result.structuredContent as { ok: boolean; error?: string }; expect(result.isError).toBe(true); expect(payload.ok).toBe(false); expect(payload.error).toContain("install: false"); expect(await fs.pathExists(projectPath)).toBe(false); }); it("returns an MCP tool error when creating into a non-empty directory", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const projectPath = path.join(SMOKE_DIR, "mcp-existing-directory"); await fs.ensureDir(projectPath); await fs.writeFile(path.join(projectPath, "existing.txt"), "hello"); const result = await client.callTool({ name: "bts_create_project", arguments: getExplicitCreateInput(projectPath), }); const payload = result.structuredContent as { ok: boolean; error?: string }; expect(result.isError).toBe(true); expect(payload.ok).toBe(false); expect(payload.error).toContain("already exists and is not empty"); }); it("plans addon installation without mutating bts.jsonc", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const projectPath = path.join(SMOKE_DIR, "mcp-plan-addons"); await fs.remove(projectPath); const createResult = await create(projectPath, { frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", auth: "none", payments: "none", addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, git: true, packageManager: "bun", disableAnalytics: true, }); expect(createResult.isOk()).toBe(true); const before = await readBtsConfig(projectPath); const result = await client.callTool({ name: "bts_plan_addons", arguments: { projectDir: projectPath, addons: ["biome"], packageManager: "bun", install: false, }, }); const payload = result.structuredContent as { ok: boolean; data?: { success?: boolean; dryRun?: boolean; addedAddons?: string[] }; }; expect(payload.ok).toBe(true); expect(payload.data?.success).toBe(true); expect(payload.data?.dryRun).toBe(true); expect(payload.data?.addedAddons).toEqual(["biome"]); const after = await readBtsConfig(projectPath); expect(after).toEqual(before); }); it("adds addons through MCP and persists them to bts.jsonc", async () => { const { client, cleanup } = await connectInMemoryClient(); cleanups.push(cleanup); const projectPath = path.join(SMOKE_DIR, "mcp-add-addons"); await fs.remove(projectPath); const createResult = await create(projectPath, { frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", api: "trpc", auth: "none", payments: "none", addons: ["turborepo"], examples: ["none"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, git: true, packageManager: "bun", disableAnalytics: true, }); expect(createResult.isOk()).toBe(true); const result = await client.callTool({ name: "bts_add_addons", arguments: { projectDir: projectPath, addons: ["biome"], packageManager: "bun", install: false, }, }); const payload = result.structuredContent as { ok: boolean; data?: { success?: boolean; addedAddons?: string[] }; }; expect(payload.ok).toBe(true); expect(payload.data?.success).toBe(true); expect(payload.data?.addedAddons).toEqual(["biome"]); const after = await readBtsConfig(projectPath); expect(after?.addons).toEqual(expect.arrayContaining(["turborepo", "biome"])); }); it("starts over stdio through the CLI entrypoint", async () => { const cliRoot = path.join(import.meta.dir, ".."); const cliEntrypoint = path.join(cliRoot, "src", "cli.ts"); const client = new Client({ name: "mcp-stdio-test", version: "0.0.0" }, { capabilities: {} }); const transport = new StdioClientTransport({ command: "bun", args: [cliEntrypoint, "mcp"], cwd: cliRoot, env: { BTS_SKIP_EXTERNAL_COMMANDS: "1", BTS_TEST_MODE: "1", }, }); await client.connect(transport); cleanups.push(async () => { await transport.close(); await client.close(); }); const tools = await client.listTools(); expect(tools.tools.map((tool) => tool.name)).toEqual( expect.arrayContaining(["bts_get_stack_guidance", "bts_plan_project", "bts_add_addons"]), ); const guidance = await client.callTool({ name: "bts_get_stack_guidance", arguments: {}, }); const payload = guidance.structuredContent as { ok: boolean }; expect(payload.ok).toBe(true); }); }); ================================================ FILE: apps/cli/test/project-name-validation.test.ts ================================================ import { describe, expect, it } from "bun:test"; import path from "node:path"; import fs from "fs-extra"; import { create } from "../src/index"; import { extractAndValidateProjectName, validateProjectName, } from "../src/utils/project-name-validation"; const SMOKE_DIR_PATH = path.join(import.meta.dir, "..", ".smoke"); describe("Project name validation hardening", () => { it("rejects control characters", () => { const result = validateProjectName("bad\nname"); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain("control characters"); } }); it("rejects query-like characters in project input", () => { const result = extractAndValidateProjectName("my-app?fields=id,name"); expect(result.isErr()).toBe(true); if (result.isErr()) { expect(result.error.message).toContain("Invalid project name"); } }); it("allows percent characters in filesystem paths", () => { const result = extractAndValidateProjectName("my-app%23docs"); expect(result.isOk()).toBe(true); }); it("allows hash characters in filesystem paths", () => { const result = extractAndValidateProjectName("my-app#docs"); expect(result.isOk()).toBe(true); }); it("fails invalid names before creating the project directory", async () => { const projectPath = path.join(SMOKE_DIR_PATH, "invalid?name"); await fs.remove(projectPath); const result = await create(projectPath, { yes: true, install: false, disableAnalytics: true, directoryConflict: "overwrite", }); expect(result.isErr()).toBe(true); expect(await fs.pathExists(projectPath)).toBe(false); }); }); ================================================ FILE: apps/cli/test/readme.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { createVirtual } from "../src/index"; import { collectFiles } from "./setup"; async function generateReadme(config: Parameters[0]): Promise { const result = await createVirtual({ projectName: "readme-check", frontend: ["tanstack-router"], backend: "hono", runtime: "bun", database: "sqlite", orm: "drizzle", auth: "clerk", api: "trpc", addons: ["turborepo"], examples: ["todo"], dbSetup: "none", webDeploy: "none", serverDeploy: "none", install: false, git: false, packageManager: "bun", payments: "none", ...config, }); expect(result.isOk()).toBe(true); if (result.isErr()) { throw result.error; } const files = collectFiles(result.value.root, result.value.root.path); return files.get("README.md") ?? ""; } describe("README generation", () => { it("documents Clerk env setup for next + express", async () => { const readme = await generateReadme({ frontend: ["next"], backend: "express", }); expect(readme).toContain("`NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in `apps/web/.env`"); expect(readme).toContain("`CLERK_SECRET_KEY` in `apps/web/.env` for Clerk server middleware"); expect(readme).toContain("`CLERK_SECRET_KEY` in `apps/server/.env` for server-side Clerk auth"); expect(readme).toContain( "`CLERK_PUBLISHABLE_KEY` in `apps/server/.env` for Clerk backend middleware", ); }); it("documents Clerk request verification for self backends", async () => { const readme = await generateReadme({ frontend: ["next"], backend: "self", runtime: "none", }); expect(readme).toContain("`NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` in `apps/web/.env`"); expect(readme).toContain( "`CLERK_SECRET_KEY` in `apps/web/.env` for Clerk server middleware and server-side Clerk auth", ); expect(readme).toContain( "`CLERK_PUBLISHABLE_KEY` in `apps/web/.env` for server-side Clerk request verification", ); }); it("documents Clerk native env setup for standalone backends", async () => { const readme = await generateReadme({ frontend: ["native-uniwind"], backend: "hono", api: "trpc", }); expect(readme).toContain("`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` in `apps/native/.env`"); expect(readme).toContain("`CLERK_SECRET_KEY` in `apps/server/.env` for server-side Clerk auth"); expect(readme).toContain( "`CLERK_PUBLISHABLE_KEY` in `apps/server/.env` for server-side Clerk request verification", ); expect(readme).not.toContain("Open [http://localhost:3001]"); expect(readme).not.toContain("web/ # Frontend application"); }); }); ================================================ FILE: apps/cli/test/schema-command.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { initTRPC } from "@trpc/server"; import { router } from "../src/index"; const caller = initTRPC.create().createCallerFactory(router)({}); describe("Schema command", () => { it("returns full schema payload for 'all'", async () => { const result = await caller.schema({ name: "all" }); expect(result).toHaveProperty("cli"); expect(result).toHaveProperty("schemas"); expect(result.schemas).toHaveProperty("createInput"); expect(result.schemas).toHaveProperty("addInput"); expect(result.schemas).toHaveProperty("addonOptions"); expect(result.schemas).toHaveProperty("dbSetupOptions"); expect(Array.isArray(result.cli.commands)).toBe(true); }); it("returns a specific schema payload", async () => { const result = await caller.schema({ name: "createInput" }); expect(result).toHaveProperty("type", "object"); expect(result).toHaveProperty("properties"); }); it("includes agent-focused commands in CLI introspection", async () => { const result = await caller.schema({ name: "cli" }); const commandNames = result.commands.map((command) => command.name); expect(commandNames).toContain("create-json"); expect(commandNames).toContain("add-json"); expect(commandNames).toContain("schema"); }); }); ================================================ FILE: apps/cli/test/setup.ts ================================================ import { afterAll, beforeAll } from "bun:test"; import { mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; export const SMOKE_DIR = join(import.meta.dir, "..", ".smoke"); type VirtualFileNode = { type: "file"; path: string; content: string; }; type VirtualDirectoryNode = { type: "directory"; path: string; children: VirtualNode[]; }; export type VirtualNode = VirtualFileNode | VirtualDirectoryNode; export async function ensureSmokeDirectory() { await mkdir(SMOKE_DIR, { recursive: true }); } export async function cleanupSmokeDirectory() { await rm(SMOKE_DIR, { recursive: true, force: true }); } export function collectFiles( node: VirtualNode, rootPath: string, files = new Map(), ) { if (node.type === "file") { const relativePath = node.path.startsWith(`${rootPath}/`) ? node.path.slice(rootPath.length + 1) : node.path; files.set(relativePath, node.content); return files; } for (const child of node.children) { collectFiles(child, rootPath, files); } return files; } // Global setup - runs once before all tests beforeAll(async () => { try { process.env.BTS_SKIP_EXTERNAL_COMMANDS = "1"; process.env.BTS_TEST_MODE = "1"; await cleanupSmokeDirectory(); await ensureSmokeDirectory(); } catch (error) { console.error("Failed to setup smoke directory:", error); throw error; } }); // Global teardown - runs once after all tests afterAll(async () => { try { await cleanupSmokeDirectory(); } catch { // Ignore cleanup errors on teardown } }); ================================================ FILE: apps/cli/test/silent-create-output.test.ts ================================================ import { describe, expect, it } from "bun:test"; import path from "node:path"; import { execa } from "execa"; import fs from "fs-extra"; import { SMOKE_DIR } from "./setup"; const CLI_INDEX_PATH = path.join(import.meta.dir, "..", "src", "index.ts"); type SilentCreateCase = { name: string; projectName: string; options: Record; }; async function runSilentCreate(testCase: SilentCreateCase) { const projectPath = path.join(SMOKE_DIR, testCase.projectName); await fs.remove(projectPath); const script = ` import { create } from ${JSON.stringify(CLI_INDEX_PATH)}; const result = await create(${JSON.stringify(testCase.projectName)}, { ...${JSON.stringify(testCase.options)}, disableAnalytics: true, }); if (result.isErr()) { console.error(result.error.message); process.exit(1); } `; const result = await execa("bun", ["-e", script], { cwd: SMOKE_DIR, env: { BTS_TELEMETRY_DISABLED: "1", }, reject: false, }); return { ...result, projectPath, }; } describe("silent create output", () => { const cases: SilentCreateCase[] = [ { name: "stays quiet for oxlint addon setup", projectName: "silent-addon-oxlint", options: { frontend: ["next"], backend: "hono", runtime: "node", database: "none", orm: "none", api: "none", auth: "none", payments: "none", addons: ["nx", "oxlint"], examples: [], git: true, packageManager: "pnpm", install: false, dbSetup: "none", webDeploy: "none", serverDeploy: "none", }, }, { name: "stays quiet for manual neon setup", projectName: "silent-db-neon", options: { frontend: ["next"], backend: "hono", runtime: "node", database: "postgres", orm: "drizzle", api: "trpc", auth: "none", payments: "none", addons: [], examples: [], git: true, packageManager: "pnpm", install: false, dbSetup: "neon", dbSetupOptions: { mode: "manual" }, webDeploy: "none", serverDeploy: "none", }, }, { name: "stays quiet for manual prisma-postgres setup", projectName: "silent-db-prisma-postgres", options: { frontend: ["next"], backend: "hono", runtime: "node", database: "postgres", orm: "prisma", api: "trpc", auth: "none", payments: "none", addons: [], examples: [], git: true, packageManager: "pnpm", install: false, dbSetup: "prisma-postgres", dbSetupOptions: { mode: "manual" }, webDeploy: "none", serverDeploy: "none", }, }, { name: "stays quiet for manual turso setup", projectName: "silent-db-turso", options: { frontend: ["next"], backend: "hono", runtime: "node", database: "sqlite", orm: "drizzle", api: "trpc", auth: "none", payments: "none", addons: [], examples: [], git: true, packageManager: "pnpm", install: false, dbSetup: "turso", dbSetupOptions: { mode: "manual" }, webDeploy: "none", serverDeploy: "none", }, }, { name: "stays quiet for manual supabase setup", projectName: "silent-db-supabase", options: { frontend: ["next"], backend: "hono", runtime: "node", database: "postgres", orm: "drizzle", api: "trpc", auth: "none", payments: "none", addons: [], examples: [], git: true, packageManager: "pnpm", install: false, dbSetup: "supabase", dbSetupOptions: { mode: "manual" }, webDeploy: "none", serverDeploy: "none", }, }, { name: "stays quiet for manual mongodb atlas setup", projectName: "silent-db-mongodb-atlas", options: { frontend: ["next"], backend: "hono", runtime: "node", database: "mongodb", orm: "mongoose", api: "none", auth: "none", payments: "none", addons: [], examples: [], git: true, packageManager: "pnpm", install: false, dbSetup: "mongodb-atlas", dbSetupOptions: { mode: "manual" }, webDeploy: "none", serverDeploy: "none", }, }, ]; for (const testCase of cases) { it(testCase.name, async () => { const result = await runSilentCreate(testCase); expect(result.exitCode).toBe(0); expect(result.stdout).toBe(""); expect(result.stderr).toBe(""); expect(await fs.pathExists(result.projectPath)).toBe(true); }); } }); ================================================ FILE: apps/cli/test/sponsors.test.ts ================================================ import { describe, expect, it } from "bun:test"; import type { SponsorEntry } from "../src/utils/sponsors"; import { formatPostInstallSpecialSponsorsSection } from "../src/utils/sponsors"; function createSponsorsFixture(): SponsorEntry { return { generated_at: "2026-02-25T00:00:00.000Z", summary: { total_sponsors: 3, total_lifetime_amount: 300, total_current_monthly: 100, special_sponsors: 3, current_sponsors: 3, past_sponsors: 0, backers: 0, top_sponsor: { name: "Ada", amount: 100, }, }, specialSponsors: [ { githubId: "ada", githubUrl: "https://github.com/ada", avatarUrl: "https://example.com/ada.png", tierName: "Pro", sinceWhen: "2025-01", transactionCount: 8, name: "Ada", }, { githubId: "grace", githubUrl: "https://github.com/grace", avatarUrl: "https://example.com/grace.png", tierName: "Starter", sinceWhen: "2025-02", transactionCount: 5, name: "Grace", }, { githubId: "linus", githubUrl: "https://github.com/linus", avatarUrl: "https://example.com/linus.png", sinceWhen: "2025-03", transactionCount: 3, name: "Linus", }, ], sponsors: [], pastSponsors: [], backers: [], }; } describe("formatPostInstallSpecialSponsorsSection", () => { it("returns empty output when no special sponsors exist", () => { const fixture = createSponsorsFixture(); fixture.specialSponsors = []; const output = formatPostInstallSpecialSponsorsSection(fixture); expect(output).toBe(""); }); it("renders all special sponsors without tier details or sponsor link", () => { const fixture = createSponsorsFixture(); const output = formatPostInstallSpecialSponsorsSection(fixture); expect(output).toContain("Special sponsors"); expect(output).toContain("Ada"); expect(output).toContain("Grace"); expect(output).toContain("Linus"); expect(output).toContain("• Ada"); expect(output).not.toContain("Pro"); expect(output).not.toContain("Starter"); expect(output).not.toContain("Become a sponsor"); }); }); ================================================ FILE: apps/cli/test/tauri-setup.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { buildTauriInitArgs } from "../src/helpers/addons/tauri-setup"; describe("Tauri setup", () => { it("builds init args with frontend-specific dev urls and static output paths", () => { const cases = [ { frontend: ["tanstack-start"], expectedDist: "../dist/client", expectedUrl: "http://localhost:3001", expectedBuildCommand: "bun run build", }, { frontend: ["next"], expectedDist: "../out", expectedUrl: "http://localhost:3001", expectedBuildCommand: "bun run build", }, { frontend: ["nuxt"], expectedDist: "../.output/public", expectedUrl: "http://localhost:3001", expectedBuildCommand: "bun run generate", }, { frontend: ["astro"], expectedDist: "../dist", expectedUrl: "http://localhost:4321", expectedBuildCommand: "bun run build", }, { frontend: ["react-router"], expectedDist: "../build/client", expectedUrl: "http://localhost:5173", expectedBuildCommand: "bun run build", }, { frontend: ["solid"], expectedDist: "../dist", expectedUrl: "http://localhost:3001", expectedBuildCommand: "bun run build", }, ] as const; for (const testCase of cases) { const args = buildTauriInitArgs({ packageManager: "bun", frontend: [...testCase.frontend], projectDir: "/tmp/my app", }); expect(args).toContain("@tauri-apps/cli@latest"); expect(args).toContain("--app-name"); expect(args).toContain("my app"); expect(args).toContain("--frontend-dist"); expect(args).toContain(testCase.expectedDist); expect(args).toContain("--dev-url"); expect(args).toContain(testCase.expectedUrl); expect(args).toContain("--before-dev-command"); expect(args).toContain("bun run dev"); expect(args).toContain("--before-build-command"); expect(args).toContain(testCase.expectedBuildCommand); expect(args.some((arg) => arg.startsWith("--app-name="))).toBe(false); expect(args.some((arg) => arg.startsWith("--before-dev-command="))).toBe(false); expect(args.some((arg) => arg.startsWith("--before-build-command="))).toBe(false); } }); }); ================================================ FILE: apps/cli/test/test-utils.ts ================================================ import { expect } from "bun:test"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { create, UserCancelledError, CLIError, ProjectCreationError } from "../src/index"; import type { CreateInput, InitResult, Database, ORM, Backend, Runtime, Frontend, Addons, Examples, Auth, Payments, API, WebDeploy, ServerDeploy, DatabaseSetup, } from "../src/types"; import { AddonsSchema, APISchema, AuthSchema, BackendSchema, DatabaseSchema, DatabaseSetupSchema, ExamplesSchema, FrontendSchema, ORMSchema, PackageManagerSchema, PaymentsSchema, RuntimeSchema, ServerDeploySchema, WebDeploySchema, } from "../src/types"; // Smoke directory path - use the same as setup.ts const SMOKE_DIR_PATH = join(import.meta.dir, "..", ".smoke"); export interface TestResult { success: boolean; result?: InitResult; error?: string; projectDir?: string; config: TestConfig; } export interface TestConfig extends CreateInput { projectName?: string; expectError?: boolean; expectedErrorMessage?: string; } /** * Run test using the programmatic create() API instead of the router. * The create() API runs in silent mode and returns JSON instead of calling process.exit(). */ export async function runTRPCTest(config: TestConfig): Promise { // Ensure smoke directory exists (may be called before global setup in some cases) try { await mkdir(SMOKE_DIR_PATH, { recursive: true }); } catch { // Directory may already exist } const projectName = config.projectName || "default-app"; const projectPath = join(SMOKE_DIR_PATH, projectName); // Determine if we should use --yes or not // Only core stack flags conflict with --yes flag (from CLI error message) const coreStackFlags: (keyof TestConfig)[] = [ "database", "orm", "backend", "runtime", "frontend", "addons", "examples", "auth", "payments", "dbSetup", "api", "webDeploy", "serverDeploy", ]; const hasSpecificCoreConfig = coreStackFlags.some((flag) => config[flag] !== undefined); // Only use --yes if no core stack flags are provided and not explicitly disabled const willUseYesFlag = config.yes !== undefined ? config.yes : !hasSpecificCoreConfig; // Provide defaults for missing core stack options to avoid prompts // But don't provide core stack defaults when yes: true is explicitly set const coreStackDefaults = willUseYesFlag ? {} : { frontend: ["tanstack-router"] as Frontend[], backend: "hono" as Backend, runtime: "bun" as Runtime, api: "trpc" as API, database: "sqlite" as Database, orm: "drizzle" as ORM, auth: "none" as Auth, payments: "none" as Payments, addons: ["none"] as Addons[], examples: ["none"] as Examples[], dbSetup: "none" as DatabaseSetup, webDeploy: "none" as WebDeploy, serverDeploy: "none" as ServerDeploy, }; // Build options object - let the CLI handle all validation // Remove test-specific properties before passing to create() const { projectName: _, expectError: __, expectedErrorMessage: ___, ...restConfig } = config; const options: Partial = { install: config.install ?? false, git: config.git ?? true, packageManager: config.packageManager ?? "bun", directoryConflict: "overwrite", disableAnalytics: true, yes: willUseYesFlag, ...coreStackDefaults, ...restConfig, }; // Use the programmatic create() API which runs in silent mode // and returns a Result type instead of calling process.exit() const result = await create(projectPath, options); // Handle the Result type from better-result if (result.isOk()) { const initResult = result.value; return { success: true, result: initResult, error: undefined, projectDir: initResult.projectDirectory, config, }; } // Handle error case - extract error message based on error type const error = result.error; let errorMessage: string; if (UserCancelledError.is(error)) { errorMessage = error.message || "User cancelled"; } else if (CLIError.is(error)) { errorMessage = error.message; } else if (ProjectCreationError.is(error)) { errorMessage = error.message; } else { errorMessage = String(error); } return { success: false, result: undefined, error: errorMessage, projectDir: undefined, config, }; } export function expectSuccess(result: TestResult) { if (!result.success) { console.error("Test failed:"); console.error("Error:", result.error); if (result.result) { console.error("Result:", result.result); } } expect(result.success).toBe(true); expect(result.result).toBeDefined(); } export function expectError(result: TestResult, expectedMessage?: string) { expect(result.success).toBe(false); if (expectedMessage) { expect(result.error).toContain(expectedMessage); } } // Helper function to create properly typed test configs export function createTestConfig( config: Partial & { projectName: string }, ): TestConfig { return config as TestConfig; } /** * Extract enum values from a Zod enum schema */ function extractEnumValues(schema: { options: readonly T[] }): readonly T[] { return schema.options; } // Test data generators inferred from Zod schemas export const PACKAGE_MANAGERS = extractEnumValues(PackageManagerSchema); export const DATABASES = extractEnumValues(DatabaseSchema); export const ORMS = extractEnumValues(ORMSchema); export const BACKENDS = extractEnumValues(BackendSchema); export const RUNTIMES = extractEnumValues(RuntimeSchema); export const FRONTENDS = extractEnumValues(FrontendSchema); export const ADDONS = extractEnumValues(AddonsSchema); export const EXAMPLES = extractEnumValues(ExamplesSchema); export const AUTH_PROVIDERS = extractEnumValues(AuthSchema); export const PAYMENTS_PROVIDERS = extractEnumValues(PaymentsSchema); export const API_TYPES = extractEnumValues(APISchema); export const WEB_DEPLOYS = extractEnumValues(WebDeploySchema); export const SERVER_DEPLOYS = extractEnumValues(ServerDeploySchema); export const DB_SETUPS = extractEnumValues(DatabaseSetupSchema); // Convenience functions for common test patterns export function createBasicConfig(overrides: Partial = {}): TestConfig { return { projectName: "test-app", yes: true, // Use defaults install: false, git: true, ...overrides, }; } export function createCustomConfig(config: Partial): TestConfig { return { projectName: "test-app", install: false, git: true, ...config, }; } ================================================ FILE: apps/cli/test/tui-setup.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { join } from "node:path"; import fs from "fs-extra"; import { postProcessTuiWorkspace, resolveTuiTemplate } from "../src/helpers/addons/tui-setup"; import type { ProjectConfig } from "../src/types"; import { runWithContextAsync } from "../src/utils/context"; import { SMOKE_DIR } from "./setup"; function createTuiConfig(overrides: Partial = {}): ProjectConfig { return { projectName: "test-app", projectDir: SMOKE_DIR, relativePath: "test-app", database: "sqlite", orm: "drizzle", backend: "hono", runtime: "bun", frontend: ["tanstack-router"], addons: ["opentui"], examples: ["none"], auth: "none", payments: "none", git: true, packageManager: "bun", install: false, dbSetup: "none", api: "trpc", webDeploy: "none", serverDeploy: "none", ...overrides, }; } describe("OpenTUI setup", () => { it("defaults to the core template in silent mode", async () => { const template = await runWithContextAsync({ silent: true }, async () => resolveTuiTemplate(createTuiConfig()), ); expect(template).toBe("core"); }); it("uses persisted addon options before falling back to the silent default", async () => { const template = await runWithContextAsync({ silent: true }, async () => resolveTuiTemplate( createTuiConfig({ addonOptions: { opentui: { template: "react", }, }, }), ), ); expect(template).toBe("react"); }); it("injects check-types and removes nested lockfiles during post-processing", async () => { const tuiDir = join(SMOKE_DIR, "tui-post-process"); await fs.ensureDir(tuiDir); await fs.writeJson(join(tuiDir, "package.json"), { name: "tui", scripts: { dev: "opentui dev", }, }); await fs.writeFile(join(tuiDir, "bun.lock"), ""); await fs.writeFile(join(tuiDir, "pnpm-lock.yaml"), ""); const result = await postProcessTuiWorkspace(tuiDir); expect(result.isOk()).toBe(true); const packageJson = await fs.readJson(join(tuiDir, "package.json")); expect(packageJson.scripts["check-types"]).toBe("tsc --noEmit"); expect(await fs.pathExists(join(tuiDir, "bun.lock"))).toBe(false); expect(await fs.pathExists(join(tuiDir, "pnpm-lock.yaml"))).toBe(false); }); }); ================================================ FILE: apps/cli/test/ultracite-setup.test.ts ================================================ import { describe, expect, it } from "bun:test"; import { buildUltraciteInitArgs } from "../src/helpers/addons/ultracite-setup"; describe("Ultracite setup", () => { it("omits optional flags when editors, agents, and hooks are empty", () => { const args = buildUltraciteInitArgs({ packageManager: "bun", linter: "biome", frameworks: ["react"], editors: [], agents: [], hooks: [], gitHooks: [], }); expect(args).toContain("--frameworks"); expect(args).not.toContain("--editors"); expect(args).not.toContain("--agents"); expect(args).not.toContain("--hooks"); expect(args).toContain("--skip-install"); expect(args).toContain("--quiet"); }); it("passes integrations as separate values without implicit additions", () => { const args = buildUltraciteInitArgs({ packageManager: "bun", linter: "biome", frameworks: ["react"], editors: [], agents: [], hooks: [], gitHooks: ["husky", "lefthook"], }); const integrationsIndex = args.indexOf("--integrations"); expect(integrationsIndex).toBeGreaterThan(-1); expect(args.slice(integrationsIndex + 1, integrationsIndex + 3)).toEqual(["husky", "lefthook"]); // Matches upstream: lint-staged is only added when explicitly selected. expect(args).not.toContain("lint-staged"); expect(args).not.toContain("husky lefthook"); }); }); ================================================ FILE: apps/cli/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "esModuleInterop": true, "verbatimModuleSyntax": true, "strict": true, "skipLibCheck": true, "outDir": "dist", "types": ["node"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"] } ================================================ FILE: apps/cli/tsdown.config.ts ================================================ import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/index.ts", "src/cli.ts", "src/virtual.ts"], format: ["esm"], clean: true, shims: true, outDir: "dist", dts: true, outputOptions: { banner: "#!/usr/bin/env node", }, env: { BTS_TELEMETRY: process.env.BTS_TELEMETRY || "0", CONVEX_INGEST_URL: process.env.CONVEX_INGEST_URL || "", }, }); ================================================ FILE: apps/web/.eslintrc.json ================================================ { "extends": ["next/core-web-vitals", "next/typescript"] } ================================================ FILE: apps/web/.gitignore ================================================ # deps /node_modules # generated content .contentlayer .content-collections .source public/analytics-minimal.json # test & build /coverage /.next/ /out/ /build *.tsbuildinfo /.open-next/ /.wrangler/ .alchemy # misc .DS_Store *.pem /.pnp .pnp.js npm-debug.log* yarn-debug.log* yarn-error.log* # others .env*.local .vercel next-env.d.ts .dev.vars .dev.vars.prod ================================================ FILE: apps/web/.vercelignore ================================================ apps/web/scripts/ ================================================ FILE: apps/web/README.md ================================================ # Better-T-Stack Website This is the official documentation website for Better-T-Stack, built with Next.js and Fumadocs. ## Getting Started To run the development server: ```bash # Install dependencies npm install # or pnpm install # or bun install # Start development server npm run dev # or pnpm dev # or bun dev ``` Open [http://localhost:3333](http://localhost:3333) with your browser to see the site. ## Project Structure - `/src/app` - Next.js application routes - `/content/docs` - Documentation content in MDX format - `/public` - Static assets ## Contributing to Documentation To add or modify documentation: 1. Edit the appropriate MDX files in the `content/docs` directory 2. Run the development server to preview your changes 3. Submit a pull request with your updates ## Learn More To learn more about the technologies used in this website: - [Next.js Documentation](https://nextjs.org/docs) - Next.js features and API - [Fumadocs](https://fumadocs.vercel.app) - The documentation framework used - [Better-T-Stack](https://better-t-stack.dev) - Main project site ================================================ FILE: apps/web/cli.json ================================================ { "uiLibrary": "base-ui" } ================================================ FILE: apps/web/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "base-lyra", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "src/app/global.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "iconLibrary": "lucide", "menuColor": "default", "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "registries": { "@kibo-ui": "https://www.kibo-ui.com/r/{name}.json", "@magicui": "https://magicui.design/r/{name}.json", "@evilcharts": "https://evilcharts.com/r/{name}.json" } } ================================================ FILE: apps/web/content/docs/analytics.mdx ================================================ --- title: Analytics & Telemetry description: What we collect, how to disable it, and where to view aggregated insights --- ## What is collected On successful project creation, the CLI sends a single event (`project_created`) with: - Selected options (stack choices), including: `frontend`, `backend`, `runtime`, `database`, `orm`, `api`, `auth`, `payments`, `addons`, `examples`, `dbSetup`, `webDeploy`, `serverDeploy`, `packageManager`, `git`, `install` - Environment data: `cli_version`, `node_version`, `platform` Not collected: - Project name, path, or file contents (explicitly omitted) - Secrets or environment variables from your machine ## Disable telemetry Telemetry is enabled by default. To disable: ```bash BTS_TELEMETRY_DISABLED=1 npx create-better-t-stack@latest ``` The above command disables it for a single run. Add `export BTS_TELEMETRY_DISABLED=1` to your shell profile to make it permanent. ## Where to view analytics - Charts: [`/analytics`](/analytics) - Shared website analytics: [Umami dashboard](https://umami.amanv.cloud/share/pHvqHleyOl9PBfaK) (self-hosted on a Hostinger VPS) - Raw JSON snapshot: `https://r2.better-t-stack.dev/analytics-data.json` - CSV export: `https://r2.better-t-stack.dev/export.csv` Notes: - Aggregates are periodically regenerated from incoming events - Raw data is not publicly exposed; the `/analytics` page presents only summary statistics ## Full transparency Single event per scaffold; no IP or project identifiers. See source code below. If in doubt, set `BTS_TELEMETRY_DISABLED=1` and proceed. You can still use all CLI features. ## Source code - CLI event sender: [`apps/cli/src/utils/analytics.ts`](https://github.com/AmanVarshney01/create-better-t-stack/blob/main/apps/cli/src/utils/analytics.ts) - Telemetry toggle logic: [`apps/cli/src/utils/telemetry.ts`](https://github.com/AmanVarshney01/create-better-t-stack/blob/main/apps/cli/src/utils/telemetry.ts) - Ingest endpoint: [`packages/backend/convex/http.ts`](https://github.com/AmanVarshney01/create-better-t-stack/blob/main/packages/backend/convex/http.ts) - Analytics backend (ingest + aggregation): [`packages/backend/convex/analytics.ts`](https://github.com/AmanVarshney01/create-better-t-stack/blob/main/packages/backend/convex/analytics.ts) ================================================ FILE: apps/web/content/docs/bts-config.mdx ================================================ --- title: bts.jsonc description: What bts.jsonc does and why it matters --- ## What is it? `bts.jsonc` is a small config file written to your project root when you create a project. It captures the stack choices you selected (frontend, backend, API, DB/ORM, auth, addons, etc.). The file uses JSONC (JSON with comments) and includes a schema for editor hints. Where: `./bts.jsonc` ## Why it exists - Required for the `add` command to detect your current stack - Helps validate compatibility and pre‑fill sensible defaults If `bts.jsonc` is missing, the `add` command cannot run because the project cannot be detected. ## Safe to delete (with a caveat) It’s safe to delete for normal development; the generated code in `apps/*` and `packages/*` remains the source of truth. However, if you plan to use the `add` command later, you must keep `bts.jsonc` (or recreate it) so the CLI can detect your project. ## Format The file is JSONC with comments enabled and includes a `$schema` URL for tooling. ```jsonc // Better-T-Stack configuration file // safe to delete { "$schema": "https://r2.better-t-stack.dev/schema.json", "version": "x.y.z", "createdAt": "2025-01-01T00:00:00.000Z", "reproducibleCommand": "bun create better-t-stack@latest create-json --input '{...}'", "frontend": ["tanstack-router"], "backend": "hono", "runtime": "bun", "database": "sqlite", "orm": "drizzle", "api": "trpc", "auth": "better-auth", "addons": ["turborepo"], "addonOptions": { "wxt": { "template": "react", }, }, "examples": [], "dbSetup": "none", "dbSetupOptions": { "mode": "manual", }, "webDeploy": "none", "serverDeploy": "none", "packageManager": "bun", } ``` Notes: - Values mirror what you selected during project creation - `reproducibleCommand` contains the exact command to recreate this project - When structured options are present, the reproducible command uses `create-json` - `addonOptions` stores nested configuration for prompt-driven addons - `dbSetupOptions` stores structured database setup behavior for automation - The file may be updated when you run `add` (addons and addon-specific config) See also: [`add` command](/docs/cli#add) ================================================ FILE: apps/web/content/docs/cli/agent-workflows.mdx ================================================ --- title: Agent Workflows description: JSON-first and schema-driven workflows for agents, scripts, and automation --- ## Overview Better-T-Stack now supports a fully JSON-first workflow for agents and automation: - Raw JSON project creation with `create-json` - Raw JSON addon installation with `add-json` - Runtime schema introspection with `schema` - Local stdio MCP server with `mcp` - Structured addon configuration with `addonOptions` - Structured database setup configuration with `dbSetupOptions` - Safe planning with `--dry-run` If you are automating the CLI from an LLM, CI job, or another tool, start here instead of the interactive prompt flow. ## `create-json` Create a project from a single JSON payload instead of many flags. ```bash create-better-t-stack create-json --input '{ "projectName": "my-app", "frontend": ["tanstack-router"], "backend": "hono", "runtime": "bun", "database": "postgres", "orm": "drizzle", "api": "trpc", "auth": "none", "addons": ["wxt", "mcp"], "addonOptions": { "wxt": { "template": "react", "devPort": 5555 }, "mcp": { "scope": "project", "servers": ["context7"], "agents": ["cursor"] } }, "install": false }' ``` This is the best path when: - Your input already exists as structured data - You want to avoid shell flag expansion issues - You need nested addon or database setup options ## `add-json` Add addons to an existing project with a single JSON payload. ```bash create-better-t-stack add-json --input '{ "projectDir": "./my-app", "addons": ["skills", "ultracite"], "addonOptions": { "skills": { "scope": "project", "agents": ["cursor", "codex"], "selections": [ { "source": "vercel-labs/agent-skills", "skills": ["web-design-guidelines"] }, { "source": "https://www.evlog.dev", "skills": ["review-logging-patterns", "analyze-logs"] } ] }, "ultracite": { "linter": "biome", "editors": ["vscode", "cursor"], "agents": ["claude", "codex"] } }, "dryRun": true }' ``` ## Runtime Schema Introspection Use the CLI itself as the source of truth for current input shapes. ```bash create-better-t-stack schema --name all create-better-t-stack schema --name cli create-better-t-stack schema --name createInput create-better-t-stack schema --name addInput create-better-t-stack schema --name addonOptions create-better-t-stack schema --name dbSetupOptions ``` Useful patterns: - `schema --name cli`: inspect available commands - `schema --name createInput`: inspect the full create payload - `schema --name addonOptions`: inspect nested addon configuration - `schema --name dbSetupOptions`: inspect structured database setup behavior ## `mcp` Run Better-T-Stack itself as a local MCP server over stdio: ```bash npx create-better-t-stack@latest mcp ``` Install it into supported agent configs with `add-mcp`: ```bash npx -y add-mcp@latest "npx -y create-better-t-stack@latest mcp" ``` Exposed tools: - `bts_get_stack_guidance` - `bts_get_schema` - `bts_plan_project` - `bts_create_project` - `bts_plan_addons` - `bts_add_addons` This is the best path when an MCP-capable client should scaffold or modify Better-T-Stack projects without shelling out to a prompt-driven CLI flow. Recommended MCP workflow: 1. Call `bts_get_stack_guidance` if the user's request is ambiguous. 2. Call `bts_get_schema` for the exact input shape you need. 3. Build a full explicit stack config. 4. Call `bts_plan_project` before `bts_create_project`. 5. For MCP execution, use `install: false` when creating projects. 6. Call `bts_plan_addons` before `bts_add_addons`. For project creation, the MCP tools now expect a full explicit config, not a partial payload. That means the agent should provide all major stack choices such as: - `frontend` - `backend` - `runtime` - `database` - `orm` - `api` - `auth` - `payments` - `addons` - `examples` - `dbSetup` - `webDeploy` - `serverDeploy` - `git` - `packageManager` - `install` Important field rules: - `frontend` means app surfaces, not styling choices. - `addons` must be an explicit array. Use `[]` when none are requested. - `examples` must be an explicit array. Use `[]` when none are requested. - `dbSetup`, `webDeploy`, and `serverDeploy` should be explicit even when the answer is `none`. - `git`, `install`, and `packageManager` should always be set explicitly. - `install` should usually be `false` for `bts_create_project`, because dependency installation can exceed common MCP client timeouts. Run `npm install`, `pnpm install`, or `bun install` separately after scaffolding. - If a request is still ambiguous after reading the guidance, the agent should resolve that ambiguity before calling `bts_plan_project`. If you scaffold a project with the `mcp` addon, Better-T-Stack itself is now one of the recommended MCP servers. The addon installs it through `add-mcp` with a package runner command so the generated config does not rely on a globally installed CLI: ```bash npx -y add-mcp@latest "npx -y create-better-t-stack@latest mcp" ``` For Bun projects, the generated config uses the equivalent `bunx create-better-t-stack@latest mcp` server command inside `add-mcp`. That means MCP-capable agents inside the generated project can talk back to Better-T-Stack without any extra global install, alongside other recommended docs, framework, and database MCP servers. ## Structured Addon Options Prompt-driven addons can be configured up front through `addonOptions`. Supported structured addon surfaces include: - `wxt` - `fumadocs` - `opentui` - `mcp` - `skills` - `ultracite` When `skills` is used with the `evlog` addon, Better-T-Stack recommends Evlog's agent skills from `https://www.evlog.dev`: `review-logging-patterns` and `analyze-logs`. Example: ```json { "addons": ["wxt"], "addonOptions": { "wxt": { "template": "react", "devPort": 5555 } } } ``` Structured addon options are still persisted in `bts.jsonc`, but the printed reproducible command now stays on normal CLI flags for consistency. That means deeply nested addon or provider-specific setup options are not fully encoded in the one-line replay command. ## Structured Database Setup Options `dbSetupOptions` controls how database provisioning behaves in automation. Example: ```bash create-better-t-stack create-json --input '{ "projectName": "db-app", "database": "postgres", "orm": "drizzle", "backend": "hono", "runtime": "bun", "api": "trpc", "frontend": ["tanstack-router"], "dbSetup": "neon", "dbSetupOptions": { "mode": "manual" } }' ``` Current behavior: - Providers with automatic cloud provisioning default to `manual` in silent/agent flows: - `turso` - `neon` - `prisma-postgres` - `supabase` - `mongodb-atlas` - Providers that only write local/manual config today are not forced to `manual`: - `d1` - `docker` - `planetscale` This keeps agents from accidentally creating cloud resources unless automation explicitly opts into `auto`, while leaving local or manual-only setups unchanged. Provider-specific options are intentionally small today: - `neon.method`, `neon.projectName`, `neon.regionId` - `prismaPostgres.regionId` - `turso.databaseName`, `turso.groupName`, `turso.installCli` ## Dry Runs Use `--dry-run` to validate inputs and target directories without writing files. ```bash create-better-t-stack --yes --dry-run create-better-t-stack create-json --input '{"projectName":"my-app","yes":true,"dryRun":true}' create-better-t-stack add-json --input '{"projectDir":"./my-app","addons":["mcp"],"dryRun":true}' ``` This is especially useful for: - Planning actions before mutation - CI validation - Agent reasoning loops ## Programmatic API The exported `create()` and `add()` APIs run in silent mode by default, so they benefit from the same agent-safe behavior as the JSON commands. See [Programmatic API](/docs/cli/programmatic-api) for TypeScript examples. ## Stored in `bts.jsonc` Better-T-Stack persists structured configuration in `bts.jsonc`, including: - `reproducibleCommand` - `addonOptions` - `dbSetupOptions` See [`bts.jsonc`](/docs/bts-config) for the file format. ================================================ FILE: apps/web/content/docs/cli/compatibility.mdx ================================================ --- title: Compatibility Rules description: Understanding compatibility rules and restrictions between different CLI options --- ## Overview The CLI validates option combinations to ensure generated projects work correctly. Here are the key compatibility rules and restrictions. ## Database & ORM Compatibility ### Required Combinations | Database | Compatible ORMs | Notes | | ---------- | -------------------- | ----------------------------------------- | | `sqlite` | `drizzle`, `prisma` | Lightweight, file-based database | | `postgres` | `drizzle`, `prisma` | Advanced relational database | | `mysql` | `drizzle`, `prisma` | Traditional relational database | | `mongodb` | `mongoose`, `prisma` | Document database, requires specific ORMs | | `none` | `none` | No database setup | ### Restrictions - **MongoDB + Drizzle**: ❌ Not supported - Drizzle doesn't support MongoDB - **Database without ORM**: ❌ Not supported - Database requires an ORM for code generation - **ORM without Database**: ❌ Not supported - ORM requires a database target ```bash # ❌ Invalid - MongoDB with Drizzle create-better-t-stack --database mongodb --orm drizzle # ✅ Valid - MongoDB with Mongoose create-better-t-stack --database mongodb --orm mongoose ``` ## Backend & Runtime Compatibility ### Cloudflare Workers Restrictions Cloudflare Workers has specific compatibility requirements: | Component | Requirement | Reason | | -------------- | ---------------------------------------- | -------------------------------------------------------------- | | Backend | Must be `hono` | Only Hono supports Workers runtime | | ORM | If DB is used, use `drizzle` or `prisma` | Mongoose is MongoDB-only and MongoDB is not workers-compatible | | Database | Cannot be `mongodb` | MongoDB is not compatible with Workers runtime | | Database Setup | Cannot be `docker` | Workers is serverless, no Docker support | ```bash # ❌ Invalid - Workers with Express create-better-t-stack --runtime workers --backend express # ✅ Valid - Workers with Hono create-better-t-stack --runtime workers --backend hono --database sqlite --orm drizzle --db-setup d1 # ✅ Also valid - Workers with Prisma (D1) create-better-t-stack --runtime workers --backend hono --database sqlite --orm prisma --db-setup d1 ``` ### Backend Presets #### Convex Backend When using `--backend convex`, these constraints apply: - `--runtime none` - `--database none` (Convex provides database) - `--orm none` (Convex provides data layer) - `--api none` (Convex provides API) - `--db-setup none` (Convex manages hosting) - `--server-deploy none` - Auth can be `better-auth`, `clerk`, or `none` depending frontend compatibility **Note:** Convex supports Clerk authentication with compatible frontends (React frameworks, Next.js, TanStack Start, and native frameworks). Nuxt, Svelte, Solid, and Astro are not compatible with Clerk. **Better Auth with Convex:** supported frontends are `react-router`, `tanstack-router`, `tanstack-start`, `next`, `native-bare`, `native-uniwind`, and `native-unistyles`. Nuxt, Svelte, Solid, and Astro are not supported with Convex Better Auth. #### No Backend When using `--backend none`, the following options are automatically set: - `--auth none` (No backend for auth) - `--database none` (No backend for database) - `--orm none` (No database) - `--api none` (No backend for API) - `--runtime none` (No backend to run) - `--db-setup none` (No database to host) - `--examples none` (Examples require backend) ## Frontend & API Compatibility ### API Framework Support | Frontend | tRPC Support | oRPC Support | Notes | | ----------------- | ------------ | ------------ | ------------------ | | `tanstack-router` | ✅ | ✅ | Full support | | `react-router` | ✅ | ✅ | Full support | | `tanstack-start` | ✅ | ✅ | Full support | | `next` | ✅ | ✅ | Full support | | `nuxt` | ❌ | ✅ | tRPC not supported | | `svelte` | ❌ | ✅ | tRPC not supported | | `solid` | ❌ | ✅ | tRPC not supported | | `astro` | ❌ | ✅ | tRPC not supported | | Native frameworks | ✅ | ✅ | Full support | ```bash # ❌ Invalid - Nuxt with tRPC create-better-t-stack --frontend nuxt --api trpc # ✅ Valid - Nuxt with oRPC create-better-t-stack --frontend nuxt --api orpc ``` ### Frontend Restrictions - **Multiple Web Frontends**: ❌ Only one web framework allowed - **Multiple Native Frontends**: ❌ Only one native framework allowed - **Web + Native**: ✅ One web and one native framework allowed ```bash # ❌ Invalid - Multiple web frontends create-better-t-stack --frontend next tanstack-router # ✅ Valid - Web + native create-better-t-stack --frontend next native-uniwind ``` ## Database Setup Compatibility ### Provider Requirements | Setup Provider | Required Database | Notes | | ----------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | `turso` | `sqlite` | Distributed SQLite; works with Drizzle and Prisma | | `d1` | `sqlite` | Cloudflare D1; works with Drizzle and Prisma on Cloudflare Workers or supported self-hosted Cloudflare frontends | | `neon` | `postgres` | Serverless PostgreSQL | | `supabase` | `postgres` | PostgreSQL with additional features | | `prisma-postgres` | `postgres` | Managed PostgreSQL via Prisma | | `planetscale` | `mysql`, `postgres` | PlanetScale serverless database | | `mongodb-atlas` | `mongodb` | Managed MongoDB | | `docker` | `postgres`, `mysql`, `mongodb` | Not compatible with `sqlite` or Workers | ### Special Cases #### Cloudflare D1 - Requires `--database sqlite` - Requires one of these Cloudflare deployment targets: - `--backend hono --runtime workers --server-deploy cloudflare` - `--backend self --web-deploy cloudflare` - With `--backend self`, D1 is supported on `next`, `tanstack-start`, `nuxt`, `svelte`, and `astro` - With `--backend self`, frontend API compatibility still applies: `nuxt`, `svelte`, and `astro` require `--api orpc` or `--api none` #### Docker Setup - Cannot be used with `sqlite` (file-based database) - Cannot be used with `workers` runtime (serverless environment) ## Addon Compatibility ### PWA Support - Requires web frontend - Compatible frontends: `tanstack-router`, `react-router`, `next`, `solid` - Not compatible with native-only projects ### Tauri (Desktop Apps) - Requires web frontend - Compatible frontends: `tanstack-router`, `react-router`, `tanstack-start`, `next`, `nuxt`, `svelte`, `solid`, `astro` - Desktop builds package static web output, so `tanstack-start`, `next`, `nuxt`, `svelte`, and `astro` need static/export configuration before packaging - Cannot be combined with native frameworks ### Electrobun (Desktop Apps) - Requires web frontend - Compatible frontends: `tanstack-router`, `react-router`, `tanstack-start`, `next`, `nuxt`, `svelte`, `solid`, `astro` - Uses a generated `apps/desktop` shell that loads `apps/web` during development and bundles its static build output for distribution - Desktop builds package static web output, so `tanstack-start`, `next`, `nuxt`, `svelte`, and `astro` need static/export configuration before packaging ### Web Deployment - `--web-deploy cloudflare` requires a web frontend - Cannot be used with native-only projects ## Authentication Requirements ### Better-Auth Requirements Better-Auth authentication requires: - A backend framework (cannot be `none`) - With database: Requires an ORM - Without database: Works with Convex backend or custom configuration - With `--backend convex`: requires `react-router`, `tanstack-router`, `tanstack-start`, `next`, or a native Expo frontend ### Clerk Requirements Clerk authentication requires: - Compatible frontends (React frameworks, Next.js, TanStack Start, native frameworks) - Supported backends: Convex, Hono, Express, Fastify, and Elysia - Fullstack (`--backend self`) support with Next.js or TanStack Start - Not compatible with Nuxt, Svelte, Solid, or Astro ### Payments Requirements Polar payments require: - Better-Auth authentication - A web frontend (or no frontend selected) ```bash # ✅ Valid - Better-Auth without database create-better-t-stack --auth better-auth --database none # ✅ Valid - Better-Auth with full stack create-better-t-stack --auth better-auth --database postgres --orm drizzle --backend hono # ✅ Valid - Clerk with Hono create-better-t-stack --auth clerk --backend hono --frontend tanstack-router # ✅ Valid - Clerk with fullstack Next.js create-better-t-stack --auth clerk --backend self --frontend next # ❌ Invalid - Clerk with Astro create-better-t-stack --auth clerk --backend self --frontend astro ``` ## Example Compatibility ### Todo Example - Requires a database when backend is present (except Convex) - Requires an API layer (`trpc` or `orpc`) for non-Convex backends - Cannot be used with `--backend none` ### AI Example - Not compatible with `--frontend solid` - Not compatible with `--frontend astro` - With `--backend convex`, Nuxt and Svelte frontends are not supported ## Common Error Messages ### "Mongoose ORM requires MongoDB database" ```bash # Fix by using MongoDB create-better-t-stack --database mongodb --orm mongoose ``` ### "Cloudflare Workers runtime is only supported with Hono backend" ```bash # Fix by using Hono create-better-t-stack --runtime workers --backend hono ``` ### "Cannot select multiple web frameworks" ```bash # Fix by choosing one web framework create-better-t-stack --frontend tanstack-router ``` ### "Polar payments requires Better Auth" ```bash # Fix by using Better-Auth create-better-t-stack --payments polar --auth better-auth # Or use Clerk without Polar payments create-better-t-stack --auth clerk --backend hono --frontend tanstack-router ``` ## Validation Strategy The CLI validates compatibility in this order: 1. **Basic validation**: Required parameters, valid enum values 2. **Combination validation**: Database + ORM, Backend + Runtime compatibility 3. **Feature validation**: Auth requirements, addon compatibility 4. **Example validation**: Example + stack compatibility Understanding these rules helps you create valid configurations and troubleshoot issues when the CLI reports compatibility errors. ================================================ FILE: apps/web/content/docs/cli/index.mdx ================================================ --- title: Commands description: Complete reference for all CLI commands --- ## Overview The Better-T-Stack CLI provides several commands to manage your TypeScript projects. ## `create` (Default Command) Creates a new Better-T-Stack project. ```bash create-better-t-stack [project-directory] [options] ``` ### Parameters - `project-directory` (optional): Name or path for your project directory ### Key Options - `--yes, -y`: Use default configuration (skips prompts) - `--dry-run`: Validate configuration and target directory without writing files - `--verbose`: Show detailed result information as JSON - `--yolo`: Bypass validations and compatibility checks - `--package-manager `: `npm`, `pnpm`, `bun` - `--install / --no-install`: Install dependencies after creation - `--git / --no-git`: Initialize Git repository - `--frontend `: Web and/or native frameworks (see [Options](/docs/cli/options#frontend)) - `--backend `: `hono`, `express`, `fastify`, `elysia`, `convex`, `self`, `none` - `--runtime `: `bun`, `node`, `workers` (`none` only with `--backend convex`, `--backend none`, or `--backend self`) - `--database `: `none`, `sqlite`, `postgres`, `mysql`, `mongodb` - `--orm `: `none`, `drizzle`, `prisma`, `mongoose` - `--api `: `none`, `trpc`, `orpc` - `--auth `: `better-auth`, `clerk`, `none` (see [Options](/docs/cli/options#authentication)) - `--payments `: `polar`, `none` - `--db-setup `: `none`, `turso`, `d1`, `neon`, `supabase`, `prisma-postgres`, `planetscale`, `mongodb-atlas`, `docker` - `--examples `: `none`, `todo`, `ai` - `--web-deploy `: `none`, `cloudflare` - `--server-deploy `: `none`, `cloudflare` - `--template `: `none`, `mern`, `pern`, `t3`, `uniwind` - `--directory-conflict `: `merge`, `overwrite`, `increment`, `error` - `--render-title / --no-render-title`: Show/hide ASCII art title - `--disable-analytics / --no-disable-analytics`: Control analytics collection - `--manual-db`: Skip automatic database setup prompts See the full reference in [Options](/docs/cli/options). For JSON-first automation and agent usage, see [Agent Workflows](/docs/cli/agent-workflows). ### Examples ```bash # Default setup with prompts create-better-t-stack # Quick setup with defaults create-better-t-stack --yes # Specific configuration create-better-t-stack --database postgres --backend hono --frontend tanstack-router # Validate without writing files create-better-t-stack my-app --yes --dry-run ``` ## `add` Adds addons to an existing Better-T-Stack project. ```bash create-better-t-stack add [options] ``` ### Options - `--addons `: Addons to add (see [Addons](/docs/cli/options#addons)) - `--project-dir `: Project directory (defaults to current directory) - `--install / --no-install`: Install dependencies after adding - `--package-manager `: Package manager to use (`npm`, `pnpm`, `bun`) ### Examples ```bash # Add addons interactively create-better-t-stack add # Add specific addons create-better-t-stack add --addons pwa tauri --install # Add addons in a specific project directory create-better-t-stack add --project-dir ./my-app --addons mcp skills ``` ## `create-json` Create a project from a raw JSON payload. ```bash create-better-t-stack create-json --input '{"projectName":"my-app","yes":true,"dryRun":true}' ``` Use this when you want a single machine-readable input object instead of many flags. ## `add-json` Add addons from a raw JSON payload. ```bash create-better-t-stack add-json --input '{"projectDir":"./my-app","addons":["wxt"],"addonOptions":{"wxt":{"template":"react"}}}' ``` ## `schema` Print runtime CLI and input schemas as JSON. ```bash create-better-t-stack schema --name all create-better-t-stack schema --name createInput create-better-t-stack schema --name addonOptions create-better-t-stack schema --name dbSetupOptions ``` This is the best way for scripts and agents to discover the current input contract at runtime. ## `sponsors` Displays Better-T-Stack sponsors. ```bash create-better-t-stack sponsors ``` Shows a list of project sponsors and supporters. ## `docs` Opens the Better-T-Stack documentation in your default browser. ```bash create-better-t-stack docs ``` Opens `https://better-t-stack.dev/docs` in your browser. ## `builder` Opens the web-based stack builder in your default browser. ```bash create-better-t-stack builder ``` Opens `https://better-t-stack.dev/new` where you can configure your stack visually. ## `history` Shows project creation history stored on your local machine. ```bash create-better-t-stack history [options] ``` ### Options - `--limit `: Number of entries to show (default: 10) - `--json`: Output history as JSON - `--clear`: Clear all history entries ### Examples ```bash # Show latest entries create-better-t-stack history # Show 5 entries create-better-t-stack history --limit 5 # JSON output create-better-t-stack history --json # Clear history create-better-t-stack history --clear ``` ## Global Options These options work with any command: - `--help, -h`: Display help information - `--version, -V`: Display CLI version ## Command Examples ### Create a Full-Stack App ```bash create-better-t-stack \ --database postgres \ --orm drizzle \ --backend hono \ --frontend tanstack-router \ --auth better-auth \ --addons pwa biome ``` ### Create a Backend-Only Project ```bash create-better-t-stack api-server \ --frontend none \ --backend hono \ --database postgres \ --orm drizzle \ --api trpc ``` ### Add Features to Existing Project ```bash cd my-existing-project create-better-t-stack add --addons tauri starlight --install ``` ### Show Local History ```bash create-better-t-stack history --limit 20 ``` ## Programmatic Usage For advanced use cases, automation, or integration with other tools, you can use the [Programmatic API](/docs/cli/programmatic-api) to create projects from Node.js code: ```typescript import { create } from "create-better-t-stack"; const result = await create("my-app", { frontend: ["tanstack-router"], backend: "hono", database: "sqlite", orm: "drizzle", }); result.match({ ok: (data) => console.log(`Project created at: ${data.projectDirectory}`), err: (error) => console.error(`Failed: ${error.message}`), }); ``` This is useful for: - **Build tools and generators** - Create projects from templates - **CI/CD pipelines** - Generate test projects automatically - **Development workflows** - Batch create related projects - **Custom tooling** - Integrate with your existing development setup See the [Programmatic API documentation](/docs/cli/programmatic-api) for complete examples and API reference. ================================================ FILE: apps/web/content/docs/cli/meta.json ================================================ { "title": "CLI", "defaultOpen": true, "pages": ["index", "agent-workflows", "programmatic-api", "options", "prompts", "compatibility"] } ================================================ FILE: apps/web/content/docs/cli/options.mdx ================================================ --- title: Options Reference description: Complete reference for all CLI options and flags --- ## General Options ### `--yes, -y` Use default configuration and skip interactive prompts. ```bash create-better-t-stack --yes ``` ### `--template ` Use a predefined project template: - `none`: No template (default) - `mern`: MongoDB, Express, React, Node.js stack - `pern`: PostgreSQL, Express, React, Node.js stack - `t3`: T3 stack configuration - `uniwind`: UniWind React Native template ```bash create-better-t-stack --template t3 ``` ### `--manual-db` Skip automatic database setup prompts and use manual database configuration. ```bash create-better-t-stack --manual-db ``` ### `--dry-run` Validate configuration, compatibility, and directory handling without writing files. ```bash create-better-t-stack my-app --yes --dry-run ``` ### `--package-manager ` Choose package manager: `npm`, `pnpm`, or `bun`. ```bash create-better-t-stack --package-manager bun ``` ### `--install / --no-install` Control dependency installation after project creation. ```bash create-better-t-stack --no-install ``` ### `--git / --no-git` Control Git repository initialization. ```bash create-better-t-stack --no-git ``` ### `--yolo` Bypass validations and compatibility checks. Not recommended for normal use. ```bash create-better-t-stack --yolo ``` ### `--verbose` Show detailed result information in JSON format after project creation. ```bash create-better-t-stack --verbose ``` ### `--render-title / --no-render-title` Control whether the ASCII art title is shown. Enabled by default. ```bash # Hide the title (useful in CI) create-better-t-stack --no-render-title ``` ### `--directory-conflict ` How to handle existing, non-empty target directories: - `merge`: Keep existing files and merge new ones - `overwrite`: Clear the directory before scaffolding - `increment`: Create a suffixed directory (e.g., `my-app-1`) - `error`: Fail instead of prompting ```bash # Overwrite an existing directory without prompting create-better-t-stack my-app --yes --directory-conflict overwrite # Safely create a new directory name if it exists create-better-t-stack my-app --yes --directory-conflict increment ``` ### `--disable-analytics / --no-disable-analytics` Control whether analytics and telemetry data is collected. ```bash # Disable analytics collection create-better-t-stack --disable-analytics # Enable analytics collection (default) create-better-t-stack --no-disable-analytics ``` Analytics help improve Better-T-Stack by providing insights into usage patterns. When disabled, no data is collected or transmitted. For JSON-first automation, runtime schemas, and nested structured options, see [Agent Workflows](/docs/cli/agent-workflows). ## Database Options ### `--database ` Database type to use: - `none`: No database - `sqlite`: SQLite database - `postgres`: PostgreSQL database - `mysql`: MySQL database - `mongodb`: MongoDB database ```bash create-better-t-stack --database postgres ``` ### `--orm ` ORM to use with your database: - `none`: No ORM - `drizzle`: Drizzle ORM (TypeScript-first) - `prisma`: Prisma ORM (feature-rich) - `mongoose`: Mongoose ODM (for MongoDB) ```bash create-better-t-stack --database postgres --orm drizzle ``` ### `--db-setup ` Database hosting/setup provider: - `none`: Manual setup - `turso`: Turso (SQLite) - `d1`: Cloudflare D1 (SQLite; requires either Cloudflare Workers server deployment or `backend self` with Cloudflare web deployment) - `neon`: Neon (PostgreSQL) - `supabase`: Supabase (PostgreSQL) - `prisma-postgres`: Prisma Postgres - `planetscale`: PlanetScale (MySQL/PostgreSQL) - `mongodb-atlas`: MongoDB Atlas - `docker`: Local Docker containers ```bash create-better-t-stack --database postgres --db-setup neon ``` If you need structured control over database provisioning behavior, use `dbSetupOptions` with `create-json` or the programmatic API. See [Agent Workflows](/docs/cli/agent-workflows). ## Backend Options ### `--backend ` Backend framework to use: - `none`: No backend - `hono`: Hono (fast, lightweight) - `express`: Express.js (popular, mature) - `fastify`: Fastify (fast, plugin-based) - `elysia`: Elysia (Bun-native) - `convex`: Convex backend - `self`: Self-hosted/custom backend ```bash create-better-t-stack --backend hono ``` ### `--runtime ` Runtime environment: - `none`: No specific runtime (only with `convex`, `none`, or `self` backend) - `bun`: Bun runtime - `node`: Node.js runtime - `workers`: Cloudflare Workers ```bash create-better-t-stack --backend hono --runtime bun ``` ### `--api ` API layer type: - `none`: No API layer - `trpc`: tRPC (type-safe) - `orpc`: oRPC (OpenAPI-compatible) ```bash create-better-t-stack --api trpc ``` ## Frontend Options ### `--frontend ` Frontend frameworks (can specify multiple): **Web Frameworks:** - `tanstack-router`: React with TanStack Router - `react-router`: React with React Router - `tanstack-start`: React with TanStack Start (SSR) - `next`: Next.js - `nuxt`: Nuxt (Vue) - `svelte`: SvelteKit - `solid`: SolidJS - `astro`: Astro **Native Frameworks:** - `native-bare`: React Native (bare setup) - `native-uniwind`: React Native with UniWind (NativeWind alternative) - `native-unistyles`: React Native with Unistyles **No Frontend:** - `none`: Backend-only project ```bash # Single web frontend create-better-t-stack --frontend tanstack-router # Web + native frontend create-better-t-stack --frontend next native-uniwind # Backend-only create-better-t-stack --frontend none ``` ## Authentication ### `--auth ` Choose authentication provider: - `better-auth`: Better-Auth authentication (default) - `clerk`: Clerk authentication - `none`: No authentication ```bash create-better-t-stack --auth better-auth create-better-t-stack --auth clerk create-better-t-stack --auth none ``` **Note:** - `better-auth` requires a backend framework (cannot be `none`) - if you choose a database, you should also choose an ORM - with `--backend convex`, `better-auth` supports `react-router`, `tanstack-router`, `tanstack-start`, `next`, and native Expo frontends - `clerk` requires a compatible frontend - Supported Clerk backends: `convex`, `hono`, `express`, `fastify`, `elysia`, and `self` with Next.js or TanStack Start - Authentication is automatically set to `none` when using `--backend none` ## Payments ### `--payments ` Payments provider: - `none`: No payments integration - `polar`: Polar payments integration ```bash create-better-t-stack --payments polar --auth better-auth ``` **Note:** Polar payments requires Better-Auth authentication. ## Addons ### `--addons ` Additional features to include: - `none`: No addons - `pwa`: Progressive Web App support - `tauri`: Desktop app support - `electrobun`: Lightweight desktop shell for web frontends - `starlight`: Starlight documentation site - `fumadocs`: Fumadocs documentation site - `biome`: Biome linting and formatting - `lefthook`: Git hooks with Lefthook - `husky`: Git hooks with Husky - `turborepo`: Turborepo monorepo setup - `nx`: Nx monorepo setup - `ultracite`: Ultracite configuration - `oxlint`: Oxlint + Oxfmt (linting & formatting) - `mcp`: Install MCP servers, including Better T Stack itself, with add-mcp - `opentui`: OpenTUI components - `wxt`: WXT browser extension framework - `skills`: Install AI agent skills for coding assistants (Cursor, Claude Code, GitHub Copilot, etc.) - `evlog`: Structured request logging for Hono, Express, Fastify, Elysia, or fullstack web backends ```bash create-better-t-stack --addons pwa biome husky ``` ## Examples ### `--examples ` Example implementations to include: - `none`: No examples - `todo`: Todo app example - `ai`: AI chat interface example ```bash create-better-t-stack --examples todo ai ``` ## Deployment ### `--web-deploy ` Web deployment configuration: - `none`: No deployment setup - `cloudflare`: Cloudflare Workers deployment (via Alchemy infrastructure as code) ```bash create-better-t-stack --web-deploy cloudflare ``` **Note:** Alchemy uses TypeScript to define infrastructure programmatically. See the [Deploying to Cloudflare with Alchemy Guide](/docs/guides/cloudflare-alchemy) for details. ## Automation Surfaces These are part of the CLI contract for agents and scripts even though they are commands or JSON fields instead of traditional flags: - `create-json` - `add-json` - `schema --name ` - `mcp` - `addonOptions` - `dbSetupOptions` See [Agent Workflows](/docs/cli/agent-workflows) for examples. ### `--server-deploy ` Server deployment configuration: - `none`: No deployment setup - `cloudflare`: Cloudflare Workers deployment (when runtime is workers, via Alchemy infrastructure as code) ```bash create-better-t-stack --server-deploy cloudflare ``` **Note:** Alchemy uses TypeScript to define infrastructure programmatically. See the [Deploying to Cloudflare with Alchemy Guide](/docs/guides/cloudflare-alchemy) for details. ## History ### `history` View your project creation history. Projects are tracked locally using platform-specific directories: - **macOS**: `~/Library/Application Support/better-t-stack/history.json` - **Linux**: `~/.local/share/better-t-stack/history.json` - **Windows**: `%LOCALAPPDATA%\better-t-stack\Data\history.json` ```bash # Show last 10 projects create-better-t-stack history # Show last 5 projects create-better-t-stack history --limit 5 # Output as JSON create-better-t-stack history --json # Clear all history create-better-t-stack history --clear ``` **Options:** - `--limit `: Number of entries to show (default: 10) - `--clear`: Clear all project history - `--json`: Output history as JSON ## Option Validation The CLI validates option combinations and will show errors for incompatible selections. See the [Compatibility](/docs/cli/compatibility) page for detailed rules. ## Examples ### Full Configuration ```bash create-better-t-stack \ --database postgres \ --orm drizzle \ --backend hono \ --runtime bun \ --frontend tanstack-router \ --api trpc \ --auth better-auth \ --addons pwa biome \ --examples todo \ --package-manager bun \ --web-deploy cloudflare \ --server-deploy cloudflare \ --install ``` ### Minimal Setup ```bash create-better-t-stack \ --backend none \ --frontend tanstack-router \ --addons none \ --examples none ``` ================================================ FILE: apps/web/content/docs/cli/programmatic-api.mdx ================================================ --- title: Programmatic API description: Use Better-T-Stack programmatically in your Node.js applications --- ## Overview You can call Better-T-Stack directly from TypeScript/JavaScript without shelling out to the CLI. The programmatic API is exported from `create-better-t-stack` and is designed for automation tools, internal generators, and scripted workflows. Because it runs in silent mode by default, it also benefits from the same agent-safe behavior as `create-json`, including structured addon and database setup options. ## Installation ```npm npm i create-better-t-stack ``` ## Quick Start ```typescript import { create } from "create-better-t-stack"; const result = await create("my-app", { frontend: ["tanstack-router"], backend: "hono", database: "sqlite", orm: "drizzle", auth: "better-auth", packageManager: "bun", install: false, dryRun: true, }); result.match({ ok: (data) => { console.log(`Project created at: ${data.projectDirectory}`); console.log(`Reproducible command: ${data.reproducibleCommand}`); }, err: (error) => { console.error(`Failed: ${error.message}`); }, }); ``` ## API Reference ### `create(projectName?, options?)` Create a new project. ```typescript function create( projectName?: string, options?: Partial, ): Promise>; ``` Notes: - Uses the same option model as the CLI `create` command (`frontend`, `backend`, `database`, `orm`, `api`, `auth`, `addons`, etc.). - Supports structured `addonOptions` and `dbSetupOptions`. - Supports `dryRun` for validation-only automation. - Runs in silent mode (no interactive prompts / no CLI UI output). - Returns a `Result` (`ok`/`err`) instead of exiting the process. ### `add(options?)` Add addons to an existing Better-T-Stack project. ```typescript function add(options?: { addons?: Addons[]; addonOptions?: AddonOptions; install?: boolean; packageManager?: PackageManager; projectDir?: string; dryRun?: boolean; }): Promise; ``` Example: ```typescript import { add } from "create-better-t-stack"; const result = await add({ projectDir: "./my-app", addons: ["biome", "mcp"], addonOptions: { mcp: { scope: "project", servers: ["context7"], agents: ["cursor"], }, }, install: true, }); if (result?.success) { console.log(`Added: ${result.addedAddons.join(", ")}`); } else { console.error(result?.error ?? "Failed to add addons"); } ``` ### `createVirtual(options)` Generate a project in memory without writing to disk. ```typescript import { createVirtual } from "create-better-t-stack"; const result = await createVirtual({ frontend: ["tanstack-router"], backend: "hono", database: "sqlite", orm: "drizzle", addonOptions: { wxt: { template: "react", }, }, }); ``` This is useful for previews, tests, and web-based builders. ### `sponsors()` Show sponsors (same behavior as CLI command). ### `docs()` Open docs URL (same behavior as CLI command). ### `builder()` Open the web stack builder (same behavior as CLI command). ## Result Types ### `InitResult` (from `create` on `ok`) ```typescript type InitResult = { success: boolean; projectConfig: ProjectConfig; reproducibleCommand: string; timeScaffolded: string; elapsedTimeMs: number; projectDirectory: string; relativePath: string; error?: string; }; ``` ### `AddResult` (from `add`) ```typescript type AddResult = { success: boolean; addedAddons: Addons[]; projectDir: string; dryRun?: boolean; plannedFileCount?: number; error?: string; }; ``` ### `CreateError` `create()` can return these error types in `Result.err(...)`: - `UserCancelledError` - `CLIError` - `ProjectCreationError` ## Error Handling Pattern ```typescript import { create } from "create-better-t-stack"; const result = await create("existing-dir", { directoryConflict: "error", }); if (result.isErr()) { console.error(result.error.message); process.exit(1); } console.log(result.value.projectDirectory); ``` ## Mapping CLI to Programmatic CLI: ```bash create-better-t-stack my-app \ --frontend tanstack-router \ --backend hono \ --database postgres \ --orm drizzle \ --auth better-auth ``` Programmatic: ```typescript const result = await create("my-app", { frontend: ["tanstack-router"], backend: "hono", database: "postgres", orm: "drizzle", auth: "better-auth", addonOptions: { wxt: { template: "react" }, }, dbSetupOptions: { mode: "manual", }, }); ``` For the CLI-side JSON equivalents, see [Agent Workflows](/docs/cli/agent-workflows). ================================================ FILE: apps/web/content/docs/cli/prompts.mdx ================================================ --- title: Using the interactive prompts description: How to navigate and answer the CLI's interactive questions --- ## Overview The CLI uses `@clack/prompts` for interactive questions. These prompts work well in most terminals and are fully keyboard-driven. ## Core keys - **Navigate**: Up/Down arrow keys - **Confirm/continue**: Enter - **Cancel**: Ctrl+C ## Prompt types you’ll see ### Single select (choose one) - **Move** with Up/Down, **Enter** to choose the highlighted option. Typical places: choosing a web or native framework, picking a runtime or API. ### Multi-select (choose many) - **Move** with Up/Down. - **Space** toggles the highlighted option on/off. - **Enter** confirms your selection(s). - Some prompts allow selecting none (you can press Enter without toggling anything). Typical places: selecting project types (web/native), choosing example apps. ### Grouped multi-select (addons) - Options are organized under group headings. - **Move** with Up/Down, **Space** to toggle an option, **Enter** to confirm. - Group headings are informational; toggle the items within groups. Used when selecting addons like Biome, PWA, Turborepo, etc. ### Confirm (yes/no) - Use Left/Right or Up/Down to highlight Yes/No, then **Enter**. Typical places: installing dependencies, initializing Git. ### Text input - Type your answer and press **Enter**. - If validation fails, a short message will explain what to fix; edit and press **Enter** again. Typical places: project name/path, database URLs, provider-specific inputs. ## Tips - You can skip all prompts with `--yes` if you want the defaults. See the [Options](/docs/cli/options#yes--y) page. - If you accidentally start the wrong flow, press **Ctrl+C** to cancel safely. ================================================ FILE: apps/web/content/docs/contributing.mdx ================================================ --- title: Contributing description: How to set up your environment and contribute changes --- Before starting work on any new features or major changes, **please open an issue first to discuss your proposal and get approval.** We don't want you to **waste time** on work that might not align with the project's direction or get merged. ## Overview This project is a monorepo with two main apps: - CLI: `apps/cli` - Documentation site: `apps/web` ## Setup ### Prerequisites - Node.js (lts) - Bun (recommended) - Git ### Install ```bash git clone https://github.com/AmanVarshney01/create-better-t-stack.git cd create-better-t-stack bun install ``` ## Develop the CLI ```bash cd apps/cli # optional global link for testing anywhere bun link # run in watch mode (runs tsdown build in watch mode) bun dev ``` Now go to anywhere else in your system (maybe like a test folder) and run: ```bash create-better-t-stack ``` This will run the locally installed CLI. ## Develop the Docs ```bash # from repo root bun install cd packages/backend bun dev:setup # you can choose local development too in prompts ``` Copy the Convex URL from `packages/backend/.env.local` to `apps/web/.env`: ``` NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210/ ``` Now run `bun dev` in the root. It will complain about GitHub token, so run this in `packages/backend`: ```bash npx convex env set GITHUB_ACCESS_TOKEN=xxxxx npx convex env set GITHUB_WEBHOOK_SECRET=xxxxx ``` ## Contribution Flow 1. Open an issue/discussion before starting major work 2. Fork the repository 3. Create a feature branch 4. Make changes following existing code style 5. Update docs as needed 6. Test and format ```bash # CLI cd apps/cli && bun dev cd apps/cli && bun run test # Web bun dev # Lint + format checks bun check ``` 7. Commit and push ```bash git add . git commit -m "feat(web): ..." # or fix(cli): ... git push origin ``` 8. Open a Pull Request and link any related issues ## Commit Conventions Use conventional commit messages with the appropriate scope: - `feat(cli): add new CLI feature` - `fix(cli): fix CLI bug` - `feat(web): add new web feature` - `fix(web): fix web bug` - `chore(web): update dependencies` - `docs: update documentation` ## Help - Issues and Discussions on GitHub - Discord: https://discord.gg/ZYsbjpDaM5 See full contributor guide in the repository: `.github/CONTRIBUTING.md`. ================================================ FILE: apps/web/content/docs/faq.mdx ================================================ --- title: Frequently Asked Questions description: Short answers to common beginner questions --- import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; ## General An opinionated CLI that scaffolds full‑stack TypeScript projects (frontend, backend, API, DB/ORM, auth, addons) with a clean monorepo. See the Quick Start on the docs home. No. Run the CLI directly with your package manager. See Quick Start and the per‑command pages under CLI. `npm`, `pnpm`, or `bun` (all supported). Node.js 20+ (LTS recommended). The CLI is for new projects. You can migrate gradually or use `add` to extend a Better‑T‑Stack project. See Project Structure for high‑level layouts (server‑based vs. Convex, optional web/native).. ## Choosing options No. Pick what fits your needs. The CLI validates compatibility. See CLI (per command) and Compatibility for rules. See Compatibility for guidance and constraints. Both pairs work well; choose based on team and hosting needs. ## Common issues Set `EXPO_PUBLIC_SERVER_URL` in `apps/native/.env` to your machine IP (not `localhost`), check firewall, or try `npx expo start --tunnel`. Set `BTS_TELEMETRY_DISABLED=1` (shell env). For one run, prefix the command; to make it permanent, export it in your shell profile. ## Getting help - Docs: Quick Start, CLI, Project Structure, Compatibility - Ask/Report: GitHub Issues & Discussions - Community: Discord ================================================ FILE: apps/web/content/docs/guides/cloudflare-alchemy.mdx ================================================ --- title: Deploying to Cloudflare with Alchemy description: Learn how to deploy your Better-T-Stack app to Cloudflare Workers using Alchemy infrastructure-as-code author: name: Oscar Gabriel url: https://github.com/oscabriel date: Dec 31, 2025 --- ## Overview This guide explains how Better-T-Stack uses [Alchemy](https://alchemy.run) to deploy your applications to [Cloudflare Workers](https://developers.cloudflare.com/workers/). You'll learn: - What Cloudflare Workers and Alchemy are - How to deploy web apps, server apps, or both - How environment variables and secrets are managed - How to work with D1 databases - How to manage multiple stages (dev, prod, staging, etc) - How type-safe bindings work with the `packages/env` package ## What is Cloudflare Workers? **Cloudflare Workers** is a serverless platform that runs your code on Cloudflare's edge network across 300+ data centers worldwide. Unlike traditional serverless (AWS Lambda, Google Cloud Functions), Workers use **V8 isolates** instead of containers. This architecture provides near-zero cold starts, global distribution, native TypeScript type definitions generated by [workerd](https://github.com/cloudflare/workerd), and full-stack framework support for React Router, TanStack Start, SvelteKit, and more. Workers integrate seamlessly with Cloudflare's developer platform, including D1 databases, R2 object storage, KV stores, Durable Objects, etc; all accessible via typed bindings, all with very generous free tiers. ## What is Alchemy? **Alchemy** is an Infrastructure-as-Code (IaC) library. Unlike Terraform or Pulumi, Alchemy is pure TypeScript, resource-based, AI-friendly, and runs anywhere JS runs. You define what you want via normal async functions and Alchemy handles the creation, updating, and deletion of everything for you. When you scaffold a project with Cloudflare deployment enabled, Better-T-Stack generates an `alchemy.run.ts` file that defines your entire infrastructure as code. ## Enabling Cloudflare Deployment When creating a project: **Combined deployment (web + server):** ```npm npm create better-t-stack@latest my-app \ --frontend tanstack-router \ --backend hono \ --runtime workers \ --web-deploy cloudflare \ --server-deploy cloudflare ``` **Web-only (e.g., with Convex backend):** ```npm npm create better-t-stack@latest my-app \ --frontend tanstack-start \ --backend convex \ --web-deploy cloudflare ``` **Server-only:** ```npm npm create better-t-stack@latest my-app \ --frontend none \ --backend hono \ --runtime workers \ --server-deploy cloudflare ``` ## Understanding alchemy.run.ts The `alchemy.run.ts` file is the heart of your deployment configuration. Here's a simplified example for a combined web + server deployment: ```typescript // packages/infra/alchemy.run.ts import alchemy from "alchemy"; import { TanStackStart } from "alchemy/cloudflare"; import { Worker } from "alchemy/cloudflare"; import { D1Database } from "alchemy/cloudflare"; import { config } from "dotenv"; // Load environment variables from multiple .env files config({ path: "./.env" }); config({ path: "../../apps/web/.env" }); config({ path: "../../apps/server/.env" }); // Initialize the Alchemy app const app = await alchemy("my-app"); // Create D1 database (if using D1) const db = await D1Database("database", { migrationsDir: "../../packages/db/src/migrations", }); // Deploy web frontend export const web = await TanStackStart("web", { cwd: "../../apps/web", bindings: { VITE_SERVER_URL: alchemy.env.VITE_SERVER_URL!, }, }); // Deploy server backend export const server = await Worker("server", { cwd: "../../apps/server", entrypoint: "src/index.ts", compatibility: "node", bindings: { DB: db, CORS_ORIGIN: alchemy.env.CORS_ORIGIN!, BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!, BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!, DATABASE_URL: alchemy.secret.env.DATABASE_URL!, }, dev: { port: 3000, }, }); // Log deployment URLs console.log(`Web -> ${web.url}`); console.log(`Server -> ${server.url}`); // Finalize (triggers cleanup of orphaned resources) await app.finalize(); ``` ### Framework-Specific Deployments Alchemy provides optimized deployment resources for each frontend framework: | Framework | Alchemy Resource | Notes | | --------------- | ---------------- | ------------------------------------ | | Next.js | `Nextjs` | Uses OpenNext adapter | | Nuxt | `Nuxt` | Uses Nitro Cloudflare preset | | SvelteKit | `SvelteKit` | Uses Alchemy SvelteKit adapter | | TanStack Start | `TanStackStart` | Full SSR support | | React Router | `ReactRouter` | Uses React Router Cloudflare adapter | | TanStack Router | `Vite` | Static site with assets | | SolidJS | `Vite` | Static site with assets | ## Environment Variables and Secrets ### Loading Environment Variables The generated `alchemy.run.ts` loads environment variables using dotenv: ```typescript import { config } from "dotenv"; // Load from multiple locations (order matters - later files override) config({ path: "./.env" }); // packages/infra/.env config({ path: "../../apps/web/.env" }); // apps/web/.env config({ path: "../../apps/server/.env" }); // apps/server/.env ``` This allows you to: - Keep shared variables in `packages/infra/.env` - Keep web-specific variables in `apps/web/.env` - Keep server-specific variables in `apps/server/.env` ### alchemy.env vs alchemy.secret.env Alchemy provides two ways to access environment variables for bindings: #### Public Variables Use `alchemy.env` for non-sensitive configuration values. These are stored as plaintext in Alchemy's state files and are visible in logs: ```typescript bindings: { CORS_ORIGIN: alchemy.env.CORS_ORIGIN!, VITE_SERVER_URL: alchemy.env.VITE_SERVER_URL!, STAGE: alchemy.env.STAGE!, VERSION: "1.0.0", } ``` **Use for:** - URLs and endpoints - Feature flags - Stage/environment identifiers - Public configuration #### Encrypted Secrets Use `alchemy.secret.env` for sensitive values. These are **encrypted** in Alchemy's state files using AES-256-GCM: ```typescript bindings: { BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!, DATABASE_URL: alchemy.secret.env.DATABASE_URL!, API_KEY: alchemy.secret.env.API_KEY!, STRIPE_SECRET_KEY: alchemy.secret.env.STRIPE_SECRET_KEY!, } ``` **Use for:** - API keys and tokens - Database credentials - Auth secrets - Any sensitive data ### Alchemy Password Alchemy uses a password to encrypt and decrypt secrets. After creating a project, make sure to update the password variable in `packages/infra/.env`. **For CI/CD (GitHub Actions):** ```yaml env: ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }} ``` Generate a strong password: `openssl rand -base64 32` Without `ALCHEMY_PASSWORD`, any operation involving secrets will fail. Store this password securely and never commit it to source control. Note that for apps using Convex as their backend, you should instead set your secrets directly in the Convex dashboard manually or with `convex env set` from `packages/backend`. ## Multi-Stage Deployments Alchemy supports deploying to multiple stages (environments) like development, production, staging, and so on. Each stage has isolated state and resources. **CLI Argument:** ```bash # Development (default) bun run deploy # Staging bun run deploy --stage staging # Production bun run deploy --stage prod ``` **Environment Variables:** ```bash # packages/infra/.env.prod ALCHEMY_STAGE=prod bun run deploy --env-file .env.prod ``` **Default Stage Resolution:** 1. `--stage` CLI argument 2. `ALCHEMY_STAGE` environment variable 3. `STAGE` environment variable 4. Current username (`$USER`) 5. `"dev"` as fallback ### Stage-Isolated State Each stage stores its state in a separate directory: This ensures complete isolation between environments. ### Stage-Based Resource Naming Use `app.stage` to create unique resource names per environment: ```typescript const app = await alchemy("my-app"); // Resources include stage in their names export const server = await Worker("server", { name: `${app.name}-${app.stage}-server`, // e.g., "my-app-prod-server" // ... }); export const db = await D1Database("database", { name: `${app.name}-${app.stage}-db`, // e.g., "my-app-dev-db" // ... }); ``` ### Environment-Specific Configuration ```typescript const stage = process.env.STAGE || "dev"; const app = await alchemy("my-app", { stage }); // Stage-specific settings const isProd = app.stage === "prod"; export const server = await Worker("server", { // Production gets custom domain, others get workers.dev URLs url: !isProd, domains: isProd ? ["api.myapp.com"] : undefined, bindings: { // Different URLs per environment CORS_ORIGIN: isProd ? "https://myapp.com" : `https://${app.stage}.myapp.com`, }, }); ``` ## Type-Safe Bindings ### How Bindings Work When you define bindings in `alchemy.run.ts`, they become available in your Worker code at runtime. Alchemy provides type inference so you get full TypeScript support. ### The env.d.ts Pattern Better-T-Stack generates a `packages/env/env.d.ts` file that connects your Alchemy bindings to TypeScript: ```typescript import { type server } from "@my-app/infra/alchemy.run"; // Infer types from the Worker's bindings export type CloudflareEnv = typeof server.Env; declare global { type Env = CloudflareEnv; } declare module "cloudflare:workers" { namespace Cloudflare { export interface Env extends CloudflareEnv {} } } ``` This enables type-safe access to bindings in your server code. ### Accessing Bindings in Code **With Hono:** ```typescript import { Hono } from "hono"; import { env } from "cloudflare:workers"; // Access bindings via cloudflare:workers module const app = new Hono() .get("/users", async (c) => { // Type-safe access to bindings const db = drizzle(env.DB); const users = await db.select().from(usersTable); return c.json(users); }) .get("/config", (c) => { // Access env vars and secrets return c.json({ corsOrigin: env.CORS_ORIGIN, stage: env.STAGE, }); }); ``` **With Request Handler:** ```typescript import type { server } from "@my-app/infra/alchemy.run"; export default { async fetch(request: Request, env: typeof server.Env) { // Type-safe access to all bindings const value = await env.KV.get("key"); const apiKey = env.API_KEY; return new Response(`Value: ${value}`); }, }; ``` ## Integration with packages/env Better-T-Stack uses the `packages/env` package for type-safe environment variables. The setup differs between Cloudflare Workers and traditional runtimes. ### For Cloudflare Workers (server.ts) When deploying to Cloudflare, server environment variables come from Worker bindings, not `process.env`: ```typescript // packages/env/src/server.ts (Cloudflare Workers) /// // Re-export env from cloudflare:workers module // Types are defined in env.d.ts based on your alchemy.run.ts bindings export { env } from "cloudflare:workers"; ``` This means: - **No t3-env validation** for server env (bindings are already type-safe) - **Types come from Alchemy** via the `env.d.ts` file - **Runtime values** are injected by Cloudflare Workers For traditional backend runtimes, server environment variables come from `process.env` and do make use of t3-env validation. ### For Web/Client (web.ts) Client-side environment variables always use t3-env (Cloudflare or not): ```typescript // packages/env/src/web.ts import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; export const env = createEnv({ clientPrefix: "VITE_", client: { VITE_SERVER_URL: z.url(), }, runtimeEnv: import.meta.env, emptyStringAsUndefined: true, }); ``` ## Local Resources During development, Alchemy emulates your local environment using Miniflare. Running `bun run dev` creates a local SQLite databases that mimic your resources' real production behavior, so you can develop without deploying. You can find these emulated resources in `.alchemy/miniflare/v3/`. ## Deployment Commands ### Root-Level Commands Better-T-Stack adds these scripts to your root `package.json`: ```json { "scripts": { "dev": "...", "deploy": "turbo -F @my-app/infra deploy", "destroy": "turbo -F @my-app/infra destroy" } } ``` ### Deploy Your Application ```bash # Deploy to default stage (your username or "dev") bun run deploy # Deploy to a specific stage bun run deploy --stage prod # Or from the infra package directly cd packages/infra && bun run deploy ``` On first deploy, Alchemy will: 1. Create Cloudflare Workers for your web and/or server 2. Create D1 database (if configured) 3. Apply database migrations 4. Upload your code and assets 5. Log the deployment URLs ### Development Mode ```bash # Runs web and/or server with Alchemy's local emulation bun run dev ``` In dev mode, Alchemy: - Emulates D1 locally using Miniflare - Provides local URLs for testing - Hot-reloads on changes ### Destroy Resources ```bash # Tear down all deployed resources for current stage bun run destroy # Destroy a specific stage bun run destroy --stage staging ``` This removes: - Cloudflare Workers - D1 databases (unless `delete: false` is set) - All associated bindings **Warning**: `destroy` permanently deletes your deployed resources. Database data will be lost unless you've configured `delete: false` or exported backups. ## Continous Integration **Important**: By default, Alchemy uses local file-based state storage. This can cause issues in CI/CD where the filesystem is ephemeral. For CI/CD, you should either: 1. **Commit state files** to your repository (secrets are encrypted) 2. **Use a remote state store** via the CloudflareStateStore resource See the alchemy docs to configure a [state store](https://alchemy.run/guides/cloudflare-state-store/) and set up a [CI/CD pipeline](https://alchemy.run/guides/ci/). ## Cross-Domain Considerations When web and server are deployed as separate Workers, they have different domains: ``` Web: https://my-app-web.your-subdomain.workers.dev Server: https://my-app-server.your-subdomain.workers.dev ``` ### Updating Environment Variables for Production Before deploying, update your environment variables to use your production Worker URLs: ```bash # apps/web/.env VITE_SERVER_URL=https://my-app-server.your-subdomain.workers.dev # apps/server/.env CORS_ORIGIN=https://my-app-web.your-subdomain.workers.dev BETTER_AUTH_URL=https://my-app-server.your-subdomain.workers.dev ``` Replace `your-subdomain` with your actual Cloudflare Workers subdomain (found in the Cloudflare dashboard under Workers & Pages). ### Cookie Configuration for Auth When using Better-Auth with separate web and server Workers, cookies need special configuration to work across subdomains. In `packages/auth/src/auth.ts`, configure these settings: ```typescript // packages/auth/src/auth.ts export const auth = betterAuth({ // ... other config session: { cookieCache: { enabled: true, maxAge: 5 * 60, // 5 minutes }, }, advanced: { crossSubDomainCookies: { enabled: true, domain: ".workers.dev", // Shared domain for cookies }, }, }); ``` The generated auth configuration includes these settings commented out. Uncomment them and replace the domain with your actual workers subdomain (e.g., `.your-subdomain.workers.dev`) when deploying to production. Pay careful attention to CORS settings when moving between stages. A configuration that works locally may fail in production (and vice versa) if CORS origins or cookie domains don't match your actual deployment URLs. ## Troubleshooting ### "Environment variable X is undefined" 1. Check that the variable exists in the correct `.env` file 2. Verify the dotenv `config()` call loads that file 3. For secrets, use `alchemy.secret.env.X` not `alchemy.env.X` ### "Secret cannot be decrypted" or "Password required" 1. Ensure `ALCHEMY_PASSWORD` is set in your environment 2. Use the same password that was used to encrypt the secrets 3. Check that the password hasn't been changed since last deployment ### "D1 migrations failed" 1. Ensure migrations directory path is correct in `alchemy.run.ts` 2. Run migrations locally first: `bun run db:push` 3. Check migration files are valid SQL ### "Worker size too large" 1. Check your bundle size with `bun run build` 2. Enable minification in your build config 3. Review dependencies - some packages aren't edge-compatible ### "CORS errors in browser" 1. Verify `CORS_ORIGIN` matches your web Worker URL exactly 2. Check that preflight requests are handled 3. Ensure cookies have correct `SameSite` settings ================================================ FILE: apps/web/content/docs/guides/index.mdx ================================================ --- title: Guides description: Practical guides for common setups --- ## Guides Curated, task-focused guides for working with Better-T-Stack projects. ### Available Guides Deploy your app to Cloudflare Workers using Alchemy infrastructure-as-code. Covers web + server deployments, D1 databases, and environment management. ### Coming Soon More guides are planned: - Using Better-Auth with Convex - Workers + D1 setup with Hono and Drizzle - Adding PWA to React Router or Next.js - Using Prisma with Neon or Supabase - Monorepo tips with Turborepo ### Other Resources - [CLI Reference](/docs/cli) - Command options and usage - [Compatibility](/docs/cli/compatibility) - Valid stack combinations - [Project Structure](/docs/project-structure) - Understanding generated projects ================================================ FILE: apps/web/content/docs/guides/meta.json ================================================ { "title": "Guides", "defaultOpen": true, "pages": ["index", "cloudflare-alchemy"] } ================================================ FILE: apps/web/content/docs/index.mdx ================================================ --- title: Quick Start description: Create your first Better-T-Stack project in minutes --- ## Philosophy - Roll your own stack: pick only what you need, nothing extra. - Minimal templates: bare-bones scaffolds with zero bloat. - Latest dependencies: always current and stable by default. - Free and open source: forever. ## Get Started ### Prerequisites - **Node.js LTS** - [Download from nodejs.org](https://nodejs.org/) - **Git** (optional) - [Download from git-scm.com](https://git-scm.com/) - if you want to initialize a git repository - **Bun** (optional) - [Download from bun.com](https://bun.com/) - if you want to use Bun as your package manager ### CLI (prompts) ```npm npm create better-t-stack@latest ``` Follow the interactive prompts to choose your frontend, backend, database, ORM, API layer, and addons. Skip prompts and use the default stack: ```npm npm create better-t-stack@latest --yes ``` ### Stack Builder (UI) - Visit [/new](/new) to pick your stack and copy the generated command - Or open it via: ```npm npm create better-t-stack@latest builder ``` ## Common Setups ### Default Stack ```npm npm create better-t-stack@latest my-webapp \ --frontend tanstack-router \ --backend hono \ --database sqlite \ --orm drizzle \ --auth better-auth \ --addons turborepo ``` ### Convex + React + Clerk ```npm npm create better-t-stack@latest my-api \ --frontend tanstack-router \ --backend convex \ --auth clerk ``` ### Mobile App (Expo) ```npm npm create better-t-stack@latest my-native \ --frontend native-uniwind \ --backend hono \ --database sqlite \ --orm drizzle \ --auth better-auth ``` ### Empty Monorepo ```npm npm create better-t-stack@latest my-workspace \ --frontend none \ --backend none ``` ## Flags Cheat Sheet See the full list in the [CLI Reference](/docs/cli). Key flags: - `--frontend`: tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, astro, native-bare, native-uniwind, native-unistyles, none - `--backend`: hono, express, fastify, elysia, convex, self, none - `--runtime`: bun, node, workers, none - `--database`: sqlite, postgres, mysql, mongodb, none - `--orm`: drizzle, prisma, mongoose, none - `--api`: trpc, orpc, none - `--auth`: better-auth, clerk, none - `--payments`: polar, none - `--addons`: turborepo, nx, pwa, tauri, electrobun, biome, lefthook, husky, starlight, fumadocs, ultracite, oxlint, mcp, opentui, wxt, skills, evlog, none - `--examples`: todo, ai, none ## Next Steps Flags, usage, and examples for each command See how web/server/native and Convex layouts are generated Valid combinations for backend, runtime, database, ORM, API Required for the add command; safe to delete if you don't use add Dev setup and contribution flow ================================================ FILE: apps/web/content/docs/meta.json ================================================ { "pages": [ "index", "cli", "guides", "project-structure", "bts-config", "analytics", "contributing", "faq" ] } ================================================ FILE: apps/web/content/docs/project-structure.mdx ================================================ --- title: Project Structure description: Understanding the structure of projects created by Better-T-Stack CLI --- ## Overview Better-T-Stack CLI scaffolds a monorepo with `apps/*` and `packages/*`. `packages/config` is always present; other packages and apps appear based on your choices (frontend, backend, API, database/ORM, auth, addons, runtime, deploy). This page mirrors what the CLI actually writes. ## Root layout At the repository root you will see: Notes: - `bts.jsonc` lets the CLI detect and enhance your project later; keep it if you plan to use `create-better-t-stack add`. - `turbo.json` exists only if you picked the Turborepo addon. - `nx.json` exists only if you picked the Nx addon. - `pnpm-workspace.yaml`, `bunfig.toml`, and `.npmrc` are added based on the package manager you choose. - `packages/infra` is created only when Cloudflare deployment is enabled. ## Monorepo structure by backend ### Server backends (hono, express, fastify, elysia) Notes: - `apps/desktop` is created only with the Electrobun addon. - `apps/docs` is created only with the Starlight addon. - `apps/fumadocs` is created only with the Fumadocs addon. ### Self backend (fullstack) When `--backend self` is used, API routes live inside `apps/web` (no `apps/server`). ### Convex backend ## Frontend Structure (apps/web) The structure varies by framework. Items marked "(auth)" or "(API)" appear only when those options are enabled. ### React with TanStack Router ### Next.js Notes: - If you choose `--backend self` with Next.js, TanStack Start, Nuxt, SvelteKit, or Astro, API routes live inside `apps/web` (no `apps/server`). - (auth) adds `src/app/login/*` and `src/app/dashboard/*` plus sign-in components. ## React UI Customization React web apps (`tanstack-router`, `react-router`, `tanstack-start`, and `next`) share shadcn/ui primitives through `packages/ui`. - Change design tokens and global styles in `packages/ui/src/styles/globals.css` - Update shared primitives in `packages/ui/src/components/*` - Adjust shadcn aliases or style config in `packages/ui/components.json` and `apps/web/components.json` ### Add more shared components Run this from the project root to add more primitives to the shared UI package: ```bash npx shadcn@latest add accordion dialog popover sheet table -c packages/ui ``` Import shared components like this: ```tsx import { Button } from "@your-project/ui/components/button"; ``` ### Add app-specific blocks If you want to add app-specific shadcn blocks instead of shared primitives, run the shadcn CLI from `apps/web`. ## Backend Structure (apps/server) The server structure depends on your backend choice: ### Hono backend ### Express / Fastify / Elysia ### Workers runtime (optional) When `runtime=workers`, the server targets Cloudflare Workers. If you also choose Cloudflare deployment, you'll get `packages/infra/alchemy.run.ts` and related infra files. API and auth scaffolding (conditional): - API=trpc: `src/lib/trpc.ts`, `src/lib/context.ts` - API=orpc: `src/lib/orpc.ts`, `src/lib/context.ts` - Auth: `src/lib/auth.ts` ## Database Configuration Added only when you selected a database and ORM: ### Drizzle ORM ### Prisma ORM ### Mongoose (MongoDB) ### Auth + DB If you selected auth, additional files are added for your ORM: - Drizzle: `src/db/schema/auth.ts` - Prisma: `prisma/schema/auth.prisma` - Mongoose: `src/db/models/auth.model.ts` ### Docker compose (optional) If `dbSetup=docker`, a `docker-compose.yml` is added in `apps/server/` for your database. ## Native App Structure (apps/native) Created only when you include React Native (NativeWind or Unistyles): If an API is selected, a client utility is added: - API=trpc: `utils/trpc.ts` - API=orpc: `utils/orpc.ts` ## Documentation Structure ### Starlight (apps/docs) ### Fumadocs (apps/fumadocs) Fumadocs is generated by `create-fumadocs-app` inside `apps/fumadocs`. The exact structure depends on the template you choose (MDX vs static). ### Electrobun (apps/desktop) Electrobun adds an `apps/desktop` workspace with its own `package.json`, `electrobun.config.ts`, and `src/bun/index.ts`. The desktop shell reuses `apps/web` during development and bundles its built static output for release builds. If your frontend is SSR-first, switch it to a static/export build before packaging the desktop app. ## Configuration Files ### Better-T-Stack Config (bts.jsonc) ```json { "$schema": "https://r2.better-t-stack.dev/schema.json", "version": "", "createdAt": "", "database": "", "orm": "", "backend": "", "runtime": "", "frontend": [""] , "addons": [""] , "examples": [""] , "auth": <"better-auth"|"clerk"|"none">, "packageManager": "", "dbSetup": "", "api": "", "webDeploy": "", "serverDeploy": "" } ``` ### Turborepo Config (turbo.json) Generated only if you chose the Turborepo addon. ### Nx Config (nx.json) Generated only if you chose the Nx addon. ```json { "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] }, "dev": { "cache": false, "persistent": true } } } ``` ## Shared packages Better-T-Stack always creates `packages/config`. Other packages are added based on your selections: - `packages/env`: when any frontend is selected or the backend is not `none` - `packages/api`: when `--api` is not `none` (non-Convex) - `packages/auth`: when `--auth` is not `none` (non-Convex) - `packages/db`: when both `--database` and `--orm` are selected (non-Convex) - `packages/backend`: Convex backend only - `packages/infra`: Cloudflare deployment only ## Development Scripts Scripts are adjusted based on your package manager and whether the Turborepo or Nx addon is selected. With Turborepo: ```json { "scripts": { "dev": "turbo dev", "build": "turbo build", "check-types": "turbo check-types", "dev:web": "turbo -F web dev", "dev:server": "turbo -F server dev", "db:push": "turbo -F server db:push", "db:studio": "turbo -F server db:studio" } } ``` With Nx: ```json { "scripts": { "dev": "nx run-many -t dev", "build": "nx run-many -t build", "check-types": "nx run-many -t check-types", "dev:web": "nx run-many -t dev --projects=web", "dev:server": "nx run-many -t dev --projects=server" } } ``` Without Turborepo or Nx (example for Bun): ```json { "scripts": { "dev": "bun run --filter '*' dev", "build": "bun run --filter '*' build", "check-types": "bun run --filter '*' check-types", "dev:web": "bun run --filter web dev", "dev:server": "bun run --filter server dev" } } ``` Notes: - Convex adds `dev:setup` for initial backend configuration. - Database scripts (`db:*`) are added only when a database + ORM are selected (Drizzle/Prisma). D1 + Cloudflare omits `db:studio`. ## Key Details - **Monorepo**: `apps/*` and `packages/*` are created only when relevant (except `packages/config`, which is always present) - **React web base**: app-specific files stay in `apps/web`, while shared shadcn/ui primitives live in `packages/ui` - **API clients**: `src/utils/trpc.ts` or `src/utils/orpc.ts` added to web/native when selected - **Auth**: Adds authentication setup based on provider: - `better-auth`: `src/lib/auth.ts` on server and login/dashboard pages on web app - `clerk`: Clerk provider setup and authentication components - **ORM/DB**: Drizzle/Prisma/Mongoose files added only when selected - **Extras**: `pnpm-workspace.yaml`, `bunfig.toml`, or `.npmrc` added based on package manager and choices - **Deploy**: Cloudflare deployment adds `packages/infra/alchemy.run.ts` and related infra files This reflects the actual files written by the CLI so new projects match what's documented here. ================================================ FILE: apps/web/next.config.ts ================================================ import { createMDX } from "fumadocs-mdx/next"; import type { NextConfig } from "next"; const withMDX = createMDX(); const config: NextConfig = { reactCompiler: true, reactStrictMode: true, images: { remotePatterns: [ { protocol: "https", hostname: "pbs.twimg.com" }, { protocol: "https", hostname: "abs.twimg.com" }, { protocol: "https", hostname: "r2.better-t-stack.dev" }, { protocol: "https", hostname: "avatars.githubusercontent.com" }, ], }, outputFileTracingExcludes: { "*": ["./**/*.js.map", "./**/*.mjs.map", "./**/*.cjs.map"], }, async rewrites() { return [ { source: "/docs/:path*.mdx", destination: "/llms.mdx/:path*", }, ]; }, experimental: { turbopackFileSystemCacheForDev: true, }, serverExternalPackages: ["create-better-t-stack", "fs-extra", "tinyglobby", "handlebars"], }; export default withMDX(config); ================================================ FILE: apps/web/package.json ================================================ { "name": "web", "version": "0.0.0", "private": true, "scripts": { "prebuild": "(cd ../../packages/types && bun run build) && (cd ../../packages/template-generator && bun run build)", "build": "next build", "dev": "next dev --port 3333", "start": "next start", "postinstall": "fumadocs-mdx", "generate-schema": "bun scripts/generate-schema.ts" }, "dependencies": { "@base-ui/react": "^1.4.1", "@base-ui/utils": "^0.2.8", "@better-t-stack/backend": "workspace:*", "@better-t-stack/template-generator": "workspace:*", "@better-t-stack/types": "workspace:*", "@erquhart/convex-oss-stats": "catalog:", "@number-flow/react": "^0.6.0", "@orama/orama": "^3.1.18", "@shikijs/transformers": "^4.0.2", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "catalog:", "convex-helpers": "catalog:", "create-better-t-stack": "workspace:*", "culori": "^4.0.2", "date-fns": "^4.1.0", "fumadocs-core": "16.8.1", "fumadocs-mdx": "14.3.1", "fumadocs-ui": "npm:@fumadocs/base-ui@16.8.1", "lucide-react": "^1.8.0", "motion": "^12.38.0", "next": "^16.2.4", "next-themes": "^0.4.6", "nuqs": "^2.8.8", "papaparse": "^5.5.3", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-dom": "^19.2.5", "react-icons": "^5.5.0", "react-tweet": "^3.3.0", "recharts": "^3.8.1", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-mdx": "^3.1.1", "shiki": "^4.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "catalog:" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.4", "@types/culori": "^4.0.1", "@types/mdx": "^2.0.13", "@types/node": "catalog:", "@types/papaparse": "^5.5.2", "@types/qrcode": "^1.5.6", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "eslint": "^10.2.1", "eslint-config-next": "16.2.4", "postcss": "^8.5.10", "tailwindcss": "^4.2.4", "tw-animate-css": "^1.4.0", "typescript": "catalog:" } } ================================================ FILE: apps/web/postcss.config.mjs ================================================ export default { plugins: { "@tailwindcss/postcss": {}, }, }; ================================================ FILE: apps/web/public/_headers ================================================ /_next/static/* Cache-Control: public,max-age=31536000,immutable ================================================ FILE: apps/web/public/favicon/site.webmanifest ================================================ { "name": "Better T Stack", "short_name": "Better T Stack", "icons": [ { "src": "/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" } ================================================ FILE: apps/web/public/robots.txt ================================================ User-agent: * Allow: / Sitemap: https://better-t-stack.dev/sitemap.xml ================================================ FILE: apps/web/scripts/generate-schema.ts ================================================ import { execSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BetterTStackConfigFileSchema } from "@better-t-stack/types"; import { z } from "zod"; const schema = z.toJSONSchema(BetterTStackConfigFileSchema, { target: "draft-7" }); const tempPath = join(tmpdir(), "bts-schema.json"); writeFileSync(tempPath, JSON.stringify(schema, null, 2)); execSync(`npx wrangler r2 object put "bucket/schema.json" --file="${tempPath}" --remote`, { stdio: "inherit", }); console.log("Uploaded schema.json to R2"); ================================================ FILE: apps/web/source.config.ts ================================================ import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from "fumadocs-mdx/config"; import { z } from "zod"; export const docs = defineDocs({ dir: "content/docs", docs: { postprocess: { includeProcessedMarkdown: true, }, schema: frontmatterSchema.extend({ author: z .object({ name: z.string(), url: z.string().url().optional(), }) .optional(), date: z.string().optional(), }), }, meta: { schema: metaSchema, }, }); export default defineConfig({ mdxOptions: { remarkNpmOptions: { persist: { id: "package-manager", }, }, }, }); ================================================ FILE: apps/web/src/app/(home)/_components/FeatureCard.tsx ================================================ "use client"; import { motion } from "motion/react"; import { useTheme } from "next-themes"; import Image from "next/image"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; type TechOption = { id: string; name: string; icon: string; }; type FeatureCardProps = { title: string; description?: string; options: TechOption[]; className?: string; }; function TechIcon({ icon, name, className }: { icon: string; name: string; className?: string }) { const { theme } = useTheme(); if (!icon) return null; if (!icon.startsWith("https://")) { return ( {icon} ); } let iconSrc = icon; if ( theme === "light" && (icon.includes("drizzle") || icon.includes("prisma") || icon.includes("express") || icon.includes("astro")) ) { iconSrc = icon.replace(".svg", "-light.svg"); } return ( {`${name} ); } export default function FeatureCard({ title, options, className }: FeatureCardProps) { return (

{title}

    {options.map((option) => (
  • {/* {option.icon.startsWith("/") ? ( {option.name} ) : ( {option.icon} )} */}
  • ))}
); } ================================================ FILE: apps/web/src/app/(home)/_components/code-container.tsx ================================================ "use client"; import { Check, ClipboardCopy } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; import { cn } from "@/lib/utils"; import PackageIcon from "./icons"; const CodeContainer = () => { const [selectedPM, setSelectedPM] = useState<"npm" | "pnpm" | "bun">("bun"); const [copied, setCopied] = useState(false); const commands = { npm: "npx create-better-t-stack@latest", pnpm: "pnpm create better-t-stack@latest", bun: "bun create better-t-stack@latest", }; const copyToClipboard = async () => { if (copied) return; await navigator.clipboard.writeText(commands[selectedPM]); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const packageManagers: Array<"npm" | "pnpm" | "bun"> = ["bun", "pnpm", "npm"]; return (
Package manager:
{packageManagers.map((pm) => ( ))}
$ {commands[selectedPM]}
{copied ? ( ) : ( )}
); }; export default CodeContainer; ================================================ FILE: apps/web/src/app/(home)/_components/command-section.tsx ================================================ "use client"; import { Check, ChevronDown, ChevronRight, Copy, Terminal, Zap } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import PackageIcon from "./icons"; export default function CommandSection() { const [copiedCommand, setCopiedCommand] = useState(null); const [selectedPM, setSelectedPM] = useState<"npm" | "pnpm" | "bun">("bun"); const commands = { npm: "npx create-better-t-stack@latest", pnpm: "pnpm create better-t-stack@latest", bun: "bun create better-t-stack@latest", }; const copyCommand = (command: string, packageManager: string) => { navigator.clipboard.writeText(command); setCopiedCommand(packageManager); setTimeout(() => setCopiedCommand(null), 2000); }; return (
CLI_COMMAND
} > {selectedPM.toUpperCase()} {(["bun", "pnpm", "npm"] as const).map((pm) => ( setSelectedPM(pm)} className={cn( "flex items-center gap-2", selectedPM === pm && "bg-accent text-background", )} > {pm.toUpperCase()} {selectedPM === pm && } ))}
copyCommand(commands[selectedPM], selectedPM)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); copyCommand(commands[selectedPM], selectedPM); } }} aria-label={`Copy ${selectedPM} command`} title="Click to copy command" >
$ {commands[selectedPM]}
{copiedCommand === selectedPM ? ( ) : ( )} {copiedCommand === selectedPM ? "COPIED!" : "COPY"}
STACK_BUILDER
INTERACTIVE
Interactive configuration wizard
START
); } ================================================ FILE: apps/web/src/app/(home)/_components/footer.tsx ================================================ import { Terminal } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { FaGithub } from "react-icons/fa6"; import npmIcon from "@/public/icon/npm.svg"; const Footer = () => { return (

BETTER_T_STACK.INFO

Type-safe, modern TypeScript scaffolding for full-stack web development

NPM

RESOURCES.LIST

  • GitHub Repository
  • NPM Package
  • Demo Application

CONTACT.ENV

$ amanvarshney.work@gmail.com

Have questions or feedback? Feel free to reach out or open an issue on GitHub.

© {new Date().getFullYear()} Better-T-Stack. All rights reserved.

$ Built with{" "} TypeScript

); }; export default Footer; ================================================ FILE: apps/web/src/app/(home)/_components/hero-section.tsx ================================================ import NpmPackage from "./npm-package"; export default function HeroSection() { return (
            {`
██████╗  ██████╗ ██╗     ██╗
██╔══██╗██╔═══██╗██║     ██║
██████╔╝██║   ██║██║     ██║
██╔══██╗██║   ██║██║     ██║
██║  ██║╚██████╔╝███████╗███████╗
╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚══════╝`}
          
            {`
██╗   ██╗ ██████╗ ██╗   ██╗██████╗
╚██╗ ██╔╝██╔═══██╗██║   ██║██╔══██╗
 ╚████╔╝ ██║   ██║██║   ██║██████╔╝
  ╚██╔╝  ██║   ██║██║   ██║██╔══██╗
   ██║   ╚██████╔╝╚██████╔╝██║  ██║
   ╚═╝    ╚═════╝  ╚═════╝ ╚═╝  ╚═╝`}
          
            {`
 ██████╗ ██╗    ██╗███╗   ██╗
██╔═══██╗██║    ██║████╗  ██║
██║   ██║██║ █╗ ██║██╔██╗ ██║
██║   ██║██║███╗██║██║╚██╗██║
╚██████╔╝╚███╔███╔╝██║ ╚████║
 ╚═════╝  ╚══╝╚══╝ ╚═╝  ╚═══╝`}
          
            {`
███████╗████████╗ █████╗  ██████╗██╗  ██╗
██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
███████╗   ██║   ███████║██║     █████╔╝
╚════██║   ██║   ██╔══██║██║     ██╔═██╗
███████║   ██║   ██║  ██║╚██████╗██║  ██╗
╚══════╝   ╚═╝   ╚═╝  ╚═╝ ╚═════╝╚═╝  ╚═╝`}
          

Modern CLI for scaffolding end-to-end type-safe TypeScript projects

); } ================================================ FILE: apps/web/src/app/(home)/_components/icons.tsx ================================================ const PackageIcon = ({ pm, className }: { pm: string; className?: string }) => { switch (pm) { case "npm": return ( npm ); case "pnpm": return ( pnpm ); case "bun": return ( bun ); case "github": return ( Github ); default: return null; } }; export default PackageIcon; ================================================ FILE: apps/web/src/app/(home)/_components/npm-package.tsx ================================================ "use client"; import { useEffect, useState } from "react"; const NpmPackage = () => { const [version, setVersion] = useState("0.0.0"); useEffect(() => { const getLatestVersion = async () => { try { const res = await fetch("https://registry.npmjs.org/create-better-t-stack/latest"); if (!res.ok) throw new Error("Failed to fetch version"); const data = await res.json(); const latestVersion = typeof data?.version === "string" && data.version.trim().length > 0 ? data.version : "latest"; setVersion(latestVersion); } catch (error) { console.error("Error fetching NPM version:", error); setVersion("latest"); } }; getLatestVersion(); }, []); return (
[v{version}]
); }; export default NpmPackage; ================================================ FILE: apps/web/src/app/(home)/_components/shiny-text.tsx ================================================ interface ShinyTextProps { text: string; disabled?: boolean; speed?: number; className?: string; } const ShinyText = ({ text, disabled = false, speed = 5, className = "" }: ShinyTextProps) => { const animationDuration = `${speed}s`; return (
{text}
); }; export default ShinyText; ================================================ FILE: apps/web/src/app/(home)/_components/sponsors-section.tsx ================================================ "use client"; import { ChevronDown, ChevronUp, Globe, Heart, Star, Terminal } from "lucide-react"; import Image from "next/image"; import { useState } from "react"; import { FaGithub } from "react-icons/fa6"; import { formatSponsorUrl, getSponsorUrl, isLifetimeSpecialSponsor, shouldShowLifetimeTotal, } from "@/lib/sponsor-utils"; import type { SponsorsData } from "@/lib/types"; export default function SponsorsSection({ sponsorsData }: { sponsorsData: SponsorsData }) { const [showPastSponsors, setShowPastSponsors] = useState(false); const specialSponsors = sponsorsData.specialSponsors; const regularSponsors = sponsorsData.sponsors; const pastSponsors = sponsorsData.pastSponsors; const totalCurrentSponsors = specialSponsors.length + regularSponsors.length; return (
SPONSORS_DATABASE.JSON
[{totalCurrentSponsors} RECORDS]
{totalCurrentSponsors === 0 ? (
NO_SPONSORS_FOUND.NULL
$ Be the first to support this project!
) : (
{specialSponsors.length > 0 && (
{specialSponsors.map((entry, index) => { const sponsorUrl = getSponsorUrl(entry); return (
SPECIAL {entry.sinceWhen.toUpperCase()}
{entry.name}

{entry.name}

{shouldShowLifetimeTotal(entry) ? ( <> {entry.tierName && (

{entry.tierName}

)}

Total: {entry.formattedAmount}

) : (

{entry.tierName}

)}
); })}
)} {regularSponsors.length > 0 && (
{regularSponsors.map((entry, index) => { const sponsorUrl = getSponsorUrl(entry); return (
{entry.sinceWhen.toUpperCase()}
{entry.name}

{entry.name}

{shouldShowLifetimeTotal(entry) ? ( <> {entry.tierName && (

{entry.tierName}

)}

Total: {entry.formattedAmount}

) : (

{entry.tierName}

)}
); })}
)} {pastSponsors.length > 0 && (
{showPastSponsors && (
{pastSponsors.map((entry, index) => { const wasSpecial = isLifetimeSpecialSponsor(entry); const sponsorUrl = getSponsorUrl(entry); return (
{wasSpecial ? ( ) : ( )}
{wasSpecial && SPECIAL} {wasSpecial && } {entry.sinceWhen.toUpperCase()}
{entry.name}

{entry.name}

{shouldShowLifetimeTotal(entry) ? ( <> {entry.tierName && (

{entry.tierName}

)}

Total: {entry.formattedAmount}

) : (

{entry.tierName}

)}
); })}
)}
)}
)}
); } ================================================ FILE: apps/web/src/app/(home)/_components/stats-section.tsx ================================================ "use client"; import { api } from "@better-t-stack/backend/convex/_generated/api"; import { useNpmDownloadCounter } from "@erquhart/convex-oss-stats/react"; import NumberFlow, { continuous } from "@number-flow/react"; import { useQuery } from "convex/react"; import { BarChart3, Package, Star, Terminal, TrendingUp, Users } from "lucide-react"; import Link from "next/link"; import { FaGithub } from "react-icons/fa6"; export default function StatsSection() { const stats = useQuery(api.analytics.getStats, {}); const dailyStats = useQuery(api.analytics.getDailyStats, { days: 30 }); const githubRepo = useQuery(api.stats.getGithubRepo, { name: "AmanVarshney01/create-better-t-stack", }); const npmPackages = useQuery(api.stats.getNpmPackages, { names: ["create-better-t-stack"], }); const liveNpmDownloadCount = useNpmDownloadCounter(npmPackages); const totalProjects = stats?.totalProjects ?? 0; const avgProjectsPerDay = dailyStats && dailyStats.length > 0 ? (totalProjects / dailyStats.length).toFixed(2) : "0"; const lastUpdated = stats?.lastEventTime ? new Date(stats.lastEventTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }) : null; return (
CLI_ANALYTICS.JSON
Total Projects
Avg/Day {avgProjectsPerDay}
Last Updated {lastUpdated || new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", })}
GITHUB_REPO.GIT
Stars
Contributors {githubRepo?.contributorCount || "—"}
Repository AmanVarshney01/create-better-t-stack
NPM_PACKAGE.JS
Downloads
Avg/Day {npmPackages?.dayOfWeekAverages ? Math.round( npmPackages.dayOfWeekAverages.reduce((a: number, b: number) => a + b, 0) / 7, ) : "—"}
Package create-better-t-stack
); } ================================================ FILE: apps/web/src/app/(home)/_components/testimonials.tsx ================================================ "use client"; import { ChevronDown, ChevronUp, Play, Terminal } from "lucide-react"; import { motion } from "motion/react"; import Image from "next/image"; import { useState } from "react"; import { Tweet, type TwitterComponents } from "react-tweet"; export const components: TwitterComponents = { AvatarImg: (props) => { if (!props.src || props.src === "") { return
; } return {props.alt; }, MediaImg: (props) => { if (!props.src || props.src === "") { return
; } return {props.alt; }, }; const sectionHeaderClass = "mb-6 flex flex-wrap items-center justify-between gap-2 sm:flex-nowrap"; const sectionTitleClass = "flex items-center gap-2"; const sectionTitleTextClass = "font-bold font-mono text-lg sm:text-xl"; const sectionCountClass = "w-full text-right font-mono text-muted-foreground text-xs sm:w-auto sm:text-left"; const cardHeaderClass = "sticky top-0 z-10 flex items-center gap-2 border-border border-b px-3 py-2"; function ArchiveToggleButton({ expanded, count, onToggle, }: { expanded: boolean; count: number; onToggle: () => void; }) { return ( ); } const VideoCard = ({ video, index, }: { video: { embedId: string; title: string }; index: number; }) => (
[VIDEO_{String(index + 1).padStart(3, "0")}]