From 5788876c4780402e66772ce648af644805a1cc3d Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 20 Aug 2025 23:43:58 +0530 Subject: [PATCH] feat(cli): add alchemy and improve cli tooling and structure (#520) --- .changeset/README.md | 8 - .changeset/config.json | 11 - .cursor/rules/convex_rules.mdc | 674 ++++++++++++++++++ .../use-bun-instead-of-node-vite-npm-pnpm.mdc | 108 +++ .github/workflows/preview.yaml | 30 - .github/workflows/release.yaml | 36 +- apps/cli/README.md | 3 +- apps/cli/package.json | 9 +- apps/cli/src/constants.ts | 55 +- .../helpers/{setup => addons}/addons-setup.ts | 0 .../helpers/{setup => addons}/auth-setup.ts | 0 .../{setup => addons}/examples-setup.ts | 0 .../{setup => addons}/fumadocs-setup.ts | 0 .../helpers/{setup => addons}/ruler-setup.ts | 2 +- .../{setup => addons}/starlight-setup.ts | 0 .../helpers/{setup => addons}/tauri-setup.ts | 4 +- .../{setup => addons}/ultracite-setup.ts | 0 .../{setup => addons}/vite-pwa-setup.ts | 0 .../add-addons.ts | 3 +- .../add-deployment.ts | 54 +- apps/cli/src/helpers/core/api-setup.ts | 303 ++++++++ .../helpers/{setup => core}/backend-setup.ts | 0 .../command-handlers.ts | 88 ++- apps/cli/src/helpers/core/convex-codegen.ts | 13 + .../create-project.ts | 30 +- .../create-readme.ts | 0 .../src/helpers/{setup => core}/db-setup.ts | 0 .../detect-project-config.ts | 1 + .../{project-generation => core}/env-setup.ts | 66 +- .../{project-generation => core}/git.ts | 0 .../install-dependencies.ts | 0 .../post-installation.ts | 96 ++- .../project-config.ts | 0 .../helpers/{setup => core}/runtime-setup.ts | 63 -- .../template-manager.ts | 171 +++-- .../helpers/database-providers/d1-setup.ts | 51 +- .../docker-compose-setup.ts | 5 +- .../database-providers/mongodb-atlas-setup.ts | 5 +- .../helpers/database-providers/neon-setup.ts | 5 +- .../prisma-postgres-setup.ts | 8 +- .../database-providers/supabase-setup.ts | 5 +- .../helpers/database-providers/turso-setup.ts | 5 +- .../alchemy/alchemy-combined-setup.ts | 66 ++ .../deployment/alchemy/alchemy-next-setup.ts | 30 + .../deployment/alchemy/alchemy-nuxt-setup.ts | 104 +++ .../alchemy/alchemy-react-router-setup.ts | 168 +++++ .../deployment/alchemy/alchemy-solid-setup.ts | 30 + .../alchemy/alchemy-svelte-setup.ts | 98 +++ .../alchemy/alchemy-tanstack-router-setup.ts | 30 + .../alchemy/alchemy-tanstack-start-setup.ts | 158 ++++ .../src/helpers/deployment/alchemy/index.ts | 7 + .../helpers/deployment/server-deploy-setup.ts | 111 +++ .../helpers/deployment/web-deploy-setup.ts | 94 +++ .../deployment/workers/workers-next-setup.ts | 34 + .../workers}/workers-nuxt-setup.ts | 6 +- .../workers}/workers-svelte-setup.ts | 6 +- .../workers}/workers-tanstack-start-setup.ts | 6 +- .../workers}/workers-vite-setup.ts | 4 +- apps/cli/src/helpers/setup/api-setup.ts | 284 -------- .../cli/src/helpers/setup/web-deploy-setup.ts | 93 --- apps/cli/src/index.ts | 7 +- apps/cli/src/prompts/config-prompts.ts | 11 + apps/cli/src/prompts/project-name.ts | 4 +- apps/cli/src/prompts/server-deploy.ts | 129 ++++ apps/cli/src/prompts/web-deploy.ts | 41 +- apps/cli/src/types.ts | 11 +- apps/cli/src/utils/analytics.ts | 12 +- apps/cli/src/utils/bts-config.ts | 6 +- apps/cli/src/utils/compatibility-rules.ts | 45 ++ apps/cli/src/utils/config-processing.ts | 134 ++++ apps/cli/src/utils/config-validation.ts | 333 +++++++++ apps/cli/src/utils/display-config.ts | 6 + apps/cli/src/utils/format-with-biome.ts | 61 ++ .../utils/generate-reproducible-command.ts | 1 + apps/cli/src/utils/project-directory.ts | 5 +- apps/cli/src/utils/project-name-validation.ts | 47 ++ apps/cli/src/validation.ts | 537 ++------------ .../cli/templates/addons/biome/biome.json.hbs | 1 + .../addons/ruler/.ruler/mcp.json.hbs | 2 +- .../templates/addons/ultracite/biome.json.hbs | 1 + .../auth/server/base/src/lib/auth.ts.hbs | 41 +- .../backend/server/server-base/_gitignore | 1 + .../server/server-base/tsconfig.json.hbs | 2 +- apps/cli/templates/base/_gitignore | 2 + .../db/drizzle/sqlite/drizzle.config.ts.hbs | 2 + .../deploy/alchemy/alchemy.run.ts.hbs | 208 ++++++ .../cli/templates/deploy/alchemy/env.d.ts.hbs | 20 + .../deploy/alchemy/wrangler.jsonc.hbs | 11 + .../wrangler}/server/wrangler.jsonc.hbs | 0 .../web/nuxt/wrangler.jsonc.hbs | 2 +- .../web/react/next/open-next.config.ts | 0 .../web/react/next/wrangler.jsonc.hbs | 2 +- .../react/react-router}/wrangler.jsonc.hbs | 2 +- .../react/tanstack-router}/wrangler.jsonc.hbs | 2 +- .../react/tanstack-start/wrangler.jsonc.hbs | 2 +- .../web/solid/wrangler.jsonc.hbs | 2 +- .../web/svelte/wrangler.jsonc.hbs | 2 +- apps/cli/templates/frontend/nuxt/_gitignore | 3 + .../templates/frontend/nuxt/tsconfig.json.hbs | 4 +- .../frontend/react/web-base/_gitignore | 1 + .../web-base/src/components/header.tsx.hbs | 1 - apps/cli/templates/frontend/solid/_gitignore | 1 + .../templates/frontend/solid/package.json.hbs | 1 - apps/cli/templates/frontend/svelte/_gitignore | 1 + .../frontend/svelte/package.json.hbs | 24 +- apps/cli/test/cli.smoke.test.ts | 614 ++++++++++++++-- apps/cli/test/programmatic-api.test.ts | 38 +- apps/web/.gitignore | 1 + apps/web/content/docs/cli/compatibility.mdx | 4 +- apps/web/content/docs/cli/index.mdx | 11 +- apps/web/content/docs/cli/options.mdx | 41 +- .../web/content/docs/cli/programmatic-api.mdx | 1 + apps/web/content/docs/index.mdx | 1 - apps/web/content/docs/project-structure.mdx | 3 +- apps/web/next.config.mjs | 1 + apps/web/package.json | 1 + apps/web/public/schema.json | 178 ----- apps/web/scripts/generate-schema.ts | 8 + .../_components/customizable-section.tsx | 2 +- .../web/src/app/(home)/_components/navbar.tsx | 4 +- .../(home)/_components/sponsors-section.tsx | 274 ++++--- .../app/(home)/_components/stack-builder.tsx | 184 ++++- .../app/(home)/_components/testimonials.tsx | 378 +++++----- .../_components/addons-examples-charts.tsx | 8 +- .../_components/analytics-header.tsx | 14 +- .../_components/dev-environment-charts.tsx | 16 +- .../analytics/_components/metrics-cards.tsx | 16 +- .../stack-configuration-charts.tsx | 28 +- .../analytics/_components/timeline-charts.tsx | 12 +- .../showcase/_components/ShowcaseItem.tsx | 10 +- apps/web/src/app/(home)/showcase/page.tsx | 204 +++--- apps/web/src/app/layout.config.tsx | 2 +- apps/web/src/components/providers.tsx | 1 - apps/web/src/lib/constant.ts | 45 +- apps/web/src/lib/stack-url-state.ts | 4 + apps/web/src/lib/types.ts | 1 + biome.json | 9 +- bun.lock | 393 +++++----- changelogithub.config.ts | 5 + package.json | 15 +- packages/backend/convex/_generated/api.d.ts | 8 + .../backend/convex/_generated/dataModel.d.ts | 34 +- packages/backend/convex/hooks.ts | 4 + packages/backend/convex/schema.ts | 39 + packages/backend/convex/showcase.ts | 20 + packages/backend/convex/sponsors.ts | 30 + packages/backend/convex/testimonials.ts | 31 + packages/backend/package.json | 3 +- scripts/bump-version.ts | 94 +++ scripts/canary-release.ts | 239 +++++++ scripts/release.ts | 38 + tsconfig.json | 29 + 152 files changed, 5804 insertions(+), 2264 deletions(-) delete mode 100644 .changeset/README.md delete mode 100644 .changeset/config.json create mode 100644 .cursor/rules/convex_rules.mdc create mode 100644 .cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc delete mode 100644 .github/workflows/preview.yaml rename apps/cli/src/helpers/{setup => addons}/addons-setup.ts (100%) rename apps/cli/src/helpers/{setup => addons}/auth-setup.ts (100%) rename apps/cli/src/helpers/{setup => addons}/examples-setup.ts (100%) rename apps/cli/src/helpers/{setup => addons}/fumadocs-setup.ts (100%) rename apps/cli/src/helpers/{setup => addons}/ruler-setup.ts (98%) rename apps/cli/src/helpers/{setup => addons}/starlight-setup.ts (100%) rename apps/cli/src/helpers/{setup => addons}/tauri-setup.ts (95%) rename apps/cli/src/helpers/{setup => addons}/ultracite-setup.ts (100%) rename apps/cli/src/helpers/{setup => addons}/vite-pwa-setup.ts (100%) rename apps/cli/src/helpers/{project-generation => core}/add-addons.ts (96%) rename apps/cli/src/helpers/{project-generation => core}/add-deployment.ts (62%) create mode 100644 apps/cli/src/helpers/core/api-setup.ts rename apps/cli/src/helpers/{setup => core}/backend-setup.ts (100%) rename apps/cli/src/helpers/{project-generation => core}/command-handlers.ts (80%) create mode 100644 apps/cli/src/helpers/core/convex-codegen.ts rename apps/cli/src/helpers/{project-generation => core}/create-project.ts (78%) rename apps/cli/src/helpers/{project-generation => core}/create-readme.ts (100%) rename apps/cli/src/helpers/{setup => core}/db-setup.ts (100%) rename apps/cli/src/helpers/{project-generation => core}/detect-project-config.ts (96%) rename apps/cli/src/helpers/{project-generation => core}/env-setup.ts (80%) rename apps/cli/src/helpers/{project-generation => core}/git.ts (100%) rename apps/cli/src/helpers/{project-generation => core}/install-dependencies.ts (100%) rename apps/cli/src/helpers/{project-generation => core}/post-installation.ts (80%) rename apps/cli/src/helpers/{project-generation => core}/project-config.ts (100%) rename apps/cli/src/helpers/{setup => core}/runtime-setup.ts (53%) rename apps/cli/src/helpers/{project-generation => core}/template-manager.ts (86%) create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-combined-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts create mode 100644 apps/cli/src/helpers/deployment/alchemy/index.ts create mode 100644 apps/cli/src/helpers/deployment/server-deploy-setup.ts create mode 100644 apps/cli/src/helpers/deployment/web-deploy-setup.ts create mode 100644 apps/cli/src/helpers/deployment/workers/workers-next-setup.ts rename apps/cli/src/helpers/{setup => deployment/workers}/workers-nuxt-setup.ts (94%) rename apps/cli/src/helpers/{setup => deployment/workers}/workers-svelte-setup.ts (91%) rename apps/cli/src/helpers/{setup => deployment/workers}/workers-tanstack-start-setup.ts (90%) rename apps/cli/src/helpers/{setup => deployment/workers}/workers-vite-setup.ts (92%) delete mode 100644 apps/cli/src/helpers/setup/api-setup.ts delete mode 100644 apps/cli/src/helpers/setup/web-deploy-setup.ts create mode 100644 apps/cli/src/prompts/server-deploy.ts create mode 100644 apps/cli/src/utils/config-processing.ts create mode 100644 apps/cli/src/utils/config-validation.ts create mode 100644 apps/cli/src/utils/format-with-biome.ts create mode 100644 apps/cli/src/utils/project-name-validation.ts create mode 100644 apps/cli/templates/deploy/alchemy/alchemy.run.ts.hbs create mode 100644 apps/cli/templates/deploy/alchemy/env.d.ts.hbs create mode 100644 apps/cli/templates/deploy/alchemy/wrangler.jsonc.hbs rename apps/cli/templates/{runtime/workers/apps => deploy/wrangler}/server/wrangler.jsonc.hbs (100%) rename apps/cli/templates/deploy/{ => wrangler}/web/nuxt/wrangler.jsonc.hbs (96%) rename apps/cli/templates/deploy/{ => wrangler}/web/react/next/open-next.config.ts (100%) rename apps/cli/templates/deploy/{ => wrangler}/web/react/next/wrangler.jsonc.hbs (91%) rename apps/cli/templates/deploy/{web/react/tanstack-router => wrangler/web/react/react-router}/wrangler.jsonc.hbs (69%) rename apps/cli/templates/deploy/{web/react/react-router => wrangler/web/react/tanstack-router}/wrangler.jsonc.hbs (69%) rename apps/cli/templates/deploy/{ => wrangler}/web/react/tanstack-start/wrangler.jsonc.hbs (86%) rename apps/cli/templates/deploy/{ => wrangler}/web/solid/wrangler.jsonc.hbs (68%) rename apps/cli/templates/deploy/{ => wrangler}/web/svelte/wrangler.jsonc.hbs (96%) delete mode 100644 apps/web/public/schema.json create mode 100644 changelogithub.config.ts create mode 100644 packages/backend/convex/hooks.ts create mode 100644 packages/backend/convex/schema.ts create mode 100644 packages/backend/convex/showcase.ts create mode 100644 packages/backend/convex/sponsors.ts create mode 100644 packages/backend/convex/testimonials.ts create mode 100644 scripts/bump-version.ts create mode 100644 scripts/canary-release.ts create mode 100644 scripts/release.ts create mode 100644 tsconfig.json diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index e5b6d8d..0000000 --- a/.changeset/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Changesets - -Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works -with multi-package repos, or single-package repos to help you version and publish your code. You can -find the full documentation for it [in our repository](https://github.com/changesets/changesets) - -We have a quick list of common questions to get you started engaging with this project in -[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json deleted file mode 100644 index 1bd913b..0000000 --- a/.changeset/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": ["@better-t-stack/backend", "web"] -} \ No newline at end of file diff --git a/.cursor/rules/convex_rules.mdc b/.cursor/rules/convex_rules.mdc new file mode 100644 index 0000000..3e60ff9 --- /dev/null +++ b/.cursor/rules/convex_rules.mdc @@ -0,0 +1,674 @@ +--- +alwaysApply: true +--- +# Convex guidelines +## Function guidelines +### New function syntax +- ALWAYS use the new function syntax for Convex functions. For example: +```typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; +export const f = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + // Function body + }, +}); +``` + +### Http endpoint syntax +- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: +```typescript +import { httpRouter } from "convex/server"; +import { httpAction } from "./_generated/server"; +const http = httpRouter(); +http.route({ + path: "/echo", + method: "POST", + handler: httpAction(async (ctx, req) => { + const body = await req.bytes(); + return new Response(body, { status: 200 }); + }), +}); +``` +- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. + +### Validators +- Below is an example of an array validator: +```typescript +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export default mutation({ +args: { + simpleArray: v.array(v.union(v.string(), v.number())), +}, +handler: async (ctx, args) => { + //... +}, +}); +``` +- Below is an example of a schema with validators that codify a discriminated union type: +```typescript +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + results: defineTable( + v.union( + v.object({ + kind: v.literal("error"), + errorMessage: v.string(), + }), + v.object({ + kind: v.literal("success"), + value: v.number(), + }), + ), + ) +}); +``` +- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value: +```typescript +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const exampleQuery = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("This query returns a null value"); + return null; + }, +}); +``` +- Here are the valid Convex types along with their respective validators: +Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | +| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Id | string | `doc._id` | `v.id(tableName)` | | +| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | +| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | +| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | +| Boolean | boolean | `true` | `v.boolean()` | +| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | +| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | +| Array | Array] | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | +| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | +| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". | + +### Function registration +- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. +- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. +- You CANNOT register a function through the `api` or `internal` objects. +- ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator. +- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`. + +### Function calling +- Use `ctx.runQuery` to call a query from a query, mutation, or action. +- Use `ctx.runMutation` to call a mutation from a mutation or action. +- Use `ctx.runAction` to call an action from an action. +- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. +- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. +- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. +- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, +``` +export const f = query({ + args: { name: v.string() }, + returns: v.string(), + handler: async (ctx, args) => { + return "Hello " + args.name; + }, +}); + +export const g = query({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); + return null; + }, +}); +``` + +### Function references +- Function references are pointers to registered Convex functions. +- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. +- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. +- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. +- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. +- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. + +### Api design +- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory. +- Use `query`, `mutation`, and `action` to define public functions. +- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions. + +### Pagination +- Paginated queries are queries that return a list of results in incremental pages. +- You can define pagination using the following syntax: + +```ts +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; +export const listWithExtraArg = query({ + args: { paginationOpts: paginationOptsValidator, author: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .filter((q) => q.eq(q.field("author"), args.author)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); +``` +Note: `paginationOpts` is an object with the following properties: +- `numItems`: the maximum number of documents to return (the validator is `v.number()`) +- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) +- A query that ends in `.paginate()` returns an object that has the following properties: + - page (contains an array of documents that you fetches) + - isDone (a boolean that represents whether or not this is the last page of documents) + - continueCursor (a string that represents the cursor to use to fetch the next page of documents) + + +## Validator guidelines +- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead. +- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported. + +## Schema guidelines +- Always define your schema in `convex/schema.ts`. +- Always import the schema definition functions from `convex/server`: +- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. +- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". +- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. + +## Typescript guidelines +- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. +- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: +```ts +import { query } from "./_generated/server"; +import { Doc, Id } from "./_generated/dataModel"; + +export const exampleQuery = query({ + args: { userIds: v.array(v.id("users")) }, + returns: v.record(v.id("users"), v.string()), + handler: async (ctx, args) => { + const idToUsername: Record, string> = {}; + for (const userId of args.userIds) { + const user = await ctx.db.get(userId); + if (user) { + idToUsername[user._id] = user.username; + } + } + + return idToUsername; + }, +}); +``` +- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. +- Always use `as const` for string literals in discriminated union types. +- When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];` +- When using the `Record` type, make sure to always define your records as `const record: Record = {...};` +- Always add `@types/node` to your `package.json` when using any Node.js built-in modules. + +## Full text search guidelines +- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: + +const messages = await ctx.db + .query("messages") + .withSearchIndex("search_body", (q) => + q.search("body", "hello hi").eq("channel", "#general"), + ) + .take(10); + +## Query guidelines +- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. +- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result. +- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. +- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. +### Ordering +- By default Convex always returns documents in ascending `_creationTime` order. +- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. +- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. + + +## Mutation guidelines +- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. +- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. + +## Action guidelines +- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. +- Never use `ctx.db` inside of an action. Actions don't have access to the database. +- Below is an example of the syntax for an action: +```ts +import { action } from "./_generated/server"; + +export const exampleAction = action({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("This action does not return anything"); + return null; + }, +}); +``` + +## Scheduling guidelines +### Cron guidelines +- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. +- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. +- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, +```ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; +import { internalAction } from "./_generated/server"; + +const empty = internalAction({ + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("empty"); + }, +}); + +const crons = cronJobs(); + +// Run `internal.crons.empty` every two hours. +crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); + +export default crons; +``` +- You can register Convex functions within `crons.ts` just like any other file. +- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file. + + +## File storage guidelines +- Convex includes file storage for large files like images, videos, and PDFs. +- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. +- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. + + Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. +``` +import { query } from "./_generated/server"; +import { Id } from "./_generated/dataModel"; + +type FileMetadata = { + _id: Id<"_storage">; + _creationTime: number; + contentType?: string; + sha256: string; + size: number; +} + +export const exampleQuery = query({ + args: { fileId: v.id("_storage") }, + returns: v.null(), + handler: async (ctx, args) => { + const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId); + console.log(metadata); + return null; + }, +}); +``` +- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. + + +# Examples: +## Example: chat-app + +### Task +``` +Create a real-time chat application backend with AI responses. The app should: +- Allow creating users with names +- Support multiple chat channels +- Enable users to send messages to channels +- Automatically generate AI responses to user messages +- Show recent message history + +The backend should provide APIs for: +1. User management (creation) +2. Channel management (creation) +3. Message operations (sending, listing) +4. AI response generation using OpenAI's GPT-4 + +Messages should be stored with their channel, author, and content. The system should maintain message order +and limit history display to the 10 most recent messages per channel. + +``` + +### Analysis +1. Task Requirements Summary: +- Build a real-time chat backend with AI integration +- Support user creation +- Enable channel-based conversations +- Store and retrieve messages with proper ordering +- Generate AI responses automatically + +2. Main Components Needed: +- Database tables: users, channels, messages +- Public APIs for user/channel management +- Message handling functions +- Internal AI response generation system +- Context loading for AI responses + +3. Public API and Internal Functions Design: +Public Mutations: +- createUser: + - file path: convex/index.ts + - arguments: {name: v.string()} + - returns: v.object({userId: v.id("users")}) + - purpose: Create a new user with a given name +- createChannel: + - file path: convex/index.ts + - arguments: {name: v.string()} + - returns: v.object({channelId: v.id("channels")}) + - purpose: Create a new channel with a given name +- sendMessage: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()} + - returns: v.null() + - purpose: Send a message to a channel and schedule a response from the AI + +Public Queries: +- listMessages: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels")} + - returns: v.array(v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + })) + - purpose: List the 10 most recent messages from a channel in descending creation order + +Internal Functions: +- generateResponse: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels")} + - returns: v.null() + - purpose: Generate a response from the AI for a given channel +- loadContext: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels")} + - returns: v.array(v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + })) +- writeAgentResponse: + - file path: convex/index.ts + - arguments: {channelId: v.id("channels"), content: v.string()} + - returns: v.null() + - purpose: Write an AI response to a given channel + +4. Schema Design: +- users + - validator: { name: v.string() } + - indexes: +- channels + - validator: { name: v.string() } + - indexes: +- messages + - validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() } + - indexes + - by_channel: ["channelId"] + +5. Background Processing: +- AI response generation runs asynchronously after each user message +- Uses OpenAI's GPT-4 to generate contextual responses +- Maintains conversation context using recent message history + + +### Implementation + +#### package.json +```typescript +{ + "name": "chat-app", + "description": "This example shows how to build a chat app without authentication.", + "version": "1.0.0", + "dependencies": { + "convex": "^1.17.4", + "openai": "^4.79.0" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} +``` + +#### tsconfig.json +```typescript +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "exclude": ["convex"], + "include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"] +} +``` + +#### convex/index.ts +```typescript +import { + query, + mutation, + internalQuery, + internalMutation, + internalAction, +} from "./_generated/server"; +import { v } from "convex/values"; +import OpenAI from "openai"; +import { internal } from "./_generated/api"; + +/** + * Create a user with a given name. + */ +export const createUser = mutation({ + args: { + name: v.string(), + }, + returns: v.id("users"), + handler: async (ctx, args) => { + return await ctx.db.insert("users", { name: args.name }); + }, +}); + +/** + * Create a channel with a given name. + */ +export const createChannel = mutation({ + args: { + name: v.string(), + }, + returns: v.id("channels"), + handler: async (ctx, args) => { + return await ctx.db.insert("channels", { name: args.name }); + }, +}); + +/** + * List the 10 most recent messages from a channel in descending creation order. + */ +export const listMessages = query({ + args: { + channelId: v.id("channels"), + }, + returns: v.array( + v.object({ + _id: v.id("messages"), + _creationTime: v.number(), + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + }), + ), + handler: async (ctx, args) => { + const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) + .order("desc") + .take(10); + return messages; + }, +}); + +/** + * Send a message to a channel and schedule a response from the AI. + */ +export const sendMessage = mutation({ + args: { + channelId: v.id("channels"), + authorId: v.id("users"), + content: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + const channel = await ctx.db.get(args.channelId); + if (!channel) { + throw new Error("Channel not found"); + } + const user = await ctx.db.get(args.authorId); + if (!user) { + throw new Error("User not found"); + } + await ctx.db.insert("messages", { + channelId: args.channelId, + authorId: args.authorId, + content: args.content, + }); + await ctx.scheduler.runAfter(0, internal.index.generateResponse, { + channelId: args.channelId, + }); + return null; + }, +}); + +const openai = new OpenAI(); + +export const generateResponse = internalAction({ + args: { + channelId: v.id("channels"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const context = await ctx.runQuery(internal.index.loadContext, { + channelId: args.channelId, + }); + const response = await openai.chat.completions.create({ + model: "gpt-4o", + messages: context, + }); + const content = response.choices[0].message.content; + if (!content) { + throw new Error("No content in response"); + } + await ctx.runMutation(internal.index.writeAgentResponse, { + channelId: args.channelId, + content, + }); + return null; + }, +}); + +export const loadContext = internalQuery({ + args: { + channelId: v.id("channels"), + }, + returns: v.array( + v.object({ + role: v.union(v.literal("user"), v.literal("assistant")), + content: v.string(), + }), + ), + handler: async (ctx, args) => { + const channel = await ctx.db.get(args.channelId); + if (!channel) { + throw new Error("Channel not found"); + } + const messages = await ctx.db + .query("messages") + .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) + .order("desc") + .take(10); + + const result = []; + for (const message of messages) { + if (message.authorId) { + const user = await ctx.db.get(message.authorId); + if (!user) { + throw new Error("User not found"); + } + result.push({ + role: "user" as const, + content: `${user.name}: ${message.content}`, + }); + } else { + result.push({ role: "assistant" as const, content: message.content }); + } + } + return result; + }, +}); + +export const writeAgentResponse = internalMutation({ + args: { + channelId: v.id("channels"), + content: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.insert("messages", { + channelId: args.channelId, + content: args.content, + }); + return null; + }, +}); +``` + +#### convex/schema.ts +```typescript +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + channels: defineTable({ + name: v.string(), + }), + + users: defineTable({ + name: v.string(), + }), + + messages: defineTable({ + channelId: v.id("channels"), + authorId: v.optional(v.id("users")), + content: v.string(), + }).index("by_channel", ["channelId"]), +}); +``` + +#### src/App.tsx +```typescript +export default function App() { + return
Hello World
; +} +``` + diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 100644 index 0000000..91eaf78 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1,108 @@ +--- +alwaysApply: true +--- +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml deleted file mode 100644 index 2321e93..0000000 --- a/.github/workflows/preview.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: Preview - -on: - pull_request: - paths: - - "apps/cli/**" - - "package.json" - - "bun.lock" - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - preview: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Publish Preview Package - run: bunx pkg-pr-new publish './apps/cli' --bin \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a31d6f2..d152f7d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,17 +1,12 @@ name: Release +permissions: + contents: write + on: push: - branches: - - main - - beta - paths: - - "apps/cli/**" - - ".changeset/**" - - "package.json" - - "bun.lock" - -concurrency: ${{ github.workflow }}-${{ github.ref }} + tags: + - 'v*' jobs: release: @@ -30,14 +25,19 @@ jobs: - name: Install Dependencies run: bun install --frozen-lockfile - - name: Create Release Pull Request or Publish - id: changesets - uses: changesets/action@v1 - with: - publish: bun run publish-packages + - name: Build CLI + run: bun run build:cli env: BTS_TELEMETRY: 1 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} - POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} \ No newline at end of file + POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + + - name: Generate Changelog + run: bun run release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish CLI to NPM + run: cd apps/cli && bun publish --access public + env: + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/apps/cli/README.md b/apps/cli/README.md index 419039b..6001e74 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -66,7 +66,8 @@ Options: --install Install dependencies --no-install Skip installing dependencies --db-setup Database setup (turso, d1, neon, supabase, prisma-postgres, mongodb-atlas, docker, none) - --web-deploy Web deployment (workers, none) + --web-deploy Web deployment (workers, alchemy, none) + --server-deploy Server deployment (workers, alchemy, none) --backend Backend framework (hono, express, elysia, next, convex, fastify, none) --runtime Runtime (bun, node, workers, none) --api API type (trpc, orpc, none) diff --git a/apps/cli/package.json b/apps/cli/package.json index cfadd08..d88b715 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -52,7 +52,8 @@ "dev": "tsdown --watch", "check-types": "tsc --noEmit", "check": "biome check --write .", - "test": "bun run build && vitest --ui", + "test": "bun run build && vitest run", + "test:ui": "bun run build && vitest --ui", "test:with-build": "bun run build && WITH_BUILD=1 vitest --ui", "prepublishOnly": "npm run build" }, @@ -63,22 +64,24 @@ } }, "dependencies": { + "@biomejs/js-api": "^3.0.0", + "@biomejs/wasm-nodejs": "^2.2.0", "@clack/prompts": "^0.11.0", "consola": "^3.4.2", "execa": "^9.6.0", "fs-extra": "^11.3.1", - "globby": "^14.1.0", "gradient-string": "^3.0.0", "handlebars": "^4.7.8", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", + "tinyglobby": "^0.2.14", "trpc-cli": "^0.10.2", "ts-morph": "^26.0.0", "zod": "^4.0.17" }, "devDependencies": { "@types/fs-extra": "^11.0.4", - "@types/node": "^24.2.1", + "@types/node": "^24.3.0", "@vitest/ui": "^3.2.4", "tsdown": "^0.14.1", "typescript": "^5.9.2", diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index 20af73c..be157c0 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -7,25 +7,37 @@ const __filename = fileURLToPath(import.meta.url); const distPath = path.dirname(__filename); export const PKG_ROOT = path.join(distPath, "../"); -export const DEFAULT_CONFIG: ProjectConfig = { +export const DEFAULT_CONFIG_BASE = { projectName: "my-better-t-app", - projectDir: path.resolve(process.cwd(), "my-better-t-app"), relativePath: "my-better-t-app", - frontend: ["tanstack-router"], - database: "sqlite", - orm: "drizzle", + frontend: ["tanstack-router"] as const, + database: "sqlite" as const, + orm: "drizzle" as const, auth: true, - addons: ["turborepo"], - examples: [], + addons: ["turborepo"] as const, + examples: [] as const, git: true, - packageManager: getUserPkgManager(), install: true, - dbSetup: "none", - backend: "hono", - runtime: "bun", - api: "trpc", - webDeploy: "none", -}; + dbSetup: "none" as const, + backend: "hono" as const, + runtime: "bun" as const, + api: "trpc" as const, + webDeploy: "none" as const, + serverDeploy: "none" as const, +} as const; + +export function getDefaultConfig(): ProjectConfig { + 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 const dependencyVersionMap = { "better-auth": "^1.3.4", @@ -106,7 +118,8 @@ export const dependencyVersionMap = { "convex-nuxt": "0.1.5", "convex-vue": "^0.1.5", - "@tanstack/svelte-query": "^5.74.4", + "@tanstack/svelte-query": "^5.85.3", + "@tanstack/svelte-query-devtools": "^5.85.3", "@tanstack/vue-query-devtools": "^5.83.0", "@tanstack/vue-query": "^5.83.0", @@ -116,19 +129,27 @@ export const dependencyVersionMap = { "@tanstack/solid-query": "^5.75.0", "@tanstack/solid-query-devtools": "^5.75.0", + "@tanstack/solid-router-devtools": "^1.131.25", wrangler: "^4.23.0", "@cloudflare/vite-plugin": "^1.9.0", "@opennextjs/cloudflare": "^1.3.0", "nitro-cloudflare-dev": "^0.2.2", - "@sveltejs/adapter-cloudflare": "^7.0.4", + "@sveltejs/adapter-cloudflare": "^7.2.1", + "@cloudflare/workers-types": "^4.20250813.0", + + alchemy: "^0.62.1", + // temporary workaround for alchemy + tanstack start + nitropack: "^2.12.4", + + dotenv: "^17.2.1", } as const; export type AvailableDependencies = keyof typeof dependencyVersionMap; export const ADDON_COMPATIBILITY: Record = { pwa: ["tanstack-router", "react-router", "solid", "next"], - tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"], + tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid", "next"], biome: [], husky: [], turborepo: [], diff --git a/apps/cli/src/helpers/setup/addons-setup.ts b/apps/cli/src/helpers/addons/addons-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/addons-setup.ts rename to apps/cli/src/helpers/addons/addons-setup.ts diff --git a/apps/cli/src/helpers/setup/auth-setup.ts b/apps/cli/src/helpers/addons/auth-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/auth-setup.ts rename to apps/cli/src/helpers/addons/auth-setup.ts diff --git a/apps/cli/src/helpers/setup/examples-setup.ts b/apps/cli/src/helpers/addons/examples-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/examples-setup.ts rename to apps/cli/src/helpers/addons/examples-setup.ts diff --git a/apps/cli/src/helpers/setup/fumadocs-setup.ts b/apps/cli/src/helpers/addons/fumadocs-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/fumadocs-setup.ts rename to apps/cli/src/helpers/addons/fumadocs-setup.ts diff --git a/apps/cli/src/helpers/setup/ruler-setup.ts b/apps/cli/src/helpers/addons/ruler-setup.ts similarity index 98% rename from apps/cli/src/helpers/setup/ruler-setup.ts rename to apps/cli/src/helpers/addons/ruler-setup.ts index 7885e36..c22b8d1 100644 --- a/apps/cli/src/helpers/setup/ruler-setup.ts +++ b/apps/cli/src/helpers/addons/ruler-setup.ts @@ -7,7 +7,7 @@ import { PKG_ROOT } from "../../constants"; import type { ProjectConfig } from "../../types"; import { exitCancelled } from "../../utils/errors"; import { getPackageExecutionCommand } from "../../utils/package-runner"; -import { processAndCopyFiles } from "../project-generation/template-manager"; +import { processAndCopyFiles } from "../core/template-manager"; export async function setupVibeRules(config: ProjectConfig) { const { packageManager, projectDir } = config; diff --git a/apps/cli/src/helpers/setup/starlight-setup.ts b/apps/cli/src/helpers/addons/starlight-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/starlight-setup.ts rename to apps/cli/src/helpers/addons/starlight-setup.ts diff --git a/apps/cli/src/helpers/setup/tauri-setup.ts b/apps/cli/src/helpers/addons/tauri-setup.ts similarity index 95% rename from apps/cli/src/helpers/setup/tauri-setup.ts rename to apps/cli/src/helpers/addons/tauri-setup.ts index 9e54281..3bec1c4 100644 --- a/apps/cli/src/helpers/setup/tauri-setup.ts +++ b/apps/cli/src/helpers/addons/tauri-setup.ts @@ -69,8 +69,8 @@ export async function setupTauri(config: ProjectConfig) { `--window-title=${path.basename(projectDir)}`, `--frontend-dist=${frontendDist}`, `--dev-url=${devUrl}`, - `--before-dev-command=\"${packageManager} run dev\"`, - `--before-build-command=\"${packageManager} run build\"`, + `--before-dev-command="${packageManager} run dev"`, + `--before-build-command="${packageManager} run build"`, ]; const tauriArgsString = tauriArgs.join(" "); diff --git a/apps/cli/src/helpers/setup/ultracite-setup.ts b/apps/cli/src/helpers/addons/ultracite-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/ultracite-setup.ts rename to apps/cli/src/helpers/addons/ultracite-setup.ts diff --git a/apps/cli/src/helpers/setup/vite-pwa-setup.ts b/apps/cli/src/helpers/addons/vite-pwa-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/vite-pwa-setup.ts rename to apps/cli/src/helpers/addons/vite-pwa-setup.ts diff --git a/apps/cli/src/helpers/project-generation/add-addons.ts b/apps/cli/src/helpers/core/add-addons.ts similarity index 96% rename from apps/cli/src/helpers/project-generation/add-addons.ts rename to apps/cli/src/helpers/core/add-addons.ts index 9329990..a3279e9 100644 --- a/apps/cli/src/helpers/project-generation/add-addons.ts +++ b/apps/cli/src/helpers/core/add-addons.ts @@ -5,7 +5,7 @@ import type { AddInput, Addons, ProjectConfig } from "../../types"; import { validateAddonCompatibility } from "../../utils/addon-compatibility"; import { updateBtsConfig } from "../../utils/bts-config"; import { exitWithError } from "../../utils/errors"; -import { setupAddons } from "../setup/addons-setup"; +import { setupAddons } from "../addons/addons-setup"; import { detectProjectConfig, isBetterTStackProject, @@ -52,6 +52,7 @@ export async function addAddonsToProject( dbSetup: detectedConfig.dbSetup || "none", api: detectedConfig.api || "none", webDeploy: detectedConfig.webDeploy || "none", + serverDeploy: detectedConfig.serverDeploy || "none", }; for (const addon of input.addons) { diff --git a/apps/cli/src/helpers/project-generation/add-deployment.ts b/apps/cli/src/helpers/core/add-deployment.ts similarity index 62% rename from apps/cli/src/helpers/project-generation/add-deployment.ts rename to apps/cli/src/helpers/core/add-deployment.ts index 7906ff0..62b0f32 100644 --- a/apps/cli/src/helpers/project-generation/add-deployment.ts +++ b/apps/cli/src/helpers/core/add-deployment.ts @@ -1,10 +1,16 @@ import path from "node:path"; import { log } from "@clack/prompts"; import pc from "picocolors"; -import type { AddInput, ProjectConfig, WebDeploy } from "../../types"; +import type { + AddInput, + ProjectConfig, + ServerDeploy, + WebDeploy, +} from "../../types"; import { updateBtsConfig } from "../../utils/bts-config"; import { exitWithError } from "../../utils/errors"; -import { setupWebDeploy } from "../setup/web-deploy-setup"; +import { setupServerDeploy } from "../deployment/server-deploy-setup"; +import { setupWebDeploy } from "../deployment/web-deploy-setup"; import { detectProjectConfig, isBetterTStackProject, @@ -13,7 +19,11 @@ import { installDependencies } from "./install-dependencies"; import { setupDeploymentTemplates } from "./template-manager"; export async function addDeploymentToProject( - input: AddInput & { webDeploy: WebDeploy; suppressInstallMessage?: boolean }, + input: AddInput & { + webDeploy?: WebDeploy; + serverDeploy?: ServerDeploy; + suppressInstallMessage?: boolean; + }, ) { try { const projectDir = input.projectDir || process.cwd(); @@ -32,9 +42,18 @@ export async function addDeploymentToProject( ); } - if (detectedConfig.webDeploy === input.webDeploy) { + if (input.webDeploy && detectedConfig.webDeploy === input.webDeploy) { exitWithError( - `${input.webDeploy} deployment is already configured for this project.`, + `${input.webDeploy} web deployment is already configured for this project.`, + ); + } + + if ( + input.serverDeploy && + detectedConfig.serverDeploy === input.serverDeploy + ) { + exitWithError( + `${input.serverDeploy} server deployment is already configured for this project.`, ); } @@ -56,19 +75,30 @@ export async function addDeploymentToProject( install: input.install || false, dbSetup: detectedConfig.dbSetup || "none", api: detectedConfig.api || "none", - webDeploy: input.webDeploy, + webDeploy: input.webDeploy || detectedConfig.webDeploy || "none", + serverDeploy: input.serverDeploy || detectedConfig.serverDeploy || "none", }; - log.info( - pc.green( - `Adding ${input.webDeploy} deployment to ${config.frontend.join("/")}`, - ), - ); + if (input.webDeploy && input.webDeploy !== "none") { + log.info( + pc.green( + `Adding ${input.webDeploy} web deployment to ${config.frontend.join("/")}`, + ), + ); + } + + if (input.serverDeploy && input.serverDeploy !== "none") { + log.info(pc.green(`Adding ${input.serverDeploy} server deployment`)); + } await setupDeploymentTemplates(projectDir, config); await setupWebDeploy(config); + await setupServerDeploy(config); - await updateBtsConfig(projectDir, { webDeploy: input.webDeploy }); + await updateBtsConfig(projectDir, { + webDeploy: input.webDeploy || config.webDeploy, + serverDeploy: input.serverDeploy || config.serverDeploy, + }); if (config.install) { await installDependencies({ diff --git a/apps/cli/src/helpers/core/api-setup.ts b/apps/cli/src/helpers/core/api-setup.ts new file mode 100644 index 0000000..1c9ca0e --- /dev/null +++ b/apps/cli/src/helpers/core/api-setup.ts @@ -0,0 +1,303 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { AvailableDependencies } from "../../constants"; +import type { Frontend, ProjectConfig } from "../../types"; +import { addPackageDependency } from "../../utils/add-package-deps"; + +async function addBackendWorkspaceDependency( + projectDir: string, + backendPackageName: string, + workspaceVersion: string, +) { + const pkgJsonPath = path.join(projectDir, "package.json"); + try { + const pkgJson = await fs.readJson(pkgJsonPath); + if (!pkgJson.dependencies) { + pkgJson.dependencies = {}; + } + pkgJson.dependencies[backendPackageName] = workspaceVersion; + await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); + } catch (_error) {} +} + +function getFrontendType(frontend: Frontend[]): { + hasReactWeb: boolean; + hasNuxtWeb: boolean; + hasSvelteWeb: boolean; + hasSolidWeb: boolean; + hasNative: boolean; +} { + const reactBasedFrontends = [ + "tanstack-router", + "react-router", + "tanstack-start", + "next", + ]; + const nativeFrontends = ["native-nativewind", "native-unistyles"]; + + return { + hasReactWeb: frontend.some((f) => reactBasedFrontends.includes(f)), + hasNuxtWeb: frontend.includes("nuxt"), + hasSvelteWeb: frontend.includes("svelte"), + hasSolidWeb: frontend.includes("solid"), + hasNative: frontend.some((f) => nativeFrontends.includes(f)), + }; +} + +function getApiDependencies( + api: string, + frontendType: ReturnType, +) { + const deps: Record< + string, + { dependencies: string[]; devDependencies?: string[] } + > = {}; + + if (api === "orpc") { + deps.server = { dependencies: ["@orpc/server", "@orpc/client"] }; + } else if (api === "trpc") { + deps.server = { dependencies: ["@trpc/server", "@trpc/client"] }; + } + + if (frontendType.hasReactWeb) { + if (api === "orpc") { + deps.web = { dependencies: ["@orpc/tanstack-query", "@orpc/client"] }; + } else if (api === "trpc") { + deps.web = { + dependencies: [ + "@trpc/tanstack-react-query", + "@trpc/client", + "@trpc/server", + ], + }; + } + } else if (frontendType.hasNuxtWeb && api === "orpc") { + deps.web = { + dependencies: [ + "@tanstack/vue-query", + "@orpc/tanstack-query", + "@orpc/client", + ], + devDependencies: ["@tanstack/vue-query-devtools"], + }; + } else if (frontendType.hasSvelteWeb && api === "orpc") { + deps.web = { + dependencies: [ + "@orpc/tanstack-query", + "@orpc/client", + "@tanstack/svelte-query", + ], + devDependencies: ["@tanstack/svelte-query-devtools"], + }; + } else if (frontendType.hasSolidWeb && api === "orpc") { + deps.web = { + dependencies: [ + "@orpc/tanstack-query", + "@orpc/client", + "@tanstack/solid-query", + ], + devDependencies: [ + "@tanstack/solid-query-devtools", + "@tanstack/solid-router-devtools", + ], + }; + } + + if (api === "trpc") { + deps.native = { + dependencies: [ + "@trpc/tanstack-react-query", + "@trpc/client", + "@trpc/server", + ], + }; + } else if (api === "orpc") { + deps.native = { dependencies: ["@orpc/tanstack-query", "@orpc/client"] }; + } + + return deps; +} + +function getQueryDependencies(frontend: Frontend[]) { + const reactBasedFrontends: Frontend[] = [ + "react-router", + "tanstack-router", + "tanstack-start", + "next", + "native-nativewind", + "native-unistyles", + ]; + + const deps: Record< + string, + { dependencies: string[]; devDependencies?: string[] } + > = {}; + + const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f)); + if (needsReactQuery) { + const hasReactWeb = frontend.some( + (f) => + f !== "native-nativewind" && + f !== "native-unistyles" && + reactBasedFrontends.includes(f), + ); + const hasNative = + frontend.includes("native-nativewind") || + frontend.includes("native-unistyles"); + + if (hasReactWeb) { + deps.web = { + dependencies: ["@tanstack/react-query"], + devDependencies: ["@tanstack/react-query-devtools"], + }; + } + if (hasNative) { + deps.native = { dependencies: ["@tanstack/react-query"] }; + } + } + + if (frontend.includes("solid")) { + deps.web = { + dependencies: ["@tanstack/solid-query"], + devDependencies: [ + "@tanstack/solid-query-devtools", + "@tanstack/solid-router-devtools", + ], + }; + } + + return deps; +} + +function getConvexDependencies(frontend: Frontend[]) { + const deps: Record = { + web: { dependencies: ["convex"] }, + native: { dependencies: ["convex"] }, + }; + + if (frontend.includes("tanstack-start")) { + deps.web.dependencies.push("@convex-dev/react-query"); + } + if (frontend.includes("svelte")) { + deps.web.dependencies.push("convex-svelte"); + } + if (frontend.includes("nuxt")) { + deps.web.dependencies.push("convex-nuxt", "convex-vue"); + } + + return deps; +} + +export async function setupApi(config: ProjectConfig) { + const { api, projectName, frontend, backend, packageManager, projectDir } = + config; + const isConvex = backend === "convex"; + + const webDir = path.join(projectDir, "apps/web"); + const nativeDir = path.join(projectDir, "apps/native"); + const serverDir = path.join(projectDir, "apps/server"); + + const webDirExists = await fs.pathExists(webDir); + const nativeDirExists = await fs.pathExists(nativeDir); + const serverDirExists = await fs.pathExists(serverDir); + + const frontendType = getFrontendType(frontend); + + if (!isConvex && api !== "none") { + const apiDeps = getApiDependencies(api, frontendType); + + if (serverDirExists && apiDeps.server) { + await addPackageDependency({ + dependencies: apiDeps.server.dependencies as AvailableDependencies[], + projectDir: serverDir, + }); + + if (api === "trpc") { + if (backend === "hono") { + await addPackageDependency({ + dependencies: ["@hono/trpc-server"], + projectDir: serverDir, + }); + } else if (backend === "elysia") { + await addPackageDependency({ + dependencies: ["@elysiajs/trpc"], + projectDir: serverDir, + }); + } + } + } + + if (webDirExists && apiDeps.web) { + await addPackageDependency({ + dependencies: apiDeps.web.dependencies as AvailableDependencies[], + devDependencies: apiDeps.web.devDependencies as AvailableDependencies[], + projectDir: webDir, + }); + } + + if (nativeDirExists && apiDeps.native) { + await addPackageDependency({ + dependencies: apiDeps.native.dependencies as AvailableDependencies[], + projectDir: nativeDir, + }); + } + } + + if (!isConvex) { + const queryDeps = getQueryDependencies(frontend); + + if (webDirExists && queryDeps.web) { + await addPackageDependency({ + dependencies: queryDeps.web.dependencies as AvailableDependencies[], + devDependencies: queryDeps.web + .devDependencies as AvailableDependencies[], + projectDir: webDir, + }); + } + + if (nativeDirExists && queryDeps.native) { + await addPackageDependency({ + dependencies: queryDeps.native.dependencies as AvailableDependencies[], + projectDir: nativeDir, + }); + } + } + + if (isConvex) { + const convexDeps = getConvexDependencies(frontend); + + if (webDirExists) { + await addPackageDependency({ + dependencies: convexDeps.web.dependencies as AvailableDependencies[], + projectDir: webDir, + }); + } + + if (nativeDirExists) { + await addPackageDependency({ + dependencies: convexDeps.native.dependencies as AvailableDependencies[], + projectDir: nativeDir, + }); + } + + const backendPackageName = `@${projectName}/backend`; + const backendWorkspaceVersion = + packageManager === "npm" ? "*" : "workspace:*"; + + if (webDirExists) { + await addBackendWorkspaceDependency( + webDir, + backendPackageName, + backendWorkspaceVersion, + ); + } + + if (nativeDirExists) { + await addBackendWorkspaceDependency( + nativeDir, + backendPackageName, + backendWorkspaceVersion, + ); + } + } +} diff --git a/apps/cli/src/helpers/setup/backend-setup.ts b/apps/cli/src/helpers/core/backend-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/backend-setup.ts rename to apps/cli/src/helpers/core/backend-setup.ts diff --git a/apps/cli/src/helpers/project-generation/command-handlers.ts b/apps/cli/src/helpers/core/command-handlers.ts similarity index 80% rename from apps/cli/src/helpers/project-generation/command-handlers.ts rename to apps/cli/src/helpers/core/command-handlers.ts index 2e6bdb6..ae84d79 100644 --- a/apps/cli/src/helpers/project-generation/command-handlers.ts +++ b/apps/cli/src/helpers/core/command-handlers.ts @@ -3,10 +3,11 @@ import { intro, log, outro } from "@clack/prompts"; import consola from "consola"; import fs from "fs-extra"; import pc from "picocolors"; -import { DEFAULT_CONFIG } from "../../constants"; +import { getDefaultConfig } from "../../constants"; import { getAddonsToAdd } from "../../prompts/addons"; import { gatherConfig } from "../../prompts/config-prompts"; import { getProjectName } from "../../prompts/project-name"; +import { getServerDeploymentToAdd } from "../../prompts/server-deploy"; import { getDeploymentToAdd } from "../../prompts/web-deploy"; import type { AddInput, @@ -16,6 +17,7 @@ import type { ProjectConfig, } from "../../types"; import { trackProjectCreation } from "../../utils/analytics"; +import { coerceBackendPresets } from "../../utils/compatibility-rules"; import { displayConfig } from "../../utils/display-config"; import { exitWithError, handleError } from "../../utils/errors"; import { generateReproducibleCommand } from "../../utils/generate-reproducible-command"; @@ -24,7 +26,12 @@ import { setupProjectDirectory, } from "../../utils/project-directory"; import { renderTitle } from "../../utils/render-title"; -import { getProvidedFlags, processAndValidateFlags } from "../../validation"; +import { + getProvidedFlags, + processAndValidateFlags, + processProvidedFlagsWithoutValidation, + validateConfigCompatibility, +} from "../../validation"; import { addAddonsToProject } from "./add-addons"; import { addDeploymentToProject } from "./add-deployment"; import { createProject } from "./create-project"; @@ -50,13 +57,14 @@ export async function createProjectHandler( if (input.yes && input.projectName) { currentPathInput = input.projectName; } else if (input.yes) { - let defaultName = DEFAULT_CONFIG.relativePath; + const defaultConfig = getDefaultConfig(); + let defaultName = defaultConfig.relativePath; let counter = 1; while ( - fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) && - fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0 + (await fs.pathExists(path.resolve(process.cwd(), defaultName))) && + (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0 ) { - defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`; + defaultName = `${defaultConfig.projectName}-${counter}`; counter++; } currentPathInput = defaultName; @@ -102,6 +110,7 @@ export async function createProjectHandler( dbSetup: "none", api: "none", webDeploy: "none", + serverDeploy: "none", } satisfies ProjectConfig, reproducibleCommand: "", timeScaffolded, @@ -124,29 +133,25 @@ export async function createProjectHandler( const providedFlags = getProvidedFlags(cliInput); - const flagConfig = processAndValidateFlags( - cliInput, - providedFlags, - finalBaseName, - ); - const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig; - - if (!input.yes && Object.keys(otherFlags).length > 0) { - log.info(pc.yellow("Using these pre-selected options:")); - log.message(displayConfig(otherFlags)); - log.message(""); - } - let config: ProjectConfig; if (input.yes) { + const flagConfig = processProvidedFlagsWithoutValidation( + cliInput, + finalBaseName, + ); + config = { - ...DEFAULT_CONFIG, + ...getDefaultConfig(), ...flagConfig, projectName: finalBaseName, projectDir: finalResolvedPath, relativePath: finalPathInput, }; + coerceBackendPresets(config); + + validateConfigCompatibility(config, providedFlags, cliInput); + if (config.backend === "convex") { log.info( "Due to '--backend convex' flag, the following options have been automatically set: auth=false, database=none, orm=none, api=none, runtime=none, dbSetup=none, examples=todo", @@ -161,6 +166,19 @@ export async function createProjectHandler( log.message(displayConfig(config)); log.message(""); } else { + const flagConfig = processAndValidateFlags( + cliInput, + providedFlags, + finalBaseName, + ); + const { projectName: _projectNameFromFlags, ...otherFlags } = flagConfig; + + if (Object.keys(otherFlags).length > 0) { + log.info(pc.yellow("Using these pre-selected options:")); + log.message(displayConfig(otherFlags)); + log.message(""); + } + config = await gatherConfig( flagConfig, finalBaseName, @@ -207,11 +225,11 @@ async function handleDirectoryConflictProgrammatically( ): Promise<{ finalPathInput: string; shouldClearDirectory: boolean }> { const currentPath = path.resolve(process.cwd(), currentPathInput); - if (!fs.pathExistsSync(currentPath)) { + if (!(await fs.pathExists(currentPath))) { return { finalPathInput: currentPathInput, shouldClearDirectory: false }; } - const dirContents = fs.readdirSync(currentPath); + const dirContents = await fs.readdir(currentPath); const isNotEmpty = dirContents.length > 0; if (!isNotEmpty) { @@ -231,8 +249,9 @@ async function handleDirectoryConflictProgrammatically( let finalPathInput = `${baseName}-${counter}`; while ( - fs.pathExistsSync(path.resolve(process.cwd(), finalPathInput)) && - fs.readdirSync(path.resolve(process.cwd(), finalPathInput)).length > 0 + (await fs.pathExists(path.resolve(process.cwd(), finalPathInput))) && + (await fs.readdir(path.resolve(process.cwd(), finalPathInput))).length > + 0 ) { counter++; finalPathInput = `${baseName}-${counter}`; @@ -284,6 +303,17 @@ export async function addAddonsHandler(input: AddInput) { } } + if (!input.serverDeploy) { + const serverDeploymentPrompt = await getServerDeploymentToAdd( + detectedConfig.runtime, + detectedConfig.serverDeploy, + ); + + if (serverDeploymentPrompt !== "none") { + input.serverDeploy = serverDeploymentPrompt; + } + } + const packageManager = input.packageManager || detectedConfig.packageManager || "npm"; @@ -309,6 +339,16 @@ export async function addAddonsHandler(input: AddInput) { somethingAdded = true; } + if (input.serverDeploy && input.serverDeploy !== "none") { + await addDeploymentToProject({ + ...input, + install: false, + suppressInstallMessage: true, + serverDeploy: input.serverDeploy, + }); + somethingAdded = true; + } + if (!somethingAdded) { outro(pc.yellow("No addons or deployment configurations to add.")); return; diff --git a/apps/cli/src/helpers/core/convex-codegen.ts b/apps/cli/src/helpers/core/convex-codegen.ts new file mode 100644 index 0000000..2b9159e --- /dev/null +++ b/apps/cli/src/helpers/core/convex-codegen.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { execa } from "execa"; +import type { PackageManager } from "../../types"; +import { getPackageExecutionCommand } from "../../utils/package-runner"; + +export async function runConvexCodegen( + projectDir: string, + packageManager: PackageManager | null | undefined, +) { + const backendDir = path.join(projectDir, "packages/backend"); + const cmd = getPackageExecutionCommand(packageManager, "convex codegen"); + await execa(cmd, { cwd: backendDir, shell: true }); +} diff --git a/apps/cli/src/helpers/project-generation/create-project.ts b/apps/cli/src/helpers/core/create-project.ts similarity index 78% rename from apps/cli/src/helpers/project-generation/create-project.ts rename to apps/cli/src/helpers/core/create-project.ts index 647464a..b971609 100644 --- a/apps/cli/src/helpers/project-generation/create-project.ts +++ b/apps/cli/src/helpers/core/create-project.ts @@ -3,17 +3,17 @@ import fs from "fs-extra"; import type { ProjectConfig } from "../../types"; import { writeBtsConfig } from "../../utils/bts-config"; import { exitWithError } from "../../utils/errors"; -import { setupAddons } from "../setup/addons-setup"; -import { setupApi } from "../setup/api-setup"; -import { setupAuth } from "../setup/auth-setup"; -import { setupBackendDependencies } from "../setup/backend-setup"; -import { setupDatabase } from "../setup/db-setup"; -import { setupExamples } from "../setup/examples-setup"; -import { - generateCloudflareWorkerTypes, - setupRuntime, -} from "../setup/runtime-setup"; -import { setupWebDeploy } from "../setup/web-deploy-setup"; +import { formatProjectWithBiome } from "../../utils/format-with-biome"; +import { setupAddons } from "../addons/addons-setup"; +import { setupAuth } from "../addons/auth-setup"; +import { setupExamples } from "../addons/examples-setup"; +import { setupApi } from "../core/api-setup"; +import { setupBackendDependencies } from "../core/backend-setup"; +import { setupDatabase } from "../core/db-setup"; +import { setupRuntime } from "../core/runtime-setup"; +import { setupServerDeploy } from "../deployment/server-deploy-setup"; +import { setupWebDeploy } from "../deployment/web-deploy-setup"; +import { runConvexCodegen } from "./convex-codegen"; import { createReadme } from "./create-readme"; import { setupEnvironmentVariables } from "./env-setup"; import { initializeGit } from "./git"; @@ -77,6 +77,7 @@ export async function createProject(options: ProjectConfig) { await handleExtras(projectDir, options); await setupWebDeploy(options); + await setupServerDeploy(options); await setupEnvironmentVariables(options); await updatePackageConfigurations(projectDir, options); @@ -84,6 +85,12 @@ export async function createProject(options: ProjectConfig) { await writeBtsConfig(options); + await formatProjectWithBiome(projectDir); + + if (isConvex) { + await runConvexCodegen(projectDir, options.packageManager); + } + log.success("Project template successfully scaffolded!"); if (options.install) { @@ -91,7 +98,6 @@ export async function createProject(options: ProjectConfig) { projectDir, packageManager: options.packageManager, }); - await generateCloudflareWorkerTypes(options); } await initializeGit(projectDir, options.git); diff --git a/apps/cli/src/helpers/project-generation/create-readme.ts b/apps/cli/src/helpers/core/create-readme.ts similarity index 100% rename from apps/cli/src/helpers/project-generation/create-readme.ts rename to apps/cli/src/helpers/core/create-readme.ts diff --git a/apps/cli/src/helpers/setup/db-setup.ts b/apps/cli/src/helpers/core/db-setup.ts similarity index 100% rename from apps/cli/src/helpers/setup/db-setup.ts rename to apps/cli/src/helpers/core/db-setup.ts diff --git a/apps/cli/src/helpers/project-generation/detect-project-config.ts b/apps/cli/src/helpers/core/detect-project-config.ts similarity index 96% rename from apps/cli/src/helpers/project-generation/detect-project-config.ts rename to apps/cli/src/helpers/core/detect-project-config.ts index 7e490d8..4211910 100644 --- a/apps/cli/src/helpers/project-generation/detect-project-config.ts +++ b/apps/cli/src/helpers/core/detect-project-config.ts @@ -24,6 +24,7 @@ export async function detectProjectConfig( dbSetup: btsConfig.dbSetup, api: btsConfig.api, webDeploy: btsConfig.webDeploy, + serverDeploy: btsConfig.serverDeploy, }; } diff --git a/apps/cli/src/helpers/project-generation/env-setup.ts b/apps/cli/src/helpers/core/env-setup.ts similarity index 80% rename from apps/cli/src/helpers/project-generation/env-setup.ts rename to apps/cli/src/helpers/core/env-setup.ts index 210c4ca..0431965 100644 --- a/apps/cli/src/helpers/project-generation/env-setup.ts +++ b/apps/cli/src/helpers/core/env-setup.ts @@ -1,7 +1,7 @@ import path from "node:path"; import fs from "fs-extra"; import type { ProjectConfig } from "../../types"; -import { generateAuthSecret } from "../setup/auth-setup"; +import { generateAuthSecret } from "../addons/auth-setup"; export interface EnvVariable { key: string; @@ -85,8 +85,17 @@ export async function addEnvVariablesToFile( } export async function setupEnvironmentVariables(config: ProjectConfig) { - const { backend, frontend, database, auth, examples, dbSetup, projectDir } = - config; + const { + backend, + frontend, + database, + auth, + examples, + dbSetup, + projectDir, + webDeploy, + serverDeploy, + } = config; const hasReactRouter = frontend.includes("react-router"); const hasTanStackRouter = frontend.includes("tanstack-router"); @@ -239,10 +248,51 @@ export async function setupEnvironmentVariables(config: ProjectConfig) { await addEnvVariablesToFile(envPath, serverVars); - if (config.runtime === "workers") { - const devVarsPath = path.join(serverDir, ".dev.vars"); - try { - await fs.copy(envPath, devVarsPath); - } catch (_err) {} + const isUnifiedAlchemy = + webDeploy === "alchemy" && serverDeploy === "alchemy"; + const isIndividualAlchemy = + webDeploy === "alchemy" || serverDeploy === "alchemy"; + + if (isUnifiedAlchemy) { + const rootEnvPath = path.join(projectDir, ".env"); + const rootAlchemyVars: EnvVariable[] = [ + { + key: "ALCHEMY_PASSWORD", + value: "please-change-this", + condition: true, + }, + ]; + await addEnvVariablesToFile(rootEnvPath, rootAlchemyVars); + } else if (isIndividualAlchemy) { + if (webDeploy === "alchemy") { + const webDir = path.join(projectDir, "apps/web"); + if (await fs.pathExists(webDir)) { + const webAlchemyVars: EnvVariable[] = [ + { + key: "ALCHEMY_PASSWORD", + value: "please-change-this", + condition: true, + }, + ]; + await addEnvVariablesToFile(path.join(webDir, ".env"), webAlchemyVars); + } + } + + if (serverDeploy === "alchemy") { + const serverDir = path.join(projectDir, "apps/server"); + if (await fs.pathExists(serverDir)) { + const serverAlchemyVars: EnvVariable[] = [ + { + key: "ALCHEMY_PASSWORD", + value: "please-change-this", + condition: true, + }, + ]; + await addEnvVariablesToFile( + path.join(serverDir, ".env"), + serverAlchemyVars, + ); + } + } } } diff --git a/apps/cli/src/helpers/project-generation/git.ts b/apps/cli/src/helpers/core/git.ts similarity index 100% rename from apps/cli/src/helpers/project-generation/git.ts rename to apps/cli/src/helpers/core/git.ts diff --git a/apps/cli/src/helpers/project-generation/install-dependencies.ts b/apps/cli/src/helpers/core/install-dependencies.ts similarity index 100% rename from apps/cli/src/helpers/project-generation/install-dependencies.ts rename to apps/cli/src/helpers/core/install-dependencies.ts diff --git a/apps/cli/src/helpers/project-generation/post-installation.ts b/apps/cli/src/helpers/core/post-installation.ts similarity index 80% rename from apps/cli/src/helpers/project-generation/post-installation.ts rename to apps/cli/src/helpers/core/post-installation.ts index b20a1f9..261a4d7 100644 --- a/apps/cli/src/helpers/project-generation/post-installation.ts +++ b/apps/cli/src/helpers/core/post-installation.ts @@ -25,6 +25,7 @@ export async function displayPostInstallInstructions( backend, dbSetup, webDeploy, + serverDeploy, } = config; const isConvex = backend === "convex"; @@ -35,7 +36,14 @@ export async function displayPostInstallInstructions( const databaseInstructions = !isConvex && database !== "none" - ? await getDatabaseInstructions(database, orm, runCmd, runtime, dbSetup) + ? await getDatabaseInstructions( + database, + orm, + runCmd, + runtime, + dbSetup, + serverDeploy, + ) : ""; const tauriInstructions = addons?.includes("tauri") @@ -56,8 +64,16 @@ export async function displayPostInstallInstructions( const starlightInstructions = addons?.includes("starlight") ? getStarlightInstructions(runCmd) : ""; - const workersDeployInstructions = - webDeploy === "workers" ? getWorkersDeployInstructions(runCmd) : ""; + const wranglerDeployInstructions = getWranglerDeployInstructions( + runCmd, + webDeploy, + serverDeploy, + ); + const alchemyDeployInstructions = getAlchemyDeployInstructions( + runCmd, + webDeploy, + serverDeploy, + ); const hasWeb = frontend?.some((f) => [ @@ -116,11 +132,9 @@ export async function displayPostInstallInstructions( )} Complete D1 database setup first\n (see Database commands below)\n`; } output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n`; - output += `${pc.cyan( - `${stepCounter++}.`, - )} cd apps/server && ${runCmd} run cf-typegen\n\n`; - } else { - output += "\n"; + if (serverDeploy === "wrangler") { + output += `${pc.cyan(`${stepCounter++}.`)} cd apps/server && ${runCmd} cf-typegen\n`; + } } } @@ -151,8 +165,10 @@ export async function displayPostInstallInstructions( if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`; if (lintingInstructions) output += `\n${lintingInstructions.trim()}\n`; if (pwaInstructions) output += `\n${pwaInstructions.trim()}\n`; - if (workersDeployInstructions) - output += `\n${workersDeployInstructions.trim()}\n`; + if (wranglerDeployInstructions) + output += `\n${wranglerDeployInstructions.trim()}\n`; + if (alchemyDeployInstructions) + output += `\n${alchemyDeployInstructions.trim()}\n`; if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`; if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`; @@ -202,10 +218,11 @@ async function getDatabaseInstructions( database: Database, orm?: ORM, runCmd?: string, - runtime?: Runtime, + _runtime?: Runtime, dbSetup?: DatabaseSetup, + serverDeploy?: string, ): Promise { - const instructions = []; + const instructions: string[] = []; if (dbSetup === "docker") { const dockerStatus = await getDockerStatus(database); @@ -216,7 +233,7 @@ async function getDatabaseInstructions( } } - if (runtime === "workers" && dbSetup === "d1") { + if (serverDeploy === "wrangler" && dbSetup === "d1") { const packageManager = runCmd === "npm run" ? "npm" : runCmd || "npm"; instructions.push( @@ -249,7 +266,9 @@ async function getDatabaseInstructions( `${packageManager} wrangler d1 migrations apply YOUR_DB_NAME`, )}`, ); - instructions.push(""); + } + + if (dbSetup === "d1" && serverDeploy === "alchemy") { } if (orm === "prisma") { @@ -281,7 +300,9 @@ async function getDatabaseInstructions( `${pc.cyan("•")} Start docker container: ${`${runCmd} db:start`}`, ); } - instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`); + if (dbSetup !== "d1") { + instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`); + } instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`); if (database === "sqlite" && dbSetup !== "d1") { instructions.push( @@ -343,6 +364,47 @@ function getBunWebNativeWarning(): string { )} 'bun' might cause issues with web + native apps in a monorepo.\n Use 'pnpm' if problems arise.`; } -function getWorkersDeployInstructions(runCmd?: string): string { - return `\n${pc.bold("Deploy frontend to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd} run deploy`}`; +function getWranglerDeployInstructions( + runCmd?: string, + webDeploy?: string, + serverDeploy?: string, +): string { + const instructions: string[] = []; + + if (webDeploy === "wrangler") { + instructions.push( + `${pc.bold("Deploy web to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd} run deploy`}`, + ); + } + if (serverDeploy === "wrangler") { + instructions.push( + `${pc.bold("Deploy server to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/server && ${runCmd} run deploy`}`, + ); + } + + return instructions.length ? `\n${instructions.join("\n")}` : ""; +} + +function getAlchemyDeployInstructions( + runCmd?: string, + webDeploy?: string, + serverDeploy?: string, +): string { + const instructions: string[] = []; + + if (webDeploy === "alchemy" && serverDeploy !== "alchemy") { + instructions.push( + `${pc.bold("Deploy web to Alchemy:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd} deploy`}`, + ); + } else if (serverDeploy === "alchemy" && webDeploy !== "alchemy") { + instructions.push( + `${pc.bold("Deploy server to Alchemy:")}\n${pc.cyan("•")} Deploy: ${`cd apps/server && ${runCmd} deploy`}`, + ); + } else if (webDeploy === "alchemy" && serverDeploy === "alchemy") { + instructions.push( + `${pc.bold("Deploy to Alchemy:")}\n${pc.cyan("•")} Deploy: ${`${runCmd} deploy`}`, + ); + } + + return instructions.length ? `\n${instructions.join("\n")}` : ""; } diff --git a/apps/cli/src/helpers/project-generation/project-config.ts b/apps/cli/src/helpers/core/project-config.ts similarity index 100% rename from apps/cli/src/helpers/project-generation/project-config.ts rename to apps/cli/src/helpers/core/project-config.ts diff --git a/apps/cli/src/helpers/setup/runtime-setup.ts b/apps/cli/src/helpers/core/runtime-setup.ts similarity index 53% rename from apps/cli/src/helpers/setup/runtime-setup.ts rename to apps/cli/src/helpers/core/runtime-setup.ts index 5f6517d..8490ab8 100644 --- a/apps/cli/src/helpers/setup/runtime-setup.ts +++ b/apps/cli/src/helpers/core/runtime-setup.ts @@ -1,8 +1,5 @@ import path from "node:path"; -import { spinner } from "@clack/prompts"; -import { execa } from "execa"; import fs from "fs-extra"; -import pc from "picocolors"; import type { Backend, ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; @@ -23,43 +20,6 @@ export async function setupRuntime(config: ProjectConfig) { await setupBunRuntime(serverDir, backend); } else if (runtime === "node") { await setupNodeRuntime(serverDir, backend); - } else if (runtime === "workers") { - await setupWorkersRuntime(serverDir); - } -} - -export async function generateCloudflareWorkerTypes(config: ProjectConfig) { - if (config.runtime !== "workers") { - return; - } - - const serverDir = path.join(config.projectDir, "apps/server"); - - if (!(await fs.pathExists(serverDir))) { - return; - } - - const s = spinner(); - - try { - s.start("Generating Cloudflare Workers types..."); - - const runCmd = - config.packageManager === "npm" ? "npm" : config.packageManager; - await execa(runCmd, ["run", "cf-typegen"], { - cwd: serverDir, - }); - - s.stop("Cloudflare Workers types generated successfully!"); - } catch { - s.stop(pc.yellow("Failed to generate Cloudflare Workers types")); - const managerCmd = - config.packageManager === "npm" - ? "npm run" - : `${config.packageManager} run`; - console.warn( - `Note: You can manually run 'cd apps/server && ${managerCmd} cf-typegen' in the project directory later`, - ); } } @@ -114,26 +74,3 @@ async function setupNodeRuntime(serverDir: string, backend: Backend) { }); } } - -async function setupWorkersRuntime(serverDir: string) { - const packageJsonPath = path.join(serverDir, "package.json"); - if (!(await fs.pathExists(packageJsonPath))) return; - - const packageJson = await fs.readJson(packageJsonPath); - - packageJson.scripts = { - ...packageJson.scripts, - dev: "wrangler dev --port=3000", - start: "wrangler dev", - deploy: "wrangler deploy", - build: "wrangler deploy --dry-run", - "cf-typegen": "wrangler types --env-interface CloudflareBindings", - }; - - await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); - - await addPackageDependency({ - devDependencies: ["wrangler", "@types/node"], - projectDir: serverDir, - }); -} diff --git a/apps/cli/src/helpers/project-generation/template-manager.ts b/apps/cli/src/helpers/core/template-manager.ts similarity index 86% rename from apps/cli/src/helpers/project-generation/template-manager.ts rename to apps/cli/src/helpers/core/template-manager.ts index d0048f4..5290d3a 100644 --- a/apps/cli/src/helpers/project-generation/template-manager.ts +++ b/apps/cli/src/helpers/core/template-manager.ts @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "fs-extra"; -import { globby } from "globby"; +import { glob } from "tinyglobby"; import { PKG_ROOT } from "../../constants"; import type { ProjectConfig } from "../../types"; import { processTemplate } from "../../utils/template-processor"; @@ -13,7 +13,7 @@ export async function processAndCopyFiles( overwrite = true, ignorePatterns?: string[], ) { - const sourceFiles = await globby(sourcePattern, { + const sourceFiles = await glob(sourcePattern, { cwd: baseSourceDir, dot: true, onlyFiles: true, @@ -788,19 +788,6 @@ export async function handleExtras(projectDir: string, context: ProjectConfig) { await processAndCopyFiles("_npmrc.hbs", extrasDir, projectDir, context); } } - - if (context.runtime === "workers") { - const runtimeWorkersDir = path.join(PKG_ROOT, "templates/runtime/workers"); - if (await fs.pathExists(runtimeWorkersDir)) { - await processAndCopyFiles( - "**/*", - runtimeWorkersDir, - projectDir, - context, - false, - ); - } - } } export async function setupDockerComposeTemplates( @@ -827,43 +814,137 @@ export async function setupDeploymentTemplates( projectDir: string, context: ProjectConfig, ) { - if (context.webDeploy === "none") { - return; - } - - if (context.webDeploy === "workers") { - const webAppDir = path.join(projectDir, "apps/web"); - if (!(await fs.pathExists(webAppDir))) { - return; - } - - const frontends = context.frontend; - - const templateMap: Record = { - "tanstack-router": "react/tanstack-router", - "tanstack-start": "react/tanstack-start", - "react-router": "react/react-router", - solid: "solid", - next: "react/next", - nuxt: "nuxt", - svelte: "svelte", - }; - - for (const f of frontends) { - if (templateMap[f]) { - const deployTemplateSrc = path.join( - PKG_ROOT, - `templates/deploy/web/${templateMap[f]}`, + if (context.webDeploy === "alchemy" || context.serverDeploy === "alchemy") { + if (context.webDeploy === "alchemy" && context.serverDeploy === "alchemy") { + const alchemyTemplateSrc = path.join( + PKG_ROOT, + "templates/deploy/alchemy", + ); + if (await fs.pathExists(alchemyTemplateSrc)) { + await processAndCopyFiles( + "alchemy.run.ts.hbs", + alchemyTemplateSrc, + projectDir, + context, ); - if (await fs.pathExists(deployTemplateSrc)) { + const serverAppDir = path.join(projectDir, "apps/server"); + if (await fs.pathExists(serverAppDir)) { await processAndCopyFiles( - "**/*", - deployTemplateSrc, + "env.d.ts.hbs", + alchemyTemplateSrc, + serverAppDir, + context, + ); + await processAndCopyFiles( + "wrangler.jsonc.hbs", + alchemyTemplateSrc, + serverAppDir, + context, + ); + } + } + } else { + if (context.webDeploy === "alchemy") { + const alchemyTemplateSrc = path.join( + PKG_ROOT, + "templates/deploy/alchemy", + ); + const webAppDir = path.join(projectDir, "apps/web"); + if ( + (await fs.pathExists(alchemyTemplateSrc)) && + (await fs.pathExists(webAppDir)) + ) { + await processAndCopyFiles( + "alchemy.run.ts.hbs", + alchemyTemplateSrc, webAppDir, context, ); } } + + if (context.serverDeploy === "alchemy") { + const alchemyTemplateSrc = path.join( + PKG_ROOT, + "templates/deploy/alchemy", + ); + const serverAppDir = path.join(projectDir, "apps/server"); + if ( + (await fs.pathExists(alchemyTemplateSrc)) && + (await fs.pathExists(serverAppDir)) + ) { + await processAndCopyFiles( + "alchemy.run.ts.hbs", + alchemyTemplateSrc, + serverAppDir, + context, + ); + await processAndCopyFiles( + "env.d.ts.hbs", + alchemyTemplateSrc, + serverAppDir, + context, + ); + await processAndCopyFiles( + "wrangler.jsonc.hbs", + alchemyTemplateSrc, + serverAppDir, + context, + ); + } + } + } + } + + if (context.webDeploy !== "none" && context.webDeploy !== "alchemy") { + const webAppDir = path.join(projectDir, "apps/web"); + if (await fs.pathExists(webAppDir)) { + const frontends = context.frontend; + + const templateMap: Record = { + "tanstack-router": "react/tanstack-router", + "tanstack-start": "react/tanstack-start", + "react-router": "react/react-router", + solid: "solid", + next: "react/next", + nuxt: "nuxt", + svelte: "svelte", + }; + + for (const f of frontends) { + if (templateMap[f]) { + const deployTemplateSrc = path.join( + PKG_ROOT, + `templates/deploy/${context.webDeploy}/web/${templateMap[f]}`, + ); + if (await fs.pathExists(deployTemplateSrc)) { + await processAndCopyFiles( + "**/*", + deployTemplateSrc, + webAppDir, + context, + ); + } + } + } + } + } + + if (context.serverDeploy !== "none" && context.serverDeploy !== "alchemy") { + const serverAppDir = path.join(projectDir, "apps/server"); + if (await fs.pathExists(serverAppDir)) { + const deployTemplateSrc = path.join( + PKG_ROOT, + `templates/deploy/${context.serverDeploy}/server`, + ); + if (await fs.pathExists(deployTemplateSrc)) { + await processAndCopyFiles( + "**/*", + deployTemplateSrc, + serverAppDir, + context, + ); + } } } } diff --git a/apps/cli/src/helpers/database-providers/d1-setup.ts b/apps/cli/src/helpers/database-providers/d1-setup.ts index 5143b3a..05bfd47 100644 --- a/apps/cli/src/helpers/database-providers/d1-setup.ts +++ b/apps/cli/src/helpers/database-providers/d1-setup.ts @@ -1,34 +1,33 @@ import path from "node:path"; import type { ProjectConfig } from "../../types"; -import { - addEnvVariablesToFile, - type EnvVariable, -} from "../project-generation/env-setup"; +import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup"; export async function setupCloudflareD1(config: ProjectConfig) { - const { projectDir } = config; + const { projectDir, serverDeploy } = config; - const envPath = path.join(projectDir, "apps/server", ".env"); + if (serverDeploy === "wrangler") { + const envPath = path.join(projectDir, "apps/server", ".env"); - const variables: EnvVariable[] = [ - { - key: "CLOUDFLARE_ACCOUNT_ID", - value: "", - condition: true, - }, - { - key: "CLOUDFLARE_DATABASE_ID", - value: "", - condition: true, - }, - { - key: "CLOUDFLARE_D1_TOKEN", - value: "", - condition: true, - }, - ]; + const variables: EnvVariable[] = [ + { + key: "CLOUDFLARE_ACCOUNT_ID", + value: "", + condition: true, + }, + { + key: "CLOUDFLARE_DATABASE_ID", + value: "", + condition: true, + }, + { + key: "CLOUDFLARE_D1_TOKEN", + value: "", + condition: true, + }, + ]; - try { - await addEnvVariablesToFile(envPath, variables); - } catch (_err) {} + try { + await addEnvVariablesToFile(envPath, variables); + } catch (_err) {} + } } diff --git a/apps/cli/src/helpers/database-providers/docker-compose-setup.ts b/apps/cli/src/helpers/database-providers/docker-compose-setup.ts index edee1a6..f81155a 100644 --- a/apps/cli/src/helpers/database-providers/docker-compose-setup.ts +++ b/apps/cli/src/helpers/database-providers/docker-compose-setup.ts @@ -1,9 +1,6 @@ import path from "node:path"; import type { Database, ProjectConfig } from "../../types"; -import { - addEnvVariablesToFile, - type EnvVariable, -} from "../project-generation/env-setup"; +import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup"; export async function setupDockerCompose(config: ProjectConfig) { const { database, projectDir, projectName } = config; diff --git a/apps/cli/src/helpers/database-providers/mongodb-atlas-setup.ts b/apps/cli/src/helpers/database-providers/mongodb-atlas-setup.ts index 17a706f..ac65fae 100644 --- a/apps/cli/src/helpers/database-providers/mongodb-atlas-setup.ts +++ b/apps/cli/src/helpers/database-providers/mongodb-atlas-setup.ts @@ -6,10 +6,7 @@ import fs from "fs-extra"; import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { commandExists } from "../../utils/command-exists"; -import { - addEnvVariablesToFile, - type EnvVariable, -} from "../project-generation/env-setup"; +import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup"; type MongoDBConfig = { connectionString: string; diff --git a/apps/cli/src/helpers/database-providers/neon-setup.ts b/apps/cli/src/helpers/database-providers/neon-setup.ts index 94ef692..5cf25db 100644 --- a/apps/cli/src/helpers/database-providers/neon-setup.ts +++ b/apps/cli/src/helpers/database-providers/neon-setup.ts @@ -7,10 +7,7 @@ import pc from "picocolors"; import type { PackageManager, ProjectConfig } from "../../types"; import { exitCancelled } from "../../utils/errors"; import { getPackageExecutionCommand } from "../../utils/package-runner"; -import { - addEnvVariablesToFile, - type EnvVariable, -} from "../project-generation/env-setup"; +import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup"; type NeonConfig = { connectionString: string; diff --git a/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts b/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts index e9f7201..abf4b16 100644 --- a/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts +++ b/apps/cli/src/helpers/database-providers/prisma-postgres-setup.ts @@ -8,10 +8,7 @@ import type { ORM, PackageManager, ProjectConfig } from "../../types"; import { addPackageDependency } from "../../utils/add-package-deps"; import { exitCancelled } from "../../utils/errors"; import { getPackageExecutionCommand } from "../../utils/package-runner"; -import { - addEnvVariablesToFile, - type EnvVariable, -} from "../project-generation/env-setup"; +import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup"; type PrismaConfig = { databaseUrl: string; @@ -253,9 +250,8 @@ export async function setupPrismaPostgres(config: ProjectConfig) { if (prismaConfig) { await writeEnvFile(projectDir, prismaConfig); - await addDotenvImportToPrismaConfig(projectDir); - if (orm === "prisma") { + await addDotenvImportToPrismaConfig(projectDir); await addPrismaAccelerateExtension(serverDir); } log.success( diff --git a/apps/cli/src/helpers/database-providers/supabase-setup.ts b/apps/cli/src/helpers/database-providers/supabase-setup.ts index 20a6ab5..c40615d 100644 --- a/apps/cli/src/helpers/database-providers/supabase-setup.ts +++ b/apps/cli/src/helpers/database-providers/supabase-setup.ts @@ -6,10 +6,7 @@ import fs from "fs-extra"; import pc from "picocolors"; import type { PackageManager, ProjectConfig } from "../../types"; import { getPackageExecutionCommand } from "../../utils/package-runner"; -import { - addEnvVariablesToFile, - type EnvVariable, -} from "../project-generation/env-setup"; +import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup"; async function writeSupabaseEnvFile(projectDir: string, databaseUrl: string) { try { diff --git a/apps/cli/src/helpers/database-providers/turso-setup.ts b/apps/cli/src/helpers/database-providers/turso-setup.ts index e08b8b5..effe2cc 100644 --- a/apps/cli/src/helpers/database-providers/turso-setup.ts +++ b/apps/cli/src/helpers/database-providers/turso-setup.ts @@ -7,10 +7,7 @@ import pc from "picocolors"; import type { ProjectConfig } from "../../types"; import { commandExists } from "../../utils/command-exists"; import { exitCancelled } from "../../utils/errors"; -import { - addEnvVariablesToFile, - type EnvVariable, -} from "../project-generation/env-setup"; +import { addEnvVariablesToFile, type EnvVariable } from "../core/env-setup"; type TursoConfig = { dbUrl: string; diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-combined-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-combined-setup.ts new file mode 100644 index 0000000..4f7c09d --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-combined-setup.ts @@ -0,0 +1,66 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { PackageManager, ProjectConfig } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; +import { setupAlchemyServerDeploy } from "../server-deploy-setup"; +import { setupNextAlchemyDeploy } from "./alchemy-next-setup"; +import { setupNuxtAlchemyDeploy } from "./alchemy-nuxt-setup"; +import { setupReactRouterAlchemyDeploy } from "./alchemy-react-router-setup"; +import { setupSolidAlchemyDeploy } from "./alchemy-solid-setup"; +import { setupSvelteAlchemyDeploy } from "./alchemy-svelte-setup"; +import { setupTanStackRouterAlchemyDeploy } from "./alchemy-tanstack-router-setup"; +import { setupTanStackStartAlchemyDeploy } from "./alchemy-tanstack-start-setup"; + +export async function setupCombinedAlchemyDeploy( + projectDir: string, + packageManager: PackageManager, + config: ProjectConfig, +) { + await addPackageDependency({ + devDependencies: ["alchemy", "dotenv"], + projectDir, + }); + + const rootPkgPath = path.join(projectDir, "package.json"); + if (await fs.pathExists(rootPkgPath)) { + const pkg = await fs.readJson(rootPkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(rootPkgPath, pkg, { spaces: 2 }); + } + + const serverDir = path.join(projectDir, "apps/server"); + if (await fs.pathExists(serverDir)) { + await setupAlchemyServerDeploy(serverDir, packageManager); + } + + const frontend = config.frontend; + const isNext = frontend.includes("next"); + const isNuxt = frontend.includes("nuxt"); + const isSvelte = frontend.includes("svelte"); + const isTanstackRouter = frontend.includes("tanstack-router"); + const isTanstackStart = frontend.includes("tanstack-start"); + const isReactRouter = frontend.includes("react-router"); + const isSolid = frontend.includes("solid"); + + if (isNext) { + await setupNextAlchemyDeploy(projectDir, packageManager); + } else if (isNuxt) { + await setupNuxtAlchemyDeploy(projectDir, packageManager); + } else if (isSvelte) { + await setupSvelteAlchemyDeploy(projectDir, packageManager); + } else if (isTanstackStart) { + await setupTanStackStartAlchemyDeploy(projectDir, packageManager); + } else if (isTanstackRouter) { + await setupTanStackRouterAlchemyDeploy(projectDir, packageManager); + } else if (isReactRouter) { + await setupReactRouterAlchemyDeploy(projectDir, packageManager); + } else if (isSolid) { + await setupSolidAlchemyDeploy(projectDir, packageManager); + } +} diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts new file mode 100644 index 0000000..870d806 --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-next-setup.ts @@ -0,0 +1,30 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupNextAlchemyDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + devDependencies: ["alchemy", "dotenv"], + projectDir: webAppDir, + }); + + const pkgPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(pkgPath)) { + const pkg = await fs.readJson(pkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + } +} diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts new file mode 100644 index 0000000..31fb1e7 --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-nuxt-setup.ts @@ -0,0 +1,104 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { IndentationText, Node, Project, QuoteKind } from "ts-morph"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupNuxtAlchemyDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + devDependencies: ["alchemy", "nitro-cloudflare-dev", "dotenv"], + projectDir: webAppDir, + }); + + const pkgPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(pkgPath)) { + const pkg = await fs.readJson(pkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + } + + const nuxtConfigPath = path.join(webAppDir, "nuxt.config.ts"); + if (!(await fs.pathExists(nuxtConfigPath))) return; + + try { + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + quoteKind: QuoteKind.Double, + }, + }); + + project.addSourceFileAtPath(nuxtConfigPath); + const sourceFile = project.getSourceFileOrThrow(nuxtConfigPath); + + const exportAssignment = sourceFile.getExportAssignment( + (d) => !d.isExportEquals(), + ); + if (!exportAssignment) return; + + const defineConfigCall = exportAssignment.getExpression(); + if ( + !Node.isCallExpression(defineConfigCall) || + defineConfigCall.getExpression().getText() !== "defineNuxtConfig" + ) + return; + + let configObject = defineConfigCall.getArguments()[0]; + if (!configObject) { + configObject = defineConfigCall.addArgument("{}"); + } + + if (Node.isObjectLiteralExpression(configObject)) { + if (!configObject.getProperty("nitro")) { + configObject.addPropertyAssignment({ + name: "nitro", + initializer: `{ + preset: "cloudflare_module", + cloudflare: { + deployConfig: true, + nodeCompat: true + } + }`, + }); + } + + const modulesProperty = configObject.getProperty("modules"); + if (modulesProperty && Node.isPropertyAssignment(modulesProperty)) { + const initializer = modulesProperty.getInitializer(); + if (Node.isArrayLiteralExpression(initializer)) { + const hasModule = initializer + .getElements() + .some( + (el) => + el.getText() === '"nitro-cloudflare-dev"' || + el.getText() === "'nitro-cloudflare-dev'", + ); + if (!hasModule) { + initializer.addElement('"nitro-cloudflare-dev"'); + } + } + } else if (!modulesProperty) { + configObject.addPropertyAssignment({ + name: "modules", + initializer: '["nitro-cloudflare-dev"]', + }); + } + } + + await project.save(); + } catch (error) { + console.warn("Failed to update nuxt.config.ts:", error); + } +} diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts new file mode 100644 index 0000000..1db34e6 --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-react-router-setup.ts @@ -0,0 +1,168 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { IndentationText, Node, Project, QuoteKind } from "ts-morph"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupReactRouterAlchemyDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + devDependencies: ["alchemy", "@cloudflare/vite-plugin", "dotenv"], + projectDir: webAppDir, + }); + + const pkgPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(pkgPath)) { + const pkg = await fs.readJson(pkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + } + + const viteConfigPath = path.join(webAppDir, "vite.config.ts"); + if (await fs.pathExists(viteConfigPath)) { + try { + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + quoteKind: QuoteKind.Double, + }, + }); + + project.addSourceFileAtPath(viteConfigPath); + const sourceFile = project.getSourceFileOrThrow(viteConfigPath); + + const alchemyImport = sourceFile.getImportDeclaration( + "alchemy/cloudflare/react-router", + ); + if (!alchemyImport) { + sourceFile.addImportDeclaration({ + moduleSpecifier: "alchemy/cloudflare/react-router", + defaultImport: "alchemy", + }); + } + + const exportAssignment = sourceFile.getExportAssignment( + (d) => !d.isExportEquals(), + ); + if (!exportAssignment) return; + + const defineConfigCall = exportAssignment.getExpression(); + if ( + !Node.isCallExpression(defineConfigCall) || + defineConfigCall.getExpression().getText() !== "defineConfig" + ) + return; + + let configObject = defineConfigCall.getArguments()[0]; + if (!configObject) { + configObject = defineConfigCall.addArgument("{}"); + } + + if (Node.isObjectLiteralExpression(configObject)) { + const pluginsProperty = configObject.getProperty("plugins"); + if (pluginsProperty && Node.isPropertyAssignment(pluginsProperty)) { + const initializer = pluginsProperty.getInitializer(); + if (Node.isArrayLiteralExpression(initializer)) { + const hasCloudflarePlugin = initializer + .getElements() + .some((el) => el.getText().includes("cloudflare(")); + + if (!hasCloudflarePlugin) { + initializer.addElement("alchemy()"); + } + } + } else if (!pluginsProperty) { + configObject.addPropertyAssignment({ + name: "plugins", + initializer: "[alchemy()]", + }); + } + } + + await project.save(); + } catch (error) { + console.warn("Failed to update vite.config.ts:", error); + } + } + + const reactRouterConfigPath = path.join(webAppDir, "react-router.config.ts"); + if (await fs.pathExists(reactRouterConfigPath)) { + try { + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + quoteKind: QuoteKind.Double, + }, + }); + + project.addSourceFileAtPath(reactRouterConfigPath); + const sourceFile = project.getSourceFileOrThrow(reactRouterConfigPath); + + const exportAssignment = sourceFile.getExportAssignment( + (d) => !d.isExportEquals(), + ); + if (!exportAssignment) return; + + const configExpression = exportAssignment.getExpression(); + let configObject: Node | undefined; + + if (Node.isObjectLiteralExpression(configExpression)) { + configObject = configExpression; + } else if (Node.isSatisfiesExpression(configExpression)) { + const expression = configExpression.getExpression(); + if (Node.isObjectLiteralExpression(expression)) { + configObject = expression; + } + } + + if (!configObject || !Node.isObjectLiteralExpression(configObject)) + return; + + const futureProperty = configObject.getProperty("future"); + + if (!futureProperty) { + configObject.addPropertyAssignment({ + name: "future", + initializer: `{ + unstable_viteEnvironmentApi: true, + }`, + }); + } else if (Node.isPropertyAssignment(futureProperty)) { + const futureInitializer = futureProperty.getInitializer(); + + if (Node.isObjectLiteralExpression(futureInitializer)) { + const viteEnvApiProp = futureInitializer.getProperty( + "unstable_viteEnvironmentApi", + ); + + if (!viteEnvApiProp) { + futureInitializer.addPropertyAssignment({ + name: "unstable_viteEnvironmentApi", + initializer: "true", + }); + } else if (Node.isPropertyAssignment(viteEnvApiProp)) { + const value = viteEnvApiProp.getInitializer()?.getText(); + if (value === "false") { + viteEnvApiProp.setInitializer("true"); + } + } + } + } + + await project.save(); + } catch (error) { + console.warn("Failed to update react-router.config.ts:", error); + } + } +} diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts new file mode 100644 index 0000000..ca2dd47 --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-solid-setup.ts @@ -0,0 +1,30 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupSolidAlchemyDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + devDependencies: ["alchemy", "dotenv"], + projectDir: webAppDir, + }); + + const pkgPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(pkgPath)) { + const pkg = await fs.readJson(pkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + } +} diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts new file mode 100644 index 0000000..963c392 --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-svelte-setup.ts @@ -0,0 +1,98 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { IndentationText, Node, Project, QuoteKind } from "ts-morph"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupSvelteAlchemyDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + devDependencies: ["alchemy", "@sveltejs/adapter-cloudflare", "dotenv"], + projectDir: webAppDir, + }); + + const pkgPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(pkgPath)) { + const pkg = await fs.readJson(pkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + } + + const svelteConfigPath = path.join(webAppDir, "svelte.config.js"); + if (!(await fs.pathExists(svelteConfigPath))) return; + + try { + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + quoteKind: QuoteKind.Single, + }, + }); + + project.addSourceFileAtPath(svelteConfigPath); + const sourceFile = project.getSourceFileOrThrow(svelteConfigPath); + + const importDeclarations = sourceFile.getImportDeclarations(); + const adapterImport = importDeclarations.find((imp) => + imp.getModuleSpecifierValue().includes("@sveltejs/adapter"), + ); + + if (adapterImport) { + adapterImport.setModuleSpecifier("alchemy/cloudflare/sveltekit"); + adapterImport.removeDefaultImport(); + adapterImport.setDefaultImport("alchemy"); + } else { + sourceFile.insertImportDeclaration(0, { + moduleSpecifier: "alchemy/cloudflare/sveltekit", + defaultImport: "alchemy", + }); + } + + const configVariable = sourceFile.getVariableDeclaration("config"); + if (configVariable) { + const initializer = configVariable.getInitializer(); + if (Node.isObjectLiteralExpression(initializer)) { + updateAdapterInConfig(initializer); + } + } + + await project.save(); + } catch (error) { + console.warn("Failed to update svelte.config.js:", error); + } +} + +function updateAdapterInConfig(configObject: Node): void { + if (!Node.isObjectLiteralExpression(configObject)) return; + + const kitProperty = configObject.getProperty("kit"); + if (kitProperty && Node.isPropertyAssignment(kitProperty)) { + const kitInitializer = kitProperty.getInitializer(); + if (Node.isObjectLiteralExpression(kitInitializer)) { + const adapterProperty = kitInitializer.getProperty("adapter"); + if (adapterProperty && Node.isPropertyAssignment(adapterProperty)) { + const initializer = adapterProperty.getInitializer(); + if (Node.isCallExpression(initializer)) { + const expression = initializer.getExpression(); + if ( + Node.isIdentifier(expression) && + expression.getText() === "adapter" + ) { + expression.replaceWithText("alchemy"); + } + } + } + } + } +} diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts new file mode 100644 index 0000000..3f78ced --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-router-setup.ts @@ -0,0 +1,30 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupTanStackRouterAlchemyDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + devDependencies: ["alchemy", "dotenv"], + projectDir: webAppDir, + }); + + const pkgPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(pkgPath)) { + const pkg = await fs.readJson(pkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + } +} diff --git a/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts new file mode 100644 index 0000000..39ad9d0 --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/alchemy-tanstack-start-setup.ts @@ -0,0 +1,158 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { IndentationText, Node, Project, QuoteKind } from "ts-morph"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupTanStackStartAlchemyDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + devDependencies: ["alchemy", "nitropack", "dotenv"], + projectDir: webAppDir, + }); + + const pkgPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(pkgPath)) { + const pkg = await fs.readJson(pkgPath); + + pkg.scripts = { + ...pkg.scripts, + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + } + + const viteConfigPath = path.join(webAppDir, "vite.config.ts"); + if (await fs.pathExists(viteConfigPath)) { + try { + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + quoteKind: QuoteKind.Double, + }, + }); + + project.addSourceFileAtPath(viteConfigPath); + const sourceFile = project.getSourceFileOrThrow(viteConfigPath); + + const alchemyImport = sourceFile.getImportDeclaration( + "alchemy/cloudflare/tanstack-start", + ); + if (!alchemyImport) { + sourceFile.addImportDeclaration({ + moduleSpecifier: "alchemy/cloudflare/tanstack-start", + defaultImport: "alchemy", + }); + } else { + alchemyImport.setModuleSpecifier("alchemy/cloudflare/tanstack-start"); + } + + const exportAssignment = sourceFile.getExportAssignment( + (d) => !d.isExportEquals(), + ); + if (!exportAssignment) return; + + const defineConfigCall = exportAssignment.getExpression(); + if ( + !Node.isCallExpression(defineConfigCall) || + defineConfigCall.getExpression().getText() !== "defineConfig" + ) + return; + + let configObject = defineConfigCall.getArguments()[0]; + if (!configObject) { + configObject = defineConfigCall.addArgument("{}"); + } + + if (Node.isObjectLiteralExpression(configObject)) { + if (!configObject.getProperty("build")) { + configObject.addPropertyAssignment({ + name: "build", + initializer: `{ + target: "esnext", + rollupOptions: { + external: ["node:async_hooks", "cloudflare:workers"], + }, + }`, + }); + } + + const pluginsProperty = configObject.getProperty("plugins"); + if (pluginsProperty && Node.isPropertyAssignment(pluginsProperty)) { + const initializer = pluginsProperty.getInitializer(); + if (Node.isArrayLiteralExpression(initializer)) { + const hasShim = initializer + .getElements() + .some((el) => el.getText().includes("alchemy")); + if (!hasShim) { + initializer.addElement("alchemy()"); + } + + const tanstackElements = initializer + .getElements() + .filter((el) => el.getText().includes("tanstackStart")); + + tanstackElements.forEach((element) => { + if (Node.isCallExpression(element)) { + const args = element.getArguments(); + if (args.length === 0) { + element.addArgument(`{ + target: "cloudflare-module", + customViteReactPlugin: true, + }`); + } else if ( + args.length === 1 && + Node.isObjectLiteralExpression(args[0]) + ) { + const configObj = args[0]; + if (!configObj.getProperty("target")) { + configObj.addPropertyAssignment({ + name: "target", + initializer: '"cloudflare-module"', + }); + } + if (!configObj.getProperty("customViteReactPlugin")) { + configObj.addPropertyAssignment({ + name: "customViteReactPlugin", + initializer: "true", + }); + } + } + } + }); + } + } else { + configObject.addPropertyAssignment({ + name: "plugins", + initializer: "[alchemy()]", + }); + } + } + + await project.save(); + } catch (error) { + console.warn("Failed to update vite.config.ts:", error); + } + } + + // workaround for tanstack start + workers + const nitroConfigPath = path.join(webAppDir, "nitro.config.ts"); + const nitroConfigContent = `import { defineNitroConfig } from "nitropack/config"; + +export default defineNitroConfig({ + preset: "cloudflare-module", + cloudflare: { + nodeCompat: true, + }, +}); +`; + + await fs.writeFile(nitroConfigPath, nitroConfigContent, "utf-8"); +} diff --git a/apps/cli/src/helpers/deployment/alchemy/index.ts b/apps/cli/src/helpers/deployment/alchemy/index.ts new file mode 100644 index 0000000..030193e --- /dev/null +++ b/apps/cli/src/helpers/deployment/alchemy/index.ts @@ -0,0 +1,7 @@ +export { setupNextAlchemyDeploy } from "./alchemy-next-setup"; +export { setupNuxtAlchemyDeploy } from "./alchemy-nuxt-setup"; +export { setupReactRouterAlchemyDeploy } from "./alchemy-react-router-setup"; +export { setupSolidAlchemyDeploy } from "./alchemy-solid-setup"; +export { setupSvelteAlchemyDeploy } from "./alchemy-svelte-setup"; +export { setupTanStackRouterAlchemyDeploy } from "./alchemy-tanstack-router-setup"; +export { setupTanStackStartAlchemyDeploy } from "./alchemy-tanstack-start-setup"; diff --git a/apps/cli/src/helpers/deployment/server-deploy-setup.ts b/apps/cli/src/helpers/deployment/server-deploy-setup.ts new file mode 100644 index 0000000..7eeda04 --- /dev/null +++ b/apps/cli/src/helpers/deployment/server-deploy-setup.ts @@ -0,0 +1,111 @@ +import path from "node:path"; +import { log, spinner } from "@clack/prompts"; +import { execa } from "execa"; +import fs from "fs-extra"; +import pc from "picocolors"; +import type { PackageManager, ProjectConfig } from "../../types"; +import { addPackageDependency } from "../../utils/add-package-deps"; + +export async function setupServerDeploy(config: ProjectConfig) { + const { serverDeploy, webDeploy, projectDir } = config; + const { packageManager } = config; + + if (serverDeploy === "none") return; + + if (serverDeploy === "alchemy" && webDeploy === "alchemy") { + return; + } + + const serverDir = path.join(projectDir, "apps/server"); + if (!(await fs.pathExists(serverDir))) return; + + if (serverDeploy === "wrangler") { + await setupWorkersServerDeploy(serverDir, packageManager); + await generateCloudflareWorkerTypes({ serverDir, packageManager }); + } else if (serverDeploy === "alchemy") { + await setupAlchemyServerDeploy(serverDir, packageManager); + } +} + +async function setupWorkersServerDeploy( + serverDir: string, + _packageManager: PackageManager, +) { + const packageJsonPath = path.join(serverDir, "package.json"); + if (!(await fs.pathExists(packageJsonPath))) return; + + const packageJson = await fs.readJson(packageJsonPath); + + packageJson.scripts = { + ...packageJson.scripts, + dev: "wrangler dev --port=3000", + start: "wrangler dev", + deploy: "wrangler deploy", + build: "wrangler deploy --dry-run", + "cf-typegen": "wrangler types --env-interface CloudflareBindings", + }; + + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + + await addPackageDependency({ + devDependencies: ["wrangler", "@types/node", "@cloudflare/workers-types"], + projectDir: serverDir, + }); +} + +async function generateCloudflareWorkerTypes({ + serverDir, + packageManager, +}: { + serverDir: string; + packageManager: ProjectConfig["packageManager"]; +}) { + if (!(await fs.pathExists(serverDir))) return; + const s = spinner(); + try { + s.start("Generating Cloudflare Workers types..."); + const runCmd = packageManager === "npm" ? "npm" : packageManager; + await execa(runCmd, ["run", "cf-typegen"], { cwd: serverDir }); + s.stop("Cloudflare Workers types generated successfully!"); + } catch { + s.stop(pc.yellow("Failed to generate Cloudflare Workers types")); + const managerCmd = `${packageManager} run`; + log.warn( + `Note: You can manually run 'cd apps/server && ${managerCmd} cf-typegen' in the project directory later`, + ); + } +} + +export async function setupAlchemyServerDeploy( + serverDir: string, + _packageManager: PackageManager, +) { + if (!(await fs.pathExists(serverDir))) return; + + await addPackageDependency({ + devDependencies: [ + "alchemy", + "wrangler", + "@types/node", + "@cloudflare/workers-types", + "dotenv", + ], + projectDir: serverDir, + }); + + const packageJsonPath = path.join(serverDir, "package.json"); + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJson(packageJsonPath); + + packageJson.scripts = { + ...packageJson.scripts, + dev: "wrangler dev --port=3000", + build: "wrangler deploy --dry-run", + deploy: "alchemy deploy", + destroy: "alchemy destroy", + "alchemy:dev": "alchemy dev", + }; + + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + } +} diff --git a/apps/cli/src/helpers/deployment/web-deploy-setup.ts b/apps/cli/src/helpers/deployment/web-deploy-setup.ts new file mode 100644 index 0000000..f7265e3 --- /dev/null +++ b/apps/cli/src/helpers/deployment/web-deploy-setup.ts @@ -0,0 +1,94 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { PackageManager, ProjectConfig } from "../../types"; +import { setupCombinedAlchemyDeploy } from "./alchemy/alchemy-combined-setup"; +import { setupNextAlchemyDeploy } from "./alchemy/alchemy-next-setup"; +import { setupNuxtAlchemyDeploy } from "./alchemy/alchemy-nuxt-setup"; +import { setupReactRouterAlchemyDeploy } from "./alchemy/alchemy-react-router-setup"; +import { setupSolidAlchemyDeploy } from "./alchemy/alchemy-solid-setup"; +import { setupSvelteAlchemyDeploy } from "./alchemy/alchemy-svelte-setup"; +import { setupTanStackRouterAlchemyDeploy } from "./alchemy/alchemy-tanstack-router-setup"; +import { setupTanStackStartAlchemyDeploy } from "./alchemy/alchemy-tanstack-start-setup"; +import { setupNextWorkersDeploy } from "./workers/workers-next-setup"; +import { setupNuxtWorkersDeploy } from "./workers/workers-nuxt-setup"; +import { setupSvelteWorkersDeploy } from "./workers/workers-svelte-setup"; +import { setupTanstackStartWorkersDeploy } from "./workers/workers-tanstack-start-setup"; +import { setupWorkersVitePlugin } from "./workers/workers-vite-setup"; + +export async function setupWebDeploy(config: ProjectConfig) { + const { webDeploy, serverDeploy, frontend, projectDir } = config; + const { packageManager } = config; + + if (webDeploy === "none") return; + + if (webDeploy !== "wrangler" && webDeploy !== "alchemy") return; + + if (webDeploy === "alchemy" && serverDeploy === "alchemy") { + await setupCombinedAlchemyDeploy(projectDir, packageManager, config); + return; + } + + const isNext = frontend.includes("next"); + const isNuxt = frontend.includes("nuxt"); + const isSvelte = frontend.includes("svelte"); + const isTanstackRouter = frontend.includes("tanstack-router"); + const isTanstackStart = frontend.includes("tanstack-start"); + const isReactRouter = frontend.includes("react-router"); + const isSolid = frontend.includes("solid"); + + if (webDeploy === "wrangler") { + if (isNext) { + await setupNextWorkersDeploy(projectDir, packageManager); + } else if (isNuxt) { + await setupNuxtWorkersDeploy(projectDir, packageManager); + } else if (isSvelte) { + await setupSvelteWorkersDeploy(projectDir, packageManager); + } else if (isTanstackStart) { + await setupTanstackStartWorkersDeploy(projectDir, packageManager); + } else if (isTanstackRouter || isReactRouter || isSolid) { + await setupWorkersWebDeploy(projectDir, packageManager); + } + } else if (webDeploy === "alchemy") { + if (isNext) { + await setupNextAlchemyDeploy(projectDir, packageManager); + } else if (isNuxt) { + await setupNuxtAlchemyDeploy(projectDir, packageManager); + } else if (isSvelte) { + await setupSvelteAlchemyDeploy(projectDir, packageManager); + } else if (isTanstackStart) { + await setupTanStackStartAlchemyDeploy(projectDir, packageManager); + } else if (isTanstackRouter) { + await setupTanStackRouterAlchemyDeploy(projectDir, packageManager); + } else if (isReactRouter) { + await setupReactRouterAlchemyDeploy(projectDir, packageManager); + } else if (isSolid) { + await setupSolidAlchemyDeploy(projectDir, packageManager); + } + } +} + +async function setupWorkersWebDeploy( + projectDir: string, + pkgManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + + if (!(await fs.pathExists(webAppDir))) { + return; + } + + const packageJsonPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJson(packageJsonPath); + + packageJson.scripts = { + ...packageJson.scripts, + "wrangler:dev": "wrangler dev --port=3001", + deploy: `${pkgManager} run build && wrangler deploy`, + }; + + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + } + + await setupWorkersVitePlugin(projectDir); +} diff --git a/apps/cli/src/helpers/deployment/workers/workers-next-setup.ts b/apps/cli/src/helpers/deployment/workers/workers-next-setup.ts new file mode 100644 index 0000000..9e82d43 --- /dev/null +++ b/apps/cli/src/helpers/deployment/workers/workers-next-setup.ts @@ -0,0 +1,34 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; + +export async function setupNextWorkersDeploy( + projectDir: string, + _packageManager: PackageManager, +) { + const webAppDir = path.join(projectDir, "apps/web"); + if (!(await fs.pathExists(webAppDir))) return; + + await addPackageDependency({ + dependencies: ["@opennextjs/cloudflare"], + devDependencies: ["wrangler"], + projectDir: webAppDir, + }); + + const packageJsonPath = path.join(webAppDir, "package.json"); + if (await fs.pathExists(packageJsonPath)) { + const pkg = await fs.readJson(packageJsonPath); + + pkg.scripts = { + ...pkg.scripts, + preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview", + deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + upload: "opennextjs-cloudflare build && opennextjs-cloudflare upload", + "cf-typegen": + "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", + }; + + await fs.writeJson(packageJsonPath, pkg, { spaces: 2 }); + } +} diff --git a/apps/cli/src/helpers/setup/workers-nuxt-setup.ts b/apps/cli/src/helpers/deployment/workers/workers-nuxt-setup.ts similarity index 94% rename from apps/cli/src/helpers/setup/workers-nuxt-setup.ts rename to apps/cli/src/helpers/deployment/workers/workers-nuxt-setup.ts index 03457c5..13eb41d 100644 --- a/apps/cli/src/helpers/setup/workers-nuxt-setup.ts +++ b/apps/cli/src/helpers/deployment/workers/workers-nuxt-setup.ts @@ -8,9 +8,9 @@ import { type PropertyAssignment, SyntaxKind, } from "ts-morph"; -import type { PackageManager } from "../../types"; -import { addPackageDependency } from "../../utils/add-package-deps"; -import { tsProject } from "../../utils/ts-morph"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; +import { tsProject } from "../../../utils/ts-morph"; export async function setupNuxtWorkersDeploy( projectDir: string, diff --git a/apps/cli/src/helpers/setup/workers-svelte-setup.ts b/apps/cli/src/helpers/deployment/workers/workers-svelte-setup.ts similarity index 91% rename from apps/cli/src/helpers/setup/workers-svelte-setup.ts rename to apps/cli/src/helpers/deployment/workers/workers-svelte-setup.ts index d77c8d5..d17910b 100644 --- a/apps/cli/src/helpers/setup/workers-svelte-setup.ts +++ b/apps/cli/src/helpers/deployment/workers/workers-svelte-setup.ts @@ -1,9 +1,9 @@ import path from "node:path"; import fs from "fs-extra"; import type { ImportDeclaration } from "ts-morph"; -import type { PackageManager } from "../../types"; -import { addPackageDependency } from "../../utils/add-package-deps"; -import { tsProject } from "../../utils/ts-morph"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; +import { tsProject } from "../../../utils/ts-morph"; export async function setupSvelteWorkersDeploy( projectDir: string, diff --git a/apps/cli/src/helpers/setup/workers-tanstack-start-setup.ts b/apps/cli/src/helpers/deployment/workers/workers-tanstack-start-setup.ts similarity index 90% rename from apps/cli/src/helpers/setup/workers-tanstack-start-setup.ts rename to apps/cli/src/helpers/deployment/workers/workers-tanstack-start-setup.ts index 721a798..32b09a3 100644 --- a/apps/cli/src/helpers/setup/workers-tanstack-start-setup.ts +++ b/apps/cli/src/helpers/deployment/workers/workers-tanstack-start-setup.ts @@ -6,9 +6,9 @@ import { type ObjectLiteralExpression, SyntaxKind, } from "ts-morph"; -import type { PackageManager } from "../../types"; -import { addPackageDependency } from "../../utils/add-package-deps"; -import { ensureArrayProperty, tsProject } from "../../utils/ts-morph"; +import type { PackageManager } from "../../../types"; +import { addPackageDependency } from "../../../utils/add-package-deps"; +import { ensureArrayProperty, tsProject } from "../../../utils/ts-morph"; export async function setupTanstackStartWorkersDeploy( projectDir: string, diff --git a/apps/cli/src/helpers/setup/workers-vite-setup.ts b/apps/cli/src/helpers/deployment/workers/workers-vite-setup.ts similarity index 92% rename from apps/cli/src/helpers/setup/workers-vite-setup.ts rename to apps/cli/src/helpers/deployment/workers/workers-vite-setup.ts index 9ba5590..5268c6b 100644 --- a/apps/cli/src/helpers/setup/workers-vite-setup.ts +++ b/apps/cli/src/helpers/deployment/workers/workers-vite-setup.ts @@ -6,8 +6,8 @@ import { type ObjectLiteralExpression, SyntaxKind, } from "ts-morph"; -import { addPackageDependency } from "../../utils/add-package-deps"; -import { ensureArrayProperty, tsProject } from "../../utils/ts-morph"; +import { addPackageDependency } from "../../../utils/add-package-deps"; +import { ensureArrayProperty, tsProject } from "../../../utils/ts-morph"; export async function setupWorkersVitePlugin(projectDir: string) { const webAppDir = path.join(projectDir, "apps/web"); diff --git a/apps/cli/src/helpers/setup/api-setup.ts b/apps/cli/src/helpers/setup/api-setup.ts deleted file mode 100644 index 7a92e59..0000000 --- a/apps/cli/src/helpers/setup/api-setup.ts +++ /dev/null @@ -1,284 +0,0 @@ -import path from "node:path"; -import fs from "fs-extra"; -import type { AvailableDependencies } from "../../constants"; -import type { Frontend, ProjectConfig } from "../../types"; -import { addPackageDependency } from "../../utils/add-package-deps"; - -export async function setupApi(config: ProjectConfig) { - const { api, projectName, frontend, backend, packageManager, projectDir } = - config; - const isConvex = backend === "convex"; - const webDir = path.join(projectDir, "apps/web"); - const nativeDir = path.join(projectDir, "apps/native"); - const webDirExists = await fs.pathExists(webDir); - const nativeDirExists = await fs.pathExists(nativeDir); - - const hasReactWeb = frontend.some((f) => - ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), - ); - const hasNuxtWeb = frontend.includes("nuxt"); - const hasSvelteWeb = frontend.includes("svelte"); - const hasSolidWeb = frontend.includes("solid"); - - if (!isConvex && api !== "none") { - const serverDir = path.join(projectDir, "apps/server"); - const serverDirExists = await fs.pathExists(serverDir); - - if (serverDirExists) { - if (api === "orpc") { - await addPackageDependency({ - dependencies: ["@orpc/server", "@orpc/client"], - projectDir: serverDir, - }); - } else if (api === "trpc") { - await addPackageDependency({ - dependencies: ["@trpc/server", "@trpc/client"], - projectDir: serverDir, - }); - if (config.backend === "hono") { - await addPackageDependency({ - dependencies: ["@hono/trpc-server"], - projectDir: serverDir, - }); - } else if (config.backend === "elysia") { - await addPackageDependency({ - dependencies: ["@elysiajs/trpc"], - projectDir: serverDir, - }); - } - } - } else { - } - - if (webDirExists) { - if (hasReactWeb) { - if (api === "orpc") { - await addPackageDependency({ - dependencies: ["@orpc/tanstack-query", "@orpc/client"], - projectDir: webDir, - }); - } else if (api === "trpc") { - await addPackageDependency({ - dependencies: [ - "@trpc/tanstack-react-query", - "@trpc/client", - "@trpc/server", - ], - projectDir: webDir, - }); - } - } else if (hasNuxtWeb) { - if (api === "orpc") { - await addPackageDependency({ - dependencies: [ - "@tanstack/vue-query", - "@tanstack/vue-query-devtools", - "@orpc/tanstack-query", - "@orpc/client", - ], - projectDir: webDir, - }); - } - } else if (hasSvelteWeb) { - if (api === "orpc") { - await addPackageDependency({ - dependencies: [ - "@orpc/tanstack-query", - "@orpc/client", - "@tanstack/svelte-query", - ], - projectDir: webDir, - }); - } - } else if (hasSolidWeb) { - if (api === "orpc") { - await addPackageDependency({ - dependencies: [ - "@orpc/tanstack-query", - "@orpc/client", - "@tanstack/solid-query", - ], - projectDir: webDir, - }); - } - } - } - - if (nativeDirExists) { - if (api === "trpc") { - await addPackageDependency({ - dependencies: [ - "@trpc/tanstack-react-query", - "@trpc/client", - "@trpc/server", - ], - projectDir: nativeDir, - }); - } else if (api === "orpc") { - await addPackageDependency({ - dependencies: ["@orpc/tanstack-query", "@orpc/client"], - projectDir: nativeDir, - }); - } - } - } - - const reactBasedFrontends: Frontend[] = [ - "react-router", - "tanstack-router", - "tanstack-start", - "next", - "native-nativewind", - "native-unistyles", - ]; - const needsSolidQuery = frontend.includes("solid"); - const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f)); - - if (needsReactQuery && !isConvex) { - const reactQueryDeps: AvailableDependencies[] = ["@tanstack/react-query"]; - const reactQueryDevDeps: AvailableDependencies[] = [ - "@tanstack/react-query-devtools", - ]; - - const hasReactWeb = frontend.some( - (f) => - f !== "native-nativewind" && - f !== "native-unistyles" && - reactBasedFrontends.includes(f), - ); - const hasNative = - frontend.includes("native-nativewind") || - frontend.includes("native-unistyles"); - - if (hasReactWeb && webDirExists) { - const webPkgJsonPath = path.join(webDir, "package.json"); - if (await fs.pathExists(webPkgJsonPath)) { - try { - await addPackageDependency({ - dependencies: reactQueryDeps, - devDependencies: reactQueryDevDeps, - projectDir: webDir, - }); - } catch (_error) {} - } else { - } - } - - if (hasNative && nativeDirExists) { - const nativePkgJsonPath = path.join(nativeDir, "package.json"); - if (await fs.pathExists(nativePkgJsonPath)) { - try { - await addPackageDependency({ - dependencies: reactQueryDeps, - projectDir: nativeDir, - }); - } catch (_error) {} - } else { - } - } - } - - if (needsSolidQuery && !isConvex) { - const solidQueryDeps: AvailableDependencies[] = ["@tanstack/solid-query"]; - const solidQueryDevDeps: AvailableDependencies[] = [ - "@tanstack/solid-query-devtools", - ]; - - if (webDirExists) { - const webPkgJsonPath = path.join(webDir, "package.json"); - if (await fs.pathExists(webPkgJsonPath)) { - try { - await addPackageDependency({ - dependencies: solidQueryDeps, - devDependencies: solidQueryDevDeps, - projectDir: webDir, - }); - } catch (_error) {} - } - } - } - - if (isConvex) { - if (webDirExists) { - const webPkgJsonPath = path.join(webDir, "package.json"); - if (await fs.pathExists(webPkgJsonPath)) { - try { - const webDepsToAdd: AvailableDependencies[] = ["convex"]; - if (frontend.includes("tanstack-start")) { - webDepsToAdd.push("@convex-dev/react-query"); - } - if (hasSvelteWeb) { - webDepsToAdd.push("convex-svelte"); - } - if (hasNuxtWeb) { - webDepsToAdd.push("convex-nuxt"); - webDepsToAdd.push("convex-vue"); - } - await addPackageDependency({ - dependencies: webDepsToAdd, - projectDir: webDir, - }); - } catch (_error) {} - } else { - } - } - - if (nativeDirExists) { - const nativePkgJsonPath = path.join(nativeDir, "package.json"); - if (await fs.pathExists(nativePkgJsonPath)) { - try { - await addPackageDependency({ - dependencies: ["convex"], - projectDir: nativeDir, - }); - } catch (_error) {} - } else { - } - } - - const backendPackageName = `@${projectName}/backend`; - const backendWorkspaceVersion = - packageManager === "npm" ? "*" : "workspace:*"; - const addWorkspaceDepManually = async ( - pkgJsonPath: string, - depName: string, - depVersion: string, - ) => { - try { - const pkgJson = await fs.readJson(pkgJsonPath); - if (!pkgJson.dependencies) { - pkgJson.dependencies = {}; - } - if (pkgJson.dependencies[depName] !== depVersion) { - pkgJson.dependencies[depName] = depVersion; - await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); - } else { - } - } catch (_error) {} - }; - - if (webDirExists) { - const webPkgJsonPath = path.join(webDir, "package.json"); - if (await fs.pathExists(webPkgJsonPath)) { - await addWorkspaceDepManually( - webPkgJsonPath, - backendPackageName, - backendWorkspaceVersion, - ); - } else { - } - } - - if (nativeDirExists) { - const nativePkgJsonPath = path.join(nativeDir, "package.json"); - if (await fs.pathExists(nativePkgJsonPath)) { - await addWorkspaceDepManually( - nativePkgJsonPath, - backendPackageName, - backendWorkspaceVersion, - ); - } else { - } - } - } -} diff --git a/apps/cli/src/helpers/setup/web-deploy-setup.ts b/apps/cli/src/helpers/setup/web-deploy-setup.ts deleted file mode 100644 index f82d3db..0000000 --- a/apps/cli/src/helpers/setup/web-deploy-setup.ts +++ /dev/null @@ -1,93 +0,0 @@ -import path from "node:path"; -import fs from "fs-extra"; -import type { PackageManager, ProjectConfig } from "../../types"; -import { addPackageDependency } from "../../utils/add-package-deps"; -import { setupNuxtWorkersDeploy } from "./workers-nuxt-setup"; -import { setupSvelteWorkersDeploy } from "./workers-svelte-setup"; -import { setupTanstackStartWorkersDeploy } from "./workers-tanstack-start-setup"; -import { setupWorkersVitePlugin } from "./workers-vite-setup"; - -export async function setupWebDeploy(config: ProjectConfig) { - const { webDeploy, frontend, projectDir } = config; - const { packageManager } = config; - - if (webDeploy === "none") return; - - if (webDeploy !== "workers") return; - - const isNext = frontend.includes("next"); - const isNuxt = frontend.includes("nuxt"); - const isSvelte = frontend.includes("svelte"); - const isTanstackRouter = frontend.includes("tanstack-router"); - const isTanstackStart = frontend.includes("tanstack-start"); - const isReactRouter = frontend.includes("react-router"); - const isSolid = frontend.includes("solid"); - - if (isNext) { - await setupNextWorkersDeploy(projectDir, packageManager); - } else if (isNuxt) { - await setupNuxtWorkersDeploy(projectDir, packageManager); - } else if (isSvelte) { - await setupSvelteWorkersDeploy(projectDir, packageManager); - } else if (isTanstackStart) { - await setupTanstackStartWorkersDeploy(projectDir, packageManager); - } else if (isTanstackRouter || isReactRouter || isSolid) { - await setupWorkersWebDeploy(projectDir, packageManager); - } -} - -async function setupWorkersWebDeploy( - projectDir: string, - pkgManager: PackageManager, -) { - const webAppDir = path.join(projectDir, "apps/web"); - - if (!(await fs.pathExists(webAppDir))) { - return; - } - - const packageJsonPath = path.join(webAppDir, "package.json"); - if (await fs.pathExists(packageJsonPath)) { - const packageJson = await fs.readJson(packageJsonPath); - - packageJson.scripts = { - ...packageJson.scripts, - "wrangler:dev": "wrangler dev --port=3001", - deploy: `${pkgManager} run build && wrangler deploy`, - }; - - await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); - } - - await setupWorkersVitePlugin(projectDir); -} - -async function setupNextWorkersDeploy( - projectDir: string, - _packageManager: PackageManager, -) { - const webAppDir = path.join(projectDir, "apps/web"); - if (!(await fs.pathExists(webAppDir))) return; - - await addPackageDependency({ - dependencies: ["@opennextjs/cloudflare"], - devDependencies: ["wrangler"], - projectDir: webAppDir, - }); - - const packageJsonPath = path.join(webAppDir, "package.json"); - if (await fs.pathExists(packageJsonPath)) { - const pkg = await fs.readJson(packageJsonPath); - - pkg.scripts = { - ...pkg.scripts, - preview: "opennextjs-cloudflare build && opennextjs-cloudflare preview", - deploy: "opennextjs-cloudflare build && opennextjs-cloudflare deploy", - upload: "opennextjs-cloudflare build && opennextjs-cloudflare upload", - "cf-typegen": - "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", - }; - - await fs.writeJson(packageJsonPath, pkg, { spaces: 2 }); - } -} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 57bc7d5..6f142cb 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -5,7 +5,7 @@ import z from "zod"; import { addAddonsHandler, createProjectHandler, -} from "./helpers/project-generation/command-handlers"; +} from "./helpers/core/command-handlers"; import { type AddInput, type Addons, @@ -35,6 +35,8 @@ import { ProjectNameSchema, type Runtime, RuntimeSchema, + type ServerDeploy, + ServerDeploySchema, type WebDeploy, WebDeploySchema, } from "./types"; @@ -88,6 +90,7 @@ export const router = t.router({ runtime: RuntimeSchema.optional(), api: APISchema.optional(), webDeploy: WebDeploySchema.optional(), + serverDeploy: ServerDeploySchema.optional(), directoryConflict: DirectoryConflictSchema.optional(), renderTitle: z.boolean().optional(), disableAnalytics: z @@ -120,6 +123,7 @@ export const router = t.router({ z.object({ addons: z.array(AddonsSchema).optional().default([]), webDeploy: WebDeploySchema.optional(), + serverDeploy: ServerDeploySchema.optional(), projectDir: z.string().optional(), install: z .boolean() @@ -251,6 +255,7 @@ export type { DatabaseSetup, API, WebDeploy, + ServerDeploy, DirectoryConflict, CreateInput, AddInput, diff --git a/apps/cli/src/prompts/config-prompts.ts b/apps/cli/src/prompts/config-prompts.ts index daedd3f..50deae9 100644 --- a/apps/cli/src/prompts/config-prompts.ts +++ b/apps/cli/src/prompts/config-prompts.ts @@ -11,6 +11,7 @@ import type { PackageManager, ProjectConfig, Runtime, + ServerDeploy, WebDeploy, } from "../types"; import { exitCancelled } from "../utils/errors"; @@ -27,6 +28,7 @@ import { getinstallChoice } from "./install"; import { getORMChoice } from "./orm"; import { getPackageManagerChoice } from "./package-manager"; import { getRuntimeChoice } from "./runtime"; +import { getServerDeploymentChoice } from "./server-deploy"; import { getDeploymentChoice } from "./web-deploy"; type PromptGroupResults = { @@ -44,6 +46,7 @@ type PromptGroupResults = { packageManager: PackageManager; install: boolean; webDeploy: WebDeploy; + serverDeploy: ServerDeploy; }; export async function gatherConfig( @@ -97,6 +100,13 @@ export async function gatherConfig( results.backend, results.frontend, ), + serverDeploy: ({ results }) => + getServerDeploymentChoice( + flags.serverDeploy, + results.runtime, + results.backend, + results.webDeploy, + ), git: () => getGitChoice(flags.git), packageManager: () => getPackageManagerChoice(flags.packageManager), install: () => getinstallChoice(flags.install), @@ -144,5 +154,6 @@ export async function gatherConfig( dbSetup: result.dbSetup, api: result.api, webDeploy: result.webDeploy, + serverDeploy: result.serverDeploy, }; } diff --git a/apps/cli/src/prompts/project-name.ts b/apps/cli/src/prompts/project-name.ts index 9e1e478..a8319a2 100644 --- a/apps/cli/src/prompts/project-name.ts +++ b/apps/cli/src/prompts/project-name.ts @@ -45,8 +45,8 @@ export async function getProjectName(initialName?: string): Promise { let counter = 1; while ( - fs.pathExistsSync(path.resolve(process.cwd(), defaultName)) && - fs.readdirSync(path.resolve(process.cwd(), defaultName)).length > 0 + (await fs.pathExists(path.resolve(process.cwd(), defaultName))) && + (await fs.readdir(path.resolve(process.cwd(), defaultName))).length > 0 ) { defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`; counter++; diff --git a/apps/cli/src/prompts/server-deploy.ts b/apps/cli/src/prompts/server-deploy.ts new file mode 100644 index 0000000..b5e2c25 --- /dev/null +++ b/apps/cli/src/prompts/server-deploy.ts @@ -0,0 +1,129 @@ +import { isCancel, select } from "@clack/prompts"; +import { DEFAULT_CONFIG } from "../constants"; +import type { Backend, Runtime, ServerDeploy, WebDeploy } from "../types"; +import { exitCancelled } from "../utils/errors"; + +type DeploymentOption = { + value: ServerDeploy; + label: string; + hint: string; +}; + +function getDeploymentDisplay(deployment: ServerDeploy): { + label: string; + hint: string; +} { + if (deployment === "wrangler") { + return { + label: "Wrangler", + hint: "Deploy to Cloudflare Workers using Wrangler", + }; + } + if (deployment === "alchemy") { + return { + label: "Alchemy", + 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, +): Promise { + if (deployment !== undefined) return deployment; + + if (backend === "none" || backend === "convex") { + return "none"; + } + + const options: DeploymentOption[] = []; + + if (runtime === "workers") { + ["alchemy", "wrangler"].forEach((deploy) => { + const { label, hint } = getDeploymentDisplay(deploy as ServerDeploy); + options.unshift({ + value: deploy as ServerDeploy, + label, + hint, + }); + }); + } else { + options.push({ value: "none", label: "None", hint: "Manual setup" }); + } + + const response = await select({ + message: "Select server deployment", + options, + initialValue: + webDeploy === "alchemy" + ? "alchemy" + : runtime === "workers" + ? "wrangler" + : DEFAULT_CONFIG.serverDeploy, + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} + +export async function getServerDeploymentToAdd( + runtime?: Runtime, + existingDeployment?: ServerDeploy, +): Promise { + const options: DeploymentOption[] = []; + + if (runtime === "workers") { + if (existingDeployment !== "wrangler") { + const { label, hint } = getDeploymentDisplay("wrangler"); + options.push({ + value: "wrangler", + label, + hint, + }); + } + + if (existingDeployment !== "alchemy") { + const { label, hint } = getDeploymentDisplay("alchemy"); + options.push({ + value: "alchemy", + 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 select({ + message: "Select server deployment", + options, + initialValue: + runtime === "workers" ? "wrangler" : DEFAULT_CONFIG.serverDeploy, + }); + + if (isCancel(response)) return exitCancelled("Operation cancelled"); + + return response; +} diff --git a/apps/cli/src/prompts/web-deploy.ts b/apps/cli/src/prompts/web-deploy.ts index eff0bea..0605aba 100644 --- a/apps/cli/src/prompts/web-deploy.ts +++ b/apps/cli/src/prompts/web-deploy.ts @@ -18,12 +18,18 @@ function getDeploymentDisplay(deployment: WebDeploy): { label: string; hint: string; } { - if (deployment === "workers") { + if (deployment === "wrangler") { return { - label: "Cloudflare Workers", + label: "Wrangler", hint: "Deploy to Cloudflare Workers using Wrangler", }; } + if (deployment === "alchemy") { + return { + label: "Alchemy", + hint: "Deploy to Cloudflare Workers using Alchemy", + }; + } return { label: deployment, hint: `Add ${deployment} deployment`, @@ -41,14 +47,16 @@ export async function getDeploymentChoice( return "none"; } - const options: DeploymentOption[] = [ - { - value: "workers", - label: "Cloudflare Workers", - hint: "Deploy to Cloudflare Workers using Wrangler", + const options: DeploymentOption[] = ["wrangler", "alchemy", "none"].map( + (deploy) => { + const { label, hint } = getDeploymentDisplay(deploy as WebDeploy); + return { + value: deploy as WebDeploy, + label, + hint, + }; }, - { value: "none", label: "None", hint: "Manual setup" }, - ]; + ); const response = await select({ message: "Select web deployment", @@ -71,10 +79,19 @@ export async function getDeploymentToAdd( const options: DeploymentOption[] = []; - if (existingDeployment !== "workers") { - const { label, hint } = getDeploymentDisplay("workers"); + if (existingDeployment !== "wrangler") { + const { label, hint } = getDeploymentDisplay("wrangler"); options.push({ - value: "workers", + value: "wrangler", + label, + hint, + }); + } + + if (existingDeployment !== "alchemy") { + const { label, hint } = getDeploymentDisplay("alchemy"); + options.push({ + value: "alchemy", label, hint, }); diff --git a/apps/cli/src/types.ts b/apps/cli/src/types.ts index a769345..d33aabb 100644 --- a/apps/cli/src/types.ts +++ b/apps/cli/src/types.ts @@ -104,10 +104,15 @@ export const ProjectNameSchema = z export type ProjectName = z.infer; export const WebDeploySchema = z - .enum(["workers", "none"]) + .enum(["wrangler", "alchemy", "none"]) .describe("Web deployment"); export type WebDeploy = z.infer; +export const ServerDeploySchema = z + .enum(["wrangler", "alchemy", "none"]) + .describe("Server deployment"); +export type ServerDeploy = z.infer; + export const DirectoryConflictSchema = z .enum(["merge", "overwrite", "increment", "error"]) .describe("How to handle existing directory conflicts"); @@ -132,6 +137,7 @@ export type CreateInput = { runtime?: Runtime; api?: API; webDeploy?: WebDeploy; + serverDeploy?: ServerDeploy; directoryConflict?: DirectoryConflict; renderTitle?: boolean; disableAnalytics?: boolean; @@ -140,6 +146,7 @@ export type CreateInput = { export type AddInput = { addons?: Addons[]; webDeploy?: WebDeploy; + serverDeploy?: ServerDeploy; projectDir?: string; install?: boolean; packageManager?: PackageManager; @@ -167,6 +174,7 @@ export interface ProjectConfig { dbSetup: DatabaseSetup; api: API; webDeploy: WebDeploy; + serverDeploy: ServerDeploy; } export interface BetterTStackConfig { @@ -184,6 +192,7 @@ export interface BetterTStackConfig { dbSetup: DatabaseSetup; api: API; webDeploy: WebDeploy; + serverDeploy: ServerDeploy; } export interface InitResult { diff --git a/apps/cli/src/utils/analytics.ts b/apps/cli/src/utils/analytics.ts index 4ea89fb..8592c2c 100644 --- a/apps/cli/src/utils/analytics.ts +++ b/apps/cli/src/utils/analytics.ts @@ -5,13 +5,19 @@ import { isTelemetryEnabled } from "./telemetry"; const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || ""; const POSTHOG_HOST = process.env.POSTHOG_HOST; +function generateSessionId() { + const rand = Math.random().toString(36).slice(2); + const now = Date.now().toString(36); + return `cli_${now}${rand}`; +} + export async function trackProjectCreation( config: ProjectConfig, disableAnalytics = false, ) { if (!isTelemetryEnabled() || disableAnalytics) return; - const sessionId = `cli_${crypto.randomUUID().replace(/-/g, "")}`; + const sessionId = generateSessionId(); // biome-ignore lint/correctness/noUnusedVariables: `projectName`, `projectDir`, and `relativePath` are not used in the event properties const { projectName, projectDir, relativePath, ...safeConfig } = config; @@ -21,8 +27,8 @@ export async function trackProjectCreation( properties: { ...safeConfig, cli_version: getLatestCLIVersion(), - node_version: process.version, - platform: process.platform, + node_version: typeof process !== "undefined" ? process.version : "", + platform: typeof process !== "undefined" ? process.platform : "", $ip: null, }, distinct_id: sessionId, diff --git a/apps/cli/src/utils/bts-config.ts b/apps/cli/src/utils/bts-config.ts index 190200b..16f4293 100644 --- a/apps/cli/src/utils/bts-config.ts +++ b/apps/cli/src/utils/bts-config.ts @@ -22,6 +22,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { dbSetup: projectConfig.dbSetup, api: projectConfig.api, webDeploy: projectConfig.webDeploy, + serverDeploy: projectConfig.serverDeploy, }; const baseContent = { @@ -40,6 +41,7 @@ export async function writeBtsConfig(projectConfig: ProjectConfig) { dbSetup: btsConfig.dbSetup, api: btsConfig.api, webDeploy: btsConfig.webDeploy, + serverDeploy: btsConfig.serverDeploy, }; let configContent = JSON.stringify(baseContent); @@ -91,7 +93,9 @@ export async function readBtsConfig( export async function updateBtsConfig( projectDir: string, - updates: Partial>, + updates: Partial< + Pick + >, ) { try { const configPath = path.join(projectDir, BTS_CONFIG_FILE); diff --git a/apps/cli/src/utils/compatibility-rules.ts b/apps/cli/src/utils/compatibility-rules.ts index c7b4b6f..08d2cfc 100644 --- a/apps/cli/src/utils/compatibility-rules.ts +++ b/apps/cli/src/utils/compatibility-rules.ts @@ -1,9 +1,11 @@ import type { Addons, API, + Backend, CLIInput, Frontend, ProjectConfig, + ServerDeploy, WebDeploy, } from "../types"; import { validateAddonCompatibility } from "./addon-compatibility"; @@ -252,6 +254,21 @@ export function validateWebDeployRequiresWebFrontend( } } +export function validateServerDeployRequiresBackend( + serverDeploy: ServerDeploy | undefined, + backend: Backend | undefined, +) { + if ( + serverDeploy && + serverDeploy !== "none" && + (!backend || backend === "none") + ) { + exitWithError( + "'--server-deploy' requires a backend. Please select a backend or set '--server-deploy none'.", + ); + } +} + export function validateAddonsAgainstFrontends( addons: Addons[] = [], frontends: Frontend[] = [], @@ -297,3 +314,31 @@ export function validateExamplesCompatibility( ); } } + +export function validateAlchemyCompatibility( + webDeploy: WebDeploy | undefined, + serverDeploy: ServerDeploy | undefined, + frontends: Frontend[] = [], +) { + const isAlchemyWebDeploy = webDeploy === "alchemy"; + const isAlchemyServerDeploy = serverDeploy === "alchemy"; + + if (isAlchemyWebDeploy || isAlchemyServerDeploy) { + const incompatibleFrontends = frontends.filter( + (f) => f === "next" || f === "react-router", + ); + + if (incompatibleFrontends.length > 0) { + const deployType = + isAlchemyWebDeploy && isAlchemyServerDeploy + ? "web and server deployment" + : isAlchemyWebDeploy + ? "web deployment" + : "server deployment"; + + exitWithError( + `Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")} frontend(s). Please choose a different frontend or deployment option.`, + ); + } + } +} diff --git a/apps/cli/src/utils/config-processing.ts b/apps/cli/src/utils/config-processing.ts new file mode 100644 index 0000000..a57299b --- /dev/null +++ b/apps/cli/src/utils/config-processing.ts @@ -0,0 +1,134 @@ +import path from "node:path"; +import type { + API, + Backend, + CLIInput, + Database, + DatabaseSetup, + ORM, + PackageManager, + ProjectConfig, + Runtime, + ServerDeploy, + WebDeploy, +} from "../types"; + +export function processArrayOption( + options: (T | "none")[] | undefined, +): T[] { + 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, +): string { + if (projectName) { + return projectName; + } + if (projectDirectory) { + return path.basename(path.resolve(process.cwd(), projectDirectory)); + } + return ""; +} + +export function processFlags( + options: CLIInput, + projectName?: string, +): Partial { + const config: Partial = {}; + + if (options.api) { + config.api = options.api as API; + } + + 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; + } + + 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): Set { + return new Set( + Object.keys(options).filter( + (key) => options[key as keyof CLIInput] !== undefined, + ), + ); +} + +export function validateNoneExclusivity( + options: (T | "none")[] | undefined, + optionName: string, +): void { + if (!options || options.length === 0) return; + + if (options.includes("none" as T | "none") && options.length > 1) { + throw new Error(`Cannot combine 'none' with other ${optionName}.`); + } +} + +export function validateArrayOptions(options: CLIInput): void { + validateNoneExclusivity(options.frontend, "frontend options"); + validateNoneExclusivity(options.addons, "addons"); + validateNoneExclusivity(options.examples, "examples"); +} diff --git a/apps/cli/src/utils/config-validation.ts b/apps/cli/src/utils/config-validation.ts new file mode 100644 index 0000000..cb3f0ea --- /dev/null +++ b/apps/cli/src/utils/config-validation.ts @@ -0,0 +1,333 @@ +import type { + CLIInput, + Database, + DatabaseSetup, + ProjectConfig, + Runtime, +} from "../types"; +import { + coerceBackendPresets, + ensureSingleWebAndNative, + incompatibleFlagsForBackend, + isWebFrontend, + validateAddonsAgainstFrontends, + validateApiFrontendCompatibility, + validateExamplesCompatibility, + validateServerDeployRequiresBackend, + validateWebDeployRequiresWebFrontend, + validateWorkersCompatibility, + validateAlchemyCompatibility, +} from "./compatibility-rules"; +import { exitWithError } from "./errors"; + +export function validateDatabaseOrmAuth( + cfg: Partial, + flags?: Set, +): void { + const db = cfg.database; + const orm = cfg.orm; + const has = (k: string) => (flags ? flags.has(k) : true); + + if (has("orm") && has("database") && orm === "mongoose" && db !== "mongodb") { + exitWithError( + "Mongoose ORM requires MongoDB database. Please use '--database mongodb' or choose a different ORM.", + ); + } + + if (has("orm") && has("database") && orm === "drizzle" && db === "mongodb") { + exitWithError( + "Drizzle ORM does not support MongoDB. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", + ); + } + + if ( + has("database") && + has("orm") && + db === "mongodb" && + orm && + orm !== "mongoose" && + orm !== "prisma" && + orm !== "none" + ) { + exitWithError( + "MongoDB database requires Mongoose or Prisma ORM. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", + ); + } + + if (has("database") && has("orm") && db && db !== "none" && orm === "none") { + exitWithError( + "Database selection requires an ORM. Please choose '--orm drizzle', '--orm prisma', or '--orm mongoose'.", + ); + } + + if (has("orm") && has("database") && orm && orm !== "none" && db === "none") { + exitWithError( + "ORM selection requires a database. Please choose a database or set '--orm none'.", + ); + } + + if (has("auth") && has("database") && cfg.auth && db === "none") { + exitWithError( + "Authentication requires a database. Please choose a database or set '--no-auth'.", + ); + } + + if (cfg.auth && db === "none") { + exitWithError( + "Authentication requires a database. Please choose a database or set '--no-auth'.", + ); + } + + if (orm && orm !== "none" && db === "none") { + exitWithError( + "ORM selection requires a database. Please choose a database or set '--orm none'.", + ); + } +} + +export function validateDatabaseSetup( + config: Partial, + providedFlags: Set, +): void { + const { dbSetup, database, runtime } = config; + + if ( + providedFlags.has("dbSetup") && + providedFlags.has("database") && + dbSetup && + dbSetup !== "none" && + database === "none" + ) { + exitWithError( + "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.", + }, + "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", + runtime: "workers", + errorMessage: + "Cloudflare D1 setup requires SQLite database and Cloudflare Workers runtime.", + }, + 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 (validation.database && database !== validation.database) { + exitWithError(validation.errorMessage); + } + + if (validation.runtime && runtime !== validation.runtime) { + exitWithError(validation.errorMessage); + } + + if (dbSetup === "docker") { + if (database === "sqlite") { + exitWithError( + "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") { + exitWithError( + "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.", + ); + } + } + } +} + +export function validateBackendConstraints( + config: Partial, + providedFlags: Set, + options: CLIInput, +): void { + const { backend } = config; + + if ( + providedFlags.has("backend") && + backend && + backend !== "convex" && + backend !== "none" + ) { + if (providedFlags.has("runtime") && options.runtime === "none") { + exitWithError( + "'--runtime none' is only supported with '--backend convex' or '--backend none'. Please choose 'bun', 'node', or remove the --runtime flag.", + ); + } + } + + if (backend === "convex" || backend === "none") { + const incompatibleFlags = incompatibleFlagsForBackend( + backend, + providedFlags, + options, + ); + if (incompatibleFlags.length > 0) { + exitWithError( + `The following flags are incompatible with '--backend ${backend}': ${incompatibleFlags.join( + ", ", + )}. Please remove them.`, + ); + } + + if ( + backend === "convex" && + providedFlags.has("frontend") && + options.frontend + ) { + const incompatibleFrontends = options.frontend.filter( + (f) => f === "solid", + ); + if (incompatibleFrontends.length > 0) { + exitWithError( + `The following frontends are not compatible with '--backend convex': ${incompatibleFrontends.join( + ", ", + )}. Please choose a different frontend or backend.`, + ); + } + } + + coerceBackendPresets(config); + } +} + +export function validateFrontendConstraints( + config: Partial, + providedFlags: Set, +): void { + const { frontend } = config; + + if (frontend && frontend.length > 0) { + ensureSingleWebAndNative(frontend); + + if ( + providedFlags.has("api") && + providedFlags.has("frontend") && + config.api + ) { + validateApiFrontendCompatibility(config.api, frontend); + } + } + + const hasWebFrontendFlag = (frontend ?? []).some((f) => isWebFrontend(f)); + validateWebDeployRequiresWebFrontend(config.webDeploy, hasWebFrontendFlag); +} + +export function validateApiConstraints( + config: Partial, + options: CLIInput, +): void { + if (config.api === "none") { + if ( + options.examples && + !(options.examples.length === 1 && options.examples[0] === "none") && + options.backend !== "convex" + ) { + exitWithError( + "Cannot use '--examples' when '--api' is set to 'none'. Please remove the --examples flag or choose an API type.", + ); + } + } +} + +export function validateFullConfig( + config: Partial, + providedFlags: Set, + options: CLIInput, +): void { + validateDatabaseOrmAuth(config, providedFlags); + validateDatabaseSetup(config, providedFlags); + + validateBackendConstraints(config, providedFlags, options); + + validateFrontendConstraints(config, providedFlags); + + validateApiConstraints(config, options); + + validateServerDeployRequiresBackend(config.serverDeploy, config.backend); + + validateWorkersCompatibility(providedFlags, options, config); + + if (config.addons && config.addons.length > 0) { + validateAddonsAgainstFrontends(config.addons, config.frontend); + config.addons = [...new Set(config.addons)]; + } + + validateExamplesCompatibility( + config.examples ?? [], + config.backend, + config.database, + config.frontend ?? [], + ); + + validateAlchemyCompatibility( + config.webDeploy, + config.serverDeploy, + config.frontend ?? [], + ); +} + +export function validateConfigForProgrammaticUse( + config: Partial, +): void { + try { + validateDatabaseOrmAuth(config); + + if (config.frontend && config.frontend.length > 0) { + ensureSingleWebAndNative(config.frontend); + } + + validateApiFrontendCompatibility(config.api, config.frontend); + + if (config.addons && config.addons.length > 0) { + validateAddonsAgainstFrontends(config.addons, config.frontend); + } + + validateExamplesCompatibility( + config.examples ?? [], + config.backend, + config.database, + config.frontend ?? [], + ); + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error(String(error)); + } +} diff --git a/apps/cli/src/utils/display-config.ts b/apps/cli/src/utils/display-config.ts index e4103da..8a92d05 100644 --- a/apps/cli/src/utils/display-config.ts +++ b/apps/cli/src/utils/display-config.ts @@ -107,6 +107,12 @@ export function displayConfig(config: Partial) { ); } + if (config.serverDeploy !== undefined) { + configDisplay.push( + `${pc.blue("Server Deployment:")} ${String(config.serverDeploy)}`, + ); + } + if (configDisplay.length === 0) { return pc.yellow("No configuration selected."); } diff --git a/apps/cli/src/utils/format-with-biome.ts b/apps/cli/src/utils/format-with-biome.ts new file mode 100644 index 0000000..67d0ebf --- /dev/null +++ b/apps/cli/src/utils/format-with-biome.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import { Biome } from "@biomejs/js-api/nodejs"; +import fs from "fs-extra"; +import { glob } from "tinyglobby"; + +export async function formatProjectWithBiome(projectDir: string) { + const biome = new Biome(); + const { projectKey } = biome.openProject(projectDir); + + biome.applyConfiguration(projectKey, { + formatter: { + enabled: true, + indentStyle: "tab", + }, + javascript: { + formatter: { + quoteStyle: "double", + }, + }, + }); + + const files = await glob("**/*", { + cwd: projectDir, + dot: true, + absolute: true, + onlyFiles: true, + }); + + for (const filePath of files) { + try { + const ext = path.extname(filePath).toLowerCase(); + const supported = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".cjs", + ".mjs", + ".cts", + ".mts", + ".json", + ".jsonc", + ".md", + ".mdx", + ".css", + ".scss", + ".html", + ]); + if (!supported.has(ext)) continue; + + const original = await fs.readFile(filePath, "utf8"); + const result = biome.formatContent(projectKey, original, { filePath }); + const content = result?.content; + if (typeof content !== "string") continue; + if (content.length === 0 && original.length > 0) continue; + if (content !== original) { + await fs.writeFile(filePath, content); + } + } catch {} + } +} diff --git a/apps/cli/src/utils/generate-reproducible-command.ts b/apps/cli/src/utils/generate-reproducible-command.ts index 7643495..330fe71 100644 --- a/apps/cli/src/utils/generate-reproducible-command.ts +++ b/apps/cli/src/utils/generate-reproducible-command.ts @@ -30,6 +30,7 @@ export function generateReproducibleCommand(config: ProjectConfig): string { flags.push(`--db-setup ${config.dbSetup}`); flags.push(`--web-deploy ${config.webDeploy}`); + flags.push(`--server-deploy ${config.serverDeploy}`); flags.push(config.git ? "--git" : "--no-git"); flags.push(`--package-manager ${config.packageManager}`); flags.push(config.install ? "--install" : "--no-install"); diff --git a/apps/cli/src/utils/project-directory.ts b/apps/cli/src/utils/project-directory.ts index 1b733e4..943b73c 100644 --- a/apps/cli/src/utils/project-directory.ts +++ b/apps/cli/src/utils/project-directory.ts @@ -14,8 +14,9 @@ export async function handleDirectoryConflict( }> { while (true) { const resolvedPath = path.resolve(process.cwd(), currentPathInput); - const dirExists = fs.pathExistsSync(resolvedPath); - const dirIsNotEmpty = dirExists && fs.readdirSync(resolvedPath).length > 0; + const dirExists = await fs.pathExists(resolvedPath); + const dirIsNotEmpty = + dirExists && (await fs.readdir(resolvedPath)).length > 0; if (!dirIsNotEmpty) { return { finalPathInput: currentPathInput, shouldClearDirectory: false }; diff --git a/apps/cli/src/utils/project-name-validation.ts b/apps/cli/src/utils/project-name-validation.ts new file mode 100644 index 0000000..bf3d3d6 --- /dev/null +++ b/apps/cli/src/utils/project-name-validation.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { ProjectNameSchema } from "../types"; +import { exitWithError } from "./errors"; + +export function validateProjectName(name: string): void { + const result = ProjectNameSchema.safeParse(name); + if (!result.success) { + exitWithError( + `Invalid project name: ${ + result.error.issues[0]?.message || "Invalid project name" + }`, + ); + } +} + +export function validateProjectNameThrow(name: string): void { + const result = ProjectNameSchema.safeParse(name); + if (!result.success) { + throw new Error(`Invalid project name: ${result.error.issues[0]?.message}`); + } +} + +export function extractAndValidateProjectName( + projectName?: string, + projectDirectory?: string, + throwOnError = false, +): string { + const derivedName = + projectName || + (projectDirectory + ? path.basename(path.resolve(process.cwd(), projectDirectory)) + : ""); + + if (!derivedName) { + return ""; + } + + const nameToValidate = projectName ? path.basename(projectName) : derivedName; + + if (throwOnError) { + validateProjectNameThrow(nameToValidate); + } else { + validateProjectName(nameToValidate); + } + + return projectName || derivedName; +} diff --git a/apps/cli/src/validation.ts b/apps/cli/src/validation.ts index b04dc29..f274d45 100644 --- a/apps/cli/src/validation.ts +++ b/apps/cli/src/validation.ts @@ -1,514 +1,85 @@ -import path from "node:path"; +import type { CLIInput, ProjectConfig } from "./types"; import { - type API, - type Backend, - type CLIInput, - type Database, - type DatabaseSetup, - type ORM, - type PackageManager, - type ProjectConfig, - ProjectNameSchema, - type Runtime, - type WebDeploy, -} from "./types"; + getProvidedFlags, + processFlags, + validateArrayOptions, +} from "./utils/config-processing"; import { - coerceBackendPresets, - ensureSingleWebAndNative, - incompatibleFlagsForBackend, - isWebFrontend, - validateAddonsAgainstFrontends, - validateApiFrontendCompatibility, - validateExamplesCompatibility, - validateWebDeployRequiresWebFrontend, - validateWorkersCompatibility, -} from "./utils/compatibility-rules"; + validateConfigForProgrammaticUse, + validateFullConfig, +} from "./utils/config-validation"; import { exitWithError } from "./utils/errors"; - -function processArrayOption(options: (T | "none")[] | undefined): T[] { - if (!options || options.length === 0) return []; - if (options.includes("none" as T | "none")) return []; - return options.filter((item): item is T => item !== "none"); -} - -function deriveProjectName( - projectName?: string, - projectDirectory?: string, -): string { - if (projectName) { - return projectName; - } - if (projectDirectory) { - return path.basename(path.resolve(process.cwd(), projectDirectory)); - } - return ""; -} - -function validateProjectName(name: string): void { - const result = ProjectNameSchema.safeParse(name); - if (!result.success) { - exitWithError( - `Invalid project name: ${ - result.error.issues[0]?.message || "Invalid project name" - }`, - ); - } -} +import { extractAndValidateProjectName } from "./utils/project-name-validation"; export function processAndValidateFlags( options: CLIInput, providedFlags: Set, projectName?: string, ): Partial { - const config: Partial = {}; - - if (options.api) { - config.api = options.api as API; - if (options.api === "none") { - if ( - options.examples && - !(options.examples.length === 1 && options.examples[0] === "none") && - options.backend !== "convex" - ) { - exitWithError( - "Cannot use '--examples' when '--api' is set to 'none'. Please remove the --examples flag or choose an API type.", - ); - } + if (options.yolo) { + const cfg = processFlags(options, projectName); + const validatedProjectName = extractAndValidateProjectName( + projectName, + options.projectDirectory, + true, + ); + if (validatedProjectName) { + cfg.projectName = validatedProjectName; } + return cfg; } - if (options.backend) { - config.backend = options.backend as Backend; + try { + validateArrayOptions(options); + } catch (error) { + exitWithError(error instanceof Error ? error.message : String(error)); } - if ( - providedFlags.has("backend") && - config.backend && - config.backend !== "convex" && - config.backend !== "none" - ) { - if (providedFlags.has("runtime") && options.runtime === "none") { - exitWithError( - `'--runtime none' is only supported with '--backend convex' or '--backend none'. Please choose 'bun', 'node', or remove the --runtime flag.`, - ); - } - } + const config = processFlags(options, projectName); - 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; - } - 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; - } - - const derivedName = deriveProjectName(projectName, options.projectDirectory); - if (derivedName) { - const nameToValidate = projectName - ? path.basename(projectName) - : derivedName; - validateProjectName(nameToValidate); - config.projectName = projectName || derivedName; - } - - if (options.frontend && options.frontend.length > 0) { - if (options.frontend.includes("none")) { - if (options.frontend.length > 1) { - exitWithError(`Cannot combine 'none' with other frontend options.`); - } - config.frontend = []; - } else { - const validOptions = processArrayOption(options.frontend); - ensureSingleWebAndNative(validOptions); - config.frontend = validOptions; - } - } - - if ( - providedFlags.has("api") && - providedFlags.has("frontend") && - config.api && - config.frontend && - config.frontend.length > 0 - ) { - validateApiFrontendCompatibility(config.api, config.frontend); - } - if (options.addons && options.addons.length > 0) { - if (options.addons.includes("none")) { - if (options.addons.length > 1) { - exitWithError(`Cannot combine 'none' with other addons.`); - } - config.addons = []; - } else { - config.addons = processArrayOption(options.addons); - } - } - if (options.examples && options.examples.length > 0) { - if (options.examples.includes("none")) { - if (options.examples.length > 1) { - exitWithError("Cannot combine 'none' with other examples."); - } - config.examples = []; - } else { - config.examples = processArrayOption(options.examples); - if (options.examples.includes("none") && config.backend !== "convex") { - config.examples = []; - } - } - } - - if (config.backend === "convex" || config.backend === "none") { - const incompatibleFlags = incompatibleFlagsForBackend( - config.backend, - providedFlags, - options, - ); - if (incompatibleFlags.length > 0) { - exitWithError( - `The following flags are incompatible with '--backend ${config.backend}': ${incompatibleFlags.join( - ", ", - )}. Please remove them.`, - ); - } - - if ( - config.backend === "convex" && - providedFlags.has("frontend") && - options.frontend - ) { - const incompatibleFrontends = options.frontend.filter( - (f) => f === "solid", - ); - if (incompatibleFrontends.length > 0) { - exitWithError( - `The following frontends are not compatible with '--backend convex': ${incompatibleFrontends.join( - ", ", - )}. Please choose a different frontend or backend.`, - ); - } - } - - coerceBackendPresets(config); - } - - if ( - providedFlags.has("orm") && - providedFlags.has("database") && - config.orm === "mongoose" && - config.database !== "mongodb" - ) { - exitWithError( - "Mongoose ORM requires MongoDB database. Please use '--database mongodb' or choose a different ORM.", - ); - } - - if ( - providedFlags.has("database") && - providedFlags.has("orm") && - config.database === "mongodb" && - config.orm && - config.orm !== "mongoose" && - config.orm !== "prisma" - ) { - exitWithError( - "MongoDB database requires Mongoose or Prisma ORM. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", - ); - } - - if ( - providedFlags.has("orm") && - providedFlags.has("database") && - config.orm === "drizzle" && - config.database === "mongodb" - ) { - exitWithError( - "Drizzle ORM does not support MongoDB. Please use '--orm mongoose' or '--orm prisma' or choose a different database.", - ); - } - - if ( - providedFlags.has("database") && - providedFlags.has("orm") && - config.database && - config.database !== "none" && - config.orm === "none" - ) { - exitWithError( - "Database selection requires an ORM. Please choose '--orm drizzle', '--orm prisma', or '--orm mongoose'.", - ); - } - - if ( - providedFlags.has("orm") && - providedFlags.has("database") && - config.orm && - config.orm !== "none" && - config.database === "none" - ) { - exitWithError( - "ORM selection requires a database. Please choose a database or set '--orm none'.", - ); - } - - if ( - providedFlags.has("auth") && - providedFlags.has("database") && - config.auth && - config.database === "none" - ) { - exitWithError( - "Authentication requires a database. Please choose a database or set '--no-auth'.", - ); - } - - if ( - providedFlags.has("dbSetup") && - providedFlags.has("database") && - config.dbSetup && - config.dbSetup !== "none" && - config.database === "none" - ) { - exitWithError( - "Database setup requires a database. Please choose a database or set '--db-setup none'.", - ); - } - - if ( - providedFlags.has("dbSetup") && - (config.database ? providedFlags.has("database") : true) && - config.dbSetup === "turso" && - config.database !== "sqlite" - ) { - exitWithError( - "Turso setup requires SQLite database. Please use '--database sqlite' or choose a different setup.", - ); - } - - if ( - providedFlags.has("dbSetup") && - (config.database ? providedFlags.has("database") : true) && - config.dbSetup === "neon" && - config.database !== "postgres" - ) { - exitWithError( - "Neon setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", - ); - } - - if ( - providedFlags.has("dbSetup") && - (config.database ? providedFlags.has("database") : true) && - config.dbSetup === "prisma-postgres" && - config.database !== "postgres" - ) { - exitWithError( - "Prisma PostgreSQL setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", - ); - } - - if ( - providedFlags.has("dbSetup") && - (config.database ? providedFlags.has("database") : true) && - config.dbSetup === "mongodb-atlas" && - config.database !== "mongodb" - ) { - exitWithError( - "MongoDB Atlas setup requires MongoDB database. Please use '--database mongodb' or choose a different setup.", - ); - } - - if ( - providedFlags.has("dbSetup") && - (config.database ? providedFlags.has("database") : true) && - config.dbSetup === "supabase" && - config.database !== "postgres" - ) { - exitWithError( - "Supabase setup requires PostgreSQL database. Please use '--database postgres' or choose a different setup.", - ); - } - - if (config.dbSetup === "d1") { - if ( - (providedFlags.has("dbSetup") && providedFlags.has("database")) || - (providedFlags.has("dbSetup") && !config.database) - ) { - if (config.database !== "sqlite") { - exitWithError( - "Cloudflare D1 setup requires SQLite database. Please use '--database sqlite' or choose a different setup.", - ); - } - } - - if ( - (providedFlags.has("dbSetup") && providedFlags.has("runtime")) || - (providedFlags.has("dbSetup") && !config.runtime) - ) { - if (config.runtime !== "workers") { - exitWithError( - "Cloudflare D1 setup requires the Cloudflare Workers runtime. Please use '--runtime workers' or choose a different setup.", - ); - } - } - } - - if ( - providedFlags.has("dbSetup") && - providedFlags.has("database") && - config.dbSetup === "docker" && - config.database === "sqlite" - ) { - exitWithError( - "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 ( - providedFlags.has("dbSetup") && - providedFlags.has("runtime") && - config.dbSetup === "docker" && - config.runtime === "workers" - ) { - exitWithError( - "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.", - ); - } - - validateWorkersCompatibility(providedFlags, options, config); - - const hasWebFrontendFlag = (config.frontend ?? []).some((f) => - isWebFrontend(f), + const validatedProjectName = extractAndValidateProjectName( + projectName, + options.projectDirectory, + false, ); - validateWebDeployRequiresWebFrontend(config.webDeploy, hasWebFrontendFlag); + if (validatedProjectName) { + config.projectName = validatedProjectName; + } + + validateFullConfig(config, providedFlags, options); return config; } -export function validateConfigCompatibility(config: Partial) { - const effectiveDatabase = config.database; - const effectiveBackend = config.backend; - const effectiveFrontend = config.frontend; - const effectiveApi = config.api; - - validateApiFrontendCompatibility(effectiveApi, effectiveFrontend); - - if (config.addons && config.addons.length > 0) { - validateAddonsAgainstFrontends(config.addons, effectiveFrontend); - config.addons = [...new Set(config.addons)]; - } - - validateExamplesCompatibility( - config.examples ?? [], - effectiveBackend, - effectiveDatabase, - effectiveFrontend ?? [], - ); -} - export function processProvidedFlagsWithoutValidation( options: CLIInput, projectName?: string, ): Partial { - const config: Partial = {}; + const config = processFlags(options, projectName); - if (options.api) { - config.api = options.api as API; - } - - 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; - } - - 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; - } - - const derivedName = deriveProjectName(projectName, options.projectDirectory); - if (derivedName) { - const nameToValidate = projectName - ? path.basename(projectName) - : derivedName; - const result = ProjectNameSchema.safeParse(nameToValidate); - if (!result.success) { - throw new Error( - `Invalid project name: ${result.error.issues[0]?.message}`, - ); - } - 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); + const validatedProjectName = extractAndValidateProjectName( + projectName, + options.projectDirectory, + true, + ); + if (validatedProjectName) { + config.projectName = validatedProjectName; } return config; } -export function getProvidedFlags(options: CLIInput): Set { - return new Set( - Object.keys(options).filter( - (key) => options[key as keyof CLIInput] !== undefined, - ), - ); +export function validateConfigCompatibility( + config: Partial, + providedFlags?: Set, + options?: CLIInput, +) { + if (options?.yolo) return; + if (options && providedFlags) { + validateFullConfig(config, providedFlags, options); + } else { + validateConfigForProgrammaticUse(config); + } } + +export { getProvidedFlags }; diff --git a/apps/cli/templates/addons/biome/biome.json.hbs b/apps/cli/templates/addons/biome/biome.json.hbs index 3d0d58a..1b82d0f 100644 --- a/apps/cli/templates/addons/biome/biome.json.hbs +++ b/apps/cli/templates/addons/biome/biome.json.hbs @@ -21,6 +21,7 @@ "!bts.jsonc", "!**/.expo", "!**/.wrangler", + "!**/.alchemy", "!**/wrangler.jsonc", "!**/.source" ] diff --git a/apps/cli/templates/addons/ruler/.ruler/mcp.json.hbs b/apps/cli/templates/addons/ruler/.ruler/mcp.json.hbs index 7cec539..d38c313 100644 --- a/apps/cli/templates/addons/ruler/.ruler/mcp.json.hbs +++ b/apps/cli/templates/addons/ruler/.ruler/mcp.json.hbs @@ -4,7 +4,7 @@ "type": "stdio", "command": "npx", "args": ["-y", "@upstash/context7-mcp"] - }{{#if (or (eq runtime "workers") (eq webDeploy "workers"))}}, + }{{#if (or (eq runtime "workers") (eq webDeploy "wrangler"))}}, "cloudflare": { "command": "npx", "args": ["mcp-remote", "https://docs.mcp.cloudflare.com/sse"] diff --git a/apps/cli/templates/addons/ultracite/biome.json.hbs b/apps/cli/templates/addons/ultracite/biome.json.hbs index 4e1791c..2af9dd2 100644 --- a/apps/cli/templates/addons/ultracite/biome.json.hbs +++ b/apps/cli/templates/addons/ultracite/biome.json.hbs @@ -16,6 +16,7 @@ "!bts.jsonc", "!**/.expo", "!**/.wrangler", + "!**/.alchemy", "!**/wrangler.jsonc", "!**/.source" ] diff --git a/apps/cli/templates/auth/server/base/src/lib/auth.ts.hbs b/apps/cli/templates/auth/server/base/src/lib/auth.ts.hbs index 8c18520..10e53ee 100644 --- a/apps/cli/templates/auth/server/base/src/lib/auth.ts.hbs +++ b/apps/cli/templates/auth/server/base/src/lib/auth.ts.hbs @@ -21,6 +21,13 @@ export const auth = betterAuth({ ], emailAndPassword: { enabled: true, + }, + advanced: { + defaultCookieAttributes: { + sameSite: "none", + secure: true, + httpOnly: true, + }, } {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} , plugins: [expo()] @@ -54,8 +61,13 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, }, - secret: process.env.BETTER_AUTH_SECRET, - baseURL: process.env.BETTER_AUTH_URL, + advanced: { + defaultCookieAttributes: { + sameSite: "none", + secure: true, + httpOnly: true, + }, + }, {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} plugins: [expo()], {{/if}} @@ -73,7 +85,7 @@ import * as schema from "../db/schema/auth"; import { env } from "cloudflare:workers"; export const auth = betterAuth({ - database: drizzleAdapter(db, { + database: drizzleAdapter(db, { {{#if (eq database "postgres")}}provider: "pg",{{/if}} {{#if (eq database "sqlite")}}provider: "sqlite",{{/if}} {{#if (eq database "mysql")}}provider: "mysql",{{/if}} @@ -85,6 +97,13 @@ export const auth = betterAuth({ }, secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, + advanced: { + defaultCookieAttributes: { + sameSite: "none", + secure: true, + httpOnly: true, + }, + }, {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} plugins: [expo()], {{/if}} @@ -110,6 +129,13 @@ export const auth = betterAuth({ ], emailAndPassword: { enabled: true, + }, + advanced: { + defaultCookieAttributes: { + sameSite: "none", + secure: true, + httpOnly: true, + }, } {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} , plugins: [expo()] @@ -133,9 +159,16 @@ export const auth = betterAuth({ ], emailAndPassword: { enabled: true, + }, + advanced: { + defaultCookieAttributes: { + sameSite: "none", + secure: true, + httpOnly: true, + }, } {{#if (or (includes frontend "native-nativewind") (includes frontend "native-unistyles"))}} , plugins: [expo()] {{/if}} }); -{{/if}} +{{/if}} \ No newline at end of file diff --git a/apps/cli/templates/backend/server/server-base/_gitignore b/apps/cli/templates/backend/server/server-base/_gitignore index 4b9738b..2ed07fc 100644 --- a/apps/cli/templates/backend/server/server-base/_gitignore +++ b/apps/cli/templates/backend/server/server-base/_gitignore @@ -16,6 +16,7 @@ dist/ .idea/usage.statistics.xml .idea/shelf .wrangler +.alchemy /.next/ .vercel diff --git a/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs b/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs index f106b1a..41a38fc 100644 --- a/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs +++ b/apps/cli/templates/backend/server/server-base/tsconfig.json.hbs @@ -20,7 +20,7 @@ {{else if (eq runtime "bun")}} "bun" {{else if (eq runtime "workers")}} - "./worker-configuration", + "@cloudflare/workers-types", "node" {{else}} "node", diff --git a/apps/cli/templates/base/_gitignore b/apps/cli/templates/base/_gitignore index c98e331..abfadf0 100644 --- a/apps/cli/templates/base/_gitignore +++ b/apps/cli/templates/base/_gitignore @@ -1,2 +1,4 @@ node_modules .turbo +.alchemy +.env \ No newline at end of file diff --git a/apps/cli/templates/db/drizzle/sqlite/drizzle.config.ts.hbs b/apps/cli/templates/db/drizzle/sqlite/drizzle.config.ts.hbs index ce26a92..4bbf2f1 100644 --- a/apps/cli/templates/db/drizzle/sqlite/drizzle.config.ts.hbs +++ b/apps/cli/templates/db/drizzle/sqlite/drizzle.config.ts.hbs @@ -7,11 +7,13 @@ export default defineConfig({ // DOCS: https://orm.drizzle.team/docs/guides/d1-http-with-drizzle-kit dialect: "sqlite", driver: "d1-http", + {{#if (eq serverDeploy "wrangler")}} dbCredentials: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, databaseId: process.env.CLOUDFLARE_DATABASE_ID!, token: process.env.CLOUDFLARE_D1_TOKEN!, }, + {{/if}} {{else}} dialect: "turso", dbCredentials: { diff --git a/apps/cli/templates/deploy/alchemy/alchemy.run.ts.hbs b/apps/cli/templates/deploy/alchemy/alchemy.run.ts.hbs new file mode 100644 index 0000000..86a07a2 --- /dev/null +++ b/apps/cli/templates/deploy/alchemy/alchemy.run.ts.hbs @@ -0,0 +1,208 @@ +import alchemy from "alchemy"; +{{#if (eq webDeploy "alchemy")}} +{{#if (includes frontend "next")}} +import { Next } from "alchemy/cloudflare"; +{{else if (includes frontend "nuxt")}} +import { Nuxt } from "alchemy/cloudflare"; +{{else if (includes frontend "svelte")}} +import { SvelteKit } from "alchemy/cloudflare"; +{{else if (includes frontend "tanstack-start")}} +import { TanStackStart } from "alchemy/cloudflare"; +{{else if (includes frontend "tanstack-router")}} +import { Vite } from "alchemy/cloudflare"; +{{else if (includes frontend "react-router")}} +import { ReactRouter } from "alchemy/cloudflare"; +{{else if (includes frontend "solid")}} +import { Vite } from "alchemy/cloudflare"; +{{/if}} +{{/if}} +{{#if (eq serverDeploy "alchemy")}} +import { Worker, WranglerJson } from "alchemy/cloudflare"; +{{#if (eq dbSetup "d1")}} +import { D1Database } from "alchemy/cloudflare"; +{{/if}} +{{/if}} +{{#if (and (eq serverDeploy "alchemy") (eq dbSetup "d1"))}} +import { Exec } from "alchemy/os"; +{{/if}} +import { config } from "dotenv"; + +{{#if (and (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}} +config({ path: "./.env" }); +config({ path: "./apps/web/.env" }); +config({ path: "./apps/server/.env" }); +{{else if (or (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}} +config({ path: "./.env" }); +{{/if}} + +const app = await alchemy("{{projectName}}"); + +{{#if (and (eq serverDeploy "alchemy") (eq dbSetup "d1"))}} +await Exec("db-generate", { + {{#if (and (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}}cwd: "apps/server",{{/if}} + command: "{{packageManager}} run db:generate", +}); + +const db = await D1Database("database", { + name: `${app.name}-${app.stage}-db`, + migrationsDir: "apps/server/src/db/migrations", +}); +{{/if}} + +{{#if (eq webDeploy "alchemy")}} +{{#if (includes frontend "next")}} +export const web = await Next("web", { + {{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}} + name: `${app.name}-${app.stage}-web`, + bindings: { + {{#if (eq backend "convex")}} + NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL || "", + {{else}} + NEXT_PUBLIC_SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL || "", + {{/if}} + }, + dev: { + command: "{{packageManager}} run dev" + } +}); +{{else if (includes frontend "nuxt")}} +export const web = await Nuxt("web", { + {{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}} + name: `${app.name}-${app.stage}-web`, + bindings: { + {{#if (eq backend "convex")}} + NUXT_PUBLIC_CONVEX_URL: process.env.NUXT_PUBLIC_CONVEX_URL || "", + {{else}} + NUXT_PUBLIC_SERVER_URL: process.env.NUXT_PUBLIC_SERVER_URL || "", + {{/if}} + }, + dev: { + command: "{{packageManager}} run dev" + } +}); +{{else if (includes frontend "svelte")}} +export const web = await SvelteKit("web", { + {{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}} + name: `${app.name}-${app.stage}-web`, + bindings: { + {{#if (eq backend "convex")}} + PUBLIC_CONVEX_URL: process.env.PUBLIC_CONVEX_URL || "", + {{else}} + PUBLIC_SERVER_URL: process.env.PUBLIC_SERVER_URL || "", + {{/if}} + }, + dev: { + command: "{{packageManager}} run dev" + } +}); +{{else if (includes frontend "tanstack-start")}} +export const web = await TanStackStart("web", { + {{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}} + name: `${app.name}-${app.stage}-web`, + bindings: { + {{#if (eq backend "convex")}} + VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "", + {{else}} + VITE_SERVER_URL: process.env.VITE_SERVER_URL || "", + {{/if}} + }, + dev: { + command: "{{packageManager}} run dev" + } +}); +{{else if (includes frontend "tanstack-router")}} +export const web = await Vite("web", { + {{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}} + name: `${app.name}-${app.stage}-web`, + assets: "dist", + bindings: { + {{#if (eq backend "convex")}} + VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "", + {{else}} + VITE_SERVER_URL: process.env.VITE_SERVER_URL || "", + {{/if}} + }, + dev: { + command: "{{packageManager}} run dev" + } +}); +{{else if (includes frontend "react-router")}} +export const web = await ReactRouter("web", { + {{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}} + name: `${app.name}-${app.stage}-web`, + bindings: { + {{#if (eq backend "convex")}} + VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "", + {{else}} + VITE_SERVER_URL: process.env.VITE_SERVER_URL || "", + {{/if}} + }, + dev: { + command: "{{packageManager}} run dev" + } +}); +{{else if (includes frontend "solid")}} +export const web = await Vite("web", { + {{#if (eq serverDeploy "alchemy")}}cwd: "apps/web",{{/if}} + name: `${app.name}-${app.stage}-web`, + assets: "dist", + bindings: { + {{#if (eq backend "convex")}} + VITE_CONVEX_URL: process.env.VITE_CONVEX_URL || "", + {{else}} + VITE_SERVER_URL: process.env.VITE_SERVER_URL || "", + {{/if}} + }, + dev: { + command: "{{packageManager}} run dev" + } +}); +{{/if}} +{{/if}} + +{{#if (eq serverDeploy "alchemy")}} +export const server = await Worker("server", { + {{#if (eq webDeploy "alchemy")}}cwd: "apps/server",{{/if}} + name: `${app.name}-${app.stage}`, + entrypoint: "src/index.ts", + compatibility: "node", + bindings: { + {{#if (eq dbSetup "d1")}} + DB: db, + {{else if (and (ne database "none") (ne dbSetup "none"))}} + DATABASE_URL: alchemy.secret(process.env.DATABASE_URL), + {{/if}} + CORS_ORIGIN: process.env.CORS_ORIGIN || "", + {{#if auth}} + BETTER_AUTH_SECRET: alchemy.secret(process.env.BETTER_AUTH_SECRET), + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || "", + {{/if}} + {{#if (includes examples "ai")}} + GOOGLE_GENERATIVE_AI_API_KEY: alchemy.secret(process.env.GOOGLE_GENERATIVE_AI_API_KEY), + {{/if}} + {{#if (eq dbSetup "turso")}} + DATABASE_AUTH_TOKEN: alchemy.secret(process.env.DATABASE_AUTH_TOKEN), + {{/if}} + }, + dev: { + port: 3000, + }, +}); + +await WranglerJson("wrangler", { + worker: server, +}); +{{/if}} + + + +{{#if (and (eq webDeploy "alchemy") (eq serverDeploy "alchemy"))}} +console.log(`Web -> ${web.url}`); +console.log(`Server -> ${server.url}`); +{{else if (eq webDeploy "alchemy")}} +console.log(`Web -> ${web.url}`); +{{else if (eq serverDeploy "alchemy")}} +console.log(`Server -> ${server.url}`); +{{/if}} + +await app.finalize(); \ No newline at end of file diff --git a/apps/cli/templates/deploy/alchemy/env.d.ts.hbs b/apps/cli/templates/deploy/alchemy/env.d.ts.hbs new file mode 100644 index 0000000..0e89d3a --- /dev/null +++ b/apps/cli/templates/deploy/alchemy/env.d.ts.hbs @@ -0,0 +1,20 @@ +// This file infers types for the cloudflare:workers environment from your Alchemy Worker. +// @see https://alchemy.run/concepts/bindings/#type-safe-bindings + +{{#if (eq webDeploy "alchemy")}} +import type { server } from "../../alchemy.run"; +{{else}} +import type { server } from "./alchemy.run"; +{{/if}} + +export type CloudflareEnv = typeof server.Env; + +declare global { + type Env = CloudflareEnv; +} + +declare module "cloudflare:workers" { + namespace Cloudflare { + export interface Env extends CloudflareEnv {} + } +} diff --git a/apps/cli/templates/deploy/alchemy/wrangler.jsonc.hbs b/apps/cli/templates/deploy/alchemy/wrangler.jsonc.hbs new file mode 100644 index 0000000..69413c5 --- /dev/null +++ b/apps/cli/templates/deploy/alchemy/wrangler.jsonc.hbs @@ -0,0 +1,11 @@ +// This is a temporary wrangler.jsonc file that will be overwritten by alchemy +// It's only here so that `wrangler dev` can work or use alchemy dev instead +{ + "name": "{{projectName}}", + "main": "src/index.ts", + "compatibility_date": "2025-08-16", + "compatibility_flags": [ + "nodejs_compat", + "nodejs_compat_populate_process_env" + ] +} diff --git a/apps/cli/templates/runtime/workers/apps/server/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/server/wrangler.jsonc.hbs similarity index 100% rename from apps/cli/templates/runtime/workers/apps/server/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/server/wrangler.jsonc.hbs diff --git a/apps/cli/templates/deploy/web/nuxt/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/web/nuxt/wrangler.jsonc.hbs similarity index 96% rename from apps/cli/templates/deploy/web/nuxt/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/web/nuxt/wrangler.jsonc.hbs index 84cb91b..b06910b 100644 --- a/apps/cli/templates/deploy/web/nuxt/wrangler.jsonc.hbs +++ b/apps/cli/templates/deploy/wrangler/web/nuxt/wrangler.jsonc.hbs @@ -3,7 +3,7 @@ * https://developers.cloudflare.com/workers/wrangler/configuration/ */ { - "$schema": "../../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "name": "{{projectName}}", "main": "./.output/server/index.mjs", "compatibility_date": "2025-07-01", diff --git a/apps/cli/templates/deploy/web/react/next/open-next.config.ts b/apps/cli/templates/deploy/wrangler/web/react/next/open-next.config.ts similarity index 100% rename from apps/cli/templates/deploy/web/react/next/open-next.config.ts rename to apps/cli/templates/deploy/wrangler/web/react/next/open-next.config.ts diff --git a/apps/cli/templates/deploy/web/react/next/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/web/react/next/wrangler.jsonc.hbs similarity index 91% rename from apps/cli/templates/deploy/web/react/next/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/web/react/next/wrangler.jsonc.hbs index 5f9b58b..9d785b9 100644 --- a/apps/cli/templates/deploy/web/react/next/wrangler.jsonc.hbs +++ b/apps/cli/templates/deploy/wrangler/web/react/next/wrangler.jsonc.hbs @@ -1,5 +1,5 @@ { - "$schema": "../../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "main": ".open-next/worker.js", "name": "{{projectName}}", "compatibility_date": "2025-07-05", diff --git a/apps/cli/templates/deploy/web/react/tanstack-router/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/web/react/react-router/wrangler.jsonc.hbs similarity index 69% rename from apps/cli/templates/deploy/web/react/tanstack-router/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/web/react/react-router/wrangler.jsonc.hbs index 15cb97a..13fe0d3 100644 --- a/apps/cli/templates/deploy/web/react/tanstack-router/wrangler.jsonc.hbs +++ b/apps/cli/templates/deploy/wrangler/web/react/react-router/wrangler.jsonc.hbs @@ -1,5 +1,5 @@ { - "$schema": "../../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "name": "{{projectName}}", "compatibility_date": "2025-04-03", "assets": { diff --git a/apps/cli/templates/deploy/web/react/react-router/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/web/react/tanstack-router/wrangler.jsonc.hbs similarity index 69% rename from apps/cli/templates/deploy/web/react/react-router/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/web/react/tanstack-router/wrangler.jsonc.hbs index 15cb97a..13fe0d3 100644 --- a/apps/cli/templates/deploy/web/react/react-router/wrangler.jsonc.hbs +++ b/apps/cli/templates/deploy/wrangler/web/react/tanstack-router/wrangler.jsonc.hbs @@ -1,5 +1,5 @@ { - "$schema": "../../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "name": "{{projectName}}", "compatibility_date": "2025-04-03", "assets": { diff --git a/apps/cli/templates/deploy/web/react/tanstack-start/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/web/react/tanstack-start/wrangler.jsonc.hbs similarity index 86% rename from apps/cli/templates/deploy/web/react/tanstack-start/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/web/react/tanstack-start/wrangler.jsonc.hbs index 0398327..d4aa470 100644 --- a/apps/cli/templates/deploy/web/react/tanstack-start/wrangler.jsonc.hbs +++ b/apps/cli/templates/deploy/wrangler/web/react/tanstack-start/wrangler.jsonc.hbs @@ -1,5 +1,5 @@ { - "$schema": "../../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "name": "{{projectName}}", "main": ".output/server/index.mjs", "compatibility_date": "2025-07-05", diff --git a/apps/cli/templates/deploy/web/solid/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/web/solid/wrangler.jsonc.hbs similarity index 68% rename from apps/cli/templates/deploy/web/solid/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/web/solid/wrangler.jsonc.hbs index b36eef8..0a1501e 100644 --- a/apps/cli/templates/deploy/web/solid/wrangler.jsonc.hbs +++ b/apps/cli/templates/deploy/wrangler/web/solid/wrangler.jsonc.hbs @@ -1,5 +1,5 @@ { - "$schema": "../../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "name": "{{projectName}}", "compatibility_date": "2025-04-03", "assets": { diff --git a/apps/cli/templates/deploy/web/svelte/wrangler.jsonc.hbs b/apps/cli/templates/deploy/wrangler/web/svelte/wrangler.jsonc.hbs similarity index 96% rename from apps/cli/templates/deploy/web/svelte/wrangler.jsonc.hbs rename to apps/cli/templates/deploy/wrangler/web/svelte/wrangler.jsonc.hbs index 0eac96f..1c8426c 100644 --- a/apps/cli/templates/deploy/web/svelte/wrangler.jsonc.hbs +++ b/apps/cli/templates/deploy/wrangler/web/svelte/wrangler.jsonc.hbs @@ -3,7 +3,7 @@ * https://developers.cloudflare.com/workers/wrangler/configuration/ */ { - "$schema": "../../node_modules/wrangler/config-schema.json", + "$schema": "./node_modules/wrangler/config-schema.json", "name": "{{projectName}}", "main": ".svelte-kit/cloudflare/_worker.js", "compatibility_date": "2025-07-05", diff --git a/apps/cli/templates/frontend/nuxt/_gitignore b/apps/cli/templates/frontend/nuxt/_gitignore index 4a7f73a..26f73ef 100644 --- a/apps/cli/templates/frontend/nuxt/_gitignore +++ b/apps/cli/templates/frontend/nuxt/_gitignore @@ -5,6 +5,8 @@ .nitro .cache dist +.wrangler +.alchemy # Node dependencies node_modules @@ -22,3 +24,4 @@ logs .env .env.* !.env.example + diff --git a/apps/cli/templates/frontend/nuxt/tsconfig.json.hbs b/apps/cli/templates/frontend/nuxt/tsconfig.json.hbs index b6bd9e2..771b291 100644 --- a/apps/cli/templates/frontend/nuxt/tsconfig.json.hbs +++ b/apps/cli/templates/frontend/nuxt/tsconfig.json.hbs @@ -12,9 +12,7 @@ }, { "path": "./.nuxt/tsconfig.node.json" - } - {{#unless (or (eq backend "convex") (eq backend "none"))}} - , + }{{#unless (or (eq backend "convex") (eq backend "none"))}}, { "path": "../server" } diff --git a/apps/cli/templates/frontend/react/web-base/_gitignore b/apps/cli/templates/frontend/react/web-base/_gitignore index bea4be6..f1cb16d 100644 --- a/apps/cli/templates/frontend/react/web-base/_gitignore +++ b/apps/cli/templates/frontend/react/web-base/_gitignore @@ -26,6 +26,7 @@ .vercel .netlify .wrangler +.alchemy # Environment & local files .env* diff --git a/apps/cli/templates/frontend/react/web-base/src/components/header.tsx.hbs b/apps/cli/templates/frontend/react/web-base/src/components/header.tsx.hbs index bbc2830..fc29db0 100644 --- a/apps/cli/templates/frontend/react/web-base/src/components/header.tsx.hbs +++ b/apps/cli/templates/frontend/react/web-base/src/components/header.tsx.hbs @@ -6,7 +6,6 @@ import { NavLink } from "react-router"; {{else if (or (includes frontend "tanstack-router") (includes frontend "tanstack-start"))}} import { Link } from "@tanstack/react-router"; {{/if}} - {{#unless (includes frontend "tanstack-start")}} import { ModeToggle } from "./mode-toggle"; {{/unless}} diff --git a/apps/cli/templates/frontend/solid/_gitignore b/apps/cli/templates/frontend/solid/_gitignore index 4efc4c0..42a55c6 100644 --- a/apps/cli/templates/frontend/solid/_gitignore +++ b/apps/cli/templates/frontend/solid/_gitignore @@ -7,4 +7,5 @@ dist-ssr .env.* .wrangler +.alchemy .dev.vars* \ No newline at end of file diff --git a/apps/cli/templates/frontend/solid/package.json.hbs b/apps/cli/templates/frontend/solid/package.json.hbs index 4f60bb8..300aeed 100644 --- a/apps/cli/templates/frontend/solid/package.json.hbs +++ b/apps/cli/templates/frontend/solid/package.json.hbs @@ -13,7 +13,6 @@ "@tanstack/router-plugin": "^1.109.2", "@tanstack/solid-form": "^1.9.0", "@tanstack/solid-router": "^1.110.0", - "@tanstack/solid-router-devtools": "^1.109.2", "lucide-solid": "^0.507.0", "solid-js": "^1.9.4", "tailwindcss": "^4.0.6", diff --git a/apps/cli/templates/frontend/svelte/_gitignore b/apps/cli/templates/frontend/svelte/_gitignore index 3b462cb..f4a2e46 100644 --- a/apps/cli/templates/frontend/svelte/_gitignore +++ b/apps/cli/templates/frontend/svelte/_gitignore @@ -5,6 +5,7 @@ node_modules .vercel .netlify .wrangler +.alchemy /.svelte-kit /build diff --git a/apps/cli/templates/frontend/svelte/package.json.hbs b/apps/cli/templates/frontend/svelte/package.json.hbs index 2617f82..cee9343 100644 --- a/apps/cli/templates/frontend/svelte/package.json.hbs +++ b/apps/cli/templates/frontend/svelte/package.json.hbs @@ -12,20 +12,18 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { - "@sveltejs/adapter-auto": "^6.0.0", - "@sveltejs/kit": "^2.20.7", - "@sveltejs/vite-plugin-svelte": "^5.0.3", - "@tailwindcss/vite": "^4.1.4", - "svelte": "^5.28.2", - "svelte-check": "^4.1.6", - "tailwindcss": "^4.1.4", - "typescript": "^5.8.3", - "@tanstack/svelte-query-devtools": "^5.74.6", - "vite": "^7.0.2" + "@sveltejs/adapter-auto": "^6.1.0", + "@sveltejs/kit": "^2.31.1", + "@sveltejs/vite-plugin-svelte": "^6.1.2", + "@tailwindcss/vite": "^4.1.12", + "svelte": "^5.38.1", + "svelte-check": "^4.3.1", + "tailwindcss": "^4.1.12", + "typescript": "^5.9.2", + "vite": "^7.1.2" }, "dependencies": { - "@tanstack/svelte-form": "^1.7.0", - "@tanstack/svelte-query": "^5.74.4", - "zod": "^4.0.2" + "@tanstack/svelte-form": "^1.19.2", + "zod": "^4.0.17" } } diff --git a/apps/cli/test/cli.smoke.test.ts b/apps/cli/test/cli.smoke.test.ts index 8fe3c10..6360c7c 100644 --- a/apps/cli/test/cli.smoke.test.ts +++ b/apps/cli/test/cli.smoke.test.ts @@ -1,13 +1,7 @@ import { join } from "node:path"; import consola from "consola"; import { execa } from "execa"; -import { - ensureDirSync, - existsSync, - readFileSync, - readJsonSync, - removeSync, -} from "fs-extra"; +import { ensureDir, existsSync, readFile, readJson, remove } from "fs-extra"; import * as JSONC from "jsonc-parser"; import { FailedToExitError } from "trpc-cli"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -17,6 +11,8 @@ async function runCli(argv: string[], cwd: string) { const previous = process.cwd(); process.chdir(cwd); try { + consola.info(`Running CLI command: bts ${argv.join(" ")}`); + const cli = createBtsCli(); await cli .run({ @@ -37,12 +33,12 @@ async function runCli(argv: string[], cwd: string) { } } -function createTmpDir(_prefix: string) { +async function createTmpDir(_prefix: string) { const dir = join(__dirname, "..", ".smoke"); if (existsSync(dir)) { - removeSync(dir); + await remove(dir); } - ensureDirSync(dir); + await ensureDir(dir); return dir; } @@ -50,6 +46,10 @@ async function runCliExpectingError(args: string[], cwd: string) { const previous = process.cwd(); process.chdir(cwd); try { + consola.info( + `Running CLI command (expecting error): bts ${args.join(" ")}`, + ); + const cli = createBtsCli(); let threw = false; await cli @@ -72,15 +72,15 @@ async function runCliExpectingError(args: string[], cwd: string) { } } -function assertScaffoldedProject(dir: string) { +async function assertScaffoldedProject(dir: string) { const pkgJsonPath = join(dir, "package.json"); expect(existsSync(pkgJsonPath)).toBe(true); - const pkg = readJsonSync(pkgJsonPath); + const pkg = await readJson(pkgJsonPath); expect(typeof pkg.name).toBe("string"); expect(Array.isArray(pkg.workspaces)).toBe(true); } -function assertProjectStructure( +async function assertProjectStructure( dir: string, options: { hasWeb?: boolean; @@ -107,6 +107,13 @@ function assertProjectStructure( expect(existsSync(join(dir, "package.json"))).toBe(true); expect(existsSync(join(dir, ".gitignore"))).toBe(true); + try { + const pmConfig = (await readBtsConfig(dir)) as { packageManager?: string }; + if (pmConfig && pmConfig.packageManager === "bun") { + expect(existsSync(join(dir, "bunfig.toml"))).toBe(true); + } + } catch {} + if (hasWeb) { expect(existsSync(join(dir, "apps", "web", "package.json"))).toBe(true); const webDir = join(dir, "apps", "web"); @@ -132,6 +139,26 @@ function assertProjectStructure( hasAppDir || hasPublicDir, ).toBe(true); + + const bts = (await readBtsConfig(dir)) as { + webDeploy?: string; + serverDeploy?: string; + frontend?: string[]; + }; + if (bts.webDeploy === "wrangler") { + expect(existsSync(join(dir, "apps", "web", "wrangler.jsonc"))).toBe(true); + } + + if ( + bts.webDeploy === "alchemy" && + bts.serverDeploy !== "alchemy" && + bts.frontend && + bts.frontend.length > 0 + ) { + const webRunner = join(dir, "apps", "web", "alchemy.run.ts"); + consola.info(`Checking Alchemy web runner at: ${webRunner}`); + expect(existsSync(webRunner)).toBe(true); + } } if (hasNative) { @@ -154,8 +181,42 @@ function assertProjectStructure( expect(existsSync(join(dir, "apps", "server", "src", "index.ts"))).toBe( true, ); + expect(existsSync(join(dir, "apps", "server", "tsconfig.json"))).toBe(true); + + const bts = (await readBtsConfig(dir)) as { + serverDeploy?: string; + webDeploy?: string; + }; + if (bts.serverDeploy === "wrangler") { + expect(existsSync(join(dir, "apps", "server", "wrangler.jsonc"))).toBe( + true, + ); + } + if (bts.serverDeploy === "alchemy") { + const serverRunner = join(dir, "apps", "server", "alchemy.run.ts"); + const serverEnv = join(dir, "apps", "server", "env.d.ts"); + consola.info(`Checking Alchemy server runner at: ${serverRunner}`); + consola.info(`Checking Alchemy env types at: ${serverEnv}`); + expect(existsSync(serverRunner)).toBe(true); + expect(existsSync(serverEnv)).toBe(true); + } } + try { + const btsAll = (await readBtsConfig(dir)) as { + serverDeploy?: string; + webDeploy?: string; + }; + if (btsAll.serverDeploy === "alchemy" && btsAll.webDeploy === "alchemy") { + const rootRunner = join(dir, "alchemy.run.ts"); + const serverEnv = join(dir, "apps", "server", "env.d.ts"); + consola.info(`Checking Alchemy root runner at: ${rootRunner}`); + consola.info(`Checking Alchemy env types at: ${serverEnv}`); + expect(existsSync(rootRunner)).toBe(true); + expect(existsSync(serverEnv)).toBe(true); + } + } catch {} + if (hasConvexBackend) { const hasPackagesDir = existsSync(join(dir, "packages")); const hasConvexRelated = @@ -208,11 +269,11 @@ function assertProjectStructure( } expect(existsSync(join(dir, "bts.jsonc"))).toBe(true); - const btsConfig = readFileSync(join(dir, "bts.jsonc"), "utf8"); + const btsConfig = await readFile(join(dir, "bts.jsonc"), "utf8"); expect(btsConfig).toContain("Better-T-Stack configuration"); } -function assertBtsConfig( +async function assertBtsConfig( dir: string, expectedConfig: Partial<{ frontend: string[]; @@ -225,11 +286,13 @@ function assertBtsConfig( api: string; runtime: string; packageManager: string; + webDeploy: string; + serverDeploy: string; }>, ) { const btsConfigPath = join(dir, "bts.jsonc"); expect(existsSync(btsConfigPath)).toBe(true); - const content = readFileSync(btsConfigPath, "utf8"); + const content = await readFile(btsConfigPath, "utf8"); type BtsConfig = { frontend?: string[]; @@ -242,6 +305,8 @@ function assertBtsConfig( api?: string; runtime?: string; packageManager?: string; + webDeploy?: string; + serverDeploy?: string; }; const errors: JSONC.ParseError[] = []; @@ -286,13 +351,19 @@ function assertBtsConfig( if (expectedConfig.packageManager) { expect(config.packageManager).toBe(expectedConfig.packageManager); } + if (expectedConfig.webDeploy) { + expect(config.webDeploy).toBe(expectedConfig.webDeploy); + } + if (expectedConfig.serverDeploy) { + expect(config.serverDeploy).toBe(expectedConfig.serverDeploy); + } } -function readBtsConfig(dir: string) { +async function readBtsConfig(dir: string) { const btsConfigPath = join(dir, "bts.jsonc"); if (!existsSync(btsConfigPath)) return {} as Record; - const content = readFileSync(btsConfigPath, "utf8"); + const content = await readFile(btsConfigPath, "utf8"); const errors: JSONC.ParseError[] = []; const parsed = JSONC.parse(content, errors, { allowTrailingComma: true, @@ -309,7 +380,7 @@ describe("create-better-t-stack smoke", () => { let workdir: string; beforeAll(async () => { - workdir = createTmpDir("cli"); + workdir = await createTmpDir("cli"); consola.start("Building CLI..."); const buildProc = execa("bun", ["run", "build"], { cwd: join(__dirname, ".."), @@ -329,7 +400,6 @@ describe("create-better-t-stack smoke", () => { consola.info("Programmatic CLI mode"); }); - // Exhaustive matrix: all frontends x standard backends (no db, no orm, no api, no auth) describe("frontend x backend matrix (no db, no api)", () => { const FRONTENDS = [ "tanstack-router", @@ -391,15 +461,15 @@ describe("create-better-t-stack smoke", () => { ); const projectDir = join(workdir, projectName); - assertScaffoldedProject(projectDir); - assertProjectStructure(projectDir, { + await assertScaffoldedProject(projectDir); + await assertProjectStructure(projectDir, { hasWeb: WEB_FRONTENDS.has(frontend), hasNative: frontend === "native-nativewind" || frontend === "native-unistyles", hasServer: true, }); - assertBtsConfig(projectDir, { + await assertBtsConfig(projectDir, { frontend: [frontend], backend, database: "none", @@ -474,9 +544,9 @@ describe("create-better-t-stack smoke", () => { }); } }); - afterAll(() => { + afterAll(async () => { try { - removeSync(workdir); + await remove(workdir); } catch {} }); @@ -1101,6 +1171,52 @@ describe("create-better-t-stack smoke", () => { }); }); + it("scaffolds with PostgreSQL + Drizzle", async () => { + const projectName = "app-postgres-drizzle"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "bun", + "--database", + "postgres", + "--orm", + "drizzle", + "--api", + "trpc", + "--no-auth", + "--addons", + "none", + "--db-setup", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + assertScaffoldedProject(projectDir); + assertProjectStructure(projectDir, { + hasWeb: true, + hasServer: true, + hasDatabase: true, + }); + assertBtsConfig(projectDir, { + database: "postgres", + orm: "drizzle", + }); + }); + it("scaffolds with MongoDB + Mongoose", async () => { const projectName = "app-mongo-mongoose"; await runCli( @@ -1457,6 +1573,116 @@ describe("create-better-t-stack smoke", () => { workdir, ); }); + + it("rejects Turso db-setup with non-SQLite database", async () => { + await runCliExpectingError( + [ + "invalid-combo", + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "bun", + "--database", + "postgres", + "--orm", + "prisma", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "turso", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + }); + }); + + describe("YOLO mode", () => { + it("bypasses db-setup/database validation (Turso + Postgres + Prisma)", async () => { + const projectName = "app-yolo-turso-postgres"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "bun", + "--database", + "postgres", + "--orm", + "prisma", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "turso", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + "--yolo", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + database: "postgres", + orm: "prisma", + }); + }); + + it("bypasses web-deploy requires web frontend (none + wrangler)", async () => { + const projectName = "app-yolo-webdeploy-no-frontend"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "none", + "--backend", + "none", + "--web-deploy", + "wrangler", + "--addons", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + "--yolo", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + backend: "none", + webDeploy: "wrangler", + }); + }); }); describe("runtime compatibility", () => { @@ -1500,10 +1726,6 @@ describe("create-better-t-stack smoke", () => { runtime: "workers", orm: "drizzle", }); - - expect( - existsSync(join(projectDir, "apps", "server", "wrangler.jsonc")), - ).toBe(true); }); it("rejects incompatible runtime and backend combinations", async () => { @@ -1810,7 +2032,6 @@ describe("create-better-t-stack smoke", () => { }); }); - // Git and install flag variations it("scaffolds with git enabled", async () => { const projectName = "app-with-git"; await runCli( @@ -1855,6 +2076,8 @@ describe("create-better-t-stack smoke", () => { [ projectName, "--yes", + "--directory-conflict", + "overwrite", "--frontend", "tanstack-router", "--backend", @@ -1887,7 +2110,6 @@ describe("create-better-t-stack smoke", () => { expect(existsSync(join(projectDir, "node_modules"))).toBe(true); }); - // Additional addons beyond turborepo and biome it("scaffolds with PWA addon", async () => { const projectName = "app-addon-pwa"; await runCli( @@ -2008,7 +2230,6 @@ describe("create-better-t-stack smoke", () => { }); }); - // Authentication combinations it("scaffolds with authentication enabled", async () => { const projectName = "app-with-auth"; await runCli( @@ -2055,7 +2276,6 @@ describe("create-better-t-stack smoke", () => { }); }); - // MySQL database it("scaffolds with MySQL + Prisma", async () => { const projectName = "app-mysql-prisma"; await runCli( @@ -2138,7 +2358,6 @@ describe("create-better-t-stack smoke", () => { }); }); - // oRPC API with more frontends it("scaffolds oRPC with Next.js", async () => { const projectName = "app-orpc-next"; await runCli( @@ -2303,7 +2522,6 @@ describe("create-better-t-stack smoke", () => { }); }); - // Backend next combinations it("scaffolds with Next.js backend", async () => { const projectName = "app-backend-next"; await runCli( @@ -2345,7 +2563,6 @@ describe("create-better-t-stack smoke", () => { }); }); - // Node runtime combinations it("scaffolds with Node runtime", async () => { const projectName = "app-node-runtime"; await runCli( @@ -2459,14 +2676,16 @@ describe("create-better-t-stack smoke", () => { "app-orpc-solid", "app-backend-next", "app-node-runtime", - ].forEach((n) => projectNames.add(n)); + ].forEach((n) => { + projectNames.add(n); + }); - const detectPackageManager = ( + const detectPackageManager = async ( projectDir: string, - ): "bun" | "pnpm" | "npm" => { + ): Promise<"bun" | "pnpm" | "npm"> => { const bts = readBtsConfig(projectDir) as { packageManager?: string }; const pkgJsonPath = join(projectDir, "package.json"); - const pkg = existsSync(pkgJsonPath) ? readJsonSync(pkgJsonPath) : {}; + const pkg = existsSync(pkgJsonPath) ? await readJson(pkgJsonPath) : {}; const pkgMgrField = (pkg.packageManager as string | undefined) || bts.packageManager; @@ -2531,7 +2750,7 @@ describe("create-better-t-stack smoke", () => { consola.info(`${dirName} not found, skipping`); return; } - const pm = detectPackageManager(projectDir); + const pm = await detectPackageManager(projectDir); consola.info(`Processing ${dirName} (pm=${pm})`); try { @@ -2552,11 +2771,11 @@ describe("create-better-t-stack smoke", () => { } const pkgJsonPath = join(projectDir, "package.json"); - const pkg = readJsonSync(pkgJsonPath); + const pkg = await readJson(pkgJsonPath); const scripts = pkg.scripts || {}; consola.info(`Scripts: ${Object.keys(scripts).join(", ")}`); - const bts = readBtsConfig(projectDir) as { + const bts = (await readBtsConfig(projectDir)) as { backend?: string; frontend?: string[]; }; @@ -2603,33 +2822,25 @@ describe("create-better-t-stack smoke", () => { if (scripts["check-types"]) { consola.start(`Type checking ${dirName}...`); - try { - const typeRes = await runScript( - pm, - projectDir, - "check-types", - [], - 120_000, - ); - if (typeRes.exitCode === 0) { - consola.success(`${dirName} type check passed`); - } else { - consola.warn( - `${dirName} type check failed (exit code ${typeRes.exitCode}) - likely due to missing generated files`, - ); - } - } catch (error) { - consola.warn( - `${dirName} type check failed - likely due to missing generated files:`, - error.message, - ); - } + const typeRes = await runScript( + pm, + projectDir, + "check-types", + [], + 120_000, + ); + expect(typeRes.exitCode).toBe(0); + consola.success(`${dirName} type check passed`); } if (!scripts.build && !scripts["check-types"]) { consola.info( `No build or check-types script for ${dirName}, skipping`, ); + } else if (!scripts.build && scripts["check-types"]) { + consola.info( + `Only check-types script available for ${dirName}, type checking will be performed`, + ); } } catch (error) { consola.error(`${dirName} failed`, error); @@ -2639,4 +2850,279 @@ describe("create-better-t-stack smoke", () => { } }, ); + + describe("deploy combinations", () => { + it("scaffolds workers runtime + web deploy wrangler", async () => { + const projectName = "app-web-wrangler"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "workers", + "--web-deploy", + "wrangler", + "--database", + "none", + "--orm", + "none", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + frontend: ["tanstack-router"], + backend: "hono", + runtime: "workers", + }); + }); + + it("scaffolds workers runtime + web deploy alchemy", async () => { + const projectName = "app-web-alchemy"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "workers", + "--web-deploy", + "alchemy", + "--database", + "none", + "--orm", + "none", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + frontend: ["tanstack-router"], + backend: "hono", + runtime: "workers", + }); + }); + + it("scaffolds workers runtime + server deploy alchemy (server-only)", async () => { + const projectName = "app-server-only-alchemy"; + await runCli( + [ + projectName, + "--yes", + "--directory-conflict", + "overwrite", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "workers", + "--server-deploy", + "alchemy", + "--database", + "none", + "--orm", + "none", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + frontend: ["tanstack-router"], + backend: "hono", + runtime: "workers", + serverDeploy: "alchemy", + }); + consola.info("Verifying server-only Alchemy artifacts"); + expect( + existsSync(join(projectDir, "apps", "server", "alchemy.run.ts")), + ).toBe(true); + expect(existsSync(join(projectDir, "apps", "server", "env.d.ts"))).toBe( + true, + ); + expect(existsSync(join(projectDir, "alchemy.run.ts"))).toBe(false); + }); + + it("scaffolds workers runtime + server deploy wrangler", async () => { + const projectName = "app-server-wrangler"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "workers", + "--server-deploy", + "wrangler", + "--database", + "none", + "--orm", + "none", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + frontend: ["tanstack-router"], + backend: "hono", + runtime: "workers", + }); + }); + + it("scaffolds workers runtime + server deploy alchemy", async () => { + const projectName = "app-server-alchemy"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "hono", + "--runtime", + "workers", + "--server-deploy", + "alchemy", + "--database", + "none", + "--orm", + "none", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + frontend: ["tanstack-router"], + backend: "hono", + runtime: "workers", + }); + }); + + it("scaffolds web deploy wrangler with backend none (no server deploy)", async () => { + const projectName = "app-web-wrangler-only"; + await runCli( + [ + projectName, + "--yes", + "--frontend", + "tanstack-router", + "--backend", + "none", + "--web-deploy", + "wrangler", + "--database", + "none", + "--orm", + "none", + "--api", + "none", + "--no-auth", + "--addons", + "none", + "--db-setup", + "none", + "--examples", + "none", + "--package-manager", + "bun", + "--no-install", + "--no-git", + ], + workdir, + ); + + const projectDir = join(workdir, projectName); + await assertScaffoldedProject(projectDir); + await assertBtsConfig(projectDir, { + frontend: ["tanstack-router"], + backend: "none", + webDeploy: "wrangler", + }); + }); + }); }); diff --git a/apps/cli/test/programmatic-api.test.ts b/apps/cli/test/programmatic-api.test.ts index 3fe7c45..9380dff 100644 --- a/apps/cli/test/programmatic-api.test.ts +++ b/apps/cli/test/programmatic-api.test.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { ensureDirSync, existsSync, readFileSync, removeSync } from "fs-extra"; +import { ensureDir, existsSync, readFile, remove } from "fs-extra"; import { parse as parseJsonc } from "jsonc-parser"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { init } from "../src/index"; @@ -9,13 +9,13 @@ let testCounter = 0; let tmpDir: string; let originalCwd: string; -function createTmpDir() { +async function createTmpDir() { testCounter++; const dir = join(__dirname, "..", `.prog-test-${testCounter}`); if (existsSync(dir)) { - removeSync(dir); + await remove(dir); } - ensureDirSync(dir); + await ensureDir(dir); return dir; } @@ -25,7 +25,7 @@ function assertProjectExists(dir: string) { expect(existsSync(join(dir, "bts.jsonc"))).toBe(true); } -function assertBtsConfig( +async function assertBtsConfig( dir: string, expectedConfig: Partial<{ frontend: string[]; @@ -40,7 +40,7 @@ function assertBtsConfig( const configPath = join(dir, "bts.jsonc"); expect(existsSync(configPath)).toBe(true); - const configContent = readFileSync(configPath, "utf-8"); + const configContent = await readFile(configPath, "utf-8"); const config: BetterTStackConfig = parseJsonc(configContent); if (expectedConfig.frontend) { @@ -67,16 +67,16 @@ function assertBtsConfig( } describe("Programmatic API - Fast Tests", () => { - beforeEach(() => { + beforeEach(async () => { originalCwd = process.cwd(); - tmpDir = createTmpDir(); + tmpDir = await createTmpDir(); process.chdir(tmpDir); }); - afterEach(() => { + afterEach(async () => { process.chdir(originalCwd); if (existsSync(tmpDir)) { - removeSync(tmpDir); + await remove(tmpDir); } }); @@ -137,7 +137,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { frontend: ["next"], }); }, 15000); @@ -151,7 +151,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { backend: "fastify", }); }, 15000); @@ -166,7 +166,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { database: "postgres", orm: "prisma", }); @@ -181,7 +181,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { api: "orpc", }); }, 15000); @@ -195,7 +195,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { runtime: "node", }); }, 15000); @@ -209,7 +209,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { addons: ["biome"], }); }, 15000); @@ -258,7 +258,7 @@ describe("Programmatic API - Fast Tests", () => { git: false, yolo: false, }), - ).rejects.toThrow(/requires Mongoose or Prisma/); + ).rejects.toThrow(/Drizzle ORM does not support MongoDB/); }); test("handles auth without database", async () => { @@ -305,7 +305,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { addons: ["biome", "turborepo"], }); }, 15000); @@ -321,7 +321,7 @@ describe("Programmatic API - Fast Tests", () => { }); expect(result.success).toBe(true); - assertBtsConfig(result.projectDirectory, { + await assertBtsConfig(result.projectDirectory, { database: "sqlite", orm: "drizzle", }); diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 21ba86d..8a4aa65 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -15,6 +15,7 @@ public/analytics-minimal.json *.tsbuildinfo /.open-next/ /.wrangler/ +.alchemy # misc .DS_Store diff --git a/apps/web/content/docs/cli/compatibility.mdx b/apps/web/content/docs/cli/compatibility.mdx index b05811f..4f56c1e 100644 --- a/apps/web/content/docs/cli/compatibility.mdx +++ b/apps/web/content/docs/cli/compatibility.mdx @@ -149,11 +149,11 @@ create-better-t-stack --frontend next native-nativewind ### Tauri (Desktop Apps) - Requires web frontend -- Compatible frontends: `tanstack-router`, `react-router`, `nuxt`, `svelte`, `solid` +- Compatible frontends: `tanstack-router`, `react-router`, `nuxt`, `svelte`, `solid`, `next` - Cannot be combined with native frameworks ### Web Deployment -- `--web-deploy workers` requires a web frontend +- `--web-deploy wrangler` requires a web frontend - Cannot be used with native-only projects ## Authentication Requirements diff --git a/apps/web/content/docs/cli/index.mdx b/apps/web/content/docs/cli/index.mdx index b423509..2906c15 100644 --- a/apps/web/content/docs/cli/index.mdx +++ b/apps/web/content/docs/cli/index.mdx @@ -35,9 +35,11 @@ create-better-t-stack [project-directory] [options] - `--api `: `none`, `trpc`, `orpc` - `--db-setup `: `none`, `turso`, `d1`, `neon`, `supabase`, `prisma-postgres`, `mongodb-atlas`, `docker` - `--examples `: `none`, `todo`, `ai` -- `--web-deploy `: `none`, `workers` +- `--web-deploy `: `none`, `wrangler`, `alchemy` +- `--server-deploy `: `none`, `wrangler`, `alchemy` - `--directory-conflict `: `merge`, `overwrite`, `increment`, `error` - `--render-title / --no-render-title`: Show/hide ASCII art title +- `--disable-analytics / --no-disable-analytics`: Control analytics collection See the full reference in [Options](/docs/cli/options). @@ -45,7 +47,7 @@ See the full reference in [Options](/docs/cli/options). ```bash # Default setup with prompts -create-better-t-stack +create-better-t-stack # Quick setup with defaults create-better-t-stack --yes @@ -65,7 +67,8 @@ create-better-t-stack add [options] ### Options - `--addons `: Addons to add (see [Addons](/docs/cli/options#addons)) -- `--web-deploy `: Web deployment setup (`workers`, `none`) +- `--web-deploy `: Web deployment setup (`none`, `wrangler`, `alchemy`) +- `--server-deploy `: Server deployment setup (`none`, `wrangler`, `alchemy`) - `--project-dir `: Project directory (defaults to current directory) - `--install`: Install dependencies after adding - `--package-manager `: Package manager to use @@ -80,7 +83,7 @@ create-better-t-stack add create-better-t-stack add --addons pwa tauri --install # Add deployment setup -create-better-t-stack add --web-deploy workers +create-better-t-stack add --web-deploy wrangler ``` ## `sponsors` diff --git a/apps/web/content/docs/cli/options.mdx b/apps/web/content/docs/cli/options.mdx index 7650925..6195d54 100644 --- a/apps/web/content/docs/cli/options.mdx +++ b/apps/web/content/docs/cli/options.mdx @@ -79,6 +79,20 @@ create-better-t-stack my-app --yes --directory-conflict overwrite 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. + ## Database Options ### `--database ` @@ -212,6 +226,8 @@ create-better-t-stack --auth create-better-t-stack --no-auth ``` +**Note:** Authentication requires both a database and backend framework to be selected. It is automatically disabled when using Convex backend or when no backend is selected. + ## Addons ### `--addons ` @@ -255,12 +271,31 @@ create-better-t-stack --examples todo ai Web deployment configuration: - `none`: No deployment setup -- `workers`: Cloudflare Workers deployment +- `wrangler`: Cloudflare Workers deployment +- `alchemy`: Cloudflare Workers deployment (via Alchemy infrastructure as code) ```bash -create-better-t-stack --web-deploy workers +create-better-t-stack --web-deploy wrangler +create-better-t-stack --web-deploy alchemy ``` +**Note:** Alchemy uses TypeScript to define infrastructure programmatically. See the [Infrastructure as Code with Alchemy Guide](/docs/guides/alchemy-deployment) for details. + +### `--server-deploy ` + +Server deployment configuration: + +- `none`: No deployment setup +- `wrangler`: Cloudflare Workers deployment (when runtime is workers) +- `alchemy`: Cloudflare Workers deployment (when runtime is workers, via Alchemy infrastructure as code) + +```bash +create-better-t-stack --server-deploy wrangler +create-better-t-stack --server-deploy alchemy +``` + +**Note:** Alchemy uses TypeScript to define infrastructure programmatically. See the [Infrastructure as Code with Alchemy Guide](/docs/guides/alchemy-deployment) for details. + ## Option Validation The CLI validates option combinations and will show errors for incompatible selections. See the [Compatibility](/docs/cli/compatibility) page for detailed rules. @@ -281,6 +316,8 @@ create-better-t-stack \ --addons pwa biome \ --examples todo \ --package-manager bun \ + --web-deploy wrangler \ + --server-deploy alchemy \ --install ``` diff --git a/apps/web/content/docs/cli/programmatic-api.mdx b/apps/web/content/docs/cli/programmatic-api.mdx index 3538e65..bbff365 100644 --- a/apps/web/content/docs/cli/programmatic-api.mdx +++ b/apps/web/content/docs/cli/programmatic-api.mdx @@ -146,6 +146,7 @@ interface CreateInput { runtime?: Runtime; // Runtime environment api?: API; // API type webDeploy?: WebDeploy; // Web deployment setup + serverDeploy?: ServerDeploy; // Server deployment setup directoryConflict?: DirectoryConflict; // "merge" | "overwrite" | "increment" | "error" renderTitle?: boolean; // Show ASCII art title disableAnalytics?: boolean; // Disable analytics and telemetry diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx index 3e9e76f..8d416e9 100644 --- a/apps/web/content/docs/index.mdx +++ b/apps/web/content/docs/index.mdx @@ -254,7 +254,6 @@ See the full list in the [CLI Reference](/docs/cli). Key flags: - `--database`: sqlite, postgres, mysql, mongodb, none - `--orm`: drizzle, prisma, mongoose, none - `--api`: trpc, orpc, none -- `--addons`: turborepo, pwa, tauri, biome, husky, starlight, none - `--addons`: turborepo, pwa, tauri, biome, husky, starlight, fumadocs, ultracite, oxlint, ruler, none - `--examples`: todo, ai, none diff --git a/apps/web/content/docs/project-structure.mdx b/apps/web/content/docs/project-structure.mdx index b1828b0..15a8980 100644 --- a/apps/web/content/docs/project-structure.mdx +++ b/apps/web/content/docs/project-structure.mdx @@ -260,7 +260,8 @@ apps/docs/ "packageManager": "", "dbSetup": "", "api": "", - "webDeploy": "" + "webDeploy": "", + "serverDeploy": "" } ``` diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index f949a15..9baf3f7 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -10,6 +10,7 @@ const config = { { 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: { diff --git a/apps/web/package.json b/apps/web/package.json index 479e5d8..a53b736 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "convex": "^1.25.4", + "convex-helpers": "^0.1.104", "date-fns": "^4.1.0", "fumadocs-core": "15.6.7", "fumadocs-mdx": "11.7.3", diff --git a/apps/web/public/schema.json b/apps/web/public/schema.json deleted file mode 100644 index 1e5780f..0000000 --- a/apps/web/public/schema.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://better-t-stack.dev/schema.json", - "title": "Better-T-Stack Configuration", - "description": "Configuration file for Better-T-Stack projects", - "type": "object", - "properties": { - "$schema": { - "type": "string", - "description": "JSON Schema reference for validation" - }, - "version": { - "type": "string", - "description": "CLI version used to create this project", - "pattern": "^\\d+\\.\\d+\\.\\d+$" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "Timestamp when the project was created" - }, - "database": { - "type": "string", - "enum": [ - "none", - "sqlite", - "postgres", - "mysql", - "mongodb" - ], - "description": "Database type" - }, - "orm": { - "type": "string", - "enum": [ - "drizzle", - "prisma", - "mongoose", - "none" - ], - "description": "ORM type" - }, - "backend": { - "type": "string", - "enum": [ - "hono", - "express", - "fastify", - "next", - "elysia", - "convex", - "none" - ], - "description": "Backend framework" - }, - "runtime": { - "type": "string", - "enum": [ - "bun", - "node", - "workers", - "none" - ], - "description": "Runtime environment (workers only available with hono backend and drizzle orm)" - }, - "frontend": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "tanstack-router", - "react-router", - "tanstack-start", - "next", - "nuxt", - "native-nativewind", - "native-unistyles", - "svelte", - "solid", - "none" - ] - }, - "description": "Frontend framework" - }, - "addons": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "pwa", - "tauri", - "starlight", - "biome", - "husky", - "turborepo", - "fumadocs", - "ultracite", - "oxlint", - "none" - ] - }, - "description": "Additional addons" - }, - "examples": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "todo", - "ai", - "none" - ] - }, - "description": "Example templates to include" - }, - "auth": { - "type": "boolean", - "description": "Whether authentication is enabled" - }, - "packageManager": { - "type": "string", - "enum": [ - "npm", - "pnpm", - "bun" - ], - "description": "Package manager" - }, - "dbSetup": { - "type": "string", - "enum": [ - "turso", - "neon", - "prisma-postgres", - "mongodb-atlas", - "supabase", - "d1", - "docker", - "none" - ], - "description": "Database hosting setup" - }, - "api": { - "type": "string", - "enum": [ - "trpc", - "orpc", - "none" - ], - "description": "API type" - }, - "webDeploy": { - "type": "string", - "enum": [ - "workers", - "none" - ], - "description": "Web deployment" - } - }, - "required": [ - "version", - "createdAt", - "database", - "orm", - "backend", - "runtime", - "frontend", - "addons", - "examples", - "auth", - "packageManager", - "dbSetup", - "api", - "webDeploy" - ], - "additionalProperties": false -} \ No newline at end of file diff --git a/apps/web/scripts/generate-schema.ts b/apps/web/scripts/generate-schema.ts index 15120fa..6d992b1 100644 --- a/apps/web/scripts/generate-schema.ts +++ b/apps/web/scripts/generate-schema.ts @@ -13,6 +13,7 @@ import { ORMSchema, PackageManagerSchema, RuntimeSchema, + ServerDeploySchema, WebDeploySchema, } from "../../cli/src/types"; @@ -27,6 +28,7 @@ const PACKAGE_MANAGER_VALUES = PackageManagerSchema.options; const DATABASE_SETUP_VALUES = DatabaseSetupSchema.options; const API_VALUES = APISchema.options; const WEB_DEPLOY_VALUES = WebDeploySchema.options; +const SERVER_DEPLOY_VALUES = ServerDeploySchema.options; const configSchema = { $schema: "http://json-schema.org/draft-07/schema#", @@ -117,6 +119,11 @@ const configSchema = { enum: WEB_DEPLOY_VALUES, description: WebDeploySchema.description, }, + serverDeploy: { + type: "string", + enum: SERVER_DEPLOY_VALUES, + description: ServerDeploySchema.description, + }, }, required: [ "version", @@ -133,6 +140,7 @@ const configSchema = { "dbSetup", "api", "webDeploy", + "serverDeploy", ], additionalProperties: false, }; diff --git a/apps/web/src/app/(home)/_components/customizable-section.tsx b/apps/web/src/app/(home)/_components/customizable-section.tsx index bcee210..b30cb11 100644 --- a/apps/web/src/app/(home)/_components/customizable-section.tsx +++ b/apps/web/src/app/(home)/_components/customizable-section.tsx @@ -26,7 +26,7 @@ export default function CustomizableSection() { transition={{ duration: 0.5, delay: 0.2 }} className="mx-auto max-w-3xl space-y-6" > -

+

Build your perfect TypeScript stack.

diff --git a/apps/web/src/app/(home)/_components/navbar.tsx b/apps/web/src/app/(home)/_components/navbar.tsx index 2794f18..a5199e2 100644 --- a/apps/web/src/app/(home)/_components/navbar.tsx +++ b/apps/web/src/app/(home)/_components/navbar.tsx @@ -98,7 +98,7 @@ export default function Navbar() { className={cn( "fixed top-0 z-[100] w-full transition-all duration-300 ease-in-out", scrolled - ? " border- border-border shadow-sm backdrop-blur-md" + ? "border- border-border shadow-sm backdrop-blur-md" : "border-transparent border-b bg-transparent", )} > @@ -183,7 +183,7 @@ export default function Navbar() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2, ease: "easeInOut" }} - className=" fixed inset-0 z-[98 backdrop-blur-sm lg:hidden" + className="fixed inset-0 z-[98 backdrop-blur-sm lg:hidden" onClick={closeMobileMenu} aria-hidden="true" /> diff --git a/apps/web/src/app/(home)/_components/sponsors-section.tsx b/apps/web/src/app/(home)/_components/sponsors-section.tsx index da4a4be..1ceca52 100644 --- a/apps/web/src/app/(home)/_components/sponsors-section.tsx +++ b/apps/web/src/app/(home)/_components/sponsors-section.tsx @@ -1,3 +1,5 @@ +import { api } from "@better-t-stack/backend/convex/_generated/api"; +import { useQueryWithStatus } from "@better-t-stack/backend/convex/hooks"; import { ChevronDown, ChevronUp, @@ -8,12 +10,10 @@ import { Terminal, } from "lucide-react"; import Image from "next/image"; -// import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { filterCurrentSponsors, filterPastSponsors, - filterRegularSponsors, filterSpecialSponsors, formatSponsorUrl, getSponsorUrl, @@ -21,39 +21,91 @@ import { sortSpecialSponsors, sortSponsors, } from "@/lib/sponsor-utils"; -import type { Sponsor } from "@/lib/types"; export default function SponsorsSection() { - const [sponsors, setSponsors] = useState([]); - const [loadingSponsors, setLoadingSponsors] = useState(true); - const [sponsorError, setSponsorError] = useState(null); const [showPastSponsors, setShowPastSponsors] = useState(false); - useEffect(() => { - fetch("https://sponsors.amanv.dev/sponsors.json") - .then((res) => { - if (!res.ok) throw new Error("Failed to fetch sponsors"); - return res.json(); - }) - .then((data) => { - const sponsorsData = Array.isArray(data) ? data : []; - const sortedSponsors = sortSponsors(sponsorsData); - setSponsors(sortedSponsors); - setLoadingSponsors(false); - }) - .catch(() => { - setSponsorError("Could not load sponsors"); - setLoadingSponsors(false); - }); - }, []); + const sponsorsQuery = useQueryWithStatus(api.sponsors.getSponsors); - const currentSponsors = filterCurrentSponsors(sponsors); - const pastSponsors = filterPastSponsors(sponsors); + if (sponsorsQuery.isPending) { + return ( +
+
+
+ + + SPONSORS_DATABASE.JSON + +
+
+
+ + [LOADING... RECORDS] + +
+
+
+
+
+ LOADING_SPONSORS.SH +
+
+
+
+ ); + } + if (sponsorsQuery.isError) { + return ( +
+
+
+ + + SPONSORS_DATABASE.JSON + +
+
+
+ + [ERROR RECORDS] + +
+
+
+
+
+ + ERROR_LOADING_SPONSORS.NULL + +
+
+ $ + + Please try again later! + +
+
+
+
+ ); + } + + const sponsors = + sponsorsQuery.data?.map((sponsor) => ({ + ...sponsor, + sponsor: { + ...sponsor.sponsor, + customLogoUrl: sponsor.sponsor.customLogoUrl || "", + }, + })) || []; + + const sortedSponsors = sortSponsors(sponsors); + const currentSponsors = filterCurrentSponsors(sortedSponsors); + const pastSponsors = filterPastSponsors(sortedSponsors); const specialSponsors = sortSpecialSponsors( filterSpecialSponsors(currentSponsors), ); - const regularSponsors = filterRegularSponsors(currentSponsors); return (
@@ -65,37 +117,24 @@ export default function SponsorsSection() {
- - [{loadingSponsors ? "LOADING..." : sponsors.length} RECORDS] - +
+ + [{sponsors.length} RECORDS] + +
- {loadingSponsors ? ( -
-
-
- LOADING_SPONSORS.SH -
-
-
- ) : sponsorError ? ( -
-
- - ERROR: {sponsorError} -
-
- ) : sponsors.length === 0 ? ( + {sponsors.length === 0 ? (
- + NO_SPONSORS_FOUND.NULL
$ - + Be the first to support this project!
@@ -162,7 +201,7 @@ export default function SponsorsSection() { {entry.sponsor.name || entry.sponsor.login} {entry.tierName && ( -

+

{entry.tierName}

)} @@ -203,90 +242,93 @@ export default function SponsorsSection() {
)} - {regularSponsors.length > 0 && ( + + {currentSponsors.filter((s) => !isSpecialSponsor(s)).length > 0 && (
- {regularSponsors.map((entry, index) => { - const since = new Date(entry.createdAt).toLocaleDateString( - undefined, - { year: "numeric", month: "short" }, - ); - return ( -
-
-
- -
- SINCE {since.toUpperCase()} + {currentSponsors + .filter((s) => !isSpecialSponsor(s)) + .map((entry, index) => { + const since = new Date(entry.createdAt).toLocaleDateString( + undefined, + { year: "numeric", month: "short" }, + ); + return ( +
+
+
+ +
+ SINCE {since.toUpperCase()} +
-
-
-
-
- {entry.sponsor.name -
-
-
-

- {entry.sponsor.name || entry.sponsor.login} -

- {entry.tierName && ( -

- {entry.tierName} -

- )} +
+
+
+ {entry.sponsor.name
-
- - - - {entry.sponsor.login} - - - {(entry.sponsor.websiteUrl || - entry.sponsor.linkUrl) && ( +
+
+

+ {entry.sponsor.name || entry.sponsor.login} +

+ {entry.tierName && ( +

+ {entry.tierName} +

+ )} +
+
-
- ); - })} + ); + })}
)} diff --git a/apps/web/src/app/(home)/_components/stack-builder.tsx b/apps/web/src/app/(home)/_components/stack-builder.tsx index cc24712..b177be6 100644 --- a/apps/web/src/app/(home)/_components/stack-builder.tsx +++ b/apps/web/src/app/(home)/_components/stack-builder.tsx @@ -70,6 +70,7 @@ const CATEGORY_ORDER: Array = [ "orm", "dbSetup", "webDeploy", + "serverDeploy", "auth", "packageManager", "addons", @@ -122,6 +123,7 @@ const getBadgeColors = (category: string): string => { return "border-orange-300 bg-orange-100 text-orange-800 dark:border-orange-700/30 dark:bg-orange-900/30 dark:text-orange-300"; case "git": case "webDeploy": + case "serverDeploy": case "install": return "border-gray-300 bg-gray-100 text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"; default: @@ -716,10 +718,10 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { if (!isPWACompat && nextStack.addons.includes("pwa")) { incompatibleAddons.push("pwa"); notes.webFrontend.notes.push( - "PWA addon requires TanStack/React Router or Solid. Addon will be removed.", + "PWA addon requires TanStack Router, React Router, Solid, or Next.js. Addon will be removed.", ); notes.addons.notes.push( - "PWA requires TanStack/React Router/Solid. It will be removed.", + "PWA requires TanStack Router, React Router, Solid, or Next.js. It will be removed.", ); notes.webFrontend.hasIssue = true; notes.addons.hasIssue = true; @@ -731,10 +733,10 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { if (!isTauriCompat && nextStack.addons.includes("tauri")) { incompatibleAddons.push("tauri"); notes.webFrontend.notes.push( - "Tauri addon requires TanStack/React Router, Nuxt, Svelte, Solid, or Next.js. Addon will be removed.", + "Tauri addon requires TanStack Router, React Router, Nuxt, Svelte, Solid, or Next.js. Addon will be removed.", ); notes.addons.notes.push( - "Tauri requires TanStack/React Router/Nuxt/Svelte/Solid/Next.js. It will be removed.", + "Tauri requires TanStack Router, React Router, Nuxt, Svelte, Solid, or Next.js. It will be removed.", ); notes.webFrontend.hasIssue = true; notes.addons.hasIssue = true; @@ -881,6 +883,160 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { }); } + // Server deployment requires a backend (and not Convex) + if ( + nextStack.serverDeploy !== "none" && + (nextStack.backend === "none" || nextStack.backend === "convex") + ) { + notes.serverDeploy.notes.push( + "Server deployment requires a supported backend. It will be disabled.", + ); + notes.backend.notes.push( + "No compatible backend selected: Server deployment has been disabled.", + ); + notes.serverDeploy.hasIssue = true; + notes.backend.hasIssue = true; + nextStack.serverDeploy = "none"; + changed = true; + changes.push({ + category: "serverDeploy", + message: "Server deployment set to 'none' (requires backend)", + }); + } + + // Cloudflare server deployments (wrangler/alchemy) require Workers runtime + if (nextStack.serverDeploy !== "none" && nextStack.runtime !== "workers") { + notes.serverDeploy.notes.push( + "Selected server deployment targets Cloudflare Workers. Runtime will be set to 'Cloudflare Workers'.", + ); + notes.runtime.notes.push( + "Server deployment requires Cloudflare Workers runtime. It will be selected.", + ); + notes.serverDeploy.hasIssue = true; + notes.runtime.hasIssue = true; + nextStack.runtime = "workers"; + changed = true; + changes.push({ + category: "serverDeploy", + message: + "Runtime set to 'Cloudflare Workers' (required by server deployment)", + }); + + // Apply Workers runtime compatibility adjustments + if (nextStack.backend !== "hono") { + notes.runtime.notes.push( + "Cloudflare Workers runtime requires Hono backend. Hono will be selected.", + ); + notes.backend.notes.push( + "Cloudflare Workers runtime requires Hono backend. It will be selected.", + ); + notes.runtime.hasIssue = true; + notes.backend.hasIssue = true; + nextStack.backend = "hono"; + changes.push({ + category: "runtime", + message: "Backend set to 'Hono' (required by Cloudflare Workers)", + }); + } + + if (nextStack.orm !== "drizzle" && nextStack.orm !== "none") { + notes.runtime.notes.push( + "Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.", + ); + notes.orm.notes.push( + "Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.", + ); + notes.runtime.hasIssue = true; + notes.orm.hasIssue = true; + nextStack.orm = "drizzle"; + changes.push({ + category: "runtime", + message: "ORM set to 'Drizzle' (required by Cloudflare Workers)", + }); + } + + if (nextStack.database === "mongodb") { + notes.runtime.notes.push( + "Cloudflare Workers runtime is not compatible with MongoDB. SQLite will be selected.", + ); + notes.database.notes.push( + "MongoDB is not compatible with Cloudflare Workers runtime. SQLite will be selected.", + ); + notes.runtime.hasIssue = true; + notes.database.hasIssue = true; + nextStack.database = "sqlite"; + changes.push({ + category: "runtime", + message: + "Database set to 'SQLite' (MongoDB not compatible with Workers)", + }); + } + + if (nextStack.dbSetup === "docker") { + notes.runtime.notes.push( + "Cloudflare Workers runtime does not support Docker setup. D1 will be selected.", + ); + notes.dbSetup.notes.push( + "Docker setup is not compatible with Cloudflare Workers runtime. D1 will be selected.", + ); + notes.runtime.hasIssue = true; + notes.dbSetup.hasIssue = true; + nextStack.dbSetup = "d1"; + changes.push({ + category: "runtime", + message: "DB Setup set to 'D1' (Docker not compatible with Workers)", + }); + } + } + + // Alchemy deployment validation - temporarily not compatible with Next.js and React Router + const isAlchemyWebDeploy = nextStack.webDeploy === "alchemy"; + const isAlchemyServerDeploy = nextStack.serverDeploy === "alchemy"; + + if (isAlchemyWebDeploy || isAlchemyServerDeploy) { + const incompatibleFrontends = nextStack.webFrontend.filter( + (f) => f === "next" || f === "react-router", + ); + + if (incompatibleFrontends.length > 0) { + const deployType = + isAlchemyWebDeploy && isAlchemyServerDeploy + ? "web and server deployment" + : isAlchemyWebDeploy + ? "web deployment" + : "server deployment"; + + notes.webFrontend.notes.push( + `Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}. These frontends will be removed.`, + ); + notes.webDeploy.notes.push( + `Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`, + ); + notes.serverDeploy.notes.push( + `Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`, + ); + notes.webFrontend.hasIssue = true; + notes.webDeploy.hasIssue = true; + notes.serverDeploy.hasIssue = true; + + // Remove incompatible frontends + nextStack.webFrontend = nextStack.webFrontend.filter( + (f) => f !== "next" && f !== "react-router", + ); + + // If no web frontends remain, set to default + if (nextStack.webFrontend.length === 0) { + nextStack.webFrontend = ["tanstack-router"]; + } + + changed = true; + changes.push({ + category: "alchemy", + message: `Removed ${incompatibleFrontends.join(" and ")} (not compatible with Alchemy ${deployType})`, + }); + } + } + return { adjustedStack: changed ? nextStack : null, notes, @@ -985,6 +1141,13 @@ const generateCommand = (stackState: StackState): string => { flags.push(`--web-deploy ${stackState.webDeploy}`); } + if ( + stackState.serverDeploy && + !checkDefault("serverDeploy", stackState.serverDeploy) + ) { + flags.push(`--server-deploy ${stackState.serverDeploy}`); + } + if (!checkDefault("install", stackState.install)) { if (stackState.install === "false" && DEFAULT_STACK.install === "true") { flags.push("--no-install"); @@ -1461,6 +1624,19 @@ const StackBuilder = () => { const { adjustedStack } = analyzeStackCompatibility(simulatedStack); const finalStack = adjustedStack ?? simulatedStack; + // Additional check for Alchemy compatibility with Next.js and React Router + if ( + category === "webFrontend" && + (optionId === "next" || optionId === "react-router") + ) { + const isAlchemyWebDeploy = finalStack.webDeploy === "alchemy"; + const isAlchemyServerDeploy = finalStack.serverDeploy === "alchemy"; + + if (isAlchemyWebDeploy || isAlchemyServerDeploy) { + return false; + } + } + if ( category === "webFrontend" || category === "nativeFrontend" || diff --git a/apps/web/src/app/(home)/_components/testimonials.tsx b/apps/web/src/app/(home)/_components/testimonials.tsx index 81cf2d1..c6e2a0d 100644 --- a/apps/web/src/app/(home)/_components/testimonials.tsx +++ b/apps/web/src/app/(home)/_components/testimonials.tsx @@ -1,127 +1,13 @@ "use client"; +import { api } from "@better-t-stack/backend/convex/_generated/api"; +import { useQueryWithStatus } from "@better-t-stack/backend/convex/hooks"; import { Play, Terminal } from "lucide-react"; import { motion } from "motion/react"; import Image from "next/image"; import { Suspense } from "react"; import { Tweet, TweetSkeleton, type TwitterComponents } from "react-tweet"; -const YOUTUBE_VIDEOS = [ - { - embedId: "VL6zJH6z8wY", - title: "Advanced Vibe Coding - setup for new projects", - }, - { - embedId: "cdivzGRhsYk", - title: - "MY UPGRADED AI Coding Workflow + Free APIs: How I DO AI Coding! (Stitch, Better T3, SuperNinja)", - }, - { - embedId: "azhw_iq8SIA", - title: "This CLI Lets You Choose Your Entire Tech Stack Instantly", - }, - { - embedId: "CWwkWJmT_zU", - title: "The BEST Way To Start a Project (Better-T-Stack)", - }, - { - embedId: "MGmPTcgJYIo", - title: "This new CLI tool makes scaffolding projects easy", - }, - { - embedId: "g-ynSAdL6Ak", - title: "This tool cured my JavaScript fatigue", - }, - { - embedId: "uHUgw-Hi8HE", - title: "I tried React again after 2 years of Svelte", - }, -]; - -const TWEET_IDS = [ - "1930194170418999437", - "1907728148294447538", - "1936942642069455037", - "1931029815047455149", - "1933149770639614324", - "1937599252173128103", - "1947357370302304559", - "1930511724702285885", - "1945591420657532994", - "1945204056063913989", - "1912836377365905496", - "1947973299805561005", - "1949843350250738126", - "1949907407657992231", - "1907817662215757853", - "1933216760896934060", - "1949912886958301546", - "1942558041704182158", - "1947636576118304881", - "1951704580691304693", - "1937383786637094958", - "1931709370003583004", - "1929147326955704662", - "1948050877454938549", - "1951599045383770386", - "1904228496144269699", - "1949851365435469889", - "1950457707632214136", - "1930257410259616057", - "1937258706279817570", - "1917815700980391964", - "1949921211586400419", - "1947812547551498466", - "1928317790588403953", - "1917640304758514093", - "1951703990896570459", - "1907831059275735353", - "1912924558522524039", - "1945054982870282575", - "1933150129738981383", - "1949907577611145726", - "1911490975173607495", - "1930104047845158972", - "1913773945523953713", - "1951540684340469950", - "1944937093387706572", - "1904241046898556970", - "1913834145471672652", - "1946245671880966269", - "1930514202260635807", - "1931589579749892480", - "1904144343125860404", - "1917610656477348229", - "1904215768272654825", - "1931830211013718312", - "1944895251811893680", - "1913833079342522779", - "1930449311848087708", - "1942680754384953790", - "1907723601731530820", - "1944553262792810603", - "1904233896851521980", - "1930294868808515726", - "1943290033383047237", - "1913801258789491021", - "1907841646513005038", - "1904301540422070671", - "1944208789617471503", - "1912837026925195652", - "1904338606409531710", - "1942965795920679188", - "1904318186750652606", - "1943656585294643386", - "1908568583799484519", - "1913018977321693448", - "1904179661086556412", - "1908558365128876311", - "1907772878139072851", - "1906149740095705265", - "1906001923456790710", - "1906570888897777847", -]; - export const components: TwitterComponents = { AvatarImg: (props) => { if (!props.src || props.src === "") { @@ -143,15 +29,173 @@ export const components: TwitterComponents = { }, }; +const VideoCard = ({ + video, + index, +}: { + video: { embedId: string; title: string }; + index: number; +}) => ( + +
+
+
+ + + [VIDEO_{String(index + 1).padStart(3, "0")}] + +
+
+
+
+