@clubedge/create-clubedge-app 0.34.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 (107) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +18 -15
  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 +1 -0
  11. package/template/.github/workflows/preview.yml +35 -2
  12. package/template/SETUP.md +3 -1
  13. package/template/apps/api/.env.example +27 -0
  14. package/template/apps/api/clubedge.stack.json +32 -2
  15. package/template/apps/api/package.json +4 -1
  16. package/template/apps/api/src/app.ts +11 -0
  17. package/template/apps/api/src/server/auth.ts +6 -1
  18. package/template/apps/api/src/server/index.ts +7 -1
  19. package/template/apps/api/src/server/variants/auth.appwrite.ts +38 -0
  20. package/template/apps/api/src/server/variants/auth.better-auth.ts +31 -0
  21. package/template/apps/api/src/server/variants/auth.firebase.ts +34 -0
  22. package/template/apps/api/src/server/variants/auth.supabase.ts +12 -0
  23. package/template/apps/api-fastapi/.env.example +25 -0
  24. package/template/apps/api-fastapi/Dockerfile +17 -3
  25. package/template/apps/api-fastapi/clubedge.stack.json +33 -3
  26. package/template/apps/api-fastapi/package.json +1 -1
  27. package/template/apps/api-fastapi/pyproject.toml +2 -2
  28. package/template/apps/api-fastapi/src/clubedge_api/app.py +9 -0
  29. package/template/apps/api-fastapi/src/clubedge_api/auth.py +6 -6
  30. package/template/apps/api-fastapi/src/clubedge_api/server.py +12 -2
  31. package/template/apps/api-fastapi/src/clubedge_api/settings.py +6 -0
  32. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_appwrite.py +43 -0
  33. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_better_auth.py +44 -0
  34. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_firebase.py +55 -0
  35. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_supabase.py +40 -0
  36. package/template/apps/api-fastapi/uv.lock +733 -0
  37. package/template/apps/start/package.json +1 -1
  38. package/template/apps/vite/.env.example +35 -1
  39. package/template/apps/vite/Dockerfile +20 -6
  40. package/template/apps/vite/auth.server.mjs +251 -0
  41. package/template/apps/vite/build-auth-server.mjs +15 -0
  42. package/template/apps/vite/clubedge.stack.json +91 -2
  43. package/template/apps/vite/dev.mjs +39 -0
  44. package/template/apps/vite/package.json +9 -3
  45. package/template/apps/vite/server.mjs +205 -0
  46. package/template/apps/vite/src/components/dashboard/variants/account-button.supabase.tsx +51 -0
  47. package/template/apps/vite/src/lib/api.ts +3 -0
  48. package/template/apps/vite/src/lib/auth-client.ts +15 -0
  49. package/template/apps/vite/src/lib/auth-enabled.ts +1 -0
  50. package/template/apps/vite/src/lib/auth.ts +2 -0
  51. package/template/apps/vite/src/routes/dashboard.tsx +7 -4
  52. package/template/apps/vite/src/routes/forgot-password.tsx +65 -0
  53. package/template/apps/vite/src/routes/index.tsx +8 -0
  54. package/template/apps/vite/src/routes/login.tsx +181 -0
  55. package/template/apps/vite/src/routes/reset-password.tsx +61 -0
  56. package/template/apps/vite/src/variants/api.better-auth.ts +5 -0
  57. package/template/apps/vite/src/variants/api.supabase.ts +7 -0
  58. package/template/apps/vite/src/variants/auth-enabled.supabase.ts +3 -0
  59. package/template/apps/vite/src/variants/auth.appwrite.ts +91 -0
  60. package/template/apps/vite/src/variants/auth.better-auth.ts +44 -0
  61. package/template/apps/vite/src/variants/auth.firebase.ts +95 -0
  62. package/template/apps/vite/src/variants/auth.supabase.ts +48 -0
  63. package/template/apps/vite/src/variants/router.supabase.tsx +63 -0
  64. package/template/apps/vite/vite.config.ts +2 -1
  65. package/template/apps/web/package.json +1 -1
  66. package/template/clubedge/modules/auth.json +1 -0
  67. package/template/clubedge.template.json +8 -0
  68. package/template/e2e/auth.spec.ts +8 -3
  69. package/template/e2e/firebase-auth-flow.spec.ts +39 -0
  70. package/template/e2e/home.spec.ts +8 -1
  71. package/template/package.json +2 -1
  72. package/template/packages/auth/package.json +1 -1
  73. package/template/packages/auth-appwrite/package.json +1 -1
  74. package/template/packages/auth-better-auth/package.json +1 -1
  75. package/template/packages/auth-better-auth/src/index.ts +3 -2
  76. package/template/packages/auth-firebase/package.json +1 -1
  77. package/template/packages/auth-supabase/package.json +3 -2
  78. package/template/packages/auth-supabase/src/browser.ts +8 -0
  79. package/template/packages/auth-supabase/src/index.ts +20 -0
  80. package/template/packages/cache/package.json +1 -1
  81. package/template/packages/cache-redis/package.json +1 -1
  82. package/template/packages/contract/openapi.yaml +8 -0
  83. package/template/packages/contract/package.json +1 -1
  84. package/template/packages/contract/src/index.ts +19 -2
  85. package/template/packages/core/package.json +1 -1
  86. package/template/packages/db/package.json +1 -1
  87. package/template/packages/email/package.json +1 -1
  88. package/template/packages/email-resend/package.json +1 -1
  89. package/template/packages/email-smtp/package.json +1 -1
  90. package/template/packages/jobs/package.json +1 -1
  91. package/template/packages/jobs-bullmq/package.json +1 -1
  92. package/template/packages/jobs-inngest/package.json +1 -1
  93. package/template/packages/jobs-pg-boss/package.json +1 -1
  94. package/template/packages/local-db/package.json +1 -1
  95. package/template/packages/observability/package.json +1 -1
  96. package/template/packages/observability-otel/package.json +1 -1
  97. package/template/packages/observability-sentry/package.json +1 -1
  98. package/template/packages/payments/package.json +1 -1
  99. package/template/packages/payments-stripe/package.json +1 -1
  100. package/template/packages/storage/package.json +1 -1
  101. package/template/packages/storage-s3/package.json +1 -1
  102. package/template/packages/storage-supabase/package.json +1 -1
  103. package/template/packages/ui/package.json +1 -1
  104. package/template/playwright.config.ts +15 -0
  105. package/template/pnpm-lock.yaml +812 -9
  106. package/template/scripts/smoke-generated-app.mjs +106 -0
  107. package/template.lock.json +34 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
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
+
5
16
  ## 0.34.0
6
17
 
7
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/):
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
@@ -84,7 +84,9 @@ A Python API needs uv; the CLI installs its dependencies with it after `pnpm ins
84
84
 
85
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`.
86
86
 
87
- 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.
88
90
 
89
91
  ## Choosing modules
90
92
 
@@ -92,7 +94,7 @@ Authentication, file storage, caching, email, payments, background jobs, observa
92
94
 
93
95
  | Flag | Options | Default |
94
96
  | ----------- | ------------------------------------------ | ---------- |
95
- | `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
97
+ | `--auth` | `supabase`, `better-auth`, `firebase`, `appwrite`, `none` | `supabase` |
96
98
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
97
99
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
98
100
  | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
@@ -150,7 +152,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
150
152
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
151
153
 
152
154
  # Reproduce a specific Starter release or commit
153
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.23.0
155
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.0
154
156
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
155
157
 
156
158
  # Test local Starter changes before releasing them
@@ -169,7 +171,7 @@ pnpm clubedge add email resend payments stripe # several modules as
169
171
  pnpm clubedge remove storage payments
170
172
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
171
173
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
172
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.23.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
173
175
  ```
174
176
 
175
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.
@@ -245,7 +247,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
245
247
  pnpm clubedge deploy --dry-run # show the plan, create nothing
246
248
  ```
247
249
 
248
- 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.
249
251
 
250
252
  ### Deploy to Fly.io (experimental)
251
253
 
@@ -280,27 +282,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
280
282
  Each CLI release is tested against one specific Starter release:
281
283
 
282
284
  ```text
283
- create-clubedge-app v0.34.0
285
+ create-clubedge-app v0.35.0
284
286
  |
285
287
  v
286
- Clubedge Starter v0.23.0
287
- commit 0183d091b7bffac92cd0073f12b5329e96fc183f
288
+ Clubedge Starter v0.24.0
289
+ commit 3aaca476bf5ff1db9b5fc4080f7a8a8006436998
288
290
  ```
289
291
 
290
292
  The current relationship is:
291
293
 
292
294
  | CLI release | Default Starter release | Meaning |
293
295
  | --- | --- | --- |
294
- | `create-clubedge-app@0.34.0` | `clubedge-starter@v0.23.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 |
295
297
 
296
298
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
297
299
 
298
- The `v0.23.0` tag is expected to resolve to commit
299
- `0183d091b7bffac92cd0073f12b5329e96fc183f`. The CLI verifies this before
300
+ The `v0.24.0` tag is expected to resolve to commit
301
+ `3aaca476bf5ff1db9b5fc4080f7a8a8006436998`. The CLI verifies this before
300
302
  scaffolding with the default reference and fails rather than silently using a
301
303
  moved tag.
302
304
 
303
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.23.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.
304
306
 
305
307
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
306
308
 
@@ -355,7 +357,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
355
357
  1. Make and validate changes in `clubedge-starter`.
356
358
  2. Run lint, typecheck, unit tests, browser tests, and production build.
357
359
  3. Update the Starter package versions.
358
- 4. Commit `main`, create a release tag such as `v0.23.0`, and publish the Starter release.
360
+ 4. Commit `main`, create a release tag such as `v0.24.0`, and publish the Starter release.
359
361
 
360
362
  ### CLI maintainers
361
363
 
@@ -400,6 +402,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
400
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 |
401
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`) |
402
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 |
403
406
  | next | | Show the Starter's version of a conflicted file next to yours |
404
407
 
405
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.34.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.23.0",
26
- "starterCommit": "0183d091b7bffac92cd0073f12b5329e96fc183f"
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
@@ -57,6 +57,37 @@ jobs:
57
57
  project_id: ${{ vars.NEON_PROJECT_ID }}
58
58
  compare_branch: ${{ env.NEON_BRANCH }}
59
59
  api_key: ${{ secrets.NEON_API_KEY }}
60
+ - name: Identify the app stack for the preview comment
61
+ id: stack
62
+ shell: bash
63
+ run: |
64
+ set -euo pipefail
65
+ if [ -f apps/web/package.json ]; then
66
+ framework=$(node -e 'const p=require("./apps/web/package.json"); const d={...p.dependencies,...p.devDependencies}; console.log(d.vite ? "React + Vite" : d["@tanstack/react-start"] ? "TanStack Start" : "Next.js")')
67
+ else
68
+ framework="Next.js"
69
+ fi
70
+ api=""
71
+ if [ "$framework" = "React + Vite" ]; then
72
+ if [ -f apps/api/pyproject.toml ] || { [ -f apps/api-fastapi/pyproject.toml ] && [ ! -f apps/api/package.json ]; }; then
73
+ api=" + FastAPI (Python)"
74
+ elif [ -f apps/api/package.json ]; then
75
+ api=" + Hono (TypeScript)"
76
+ fi
77
+ fi
78
+ auth=""
79
+ if [ -f clubedge/modules/auth.json ]; then
80
+ auth=$(node -e 'console.log(JSON.parse(require("fs").readFileSync("clubedge/modules/auth.json", "utf8")).default)')
81
+ case "$auth" in
82
+ supabase) auth=" + Supabase Auth" ;;
83
+ better-auth) auth=" + Better Auth" ;;
84
+ firebase) auth=" + Firebase Auth" ;;
85
+ appwrite) auth=" + Appwrite Auth" ;;
86
+ none) auth="" ;;
87
+ *) auth=" + $auth auth" ;;
88
+ esac
89
+ fi
90
+ echo "label=${framework}${api}${auth}" >> "$GITHUB_OUTPUT"
60
91
  # clubedge:if deploy=vercel
61
92
  # A Vercel preview deployment with the project's Preview variables, except the database,
62
93
  # which is the pull request's branch.
@@ -124,6 +155,7 @@ jobs:
124
155
  GH_TOKEN: ${{ github.token }}
125
156
  PR: ${{ github.event.number }}
126
157
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
158
+ STACK_LABEL: ${{ steps.stack.outputs.label }}
127
159
  # Empty without their deploy step.
128
160
  VERCEL_URL: ${{ steps.vercel.outputs.url }}
129
161
  RAILWAY_URL: ${{ steps.railway.outputs.url }}
@@ -132,11 +164,12 @@ jobs:
132
164
  marker="<!-- clubedge-preview -->"
133
165
  body="$marker
134
166
  **Preview** for ${HEAD_SHA:0:7}
167
+ - Stack: $STACK_LABEL
135
168
  - Database: Neon branch \`$NEON_BRANCH\`, with this pull request's migrations"
136
169
  if [ -n "$VERCEL_URL" ]; then body="$body
137
- - App on Vercel: $VERCEL_URL"; fi
170
+ - Vercel app ($STACK_LABEL): $VERCEL_URL"; fi
138
171
  if [ -n "$RAILWAY_URL" ]; then body="$body
139
- - App on Railway: $RAILWAY_URL"; fi
172
+ - Railway app ($STACK_LABEL): $RAILWAY_URL"; fi
140
173
  id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \
141
174
  --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1)
142
175
  if [ -n "$id" ]; then