@clubedge/create-clubedge-app 0.41.1 → 0.42.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.
- package/CHANGELOG.md +20 -0
- package/README.md +56 -15
- package/dist/core/deploy.js +276 -50
- package/dist/core/env.js +10 -1
- package/dist/core/manifest-fields.js +1 -1
- package/dist/core/manifest-packs.js +23 -11
- package/dist/core/manifest.js +6 -4
- package/dist/core/plan.js +34 -13
- package/dist/core/railway.js +11 -0
- package/dist/deploy-docker.js +88 -0
- package/dist/deploy-fly.js +3 -2
- package/dist/deploy-netlify.js +3 -3
- package/dist/deploy-preview.js +13 -5
- package/dist/deploy-render.js +3 -3
- package/dist/deploy-vercel.js +3 -3
- package/dist/deploy.js +8 -25
- package/dist/index.js +2 -2
- package/dist/mcp-server.js +3 -2
- package/package.json +3 -3
- package/skills/clubedge-starter/SKILL.md +1 -1
- package/template/.env.example +8 -0
- package/template/.github/workflows/ci.yml +83 -0
- package/template/.github/workflows/deploy.yml +0 -32
- package/template/.prettierignore +5 -0
- package/template/AGENTS.md +1 -0
- package/template/README.md +16 -0
- package/template/SETUP.md +111 -3
- package/template/_gitignore +4 -0
- package/template/apps/api/.env.example +14 -0
- package/template/apps/api/package.json +1 -1
- package/template/apps/api/src/app.test.ts +5 -0
- package/template/apps/api/src/app.ts +9 -2
- package/template/apps/api/src/env/production.test.ts +29 -0
- package/template/apps/api/src/env/production.ts +10 -0
- package/template/apps/api/src/rate-limit.test.ts +44 -5
- package/template/apps/api/src/rate-limit.ts +19 -4
- package/template/apps/api/src/routes/app-version.test.ts +35 -0
- package/template/apps/api/src/routes/app-version.ts +16 -0
- package/template/apps/api/src/routes/client-errors.test.ts +2 -0
- package/template/apps/api/src/routes/client-errors.ts +4 -1
- package/template/apps/api/src/server/app-version.test.ts +67 -0
- package/template/apps/api/src/server/app-version.ts +57 -0
- package/template/apps/api/src/server/index.ts +5 -0
- package/template/apps/api/src/server/variants/auth.better-auth.ts +10 -3
- package/template/apps/api-fastapi/.env.example +14 -0
- package/template/apps/api-fastapi/package.json +1 -1
- package/template/apps/api-fastapi/pyproject.toml +1 -1
- package/template/apps/api-fastapi/src/clubedge_api/__main__.py +3 -0
- package/template/apps/api-fastapi/src/clubedge_api/app.py +10 -1
- package/template/apps/api-fastapi/src/clubedge_api/app_version.py +59 -0
- package/template/apps/api-fastapi/src/clubedge_api/contract.py +11 -0
- package/template/apps/api-fastapi/src/clubedge_api/production_env.py +9 -0
- package/template/apps/api-fastapi/src/clubedge_api/rate_limit.py +47 -16
- package/template/apps/api-fastapi/src/clubedge_api/routes/app_version.py +26 -0
- package/template/apps/api-fastapi/src/clubedge_api/routes/client_errors.py +6 -2
- package/template/apps/api-fastapi/src/clubedge_api/server.py +3 -0
- package/template/apps/api-fastapi/src/clubedge_api/settings.py +37 -0
- package/template/apps/api-fastapi/src/clubedge_api/variants/auth_better_auth.py +7 -4
- package/template/apps/api-fastapi/tests/test_app_version.py +100 -0
- package/template/apps/api-fastapi/tests/test_client_errors.py +3 -1
- package/template/apps/api-fastapi/tests/test_production_env.py +26 -0
- package/template/apps/api-fastapi/tests/test_rate_limit.py +80 -8
- package/template/apps/api-fastapi/uv.lock +1 -1
- package/template/apps/capacitor/.env.example +16 -0
- package/template/apps/capacitor/capacitor.config.ts +26 -0
- package/template/apps/capacitor/clubedge.stack.json +7 -0
- package/template/apps/capacitor/package.json +24 -0
- package/template/apps/capacitor/scripts/build.mjs +99 -0
- package/template/apps/capacitor/tsconfig.json +4 -0
- package/template/apps/start/.env.example +7 -0
- package/template/apps/start/clubedge.stack.json +2 -0
- package/template/apps/start/package.json +1 -1
- package/template/apps/start/src/env/production.test.ts +8 -0
- package/template/apps/start/src/env/production.ts +7 -0
- package/template/apps/start/src/env/server.test.ts +12 -0
- package/template/apps/start/src/env/server.ts +12 -0
- package/template/apps/start/src/nitro/plugins/client-ip.ts +10 -0
- package/template/apps/start/src/routes/api/client-errors.ts +4 -2
- package/template/apps/start/src/server/auth-forms.test.ts +8 -2
- package/template/apps/start/src/server/auth-forms.ts +3 -2
- package/template/apps/start/src/server/client-ip.ts +13 -0
- package/template/apps/vite/.env.example +15 -0
- package/template/apps/vite/Dockerfile +1 -0
- package/template/apps/vite/auth.server.mjs +15 -5
- package/template/apps/vite/client-ip.mjs +63 -0
- package/template/apps/vite/clubedge.stack.json +10 -1
- package/template/apps/vite/dev.mjs +5 -1
- package/template/apps/vite/index.html +2 -1
- package/template/apps/vite/package.json +2 -1
- package/template/apps/vite/server.mjs +110 -4
- package/template/apps/vite/src/components/billing/billing-card.tsx +5 -0
- package/template/apps/vite/src/lib/api.ts +8 -1
- package/template/apps/vite/src/lib/public-origin.ts +5 -0
- package/template/apps/vite/src/main.tsx +14 -0
- package/template/apps/vite/src/routes/dashboard.tsx +2 -1
- package/template/apps/vite/src/routes/forgot-password.tsx +3 -1
- package/template/apps/vite/src/routes/login.tsx +3 -1
- package/template/apps/vite/src/styles.css +43 -0
- package/template/apps/vite/src/variants/api.better-auth.ts +13 -1
- package/template/apps/vite/src/variants/api.supabase.ts +5 -0
- package/template/apps/vite/src/variants/auth.appwrite.ts +2 -1
- package/template/apps/vite/src/variants/auth.better-auth.ts +37 -2
- package/template/apps/vite/src/variants/auth.firebase.ts +10 -0
- package/template/apps/vite/src/variants/auth.supabase.ts +22 -2
- package/template/apps/vite/vite.config.ts +14 -1
- package/template/apps/web/clubedge.stack.json +0 -2
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/app/actions/auth.ts +4 -1
- package/template/apps/web/src/app/api/client-errors/route.ts +5 -1
- package/template/apps/web/src/env/production.test.ts +8 -0
- package/template/apps/web/src/env/production.ts +7 -0
- package/template/apps/web/src/env/server.test.ts +18 -0
- package/template/apps/web/src/env/server.ts +12 -0
- package/template/apps/web/src/instrumentation.ts +3 -0
- package/template/clubedge/modules/deploy.json +7 -5
- package/template/clubedge.template.json +25 -2
- package/template/docker-compose.yml +3 -0
- package/template/eslint.config.mjs +15 -0
- package/template/fly.toml +5 -0
- package/template/package.json +2 -2
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-appwrite/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-firebase/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/auth-supabase/src/browser.ts +12 -4
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/contract/openapi.yaml +42 -0
- package/template/packages/contract/package.json +1 -1
- package/template/packages/contract/src/conformance.ts +26 -0
- package/template/packages/contract/src/index.ts +1 -0
- package/template/packages/contract/src/schema.ts +51 -0
- package/template/packages/core/package.json +1 -1
- package/template/packages/core/src/client-ip.test.ts +67 -7
- package/template/packages/core/src/client-ip.ts +57 -8
- package/template/packages/core/src/index.ts +6 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +2 -2
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs-bullmq/package.json +1 -1
- package/template/packages/jobs-inngest/package.json +1 -1
- package/template/packages/jobs-pg-boss/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/mobile/package.json +37 -0
- package/template/packages/mobile/src/above-page.tsx +9 -0
- package/template/packages/mobile/src/demo/api.ts +84 -0
- package/template/packages/mobile/src/demo/auth.ts +35 -0
- package/template/packages/mobile/src/demo/banner.tsx +15 -0
- package/template/packages/mobile/src/demo/demo.test.ts +83 -0
- package/template/packages/mobile/src/demo/state.ts +18 -0
- package/template/packages/mobile/src/index.test.ts +55 -0
- package/template/packages/mobile/src/index.ts +33 -0
- package/template/packages/mobile/src/update-notice.test.ts +35 -0
- package/template/packages/mobile/src/update-notice.tsx +88 -0
- package/template/packages/mobile/src/version.test.ts +65 -0
- package/template/packages/mobile/src/version.ts +37 -0
- package/template/packages/mobile/tsconfig.json +7 -0
- package/template/packages/observability/package.json +1 -1
- package/template/packages/observability-otel/package.json +1 -1
- package/template/packages/observability-sentry/package.json +1 -1
- package/template/packages/payments/package.json +1 -1
- package/template/packages/payments-stripe/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +1979 -973
- package/template/render.yaml +5 -0
- package/template.lock.json +33 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.42.0
|
|
6
|
+
|
|
7
|
+
- **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):
|
|
8
|
+
- 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.
|
|
9
|
+
- `--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.
|
|
10
|
+
- `apps/shell/.env.local` is created from its example (`WEB_URL`, the website the app calls), and `add`, `remove`, and `upgrade` keep its values.
|
|
11
|
+
- 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.
|
|
12
|
+
- MCP: `get_starter_capabilities` lists each slot's question, and the tools describe what each role pairs with.
|
|
13
|
+
- CI generates a mobile project from the pinned Starter.
|
|
14
|
+
- **Client addresses per platform** (#136, #137): `deploy` tells the app where each client's address is, for Starter `v0.27.0`'s rate limits:
|
|
15
|
+
- `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.
|
|
16
|
+
- `TRUSTED_PROXY_HOPS=1` behind Caddy with Docker Compose, and on a frontend's API, which only the frontend's server reaches.
|
|
17
|
+
- Previews and `try` set them too, and the env file's settings replace both defaults.
|
|
18
|
+
- **Docker Compose behind Caddy, with images on GHCR** (#136): `deploy` with the docker option now works for full-stack apps too, and writes:
|
|
19
|
+
- `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`.
|
|
20
|
+
- A `Caddyfile`.
|
|
21
|
+
- `.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`.
|
|
22
|
+
- 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.
|
|
23
|
+
- `upgrade` keeps the blank line after a section the Starter adds to the local env file between two others (#138).
|
|
24
|
+
|
|
5
25
|
## 0.41.1
|
|
6
26
|
|
|
7
27
|
- 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.
|
|
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.
|
|
226
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.27.0
|
|
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.
|
|
245
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.27.0 # 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`
|
|
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.0.
|
|
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.
|
|
384
|
+
create-clubedge-app v0.42.0
|
|
345
385
|
|
|
|
346
386
|
v
|
|
347
|
-
Clubedge Starter v0.
|
|
348
|
-
commit
|
|
387
|
+
Clubedge Starter v0.27.0
|
|
388
|
+
commit 5f0e7e57131c4c56305d67edfd2e7366618d7b7c
|
|
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.
|
|
395
|
+
| `create-clubedge-app@0.42.0` | `clubedge-starter@v0.27.0` | 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.
|
|
360
|
-
`
|
|
399
|
+
The `v0.27.0` tag is expected to resolve to commit
|
|
400
|
+
`5f0e7e57131c4c56305d67edfd2e7366618d7b7c`. 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.
|
|
404
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.27.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
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.
|
|
459
|
+
4. Commit `main`, create a release tag such as `v0.27.0`, 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
|
|
package/dist/core/deploy.js
CHANGED
|
@@ -11,40 +11,39 @@ export function serviceName(packageName) {
|
|
|
11
11
|
return name || "web";
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
110
|
-
* their database URLs replace the platform's, except local ones (a
|
|
111
|
-
* Everything else the example lists is reported as unset; values the
|
|
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
|
|
141
|
-
|
|
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
|
|
148
|
-
* Auth the frontend's server runs auth: it gets the API's database,
|
|
149
|
-
* the service already has one), and BETTER_AUTH_URL, the public URL
|
|
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
|
-
|
|
158
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/env.js
CHANGED
|
@@ -90,7 +90,16 @@ export function updateEnvFile(local, baseExample, targetExample) {
|
|
|
90
90
|
// The last element is the end of the file after its final newline, not a blank line.
|
|
91
91
|
const followedByBlank = position + 1 < lines.length - 1 && lines[position + 1].trim() === "";
|
|
92
92
|
const reuseBlank = between[0]?.trim() === "" && followedByBlank;
|
|
93
|
-
|
|
93
|
+
const inserted = reuseBlank ? between.slice(1) : between;
|
|
94
|
+
const insertAt = position + (reuseBlank ? 2 : 1);
|
|
95
|
+
lines.splice(insertAt, 0, ...inserted);
|
|
96
|
+
// A variable that ends its section in the target keeps a blank line before the next section,
|
|
97
|
+
// which a reused separator would otherwise leave above the variable instead. (The last
|
|
98
|
+
// element of each split is the end of the file, not a blank line.)
|
|
99
|
+
const endsSection = at + 1 < target.length - 1 && target[at + 1].trim() === "";
|
|
100
|
+
const next = insertAt + inserted.length;
|
|
101
|
+
if (endsSection && next < lines.length - 1 && lines[next].trim() !== "")
|
|
102
|
+
lines.splice(next, 0, "");
|
|
94
103
|
});
|
|
95
104
|
let text = lines.join(eol);
|
|
96
105
|
// Removed variables can leave blank lines in a row, as removed template blocks do.
|