@clubedge/create-clubedge-app 0.41.1 → 0.42.1

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 (198) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +56 -15
  3. package/dist/core/deploy.js +276 -50
  4. package/dist/core/env.js +10 -1
  5. package/dist/core/manifest-fields.js +1 -1
  6. package/dist/core/manifest-packs.js +23 -11
  7. package/dist/core/manifest.js +6 -4
  8. package/dist/core/plan.js +34 -13
  9. package/dist/core/railway.js +11 -0
  10. package/dist/deploy-docker.js +88 -0
  11. package/dist/deploy-fly.js +3 -2
  12. package/dist/deploy-netlify.js +3 -3
  13. package/dist/deploy-preview.js +13 -5
  14. package/dist/deploy-render.js +3 -3
  15. package/dist/deploy-vercel.js +3 -3
  16. package/dist/deploy.js +8 -25
  17. package/dist/index.js +2 -2
  18. package/dist/mcp-server.js +3 -2
  19. package/package.json +3 -3
  20. package/skills/clubedge-starter/SKILL.md +1 -1
  21. package/template/.env.example +8 -0
  22. package/template/.github/workflows/ci.yml +83 -0
  23. package/template/.github/workflows/deploy.yml +0 -32
  24. package/template/.prettierignore +5 -0
  25. package/template/AGENTS.md +1 -0
  26. package/template/README.md +16 -0
  27. package/template/SETUP.md +111 -3
  28. package/template/_gitignore +4 -0
  29. package/template/apps/api/.env.example +14 -0
  30. package/template/apps/api/package.json +1 -1
  31. package/template/apps/api/src/app.test.ts +5 -0
  32. package/template/apps/api/src/app.ts +9 -2
  33. package/template/apps/api/src/env/production.test.ts +29 -0
  34. package/template/apps/api/src/env/production.ts +10 -0
  35. package/template/apps/api/src/rate-limit.test.ts +44 -5
  36. package/template/apps/api/src/rate-limit.ts +19 -4
  37. package/template/apps/api/src/routes/app-version.test.ts +35 -0
  38. package/template/apps/api/src/routes/app-version.ts +16 -0
  39. package/template/apps/api/src/routes/client-errors.test.ts +2 -0
  40. package/template/apps/api/src/routes/client-errors.ts +4 -1
  41. package/template/apps/api/src/server/app-version.test.ts +67 -0
  42. package/template/apps/api/src/server/app-version.ts +57 -0
  43. package/template/apps/api/src/server/index.ts +5 -0
  44. package/template/apps/api/src/server/variants/auth.better-auth.ts +10 -3
  45. package/template/apps/api-fastapi/.env.example +14 -0
  46. package/template/apps/api-fastapi/package.json +1 -1
  47. package/template/apps/api-fastapi/pyproject.toml +1 -1
  48. package/template/apps/api-fastapi/src/clubedge_api/__main__.py +3 -0
  49. package/template/apps/api-fastapi/src/clubedge_api/app.py +10 -1
  50. package/template/apps/api-fastapi/src/clubedge_api/app_version.py +59 -0
  51. package/template/apps/api-fastapi/src/clubedge_api/contract.py +11 -0
  52. package/template/apps/api-fastapi/src/clubedge_api/production_env.py +9 -0
  53. package/template/apps/api-fastapi/src/clubedge_api/rate_limit.py +47 -16
  54. package/template/apps/api-fastapi/src/clubedge_api/routes/app_version.py +26 -0
  55. package/template/apps/api-fastapi/src/clubedge_api/routes/client_errors.py +6 -2
  56. package/template/apps/api-fastapi/src/clubedge_api/server.py +3 -0
  57. package/template/apps/api-fastapi/src/clubedge_api/settings.py +37 -0
  58. package/template/apps/api-fastapi/src/clubedge_api/variants/auth_better_auth.py +7 -4
  59. package/template/apps/api-fastapi/tests/test_app_version.py +100 -0
  60. package/template/apps/api-fastapi/tests/test_client_errors.py +3 -1
  61. package/template/apps/api-fastapi/tests/test_production_env.py +26 -0
  62. package/template/apps/api-fastapi/tests/test_rate_limit.py +80 -8
  63. package/template/apps/api-fastapi/uv.lock +1 -1
  64. package/template/apps/capacitor/.env.example +16 -0
  65. package/template/apps/capacitor/assets/logo.svg +1 -0
  66. package/template/apps/capacitor/capacitor.config.ts +26 -0
  67. package/template/apps/capacitor/clubedge.stack.json +7 -0
  68. package/template/apps/capacitor/package.json +25 -0
  69. package/template/apps/capacitor/scripts/build.mjs +103 -0
  70. package/template/apps/capacitor/scripts/icons.mjs +106 -0
  71. package/template/apps/capacitor/tsconfig.json +4 -0
  72. package/template/apps/start/.env.example +7 -0
  73. package/template/apps/start/clubedge.stack.json +2 -0
  74. package/template/apps/start/package.json +1 -1
  75. package/template/apps/start/public/.well-known/logo.svg +1 -1
  76. package/template/apps/start/public/apple-touch-icon.png +0 -0
  77. package/template/apps/start/public/favicon.ico +0 -0
  78. package/template/apps/start/public/favicon.svg +1 -0
  79. package/template/apps/start/src/components/auth/auth-page.tsx +1 -1
  80. package/template/apps/start/src/components/dashboard/dashboard-shell.tsx +1 -1
  81. package/template/apps/start/src/env/production.test.ts +8 -0
  82. package/template/apps/start/src/env/production.ts +7 -0
  83. package/template/apps/start/src/env/server.test.ts +12 -0
  84. package/template/apps/start/src/env/server.ts +12 -0
  85. package/template/apps/start/src/nitro/plugins/client-ip.ts +10 -0
  86. package/template/apps/start/src/routes/__root.tsx +3 -1
  87. package/template/apps/start/src/routes/api/client-errors.ts +4 -2
  88. package/template/apps/start/src/routes/index.tsx +1 -1
  89. package/template/apps/start/src/routes/login.tsx +1 -1
  90. package/template/apps/start/src/server/auth-forms.test.ts +8 -2
  91. package/template/apps/start/src/server/auth-forms.ts +3 -2
  92. package/template/apps/start/src/server/client-ip.ts +13 -0
  93. package/template/apps/vite/.env.example +15 -0
  94. package/template/apps/vite/Dockerfile +1 -0
  95. package/template/apps/vite/auth.server.mjs +15 -5
  96. package/template/apps/vite/client-ip.mjs +63 -0
  97. package/template/apps/vite/clubedge.stack.json +10 -1
  98. package/template/apps/vite/dev.mjs +5 -1
  99. package/template/apps/vite/index.html +5 -2
  100. package/template/apps/vite/package.json +2 -1
  101. package/template/apps/vite/public/.well-known/logo.svg +1 -1
  102. package/template/apps/vite/public/apple-touch-icon.png +0 -0
  103. package/template/apps/vite/public/favicon.ico +0 -0
  104. package/template/apps/vite/public/favicon.svg +1 -0
  105. package/template/apps/vite/server.mjs +110 -4
  106. package/template/apps/vite/src/components/billing/billing-card.tsx +5 -0
  107. package/template/apps/vite/src/components/branding/clubedge-mark.tsx +5 -1
  108. package/template/apps/vite/src/components/dashboard/dashboard-shell.tsx +1 -1
  109. package/template/apps/vite/src/lib/api.ts +8 -1
  110. package/template/apps/vite/src/lib/public-origin.ts +5 -0
  111. package/template/apps/vite/src/main.tsx +14 -0
  112. package/template/apps/vite/src/routes/dashboard.tsx +2 -1
  113. package/template/apps/vite/src/routes/forgot-password.tsx +3 -1
  114. package/template/apps/vite/src/routes/index.tsx +1 -1
  115. package/template/apps/vite/src/routes/login.tsx +3 -1
  116. package/template/apps/vite/src/styles.css +43 -0
  117. package/template/apps/vite/src/variants/api.better-auth.ts +13 -1
  118. package/template/apps/vite/src/variants/api.supabase.ts +5 -0
  119. package/template/apps/vite/src/variants/auth.appwrite.ts +2 -1
  120. package/template/apps/vite/src/variants/auth.better-auth.ts +37 -2
  121. package/template/apps/vite/src/variants/auth.firebase.ts +10 -0
  122. package/template/apps/vite/src/variants/auth.supabase.ts +22 -2
  123. package/template/apps/vite/vite.config.ts +14 -1
  124. package/template/apps/web/clubedge.stack.json +0 -2
  125. package/template/apps/web/package.json +1 -1
  126. package/template/apps/web/public/.well-known/logo.svg +1 -1
  127. package/template/apps/web/src/app/actions/auth.ts +4 -1
  128. package/template/apps/web/src/app/api/client-errors/route.ts +5 -1
  129. package/template/apps/web/src/app/apple-icon.png +0 -0
  130. package/template/apps/web/src/app/dashboard/_components/dashboard-shell.tsx +1 -1
  131. package/template/apps/web/src/app/favicon.ico +0 -0
  132. package/template/apps/web/src/app/icon.svg +1 -0
  133. package/template/apps/web/src/app/login/_components/auth-page.tsx +1 -1
  134. package/template/apps/web/src/app/login/page.tsx +1 -1
  135. package/template/apps/web/src/app/page.tsx +1 -1
  136. package/template/apps/web/src/env/production.test.ts +8 -0
  137. package/template/apps/web/src/env/production.ts +7 -0
  138. package/template/apps/web/src/env/server.test.ts +18 -0
  139. package/template/apps/web/src/env/server.ts +12 -0
  140. package/template/apps/web/src/instrumentation.ts +3 -0
  141. package/template/clubedge/modules/deploy.json +7 -5
  142. package/template/clubedge.template.json +25 -2
  143. package/template/docker-compose.yml +3 -0
  144. package/template/eslint.config.mjs +15 -0
  145. package/template/fly.toml +5 -0
  146. package/template/package.json +2 -2
  147. package/template/packages/auth/package.json +1 -1
  148. package/template/packages/auth-appwrite/package.json +1 -1
  149. package/template/packages/auth-better-auth/package.json +1 -1
  150. package/template/packages/auth-firebase/package.json +1 -1
  151. package/template/packages/auth-supabase/package.json +1 -1
  152. package/template/packages/auth-supabase/src/browser.ts +12 -4
  153. package/template/packages/cache/package.json +1 -1
  154. package/template/packages/cache-redis/package.json +1 -1
  155. package/template/packages/contract/openapi.yaml +42 -0
  156. package/template/packages/contract/package.json +1 -1
  157. package/template/packages/contract/src/conformance.ts +26 -0
  158. package/template/packages/contract/src/index.ts +1 -0
  159. package/template/packages/contract/src/schema.ts +51 -0
  160. package/template/packages/core/package.json +1 -1
  161. package/template/packages/core/src/client-ip.test.ts +67 -7
  162. package/template/packages/core/src/client-ip.ts +57 -8
  163. package/template/packages/core/src/index.ts +6 -1
  164. package/template/packages/db/package.json +1 -1
  165. package/template/packages/email/package.json +1 -1
  166. package/template/packages/email-resend/package.json +1 -1
  167. package/template/packages/email-smtp/package.json +2 -2
  168. package/template/packages/jobs/package.json +1 -1
  169. package/template/packages/jobs-bullmq/package.json +1 -1
  170. package/template/packages/jobs-inngest/package.json +1 -1
  171. package/template/packages/jobs-pg-boss/package.json +1 -1
  172. package/template/packages/local-db/package.json +1 -1
  173. package/template/packages/mobile/package.json +37 -0
  174. package/template/packages/mobile/src/above-page.tsx +9 -0
  175. package/template/packages/mobile/src/demo/api.ts +84 -0
  176. package/template/packages/mobile/src/demo/auth.ts +35 -0
  177. package/template/packages/mobile/src/demo/banner.tsx +15 -0
  178. package/template/packages/mobile/src/demo/demo.test.ts +83 -0
  179. package/template/packages/mobile/src/demo/state.ts +18 -0
  180. package/template/packages/mobile/src/index.test.ts +55 -0
  181. package/template/packages/mobile/src/index.ts +33 -0
  182. package/template/packages/mobile/src/update-notice.test.ts +35 -0
  183. package/template/packages/mobile/src/update-notice.tsx +88 -0
  184. package/template/packages/mobile/src/version.test.ts +65 -0
  185. package/template/packages/mobile/src/version.ts +37 -0
  186. package/template/packages/mobile/tsconfig.json +7 -0
  187. package/template/packages/observability/package.json +1 -1
  188. package/template/packages/observability-otel/package.json +1 -1
  189. package/template/packages/observability-sentry/package.json +1 -1
  190. package/template/packages/payments/package.json +1 -1
  191. package/template/packages/payments-stripe/package.json +1 -1
  192. package/template/packages/storage/package.json +1 -1
  193. package/template/packages/storage-s3/package.json +1 -1
  194. package/template/packages/storage-supabase/package.json +1 -1
  195. package/template/packages/ui/package.json +1 -1
  196. package/template/pnpm-lock.yaml +1979 -973
  197. package/template/render.yaml +5 -0
  198. package/template.lock.json +41 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.42.1
6
+
7
+ - Default to Starter `v0.27.1` (#140): new projects carry the Clubedge Starter brand, and their mobile app shows it.
8
+ - Each web app's logo is the new mark, shown whole: the pages no longer round its corners. Each app also has `favicon.ico`, a new `favicon.svg`, and an Apple touch icon.
9
+ - The mobile app shows the logo on its pages. The logo was in a `.well-known` folder, which Android leaves out of an app, so the Vite app now bundles it.
10
+ - The mobile app's icons and splash screens are made from `apps/shell/assets/logo.svg` instead of Capacitor's default.
11
+ - Upgrading: a logo you replaced comes back as a conflict in `logo.svg`, so keep yours. The [Starter's release notes](https://github.com/Clubedge/clubedge-starter/releases/tag/v0.27.1) cover replaced favicons and existing `android/` and `ios/` projects.
12
+
13
+ ## 0.42.0
14
+
15
+ - **Mobile apps with Capacitor**: default to Starter `v0.27.0` (#138). `--framework vite --api hono --shell capacitor` (or `--api fastapi`) packages the Vite frontend as an Android and iOS app in `apps/shell` (#135):
16
+ - Template schema 5 adds the `shell` role: a stack that packages a frontend's static build and runs no server, so it has no Dockerfile or deployment, and `deploy` leaves it out. Older CLIs refuse a schema 5 Starter and ask for an update.
17
+ - `--shell` is refused with Next.js and TanStack Start, which render on their own server. Interactive runs ask about a mobile app only after a frontend and its API.
18
+ - `apps/shell/.env.local` is created from its example (`WEB_URL`, the website the app calls), and `add`, `remove`, and `upgrade` keep its values.
19
+ - A variant file's `clubedge:if` blocks are kept or dropped for the selection where the variant lands, like other conditional files'. Before, they stayed in the project.
20
+ - MCP: `get_starter_capabilities` lists each slot's question, and the tools describe what each role pairs with.
21
+ - CI generates a mobile project from the pinned Starter.
22
+ - **Client addresses per platform** (#136, #137): `deploy` tells the app where each client's address is, for Starter `v0.27.0`'s rate limits:
23
+ - `CLIENT_IP_HEADER`: `x-real-ip` on Railway and Vercel, `cf-connecting-ip` on Render (from the Cloudflare edge in front of it), `fly-client-ip` on Fly.io, `x-nf-client-connection-ip` on Netlify.
24
+ - `TRUSTED_PROXY_HOPS=1` behind Caddy with Docker Compose, and on a frontend's API, which only the frontend's server reaches.
25
+ - Previews and `try` set them too, and the env file's settings replace both defaults.
26
+ - **Docker Compose behind Caddy, with images on GHCR** (#136): `deploy` with the docker option now works for full-stack apps too, and writes:
27
+ - `compose.yaml`: each app's image (pulled from `IMAGE_PREFIX` at `TAG`, or built with `--build`), behind Caddy, the only service that publishes ports, with automatic HTTPS for `DOMAIN`.
28
+ - A `Caddyfile`.
29
+ - `.github/workflows/release-images.yml`: each published release builds every app for linux/amd64 and linux/arm64 and pushes it to the repository's container registry, tagged with the version, `<major>.<minor>`, and `latest`.
30
+ - Running `deploy` again writes only the files that are missing and never overwrites one you changed. A frontend and API's `compose.yaml` from an earlier deploy stops it until you move that file away.
31
+ - `upgrade` keeps the blank line after a section the Starter adds to the local env file between two others (#138).
32
+
5
33
  ## 0.41.1
6
34
 
7
35
  - Default to Starter `v0.26.1` (#130): new projects' weekly upgrade workflow opens its pull request when the upgrade leaves conflict markers in a `package.json`, listing them, instead of stopping without one. It runs `upgrade --detailed-exit-code`, from 0.41.0. Existing projects get the workflow through their next upgrade pull request.
package/README.md CHANGED
@@ -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` (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`.
105
+ The generated project includes `apps/web/.env.local` (and `apps/api/.env.local` with a separate API, `apps/shell/.env.local` with a mobile app), 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,22 @@ 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
- With the Hono API, the decoupled stacks support every module option the full-stack frameworks do (since Starter v0.26.1), each running in the API with the matching UI in the frontend; with FastAPI, they support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache. Both work with 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.
144
+ With the Hono API, the decoupled stacks support every module option the full-stack frameworks do (since Starter v0.27.0), each running in the API with the matching UI in the frontend; with FastAPI, they support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache. Both work with 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 runs the frontend and API as separate services behind Caddy, which serves the frontend over HTTPS while the API stays on the Compose network (see [Deploy with Docker Compose](#deploy-with-docker-compose)); copy the variables of `.env.example` and `apps/api/.env.example` to `.env.production` and fill in their values. 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
+
146
+ ### A mobile app
147
+
148
+ Since CLI 0.42.0, with Starter v0.27.0, the Vite frontend can also be packaged as an Android and iOS app with [Capacitor](https://capacitorjs.com). Earlier Starters have no mobile shell, and the CLI refuses `--shell` with them.
149
+
150
+ ```sh
151
+ pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono --shell capacitor
152
+ ```
153
+
154
+ | Flag | App | Where |
155
+ | ------------------- | --------------------------------------------------------- | ------------ |
156
+ | `--shell capacitor` | The Vite app, packaged for Android and iOS with Capacitor | `apps/shell` |
157
+ | `--shell none` | No mobile app (the default) | |
158
+
159
+ A shell packages the frontend's static files, so it needs `--framework vite`, and the frontend still needs its API (`--api hono` or `--api fastapi`). Next.js and TanStack Start render their pages on their own server, so they cannot be packaged: the CLI refuses `--shell` with them, and interactive runs only ask about a mobile app after a frontend and its API. The shell runs no server, so `deploy` deploys the frontend and the API as above and leaves it out.
145
160
 
146
161
  ## Choosing modules
147
162
 
@@ -156,7 +171,7 @@ Authentication, file storage, caching, email, payments, background jobs, observa
156
171
  | `--payments` | `stripe` (needs an auth module), `none` | `none` |
157
172
  | `--jobs` | `pg-boss`, `bullmq` (needs the `redis` cache), `inngest`, `none` | `none` |
158
173
  | `--observability` | `otel` (OpenTelemetry, to any OTLP collector), `sentry`, `none` | `none` |
159
- | `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
174
+ | `--deploy` | `docker` (any container host; `deploy` sets up Docker Compose with Caddy), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
160
175
  | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `neon` (a free hosted Neon database), `none` | `docker` |
161
176
 
162
177
  ```sh
@@ -181,6 +196,7 @@ Usage:
181
196
  Options:
182
197
  --framework <id> App framework, such as next or tanstack-start (or vite, a frontend)
183
198
  --api <id> A separate API for a frontend, such as hono or fastapi
199
+ --shell <id> A mobile app that packages a frontend, such as capacitor
184
200
  --<module> <option> A module option, such as --auth none; the Starter lists its modules
185
201
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
186
202
  (upgrade: the Starter to upgrade to)
@@ -207,7 +223,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
207
223
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
208
224
 
209
225
  # Reproduce a specific Starter release or commit
210
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.26.1
226
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.27.1
211
227
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
212
228
 
213
229
  # Test local Starter changes before releasing them
@@ -226,7 +242,7 @@ pnpm clubedge add email resend payments stripe # several modules as
226
242
  pnpm clubedge remove storage payments
227
243
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
228
244
  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.26.1 # or a specific tag, branch, or commit
245
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.27.1 # or a specific tag, branch, or commit
230
246
  ```
231
247
 
232
248
  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 +250,7 @@ The CLI renders the project twice, from the Starter commit and modules recorded
234
250
  - Files you never changed are updated, added, or deleted.
235
251
  - 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
252
  - Your own files are never touched.
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.
253
+ - Each app's local env file (`apps/web/.env.local`, and `apps/api/.env.local` or `apps/shell/.env.local` when the project has them) keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
238
254
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
239
255
 
240
256
  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`.
@@ -324,6 +340,30 @@ In a project with `--deploy netlify`, `pnpm clubedge deploy` uses [Netlify's CLI
324
340
 
325
341
  In a project with `--deploy render`, `pnpm clubedge deploy` uses Render's API. Render builds the Dockerfile from your Git repository, so push the project to GitHub, GitLab, or Bitbucket first, with Render's access to it granted in the dashboard; `deploy` warns about commits that aren't pushed. It signs in with an API key: `RENDER_API_KEY`, or else it opens the dashboard's API keys page and asks you to paste one. It creates or reuses a web service named after the package, on the Docker runtime and a starter instance (pre-deploy commands need a paid type), which runs `node /app/migrate.mjs` before each release, checks `/api/health`, and deploys again on each push, as `render.yaml` describes. `DATABASE_URL` comes from the env file (Render Postgres, Neon, Supabase, ...). It sets the production variables: the env file's values, the service's `*.onrender.com` URL, and a generated `BETTER_AUTH_SECRET` that later runs keep. Then it starts a deploy and waits until it is live. When Render names the app something other than `https://<service>.onrender.com`, `deploy` sets that URL and deploys again, since the build compiles it in. Running it again reuses the service. Render bills the instance on your plan.
326
342
 
343
+ ### Deploy with Docker Compose
344
+
345
+ In a project with `--deploy docker` (the default), `pnpm clubedge deploy` prepares the project for a server of your own and creates nothing anywhere else. It writes three files to commit:
346
+
347
+ - `compose.yaml` runs each app's image behind [Caddy](https://caddyserver.com), the only service that publishes ports (80, and 443 over TCP and UDP): it serves `DOMAIN` over HTTPS, with certificates it obtains and renews by itself. A full-stack app (Next.js or TanStack Start) is one service, `web`, built from the root `Dockerfile`; a frontend and its API are two, the frontend `web`, which Caddy serves, and `api`, which stays on the Compose network behind the frontend's `/api` proxy. Each service has an image, `${IMAGE_PREFIX:-<project>}-<service>:${TAG:-latest}`, and its build: `docker compose up -d` pulls the released images when `IMAGE_PREFIX` points at GitHub's registry, and `--build` builds them on the server. The public URL defaults to `https://$DOMAIN`: Next.js also gets it as a build argument, and Better Auth on a Vite frontend as `BETTER_AUTH_URL`.
348
+ - `Caddyfile`: every request for `DOMAIN` goes to the public app.
349
+ - `.github/workflows/release-images.yml`: each published GitHub release builds every app's image for `linux/amd64` and `linux/arm64` and pushes it to `ghcr.io/<owner>/<repository>-<service>`, tagged with its version (`1.2.3`), `1.2`, and `latest`; run by hand on the default branch, it pushes `edge`. It never runs for tag pushes, so a deleted tag leaves no images behind. The values an image compiles in come from repository variables: `NEXT_PUBLIC_APP_URL` for Next.js, the `VITE_*` values for a Vite frontend.
350
+
351
+ Then point the domain's DNS A and AAAA records at the server, publish a release, and on the server, next to `compose.yaml`, the `Caddyfile`, and `.env.production` (the whole project to build there):
352
+
353
+ ```sh
354
+ # .env.production: DOMAIN=example.com, the app's variables, and, to run the released images,
355
+ # IMAGE_PREFIX=ghcr.io/<owner>/<repository> (TAG=1.2.3 pins a release; latest otherwise)
356
+ docker login ghcr.io # while the packages are private: a token with read:packages
357
+ docker compose --env-file .env.production run --rm web node /app/migrate.mjs # api for a frontend and its API
358
+ docker compose --env-file .env.production up -d # --build builds the images on the server
359
+ ```
360
+
361
+ `deploy` prints these steps, with the `IMAGE_PREFIX` of your repository when `origin` is on GitHub. To update, publish a release, then on the server `docker compose --env-file .env.production pull` (or set `TAG` to the new version), run the migrations, and `up -d` again. Running `deploy` again writes the files that are missing and never overwrites one: it keeps a `Caddyfile` or workflow you changed, and says so, and stops when `compose.yaml` differs from the one it writes, so move yours away to get the new one. Compose prefers `compose.yaml` to the Starter's `docker-compose.yml`, so run the local one by name: `docker compose -f docker-compose.yml up --build`, which `pnpm docker:dev` does since Starter v0.27.1.
362
+
363
+ ### Client addresses
364
+
365
+ Rate limits and logs count each visitor by address, which the platform's proxies hide. `deploy` tells the public service where to find it: `CLIENT_IP_HEADER`, the header the platform's edge sets to the client's address, replacing any copy the client sent (`x-real-ip` on Railway and Vercel, `cf-connecting-ip` on Render, from the Cloudflare edge in front of it, `fly-client-ip` on Fly.io, `x-nf-client-connection-ip` on Netlify), or, with Docker Compose, `TRUSTED_PROXY_HOPS=1`: Caddy is the one proxy of your own that sets `X-Forwarded-For`, and the app reads the entry that many places from the right. A frontend's API, reached only through the frontend's server, which sends on the one address it found, gets `TRUSTED_PROXY_HOPS=1` and never `CLIENT_IP_HEADER`. Previews get the same as production, and `try` sets `x-real-ip` on its Railway VM. Either setting in the env file replaces both defaults, for another proxy in front. Unset, the app takes the connection's address, which is right only when nothing stands in front of it. Neither is ever reported as unset, and projects from Starters that predate them ignore them.
366
+
327
367
  ### Preview deployments
328
368
 
329
369
  In a project that deploys to Vercel or Railway, `pnpm clubedge deploy --preview` deploys the current Git branch next to production, after a first `deploy`:
@@ -341,27 +381,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
341
381
  Each CLI release is tested against one specific Starter release:
342
382
 
343
383
  ```text
344
- create-clubedge-app v0.41.1
384
+ create-clubedge-app v0.42.1
345
385
  |
346
386
  v
347
- Clubedge Starter v0.26.1
348
- commit 93429784e265c9dc8eb8f8c87169e7b42732719f
387
+ Clubedge Starter v0.27.1
388
+ commit 997d71d74775352d30dc2bb25017f46ef43f4fd8
349
389
  ```
350
390
 
351
391
  The current relationship is:
352
392
 
353
393
  | CLI release | Default Starter release | Meaning |
354
394
  | --- | --- | --- |
355
- | `create-clubedge-app@0.41.1` | `clubedge-starter@v0.26.1` | The CLI's required, tested default |
395
+ | `create-clubedge-app@0.42.1` | `clubedge-starter@v0.27.1` | The CLI's required, tested default |
356
396
 
357
397
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
358
398
 
359
- The `v0.26.1` tag is expected to resolve to commit
360
- `93429784e265c9dc8eb8f8c87169e7b42732719f`. The CLI verifies this before
399
+ The `v0.27.1` tag is expected to resolve to commit
400
+ `997d71d74775352d30dc2bb25017f46ef43f4fd8`. The CLI verifies this before
361
401
  scaffolding with the default reference and fails rather than silently using a
362
402
  moved tag.
363
403
 
364
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.26.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.
404
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.27.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.
365
405
 
366
406
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
367
407
 
@@ -369,7 +409,7 @@ Every generated project records its CLI version, Starter repository, Starter ref
369
409
 
370
410
  Each CLI release bundles one Starter release. When the release is built, `scripts/bundle-template.mjs` clones the pinned Starter tag, verifies that it resolves to the pinned commit, and copies its tracked files into the package (`template/` and `template.lock.json`). Running the CLI then needs no GitHub access for the default Starter.
371
411
 
372
- The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, the root `package.json` scripts it sets, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
412
+ The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, the root `package.json` scripts it sets, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. Schema 4 moves each app's description into its own stack pack, placed in a slot (`--framework`, `--api`), and schema 5 adds the shell role for a mobile app (`--shell`); see [docs/stack-packs.md](docs/stack-packs.md). The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
373
413
 
374
414
  The CLI first builds a plan (the files to copy and the edits to make), which `--dry-run` prints, and then applies it. The source is organized the same way:
375
415
 
@@ -416,7 +456,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
416
456
  1. Make and validate changes in `clubedge-starter`.
417
457
  2. Run lint, typecheck, unit tests, browser tests, and production build.
418
458
  3. Update the Starter package versions.
419
- 4. Commit `main`, create a release tag such as `v0.26.1`, and publish the Starter release.
459
+ 4. Commit `main`, create a release tag such as `v0.27.1`, and publish the Starter release.
420
460
 
421
461
  ### CLI maintainers
422
462
 
@@ -468,6 +508,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
468
508
  | 0.39.0 | v0.25.0 | Starter v0.25.0: every module on React + Vite with Hono, and account settings on React + Vite |
469
509
  | 0.40.0 | v0.26.0 | Starter v0.26.0: every module on React + Vite with FastAPI (pg-boss excepted) |
470
510
  | 0.41.0 | v0.26.0 | A conflicted package.json no longer breaks later commands (`--detailed-exit-code`); doctor finds conflict markers; projects own their version and README |
511
+ | 0.42.0 | v0.27.0 | Starter v0.27.0: mobile apps with Capacitor (`--shell capacitor`, template schema 5); client addresses per platform; Docker Compose behind Caddy, with images on GHCR |
471
512
 
472
513
  ## Community
473
514
 
@@ -11,40 +11,39 @@ export function serviceName(packageName) {
11
11
  return name || "web";
12
12
  }
13
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.
14
+ * Where the app finds a request's client address: CLIENT_IP_HEADER, a header the platform's edge
15
+ * sets to it (overwriting any copy the client sent), or TRUSTED_PROXY_HOPS, the number of proxies
16
+ * of your own that append to X-Forwarded-For (the app reads the entry that many places from the
17
+ * right). Unset, the app is exposed directly and takes the socket's address. Both have safe
18
+ * defaults, so deploy never reports them as unset.
18
19
  */
19
- export function dockerComposeSource(services, frontendKeys = []) {
20
- const api = Object.values(services).find((service) => service.role === "api");
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]) => {
30
- const name = service.id;
31
- const frontend = service.role === "frontend";
32
- const buildArgs = frontend && buildKeys.length
33
- ? `\n args:${mapping(" ", buildKeys.map((key) => [key, fromEnv(key)]))}`
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)}` : "";
40
- const env = frontend && api
41
- ? `\n environment:${mapping(" ", [["API_URL", `http://${api.id}:${api.port}`], ...runtime])}`
42
- : `\n env_file:\n - path: .env.production\n required: false${apiEnv}`;
43
- const ports = frontend ? `\n ports:\n - "${service.port}:${service.port}"` : `\n expose:\n - "${service.port}"`;
44
- const depends = frontend && api ? `\n depends_on:\n - ${api.id}` : "";
45
- return ` ${name}:\n build:\n context: .\n dockerfile: ${service.dockerfile}${buildArgs}${env}${ports}${depends}`;
46
- });
47
- return `# Generated from Clubedge stack descriptors. Build and start every app service together.\nservices:\n${rows.join("\n")}\n`;
20
+ export const clientAddressKeys = ["CLIENT_IP_HEADER", "TRUSTED_PROXY_HOPS"];
21
+ /** What deploy sets on the public service of each platform. */
22
+ export const clientAddresses = {
23
+ railway: { CLIENT_IP_HEADER: "x-real-ip" },
24
+ // Render documents Cloudflare in front of every service; Cloudflare sets this header to the
25
+ // client's address and refuses requests that send their own. Render's X-Forwarded-For keeps the
26
+ // entries a client sent.
27
+ render: { CLIENT_IP_HEADER: "cf-connecting-ip" },
28
+ fly: { CLIENT_IP_HEADER: "fly-client-ip" },
29
+ vercel: { CLIENT_IP_HEADER: "x-real-ip" },
30
+ netlify: { CLIENT_IP_HEADER: "x-nf-client-connection-ip" },
31
+ // Docker Compose: Caddy, the one proxy in front, sets X-Forwarded-For to the client's address.
32
+ docker: { TRUSTED_PROXY_HOPS: "1" },
33
+ };
34
+ /**
35
+ * An API reached only through its frontend's server, which replaces X-Forwarded-For with the one
36
+ * client address it found: the API reads that entry, never a header a client could send.
37
+ */
38
+ export const behindFrontend = { TRUSTED_PROXY_HOPS: "1" };
39
+ /**
40
+ * A public service's client-address settings: the env file's when it sets either one (they
41
+ * describe one path to the client, so a default for the other would contradict it), else the
42
+ * platform's.
43
+ */
44
+ export function clientAddressVariables(platform, envFile) {
45
+ const own = clientAddressKeys.filter((key) => envFile[key]);
46
+ return own.length ? Object.fromEntries(own.map((key) => [key, envFile[key]])) : { ...platform };
48
47
  }
49
48
  /** The variable each database's URL goes in. */
50
49
  export const databaseVariables = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
@@ -106,18 +105,19 @@ export function parseEnvFile(text) {
106
105
  /**
107
106
  * The service's production variables. The CLI provides what it can: the database URLs (references
108
107
  * to Railway's databases by default), a generated Better Auth secret (unless the service already
109
- * has one), the public URL, and the port. Values from --env-file come next and win over those;
110
- * their database URLs replace the platform's, except local ones (a development env file).
111
- * Everything else the example lists is reported as unset; values the service already has count as set.
108
+ * has one), the public URL, the port, and where the client's address is. Values from --env-file
109
+ * come next and win over those; their database URLs replace the platform's, except local ones (a
110
+ * development env file). Everything else the example lists is reported as unset; values the
111
+ * service already has count as set.
112
112
  */
113
113
  export function planVariables(input) {
114
- const { modules, keys, url, envFile, secret, attached = [], port = appPort } = input;
114
+ const { modules, keys, url, envFile, secret, attached = [], port = appPort, clientAddress = {} } = input;
115
115
  const existing = { ...input.existing, ...Object.fromEntries(attached.map((key) => [key, "(attached)"])) };
116
116
  const external = externalDatabaseUrls(modules, envFile);
117
117
  const databases = input.databases ?? railwayReferences(databasesFor(modules, Object.keys(external)));
118
118
  const provided = { NODE_ENV: "production", PORT: String(port), ...databases };
119
119
  // The public URL under both names: TanStack Start reads APP_URL, Next.js NEXT_PUBLIC_APP_URL.
120
- const set = { ...provided, APP_URL: url, NEXT_PUBLIC_APP_URL: url };
120
+ const set = { ...provided, APP_URL: url, NEXT_PUBLIC_APP_URL: url, ...clientAddressVariables(clientAddress, envFile) };
121
121
  if (modules.auth === "better-auth" && !existing.BETTER_AUTH_SECRET)
122
122
  set.BETTER_AUTH_SECRET = secret();
123
123
  const databaseKeys = [...Object.values(databaseVariables), "DATABASE_URL_DIRECT"];
@@ -137,25 +137,28 @@ export function planVariables(input) {
137
137
  // database the app now uses, or blank it (blank counts as unset).
138
138
  else if (existing.DATABASE_URL_DIRECT)
139
139
  set.DATABASE_URL_DIRECT = set.DATABASE_URL ?? "";
140
- // Optional: without it, migrations and the jobs worker use DATABASE_URL.
141
- const unset = keys.filter((key) => set[key] === undefined && !existing[key] && key !== "DATABASE_URL_DIRECT");
140
+ // Optional: without a direct URL, migrations and the jobs worker use DATABASE_URL, and the
141
+ // client-address settings have safe defaults.
142
+ const optional = ["DATABASE_URL_DIRECT", ...clientAddressKeys];
143
+ const unset = keys.filter((key) => set[key] === undefined && !existing[key] && !optional.includes(key));
142
144
  return { set, unset };
143
145
  }
144
146
  /**
145
147
  * The variables of a frontend and an API deployed as two services. The API gets what planVariables
146
148
  * 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.
149
+ * where the client's address is, the env file's VITE_* build values, and the runtime values its
150
+ * own example lists. With Better Auth the frontend's server runs auth: it gets the API's database,
151
+ * the secret (generated unless the service already has one), and BETTER_AUTH_URL, the public URL
152
+ * the API asks for the session. Each service's unset variables come from its own example.
151
153
  */
152
154
  export function planServicePair(input) {
153
155
  const { modules, frontendKeys, apiKeys, url, existing, envFile, secret, databases, ports } = input;
154
156
  const betterAuth = modules.auth === "better-auth";
155
157
  // A development env file's localhost URL would send sign-in links to this machine.
156
158
  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
+ // The env file's client-address settings are the public frontend's: the API is behind it.
160
+ const apiEnv = Object.fromEntries(Object.entries(envFile).filter(([key]) => !key.startsWith("VITE_") && !clientAddressKeys.includes(key)));
161
+ const api = planVariables({ modules, keys: apiKeys, url, existing: existing.api, envFile: apiEnv, secret, databases, port: ports.api, clientAddress: behindFrontend });
159
162
  // Better Auth's secret belongs to the frontend's server; the API only asks it for the session.
160
163
  if (!apiEnv.BETTER_AUTH_SECRET)
161
164
  delete api.set.BETTER_AUTH_SECRET;
@@ -165,8 +168,8 @@ export function planServicePair(input) {
165
168
  // The image listens on every interface: its Dockerfile sets HOST.
166
169
  api.unset = api.unset.filter((key) => api.set[key] === undefined && key !== "HOST");
167
170
  // 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) };
171
+ const provided = ["API_URL", "PORT", "BETTER_AUTH_URL", ...Object.values(databaseVariables), "DATABASE_URL_DIRECT", ...clientAddressKeys];
172
+ const set = { PORT: String(ports.frontend), ...clientAddressVariables(input.clientAddress, envFile) };
170
173
  for (const [key, value] of Object.entries(envFile)) {
171
174
  if (value && (key.startsWith("VITE_") || (frontendKeys.includes(key) && !provided.includes(key))))
172
175
  set[key] = value;
@@ -179,8 +182,10 @@ export function planServicePair(input) {
179
182
  if (!set.BETTER_AUTH_SECRET && !existing.frontend.BETTER_AUTH_SECRET)
180
183
  set.BETTER_AUTH_SECRET = secret();
181
184
  }
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));
185
+ // Optional: without VITE_API_URL the browser calls the API through the frontend's /api proxy,
186
+ // and the client-address settings have safe defaults.
187
+ const optional = ["API_URL", "VITE_API_URL", ...clientAddressKeys];
188
+ const unset = frontendKeys.filter((key) => set[key] === undefined && !existing.frontend[key] && !optional.includes(key));
184
189
  return { frontend: { set, unset }, api };
185
190
  }
186
191
  /**
@@ -633,3 +638,224 @@ export function parseRenderEnv(body) {
633
638
  }
634
639
  return values;
635
640
  }
641
+ /* ------------------------------------ Docker ------------------------------------ */
642
+ // A server of your own: Docker Compose runs each app's image behind Caddy, which serves the domain
643
+ // over HTTPS, and a GitHub workflow publishes the images to GitHub's container registry.
644
+ /** The app of a full-stack project, whose stack records no service: the root Dockerfile's image. */
645
+ export const fullstackService = {
646
+ id: "web",
647
+ role: "fullstack",
648
+ app: "apps/web",
649
+ dockerfile: "Dockerfile",
650
+ port: appPort,
651
+ healthPath: "/api/health",
652
+ platforms: ["docker"],
653
+ };
654
+ /** The service the public reaches: a frontend, whose API stays private, else the full-stack app. */
655
+ export function publicService(services) {
656
+ const apps = Object.values(services);
657
+ return apps.find(({ role }) => role === "frontend") ?? apps.find(({ role }) => role === "fullstack") ?? fullstackService;
658
+ }
659
+ /**
660
+ * The values an app's image compiles in, which its Dockerfile takes as build arguments, from the
661
+ * variables its env example lists: a Vite frontend's VITE_* values, and Next.js's public URL.
662
+ */
663
+ export function buildArgumentKeys(service, keys) {
664
+ if (service.role === "frontend")
665
+ return keys.filter((key) => key.startsWith("VITE_"));
666
+ return service.role === "fullstack" ? keys.filter((key) => key === "NEXT_PUBLIC_APP_URL") : [];
667
+ }
668
+ /**
669
+ * compose.yaml for a server of your own. Each app runs its image, pulled from IMAGE_PREFIX (where
670
+ * release-images.yml publishes them) at TAG, or built there with --build. Only Caddy publishes
671
+ * ports: it serves DOMAIN over HTTPS, with certificates it obtains and renews. `frontendKeys` are
672
+ * the variables the public app's env example lists: the values its image compiles in become build
673
+ * arguments, and a frontend's others runtime variables, such as Better Auth's when the frontend's
674
+ * server runs auth. Compose reads their values from its --env-file; the public URL defaults to
675
+ * https://DOMAIN.
676
+ */
677
+ export function dockerComposeSource(project, services, frontendKeys = []) {
678
+ const apps = Object.values(services);
679
+ const api = apps.find((service) => service.role === "api");
680
+ const web = publicService(services);
681
+ const fromEnv = (key, fallback = "") => `\${${key}:-${fallback}}`;
682
+ const publicUrl = "https://${DOMAIN}";
683
+ const behindCaddy = clientAddresses.docker;
684
+ const behindWeb = behindFrontend;
685
+ // API_URL is the API's address on the Compose network, PORT the one the image declares, and the
686
+ // client-address settings default to Caddy's.
687
+ const runtimeKeys = frontendKeys.filter((key) => !key.startsWith("VITE_") && !["API_URL", "PORT", ...clientAddressKeys].includes(key));
688
+ // Better Auth runs in the frontend's server: the API asks it for the session over the Compose network.
689
+ const authUrl = api && frontendKeys.includes("BETTER_AUTH_URL") ? `http://${web.id}:${web.port}` : null;
690
+ const environment = (service) => {
691
+ if (service.role === "api") {
692
+ return [
693
+ // The API listens on every interface, whatever HOST the env file has from development.
694
+ " HOST: 0.0.0.0",
695
+ ` # Reached only through ${web.id}, which passes on the client's address as the one X-Forwarded-For entry.`,
696
+ ...clientAddressKeys.map((key) => ` ${key}: ${JSON.stringify(behindWeb[key] ?? "")}`),
697
+ ...(authUrl ? [` BETTER_AUTH_URL: ${authUrl}`] : []),
698
+ ];
699
+ }
700
+ // A frontend has no env_file: CLIENT_IP_HEADER from the env file needs a line of its own.
701
+ const address = [
702
+ " # Caddy, the one proxy in front, sets X-Forwarded-For to the client's address.",
703
+ ...(service.role === "frontend" ? clientAddressKeys : Object.keys(behindCaddy)).map((key) => ` ${key}: ${fromEnv(key, behindCaddy[key])}`),
704
+ ];
705
+ if (service.role !== "frontend")
706
+ return [...appUrlKeys.map((key) => ` ${key}: ${fromEnv(key, publicUrl)}`), ...address];
707
+ return [
708
+ ...(api ? [` API_URL: http://${api.id}:${api.port}`] : []),
709
+ ...address,
710
+ ...runtimeKeys.map((key) => ` ${key}: ${fromEnv(key, key === "BETTER_AUTH_URL" ? publicUrl : "")}`),
711
+ ];
712
+ };
713
+ const rows = apps.flatMap((service) => {
714
+ const args = buildArgumentKeys(service, frontendKeys);
715
+ return [
716
+ ` ${service.id}:`,
717
+ ` image: \${IMAGE_PREFIX:-${project}}-${service.id}:\${TAG:-latest}`,
718
+ " build:",
719
+ " context: .",
720
+ ` dockerfile: ${service.dockerfile}`,
721
+ ...(args.length ? [" args:", ...args.map((key) => ` ${key}: ${fromEnv(key, key === "NEXT_PUBLIC_APP_URL" ? publicUrl : "")}`)] : []),
722
+ // The frontend gets only the values it needs; the API's stay with the API.
723
+ ...(service.role === "frontend" ? [] : [" env_file:", " - path: .env.production", " required: false"]),
724
+ " environment:",
725
+ ...environment(service),
726
+ " expose:",
727
+ ` - "${service.port}"`,
728
+ ...(service.role === "frontend" && api ? [" depends_on:", ` - ${api.id}`] : []),
729
+ " restart: unless-stopped",
730
+ ];
731
+ });
732
+ return [
733
+ "# Generated by create-clubedge-app deploy. Caddy serves the app at DOMAIN over HTTPS, with",
734
+ "# certificates it obtains and renews; the apps listen only on the Compose network. Each app's",
735
+ "# image is pulled from IMAGE_PREFIX at TAG (release-images.yml publishes them), or built here",
736
+ "# with --build. Run it with: docker compose --env-file .env.production up -d",
737
+ "services:",
738
+ ...rows,
739
+ " caddy:",
740
+ " image: caddy:2",
741
+ " restart: unless-stopped",
742
+ " ports:",
743
+ ' - "80:80"',
744
+ ' - "443:443"',
745
+ ' - "443:443/udp"',
746
+ " environment:",
747
+ ' DOMAIN: "${DOMAIN:?Set DOMAIN in .env.production to the domain that points at this server}"',
748
+ " volumes:",
749
+ " - ./Caddyfile:/etc/caddy/Caddyfile:ro",
750
+ " - caddy-data:/data",
751
+ " - caddy-config:/config",
752
+ " depends_on:",
753
+ ` - ${web.id}`,
754
+ "",
755
+ "volumes:",
756
+ " caddy-data:",
757
+ " caddy-config:",
758
+ "",
759
+ ].join("\n");
760
+ }
761
+ /** The Caddyfile compose.yaml mounts: Caddy serves DOMAIN over HTTPS and passes every request to the public app. */
762
+ export function caddyfileSource(services) {
763
+ const web = publicService(services);
764
+ return [
765
+ "# Generated by create-clubedge-app deploy. Caddy serves DOMAIN, from .env.production, over HTTPS:",
766
+ "# it obtains the certificate and renews it by itself once the domain's DNS points at this server.",
767
+ "{$DOMAIN} {",
768
+ `\treverse_proxy ${web.id}:${web.port}`,
769
+ "}",
770
+ "",
771
+ ].join("\n");
772
+ }
773
+ export const releaseImagesPath = ".github/workflows/release-images.yml";
774
+ /**
775
+ * The workflow that publishes the images compose.yaml pulls: each published GitHub release builds
776
+ * every app's image for linux/amd64 and linux/arm64 and pushes it to the repository's GitHub
777
+ * Container Registry, tagged with the version. Not on tag pushes: a deleted tag would leave its
778
+ * images behind. The values an image compiles in come from the repository's variables.
779
+ */
780
+ export function releaseImagesWorkflowSource(services, frontendKeys = []) {
781
+ const argsByService = Object.values(services).map((service) => ({ service, args: buildArgumentKeys(service, frontendKeys) }));
782
+ const matrix = argsByService.flatMap(({ service, args }) => {
783
+ return [
784
+ ` - service: ${service.id}`,
785
+ ` dockerfile: ${service.dockerfile}`,
786
+ " context: .",
787
+ ...(args.length
788
+ ? [" # The image compiles these in: set them as repository variables.", " build-args: |", ...args.map((key) => ` ${key}=\${{ vars.${key} }}`)]
789
+ : []),
790
+ ];
791
+ });
792
+ return [
793
+ "# Generated by create-clubedge-app deploy. Each published GitHub release builds every app's image",
794
+ "# for linux/amd64 and linux/arm64 and pushes it to this repository's container registry as",
795
+ "# ghcr.io/<owner>/<repository>-<service>, tagged with its version, <major>.<minor>, and latest;",
796
+ "# compose.yaml runs them with IMAGE_PREFIX=ghcr.io/<owner>/<repository>. Run by hand on the",
797
+ "# default branch, it pushes edge. New packages are private until you make them public.",
798
+ "name: Release images",
799
+ "",
800
+ "on:",
801
+ " release:",
802
+ " types: [published]",
803
+ " workflow_dispatch:",
804
+ "",
805
+ "permissions:",
806
+ " contents: read",
807
+ " packages: write",
808
+ "",
809
+ "jobs:",
810
+ " image:",
811
+ " name: image (${{ matrix.service }})",
812
+ " runs-on: ubuntu-24.04",
813
+ " strategy:",
814
+ " matrix:",
815
+ " include:",
816
+ ...matrix,
817
+ " steps:",
818
+ " - uses: actions/checkout@v7",
819
+ " # Builds the arm64 image on GitHub's amd64 runners.",
820
+ " - uses: docker/setup-qemu-action@v4",
821
+ " - uses: docker/setup-buildx-action@v4",
822
+ " - uses: docker/login-action@v4",
823
+ " with:",
824
+ " registry: ghcr.io",
825
+ " username: ${{ github.actor }}",
826
+ " password: ${{ secrets.GITHUB_TOKEN }}",
827
+ " - id: meta",
828
+ " uses: docker/metadata-action@v6",
829
+ " with:",
830
+ " # The action lowercases the name, as the registry requires.",
831
+ " images: ghcr.io/${{ github.repository }}-${{ matrix.service }}",
832
+ " flavor: latest=false",
833
+ " tags: |",
834
+ " type=semver,pattern={{version}}",
835
+ " type=semver,pattern={{major}}.{{minor}}",
836
+ " type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}",
837
+ " type=edge",
838
+ " type=sha",
839
+ " - uses: docker/build-push-action@v7",
840
+ " with:",
841
+ " context: ${{ matrix.context }}",
842
+ " file: ${{ matrix.dockerfile }}",
843
+ " platforms: linux/amd64,linux/arm64",
844
+ " push: true",
845
+ " tags: ${{ steps.meta.outputs.tags }}",
846
+ " labels: ${{ steps.meta.outputs.labels }}",
847
+ // Only when an image compiles values in: the matrix has no build-args otherwise.
848
+ ...(argsByService.some(({ args }) => args.length) ? [" build-args: ${{ matrix.build-args }}"] : []),
849
+ " cache-from: type=gha,scope=${{ matrix.service }}",
850
+ " cache-to: type=gha,mode=max,scope=${{ matrix.service }}",
851
+ "",
852
+ ].join("\n");
853
+ }
854
+ /**
855
+ * Where release-images.yml publishes a GitHub repository's images, as compose.yaml's IMAGE_PREFIX:
856
+ * ghcr.io/<owner>/<repository> in lowercase, as the registry requires. Null for other Git hosts.
857
+ */
858
+ export function ghcrImagePrefix(remote) {
859
+ const path = /^https:\/\/github\.com\/(.+)$/.exec(renderRepoUrl(remote) ?? "")?.[1];
860
+ return path ? `ghcr.io/${path.toLowerCase()}` : null;
861
+ }