@clubedge/create-clubedge-app 0.36.0 → 0.38.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 (67) hide show
  1. package/CHANGELOG.md +32 -4
  2. package/README.md +78 -22
  3. package/bin/mcp.mjs +2 -0
  4. package/dist/core/deploy.js +67 -7
  5. package/dist/core/neon.js +24 -4
  6. package/dist/core/plan.js +8 -3
  7. package/dist/core/project.js +22 -0
  8. package/dist/deploy-preview.js +7 -0
  9. package/dist/deploy.js +24 -25
  10. package/dist/index.js +6 -3
  11. package/dist/inspect.js +8 -6
  12. package/dist/mcp-server.js +259 -0
  13. package/dist/modify.js +6 -5
  14. package/dist/neon.js +30 -18
  15. package/dist/steps/git.js +4 -4
  16. package/dist/steps/install.js +2 -2
  17. package/dist/try.js +5 -4
  18. package/dist/utils/process.js +2 -2
  19. package/package.json +14 -7
  20. package/skills/clubedge-starter/SKILL.md +56 -0
  21. package/template/SETUP.md +1 -1
  22. package/template/apps/api/.env.example +11 -2
  23. package/template/apps/api/package.json +2 -2
  24. package/template/apps/api-fastapi/.env.example +11 -2
  25. package/template/apps/api-fastapi/Dockerfile +2 -2
  26. package/template/apps/api-fastapi/package.json +1 -1
  27. package/template/apps/api-fastapi/pyproject.toml +1 -1
  28. package/template/apps/api-fastapi/uv.lock +1 -1
  29. package/template/apps/start/package.json +2 -2
  30. package/template/apps/vite/.env.example +6 -1
  31. package/template/apps/vite/Dockerfile +4 -2
  32. package/template/apps/vite/package.json +1 -1
  33. package/template/apps/web/package.json +3 -3
  34. package/template/docker-compose.yml +16 -1
  35. package/template/package.json +1 -1
  36. package/template/packages/auth/package.json +1 -1
  37. package/template/packages/auth-appwrite/package.json +1 -1
  38. package/template/packages/auth-appwrite/src/conformance.live.test.ts +4 -3
  39. package/template/packages/auth-better-auth/package.json +1 -1
  40. package/template/packages/auth-firebase/package.json +1 -1
  41. package/template/packages/auth-supabase/package.json +1 -1
  42. package/template/packages/cache/package.json +1 -1
  43. package/template/packages/cache-redis/package.json +1 -1
  44. package/template/packages/contract/package.json +1 -1
  45. package/template/packages/core/package.json +1 -1
  46. package/template/packages/db/package.json +1 -1
  47. package/template/packages/email/package.json +1 -1
  48. package/template/packages/email-resend/package.json +1 -1
  49. package/template/packages/email-smtp/package.json +1 -1
  50. package/template/packages/jobs/package.json +1 -1
  51. package/template/packages/jobs-bullmq/package.json +1 -1
  52. package/template/packages/jobs-inngest/package.json +1 -1
  53. package/template/packages/jobs-pg-boss/package.json +1 -1
  54. package/template/packages/local-db/package.json +1 -1
  55. package/template/packages/observability/package.json +1 -1
  56. package/template/packages/observability-otel/package.json +1 -1
  57. package/template/packages/observability-sentry/package.json +1 -1
  58. package/template/packages/payments/package.json +1 -1
  59. package/template/packages/payments-stripe/package.json +1 -1
  60. package/template/packages/storage/package.json +1 -1
  61. package/template/packages/storage-s3/package.json +1 -1
  62. package/template/packages/storage-supabase/package.json +1 -1
  63. package/template/packages/ui/package.json +1 -1
  64. package/template/pnpm-lock.yaml +101 -89
  65. package/template/render.yaml +39 -0
  66. package/template.lock.json +2 -3
  67. package/template/apps/vite/Caddyfile +0 -22
package/CHANGELOG.md CHANGED
@@ -2,12 +2,40 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.38.0
6
+
7
+ - **Security: default to Starter `v0.24.2`** (#116). It updates Next.js to 16.4.3: 16.0.0 to 16.4.0 have two critical unauthenticated remote code execution advisories and a high denial of service ([GHSA-p3m5-59mp-jv5r](https://github.com/advisories/GHSA-p3m5-59mp-jv5r) and related). It also updates Zod to 4.6.7 ([GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5)). **Every Next.js project from an earlier Starter should run `pnpm clubedge upgrade`**, or raise `next` to `^16.4.3` in `apps/web/package.json` and run `pnpm install`. The Starter release also:
8
+ - gives a separate API the database URL of its `--infra` (it always pointed at `127.0.0.1:5432/postgres`, wrong for supabase, local, and docker);
9
+ - passes the Vite frontend's `VITE_*` build arguments in `docker-compose.yml`, and runs the Vite image as an unprivileged user;
10
+ - lists the Firebase, Appwrite, Inngest, Supabase, OpenTelemetry, and Sentry variables in `render.yaml`.
11
+ - **Fixes for a frontend and its API** (#115):
12
+ - The database URLs go to `apps/api/.env.local`, the API's, with `--infra neon`, `neon create|claim|status`, `add infra neon`, `try`, and `doctor`. Apps that also declare `DATABASE_URL` (Better Auth on the Vite frontend) get the same URLs.
13
+ - `deploy` with Docker: `compose.yaml` passes the frontend's `VITE_*` build arguments for every auth provider, not only Supabase. With Better Auth, the frontend gets its auth server's variables.
14
+ - `deploy` on Railway: with Better Auth, the frontend service gets the API's `DATABASE_URL`, `BETTER_AUTH_URL`, and a secret. Each service lists its own unset variables.
15
+ - `deploy --preview` refuses a frontend and its API with a clear message: it deploys one app.
16
+ - MCP: with `infra: neon`, `create_project` warns and `plan_project` returns `nextSteps` saying to run `pnpm clubedge neon create`.
17
+ - The README, roadmap, and 0.35.0–0.37.0 changelog entries describe what shipped; `pnpm release` leaves dependency bumps out of the release tables (#114).
18
+
19
+ ## 0.37.0
20
+
21
+ - **An MCP server for coding agents** (#110): `create-clubedge-app-mcp`, in the same package, lets an agent create projects with the CLI's pinned Starter and its compatibility rules. Run it from the workspace where projects should go:
22
+
23
+ ```sh
24
+ npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp
25
+ ```
26
+
27
+ - `get_starter_capabilities` lists the frameworks, API stacks, modules, defaults, and what each option requires.
28
+ - `validate_project_config` checks a choice of stacks and modules.
29
+ - `plan_project` previews a project: the stacks and modules, the files, the environment files, toolchains such as uv, and the setup steps. It writes nothing.
30
+ - `create_project` writes the project to a new or empty folder beneath the server's working directory. It refuses absolute paths, `..`, and symlinks that lead outside. Git and dependency installation are off unless asked for, and their output goes to stderr, never into the MCP stream.
31
+ - **An agent skill**, `skills/clubedge-starter/SKILL.md`, in the package: it tells Codex, Claude Code, and other agents to plan before creating, to use `add`, `remove`, and `upgrade` for existing projects, and to fall back to the CLI without MCP. The README shows how to install it and connect the server.
32
+ - CI and the release workflow start the MCP server from the packed package and call every tool.
33
+ - Still on Starter `v0.24.1`.
34
+
5
35
  ## 0.36.0
6
36
 
7
- - Bump vitest from 5.0.3 to 5.0.5 in the minor-and-patch group (#104).
8
- - Bump giget from 2.0.0 to 3.3.1 (#69).
9
- - Bump @types/node from 22.20.5 to 26.6.5 (#68).
10
- - Default to Starter `v0.24.1` (#107).
37
+ - **Security: default to Starter `v0.24.1`** (#107). It updates the FastAPI API's locked `certifi` to 2026.10.19, for the critical advisory [GHSA-6q48-6c7w-9gw2](https://github.com/certifi/python-certifi/security/advisories/GHSA-6q48-6c7w-9gw2). If your project has `--api fastapi`, run `pnpm clubedge upgrade`, then `uv sync` in `apps/api`. The Starter's pull request previews also skip secret-backed deploys for Dependabot.
38
+ - Dependency updates: giget 3.3.1 (#69), vitest 5.0.5 (#104), and @types/node 26.6.5 (#68).
11
39
 
12
40
  ## 0.35.0
13
41
 
package/README.md CHANGED
@@ -27,6 +27,61 @@ Add, remove, or upgrade them later without losing your own changes.
27
27
  pnpm dlx @clubedge/create-clubedge-app my-app
28
28
  ```
29
29
 
30
+ ## MCP server
31
+
32
+ Coding agents can connect to a local MCP server to inspect the capabilities of this CLI's pinned Starter release, validate stack and module choices, preview a complete scaffold plan, and generate a project in the server's working directory. The server exposes `get_starter_capabilities`, `validate_project_config`, `plan_project`, and `create_project`. Planning and validation do not write files. Project creation does not initialize Git or install dependencies unless those options are requested, and makes no network calls of its own: with `infra: neon`, `plan_project` lists `pnpm clubedge neon create` as a next step and `create_project` returns it as a warning, since the database is left for you to create.
33
+
34
+ Run it from the workspace where projects should be created:
35
+
36
+ ```sh
37
+ npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp
38
+ ```
39
+
40
+ Configure an MCP client to launch `npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp` with its working directory set to the workspace. In Codex, add this to `~/.codex/config.toml` and replace the example with the absolute path of the workspace where projects should be created:
41
+
42
+ ```toml
43
+ [mcp_servers.clubedge-starter]
44
+ command = "npx"
45
+ args = ["--yes", "--package", "@clubedge/create-clubedge-app", "create-clubedge-app-mcp"]
46
+ cwd = "D:/path/to/workspace"
47
+ ```
48
+
49
+ The MCP server is included in the same package and uses the CLI's bundled Starter and compatibility rules. Its create tool only writes beneath the configured working directory and refuses a non-empty destination.
50
+
51
+ ### Agent skill
52
+
53
+ The npm package includes the Agent Skill at [`skills/clubedge-starter/SKILL.md`](skills/clubedge-starter/SKILL.md). Download and extract the package in PowerShell:
54
+
55
+ ```powershell
56
+ $skillTarball = npm pack @clubedge/create-clubedge-app@latest
57
+ tar -xzf $skillTarball
58
+ ```
59
+
60
+ Then install it for the current user:
61
+
62
+ ```powershell
63
+ # Codex
64
+ New-Item -ItemType Directory -Force "$HOME/.agents/skills/clubedge-starter" | Out-Null
65
+ Copy-Item .\package\skills\clubedge-starter\SKILL.md "$HOME/.agents/skills/clubedge-starter/SKILL.md"
66
+
67
+ # Claude Code
68
+ New-Item -ItemType Directory -Force "$HOME/.claude/skills/clubedge-starter" | Out-Null
69
+ Copy-Item .\package\skills\clubedge-starter\SKILL.md "$HOME/.claude/skills/clubedge-starter/SKILL.md"
70
+ ```
71
+
72
+ These commands create `package/` in the current directory. Remove that extracted folder and tarball when the copy is done. From a clone of this repository, the same copy commands work with `skills/...` instead of `package/skills/...`.
73
+
74
+ For a repository-only install, use `.agents/skills/clubedge-starter/` for Codex or `.claude/skills/clubedge-starter/` for Claude Code instead of the `$HOME` paths. Restart the agent session after installing. In Codex, ask it to `Use the clubedge-starter skill to inspect the available stacks and modules. Do not create a project.` In Claude Code, invoke `/clubedge-starter` and give it the same request. The skill uses live MCP capabilities when available, so its choices follow the Starter release installed with the server.
75
+
76
+ To connect the MCP server in Claude Code, run this from the workspace where projects should be created:
77
+
78
+ ```sh
79
+ claude mcp add --scope local --transport stdio clubedge-starter -- npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp
80
+ claude mcp get clubedge-starter
81
+ ```
82
+
83
+ Then open Claude Code in that workspace, invoke `/clubedge-starter`, and ask it to inspect the available stacks and modules without creating a project. In Codex, use the `cwd` in `~/.codex/config.toml` above, restart Codex, and ask the same prompt. To exercise project creation, ask the agent to create a named project in a new child folder with Git and dependency installation disabled; verify the folder and report before enabling those optional actions.
84
+
30
85
  ## Why create-clubedge-app
31
86
 
32
87
  Copying a starter is easy; living with it is not. You delete the services you don't use by hand, and once you've changed the code, every later improvement to the starter has to be copied over by hand too.
@@ -47,7 +102,7 @@ pnpm dev
47
102
 
48
103
  Or start it with npm: `npx @clubedge/create-clubedge-app my-app`. The generated project is a pnpm workspace, so dependencies are installed with pnpm either way; the CLI falls back to `corepack pnpm` when `pnpm` is not on your path. Without a destination, the CLI asks where to create the project.
49
104
 
50
- The generated project includes `apps/web/.env.local`, created from the example and pointing at the local services you chose. Its `SETUP.md` describes every variable and provider. The root route is a landing page named after your project, and the dashboard is at `/dashboard`.
105
+ The generated project includes `apps/web/.env.local` (and `apps/api/.env.local` with a separate API), created from the example and pointing at the local services you chose. Its `SETUP.md` describes every variable and provider. The root route is a landing page named after your project, and the dashboard is at `/dashboard`.
51
106
 
52
107
  The default Starter release ships inside the CLI package, so GitHub is only contacted when you pass `--ref`. Internet access is needed to install dependencies.
53
108
 
@@ -86,7 +141,7 @@ They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend c
86
141
 
87
142
  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
143
 
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.
144
+ The decoupled stacks support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache, any local infrastructure (the API's `apps/api/.env.local` holds `DATABASE_URL`, since the API owns the database), and Docker or Railway deployment. With Docker, `pnpm clubedge deploy` writes a `compose.yaml` that builds the frontend and API as separate services; copy the variables of `.env.example` and `apps/api/.env.example` to `.env.production`, fill in their values, and run `docker compose --env-file .env.production up --build -d`. The frontend's `VITE_*` values are build arguments, compiled into its bundle; with Better Auth, its Node server also gets the auth variables (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and the email settings). 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.
90
145
 
91
146
  ## Choosing modules
92
147
 
@@ -98,8 +153,8 @@ Authentication, file storage, caching, email, payments, background jobs, observa
98
153
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
99
154
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
100
155
  | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
101
- | `--payments` | `stripe`, `none` (needs an auth module) | `none` |
102
- | `--jobs` | `pg-boss`, `none` | `none` |
156
+ | `--payments` | `stripe` (needs an auth module), `none` | `none` |
157
+ | `--jobs` | `pg-boss`, `bullmq` (needs the `redis` cache), `inngest`, `none` | `none` |
103
158
  | `--observability` | `otel` (OpenTelemetry, to any OTLP collector), `sentry`, `none` | `none` |
104
159
  | `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
105
160
  | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `neon` (a free hosted Neon database), `none` | `docker` |
@@ -152,7 +207,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
152
207
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
153
208
 
154
209
  # Reproduce a specific Starter release or commit
155
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.1
210
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.2
156
211
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
157
212
 
158
213
  # Test local Starter changes before releasing them
@@ -171,7 +226,7 @@ pnpm clubedge add email resend payments stripe # several modules as
171
226
  pnpm clubedge remove storage payments
172
227
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
173
228
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
174
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.24.1 # or a specific tag, branch, or commit
229
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.24.2 # or a specific tag, branch, or commit
175
230
  ```
176
231
 
177
232
  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.
@@ -179,7 +234,7 @@ The CLI renders the project twice, from the Starter commit and modules recorded
179
234
  - Files you never changed are updated, added, or deleted.
180
235
  - Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve. The Starter's whole new version of each such file is saved in `.clubedge/incoming/` to compare with yours (`git diff --no-index <file> .clubedge/incoming/<file>`); Git ignores that folder, so delete it when you are done.
181
236
  - Your own files are never touched.
182
- - `apps/web/.env.local` keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
237
+ - Each app's local env file (`apps/web/.env.local`, and `apps/api/.env.local` with a separate API) keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
183
238
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
184
239
 
185
240
  Several modules in one command are applied as one change, and their requirements are checked together, so `add auth supabase storage supabase` works even though Supabase Storage needs Supabase Auth. When lines conflict, the CLI lists each file with its number of conflict blocks. Before an upgrade, it lists the Starter releases it crosses with the sections of their release notes, such as `v0.13.0 Observability, Tests, Also`.
@@ -197,7 +252,7 @@ pnpm db:migrate
197
252
  pnpm dev
198
253
  ```
199
254
 
200
- The CLI creates a free database through Neon's [claimable Postgres](https://neon.com/docs/reference/claimable-postgres) and writes two URLs into `apps/web/.env.local`: `DATABASE_URL`, the pooled connection the app uses, and `DATABASE_URL_DIRECT`, the direct one that migrations and the background jobs worker use, since a transaction pooler can break them. Neon keeps an unclaimed database for 72 hours, with up to 100 MB:
255
+ The CLI creates a free database through Neon's [claimable Postgres](https://neon.com/docs/reference/claimable-postgres) and writes two URLs into the env file of the app that owns the database, `apps/web/.env.local`, or `apps/api/.env.local` in a project with a separate API (a Vite frontend whose Better Auth server connects too gets the same `DATABASE_URL`): `DATABASE_URL`, the pooled connection the app uses, and `DATABASE_URL_DIRECT`, the direct one that migrations and the background jobs worker use, since a transaction pooler can break them. Neon keeps an unclaimed database for 72 hours, with up to 100 MB:
201
256
 
202
257
  ```sh
203
258
  pnpm clubedge neon # where it stands and how long it has left
@@ -218,7 +273,7 @@ pnpm clubedge status # Starter release, modules, Git state, and whether a new
218
273
  pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and pnpm check:services
219
274
  ```
220
275
 
221
- `status` ends with the command to run next, such as `pnpm clubedge upgrade` when a newer Starter release is available; it asks npm for the newest CLI and still works offline. `doctor` checks that Node.js and pnpm meet the project's versions, that dependencies are installed, that `pnpm-lock.yaml` matches every `package.json`, and that `apps/web/.env.local` has every variable in `.env.example`, then runs `pnpm check:services` (skip it with `--no-services`). It exits with an error when a check fails, so it also works in CI.
276
+ `status` ends with the command to run next, such as `pnpm clubedge upgrade` when a newer Starter release is available; it asks npm for the newest CLI and still works offline. `doctor` checks that Node.js and pnpm meet the project's versions, that dependencies are installed, that `pnpm-lock.yaml` matches every `package.json`, and that each app's local env file has every variable in its example (`apps/web/.env.local` and `.env.example`, and `apps/api/.env.local` and `apps/api/.env.example` with a separate API), then runs `pnpm check:services` (skip it with `--no-services`). It exits with an error when a check fails, so it also works in CI.
222
277
 
223
278
  ## Try it on a Railway VM (experimental)
224
279
 
@@ -247,7 +302,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
247
302
  pnpm clubedge deploy --dry-run # show the plan, create nothing
248
303
  ```
249
304
 
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.
305
+ 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 Node server (`node server.mjs`) forwards the API routes 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. With Better Auth, the frontend's Node server runs auth: it gets the same `DATABASE_URL` as the API, a generated `BETTER_AUTH_SECRET`, the email settings, and `BETTER_AUTH_URL`, the public frontend URL both services use. Each service's unset variables are reported from its own env example. 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.
251
306
 
252
307
  ### Deploy to Fly.io (experimental)
253
308
 
@@ -273,7 +328,7 @@ In a project that deploys to Vercel or Railway, `pnpm clubedge deploy --preview`
273
328
  NEON_API_KEY=... NEON_PROJECT_ID=... pnpm clubedge deploy --preview
274
329
  ```
275
330
 
276
- With `NEON_API_KEY` and `NEON_PROJECT_ID` (in the shell, or in the env file), the preview gets its own [Neon branch](https://neon.com/docs/introduction/branching), `preview/<git branch>`: a copy of the production database made in seconds that shares its storage until it changes. `deploy` creates it, or reuses it on later runs, applies this branch's migrations to it, and points the preview's `DATABASE_URL` and `DATABASE_URL_DIRECT` at it. On Vercel, the preview is a preview deployment with the project's Preview variables, and the branch's URLs are set as Preview variables of your Git branch, which needs the Vercel project connected to its Git repository; without the Neon settings, it uses the Preview `DATABASE_URL`. On Railway, it is an environment named `preview-<git branch>`, copied from production with its own domain; without the Neon settings, it gets its own empty copy of Railway's Postgres, and when production's database comes from elsewhere, `deploy --preview` refuses, since the copy would use production's data. Production is not changed.
331
+ With `NEON_API_KEY` and `NEON_PROJECT_ID` (in the shell, or in the env file), the preview gets its own [Neon branch](https://neon.com/docs/introduction/branching), `preview/<git branch>`: a copy of the production database made in seconds that shares its storage until it changes. `deploy` creates it, or reuses it on later runs, applies this branch's migrations to it, and points the preview's `DATABASE_URL` and `DATABASE_URL_DIRECT` at it. On Vercel, the preview is a preview deployment with the project's Preview variables, and the branch's URLs are set as Preview variables of your Git branch, which needs the Vercel project connected to its Git repository; without the Neon settings, it uses the Preview `DATABASE_URL`. On Railway, it is an environment named `preview-<git branch>`, copied from production with its own domain; without the Neon settings, it gets its own empty copy of Railway's Postgres, and when production's database comes from elsewhere, `deploy --preview` refuses, since the copy would use production's data. Production is not changed. It deploys a single app for now: a Vite frontend and its API are two services, so `deploy --preview` refuses them and explains how to copy production's Railway environment yourself.
277
332
 
278
333
  Projects created with `--infra neon` also have `.github/workflows/preview.yml`, which does this for every pull request in CI: a Neon branch per pull request with its migrations, the schema diff as a comment, a Vercel or Railway preview, and cleanup when the pull request closes. The Starter's SETUP.md lists the secrets it needs.
279
334
 
@@ -282,27 +337,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
282
337
  Each CLI release is tested against one specific Starter release:
283
338
 
284
339
  ```text
285
- create-clubedge-app v0.36.0
340
+ create-clubedge-app v0.38.0
286
341
  |
287
342
  v
288
- Clubedge Starter v0.24.1
289
- commit 3e8b5ba8910767f27336cf253fbfe92a9bb42875
343
+ Clubedge Starter v0.24.2
344
+ commit 4450d2f00a89f1a3929a5c9d2c1541e0d65df891
290
345
  ```
291
346
 
292
347
  The current relationship is:
293
348
 
294
349
  | CLI release | Default Starter release | Meaning |
295
350
  | --- | --- | --- |
296
- | `create-clubedge-app@0.36.0` | `clubedge-starter@v0.24.1` | The CLI's required, tested default |
351
+ | `create-clubedge-app@0.38.0` | `clubedge-starter@v0.24.2` | The CLI's required, tested default |
297
352
 
298
353
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
299
354
 
300
- The `v0.24.1` tag is expected to resolve to commit
301
- `3e8b5ba8910767f27336cf253fbfe92a9bb42875`. The CLI verifies this before
355
+ The `v0.24.2` tag is expected to resolve to commit
356
+ `4450d2f00a89f1a3929a5c9d2c1541e0d65df891`. The CLI verifies this before
302
357
  scaffolding with the default reference and fails rather than silently using a
303
358
  moved tag.
304
359
 
305
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.24.1`. 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.
360
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.24.2`. 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.
306
361
 
307
362
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
308
363
 
@@ -357,7 +412,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
357
412
  1. Make and validate changes in `clubedge-starter`.
358
413
  2. Run lint, typecheck, unit tests, browser tests, and production build.
359
414
  3. Update the Starter package versions.
360
- 4. Commit `main`, create a release tag such as `v0.24.1`, and publish the Starter release.
415
+ 4. Commit `main`, create a release tag such as `v0.24.2`, and publish the Starter release.
361
416
 
362
417
  ### CLI maintainers
363
418
 
@@ -402,9 +457,10 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
402
457
  | 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 |
403
458
  | 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
459
  | 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 |
406
- | 0.36.0 | v0.24.1 | Bump vitest from 5.0.3 to 5.0.5 in the minor-and-patch group; Bump giget from 2.0.0 to 3.3.1; Bump @types/node from 22.20.5 to 26.6.5; Default to Starter v0.24.1 |
407
- | next | | Show the Starter's version of a conflicted file next to yours |
460
+ | 0.35.0 | v0.24.0 | `deploy` for a frontend and its API (Docker Compose, Railway); Starter v0.24.0: all four auth providers on React + Vite with Hono or FastAPI |
461
+ | 0.36.0 | v0.24.1 | Starter v0.24.1: security update of FastAPI's `certifi`; dependency updates |
462
+ | 0.37.0 | v0.24.1 | An MCP server (capabilities, validate, plan, create) and an agent skill for coding agents |
463
+ | 0.38.0 | v0.24.2 | Starter v0.24.2: Next.js security update (critical RCE advisories); database URLs, Neon, and deploy variables for a frontend and its API |
408
464
 
409
465
  ## Community
410
466
 
package/bin/mcp.mjs ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/mcp-server.js";
@@ -10,18 +10,36 @@ 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) {
13
+ /**
14
+ * A Docker Compose production topology rendered from the selected stack service descriptors.
15
+ * `frontendKeys` are the variables the frontend's env example lists: its VITE_* values become build
16
+ * arguments (Vite compiles them in), the others runtime variables, such as Better Auth's when the
17
+ * frontend's server runs auth. Compose reads their values from its --env-file.
18
+ */
19
+ export function dockerComposeSource(services, frontendKeys = []) {
15
20
  const api = Object.values(services).find((service) => service.role === "api");
16
- const rows = Object.entries(services).map(([slot, service]) => {
21
+ const web = Object.values(services).find((service) => service.role === "frontend");
22
+ const mapping = (indent, values) => values.map(([key, value]) => `\n${indent}${key}: ${value}`).join("");
23
+ const fromEnv = (key, fallback = "") => `\${${key}:-${fallback}}`;
24
+ const buildKeys = frontendKeys.filter((key) => key.startsWith("VITE_"));
25
+ // API_URL is the API's address on the Compose network, and PORT the one the image declares.
26
+ const runtimeKeys = frontendKeys.filter((key) => !key.startsWith("VITE_") && key !== "API_URL" && key !== "PORT");
27
+ // Better Auth runs in the frontend's server: the API asks it for the session over the Compose network.
28
+ const authUrl = web && frontendKeys.includes("BETTER_AUTH_URL") ? `http://${web.id}:${web.port}` : null;
29
+ const rows = Object.entries(services).map(([, service]) => {
17
30
  const name = service.id;
18
31
  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:-}`
32
+ const buildArgs = frontend && buildKeys.length
33
+ ? `\n args:${mapping(" ", buildKeys.map((key) => [key, fromEnv(key)]))}`
21
34
  : "";
35
+ // Better Auth's public URL defaults to the port Compose publishes.
36
+ const runtime = runtimeKeys.map((key) => [key, fromEnv(key, key === "BETTER_AUTH_URL" ? `http://localhost:${service.port}` : "")]);
37
+ // The API listens on every interface, whatever HOST the env file has from development.
38
+ const apiValues = [["HOST", "0.0.0.0"], ...(authUrl ? [["BETTER_AUTH_URL", authUrl]] : [])];
39
+ const apiEnv = service.role === "api" ? `\n environment:${mapping(" ", apiValues)}` : "";
22
40
  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`;
41
+ ? `\n environment:${mapping(" ", [["API_URL", `http://${api.id}:${api.port}`], ...runtime])}`
42
+ : `\n env_file:\n - path: .env.production\n required: false${apiEnv}`;
25
43
  const ports = frontend ? `\n ports:\n - "${service.port}:${service.port}"` : `\n expose:\n - "${service.port}"`;
26
44
  const depends = frontend && api ? `\n depends_on:\n - ${api.id}` : "";
27
45
  return ` ${name}:\n build:\n context: .\n dockerfile: ${service.dockerfile}${buildArgs}${env}${ports}${depends}`;
@@ -123,6 +141,48 @@ export function planVariables(input) {
123
141
  const unset = keys.filter((key) => set[key] === undefined && !existing[key] && key !== "DATABASE_URL_DIRECT");
124
142
  return { set, unset };
125
143
  }
144
+ /**
145
+ * The variables of a frontend and an API deployed as two services. The API gets what planVariables
146
+ * provides with the env file's server values, and the frontend's URL. The frontend gets its port,
147
+ * the env file's VITE_* build values, and the runtime values its own example lists. With Better
148
+ * Auth the frontend's server runs auth: it gets the API's database, the secret (generated unless
149
+ * the service already has one), and BETTER_AUTH_URL, the public URL the API asks for the session.
150
+ * Each service's unset variables come from its own example.
151
+ */
152
+ export function planServicePair(input) {
153
+ const { modules, frontendKeys, apiKeys, url, existing, envFile, secret, databases, ports } = input;
154
+ const betterAuth = modules.auth === "better-auth";
155
+ // A development env file's localhost URL would send sign-in links to this machine.
156
+ const authUrl = envFile.BETTER_AUTH_URL && !isLocalUrl(envFile.BETTER_AUTH_URL) ? envFile.BETTER_AUTH_URL : url;
157
+ const apiEnv = Object.fromEntries(Object.entries(envFile).filter(([key]) => !key.startsWith("VITE_")));
158
+ const api = planVariables({ modules, keys: apiKeys, url, existing: existing.api, envFile: apiEnv, secret, databases, port: ports.api });
159
+ // Better Auth's secret belongs to the frontend's server; the API only asks it for the session.
160
+ if (!apiEnv.BETTER_AUTH_SECRET)
161
+ delete api.set.BETTER_AUTH_SECRET;
162
+ api.set.FRONTEND_URL = url;
163
+ if (betterAuth)
164
+ api.set.BETTER_AUTH_URL = authUrl;
165
+ // The image listens on every interface: its Dockerfile sets HOST.
166
+ api.unset = api.unset.filter((key) => api.set[key] === undefined && key !== "HOST");
167
+ // railway.ts sets API_URL to the API's private address; the platform provides the database.
168
+ const provided = ["API_URL", "PORT", "BETTER_AUTH_URL", ...Object.values(databaseVariables), "DATABASE_URL_DIRECT"];
169
+ const set = { PORT: String(ports.frontend) };
170
+ for (const [key, value] of Object.entries(envFile)) {
171
+ if (value && (key.startsWith("VITE_") || (frontendKeys.includes(key) && !provided.includes(key))))
172
+ set[key] = value;
173
+ }
174
+ if (betterAuth) {
175
+ const database = api.set.DATABASE_URL ?? existing.api.DATABASE_URL;
176
+ if (database)
177
+ set.DATABASE_URL = database;
178
+ set.BETTER_AUTH_URL = authUrl;
179
+ if (!set.BETTER_AUTH_SECRET && !existing.frontend.BETTER_AUTH_SECRET)
180
+ set.BETTER_AUTH_SECRET = secret();
181
+ }
182
+ // Optional: without VITE_API_URL the browser calls the API through the frontend's /api proxy.
183
+ const unset = frontendKeys.filter((key) => set[key] === undefined && !existing.frontend[key] && !["API_URL", "VITE_API_URL"].includes(key));
184
+ return { frontend: { set, unset }, api };
185
+ }
126
186
  /**
127
187
  * DATABASE_URL_DIRECT for a database hosted elsewhere: the env file's, or for a pooled Neon URL the
128
188
  * same URL without -pooler. Never next to the platform's own database (no external URL), where it
package/dist/core/neon.js CHANGED
@@ -210,20 +210,22 @@ export function savedDatabaseFor(databases, url) {
210
210
  return databases.find((database) => neonEndpoint(`postgresql://${database.host}`) === endpoint) ?? null;
211
211
  }
212
212
  /* ------------------------------ Env files ------------------------------ */
213
+ /** A KEY= line, with or without `export`, as parseEnvFile reads it. */
214
+ const assignment = (key, flags = "") => new RegExp(`^\\s*(export\\s+)?${key}\\s*=`, flags);
213
215
  /**
214
- * Sets variables in an env file's text: each KEY= line is replaced (the first one; a comment after
215
- * the value goes), and a missing variable is added at the end.
216
+ * Sets variables in an env file's text: each KEY= line is replaced (the first one, keeping its
217
+ * `export`; a comment after the value goes), and a missing variable is added at the end.
216
218
  */
217
219
  export function setEnvValues(text, values) {
218
220
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
219
221
  const lines = text.split(/\r?\n/);
220
222
  const missing = [];
221
223
  for (const [key, value] of Object.entries(values)) {
222
- const at = lines.findIndex((line) => new RegExp(`^\\s*${key}\\s*=`).test(line));
224
+ const at = lines.findIndex((line) => assignment(key).test(line));
223
225
  if (at === -1)
224
226
  missing.push(`${key}=${value}`);
225
227
  else
226
- lines[at] = `${key}=${value}`;
228
+ lines[at] = `${assignment(key).exec(lines[at])[1] ? "export " : ""}${key}=${value}`;
227
229
  }
228
230
  let result = lines.join(eol);
229
231
  if (missing.length) {
@@ -233,6 +235,24 @@ export function setEnvValues(text, values) {
233
235
  }
234
236
  return result;
235
237
  }
238
+ /**
239
+ * Sets only the variables an env file already declares, or returns null when it declares none:
240
+ * for another app that connects to the database too, such as Better Auth's server in a Vite frontend.
241
+ */
242
+ export function setDeclaredEnvValues(text, values) {
243
+ const declared = Object.entries(values).filter(([key]) => assignment(key, "m").test(text));
244
+ return declared.length ? setEnvValues(text, Object.fromEntries(declared)) : null;
245
+ }
246
+ /**
247
+ * What is left to do for --infra neon when the project was created without a database, as MCP's
248
+ * create_project does (it makes no network calls), or null for other infrastructure.
249
+ */
250
+ export function neonSetupStep(plan) {
251
+ if (!plan.modules.some(({ id, option }) => id === "infra" && option === "neon"))
252
+ return null;
253
+ const where = plan.databaseEnvFile ? ` in ${plan.databaseEnvFile}` : "";
254
+ return `The project uses Neon and has no database yet: after pnpm install, run \`pnpm clubedge neon create\` in the project for a free one (it sets DATABASE_URL and DATABASE_URL_DIRECT${where}), then \`pnpm db:migrate\`.`;
255
+ }
236
256
  /* ------------------------------ Branches (Neon API) ------------------------------ */
237
257
  // `deploy --preview` gives a preview its own branch of the user's Neon project: a copy of the
238
258
  // production branch made in seconds, which shares storage until it changes.
package/dist/core/plan.js CHANGED
@@ -412,6 +412,12 @@ export function createPlan(input) {
412
412
  apply: (text) => customizeSiteConfig(text, identity),
413
413
  });
414
414
  }
415
+ const envFiles = selected
416
+ .map(({ slot }) => ({ from: slot.env.example, to: slot.env.target }))
417
+ .filter(({ from }) => has(from));
418
+ // The API's env file even when its example is missing, as databaseEnvFile(project) resolves it
419
+ // later from the API's service, so the URLs written at creation are where commands look.
420
+ const apiEnvFile = selected.find(({ stack }) => stack.role === "api")?.slot.env.target;
415
421
  return {
416
422
  targetDirectory: input.targetDirectory,
417
423
  identity,
@@ -420,9 +426,8 @@ export function createPlan(input) {
420
426
  modules,
421
427
  files,
422
428
  edits,
423
- envFiles: selected
424
- .map(({ slot }) => ({ from: slot.env.example, to: slot.env.target }))
425
- .filter(({ from }) => has(from)),
429
+ envFiles,
430
+ databaseEnvFile: apiEnvFile ?? envFiles[0]?.to ?? null,
426
431
  toolchains: selected.flatMap(({ slot, stack }) => stack.toolchain ? [{ directory: slot.path, app: stack.name, toolchain: stack.toolchain }] : []),
427
432
  git: input.git,
428
433
  install: input.install,
@@ -42,6 +42,28 @@ export async function readProject(directory) {
42
42
  current = parent;
43
43
  }
44
44
  }
45
+ /** The web app's: every project has it, with its example at the root. */
46
+ const webEnvFile = { example: ".env.example", local: "apps/web/.env.local" };
47
+ const appEnvFile = (app) => ({ example: `${app}/.env.example`, local: `${app}/.env.local` });
48
+ /** The directory of the project's separate API, or null when the app is full-stack. */
49
+ function apiDirectory(project) {
50
+ const api = Object.values(project.services).find(({ role }) => role === "api");
51
+ // Projects from Starter 0.22 record the API's stack but no services; the API is in apps/api.
52
+ return api?.app ?? (project.stacks.api ? "apps/api" : null);
53
+ }
54
+ /** Each app's env files: the web app's, then the API's, which keeps both in its own folder. */
55
+ export function projectEnvFiles(project) {
56
+ const api = apiDirectory(project);
57
+ return [webEnvFile, ...(api ? [appEnvFile(api)] : [])];
58
+ }
59
+ /**
60
+ * The env file with the database URLs: the API's when the project has one, since the API owns
61
+ * the database (its migrations, its jobs), else the web app's. Matches the plan's databaseEnvFile.
62
+ */
63
+ export function databaseEnvFile(project) {
64
+ const api = apiDirectory(project);
65
+ return api ? appEnvFile(api) : webEnvFile;
66
+ }
45
67
  /** The files the CLI generates for this selection, without writing anything. */
46
68
  export async function renderProject(input) {
47
69
  const plan = createPlan({ ...input, targetDirectory: "", git: false, install: false });
@@ -42,6 +42,13 @@ async function migrate(root, branch) {
42
42
  }
43
43
  }
44
44
  export async function deployPreview(args, project, cli, envFile) {
45
+ // A preview is one service named after the package; a frontend and an API are two.
46
+ const separate = Object.values(project.services).filter(({ role }) => role !== "fullstack");
47
+ if (separate.length > 1) {
48
+ const base = serviceName(project.identity.packageName);
49
+ const services = separate.map(({ id }) => serviceName(`${base}-${id}`)).join(" and ");
50
+ throw new UsageError(`deploy --preview works with a single app for now, and this project runs its frontend and API as separate services (${services}). Deploy production with \`${cli} deploy\`; for a preview on Railway, copy production with \`railway environment new <name> --duplicate production\`, then run \`railway up --service <service> --environment <name>\` for each service.`);
51
+ }
45
52
  const platform = project.modules.deploy ?? "docker";
46
53
  if (platform !== "vercel" && platform !== "railway") {
47
54
  throw new UsageError(`deploy --preview works with Vercel and Railway, and this project deploys with "${platform}". Its pull requests still get a Neon branch from .github/workflows/preview.yml.`);
package/dist/deploy.js CHANGED
@@ -12,7 +12,7 @@ import process from "node:process";
12
12
  import { UsageError } from "./cli/args.js";
13
13
  import { bold, dim } from "./cli/ui.js";
14
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";
15
+ import { appPort, databaseVariables, dockerComposeSource, databasesFor, deploymentStatus, exampleKeys, externalDatabaseUrls, finalStatuses, isLocalUrl, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planServicePair, 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";
@@ -259,42 +259,39 @@ async function deployRailwayServices(args, project, cli, envFile) {
259
259
  if (!domain)
260
260
  throw new Error("Railway created no frontend domain. Create one in its settings, then run deploy again.");
261
261
  const url = `https://${domain}`;
262
- const apiEnv = Object.fromEntries(Object.entries(envFile).filter(([key]) => !key.startsWith("VITE_")));
263
- const apiPlan = planVariables({
262
+ const listedWebVars = await attempt(() => railway(rw, ["variable", "list", "--service", names.frontend, "--json"]));
263
+ if (listedWebVars === null && existing.includes(names.frontend)) {
264
+ throw new Error(`Railway didn't list the variables of the frontend service "${names.frontend}", so deploy changed nothing. Run deploy again; if it keeps failing, check \`railway variable list --service ${names.frontend}\`.`);
265
+ }
266
+ const webVars = parseVariables(listedWebVars ?? "");
267
+ const plan = planServicePair({
264
268
  modules: project.modules,
265
- keys: keys.filter((key) => !key.startsWith("VITE_")),
269
+ frontendKeys: keys,
270
+ apiKeys: exampleKeys(await readFile(join(project.root, apiStack.app, ".env.example"), "utf8").catch(() => "")),
266
271
  url,
267
- existing: apiVars,
268
- envFile: apiEnv,
272
+ existing: { frontend: webVars, api: apiVars },
273
+ envFile,
269
274
  secret: () => randomBytes(32).toString("hex"),
270
275
  databases: railwayReferences(databases),
271
- port: apiStack.port,
276
+ ports: { frontend: frontendStack.port, api: apiStack.port },
272
277
  });
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.`);
278
+ const unset = [
279
+ ...(plan.api.unset.length ? [`Not set on the API service: ${plan.api.unset.join(", ")}.`] : []),
280
+ ...(plan.frontend.unset.length ? [`Not set on the frontend service: ${plan.frontend.unset.join(", ")}.`] : []),
281
+ ];
282
+ if (unset.length) {
283
+ log.warn(`${unset.join("\n")}\nPass --env-file to supply values before deployment.`);
276
284
  if (process.stdin.isTTY && !args.yes) {
277
285
  const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
278
286
  if (isCancel(proceed) || !proceed)
279
287
  return cancel("Nothing was uploaded. Set the variables, then run deploy again.");
280
288
  }
281
289
  }
282
- for (const [key, value] of Object.entries(apiPlan.set)) {
290
+ for (const [key, value] of Object.entries(plan.api.set)) {
283
291
  if (apiVars[key] !== value)
284
292
  await railway(rw, ["variable", "set", key, "--stdin", "--service", names.api, "--skip-deploys"], { input: value });
285
293
  }
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)) {
294
+ for (const [key, value] of Object.entries(plan.frontend.set)) {
298
295
  if (webVars[key] !== value)
299
296
  await railway(rw, ["variable", "set", key, "--stdin", "--service", names.frontend, "--skip-deploys"], { input: value });
300
297
  }
@@ -357,7 +354,8 @@ export async function deployProject(args) {
357
354
  if (unsupported.length) {
358
355
  throw new UsageError(`The selected stacks do not declare Docker deployment support for ${unsupported.map(([slot]) => slot).join(", ")}.`);
359
356
  }
360
- const compose = dockerComposeSource(Object.fromEntries(separateApps));
357
+ const frontendKeys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
358
+ const compose = dockerComposeSource(Object.fromEntries(separateApps), frontendKeys);
361
359
  if (args.dryRun) {
362
360
  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
361
  outro("Dry run: nothing was written. Run deploy to generate compose.yaml.");
@@ -368,7 +366,8 @@ export async function deployProject(args) {
368
366
  throw new UsageError("compose.yaml already exists. Review it and remove it before generating the stack configuration again.");
369
367
  await writeFile(path, compose);
370
368
  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.`);
369
+ const examples = [".env.example", ...separateApps.filter(([, service]) => service.role === "api").map(([, service]) => `${service.app}/.env.example`)];
370
+ outro(`Copy the variables of ${examples.join(" and ")} to .env.production and set their 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
371
  return;
373
372
  }
374
373
  if (separateApps.length > 1) {
package/dist/index.js CHANGED
@@ -158,6 +158,7 @@ async function createProject(args) {
158
158
  // The spinner runs while the manifest is read and again while files are written.
159
159
  let spinning = "preparing";
160
160
  let neonEnvFile = null;
161
+ let sharedEnvFiles = [];
161
162
  let toolchains = [];
162
163
  try {
163
164
  const manifest = await readTemplateManifest(source.root);
@@ -185,9 +186,11 @@ async function createProject(args) {
185
186
  git: args.git,
186
187
  install: args.install,
187
188
  });
188
- // --infra neon: a free Neon database, its URLs written into the new env file.
189
+ // --infra neon: a free Neon database, its URLs written into the env file of the app that owns
190
+ // the database (the API, when the project has one).
189
191
  toolchains = plan.toolchains;
190
- neonEnvFile = plan.modules.some(({ id, option }) => id === "infra" && option === "neon") && args.neon ? (plan.envFiles[0]?.to ?? null) : null;
192
+ neonEnvFile = plan.modules.some(({ id, option }) => id === "infra" && option === "neon") && args.neon ? plan.databaseEnvFile : null;
193
+ sharedEnvFiles = plan.envFiles.map(({ to }) => to);
191
194
  if (args.dryRun) {
192
195
  const lines = [...describePlan(plan), ...(neonEnvFile ? [`Create a free Neon database and set its URLs in ${neonEnvFile}`] : [])];
193
196
  log.message(lines.map((line) => `• ${line}`).join("\n"));
@@ -215,7 +218,7 @@ async function createProject(args) {
215
218
  if (neonEnvFile) {
216
219
  progress.start("Creating a free Neon database");
217
220
  try {
218
- const created = await provisionNeonDatabase(targetDirectory, neonEnvFile);
221
+ const created = await provisionNeonDatabase(targetDirectory, neonEnvFile, sharedEnvFiles);
219
222
  progress.stop(describeNewDatabase(created, "pnpm clubedge"));
220
223
  migrate = "db:migrate";
221
224
  }