@clubedge/create-clubedge-app 0.37.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 (63) hide show
  1. package/CHANGELOG.md +28 -5
  2. package/README.md +27 -27
  3. package/dist/core/deploy.js +67 -7
  4. package/dist/core/neon.js +24 -4
  5. package/dist/core/plan.js +8 -3
  6. package/dist/core/project.js +22 -0
  7. package/dist/deploy-preview.js +7 -0
  8. package/dist/deploy.js +24 -25
  9. package/dist/index.js +6 -3
  10. package/dist/inspect.js +8 -6
  11. package/dist/mcp-server.js +6 -0
  12. package/dist/modify.js +6 -5
  13. package/dist/neon.js +30 -18
  14. package/dist/try.js +5 -4
  15. package/package.json +3 -3
  16. package/skills/clubedge-starter/SKILL.md +2 -2
  17. package/template/SETUP.md +1 -1
  18. package/template/apps/api/.env.example +11 -2
  19. package/template/apps/api/package.json +2 -2
  20. package/template/apps/api-fastapi/.env.example +11 -2
  21. package/template/apps/api-fastapi/Dockerfile +2 -2
  22. package/template/apps/api-fastapi/package.json +1 -1
  23. package/template/apps/api-fastapi/pyproject.toml +1 -1
  24. package/template/apps/api-fastapi/uv.lock +1 -1
  25. package/template/apps/start/package.json +2 -2
  26. package/template/apps/vite/.env.example +6 -1
  27. package/template/apps/vite/Dockerfile +4 -2
  28. package/template/apps/vite/package.json +1 -1
  29. package/template/apps/web/package.json +3 -3
  30. package/template/docker-compose.yml +16 -1
  31. package/template/package.json +1 -1
  32. package/template/packages/auth/package.json +1 -1
  33. package/template/packages/auth-appwrite/package.json +1 -1
  34. package/template/packages/auth-appwrite/src/conformance.live.test.ts +4 -3
  35. package/template/packages/auth-better-auth/package.json +1 -1
  36. package/template/packages/auth-firebase/package.json +1 -1
  37. package/template/packages/auth-supabase/package.json +1 -1
  38. package/template/packages/cache/package.json +1 -1
  39. package/template/packages/cache-redis/package.json +1 -1
  40. package/template/packages/contract/package.json +1 -1
  41. package/template/packages/core/package.json +1 -1
  42. package/template/packages/db/package.json +1 -1
  43. package/template/packages/email/package.json +1 -1
  44. package/template/packages/email-resend/package.json +1 -1
  45. package/template/packages/email-smtp/package.json +1 -1
  46. package/template/packages/jobs/package.json +1 -1
  47. package/template/packages/jobs-bullmq/package.json +1 -1
  48. package/template/packages/jobs-inngest/package.json +1 -1
  49. package/template/packages/jobs-pg-boss/package.json +1 -1
  50. package/template/packages/local-db/package.json +1 -1
  51. package/template/packages/observability/package.json +1 -1
  52. package/template/packages/observability-otel/package.json +1 -1
  53. package/template/packages/observability-sentry/package.json +1 -1
  54. package/template/packages/payments/package.json +1 -1
  55. package/template/packages/payments-stripe/package.json +1 -1
  56. package/template/packages/storage/package.json +1 -1
  57. package/template/packages/storage-s3/package.json +1 -1
  58. package/template/packages/storage-supabase/package.json +1 -1
  59. package/template/packages/ui/package.json +1 -1
  60. package/template/pnpm-lock.yaml +101 -89
  61. package/template/render.yaml +39 -0
  62. package/template.lock.json +2 -3
  63. package/template/apps/vite/Caddyfile +0 -22
package/CHANGELOG.md CHANGED
@@ -2,17 +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
+
5
19
  ## 0.37.0
6
20
 
7
- - Add MCP server and agent skill (#110).
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.
8
33
  - Still on Starter `v0.24.1`.
9
34
 
10
35
  ## 0.36.0
11
36
 
12
- - Bump vitest from 5.0.3 to 5.0.5 in the minor-and-patch group (#104).
13
- - Bump giget from 2.0.0 to 3.3.1 (#69).
14
- - Bump @types/node from 22.20.5 to 26.6.5 (#68).
15
- - 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).
16
39
 
17
40
  ## 0.35.0
18
41
 
package/README.md CHANGED
@@ -29,9 +29,9 @@ pnpm dlx @clubedge/create-clubedge-app my-app
29
29
 
30
30
  ## MCP server
31
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.
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
33
 
34
- After publishing a release that includes the MCP server, run it from the workspace where projects should be created:
34
+ Run it from the workspace where projects should be created:
35
35
 
36
36
  ```sh
37
37
  npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp
@@ -50,7 +50,7 @@ The MCP server is included in the same package and uses the CLI's bundled Starte
50
50
 
51
51
  ### Agent skill
52
52
 
53
- The npm package includes the Agent Skill at [`skills/clubedge-starter/SKILL.md`](skills/clubedge-starter/SKILL.md). After the release is published, download and extract the package in PowerShell:
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
54
 
55
55
  ```powershell
56
56
  $skillTarball = npm pack @clubedge/create-clubedge-app@latest
@@ -69,7 +69,7 @@ New-Item -ItemType Directory -Force "$HOME/.claude/skills/clubedge-starter" | Ou
69
69
  Copy-Item .\package\skills\clubedge-starter\SKILL.md "$HOME/.claude/skills/clubedge-starter/SKILL.md"
70
70
  ```
71
71
 
72
- These commands create `package/` in the current directory. Remove that extracted folder and tarball when the copy is done. Before publication, the same copy commands work from the repository root by replacing `package/skills/...` with `skills/...`.
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
73
 
74
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
75
 
@@ -102,7 +102,7 @@ pnpm dev
102
102
 
103
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.
104
104
 
105
- 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`.
106
106
 
107
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.
108
108
 
@@ -141,7 +141,7 @@ They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend c
141
141
 
142
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.
143
143
 
144
- 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.
145
145
 
146
146
  ## Choosing modules
147
147
 
@@ -153,8 +153,8 @@ Authentication, file storage, caching, email, payments, background jobs, observa
153
153
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
154
154
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
155
155
  | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
156
- | `--payments` | `stripe`, `none` (needs an auth module) | `none` |
157
- | `--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` |
158
158
  | `--observability` | `otel` (OpenTelemetry, to any OTLP collector), `sentry`, `none` | `none` |
159
159
  | `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
160
160
  | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `neon` (a free hosted Neon database), `none` | `docker` |
@@ -207,7 +207,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
207
207
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
208
208
 
209
209
  # Reproduce a specific Starter release or commit
210
- 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
211
211
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
212
212
 
213
213
  # Test local Starter changes before releasing them
@@ -226,7 +226,7 @@ pnpm clubedge add email resend payments stripe # several modules as
226
226
  pnpm clubedge remove storage payments
227
227
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
228
228
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
229
- 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
230
230
  ```
231
231
 
232
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.
@@ -234,7 +234,7 @@ The CLI renders the project twice, from the Starter commit and modules recorded
234
234
  - Files you never changed are updated, added, or deleted.
235
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.
236
236
  - Your own files are never touched.
237
- - `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.
238
238
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
239
239
 
240
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`.
@@ -252,7 +252,7 @@ pnpm db:migrate
252
252
  pnpm dev
253
253
  ```
254
254
 
255
- 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:
256
256
 
257
257
  ```sh
258
258
  pnpm clubedge neon # where it stands and how long it has left
@@ -273,7 +273,7 @@ pnpm clubedge status # Starter release, modules, Git state, and whether a new
273
273
  pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and pnpm check:services
274
274
  ```
275
275
 
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 `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.
277
277
 
278
278
  ## Try it on a Railway VM (experimental)
279
279
 
@@ -302,7 +302,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
302
302
  pnpm clubedge deploy --dry-run # show the plan, create nothing
303
303
  ```
304
304
 
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 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.
306
306
 
307
307
  ### Deploy to Fly.io (experimental)
308
308
 
@@ -328,7 +328,7 @@ In a project that deploys to Vercel or Railway, `pnpm clubedge deploy --preview`
328
328
  NEON_API_KEY=... NEON_PROJECT_ID=... pnpm clubedge deploy --preview
329
329
  ```
330
330
 
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.
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.
332
332
 
333
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.
334
334
 
@@ -337,27 +337,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
337
337
  Each CLI release is tested against one specific Starter release:
338
338
 
339
339
  ```text
340
- create-clubedge-app v0.37.0
340
+ create-clubedge-app v0.38.0
341
341
  |
342
342
  v
343
- Clubedge Starter v0.24.1
344
- commit 3e8b5ba8910767f27336cf253fbfe92a9bb42875
343
+ Clubedge Starter v0.24.2
344
+ commit 4450d2f00a89f1a3929a5c9d2c1541e0d65df891
345
345
  ```
346
346
 
347
347
  The current relationship is:
348
348
 
349
349
  | CLI release | Default Starter release | Meaning |
350
350
  | --- | --- | --- |
351
- | `create-clubedge-app@0.37.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 |
352
352
 
353
353
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
354
354
 
355
- The `v0.24.1` tag is expected to resolve to commit
356
- `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
357
357
  scaffolding with the default reference and fails rather than silently using a
358
358
  moved tag.
359
359
 
360
- 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.
361
361
 
362
362
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
363
363
 
@@ -412,7 +412,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
412
412
  1. Make and validate changes in `clubedge-starter`.
413
413
  2. Run lint, typecheck, unit tests, browser tests, and production build.
414
414
  3. Update the Starter package versions.
415
- 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.
416
416
 
417
417
  ### CLI maintainers
418
418
 
@@ -457,10 +457,10 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
457
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 |
458
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`) |
459
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 |
460
- | 0.35.0 | v0.24.0 | Deploy frontend and API stack packs; Default to Starter v0.24.0 |
461
- | 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 |
462
- | 0.37.0 | v0.24.1 | Add MCP server and agent skill |
463
- | 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 |
464
464
 
465
465
  ## Community
466
466
 
@@ -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
  }