@clubedge/create-clubedge-app 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +23 -16
  3. package/dist/core/customize.js +1 -0
  4. package/dist/core/deploy.js +58 -3
  5. package/dist/core/manifest-packs.js +21 -0
  6. package/dist/core/plan.js +9 -0
  7. package/dist/core/project.js +1 -0
  8. package/dist/deploy.js +196 -6
  9. package/package.json +4 -4
  10. package/template/.github/workflows/ci.yml +4 -0
  11. package/template/.github/workflows/preview.yml +35 -2
  12. package/template/SETUP.md +6 -1
  13. package/template/_gitignore +7 -0
  14. package/template/apps/api/.env.example +27 -0
  15. package/template/apps/api/clubedge.stack.json +32 -2
  16. package/template/apps/api/package.json +4 -1
  17. package/template/apps/api/src/app.ts +11 -0
  18. package/template/apps/api/src/server/auth.ts +6 -1
  19. package/template/apps/api/src/server/index.ts +7 -1
  20. package/template/apps/api/src/server/variants/auth.appwrite.ts +38 -0
  21. package/template/apps/api/src/server/variants/auth.better-auth.ts +31 -0
  22. package/template/apps/api/src/server/variants/auth.firebase.ts +34 -0
  23. package/template/apps/api/src/server/variants/auth.supabase.ts +12 -0
  24. package/template/apps/api-fastapi/.env.example +33 -0
  25. package/template/apps/api-fastapi/.python-version +1 -0
  26. package/template/apps/api-fastapi/Dockerfile +38 -0
  27. package/template/apps/api-fastapi/clubedge.stack.json +69 -0
  28. package/template/apps/api-fastapi/package.json +20 -0
  29. package/template/apps/api-fastapi/pyproject.toml +40 -0
  30. package/template/apps/api-fastapi/src/clubedge_api/__init__.py +1 -0
  31. package/template/apps/api-fastapi/src/clubedge_api/__main__.py +16 -0
  32. package/template/apps/api-fastapi/src/clubedge_api/app.py +77 -0
  33. package/template/apps/api-fastapi/src/clubedge_api/auth.py +13 -0
  34. package/template/apps/api-fastapi/src/clubedge_api/check_env.py +12 -0
  35. package/template/apps/api-fastapi/src/clubedge_api/check_services.py +18 -0
  36. package/template/apps/api-fastapi/src/clubedge_api/db.py +19 -0
  37. package/template/apps/api-fastapi/src/clubedge_api/main.py +6 -0
  38. package/template/apps/api-fastapi/src/clubedge_api/server.py +41 -0
  39. package/template/apps/api-fastapi/src/clubedge_api/settings.py +41 -0
  40. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_appwrite.py +43 -0
  41. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_better_auth.py +44 -0
  42. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_firebase.py +55 -0
  43. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_supabase.py +40 -0
  44. package/template/apps/api-fastapi/src/config/site.json +11 -0
  45. package/template/apps/api-fastapi/tests/test_app.py +46 -0
  46. package/template/apps/api-fastapi/uv.lock +733 -0
  47. package/template/apps/start/package.json +1 -1
  48. package/template/apps/vite/.env.example +35 -1
  49. package/template/apps/vite/Dockerfile +20 -6
  50. package/template/apps/vite/auth.server.mjs +251 -0
  51. package/template/apps/vite/build-auth-server.mjs +15 -0
  52. package/template/apps/vite/clubedge.stack.json +91 -2
  53. package/template/apps/vite/dev.mjs +39 -0
  54. package/template/apps/vite/package.json +9 -3
  55. package/template/apps/vite/server.mjs +205 -0
  56. package/template/apps/vite/src/components/dashboard/variants/account-button.supabase.tsx +51 -0
  57. package/template/apps/vite/src/lib/api.ts +3 -0
  58. package/template/apps/vite/src/lib/auth-client.ts +15 -0
  59. package/template/apps/vite/src/lib/auth-enabled.ts +1 -0
  60. package/template/apps/vite/src/lib/auth.ts +2 -0
  61. package/template/apps/vite/src/routes/dashboard.tsx +7 -4
  62. package/template/apps/vite/src/routes/forgot-password.tsx +65 -0
  63. package/template/apps/vite/src/routes/index.tsx +8 -0
  64. package/template/apps/vite/src/routes/login.tsx +181 -0
  65. package/template/apps/vite/src/routes/reset-password.tsx +61 -0
  66. package/template/apps/vite/src/variants/api.better-auth.ts +5 -0
  67. package/template/apps/vite/src/variants/api.supabase.ts +7 -0
  68. package/template/apps/vite/src/variants/auth-enabled.supabase.ts +3 -0
  69. package/template/apps/vite/src/variants/auth.appwrite.ts +91 -0
  70. package/template/apps/vite/src/variants/auth.better-auth.ts +44 -0
  71. package/template/apps/vite/src/variants/auth.firebase.ts +95 -0
  72. package/template/apps/vite/src/variants/auth.supabase.ts +48 -0
  73. package/template/apps/vite/src/variants/router.supabase.tsx +63 -0
  74. package/template/apps/vite/vite.config.ts +2 -1
  75. package/template/apps/web/package.json +1 -1
  76. package/template/clubedge/modules/auth.json +1 -0
  77. package/template/clubedge.template.json +8 -0
  78. package/template/e2e/auth.spec.ts +8 -3
  79. package/template/e2e/firebase-auth-flow.spec.ts +39 -0
  80. package/template/e2e/home.spec.ts +8 -1
  81. package/template/package.json +4 -3
  82. package/template/packages/auth/package.json +1 -1
  83. package/template/packages/auth-appwrite/package.json +1 -1
  84. package/template/packages/auth-better-auth/package.json +1 -1
  85. package/template/packages/auth-better-auth/src/index.ts +3 -2
  86. package/template/packages/auth-firebase/package.json +1 -1
  87. package/template/packages/auth-supabase/package.json +3 -2
  88. package/template/packages/auth-supabase/src/browser.ts +8 -0
  89. package/template/packages/auth-supabase/src/index.ts +20 -0
  90. package/template/packages/cache/package.json +1 -1
  91. package/template/packages/cache-redis/package.json +1 -1
  92. package/template/packages/contract/openapi.yaml +8 -0
  93. package/template/packages/contract/package.json +4 -1
  94. package/template/packages/contract/scripts/check-api.mjs +78 -0
  95. package/template/packages/contract/src/index.ts +19 -2
  96. package/template/packages/core/package.json +1 -1
  97. package/template/packages/db/package.json +1 -1
  98. package/template/packages/email/package.json +1 -1
  99. package/template/packages/email-resend/package.json +1 -1
  100. package/template/packages/email-smtp/package.json +1 -1
  101. package/template/packages/jobs/package.json +1 -1
  102. package/template/packages/jobs-bullmq/package.json +1 -1
  103. package/template/packages/jobs-inngest/package.json +1 -1
  104. package/template/packages/jobs-pg-boss/package.json +1 -1
  105. package/template/packages/local-db/package.json +1 -1
  106. package/template/packages/observability/package.json +1 -1
  107. package/template/packages/observability-otel/package.json +1 -1
  108. package/template/packages/observability-sentry/package.json +1 -1
  109. package/template/packages/payments/package.json +1 -1
  110. package/template/packages/payments-stripe/package.json +1 -1
  111. package/template/packages/storage/package.json +1 -1
  112. package/template/packages/storage-s3/package.json +1 -1
  113. package/template/packages/storage-supabase/package.json +1 -1
  114. package/template/packages/ui/package.json +1 -1
  115. package/template/playwright.config.ts +15 -0
  116. package/template/pnpm-lock.yaml +818 -9
  117. package/template/scripts/smoke-generated-app.mjs +106 -0
  118. package/template/turbo.json +6 -0
  119. package/template.lock.json +53 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.35.0
6
+
7
+ - **Deploy a frontend and its separate API** (#103):
8
+ - `--deploy docker` generates a Compose setup with the Vite frontend and API as separate services. The frontend is public; the API stays on the internal network, and the generated configuration includes each service's port and health check.
9
+ - `--deploy railway` provisions the frontend and API services, wires the frontend to the private API URL, configures service variables, and runs database migrations before releasing the API.
10
+ - Deployment capabilities now live in each stack's descriptor, including supported platforms, ports, and health checks. See [docs/stack-packs.md](docs/stack-packs.md).
11
+ - **Default to Starter `v0.24.0`** (#105), which brings all four auth providers to React + Vite with either Hono or FastAPI:
12
+ - Supabase, Better Auth, Firebase, and Appwrite each have frontend flows and API-side token or session verification.
13
+ - Generated-project and auth-flow CI covers every provider with both APIs; Docker and Railway deploy configuration is checked for both combinations.
14
+ - FastAPI now has a committed `uv.lock` for reproducible Python installs. Pull request previews can also deploy the Vite frontend and API as separate Render services.
15
+
16
+ ## 0.34.0
17
+
18
+ - **A FastAPI API**: default to Starter `v0.23.0` (#101). `--framework vite --api fastapi` creates the React + Vite frontend with an API in Python, managed with [uv](https://docs.astral.sh/uv/):
19
+ - The Starter added it as one folder that describes itself, and this CLI generates it without a code change: the API lands in `apps/api` as `@clubedge/api`, and the CLI runs `uv sync` after `pnpm install`.
20
+ - Its `package.json` scripts call uv, ruff, mypy, and pytest, so `pnpm dev`, `pnpm lint`, `pnpm typecheck`, and `pnpm test` run it like the other apps. `pnpm lint` and `pnpm test` now also run each app's own scripts, so apps in other languages are always checked.
21
+ - It passes the same contract suite as the Hono API, against the live server, through `clubedge-contract-check`.
22
+ - A first version: the app modules are `none` (cache: memory, deploy: Docker). Install uv first.
23
+ - CI generates the pair from the packed CLI. The smoke check waits for a separate API that starts after its frontend (#100).
24
+
5
25
  ## 0.33.0
6
26
 
7
27
  - **A frontend and a separate API**: default to Starter `v0.22.0` (#98). `--framework vite --api hono` creates a React + Vite frontend in `apps/web` and a Hono API in `apps/api`, joined by one HTTP contract:
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # create-clubedge-app
6
6
 
7
- **Create a production-ready Next.js or TanStack Start app with one command, and keep it up to date.**
7
+ **Create a production-ready React app with one command: Next.js, TanStack Start, or Vite with a separate API.**
8
8
 
9
9
  Pick only the modules you need: auth, storage, email, payments, jobs, observability, deployment.<br>
10
10
  Add, remove, or upgrade them later without losing your own changes.
@@ -53,7 +53,7 @@ The default Starter release ships inside the CLI package, so GitHub is only cont
53
53
 
54
54
  ## Choosing a framework
55
55
 
56
- The Starter ships the same application for two React frameworks. Next.js is the default; pick TanStack Start with `--framework`, or choose interactively:
56
+ For full-stack apps, the Starter ships the same application for Next.js and TanStack Start. Next.js is the default; pick TanStack Start with `--framework`, or choose interactively:
57
57
 
58
58
  ```sh
59
59
  pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
@@ -78,10 +78,15 @@ pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono
78
78
  | ------------------ | ----------------------------------------------------- | ----------- |
79
79
  | `--framework vite` | React + Vite single-page app (TanStack Router) | `apps/web` |
80
80
  | `--api hono` | Hono API on Node, reusing the Starter's packages | `apps/api` |
81
+ | `--api fastapi` | FastAPI API in Python, managed with [uv](https://docs.astral.sh/uv/) | `apps/api` |
82
+
83
+ A Python API needs uv; the CLI installs its dependencies with it after `pnpm install`, and its `package.json` scripts call uv, so `pnpm dev`, `pnpm lint`, and `pnpm test` run it like the other apps.
81
84
 
82
85
  They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend calls the API only through the client generated from it, and every API passes the same conformance suite. `pnpm dev` starts both, and the frontend forwards `/api` to the API. A full-stack framework (`next`, `tanstack-start`) has its own server, so it takes no `--api`.
83
86
 
84
- This is a first version: on these stacks the modules are `none` (cache: memory, deploy: Docker), and `deploy` doesn't set them up yet; SETUP.md in the project describes running both images. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks are described.
87
+ With `--auth supabase`, the Vite app owns the browser session and attaches its access token to contract requests. Hono or FastAPI verifies that token with Supabase Auth and returns the contract's shared user shape. Configure `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_URL`, and `SUPABASE_PUBLISHABLE_KEY` in the generated env examples; no Supabase service-role key goes to the browser.
88
+
89
+ The decoupled stacks support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache and Docker or Railway deployment. With Docker, `pnpm clubedge deploy` writes a `compose.yaml` that builds the frontend and API as separate services; copy `.env.example` to `.env.production`, fill in its values, and run `docker compose --env-file .env.production up --build -d`. With Railway, deploy provisions a public frontend and a private API, wires the frontend proxy to the API, and runs migrations before releasing the API. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks and deployment facts are described.
85
90
 
86
91
  ## Choosing modules
87
92
 
@@ -89,7 +94,7 @@ Authentication, file storage, caching, email, payments, background jobs, observa
89
94
 
90
95
  | Flag | Options | Default |
91
96
  | ----------- | ------------------------------------------ | ---------- |
92
- | `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
97
+ | `--auth` | `supabase`, `better-auth`, `firebase`, `appwrite`, `none` | `supabase` |
93
98
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
94
99
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
95
100
  | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
@@ -120,7 +125,7 @@ Usage:
120
125
 
121
126
  Options:
122
127
  --framework <id> App framework, such as next or tanstack-start (or vite, a frontend)
123
- --api <id> A separate API for a frontend, such as hono
128
+ --api <id> A separate API for a frontend, such as hono or fastapi
124
129
  --<module> <option> A module option, such as --auth none; the Starter lists its modules
125
130
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
126
131
  (upgrade: the Starter to upgrade to)
@@ -147,7 +152,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
147
152
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
148
153
 
149
154
  # Reproduce a specific Starter release or commit
150
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.22.0
155
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.0
151
156
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
152
157
 
153
158
  # Test local Starter changes before releasing them
@@ -166,7 +171,7 @@ pnpm clubedge add email resend payments stripe # several modules as
166
171
  pnpm clubedge remove storage payments
167
172
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
168
173
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
169
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.22.0 # or a specific tag, branch, or commit
174
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.24.0 # or a specific tag, branch, or commit
170
175
  ```
171
176
 
172
177
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -242,7 +247,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
242
247
  pnpm clubedge deploy --dry-run # show the plan, create nothing
243
248
  ```
244
249
 
245
- With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package, Postgres (and Redis with `cache redis`), the app's service, and a `*.up.railway.app` domain. It sets the variables it can: `DATABASE_URL` and `REDIS_URL` as references to those databases, `APP_URL` and `NEXT_PUBLIC_APP_URL`, `PORT`, and a generated `BETTER_AUTH_SECRET` that later runs keep. Values from the env file (`.env.production` when there is one, or `--env-file`) come on top. With a `DATABASE_URL` or `REDIS_URL` in it (Supabase, Neon, Upstash), `deploy` creates no Railway database for it and the app uses yours; URLs to `localhost` are ignored, and a later run without the file keeps the URL the service has. The variables `.env.example` lists that nothing sets are reported, for the modules that need them (storage, email, payments). The service's settings (the migrations before each release, the `/api/health` check) go in `.railway/railway.ts`, Railway's configuration as code, which replaces `railway.json` (Railway stops reading it on 2026-12-01): `deploy` writes it when the project has none, adds Railway's TypeScript SDK (`railway`) as a dev dependency, and applies it with `railway config apply`; commit them. Then `railway up` builds the Dockerfile, and `deploy` waits for Railway's final status before printing the URL. It needs Railway CLI 5.42.1 or newer: an installed `railway`, else one npx already downloaded, else it downloads one. Running `deploy` again reuses everything, so it also adds variables and ships a new release. Railway bills usage on your plan.
250
+ With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package and the databases selected by the modules. Full-stack projects get one public app service. A Vite project gets two services: the frontend has the public `*.up.railway.app` domain, and the API stays private; the frontend's Caddy proxy connects to it over Railway's private network. API variables, including `FRONTEND_URL`, go on the API service; `VITE_*` values go on the frontend service for its build. The API's migration command runs before the API release, and the frontend and API deployments are both awaited. Database variables are references to Railway's Postgres and Redis by default. Values from `.env.production` (or `--env-file`) take precedence; external database URLs stay external, and localhost URLs are ignored. `.railway/railway.ts` describes the selected service topology and health checks; `deploy` applies it and adds Railway's TypeScript SDK (`railway`) as a dev dependency. Commit the generated file and package changes. It needs Railway CLI 5.42.1 or newer. Running `deploy` again reuses the services, so it also adds variables and ships new releases. Railway bills usage on your plan.
246
251
 
247
252
  ### Deploy to Fly.io (experimental)
248
253
 
@@ -277,27 +282,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
277
282
  Each CLI release is tested against one specific Starter release:
278
283
 
279
284
  ```text
280
- create-clubedge-app v0.33.0
285
+ create-clubedge-app v0.35.0
281
286
  |
282
287
  v
283
- Clubedge Starter v0.22.0
284
- commit d0f3f9cdd4d568504cec4cf4681c7faf97842405
288
+ Clubedge Starter v0.24.0
289
+ commit 3aaca476bf5ff1db9b5fc4080f7a8a8006436998
285
290
  ```
286
291
 
287
292
  The current relationship is:
288
293
 
289
294
  | CLI release | Default Starter release | Meaning |
290
295
  | --- | --- | --- |
291
- | `create-clubedge-app@0.33.0` | `clubedge-starter@v0.22.0` | The CLI's required, tested default |
296
+ | `create-clubedge-app@0.35.0` | `clubedge-starter@v0.24.0` | The CLI's required, tested default |
292
297
 
293
298
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
294
299
 
295
- The `v0.22.0` tag is expected to resolve to commit
296
- `d0f3f9cdd4d568504cec4cf4681c7faf97842405`. The CLI verifies this before
300
+ The `v0.24.0` tag is expected to resolve to commit
301
+ `3aaca476bf5ff1db9b5fc4080f7a8a8006436998`. The CLI verifies this before
297
302
  scaffolding with the default reference and fails rather than silently using a
298
303
  moved tag.
299
304
 
300
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.22.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
305
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.24.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
301
306
 
302
307
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
303
308
 
@@ -352,7 +357,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
352
357
  1. Make and validate changes in `clubedge-starter`.
353
358
  2. Run lint, typecheck, unit tests, browser tests, and production build.
354
359
  3. Update the Starter package versions.
355
- 4. Commit `main`, create a release tag such as `v0.22.0`, and publish the Starter release.
360
+ 4. Commit `main`, create a release tag such as `v0.24.0`, and publish the Starter release.
356
361
 
357
362
  ### CLI maintainers
358
363
 
@@ -396,6 +401,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
396
401
  | 0.31.0 | v0.20.0 | Pluggable background jobs: `--jobs bullmq` and `--jobs inngest` next to pg-boss, and a conformance suite every provider passes |
397
402
  | 0.32.0 | v0.21.0 | Pluggable authentication: `--auth firebase` and `--auth appwrite` next to Supabase and Better Auth, and a conformance suite every provider passes |
398
403
  | 0.33.0 | v0.22.0 | Starter v0.22.0: stack packs (template schema 4), and a React + Vite frontend with a separate Hono API (`--framework vite --api hono`) |
404
+ | 0.34.0 | v0.23.0 | Starter v0.23.0: a FastAPI API in Python (`--framework vite --api fastapi`), generated with no CLI change |
405
+ | 0.35.0 | v0.24.0 | Deploy frontend and API stack packs; Default to Starter v0.24.0 |
399
406
  | next | | Show the Starter's version of a conflicted file next to yours |
400
407
 
401
408
  ## Community
@@ -37,6 +37,7 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
37
37
  ...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
38
38
  ...recordStacks(provenance.stacks ?? {}),
39
39
  ...(provenance.modules ? { modules: provenance.modules } : {}),
40
+ ...(provenance.services && Object.keys(provenance.services).length ? { services: provenance.services } : {}),
40
41
  };
41
42
  for (const script of ["docker:build", "docker:start"]) {
42
43
  if (typeof manifest.scripts?.[script] === "string") {
@@ -10,6 +10,24 @@ export function serviceName(packageName) {
10
10
  const name = packageName.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
11
11
  return name || "web";
12
12
  }
13
+ /** A Docker Compose production topology rendered from the selected stack service descriptors. */
14
+ export function dockerComposeSource(services) {
15
+ const api = Object.values(services).find((service) => service.role === "api");
16
+ const rows = Object.entries(services).map(([slot, service]) => {
17
+ const name = service.id;
18
+ const frontend = service.role === "frontend";
19
+ const buildArgs = frontend
20
+ ? `\n args:\n VITE_SUPABASE_URL: \${VITE_SUPABASE_URL:-}\n VITE_SUPABASE_PUBLISHABLE_KEY: \${VITE_SUPABASE_PUBLISHABLE_KEY:-}`
21
+ : "";
22
+ const env = frontend && api
23
+ ? `\n environment:\n API_URL: http://${api.id}:${api.port}`
24
+ : `\n env_file:\n - path: .env.production\n required: false`;
25
+ const ports = frontend ? `\n ports:\n - "${service.port}:${service.port}"` : `\n expose:\n - "${service.port}"`;
26
+ const depends = frontend && api ? `\n depends_on:\n - ${api.id}` : "";
27
+ return ` ${name}:\n build:\n context: .\n dockerfile: ${service.dockerfile}${buildArgs}${env}${ports}${depends}`;
28
+ });
29
+ return `# Generated from Clubedge stack descriptors. Build and start every app service together.\nservices:\n${rows.join("\n")}\n`;
30
+ }
13
31
  /** The variable each database's URL goes in. */
14
32
  export const databaseVariables = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
15
33
  /** A URL to this machine: from a development env file, unreachable from the deployed app. */
@@ -75,11 +93,11 @@ export function parseEnvFile(text) {
75
93
  * Everything else the example lists is reported as unset; values the service already has count as set.
76
94
  */
77
95
  export function planVariables(input) {
78
- const { modules, keys, url, envFile, secret, attached = [] } = input;
96
+ const { modules, keys, url, envFile, secret, attached = [], port = appPort } = input;
79
97
  const existing = { ...input.existing, ...Object.fromEntries(attached.map((key) => [key, "(attached)"])) };
80
98
  const external = externalDatabaseUrls(modules, envFile);
81
99
  const databases = input.databases ?? railwayReferences(databasesFor(modules, Object.keys(external)));
82
- const provided = { NODE_ENV: "production", PORT: String(appPort), ...databases };
100
+ const provided = { NODE_ENV: "production", PORT: String(port), ...databases };
83
101
  // The public URL under both names: TanStack Start reads APP_URL, Next.js NEXT_PUBLIC_APP_URL.
84
102
  const set = { ...provided, APP_URL: url, NEXT_PUBLIC_APP_URL: url };
85
103
  if (modules.auth === "better-auth" && !existing.BETTER_AUTH_SECRET)
@@ -223,7 +241,44 @@ export const railwayIacPath = ".railway/railway.ts";
223
241
  * Railway stops reading railway.json on 2026-12-01. Railway builds the root Dockerfile by itself,
224
242
  * the variables stay managed by `deploy` and the dashboard, and the region is Railway's default.
225
243
  */
226
- export function railwayIacSource(service) {
244
+ export function railwayIacSource(service, services) {
245
+ if (services && Object.keys(services).length > 1) {
246
+ const identifier = (slot) => slot.replace(/[^a-zA-Z0-9_$]/g, "_");
247
+ const apiEntry = Object.entries(services).find(([, item]) => item.role === "api");
248
+ const api = apiEntry?.[1];
249
+ const apiIdentifier = apiEntry ? identifier(apiEntry[0]) : null;
250
+ const ordered = Object.entries(services).sort(([, a], [, b]) => Number(a.role === "frontend") - Number(b.role === "frontend"));
251
+ const resources = ordered.map(([slot, item]) => {
252
+ const name = serviceName(`${service}-${item.id}`);
253
+ const dockerfilePath = item.dockerfile;
254
+ const healthcheck = item.healthPath;
255
+ const migration = item.role === "api" ? 'preDeploy: "node /app/migrate.mjs",' : "";
256
+ const env = item.role === "frontend" && api && apiIdentifier
257
+ ? `env: { API_URL: \`http://\${${apiIdentifier}.env.RAILWAY_PRIVATE_DOMAIN}:${api.port}\` },`
258
+ : "";
259
+ return ` const ${identifier(slot)} = service(${JSON.stringify(name)}, {
260
+ build: { builder: "DOCKERFILE", dockerfilePath: ${JSON.stringify(dockerfilePath)} },
261
+ ${migration}
262
+ ${env}
263
+ healthcheck: ${JSON.stringify(healthcheck)},
264
+ healthcheckTimeout: 60,
265
+ });`;
266
+ });
267
+ const resourceNames = Object.keys(services).map(identifier).join(", ");
268
+ return `// Generated from the selected stack packs; each app runs as its own Railway service.
269
+ import { defineRailway, project, service } from "railway/iac";
270
+
271
+ export const partial = ${JSON.stringify(service)};
272
+
273
+ export default defineRailway(() => {
274
+ ${resources.join("\n\n")}
275
+ return project(${JSON.stringify(service)}, {
276
+ variables: { managed: false },
277
+ resources: [${resourceNames}],
278
+ });
279
+ });
280
+ `;
281
+ }
227
282
  const name = JSON.stringify(service);
228
283
  return `// Railway's configuration for this project, applied with \`railway config apply\` (pnpm clubedge
229
284
  // deploy does it before each release). It replaces railway.json, which Railway stops reading on
@@ -155,6 +155,7 @@ function parseStack(directory, descriptor, slots, modules) {
155
155
  implemented[moduleId] = Object.keys(module.options).filter((option) => options.has(option));
156
156
  }
157
157
  const toolchain = data.toolchain === undefined ? undefined : parseToolchain(file, data.toolchain);
158
+ const deployment = data.deployment === undefined ? undefined : parseDeployment(file, data.deployment);
158
159
  return {
159
160
  id,
160
161
  stack: {
@@ -166,10 +167,30 @@ function parseStack(directory, descriptor, slots, modules) {
166
167
  dockerfile: resolve(read.requireString(data, "dockerfile"), "dockerfile"),
167
168
  modules: implemented,
168
169
  ...(toolchain ? { toolchain } : {}),
170
+ ...(deployment ? { deployment } : {}),
169
171
  },
170
172
  files,
171
173
  };
172
174
  }
175
+ function parseDeployment(file, value) {
176
+ const read = fields(file, "deployment.");
177
+ if (!isRecord(value))
178
+ read.fail("deployment must be an object.");
179
+ const deployment = value;
180
+ const port = deployment.port;
181
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
182
+ read.fail("deployment.port must be an integer from 1 to 65535.");
183
+ }
184
+ const platforms = read.optionalStringArray(deployment, "platforms");
185
+ if (!platforms.length)
186
+ read.fail("deployment.platforms must list at least one platform.");
187
+ return {
188
+ id: read.requireId(read.requireString(deployment, "id"), "deployment service"),
189
+ port: port,
190
+ healthPath: read.requireString(deployment, "healthPath"),
191
+ platforms,
192
+ };
193
+ }
173
194
  function parseToolchain(file, value) {
174
195
  const read = fields(file, "toolchain.");
175
196
  if (!isRecord(value))
package/dist/core/plan.js CHANGED
@@ -262,6 +262,14 @@ export function createPlan(input) {
262
262
  option,
263
263
  optionName: manifest.modules[id].options[option].name,
264
264
  }));
265
+ const services = Object.fromEntries(selected.flatMap(({ slotId, slot, stack }) => stack.deployment
266
+ ? [[slotId, {
267
+ ...stack.deployment,
268
+ role: stack.role,
269
+ app: slot.path,
270
+ dockerfile: slot.dockerfile,
271
+ }]]
272
+ : []));
265
273
  const provenance = {
266
274
  cliVersion: input.cliVersion,
267
275
  starterRepository: source.repository,
@@ -279,6 +287,7 @@ export function createPlan(input) {
279
287
  moduleSummary: modules.map(({ name, optionName }) => `${name}: ${optionName}`),
280
288
  }
281
289
  : {}),
290
+ ...(Object.keys(services).length ? { services } : {}),
282
291
  };
283
292
  const place = placement(selected, others);
284
293
  const envTargets = Object.values(manifest.slots).map((slot) => slot.env.target);
@@ -31,6 +31,7 @@ export async function readProject(directory) {
31
31
  starterCommit: origin.starterCommit,
32
32
  stacks: recordedStacks(origin),
33
33
  modules: origin.modules,
34
+ services: origin.services && typeof origin.services === "object" ? origin.services : {},
34
35
  };
35
36
  }
36
37
  }
package/dist/deploy.js CHANGED
@@ -9,10 +9,10 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import process from "node:process";
12
- import { FRAMEWORK_SLOT } from "./core/manifest.js";
13
12
  import { UsageError } from "./cli/args.js";
14
13
  import { bold, dim } from "./cli/ui.js";
15
- import { appPort, databaseVariables, databasesFor, deploymentStatus, exampleKeys, externalDatabaseUrls, finalStatuses, isLocalUrl, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planVariables, railwayCliVersion, railwayDatabases, railwayIacPath, railwayIacSource, railwayReferences, serviceName, serviceNames, } from "./core/deploy.js";
14
+ import { FRAMEWORK_SLOT } from "./core/manifest.js";
15
+ import { appPort, databaseVariables, dockerComposeSource, databasesFor, deploymentStatus, exampleKeys, externalDatabaseUrls, finalStatuses, isLocalUrl, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planVariables, railwayCliVersion, railwayDatabases, railwayIacPath, railwayIacSource, railwayReferences, serviceName, serviceNames, } from "./core/deploy.js";
16
16
  import { neonDatabaseState } from "./core/neon.js";
17
17
  import { readProject } from "./core/project.js";
18
18
  import { savedDatabaseForUrl } from "./neon.js";
@@ -173,17 +173,207 @@ async function agree(args, service, databases, external) {
173
173
  const answer = await confirm({ message: "Deploy?", initialValue: true });
174
174
  return !isCancel(answer) && answer;
175
175
  }
176
+ async function deployRailwayServices(args, project, cli, envFile) {
177
+ const selected = Object.entries(project.services);
178
+ const frontend = selected.find(([, item]) => item.role === "frontend");
179
+ const api = selected.find(([, item]) => item.role === "api");
180
+ if (!frontend || !api || selected.length !== 2) {
181
+ throw new UsageError("Railway deployment currently supports one frontend and one API service in a decoupled project.");
182
+ }
183
+ const unsupported = selected.filter(([, service]) => !service.platforms.includes("railway"));
184
+ if (unsupported.length) {
185
+ throw new UsageError(`The selected stacks do not declare Railway deployment support for ${unsupported.map(([slot]) => slot).join(", ")}.`);
186
+ }
187
+ const [frontendSlot, frontendStack] = frontend;
188
+ const [apiSlot, apiStack] = api;
189
+ const baseName = serviceName(project.identity.packageName);
190
+ const names = {
191
+ frontend: serviceName(`${baseName}-${frontendStack.id}`),
192
+ api: serviceName(`${baseName}-${apiStack.id}`),
193
+ };
194
+ const keys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
195
+ const external = Object.keys(externalDatabaseUrls(project.modules, envFile));
196
+ const requestedDatabases = databasesFor(project.modules, external);
197
+ const serviceSummary = `${frontendSlot} (${frontendStack.dockerfile}) and ${apiSlot} (${apiStack.dockerfile})`;
198
+ if (args.dryRun) {
199
+ log.message([
200
+ `Railway will create two services: ${names.frontend} (public, port ${frontendStack.port}) and ${names.api} (private, port ${apiStack.port}).`,
201
+ `The frontend proxies /api to ${names.api} over Railway's private network.`,
202
+ ...(requestedDatabases.length ? [`Databases: ${requestedDatabases.map((db) => railwayDatabases[db]).join(", ")}.`] : []),
203
+ `Dockerfiles: ${frontendStack.dockerfile}, ${apiStack.dockerfile}. API migrations run before its release.`,
204
+ `Variables from .env.production are applied to their corresponding services; ${railwayIacPath} records both service configurations.`,
205
+ ].join("\n"));
206
+ outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
207
+ return;
208
+ }
209
+ note(`This creates or updates Railway services ${names.frontend} and ${names.api}; publishes only the frontend, connects the services over Railway's private network, and runs migrations before the API release. Railway bills usage on your plan.`, "Deploy frontend and API to Railway");
210
+ if (!args.yes) {
211
+ if (!process.stdin.isTTY)
212
+ throw new UsageError("deploy creates Railway resources. Run it in a terminal to confirm, or pass --yes.");
213
+ const answer = await confirm({ message: "Deploy?", initialValue: true });
214
+ if (isCancel(answer) || !answer)
215
+ return cancel("Nothing was deployed.");
216
+ }
217
+ const progress = spinner();
218
+ progress.start("Preparing the Railway CLI");
219
+ const rw = await railwayCli();
220
+ process.chdir(project.root);
221
+ if (!(await attempt(() => railway(rw, ["whoami"])))) {
222
+ progress.stop("Signing in to Railway");
223
+ if (!process.stdin.isTTY)
224
+ throw new UsageError("Sign in to Railway first with `railway login`, or set RAILWAY_API_TOKEN.");
225
+ await railway(rw, ["login"], { inherit: true });
226
+ progress.start("Signed in");
227
+ }
228
+ progress.message("Finding or creating the Railway project");
229
+ let status = await attempt(() => railway(rw, ["status", "--json"]));
230
+ if (!status) {
231
+ await railway(rw, ["init", "--name", baseName, "--json"]);
232
+ status = await attempt(() => railway(rw, ["status", "--json"]));
233
+ }
234
+ let existing = [];
235
+ try {
236
+ existing = serviceNames(JSON.parse(status ?? "{}"));
237
+ }
238
+ catch { /* unknown status: add missing services */ }
239
+ const listedApiVars = await attempt(() => railway(rw, ["variable", "list", "--service", names.api, "--json"]));
240
+ if (listedApiVars === null && existing.includes(names.api)) {
241
+ throw new Error(`Railway didn't list the variables of the API service "${names.api}", so deploy changed nothing. Run deploy again; if it keeps failing, check \`railway variable list --service ${names.api}\`.`);
242
+ }
243
+ const apiVars = parseVariables(listedApiVars ?? "");
244
+ const kept = databasesFor(project.modules, external)
245
+ .filter((database) => apiVars[databaseVariables[database]] && !existing.includes(railwayDatabases[database]))
246
+ .map((database) => databaseVariables[database]);
247
+ const databases = databasesFor(project.modules, [...external, ...kept]);
248
+ for (const database of databases) {
249
+ const name = railwayDatabases[database];
250
+ if (!existing.includes(name))
251
+ await railway(rw, ["add", "--database", database]);
252
+ }
253
+ for (const name of Object.values(names)) {
254
+ if (!existing.includes(name))
255
+ await railway(rw, ["add", "--service", name]);
256
+ }
257
+ const listed = await attempt(() => railway(rw, ["domain", "list", "--service", names.frontend]));
258
+ const domain = parseRailwayDomain(listed ?? "") ?? parseRailwayDomain(await railway(rw, ["domain", "--service", names.frontend, "--port", String(frontendStack.port), "--json"]));
259
+ if (!domain)
260
+ throw new Error("Railway created no frontend domain. Create one in its settings, then run deploy again.");
261
+ const url = `https://${domain}`;
262
+ const apiEnv = Object.fromEntries(Object.entries(envFile).filter(([key]) => !key.startsWith("VITE_")));
263
+ const apiPlan = planVariables({
264
+ modules: project.modules,
265
+ keys: keys.filter((key) => !key.startsWith("VITE_")),
266
+ url,
267
+ existing: apiVars,
268
+ envFile: apiEnv,
269
+ secret: () => randomBytes(32).toString("hex"),
270
+ databases: railwayReferences(databases),
271
+ port: apiStack.port,
272
+ });
273
+ apiPlan.set.FRONTEND_URL = url;
274
+ if (apiPlan.unset.length) {
275
+ log.warn(`Not set on the API service: ${apiPlan.unset.join(", ")}. Pass --env-file to supply values before deployment.`);
276
+ if (process.stdin.isTTY && !args.yes) {
277
+ const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
278
+ if (isCancel(proceed) || !proceed)
279
+ return cancel("Nothing was uploaded. Set the variables, then run deploy again.");
280
+ }
281
+ }
282
+ for (const [key, value] of Object.entries(apiPlan.set)) {
283
+ if (apiVars[key] !== value)
284
+ await railway(rw, ["variable", "set", key, "--stdin", "--service", names.api, "--skip-deploys"], { input: value });
285
+ }
286
+ const webVars = parseVariables(await attempt(() => railway(rw, ["variable", "list", "--service", names.frontend, "--json"])) ?? "");
287
+ const missingWebBuildVars = keys.filter((key) => key.startsWith("VITE_") && !envFile[key] && !webVars[key]);
288
+ if (missingWebBuildVars.length) {
289
+ log.warn(`Not set on the frontend service: ${missingWebBuildVars.join(", ")}. Pass --env-file to provide them.`);
290
+ if (process.stdin.isTTY && !args.yes) {
291
+ const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
292
+ if (isCancel(proceed) || !proceed)
293
+ return cancel("Nothing was uploaded. Set the variables, then run deploy again.");
294
+ }
295
+ }
296
+ const webSet = { PORT: String(frontendStack.port), ...Object.fromEntries(Object.entries(envFile).filter(([key, value]) => key.startsWith("VITE_") && value)) };
297
+ for (const [key, value] of Object.entries(webSet)) {
298
+ if (webVars[key] !== value)
299
+ await railway(rw, ["variable", "set", key, "--stdin", "--service", names.frontend, "--skip-deploys"], { input: value });
300
+ }
301
+ await mkdir(join(project.root, ".railway"), { recursive: true });
302
+ const iacFile = join(project.root, railwayIacPath);
303
+ const existingIac = await readFile(iacFile, "utf8").catch(() => "");
304
+ if (existingIac && !existingIac.includes("The Railway service is named after the package") && !existingIac.includes("Generated from the selected stack packs")) {
305
+ throw new UsageError(`${railwayIacPath} contains custom configuration. Review it and merge the generated two-service configuration before deploying.`);
306
+ }
307
+ await writeFile(iacFile, railwayIacSource(baseName, project.services));
308
+ const packageJson = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
309
+ if (!packageJson.devDependencies?.railway && !packageJson.dependencies?.railway) {
310
+ const pnpm = await resolvePnpm();
311
+ if (!pnpm)
312
+ throw new UsageError("pnpm was not found. Enable it with `corepack enable`, then run deploy again.");
313
+ const [command, ...prefix] = pnpm;
314
+ await run(command, [...prefix, "add", "--save-dev", "--workspace-root", "railway"], project.root);
315
+ }
316
+ progress.message("Applying the two-service Railway configuration");
317
+ await railway(rw, ["config", "apply", "--yes"]);
318
+ const deployments = [];
319
+ for (const name of [names.api, names.frontend]) {
320
+ progress.message(`Uploading ${name}`);
321
+ const started = new Date();
322
+ deployments.push({ name, id: parseDeploymentId(await railway(rw, ["up", "--service", name, "--detach"])), started });
323
+ }
324
+ for (const item of deployments) {
325
+ let state = "QUEUED";
326
+ const deadline = Date.now() + 45 * 60_000;
327
+ while (!finalStatuses.includes(state) && Date.now() < deadline) {
328
+ progress.message(`Railway ${item.name}: ${state.toLowerCase()}`);
329
+ await new Promise((wait) => setTimeout(wait, 10_000));
330
+ const list = await attempt(() => railway(rw, ["deployment", "list", "--service", item.name, "--json"]));
331
+ state = deploymentStatus(list ?? "", item.id, item.started)?.status ?? state;
332
+ }
333
+ if (state !== "SUCCESS")
334
+ throw new Error(`Railway's ${item.name} deployment ended as ${state}. Read its build and runtime logs with railway logs --service ${item.name}.`);
335
+ }
336
+ progress.stop("Both Railway services are healthy");
337
+ note(`${bold("Frontend")} ${url}\n${bold("Services")} ${names.frontend} and ${names.api}\n${bold("API")} private to Railway`, "Deployed to Railway");
338
+ outro(`${dim("Frontend logs")} railway logs --service ${names.frontend}\n${dim("API logs")} railway logs --service ${names.api}\n${dim("Again")} ${cli} deploy`);
339
+ log.info(`Configured: ${serviceSummary}.`);
340
+ }
176
341
  export async function deployProject(args) {
177
342
  const project = await readProject(process.cwd());
178
343
  const cli = await cliCommand(project);
179
344
  const platform = project.modules.deploy ?? "docker";
180
- // A frontend and its API are two services; deploy sets up one app so far.
181
- const separateApps = Object.keys(project.stacks).filter((slot) => slot !== FRAMEWORK_SLOT);
182
- if (separateApps.length) {
183
- throw new UsageError(`deploy sets up one app, and this project has a frontend and ${separateApps.join(", ")}. Build each app's Dockerfile (Dockerfile and apps/${separateApps[0]}/Dockerfile) and run them on any container host; SETUP.md describes it.`);
345
+ const separateApps = Object.entries(project.services).filter(([, service]) => service.role !== "fullstack");
346
+ const decoupledSlots = Object.keys(project.stacks).filter((slot) => slot !== FRAMEWORK_SLOT);
347
+ if (decoupledSlots.length && separateApps.length < 2) {
348
+ throw new UsageError(`This project has a frontend and ${decoupledSlots.join(", ")}, but does not record deployment services for them. Upgrade it to a Starter release whose stacks declare deployment, or build each app's Dockerfile and run them on any container host.`);
184
349
  }
185
350
  if (args.preview)
186
351
  return await deployPreview(args, project, cli, await readDeployEnv(args, project));
352
+ if (separateApps.length > 1 && platform === "railway") {
353
+ return await deployRailwayServices(args, project, cli, await readDeployEnv(args, project));
354
+ }
355
+ if (separateApps.length > 1 && platform === "docker") {
356
+ const unsupported = separateApps.filter(([, service]) => !service.platforms.includes("docker"));
357
+ if (unsupported.length) {
358
+ throw new UsageError(`The selected stacks do not declare Docker deployment support for ${unsupported.map(([slot]) => slot).join(", ")}.`);
359
+ }
360
+ const compose = dockerComposeSource(Object.fromEntries(separateApps));
361
+ if (args.dryRun) {
362
+ log.message(`Docker Compose will start ${separateApps.map(([slot, service]) => `${slot} (${service.dockerfile}, port ${service.port})`).join(" and ")}; the frontend proxies /api to the API over the Compose network.`);
363
+ outro("Dry run: nothing was written. Run deploy to generate compose.yaml.");
364
+ return;
365
+ }
366
+ const path = join(project.root, "compose.yaml");
367
+ if (await pathExists(path))
368
+ throw new UsageError("compose.yaml already exists. Review it and remove it before generating the stack configuration again.");
369
+ await writeFile(path, compose);
370
+ note(`Generated compose.yaml for ${separateApps.map(([slot]) => slot).join(" and ")}.`, "Docker Compose deployment");
371
+ outro(`Copy .env.example to .env.production and set its values, then run from the project root:\n docker compose --env-file .env.production up --build -d\n\nThe frontend is published on its declared port; the API stays on the private Compose network.`);
372
+ return;
373
+ }
374
+ if (separateApps.length > 1) {
375
+ throw new UsageError(`The ${platform} deploy adapter does not yet support the selected ${separateApps.map(([slot]) => slot).join(" + ")} services. Select Docker Compose with \`${cli} add deploy docker\`, or deploy each Dockerfile separately.`);
376
+ }
187
377
  if (platform === "fly")
188
378
  return await deployToFly(args, project, cli, await readDeployEnv(args, project));
189
379
  if (platform === "vercel")
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.33.0",
4
- "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
3
+ "version": "0.35.0",
4
+ "description": "Create a React app with Next.js, TanStack Start, or Vite and a separate API from the Clubedge Starter.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
7
7
  "bin": {
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.22.0",
26
- "starterCommit": "d0f3f9cdd4d568504cec4cf4681c7faf97842405"
25
+ "starterRef": "v0.24.0",
26
+ "starterCommit": "3aaca476bf5ff1db9b5fc4080f7a8a8006436998"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -21,6 +21,7 @@ jobs:
21
21
  # clubedge:end
22
22
  env:
23
23
  DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres
24
+ UV_LOCKED: "1"
24
25
  REDIS_URL: redis://127.0.0.1:6379 # clubedge:only jobs=bullmq
25
26
  FIREBASE_AUTH_EMULATOR_HOST: 127.0.0.1:9099 # clubedge:only auth=firebase
26
27
  FIREBASE_PROJECT_ID: demo-clubedge # clubedge:only auth=firebase
@@ -31,6 +32,9 @@ jobs:
31
32
  with:
32
33
  node-version: 22
33
34
  cache: pnpm
35
+ # Apps in other languages run their lint, typecheck, and tests with their own tools.
36
+ - uses: astral-sh/setup-uv@v10.3.0
37
+ if: ${{ hashFiles('apps/*/pyproject.toml') != '' }}
34
38
  - run: pnpm install --frozen-lockfile
35
39
  - run: pnpm format:check
36
40
  - run: pnpm lint