@clubedge/create-clubedge-app 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +23 -16
- package/dist/core/customize.js +1 -0
- package/dist/core/deploy.js +58 -3
- package/dist/core/manifest-packs.js +21 -0
- package/dist/core/plan.js +9 -0
- package/dist/core/project.js +1 -0
- package/dist/deploy.js +196 -6
- package/package.json +4 -4
- package/template/.github/workflows/ci.yml +4 -0
- package/template/.github/workflows/preview.yml +35 -2
- package/template/SETUP.md +6 -1
- package/template/_gitignore +7 -0
- package/template/apps/api/.env.example +27 -0
- package/template/apps/api/clubedge.stack.json +32 -2
- package/template/apps/api/package.json +4 -1
- package/template/apps/api/src/app.ts +11 -0
- package/template/apps/api/src/server/auth.ts +6 -1
- package/template/apps/api/src/server/index.ts +7 -1
- package/template/apps/api/src/server/variants/auth.appwrite.ts +38 -0
- package/template/apps/api/src/server/variants/auth.better-auth.ts +31 -0
- package/template/apps/api/src/server/variants/auth.firebase.ts +34 -0
- package/template/apps/api/src/server/variants/auth.supabase.ts +12 -0
- package/template/apps/api-fastapi/.env.example +33 -0
- package/template/apps/api-fastapi/.python-version +1 -0
- package/template/apps/api-fastapi/Dockerfile +38 -0
- package/template/apps/api-fastapi/clubedge.stack.json +69 -0
- package/template/apps/api-fastapi/package.json +20 -0
- package/template/apps/api-fastapi/pyproject.toml +40 -0
- package/template/apps/api-fastapi/src/clubedge_api/__init__.py +1 -0
- package/template/apps/api-fastapi/src/clubedge_api/__main__.py +16 -0
- package/template/apps/api-fastapi/src/clubedge_api/app.py +77 -0
- package/template/apps/api-fastapi/src/clubedge_api/auth.py +13 -0
- package/template/apps/api-fastapi/src/clubedge_api/check_env.py +12 -0
- package/template/apps/api-fastapi/src/clubedge_api/check_services.py +18 -0
- package/template/apps/api-fastapi/src/clubedge_api/db.py +19 -0
- package/template/apps/api-fastapi/src/clubedge_api/main.py +6 -0
- package/template/apps/api-fastapi/src/clubedge_api/server.py +41 -0
- package/template/apps/api-fastapi/src/clubedge_api/settings.py +41 -0
- package/template/apps/api-fastapi/src/clubedge_api/variants/auth_appwrite.py +43 -0
- package/template/apps/api-fastapi/src/clubedge_api/variants/auth_better_auth.py +44 -0
- package/template/apps/api-fastapi/src/clubedge_api/variants/auth_firebase.py +55 -0
- package/template/apps/api-fastapi/src/clubedge_api/variants/auth_supabase.py +40 -0
- package/template/apps/api-fastapi/src/config/site.json +11 -0
- package/template/apps/api-fastapi/tests/test_app.py +46 -0
- package/template/apps/api-fastapi/uv.lock +733 -0
- package/template/apps/start/package.json +1 -1
- package/template/apps/vite/.env.example +35 -1
- package/template/apps/vite/Dockerfile +20 -6
- package/template/apps/vite/auth.server.mjs +251 -0
- package/template/apps/vite/build-auth-server.mjs +15 -0
- package/template/apps/vite/clubedge.stack.json +91 -2
- package/template/apps/vite/dev.mjs +39 -0
- package/template/apps/vite/package.json +9 -3
- package/template/apps/vite/server.mjs +205 -0
- package/template/apps/vite/src/components/dashboard/variants/account-button.supabase.tsx +51 -0
- package/template/apps/vite/src/lib/api.ts +3 -0
- package/template/apps/vite/src/lib/auth-client.ts +15 -0
- package/template/apps/vite/src/lib/auth-enabled.ts +1 -0
- package/template/apps/vite/src/lib/auth.ts +2 -0
- package/template/apps/vite/src/routes/dashboard.tsx +7 -4
- package/template/apps/vite/src/routes/forgot-password.tsx +65 -0
- package/template/apps/vite/src/routes/index.tsx +8 -0
- package/template/apps/vite/src/routes/login.tsx +181 -0
- package/template/apps/vite/src/routes/reset-password.tsx +61 -0
- package/template/apps/vite/src/variants/api.better-auth.ts +5 -0
- package/template/apps/vite/src/variants/api.supabase.ts +7 -0
- package/template/apps/vite/src/variants/auth-enabled.supabase.ts +3 -0
- package/template/apps/vite/src/variants/auth.appwrite.ts +91 -0
- package/template/apps/vite/src/variants/auth.better-auth.ts +44 -0
- package/template/apps/vite/src/variants/auth.firebase.ts +95 -0
- package/template/apps/vite/src/variants/auth.supabase.ts +48 -0
- package/template/apps/vite/src/variants/router.supabase.tsx +63 -0
- package/template/apps/vite/vite.config.ts +2 -1
- package/template/apps/web/package.json +1 -1
- package/template/clubedge/modules/auth.json +1 -0
- package/template/clubedge.template.json +8 -0
- package/template/e2e/auth.spec.ts +8 -3
- package/template/e2e/firebase-auth-flow.spec.ts +39 -0
- package/template/e2e/home.spec.ts +8 -1
- package/template/package.json +4 -3
- 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-better-auth/src/index.ts +3 -2
- package/template/packages/auth-firebase/package.json +1 -1
- package/template/packages/auth-supabase/package.json +3 -2
- package/template/packages/auth-supabase/src/browser.ts +8 -0
- package/template/packages/auth-supabase/src/index.ts +20 -0
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/contract/openapi.yaml +8 -0
- package/template/packages/contract/package.json +4 -1
- package/template/packages/contract/scripts/check-api.mjs +78 -0
- package/template/packages/contract/src/index.ts +19 -2
- package/template/packages/core/package.json +1 -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 +1 -1
- 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/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/playwright.config.ts +15 -0
- package/template/pnpm-lock.yaml +818 -9
- package/template/scripts/smoke-generated-app.mjs +106 -0
- package/template/turbo.json +6 -0
- package/template.lock.json +53 -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.35.0
|
|
6
|
+
|
|
7
|
+
- **Deploy a frontend and its separate API** (#103):
|
|
8
|
+
- `--deploy docker` generates a Compose setup with the Vite frontend and API as separate services. The frontend is public; the API stays on the internal network, and the generated configuration includes each service's port and health check.
|
|
9
|
+
- `--deploy railway` provisions the frontend and API services, wires the frontend to the private API URL, configures service variables, and runs database migrations before releasing the API.
|
|
10
|
+
- Deployment capabilities now live in each stack's descriptor, including supported platforms, ports, and health checks. See [docs/stack-packs.md](docs/stack-packs.md).
|
|
11
|
+
- **Default to Starter `v0.24.0`** (#105), which brings all four auth providers to React + Vite with either Hono or FastAPI:
|
|
12
|
+
- Supabase, Better Auth, Firebase, and Appwrite each have frontend flows and API-side token or session verification.
|
|
13
|
+
- Generated-project and auth-flow CI covers every provider with both APIs; Docker and Railway deploy configuration is checked for both combinations.
|
|
14
|
+
- FastAPI now has a committed `uv.lock` for reproducible Python installs. Pull request previews can also deploy the Vite frontend and API as separate Render services.
|
|
15
|
+
|
|
16
|
+
## 0.34.0
|
|
17
|
+
|
|
18
|
+
- **A FastAPI API**: default to Starter `v0.23.0` (#101). `--framework vite --api fastapi` creates the React + Vite frontend with an API in Python, managed with [uv](https://docs.astral.sh/uv/):
|
|
19
|
+
- The Starter added it as one folder that describes itself, and this CLI generates it without a code change: the API lands in `apps/api` as `@clubedge/api`, and the CLI runs `uv sync` after `pnpm install`.
|
|
20
|
+
- Its `package.json` scripts call uv, ruff, mypy, and pytest, so `pnpm dev`, `pnpm lint`, `pnpm typecheck`, and `pnpm test` run it like the other apps. `pnpm lint` and `pnpm test` now also run each app's own scripts, so apps in other languages are always checked.
|
|
21
|
+
- It passes the same contract suite as the Hono API, against the live server, through `clubedge-contract-check`.
|
|
22
|
+
- A first version: the app modules are `none` (cache: memory, deploy: Docker). Install uv first.
|
|
23
|
+
- CI generates the pair from the packed CLI. The smoke check waits for a separate API that starts after its frontend (#100).
|
|
24
|
+
|
|
5
25
|
## 0.33.0
|
|
6
26
|
|
|
7
27
|
- **A frontend and a separate API**: default to Starter `v0.22.0` (#98). `--framework vite --api hono` creates a React + Vite frontend in `apps/web` and a Hono API in `apps/api`, joined by one HTTP contract:
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# create-clubedge-app
|
|
6
6
|
|
|
7
|
-
**Create a production-ready
|
|
7
|
+
**Create a production-ready React app with one command: Next.js, TanStack Start, or Vite with a separate API.**
|
|
8
8
|
|
|
9
9
|
Pick only the modules you need: auth, storage, email, payments, jobs, observability, deployment.<br>
|
|
10
10
|
Add, remove, or upgrade them later without losing your own changes.
|
|
@@ -53,7 +53,7 @@ The default Starter release ships inside the CLI package, so GitHub is only cont
|
|
|
53
53
|
|
|
54
54
|
## Choosing a framework
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
For full-stack apps, the Starter ships the same application for Next.js and TanStack Start. Next.js is the default; pick TanStack Start with `--framework`, or choose interactively:
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
59
|
pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
|
|
@@ -78,10 +78,15 @@ pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono
|
|
|
78
78
|
| ------------------ | ----------------------------------------------------- | ----------- |
|
|
79
79
|
| `--framework vite` | React + Vite single-page app (TanStack Router) | `apps/web` |
|
|
80
80
|
| `--api hono` | Hono API on Node, reusing the Starter's packages | `apps/api` |
|
|
81
|
+
| `--api fastapi` | FastAPI API in Python, managed with [uv](https://docs.astral.sh/uv/) | `apps/api` |
|
|
82
|
+
|
|
83
|
+
A Python API needs uv; the CLI installs its dependencies with it after `pnpm install`, and its `package.json` scripts call uv, so `pnpm dev`, `pnpm lint`, and `pnpm test` run it like the other apps.
|
|
81
84
|
|
|
82
85
|
They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend calls the API only through the client generated from it, and every API passes the same conformance suite. `pnpm dev` starts both, and the frontend forwards `/api` to the API. A full-stack framework (`next`, `tanstack-start`) has its own server, so it takes no `--api`.
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
With `--auth supabase`, the Vite app owns the browser session and attaches its access token to contract requests. Hono or FastAPI verifies that token with Supabase Auth and returns the contract's shared user shape. Configure `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_URL`, and `SUPABASE_PUBLISHABLE_KEY` in the generated env examples; no Supabase service-role key goes to the browser.
|
|
88
|
+
|
|
89
|
+
The decoupled stacks support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache and Docker or Railway deployment. With Docker, `pnpm clubedge deploy` writes a `compose.yaml` that builds the frontend and API as separate services; copy `.env.example` to `.env.production`, fill in its values, and run `docker compose --env-file .env.production up --build -d`. With Railway, deploy provisions a public frontend and a private API, wires the frontend proxy to the API, and runs migrations before releasing the API. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks and deployment facts are described.
|
|
85
90
|
|
|
86
91
|
## Choosing modules
|
|
87
92
|
|
|
@@ -89,7 +94,7 @@ Authentication, file storage, caching, email, payments, background jobs, observa
|
|
|
89
94
|
|
|
90
95
|
| Flag | Options | Default |
|
|
91
96
|
| ----------- | ------------------------------------------ | ---------- |
|
|
92
|
-
| `--auth` | `supabase`, `better-auth`, `none`
|
|
97
|
+
| `--auth` | `supabase`, `better-auth`, `firebase`, `appwrite`, `none` | `supabase` |
|
|
93
98
|
| `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
|
|
94
99
|
| `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
|
|
95
100
|
| `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
|
|
@@ -120,7 +125,7 @@ Usage:
|
|
|
120
125
|
|
|
121
126
|
Options:
|
|
122
127
|
--framework <id> App framework, such as next or tanstack-start (or vite, a frontend)
|
|
123
|
-
--api <id> A separate API for a frontend, such as hono
|
|
128
|
+
--api <id> A separate API for a frontend, such as hono or fastapi
|
|
124
129
|
--<module> <option> A module option, such as --auth none; the Starter lists its modules
|
|
125
130
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
126
131
|
(upgrade: the Starter to upgrade to)
|
|
@@ -147,7 +152,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
147
152
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
148
153
|
|
|
149
154
|
# Reproduce a specific Starter release or commit
|
|
150
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
155
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.0
|
|
151
156
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
152
157
|
|
|
153
158
|
# Test local Starter changes before releasing them
|
|
@@ -166,7 +171,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
166
171
|
pnpm clubedge remove storage payments
|
|
167
172
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
168
173
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
169
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.
|
|
174
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.24.0 # or a specific tag, branch, or commit
|
|
170
175
|
```
|
|
171
176
|
|
|
172
177
|
The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
|
|
@@ -242,7 +247,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
|
|
|
242
247
|
pnpm clubedge deploy --dry-run # show the plan, create nothing
|
|
243
248
|
```
|
|
244
249
|
|
|
245
|
-
With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package
|
|
250
|
+
With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package and the databases selected by the modules. Full-stack projects get one public app service. A Vite project gets two services: the frontend has the public `*.up.railway.app` domain, and the API stays private; the frontend's Caddy proxy connects to it over Railway's private network. API variables, including `FRONTEND_URL`, go on the API service; `VITE_*` values go on the frontend service for its build. The API's migration command runs before the API release, and the frontend and API deployments are both awaited. Database variables are references to Railway's Postgres and Redis by default. Values from `.env.production` (or `--env-file`) take precedence; external database URLs stay external, and localhost URLs are ignored. `.railway/railway.ts` describes the selected service topology and health checks; `deploy` applies it and adds Railway's TypeScript SDK (`railway`) as a dev dependency. Commit the generated file and package changes. It needs Railway CLI 5.42.1 or newer. Running `deploy` again reuses the services, so it also adds variables and ships new releases. Railway bills usage on your plan.
|
|
246
251
|
|
|
247
252
|
### Deploy to Fly.io (experimental)
|
|
248
253
|
|
|
@@ -277,27 +282,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
|
|
|
277
282
|
Each CLI release is tested against one specific Starter release:
|
|
278
283
|
|
|
279
284
|
```text
|
|
280
|
-
create-clubedge-app v0.
|
|
285
|
+
create-clubedge-app v0.35.0
|
|
281
286
|
|
|
|
282
287
|
v
|
|
283
|
-
Clubedge Starter v0.
|
|
284
|
-
commit
|
|
288
|
+
Clubedge Starter v0.24.0
|
|
289
|
+
commit 3aaca476bf5ff1db9b5fc4080f7a8a8006436998
|
|
285
290
|
```
|
|
286
291
|
|
|
287
292
|
The current relationship is:
|
|
288
293
|
|
|
289
294
|
| CLI release | Default Starter release | Meaning |
|
|
290
295
|
| --- | --- | --- |
|
|
291
|
-
| `create-clubedge-app@0.
|
|
296
|
+
| `create-clubedge-app@0.35.0` | `clubedge-starter@v0.24.0` | The CLI's required, tested default |
|
|
292
297
|
|
|
293
298
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
294
299
|
|
|
295
|
-
The `v0.
|
|
296
|
-
`
|
|
300
|
+
The `v0.24.0` tag is expected to resolve to commit
|
|
301
|
+
`3aaca476bf5ff1db9b5fc4080f7a8a8006436998`. The CLI verifies this before
|
|
297
302
|
scaffolding with the default reference and fails rather than silently using a
|
|
298
303
|
moved tag.
|
|
299
304
|
|
|
300
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.
|
|
305
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.24.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
301
306
|
|
|
302
307
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
303
308
|
|
|
@@ -352,7 +357,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
352
357
|
1. Make and validate changes in `clubedge-starter`.
|
|
353
358
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
354
359
|
3. Update the Starter package versions.
|
|
355
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
360
|
+
4. Commit `main`, create a release tag such as `v0.24.0`, and publish the Starter release.
|
|
356
361
|
|
|
357
362
|
### CLI maintainers
|
|
358
363
|
|
|
@@ -396,6 +401,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
396
401
|
| 0.31.0 | v0.20.0 | Pluggable background jobs: `--jobs bullmq` and `--jobs inngest` next to pg-boss, and a conformance suite every provider passes |
|
|
397
402
|
| 0.32.0 | v0.21.0 | Pluggable authentication: `--auth firebase` and `--auth appwrite` next to Supabase and Better Auth, and a conformance suite every provider passes |
|
|
398
403
|
| 0.33.0 | v0.22.0 | Starter v0.22.0: stack packs (template schema 4), and a React + Vite frontend with a separate Hono API (`--framework vite --api hono`) |
|
|
404
|
+
| 0.34.0 | v0.23.0 | Starter v0.23.0: a FastAPI API in Python (`--framework vite --api fastapi`), generated with no CLI change |
|
|
405
|
+
| 0.35.0 | v0.24.0 | Deploy frontend and API stack packs; Default to Starter v0.24.0 |
|
|
399
406
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
400
407
|
|
|
401
408
|
## Community
|
package/dist/core/customize.js
CHANGED
|
@@ -37,6 +37,7 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
|
|
|
37
37
|
...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
|
|
38
38
|
...recordStacks(provenance.stacks ?? {}),
|
|
39
39
|
...(provenance.modules ? { modules: provenance.modules } : {}),
|
|
40
|
+
...(provenance.services && Object.keys(provenance.services).length ? { services: provenance.services } : {}),
|
|
40
41
|
};
|
|
41
42
|
for (const script of ["docker:build", "docker:start"]) {
|
|
42
43
|
if (typeof manifest.scripts?.[script] === "string") {
|
package/dist/core/deploy.js
CHANGED
|
@@ -10,6 +10,24 @@ export function serviceName(packageName) {
|
|
|
10
10
|
const name = packageName.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
11
11
|
return name || "web";
|
|
12
12
|
}
|
|
13
|
+
/** A Docker Compose production topology rendered from the selected stack service descriptors. */
|
|
14
|
+
export function dockerComposeSource(services) {
|
|
15
|
+
const api = Object.values(services).find((service) => service.role === "api");
|
|
16
|
+
const rows = Object.entries(services).map(([slot, service]) => {
|
|
17
|
+
const name = service.id;
|
|
18
|
+
const frontend = service.role === "frontend";
|
|
19
|
+
const buildArgs = frontend
|
|
20
|
+
? `\n args:\n VITE_SUPABASE_URL: \${VITE_SUPABASE_URL:-}\n VITE_SUPABASE_PUBLISHABLE_KEY: \${VITE_SUPABASE_PUBLISHABLE_KEY:-}`
|
|
21
|
+
: "";
|
|
22
|
+
const env = frontend && api
|
|
23
|
+
? `\n environment:\n API_URL: http://${api.id}:${api.port}`
|
|
24
|
+
: `\n env_file:\n - path: .env.production\n required: false`;
|
|
25
|
+
const ports = frontend ? `\n ports:\n - "${service.port}:${service.port}"` : `\n expose:\n - "${service.port}"`;
|
|
26
|
+
const depends = frontend && api ? `\n depends_on:\n - ${api.id}` : "";
|
|
27
|
+
return ` ${name}:\n build:\n context: .\n dockerfile: ${service.dockerfile}${buildArgs}${env}${ports}${depends}`;
|
|
28
|
+
});
|
|
29
|
+
return `# Generated from Clubedge stack descriptors. Build and start every app service together.\nservices:\n${rows.join("\n")}\n`;
|
|
30
|
+
}
|
|
13
31
|
/** The variable each database's URL goes in. */
|
|
14
32
|
export const databaseVariables = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
|
|
15
33
|
/** A URL to this machine: from a development env file, unreachable from the deployed app. */
|
|
@@ -75,11 +93,11 @@ export function parseEnvFile(text) {
|
|
|
75
93
|
* Everything else the example lists is reported as unset; values the service already has count as set.
|
|
76
94
|
*/
|
|
77
95
|
export function planVariables(input) {
|
|
78
|
-
const { modules, keys, url, envFile, secret, attached = [] } = input;
|
|
96
|
+
const { modules, keys, url, envFile, secret, attached = [], port = appPort } = input;
|
|
79
97
|
const existing = { ...input.existing, ...Object.fromEntries(attached.map((key) => [key, "(attached)"])) };
|
|
80
98
|
const external = externalDatabaseUrls(modules, envFile);
|
|
81
99
|
const databases = input.databases ?? railwayReferences(databasesFor(modules, Object.keys(external)));
|
|
82
|
-
const provided = { NODE_ENV: "production", PORT: String(
|
|
100
|
+
const provided = { NODE_ENV: "production", PORT: String(port), ...databases };
|
|
83
101
|
// The public URL under both names: TanStack Start reads APP_URL, Next.js NEXT_PUBLIC_APP_URL.
|
|
84
102
|
const set = { ...provided, APP_URL: url, NEXT_PUBLIC_APP_URL: url };
|
|
85
103
|
if (modules.auth === "better-auth" && !existing.BETTER_AUTH_SECRET)
|
|
@@ -223,7 +241,44 @@ export const railwayIacPath = ".railway/railway.ts";
|
|
|
223
241
|
* Railway stops reading railway.json on 2026-12-01. Railway builds the root Dockerfile by itself,
|
|
224
242
|
* the variables stay managed by `deploy` and the dashboard, and the region is Railway's default.
|
|
225
243
|
*/
|
|
226
|
-
export function railwayIacSource(service) {
|
|
244
|
+
export function railwayIacSource(service, services) {
|
|
245
|
+
if (services && Object.keys(services).length > 1) {
|
|
246
|
+
const identifier = (slot) => slot.replace(/[^a-zA-Z0-9_$]/g, "_");
|
|
247
|
+
const apiEntry = Object.entries(services).find(([, item]) => item.role === "api");
|
|
248
|
+
const api = apiEntry?.[1];
|
|
249
|
+
const apiIdentifier = apiEntry ? identifier(apiEntry[0]) : null;
|
|
250
|
+
const ordered = Object.entries(services).sort(([, a], [, b]) => Number(a.role === "frontend") - Number(b.role === "frontend"));
|
|
251
|
+
const resources = ordered.map(([slot, item]) => {
|
|
252
|
+
const name = serviceName(`${service}-${item.id}`);
|
|
253
|
+
const dockerfilePath = item.dockerfile;
|
|
254
|
+
const healthcheck = item.healthPath;
|
|
255
|
+
const migration = item.role === "api" ? 'preDeploy: "node /app/migrate.mjs",' : "";
|
|
256
|
+
const env = item.role === "frontend" && api && apiIdentifier
|
|
257
|
+
? `env: { API_URL: \`http://\${${apiIdentifier}.env.RAILWAY_PRIVATE_DOMAIN}:${api.port}\` },`
|
|
258
|
+
: "";
|
|
259
|
+
return ` const ${identifier(slot)} = service(${JSON.stringify(name)}, {
|
|
260
|
+
build: { builder: "DOCKERFILE", dockerfilePath: ${JSON.stringify(dockerfilePath)} },
|
|
261
|
+
${migration}
|
|
262
|
+
${env}
|
|
263
|
+
healthcheck: ${JSON.stringify(healthcheck)},
|
|
264
|
+
healthcheckTimeout: 60,
|
|
265
|
+
});`;
|
|
266
|
+
});
|
|
267
|
+
const resourceNames = Object.keys(services).map(identifier).join(", ");
|
|
268
|
+
return `// Generated from the selected stack packs; each app runs as its own Railway service.
|
|
269
|
+
import { defineRailway, project, service } from "railway/iac";
|
|
270
|
+
|
|
271
|
+
export const partial = ${JSON.stringify(service)};
|
|
272
|
+
|
|
273
|
+
export default defineRailway(() => {
|
|
274
|
+
${resources.join("\n\n")}
|
|
275
|
+
return project(${JSON.stringify(service)}, {
|
|
276
|
+
variables: { managed: false },
|
|
277
|
+
resources: [${resourceNames}],
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
227
282
|
const name = JSON.stringify(service);
|
|
228
283
|
return `// Railway's configuration for this project, applied with \`railway config apply\` (pnpm clubedge
|
|
229
284
|
// deploy does it before each release). It replaces railway.json, which Railway stops reading on
|
|
@@ -155,6 +155,7 @@ function parseStack(directory, descriptor, slots, modules) {
|
|
|
155
155
|
implemented[moduleId] = Object.keys(module.options).filter((option) => options.has(option));
|
|
156
156
|
}
|
|
157
157
|
const toolchain = data.toolchain === undefined ? undefined : parseToolchain(file, data.toolchain);
|
|
158
|
+
const deployment = data.deployment === undefined ? undefined : parseDeployment(file, data.deployment);
|
|
158
159
|
return {
|
|
159
160
|
id,
|
|
160
161
|
stack: {
|
|
@@ -166,10 +167,30 @@ function parseStack(directory, descriptor, slots, modules) {
|
|
|
166
167
|
dockerfile: resolve(read.requireString(data, "dockerfile"), "dockerfile"),
|
|
167
168
|
modules: implemented,
|
|
168
169
|
...(toolchain ? { toolchain } : {}),
|
|
170
|
+
...(deployment ? { deployment } : {}),
|
|
169
171
|
},
|
|
170
172
|
files,
|
|
171
173
|
};
|
|
172
174
|
}
|
|
175
|
+
function parseDeployment(file, value) {
|
|
176
|
+
const read = fields(file, "deployment.");
|
|
177
|
+
if (!isRecord(value))
|
|
178
|
+
read.fail("deployment must be an object.");
|
|
179
|
+
const deployment = value;
|
|
180
|
+
const port = deployment.port;
|
|
181
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
182
|
+
read.fail("deployment.port must be an integer from 1 to 65535.");
|
|
183
|
+
}
|
|
184
|
+
const platforms = read.optionalStringArray(deployment, "platforms");
|
|
185
|
+
if (!platforms.length)
|
|
186
|
+
read.fail("deployment.platforms must list at least one platform.");
|
|
187
|
+
return {
|
|
188
|
+
id: read.requireId(read.requireString(deployment, "id"), "deployment service"),
|
|
189
|
+
port: port,
|
|
190
|
+
healthPath: read.requireString(deployment, "healthPath"),
|
|
191
|
+
platforms,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
173
194
|
function parseToolchain(file, value) {
|
|
174
195
|
const read = fields(file, "toolchain.");
|
|
175
196
|
if (!isRecord(value))
|
package/dist/core/plan.js
CHANGED
|
@@ -262,6 +262,14 @@ export function createPlan(input) {
|
|
|
262
262
|
option,
|
|
263
263
|
optionName: manifest.modules[id].options[option].name,
|
|
264
264
|
}));
|
|
265
|
+
const services = Object.fromEntries(selected.flatMap(({ slotId, slot, stack }) => stack.deployment
|
|
266
|
+
? [[slotId, {
|
|
267
|
+
...stack.deployment,
|
|
268
|
+
role: stack.role,
|
|
269
|
+
app: slot.path,
|
|
270
|
+
dockerfile: slot.dockerfile,
|
|
271
|
+
}]]
|
|
272
|
+
: []));
|
|
265
273
|
const provenance = {
|
|
266
274
|
cliVersion: input.cliVersion,
|
|
267
275
|
starterRepository: source.repository,
|
|
@@ -279,6 +287,7 @@ export function createPlan(input) {
|
|
|
279
287
|
moduleSummary: modules.map(({ name, optionName }) => `${name}: ${optionName}`),
|
|
280
288
|
}
|
|
281
289
|
: {}),
|
|
290
|
+
...(Object.keys(services).length ? { services } : {}),
|
|
282
291
|
};
|
|
283
292
|
const place = placement(selected, others);
|
|
284
293
|
const envTargets = Object.values(manifest.slots).map((slot) => slot.env.target);
|
package/dist/core/project.js
CHANGED
package/dist/deploy.js
CHANGED
|
@@ -9,10 +9,10 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import process from "node:process";
|
|
12
|
-
import { FRAMEWORK_SLOT } from "./core/manifest.js";
|
|
13
12
|
import { UsageError } from "./cli/args.js";
|
|
14
13
|
import { bold, dim } from "./cli/ui.js";
|
|
15
|
-
import {
|
|
14
|
+
import { FRAMEWORK_SLOT } from "./core/manifest.js";
|
|
15
|
+
import { appPort, databaseVariables, dockerComposeSource, databasesFor, deploymentStatus, exampleKeys, externalDatabaseUrls, finalStatuses, isLocalUrl, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planVariables, railwayCliVersion, railwayDatabases, railwayIacPath, railwayIacSource, railwayReferences, serviceName, serviceNames, } from "./core/deploy.js";
|
|
16
16
|
import { neonDatabaseState } from "./core/neon.js";
|
|
17
17
|
import { readProject } from "./core/project.js";
|
|
18
18
|
import { savedDatabaseForUrl } from "./neon.js";
|
|
@@ -173,17 +173,207 @@ async function agree(args, service, databases, external) {
|
|
|
173
173
|
const answer = await confirm({ message: "Deploy?", initialValue: true });
|
|
174
174
|
return !isCancel(answer) && answer;
|
|
175
175
|
}
|
|
176
|
+
async function deployRailwayServices(args, project, cli, envFile) {
|
|
177
|
+
const selected = Object.entries(project.services);
|
|
178
|
+
const frontend = selected.find(([, item]) => item.role === "frontend");
|
|
179
|
+
const api = selected.find(([, item]) => item.role === "api");
|
|
180
|
+
if (!frontend || !api || selected.length !== 2) {
|
|
181
|
+
throw new UsageError("Railway deployment currently supports one frontend and one API service in a decoupled project.");
|
|
182
|
+
}
|
|
183
|
+
const unsupported = selected.filter(([, service]) => !service.platforms.includes("railway"));
|
|
184
|
+
if (unsupported.length) {
|
|
185
|
+
throw new UsageError(`The selected stacks do not declare Railway deployment support for ${unsupported.map(([slot]) => slot).join(", ")}.`);
|
|
186
|
+
}
|
|
187
|
+
const [frontendSlot, frontendStack] = frontend;
|
|
188
|
+
const [apiSlot, apiStack] = api;
|
|
189
|
+
const baseName = serviceName(project.identity.packageName);
|
|
190
|
+
const names = {
|
|
191
|
+
frontend: serviceName(`${baseName}-${frontendStack.id}`),
|
|
192
|
+
api: serviceName(`${baseName}-${apiStack.id}`),
|
|
193
|
+
};
|
|
194
|
+
const keys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
|
|
195
|
+
const external = Object.keys(externalDatabaseUrls(project.modules, envFile));
|
|
196
|
+
const requestedDatabases = databasesFor(project.modules, external);
|
|
197
|
+
const serviceSummary = `${frontendSlot} (${frontendStack.dockerfile}) and ${apiSlot} (${apiStack.dockerfile})`;
|
|
198
|
+
if (args.dryRun) {
|
|
199
|
+
log.message([
|
|
200
|
+
`Railway will create two services: ${names.frontend} (public, port ${frontendStack.port}) and ${names.api} (private, port ${apiStack.port}).`,
|
|
201
|
+
`The frontend proxies /api to ${names.api} over Railway's private network.`,
|
|
202
|
+
...(requestedDatabases.length ? [`Databases: ${requestedDatabases.map((db) => railwayDatabases[db]).join(", ")}.`] : []),
|
|
203
|
+
`Dockerfiles: ${frontendStack.dockerfile}, ${apiStack.dockerfile}. API migrations run before its release.`,
|
|
204
|
+
`Variables from .env.production are applied to their corresponding services; ${railwayIacPath} records both service configurations.`,
|
|
205
|
+
].join("\n"));
|
|
206
|
+
outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
note(`This creates or updates Railway services ${names.frontend} and ${names.api}; publishes only the frontend, connects the services over Railway's private network, and runs migrations before the API release. Railway bills usage on your plan.`, "Deploy frontend and API to Railway");
|
|
210
|
+
if (!args.yes) {
|
|
211
|
+
if (!process.stdin.isTTY)
|
|
212
|
+
throw new UsageError("deploy creates Railway resources. Run it in a terminal to confirm, or pass --yes.");
|
|
213
|
+
const answer = await confirm({ message: "Deploy?", initialValue: true });
|
|
214
|
+
if (isCancel(answer) || !answer)
|
|
215
|
+
return cancel("Nothing was deployed.");
|
|
216
|
+
}
|
|
217
|
+
const progress = spinner();
|
|
218
|
+
progress.start("Preparing the Railway CLI");
|
|
219
|
+
const rw = await railwayCli();
|
|
220
|
+
process.chdir(project.root);
|
|
221
|
+
if (!(await attempt(() => railway(rw, ["whoami"])))) {
|
|
222
|
+
progress.stop("Signing in to Railway");
|
|
223
|
+
if (!process.stdin.isTTY)
|
|
224
|
+
throw new UsageError("Sign in to Railway first with `railway login`, or set RAILWAY_API_TOKEN.");
|
|
225
|
+
await railway(rw, ["login"], { inherit: true });
|
|
226
|
+
progress.start("Signed in");
|
|
227
|
+
}
|
|
228
|
+
progress.message("Finding or creating the Railway project");
|
|
229
|
+
let status = await attempt(() => railway(rw, ["status", "--json"]));
|
|
230
|
+
if (!status) {
|
|
231
|
+
await railway(rw, ["init", "--name", baseName, "--json"]);
|
|
232
|
+
status = await attempt(() => railway(rw, ["status", "--json"]));
|
|
233
|
+
}
|
|
234
|
+
let existing = [];
|
|
235
|
+
try {
|
|
236
|
+
existing = serviceNames(JSON.parse(status ?? "{}"));
|
|
237
|
+
}
|
|
238
|
+
catch { /* unknown status: add missing services */ }
|
|
239
|
+
const listedApiVars = await attempt(() => railway(rw, ["variable", "list", "--service", names.api, "--json"]));
|
|
240
|
+
if (listedApiVars === null && existing.includes(names.api)) {
|
|
241
|
+
throw new Error(`Railway didn't list the variables of the API service "${names.api}", so deploy changed nothing. Run deploy again; if it keeps failing, check \`railway variable list --service ${names.api}\`.`);
|
|
242
|
+
}
|
|
243
|
+
const apiVars = parseVariables(listedApiVars ?? "");
|
|
244
|
+
const kept = databasesFor(project.modules, external)
|
|
245
|
+
.filter((database) => apiVars[databaseVariables[database]] && !existing.includes(railwayDatabases[database]))
|
|
246
|
+
.map((database) => databaseVariables[database]);
|
|
247
|
+
const databases = databasesFor(project.modules, [...external, ...kept]);
|
|
248
|
+
for (const database of databases) {
|
|
249
|
+
const name = railwayDatabases[database];
|
|
250
|
+
if (!existing.includes(name))
|
|
251
|
+
await railway(rw, ["add", "--database", database]);
|
|
252
|
+
}
|
|
253
|
+
for (const name of Object.values(names)) {
|
|
254
|
+
if (!existing.includes(name))
|
|
255
|
+
await railway(rw, ["add", "--service", name]);
|
|
256
|
+
}
|
|
257
|
+
const listed = await attempt(() => railway(rw, ["domain", "list", "--service", names.frontend]));
|
|
258
|
+
const domain = parseRailwayDomain(listed ?? "") ?? parseRailwayDomain(await railway(rw, ["domain", "--service", names.frontend, "--port", String(frontendStack.port), "--json"]));
|
|
259
|
+
if (!domain)
|
|
260
|
+
throw new Error("Railway created no frontend domain. Create one in its settings, then run deploy again.");
|
|
261
|
+
const url = `https://${domain}`;
|
|
262
|
+
const apiEnv = Object.fromEntries(Object.entries(envFile).filter(([key]) => !key.startsWith("VITE_")));
|
|
263
|
+
const apiPlan = planVariables({
|
|
264
|
+
modules: project.modules,
|
|
265
|
+
keys: keys.filter((key) => !key.startsWith("VITE_")),
|
|
266
|
+
url,
|
|
267
|
+
existing: apiVars,
|
|
268
|
+
envFile: apiEnv,
|
|
269
|
+
secret: () => randomBytes(32).toString("hex"),
|
|
270
|
+
databases: railwayReferences(databases),
|
|
271
|
+
port: apiStack.port,
|
|
272
|
+
});
|
|
273
|
+
apiPlan.set.FRONTEND_URL = url;
|
|
274
|
+
if (apiPlan.unset.length) {
|
|
275
|
+
log.warn(`Not set on the API service: ${apiPlan.unset.join(", ")}. Pass --env-file to supply values before deployment.`);
|
|
276
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
277
|
+
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
278
|
+
if (isCancel(proceed) || !proceed)
|
|
279
|
+
return cancel("Nothing was uploaded. Set the variables, then run deploy again.");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const [key, value] of Object.entries(apiPlan.set)) {
|
|
283
|
+
if (apiVars[key] !== value)
|
|
284
|
+
await railway(rw, ["variable", "set", key, "--stdin", "--service", names.api, "--skip-deploys"], { input: value });
|
|
285
|
+
}
|
|
286
|
+
const webVars = parseVariables(await attempt(() => railway(rw, ["variable", "list", "--service", names.frontend, "--json"])) ?? "");
|
|
287
|
+
const missingWebBuildVars = keys.filter((key) => key.startsWith("VITE_") && !envFile[key] && !webVars[key]);
|
|
288
|
+
if (missingWebBuildVars.length) {
|
|
289
|
+
log.warn(`Not set on the frontend service: ${missingWebBuildVars.join(", ")}. Pass --env-file to provide them.`);
|
|
290
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
291
|
+
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
292
|
+
if (isCancel(proceed) || !proceed)
|
|
293
|
+
return cancel("Nothing was uploaded. Set the variables, then run deploy again.");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const webSet = { PORT: String(frontendStack.port), ...Object.fromEntries(Object.entries(envFile).filter(([key, value]) => key.startsWith("VITE_") && value)) };
|
|
297
|
+
for (const [key, value] of Object.entries(webSet)) {
|
|
298
|
+
if (webVars[key] !== value)
|
|
299
|
+
await railway(rw, ["variable", "set", key, "--stdin", "--service", names.frontend, "--skip-deploys"], { input: value });
|
|
300
|
+
}
|
|
301
|
+
await mkdir(join(project.root, ".railway"), { recursive: true });
|
|
302
|
+
const iacFile = join(project.root, railwayIacPath);
|
|
303
|
+
const existingIac = await readFile(iacFile, "utf8").catch(() => "");
|
|
304
|
+
if (existingIac && !existingIac.includes("The Railway service is named after the package") && !existingIac.includes("Generated from the selected stack packs")) {
|
|
305
|
+
throw new UsageError(`${railwayIacPath} contains custom configuration. Review it and merge the generated two-service configuration before deploying.`);
|
|
306
|
+
}
|
|
307
|
+
await writeFile(iacFile, railwayIacSource(baseName, project.services));
|
|
308
|
+
const packageJson = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
|
|
309
|
+
if (!packageJson.devDependencies?.railway && !packageJson.dependencies?.railway) {
|
|
310
|
+
const pnpm = await resolvePnpm();
|
|
311
|
+
if (!pnpm)
|
|
312
|
+
throw new UsageError("pnpm was not found. Enable it with `corepack enable`, then run deploy again.");
|
|
313
|
+
const [command, ...prefix] = pnpm;
|
|
314
|
+
await run(command, [...prefix, "add", "--save-dev", "--workspace-root", "railway"], project.root);
|
|
315
|
+
}
|
|
316
|
+
progress.message("Applying the two-service Railway configuration");
|
|
317
|
+
await railway(rw, ["config", "apply", "--yes"]);
|
|
318
|
+
const deployments = [];
|
|
319
|
+
for (const name of [names.api, names.frontend]) {
|
|
320
|
+
progress.message(`Uploading ${name}`);
|
|
321
|
+
const started = new Date();
|
|
322
|
+
deployments.push({ name, id: parseDeploymentId(await railway(rw, ["up", "--service", name, "--detach"])), started });
|
|
323
|
+
}
|
|
324
|
+
for (const item of deployments) {
|
|
325
|
+
let state = "QUEUED";
|
|
326
|
+
const deadline = Date.now() + 45 * 60_000;
|
|
327
|
+
while (!finalStatuses.includes(state) && Date.now() < deadline) {
|
|
328
|
+
progress.message(`Railway ${item.name}: ${state.toLowerCase()}`);
|
|
329
|
+
await new Promise((wait) => setTimeout(wait, 10_000));
|
|
330
|
+
const list = await attempt(() => railway(rw, ["deployment", "list", "--service", item.name, "--json"]));
|
|
331
|
+
state = deploymentStatus(list ?? "", item.id, item.started)?.status ?? state;
|
|
332
|
+
}
|
|
333
|
+
if (state !== "SUCCESS")
|
|
334
|
+
throw new Error(`Railway's ${item.name} deployment ended as ${state}. Read its build and runtime logs with railway logs --service ${item.name}.`);
|
|
335
|
+
}
|
|
336
|
+
progress.stop("Both Railway services are healthy");
|
|
337
|
+
note(`${bold("Frontend")} ${url}\n${bold("Services")} ${names.frontend} and ${names.api}\n${bold("API")} private to Railway`, "Deployed to Railway");
|
|
338
|
+
outro(`${dim("Frontend logs")} railway logs --service ${names.frontend}\n${dim("API logs")} railway logs --service ${names.api}\n${dim("Again")} ${cli} deploy`);
|
|
339
|
+
log.info(`Configured: ${serviceSummary}.`);
|
|
340
|
+
}
|
|
176
341
|
export async function deployProject(args) {
|
|
177
342
|
const project = await readProject(process.cwd());
|
|
178
343
|
const cli = await cliCommand(project);
|
|
179
344
|
const platform = project.modules.deploy ?? "docker";
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
if (separateApps.length) {
|
|
183
|
-
throw new UsageError(`
|
|
345
|
+
const separateApps = Object.entries(project.services).filter(([, service]) => service.role !== "fullstack");
|
|
346
|
+
const decoupledSlots = Object.keys(project.stacks).filter((slot) => slot !== FRAMEWORK_SLOT);
|
|
347
|
+
if (decoupledSlots.length && separateApps.length < 2) {
|
|
348
|
+
throw new UsageError(`This project has a frontend and ${decoupledSlots.join(", ")}, but does not record deployment services for them. Upgrade it to a Starter release whose stacks declare deployment, or build each app's Dockerfile and run them on any container host.`);
|
|
184
349
|
}
|
|
185
350
|
if (args.preview)
|
|
186
351
|
return await deployPreview(args, project, cli, await readDeployEnv(args, project));
|
|
352
|
+
if (separateApps.length > 1 && platform === "railway") {
|
|
353
|
+
return await deployRailwayServices(args, project, cli, await readDeployEnv(args, project));
|
|
354
|
+
}
|
|
355
|
+
if (separateApps.length > 1 && platform === "docker") {
|
|
356
|
+
const unsupported = separateApps.filter(([, service]) => !service.platforms.includes("docker"));
|
|
357
|
+
if (unsupported.length) {
|
|
358
|
+
throw new UsageError(`The selected stacks do not declare Docker deployment support for ${unsupported.map(([slot]) => slot).join(", ")}.`);
|
|
359
|
+
}
|
|
360
|
+
const compose = dockerComposeSource(Object.fromEntries(separateApps));
|
|
361
|
+
if (args.dryRun) {
|
|
362
|
+
log.message(`Docker Compose will start ${separateApps.map(([slot, service]) => `${slot} (${service.dockerfile}, port ${service.port})`).join(" and ")}; the frontend proxies /api to the API over the Compose network.`);
|
|
363
|
+
outro("Dry run: nothing was written. Run deploy to generate compose.yaml.");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const path = join(project.root, "compose.yaml");
|
|
367
|
+
if (await pathExists(path))
|
|
368
|
+
throw new UsageError("compose.yaml already exists. Review it and remove it before generating the stack configuration again.");
|
|
369
|
+
await writeFile(path, compose);
|
|
370
|
+
note(`Generated compose.yaml for ${separateApps.map(([slot]) => slot).join(" and ")}.`, "Docker Compose deployment");
|
|
371
|
+
outro(`Copy .env.example to .env.production and set its values, then run from the project root:\n docker compose --env-file .env.production up --build -d\n\nThe frontend is published on its declared port; the API stays on the private Compose network.`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (separateApps.length > 1) {
|
|
375
|
+
throw new UsageError(`The ${platform} deploy adapter does not yet support the selected ${separateApps.map(([slot]) => slot).join(" + ")} services. Select Docker Compose with \`${cli} add deploy docker\`, or deploy each Dockerfile separately.`);
|
|
376
|
+
}
|
|
187
377
|
if (platform === "fly")
|
|
188
378
|
return await deployToFly(args, project, cli, await readDeployEnv(args, project));
|
|
189
379
|
if (platform === "vercel")
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Create a Next.js
|
|
3
|
+
"version": "0.35.0",
|
|
4
|
+
"description": "Create a React app with Next.js, TanStack Start, or Vite and a separate API from the Clubedge Starter.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
7
7
|
"bin": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"clubedge": {
|
|
24
24
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
25
|
-
"starterRef": "v0.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.24.0",
|
|
26
|
+
"starterCommit": "3aaca476bf5ff1db9b5fc4080f7a8a8006436998"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
|
@@ -21,6 +21,7 @@ jobs:
|
|
|
21
21
|
# clubedge:end
|
|
22
22
|
env:
|
|
23
23
|
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres
|
|
24
|
+
UV_LOCKED: "1"
|
|
24
25
|
REDIS_URL: redis://127.0.0.1:6379 # clubedge:only jobs=bullmq
|
|
25
26
|
FIREBASE_AUTH_EMULATOR_HOST: 127.0.0.1:9099 # clubedge:only auth=firebase
|
|
26
27
|
FIREBASE_PROJECT_ID: demo-clubedge # clubedge:only auth=firebase
|
|
@@ -31,6 +32,9 @@ jobs:
|
|
|
31
32
|
with:
|
|
32
33
|
node-version: 22
|
|
33
34
|
cache: pnpm
|
|
35
|
+
# Apps in other languages run their lint, typecheck, and tests with their own tools.
|
|
36
|
+
- uses: astral-sh/setup-uv@v10.3.0
|
|
37
|
+
if: ${{ hashFiles('apps/*/pyproject.toml') != '' }}
|
|
34
38
|
- run: pnpm install --frozen-lockfile
|
|
35
39
|
- run: pnpm format:check
|
|
36
40
|
- run: pnpm lint
|