@clubedge/create-clubedge-app 0.34.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 +11 -0
- package/README.md +18 -15
- 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 +1 -0
- package/template/.github/workflows/preview.yml +35 -2
- package/template/SETUP.md +3 -1
- 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 +25 -0
- package/template/apps/api-fastapi/Dockerfile +17 -3
- package/template/apps/api-fastapi/clubedge.stack.json +33 -3
- package/template/apps/api-fastapi/package.json +1 -1
- package/template/apps/api-fastapi/pyproject.toml +2 -2
- package/template/apps/api-fastapi/src/clubedge_api/app.py +9 -0
- package/template/apps/api-fastapi/src/clubedge_api/auth.py +6 -6
- package/template/apps/api-fastapi/src/clubedge_api/server.py +12 -2
- package/template/apps/api-fastapi/src/clubedge_api/settings.py +6 -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/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 +2 -1
- 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 +1 -1
- 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 +812 -9
- package/template/scripts/smoke-generated-app.mjs +106 -0
- package/template.lock.json +34 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
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
|
+
|
|
5
16
|
## 0.34.0
|
|
6
17
|
|
|
7
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/):
|
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
|
|
@@ -84,7 +84,9 @@ A Python API needs uv; the CLI installs its dependencies with it after `pnpm ins
|
|
|
84
84
|
|
|
85
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`.
|
|
86
86
|
|
|
87
|
-
|
|
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.
|
|
88
90
|
|
|
89
91
|
## Choosing modules
|
|
90
92
|
|
|
@@ -92,7 +94,7 @@ Authentication, file storage, caching, email, payments, background jobs, observa
|
|
|
92
94
|
|
|
93
95
|
| Flag | Options | Default |
|
|
94
96
|
| ----------- | ------------------------------------------ | ---------- |
|
|
95
|
-
| `--auth` | `supabase`, `better-auth`, `none`
|
|
97
|
+
| `--auth` | `supabase`, `better-auth`, `firebase`, `appwrite`, `none` | `supabase` |
|
|
96
98
|
| `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
|
|
97
99
|
| `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
|
|
98
100
|
| `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
|
|
@@ -150,7 +152,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
150
152
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
151
153
|
|
|
152
154
|
# Reproduce a specific Starter release or commit
|
|
153
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
155
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.0
|
|
154
156
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
155
157
|
|
|
156
158
|
# Test local Starter changes before releasing them
|
|
@@ -169,7 +171,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
169
171
|
pnpm clubedge remove storage payments
|
|
170
172
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
171
173
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
172
|
-
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
|
|
173
175
|
```
|
|
174
176
|
|
|
175
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.
|
|
@@ -245,7 +247,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
|
|
|
245
247
|
pnpm clubedge deploy --dry-run # show the plan, create nothing
|
|
246
248
|
```
|
|
247
249
|
|
|
248
|
-
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.
|
|
249
251
|
|
|
250
252
|
### Deploy to Fly.io (experimental)
|
|
251
253
|
|
|
@@ -280,27 +282,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
|
|
|
280
282
|
Each CLI release is tested against one specific Starter release:
|
|
281
283
|
|
|
282
284
|
```text
|
|
283
|
-
create-clubedge-app v0.
|
|
285
|
+
create-clubedge-app v0.35.0
|
|
284
286
|
|
|
|
285
287
|
v
|
|
286
|
-
Clubedge Starter v0.
|
|
287
|
-
commit
|
|
288
|
+
Clubedge Starter v0.24.0
|
|
289
|
+
commit 3aaca476bf5ff1db9b5fc4080f7a8a8006436998
|
|
288
290
|
```
|
|
289
291
|
|
|
290
292
|
The current relationship is:
|
|
291
293
|
|
|
292
294
|
| CLI release | Default Starter release | Meaning |
|
|
293
295
|
| --- | --- | --- |
|
|
294
|
-
| `create-clubedge-app@0.
|
|
296
|
+
| `create-clubedge-app@0.35.0` | `clubedge-starter@v0.24.0` | The CLI's required, tested default |
|
|
295
297
|
|
|
296
298
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
297
299
|
|
|
298
|
-
The `v0.
|
|
299
|
-
`
|
|
300
|
+
The `v0.24.0` tag is expected to resolve to commit
|
|
301
|
+
`3aaca476bf5ff1db9b5fc4080f7a8a8006436998`. The CLI verifies this before
|
|
300
302
|
scaffolding with the default reference and fails rather than silently using a
|
|
301
303
|
moved tag.
|
|
302
304
|
|
|
303
|
-
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.
|
|
304
306
|
|
|
305
307
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
306
308
|
|
|
@@ -355,7 +357,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
355
357
|
1. Make and validate changes in `clubedge-starter`.
|
|
356
358
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
357
359
|
3. Update the Starter package versions.
|
|
358
|
-
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.
|
|
359
361
|
|
|
360
362
|
### CLI maintainers
|
|
361
363
|
|
|
@@ -400,6 +402,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
400
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 |
|
|
401
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`) |
|
|
402
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 |
|
|
403
406
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
404
407
|
|
|
405
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
|
|
@@ -57,6 +57,37 @@ jobs:
|
|
|
57
57
|
project_id: ${{ vars.NEON_PROJECT_ID }}
|
|
58
58
|
compare_branch: ${{ env.NEON_BRANCH }}
|
|
59
59
|
api_key: ${{ secrets.NEON_API_KEY }}
|
|
60
|
+
- name: Identify the app stack for the preview comment
|
|
61
|
+
id: stack
|
|
62
|
+
shell: bash
|
|
63
|
+
run: |
|
|
64
|
+
set -euo pipefail
|
|
65
|
+
if [ -f apps/web/package.json ]; then
|
|
66
|
+
framework=$(node -e 'const p=require("./apps/web/package.json"); const d={...p.dependencies,...p.devDependencies}; console.log(d.vite ? "React + Vite" : d["@tanstack/react-start"] ? "TanStack Start" : "Next.js")')
|
|
67
|
+
else
|
|
68
|
+
framework="Next.js"
|
|
69
|
+
fi
|
|
70
|
+
api=""
|
|
71
|
+
if [ "$framework" = "React + Vite" ]; then
|
|
72
|
+
if [ -f apps/api/pyproject.toml ] || { [ -f apps/api-fastapi/pyproject.toml ] && [ ! -f apps/api/package.json ]; }; then
|
|
73
|
+
api=" + FastAPI (Python)"
|
|
74
|
+
elif [ -f apps/api/package.json ]; then
|
|
75
|
+
api=" + Hono (TypeScript)"
|
|
76
|
+
fi
|
|
77
|
+
fi
|
|
78
|
+
auth=""
|
|
79
|
+
if [ -f clubedge/modules/auth.json ]; then
|
|
80
|
+
auth=$(node -e 'console.log(JSON.parse(require("fs").readFileSync("clubedge/modules/auth.json", "utf8")).default)')
|
|
81
|
+
case "$auth" in
|
|
82
|
+
supabase) auth=" + Supabase Auth" ;;
|
|
83
|
+
better-auth) auth=" + Better Auth" ;;
|
|
84
|
+
firebase) auth=" + Firebase Auth" ;;
|
|
85
|
+
appwrite) auth=" + Appwrite Auth" ;;
|
|
86
|
+
none) auth="" ;;
|
|
87
|
+
*) auth=" + $auth auth" ;;
|
|
88
|
+
esac
|
|
89
|
+
fi
|
|
90
|
+
echo "label=${framework}${api}${auth}" >> "$GITHUB_OUTPUT"
|
|
60
91
|
# clubedge:if deploy=vercel
|
|
61
92
|
# A Vercel preview deployment with the project's Preview variables, except the database,
|
|
62
93
|
# which is the pull request's branch.
|
|
@@ -124,6 +155,7 @@ jobs:
|
|
|
124
155
|
GH_TOKEN: ${{ github.token }}
|
|
125
156
|
PR: ${{ github.event.number }}
|
|
126
157
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
158
|
+
STACK_LABEL: ${{ steps.stack.outputs.label }}
|
|
127
159
|
# Empty without their deploy step.
|
|
128
160
|
VERCEL_URL: ${{ steps.vercel.outputs.url }}
|
|
129
161
|
RAILWAY_URL: ${{ steps.railway.outputs.url }}
|
|
@@ -132,11 +164,12 @@ jobs:
|
|
|
132
164
|
marker="<!-- clubedge-preview -->"
|
|
133
165
|
body="$marker
|
|
134
166
|
**Preview** for ${HEAD_SHA:0:7}
|
|
167
|
+
- Stack: $STACK_LABEL
|
|
135
168
|
- Database: Neon branch \`$NEON_BRANCH\`, with this pull request's migrations"
|
|
136
169
|
if [ -n "$VERCEL_URL" ]; then body="$body
|
|
137
|
-
-
|
|
170
|
+
- Vercel app ($STACK_LABEL): $VERCEL_URL"; fi
|
|
138
171
|
if [ -n "$RAILWAY_URL" ]; then body="$body
|
|
139
|
-
-
|
|
172
|
+
- Railway app ($STACK_LABEL): $RAILWAY_URL"; fi
|
|
140
173
|
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \
|
|
141
174
|
--jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1)
|
|
142
175
|
if [ -n "$id" ]; then
|