@clubedge/create-clubedge-app 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -4
- package/README.md +78 -22
- package/bin/mcp.mjs +2 -0
- package/dist/core/deploy.js +67 -7
- package/dist/core/neon.js +24 -4
- package/dist/core/plan.js +8 -3
- package/dist/core/project.js +22 -0
- package/dist/deploy-preview.js +7 -0
- package/dist/deploy.js +24 -25
- package/dist/index.js +6 -3
- package/dist/inspect.js +8 -6
- package/dist/mcp-server.js +259 -0
- package/dist/modify.js +6 -5
- package/dist/neon.js +30 -18
- package/dist/steps/git.js +4 -4
- package/dist/steps/install.js +2 -2
- package/dist/try.js +5 -4
- package/dist/utils/process.js +2 -2
- package/package.json +14 -7
- package/skills/clubedge-starter/SKILL.md +56 -0
- package/template/SETUP.md +1 -1
- package/template/apps/api/.env.example +11 -2
- package/template/apps/api/package.json +2 -2
- package/template/apps/api-fastapi/.env.example +11 -2
- package/template/apps/api-fastapi/Dockerfile +2 -2
- package/template/apps/api-fastapi/package.json +1 -1
- package/template/apps/api-fastapi/pyproject.toml +1 -1
- package/template/apps/api-fastapi/uv.lock +1 -1
- package/template/apps/start/package.json +2 -2
- package/template/apps/vite/.env.example +6 -1
- package/template/apps/vite/Dockerfile +4 -2
- package/template/apps/vite/package.json +1 -1
- package/template/apps/web/package.json +3 -3
- package/template/docker-compose.yml +16 -1
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-appwrite/package.json +1 -1
- package/template/packages/auth-appwrite/src/conformance.live.test.ts +4 -3
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-firebase/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/contract/package.json +1 -1
- 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/pnpm-lock.yaml +101 -89
- package/template/render.yaml +39 -0
- package/template.lock.json +2 -3
- package/template/apps/vite/Caddyfile +0 -22
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.38.0
|
|
6
|
+
|
|
7
|
+
- **Security: default to Starter `v0.24.2`** (#116). It updates Next.js to 16.4.3: 16.0.0 to 16.4.0 have two critical unauthenticated remote code execution advisories and a high denial of service ([GHSA-p3m5-59mp-jv5r](https://github.com/advisories/GHSA-p3m5-59mp-jv5r) and related). It also updates Zod to 4.6.7 ([GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5)). **Every Next.js project from an earlier Starter should run `pnpm clubedge upgrade`**, or raise `next` to `^16.4.3` in `apps/web/package.json` and run `pnpm install`. The Starter release also:
|
|
8
|
+
- gives a separate API the database URL of its `--infra` (it always pointed at `127.0.0.1:5432/postgres`, wrong for supabase, local, and docker);
|
|
9
|
+
- passes the Vite frontend's `VITE_*` build arguments in `docker-compose.yml`, and runs the Vite image as an unprivileged user;
|
|
10
|
+
- lists the Firebase, Appwrite, Inngest, Supabase, OpenTelemetry, and Sentry variables in `render.yaml`.
|
|
11
|
+
- **Fixes for a frontend and its API** (#115):
|
|
12
|
+
- The database URLs go to `apps/api/.env.local`, the API's, with `--infra neon`, `neon create|claim|status`, `add infra neon`, `try`, and `doctor`. Apps that also declare `DATABASE_URL` (Better Auth on the Vite frontend) get the same URLs.
|
|
13
|
+
- `deploy` with Docker: `compose.yaml` passes the frontend's `VITE_*` build arguments for every auth provider, not only Supabase. With Better Auth, the frontend gets its auth server's variables.
|
|
14
|
+
- `deploy` on Railway: with Better Auth, the frontend service gets the API's `DATABASE_URL`, `BETTER_AUTH_URL`, and a secret. Each service lists its own unset variables.
|
|
15
|
+
- `deploy --preview` refuses a frontend and its API with a clear message: it deploys one app.
|
|
16
|
+
- MCP: with `infra: neon`, `create_project` warns and `plan_project` returns `nextSteps` saying to run `pnpm clubedge neon create`.
|
|
17
|
+
- The README, roadmap, and 0.35.0–0.37.0 changelog entries describe what shipped; `pnpm release` leaves dependency bumps out of the release tables (#114).
|
|
18
|
+
|
|
19
|
+
## 0.37.0
|
|
20
|
+
|
|
21
|
+
- **An MCP server for coding agents** (#110): `create-clubedge-app-mcp`, in the same package, lets an agent create projects with the CLI's pinned Starter and its compatibility rules. Run it from the workspace where projects should go:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `get_starter_capabilities` lists the frameworks, API stacks, modules, defaults, and what each option requires.
|
|
28
|
+
- `validate_project_config` checks a choice of stacks and modules.
|
|
29
|
+
- `plan_project` previews a project: the stacks and modules, the files, the environment files, toolchains such as uv, and the setup steps. It writes nothing.
|
|
30
|
+
- `create_project` writes the project to a new or empty folder beneath the server's working directory. It refuses absolute paths, `..`, and symlinks that lead outside. Git and dependency installation are off unless asked for, and their output goes to stderr, never into the MCP stream.
|
|
31
|
+
- **An agent skill**, `skills/clubedge-starter/SKILL.md`, in the package: it tells Codex, Claude Code, and other agents to plan before creating, to use `add`, `remove`, and `upgrade` for existing projects, and to fall back to the CLI without MCP. The README shows how to install it and connect the server.
|
|
32
|
+
- CI and the release workflow start the MCP server from the packed package and call every tool.
|
|
33
|
+
- Still on Starter `v0.24.1`.
|
|
34
|
+
|
|
5
35
|
## 0.36.0
|
|
6
36
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
- Bump @types/node from 22.20.5 to 26.6.5 (#68).
|
|
10
|
-
- Default to Starter `v0.24.1` (#107).
|
|
37
|
+
- **Security: default to Starter `v0.24.1`** (#107). It updates the FastAPI API's locked `certifi` to 2026.10.19, for the critical advisory [GHSA-6q48-6c7w-9gw2](https://github.com/certifi/python-certifi/security/advisories/GHSA-6q48-6c7w-9gw2). If your project has `--api fastapi`, run `pnpm clubedge upgrade`, then `uv sync` in `apps/api`. The Starter's pull request previews also skip secret-backed deploys for Dependabot.
|
|
38
|
+
- Dependency updates: giget 3.3.1 (#69), vitest 5.0.5 (#104), and @types/node 26.6.5 (#68).
|
|
11
39
|
|
|
12
40
|
## 0.35.0
|
|
13
41
|
|
package/README.md
CHANGED
|
@@ -27,6 +27,61 @@ Add, remove, or upgrade them later without losing your own changes.
|
|
|
27
27
|
pnpm dlx @clubedge/create-clubedge-app my-app
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## MCP server
|
|
31
|
+
|
|
32
|
+
Coding agents can connect to a local MCP server to inspect the capabilities of this CLI's pinned Starter release, validate stack and module choices, preview a complete scaffold plan, and generate a project in the server's working directory. The server exposes `get_starter_capabilities`, `validate_project_config`, `plan_project`, and `create_project`. Planning and validation do not write files. Project creation does not initialize Git or install dependencies unless those options are requested, and makes no network calls of its own: with `infra: neon`, `plan_project` lists `pnpm clubedge neon create` as a next step and `create_project` returns it as a warning, since the database is left for you to create.
|
|
33
|
+
|
|
34
|
+
Run it from the workspace where projects should be created:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Configure an MCP client to launch `npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp` with its working directory set to the workspace. In Codex, add this to `~/.codex/config.toml` and replace the example with the absolute path of the workspace where projects should be created:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
[mcp_servers.clubedge-starter]
|
|
44
|
+
command = "npx"
|
|
45
|
+
args = ["--yes", "--package", "@clubedge/create-clubedge-app", "create-clubedge-app-mcp"]
|
|
46
|
+
cwd = "D:/path/to/workspace"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The MCP server is included in the same package and uses the CLI's bundled Starter and compatibility rules. Its create tool only writes beneath the configured working directory and refuses a non-empty destination.
|
|
50
|
+
|
|
51
|
+
### Agent skill
|
|
52
|
+
|
|
53
|
+
The npm package includes the Agent Skill at [`skills/clubedge-starter/SKILL.md`](skills/clubedge-starter/SKILL.md). Download and extract the package in PowerShell:
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
$skillTarball = npm pack @clubedge/create-clubedge-app@latest
|
|
57
|
+
tar -xzf $skillTarball
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Then install it for the current user:
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
# Codex
|
|
64
|
+
New-Item -ItemType Directory -Force "$HOME/.agents/skills/clubedge-starter" | Out-Null
|
|
65
|
+
Copy-Item .\package\skills\clubedge-starter\SKILL.md "$HOME/.agents/skills/clubedge-starter/SKILL.md"
|
|
66
|
+
|
|
67
|
+
# Claude Code
|
|
68
|
+
New-Item -ItemType Directory -Force "$HOME/.claude/skills/clubedge-starter" | Out-Null
|
|
69
|
+
Copy-Item .\package\skills\clubedge-starter\SKILL.md "$HOME/.claude/skills/clubedge-starter/SKILL.md"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
These commands create `package/` in the current directory. Remove that extracted folder and tarball when the copy is done. From a clone of this repository, the same copy commands work with `skills/...` instead of `package/skills/...`.
|
|
73
|
+
|
|
74
|
+
For a repository-only install, use `.agents/skills/clubedge-starter/` for Codex or `.claude/skills/clubedge-starter/` for Claude Code instead of the `$HOME` paths. Restart the agent session after installing. In Codex, ask it to `Use the clubedge-starter skill to inspect the available stacks and modules. Do not create a project.` In Claude Code, invoke `/clubedge-starter` and give it the same request. The skill uses live MCP capabilities when available, so its choices follow the Starter release installed with the server.
|
|
75
|
+
|
|
76
|
+
To connect the MCP server in Claude Code, run this from the workspace where projects should be created:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
claude mcp add --scope local --transport stdio clubedge-starter -- npx -y --package @clubedge/create-clubedge-app create-clubedge-app-mcp
|
|
80
|
+
claude mcp get clubedge-starter
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then open Claude Code in that workspace, invoke `/clubedge-starter`, and ask it to inspect the available stacks and modules without creating a project. In Codex, use the `cwd` in `~/.codex/config.toml` above, restart Codex, and ask the same prompt. To exercise project creation, ask the agent to create a named project in a new child folder with Git and dependency installation disabled; verify the folder and report before enabling those optional actions.
|
|
84
|
+
|
|
30
85
|
## Why create-clubedge-app
|
|
31
86
|
|
|
32
87
|
Copying a starter is easy; living with it is not. You delete the services you don't use by hand, and once you've changed the code, every later improvement to the starter has to be copied over by hand too.
|
|
@@ -47,7 +102,7 @@ pnpm dev
|
|
|
47
102
|
|
|
48
103
|
Or start it with npm: `npx @clubedge/create-clubedge-app my-app`. The generated project is a pnpm workspace, so dependencies are installed with pnpm either way; the CLI falls back to `corepack pnpm` when `pnpm` is not on your path. Without a destination, the CLI asks where to create the project.
|
|
49
104
|
|
|
50
|
-
The generated project includes `apps/web/.env.local
|
|
105
|
+
The generated project includes `apps/web/.env.local` (and `apps/api/.env.local` with a separate API), created from the example and pointing at the local services you chose. Its `SETUP.md` describes every variable and provider. The root route is a landing page named after your project, and the dashboard is at `/dashboard`.
|
|
51
106
|
|
|
52
107
|
The default Starter release ships inside the CLI package, so GitHub is only contacted when you pass `--ref`. Internet access is needed to install dependencies.
|
|
53
108
|
|
|
@@ -86,7 +141,7 @@ They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend c
|
|
|
86
141
|
|
|
87
142
|
With `--auth supabase`, the Vite app owns the browser session and attaches its access token to contract requests. Hono or FastAPI verifies that token with Supabase Auth and returns the contract's shared user shape. Configure `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_URL`, and `SUPABASE_PUBLISHABLE_KEY` in the generated env examples; no Supabase service-role key goes to the browser.
|
|
88
143
|
|
|
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
|
|
144
|
+
The decoupled stacks support Supabase Auth, Better Auth, Firebase, Appwrite, or `none`, plus memory cache, any local infrastructure (the API's `apps/api/.env.local` holds `DATABASE_URL`, since the API owns the database), and Docker or Railway deployment. With Docker, `pnpm clubedge deploy` writes a `compose.yaml` that builds the frontend and API as separate services; copy the variables of `.env.example` and `apps/api/.env.example` to `.env.production`, fill in their values, and run `docker compose --env-file .env.production up --build -d`. The frontend's `VITE_*` values are build arguments, compiled into its bundle; with Better Auth, its Node server also gets the auth variables (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, and the email settings). With Railway, deploy provisions a public frontend and a private API, wires the frontend proxy to the API, and runs migrations before releasing the API. See [docs/stack-packs.md](docs/stack-packs.md) for how stacks and deployment facts are described.
|
|
90
145
|
|
|
91
146
|
## Choosing modules
|
|
92
147
|
|
|
@@ -98,8 +153,8 @@ Authentication, file storage, caching, email, payments, background jobs, observa
|
|
|
98
153
|
| `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
|
|
99
154
|
| `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
|
|
100
155
|
| `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
|
|
101
|
-
| `--payments` | `stripe
|
|
102
|
-
| `--jobs` | `pg-boss`, `none` | `none` |
|
|
156
|
+
| `--payments` | `stripe` (needs an auth module), `none` | `none` |
|
|
157
|
+
| `--jobs` | `pg-boss`, `bullmq` (needs the `redis` cache), `inngest`, `none` | `none` |
|
|
103
158
|
| `--observability` | `otel` (OpenTelemetry, to any OTLP collector), `sentry`, `none` | `none` |
|
|
104
159
|
| `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
|
|
105
160
|
| `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `neon` (a free hosted Neon database), `none` | `docker` |
|
|
@@ -152,7 +207,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
152
207
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
153
208
|
|
|
154
209
|
# Reproduce a specific Starter release or commit
|
|
155
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.
|
|
210
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.24.2
|
|
156
211
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
157
212
|
|
|
158
213
|
# Test local Starter changes before releasing them
|
|
@@ -171,7 +226,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
171
226
|
pnpm clubedge remove storage payments
|
|
172
227
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
173
228
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
174
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.24.
|
|
229
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.24.2 # or a specific tag, branch, or commit
|
|
175
230
|
```
|
|
176
231
|
|
|
177
232
|
The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
|
|
@@ -179,7 +234,7 @@ The CLI renders the project twice, from the Starter commit and modules recorded
|
|
|
179
234
|
- Files you never changed are updated, added, or deleted.
|
|
180
235
|
- Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve. The Starter's whole new version of each such file is saved in `.clubedge/incoming/` to compare with yours (`git diff --no-index <file> .clubedge/incoming/<file>`); Git ignores that folder, so delete it when you are done.
|
|
181
236
|
- Your own files are never touched.
|
|
182
|
-
- `apps/web/.env.local` keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
|
|
237
|
+
- Each app's local env file (`apps/web/.env.local`, and `apps/api/.env.local` with a separate API) keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
|
|
183
238
|
- `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
|
|
184
239
|
|
|
185
240
|
Several modules in one command are applied as one change, and their requirements are checked together, so `add auth supabase storage supabase` works even though Supabase Storage needs Supabase Auth. When lines conflict, the CLI lists each file with its number of conflict blocks. Before an upgrade, it lists the Starter releases it crosses with the sections of their release notes, such as `v0.13.0 Observability, Tests, Also`.
|
|
@@ -197,7 +252,7 @@ pnpm db:migrate
|
|
|
197
252
|
pnpm dev
|
|
198
253
|
```
|
|
199
254
|
|
|
200
|
-
The CLI creates a free database through Neon's [claimable Postgres](https://neon.com/docs/reference/claimable-postgres) and writes two URLs into `apps/web/.env.local
|
|
255
|
+
The CLI creates a free database through Neon's [claimable Postgres](https://neon.com/docs/reference/claimable-postgres) and writes two URLs into the env file of the app that owns the database, `apps/web/.env.local`, or `apps/api/.env.local` in a project with a separate API (a Vite frontend whose Better Auth server connects too gets the same `DATABASE_URL`): `DATABASE_URL`, the pooled connection the app uses, and `DATABASE_URL_DIRECT`, the direct one that migrations and the background jobs worker use, since a transaction pooler can break them. Neon keeps an unclaimed database for 72 hours, with up to 100 MB:
|
|
201
256
|
|
|
202
257
|
```sh
|
|
203
258
|
pnpm clubedge neon # where it stands and how long it has left
|
|
@@ -218,7 +273,7 @@ pnpm clubedge status # Starter release, modules, Git state, and whether a new
|
|
|
218
273
|
pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and pnpm check:services
|
|
219
274
|
```
|
|
220
275
|
|
|
221
|
-
`status` ends with the command to run next, such as `pnpm clubedge upgrade` when a newer Starter release is available; it asks npm for the newest CLI and still works offline. `doctor` checks that Node.js and pnpm meet the project's versions, that dependencies are installed, that `pnpm-lock.yaml` matches every `package.json`, and that `apps/web/.env.local`
|
|
276
|
+
`status` ends with the command to run next, such as `pnpm clubedge upgrade` when a newer Starter release is available; it asks npm for the newest CLI and still works offline. `doctor` checks that Node.js and pnpm meet the project's versions, that dependencies are installed, that `pnpm-lock.yaml` matches every `package.json`, and that each app's local env file has every variable in its example (`apps/web/.env.local` and `.env.example`, and `apps/api/.env.local` and `apps/api/.env.example` with a separate API), then runs `pnpm check:services` (skip it with `--no-services`). It exits with an error when a check fails, so it also works in CI.
|
|
222
277
|
|
|
223
278
|
## Try it on a Railway VM (experimental)
|
|
224
279
|
|
|
@@ -247,7 +302,7 @@ pnpm clubedge deploy --env-file .env.staging # with another file of product
|
|
|
247
302
|
pnpm clubedge deploy --dry-run # show the plan, create nothing
|
|
248
303
|
```
|
|
249
304
|
|
|
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
|
|
305
|
+
With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package and the databases selected by the modules. Full-stack projects get one public app service. A Vite project gets two services: the frontend has the public `*.up.railway.app` domain, and the API stays private; the frontend's Node server (`node server.mjs`) forwards the API routes to it over Railway's private network. API variables, including `FRONTEND_URL`, go on the API service; `VITE_*` values go on the frontend service for its build. With Better Auth, the frontend's Node server runs auth: it gets the same `DATABASE_URL` as the API, a generated `BETTER_AUTH_SECRET`, the email settings, and `BETTER_AUTH_URL`, the public frontend URL both services use. Each service's unset variables are reported from its own env example. The API's migration command runs before the API release, and the frontend and API deployments are both awaited. Database variables are references to Railway's Postgres and Redis by default. Values from `.env.production` (or `--env-file`) take precedence; external database URLs stay external, and localhost URLs are ignored. `.railway/railway.ts` describes the selected service topology and health checks; `deploy` applies it and adds Railway's TypeScript SDK (`railway`) as a dev dependency. Commit the generated file and package changes. It needs Railway CLI 5.42.1 or newer. Running `deploy` again reuses the services, so it also adds variables and ships new releases. Railway bills usage on your plan.
|
|
251
306
|
|
|
252
307
|
### Deploy to Fly.io (experimental)
|
|
253
308
|
|
|
@@ -273,7 +328,7 @@ In a project that deploys to Vercel or Railway, `pnpm clubedge deploy --preview`
|
|
|
273
328
|
NEON_API_KEY=... NEON_PROJECT_ID=... pnpm clubedge deploy --preview
|
|
274
329
|
```
|
|
275
330
|
|
|
276
|
-
With `NEON_API_KEY` and `NEON_PROJECT_ID` (in the shell, or in the env file), the preview gets its own [Neon branch](https://neon.com/docs/introduction/branching), `preview/<git branch>`: a copy of the production database made in seconds that shares its storage until it changes. `deploy` creates it, or reuses it on later runs, applies this branch's migrations to it, and points the preview's `DATABASE_URL` and `DATABASE_URL_DIRECT` at it. On Vercel, the preview is a preview deployment with the project's Preview variables, and the branch's URLs are set as Preview variables of your Git branch, which needs the Vercel project connected to its Git repository; without the Neon settings, it uses the Preview `DATABASE_URL`. On Railway, it is an environment named `preview-<git branch>`, copied from production with its own domain; without the Neon settings, it gets its own empty copy of Railway's Postgres, and when production's database comes from elsewhere, `deploy --preview` refuses, since the copy would use production's data. Production is not changed.
|
|
331
|
+
With `NEON_API_KEY` and `NEON_PROJECT_ID` (in the shell, or in the env file), the preview gets its own [Neon branch](https://neon.com/docs/introduction/branching), `preview/<git branch>`: a copy of the production database made in seconds that shares its storage until it changes. `deploy` creates it, or reuses it on later runs, applies this branch's migrations to it, and points the preview's `DATABASE_URL` and `DATABASE_URL_DIRECT` at it. On Vercel, the preview is a preview deployment with the project's Preview variables, and the branch's URLs are set as Preview variables of your Git branch, which needs the Vercel project connected to its Git repository; without the Neon settings, it uses the Preview `DATABASE_URL`. On Railway, it is an environment named `preview-<git branch>`, copied from production with its own domain; without the Neon settings, it gets its own empty copy of Railway's Postgres, and when production's database comes from elsewhere, `deploy --preview` refuses, since the copy would use production's data. Production is not changed. It deploys a single app for now: a Vite frontend and its API are two services, so `deploy --preview` refuses them and explains how to copy production's Railway environment yourself.
|
|
277
332
|
|
|
278
333
|
Projects created with `--infra neon` also have `.github/workflows/preview.yml`, which does this for every pull request in CI: a Neon branch per pull request with its migrations, the schema diff as a comment, a Vercel or Railway preview, and cleanup when the pull request closes. The Starter's SETUP.md lists the secrets it needs.
|
|
279
334
|
|
|
@@ -282,27 +337,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
|
|
|
282
337
|
Each CLI release is tested against one specific Starter release:
|
|
283
338
|
|
|
284
339
|
```text
|
|
285
|
-
create-clubedge-app v0.
|
|
340
|
+
create-clubedge-app v0.38.0
|
|
286
341
|
|
|
|
287
342
|
v
|
|
288
|
-
Clubedge Starter v0.24.
|
|
289
|
-
commit
|
|
343
|
+
Clubedge Starter v0.24.2
|
|
344
|
+
commit 4450d2f00a89f1a3929a5c9d2c1541e0d65df891
|
|
290
345
|
```
|
|
291
346
|
|
|
292
347
|
The current relationship is:
|
|
293
348
|
|
|
294
349
|
| CLI release | Default Starter release | Meaning |
|
|
295
350
|
| --- | --- | --- |
|
|
296
|
-
| `create-clubedge-app@0.
|
|
351
|
+
| `create-clubedge-app@0.38.0` | `clubedge-starter@v0.24.2` | The CLI's required, tested default |
|
|
297
352
|
|
|
298
353
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
299
354
|
|
|
300
|
-
The `v0.24.
|
|
301
|
-
`
|
|
355
|
+
The `v0.24.2` tag is expected to resolve to commit
|
|
356
|
+
`4450d2f00a89f1a3929a5c9d2c1541e0d65df891`. The CLI verifies this before
|
|
302
357
|
scaffolding with the default reference and fails rather than silently using a
|
|
303
358
|
moved tag.
|
|
304
359
|
|
|
305
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.24.
|
|
360
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.24.2`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
306
361
|
|
|
307
362
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
308
363
|
|
|
@@ -357,7 +412,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
357
412
|
1. Make and validate changes in `clubedge-starter`.
|
|
358
413
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
359
414
|
3. Update the Starter package versions.
|
|
360
|
-
4. Commit `main`, create a release tag such as `v0.24.
|
|
415
|
+
4. Commit `main`, create a release tag such as `v0.24.2`, and publish the Starter release.
|
|
361
416
|
|
|
362
417
|
### CLI maintainers
|
|
363
418
|
|
|
@@ -402,9 +457,10 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
402
457
|
| 0.32.0 | v0.21.0 | Pluggable authentication: `--auth firebase` and `--auth appwrite` next to Supabase and Better Auth, and a conformance suite every provider passes |
|
|
403
458
|
| 0.33.0 | v0.22.0 | Starter v0.22.0: stack packs (template schema 4), and a React + Vite frontend with a separate Hono API (`--framework vite --api hono`) |
|
|
404
459
|
| 0.34.0 | v0.23.0 | Starter v0.23.0: a FastAPI API in Python (`--framework vite --api fastapi`), generated with no CLI change |
|
|
405
|
-
| 0.35.0 | v0.24.0 |
|
|
406
|
-
| 0.36.0 | v0.24.1 |
|
|
407
|
-
|
|
|
460
|
+
| 0.35.0 | v0.24.0 | `deploy` for a frontend and its API (Docker Compose, Railway); Starter v0.24.0: all four auth providers on React + Vite with Hono or FastAPI |
|
|
461
|
+
| 0.36.0 | v0.24.1 | Starter v0.24.1: security update of FastAPI's `certifi`; dependency updates |
|
|
462
|
+
| 0.37.0 | v0.24.1 | An MCP server (capabilities, validate, plan, create) and an agent skill for coding agents |
|
|
463
|
+
| 0.38.0 | v0.24.2 | Starter v0.24.2: Next.js security update (critical RCE advisories); database URLs, Neon, and deploy variables for a frontend and its API |
|
|
408
464
|
|
|
409
465
|
## Community
|
|
410
466
|
|
package/bin/mcp.mjs
ADDED
package/dist/core/deploy.js
CHANGED
|
@@ -10,18 +10,36 @@ export function serviceName(packageName) {
|
|
|
10
10
|
const name = packageName.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
11
11
|
return name || "web";
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A Docker Compose production topology rendered from the selected stack service descriptors.
|
|
15
|
+
* `frontendKeys` are the variables the frontend's env example lists: its VITE_* values become build
|
|
16
|
+
* arguments (Vite compiles them in), the others runtime variables, such as Better Auth's when the
|
|
17
|
+
* frontend's server runs auth. Compose reads their values from its --env-file.
|
|
18
|
+
*/
|
|
19
|
+
export function dockerComposeSource(services, frontendKeys = []) {
|
|
15
20
|
const api = Object.values(services).find((service) => service.role === "api");
|
|
16
|
-
const
|
|
21
|
+
const web = Object.values(services).find((service) => service.role === "frontend");
|
|
22
|
+
const mapping = (indent, values) => values.map(([key, value]) => `\n${indent}${key}: ${value}`).join("");
|
|
23
|
+
const fromEnv = (key, fallback = "") => `\${${key}:-${fallback}}`;
|
|
24
|
+
const buildKeys = frontendKeys.filter((key) => key.startsWith("VITE_"));
|
|
25
|
+
// API_URL is the API's address on the Compose network, and PORT the one the image declares.
|
|
26
|
+
const runtimeKeys = frontendKeys.filter((key) => !key.startsWith("VITE_") && key !== "API_URL" && key !== "PORT");
|
|
27
|
+
// Better Auth runs in the frontend's server: the API asks it for the session over the Compose network.
|
|
28
|
+
const authUrl = web && frontendKeys.includes("BETTER_AUTH_URL") ? `http://${web.id}:${web.port}` : null;
|
|
29
|
+
const rows = Object.entries(services).map(([, service]) => {
|
|
17
30
|
const name = service.id;
|
|
18
31
|
const frontend = service.role === "frontend";
|
|
19
|
-
const buildArgs = frontend
|
|
20
|
-
? `\n args
|
|
32
|
+
const buildArgs = frontend && buildKeys.length
|
|
33
|
+
? `\n args:${mapping(" ", buildKeys.map((key) => [key, fromEnv(key)]))}`
|
|
21
34
|
: "";
|
|
35
|
+
// Better Auth's public URL defaults to the port Compose publishes.
|
|
36
|
+
const runtime = runtimeKeys.map((key) => [key, fromEnv(key, key === "BETTER_AUTH_URL" ? `http://localhost:${service.port}` : "")]);
|
|
37
|
+
// The API listens on every interface, whatever HOST the env file has from development.
|
|
38
|
+
const apiValues = [["HOST", "0.0.0.0"], ...(authUrl ? [["BETTER_AUTH_URL", authUrl]] : [])];
|
|
39
|
+
const apiEnv = service.role === "api" ? `\n environment:${mapping(" ", apiValues)}` : "";
|
|
22
40
|
const env = frontend && api
|
|
23
|
-
? `\n environment
|
|
24
|
-
: `\n env_file:\n - path: .env.production\n required: false`;
|
|
41
|
+
? `\n environment:${mapping(" ", [["API_URL", `http://${api.id}:${api.port}`], ...runtime])}`
|
|
42
|
+
: `\n env_file:\n - path: .env.production\n required: false${apiEnv}`;
|
|
25
43
|
const ports = frontend ? `\n ports:\n - "${service.port}:${service.port}"` : `\n expose:\n - "${service.port}"`;
|
|
26
44
|
const depends = frontend && api ? `\n depends_on:\n - ${api.id}` : "";
|
|
27
45
|
return ` ${name}:\n build:\n context: .\n dockerfile: ${service.dockerfile}${buildArgs}${env}${ports}${depends}`;
|
|
@@ -123,6 +141,48 @@ export function planVariables(input) {
|
|
|
123
141
|
const unset = keys.filter((key) => set[key] === undefined && !existing[key] && key !== "DATABASE_URL_DIRECT");
|
|
124
142
|
return { set, unset };
|
|
125
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* The variables of a frontend and an API deployed as two services. The API gets what planVariables
|
|
146
|
+
* provides with the env file's server values, and the frontend's URL. The frontend gets its port,
|
|
147
|
+
* the env file's VITE_* build values, and the runtime values its own example lists. With Better
|
|
148
|
+
* Auth the frontend's server runs auth: it gets the API's database, the secret (generated unless
|
|
149
|
+
* the service already has one), and BETTER_AUTH_URL, the public URL the API asks for the session.
|
|
150
|
+
* Each service's unset variables come from its own example.
|
|
151
|
+
*/
|
|
152
|
+
export function planServicePair(input) {
|
|
153
|
+
const { modules, frontendKeys, apiKeys, url, existing, envFile, secret, databases, ports } = input;
|
|
154
|
+
const betterAuth = modules.auth === "better-auth";
|
|
155
|
+
// A development env file's localhost URL would send sign-in links to this machine.
|
|
156
|
+
const authUrl = envFile.BETTER_AUTH_URL && !isLocalUrl(envFile.BETTER_AUTH_URL) ? envFile.BETTER_AUTH_URL : url;
|
|
157
|
+
const apiEnv = Object.fromEntries(Object.entries(envFile).filter(([key]) => !key.startsWith("VITE_")));
|
|
158
|
+
const api = planVariables({ modules, keys: apiKeys, url, existing: existing.api, envFile: apiEnv, secret, databases, port: ports.api });
|
|
159
|
+
// Better Auth's secret belongs to the frontend's server; the API only asks it for the session.
|
|
160
|
+
if (!apiEnv.BETTER_AUTH_SECRET)
|
|
161
|
+
delete api.set.BETTER_AUTH_SECRET;
|
|
162
|
+
api.set.FRONTEND_URL = url;
|
|
163
|
+
if (betterAuth)
|
|
164
|
+
api.set.BETTER_AUTH_URL = authUrl;
|
|
165
|
+
// The image listens on every interface: its Dockerfile sets HOST.
|
|
166
|
+
api.unset = api.unset.filter((key) => api.set[key] === undefined && key !== "HOST");
|
|
167
|
+
// railway.ts sets API_URL to the API's private address; the platform provides the database.
|
|
168
|
+
const provided = ["API_URL", "PORT", "BETTER_AUTH_URL", ...Object.values(databaseVariables), "DATABASE_URL_DIRECT"];
|
|
169
|
+
const set = { PORT: String(ports.frontend) };
|
|
170
|
+
for (const [key, value] of Object.entries(envFile)) {
|
|
171
|
+
if (value && (key.startsWith("VITE_") || (frontendKeys.includes(key) && !provided.includes(key))))
|
|
172
|
+
set[key] = value;
|
|
173
|
+
}
|
|
174
|
+
if (betterAuth) {
|
|
175
|
+
const database = api.set.DATABASE_URL ?? existing.api.DATABASE_URL;
|
|
176
|
+
if (database)
|
|
177
|
+
set.DATABASE_URL = database;
|
|
178
|
+
set.BETTER_AUTH_URL = authUrl;
|
|
179
|
+
if (!set.BETTER_AUTH_SECRET && !existing.frontend.BETTER_AUTH_SECRET)
|
|
180
|
+
set.BETTER_AUTH_SECRET = secret();
|
|
181
|
+
}
|
|
182
|
+
// Optional: without VITE_API_URL the browser calls the API through the frontend's /api proxy.
|
|
183
|
+
const unset = frontendKeys.filter((key) => set[key] === undefined && !existing.frontend[key] && !["API_URL", "VITE_API_URL"].includes(key));
|
|
184
|
+
return { frontend: { set, unset }, api };
|
|
185
|
+
}
|
|
126
186
|
/**
|
|
127
187
|
* DATABASE_URL_DIRECT for a database hosted elsewhere: the env file's, or for a pooled Neon URL the
|
|
128
188
|
* same URL without -pooler. Never next to the platform's own database (no external URL), where it
|
package/dist/core/neon.js
CHANGED
|
@@ -210,20 +210,22 @@ export function savedDatabaseFor(databases, url) {
|
|
|
210
210
|
return databases.find((database) => neonEndpoint(`postgresql://${database.host}`) === endpoint) ?? null;
|
|
211
211
|
}
|
|
212
212
|
/* ------------------------------ Env files ------------------------------ */
|
|
213
|
+
/** A KEY= line, with or without `export`, as parseEnvFile reads it. */
|
|
214
|
+
const assignment = (key, flags = "") => new RegExp(`^\\s*(export\\s+)?${key}\\s*=`, flags);
|
|
213
215
|
/**
|
|
214
|
-
* Sets variables in an env file's text: each KEY= line is replaced (the first one
|
|
215
|
-
* the value goes), and a missing variable is added at the end.
|
|
216
|
+
* Sets variables in an env file's text: each KEY= line is replaced (the first one, keeping its
|
|
217
|
+
* `export`; a comment after the value goes), and a missing variable is added at the end.
|
|
216
218
|
*/
|
|
217
219
|
export function setEnvValues(text, values) {
|
|
218
220
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
219
221
|
const lines = text.split(/\r?\n/);
|
|
220
222
|
const missing = [];
|
|
221
223
|
for (const [key, value] of Object.entries(values)) {
|
|
222
|
-
const at = lines.findIndex((line) =>
|
|
224
|
+
const at = lines.findIndex((line) => assignment(key).test(line));
|
|
223
225
|
if (at === -1)
|
|
224
226
|
missing.push(`${key}=${value}`);
|
|
225
227
|
else
|
|
226
|
-
lines[at] = `${key}=${value}`;
|
|
228
|
+
lines[at] = `${assignment(key).exec(lines[at])[1] ? "export " : ""}${key}=${value}`;
|
|
227
229
|
}
|
|
228
230
|
let result = lines.join(eol);
|
|
229
231
|
if (missing.length) {
|
|
@@ -233,6 +235,24 @@ export function setEnvValues(text, values) {
|
|
|
233
235
|
}
|
|
234
236
|
return result;
|
|
235
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Sets only the variables an env file already declares, or returns null when it declares none:
|
|
240
|
+
* for another app that connects to the database too, such as Better Auth's server in a Vite frontend.
|
|
241
|
+
*/
|
|
242
|
+
export function setDeclaredEnvValues(text, values) {
|
|
243
|
+
const declared = Object.entries(values).filter(([key]) => assignment(key, "m").test(text));
|
|
244
|
+
return declared.length ? setEnvValues(text, Object.fromEntries(declared)) : null;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* What is left to do for --infra neon when the project was created without a database, as MCP's
|
|
248
|
+
* create_project does (it makes no network calls), or null for other infrastructure.
|
|
249
|
+
*/
|
|
250
|
+
export function neonSetupStep(plan) {
|
|
251
|
+
if (!plan.modules.some(({ id, option }) => id === "infra" && option === "neon"))
|
|
252
|
+
return null;
|
|
253
|
+
const where = plan.databaseEnvFile ? ` in ${plan.databaseEnvFile}` : "";
|
|
254
|
+
return `The project uses Neon and has no database yet: after pnpm install, run \`pnpm clubedge neon create\` in the project for a free one (it sets DATABASE_URL and DATABASE_URL_DIRECT${where}), then \`pnpm db:migrate\`.`;
|
|
255
|
+
}
|
|
236
256
|
/* ------------------------------ Branches (Neon API) ------------------------------ */
|
|
237
257
|
// `deploy --preview` gives a preview its own branch of the user's Neon project: a copy of the
|
|
238
258
|
// production branch made in seconds, which shares storage until it changes.
|
package/dist/core/plan.js
CHANGED
|
@@ -412,6 +412,12 @@ export function createPlan(input) {
|
|
|
412
412
|
apply: (text) => customizeSiteConfig(text, identity),
|
|
413
413
|
});
|
|
414
414
|
}
|
|
415
|
+
const envFiles = selected
|
|
416
|
+
.map(({ slot }) => ({ from: slot.env.example, to: slot.env.target }))
|
|
417
|
+
.filter(({ from }) => has(from));
|
|
418
|
+
// The API's env file even when its example is missing, as databaseEnvFile(project) resolves it
|
|
419
|
+
// later from the API's service, so the URLs written at creation are where commands look.
|
|
420
|
+
const apiEnvFile = selected.find(({ stack }) => stack.role === "api")?.slot.env.target;
|
|
415
421
|
return {
|
|
416
422
|
targetDirectory: input.targetDirectory,
|
|
417
423
|
identity,
|
|
@@ -420,9 +426,8 @@ export function createPlan(input) {
|
|
|
420
426
|
modules,
|
|
421
427
|
files,
|
|
422
428
|
edits,
|
|
423
|
-
envFiles
|
|
424
|
-
|
|
425
|
-
.filter(({ from }) => has(from)),
|
|
429
|
+
envFiles,
|
|
430
|
+
databaseEnvFile: apiEnvFile ?? envFiles[0]?.to ?? null,
|
|
426
431
|
toolchains: selected.flatMap(({ slot, stack }) => stack.toolchain ? [{ directory: slot.path, app: stack.name, toolchain: stack.toolchain }] : []),
|
|
427
432
|
git: input.git,
|
|
428
433
|
install: input.install,
|
package/dist/core/project.js
CHANGED
|
@@ -42,6 +42,28 @@ export async function readProject(directory) {
|
|
|
42
42
|
current = parent;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
/** The web app's: every project has it, with its example at the root. */
|
|
46
|
+
const webEnvFile = { example: ".env.example", local: "apps/web/.env.local" };
|
|
47
|
+
const appEnvFile = (app) => ({ example: `${app}/.env.example`, local: `${app}/.env.local` });
|
|
48
|
+
/** The directory of the project's separate API, or null when the app is full-stack. */
|
|
49
|
+
function apiDirectory(project) {
|
|
50
|
+
const api = Object.values(project.services).find(({ role }) => role === "api");
|
|
51
|
+
// Projects from Starter 0.22 record the API's stack but no services; the API is in apps/api.
|
|
52
|
+
return api?.app ?? (project.stacks.api ? "apps/api" : null);
|
|
53
|
+
}
|
|
54
|
+
/** Each app's env files: the web app's, then the API's, which keeps both in its own folder. */
|
|
55
|
+
export function projectEnvFiles(project) {
|
|
56
|
+
const api = apiDirectory(project);
|
|
57
|
+
return [webEnvFile, ...(api ? [appEnvFile(api)] : [])];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The env file with the database URLs: the API's when the project has one, since the API owns
|
|
61
|
+
* the database (its migrations, its jobs), else the web app's. Matches the plan's databaseEnvFile.
|
|
62
|
+
*/
|
|
63
|
+
export function databaseEnvFile(project) {
|
|
64
|
+
const api = apiDirectory(project);
|
|
65
|
+
return api ? appEnvFile(api) : webEnvFile;
|
|
66
|
+
}
|
|
45
67
|
/** The files the CLI generates for this selection, without writing anything. */
|
|
46
68
|
export async function renderProject(input) {
|
|
47
69
|
const plan = createPlan({ ...input, targetDirectory: "", git: false, install: false });
|
package/dist/deploy-preview.js
CHANGED
|
@@ -42,6 +42,13 @@ async function migrate(root, branch) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
export async function deployPreview(args, project, cli, envFile) {
|
|
45
|
+
// A preview is one service named after the package; a frontend and an API are two.
|
|
46
|
+
const separate = Object.values(project.services).filter(({ role }) => role !== "fullstack");
|
|
47
|
+
if (separate.length > 1) {
|
|
48
|
+
const base = serviceName(project.identity.packageName);
|
|
49
|
+
const services = separate.map(({ id }) => serviceName(`${base}-${id}`)).join(" and ");
|
|
50
|
+
throw new UsageError(`deploy --preview works with a single app for now, and this project runs its frontend and API as separate services (${services}). Deploy production with \`${cli} deploy\`; for a preview on Railway, copy production with \`railway environment new <name> --duplicate production\`, then run \`railway up --service <service> --environment <name>\` for each service.`);
|
|
51
|
+
}
|
|
45
52
|
const platform = project.modules.deploy ?? "docker";
|
|
46
53
|
if (platform !== "vercel" && platform !== "railway") {
|
|
47
54
|
throw new UsageError(`deploy --preview works with Vercel and Railway, and this project deploys with "${platform}". Its pull requests still get a Neon branch from .github/workflows/preview.yml.`);
|
package/dist/deploy.js
CHANGED
|
@@ -12,7 +12,7 @@ import process from "node:process";
|
|
|
12
12
|
import { UsageError } from "./cli/args.js";
|
|
13
13
|
import { bold, dim } from "./cli/ui.js";
|
|
14
14
|
import { FRAMEWORK_SLOT } from "./core/manifest.js";
|
|
15
|
-
import { appPort, databaseVariables, dockerComposeSource, databasesFor, deploymentStatus, exampleKeys, externalDatabaseUrls, finalStatuses, isLocalUrl, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planVariables, railwayCliVersion, railwayDatabases, railwayIacPath, railwayIacSource, railwayReferences, serviceName, serviceNames, } from "./core/deploy.js";
|
|
15
|
+
import { appPort, databaseVariables, dockerComposeSource, databasesFor, deploymentStatus, exampleKeys, externalDatabaseUrls, finalStatuses, isLocalUrl, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planServicePair, planVariables, railwayCliVersion, railwayDatabases, railwayIacPath, railwayIacSource, railwayReferences, serviceName, serviceNames, } from "./core/deploy.js";
|
|
16
16
|
import { neonDatabaseState } from "./core/neon.js";
|
|
17
17
|
import { readProject } from "./core/project.js";
|
|
18
18
|
import { savedDatabaseForUrl } from "./neon.js";
|
|
@@ -259,42 +259,39 @@ async function deployRailwayServices(args, project, cli, envFile) {
|
|
|
259
259
|
if (!domain)
|
|
260
260
|
throw new Error("Railway created no frontend domain. Create one in its settings, then run deploy again.");
|
|
261
261
|
const url = `https://${domain}`;
|
|
262
|
-
const
|
|
263
|
-
|
|
262
|
+
const listedWebVars = await attempt(() => railway(rw, ["variable", "list", "--service", names.frontend, "--json"]));
|
|
263
|
+
if (listedWebVars === null && existing.includes(names.frontend)) {
|
|
264
|
+
throw new Error(`Railway didn't list the variables of the frontend service "${names.frontend}", so deploy changed nothing. Run deploy again; if it keeps failing, check \`railway variable list --service ${names.frontend}\`.`);
|
|
265
|
+
}
|
|
266
|
+
const webVars = parseVariables(listedWebVars ?? "");
|
|
267
|
+
const plan = planServicePair({
|
|
264
268
|
modules: project.modules,
|
|
265
|
-
|
|
269
|
+
frontendKeys: keys,
|
|
270
|
+
apiKeys: exampleKeys(await readFile(join(project.root, apiStack.app, ".env.example"), "utf8").catch(() => "")),
|
|
266
271
|
url,
|
|
267
|
-
existing: apiVars,
|
|
268
|
-
envFile
|
|
272
|
+
existing: { frontend: webVars, api: apiVars },
|
|
273
|
+
envFile,
|
|
269
274
|
secret: () => randomBytes(32).toString("hex"),
|
|
270
275
|
databases: railwayReferences(databases),
|
|
271
|
-
port: apiStack.port,
|
|
276
|
+
ports: { frontend: frontendStack.port, api: apiStack.port },
|
|
272
277
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
278
|
+
const unset = [
|
|
279
|
+
...(plan.api.unset.length ? [`Not set on the API service: ${plan.api.unset.join(", ")}.`] : []),
|
|
280
|
+
...(plan.frontend.unset.length ? [`Not set on the frontend service: ${plan.frontend.unset.join(", ")}.`] : []),
|
|
281
|
+
];
|
|
282
|
+
if (unset.length) {
|
|
283
|
+
log.warn(`${unset.join("\n")}\nPass --env-file to supply values before deployment.`);
|
|
276
284
|
if (process.stdin.isTTY && !args.yes) {
|
|
277
285
|
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
278
286
|
if (isCancel(proceed) || !proceed)
|
|
279
287
|
return cancel("Nothing was uploaded. Set the variables, then run deploy again.");
|
|
280
288
|
}
|
|
281
289
|
}
|
|
282
|
-
for (const [key, value] of Object.entries(
|
|
290
|
+
for (const [key, value] of Object.entries(plan.api.set)) {
|
|
283
291
|
if (apiVars[key] !== value)
|
|
284
292
|
await railway(rw, ["variable", "set", key, "--stdin", "--service", names.api, "--skip-deploys"], { input: value });
|
|
285
293
|
}
|
|
286
|
-
|
|
287
|
-
const missingWebBuildVars = keys.filter((key) => key.startsWith("VITE_") && !envFile[key] && !webVars[key]);
|
|
288
|
-
if (missingWebBuildVars.length) {
|
|
289
|
-
log.warn(`Not set on the frontend service: ${missingWebBuildVars.join(", ")}. Pass --env-file to provide them.`);
|
|
290
|
-
if (process.stdin.isTTY && !args.yes) {
|
|
291
|
-
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
292
|
-
if (isCancel(proceed) || !proceed)
|
|
293
|
-
return cancel("Nothing was uploaded. Set the variables, then run deploy again.");
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
const webSet = { PORT: String(frontendStack.port), ...Object.fromEntries(Object.entries(envFile).filter(([key, value]) => key.startsWith("VITE_") && value)) };
|
|
297
|
-
for (const [key, value] of Object.entries(webSet)) {
|
|
294
|
+
for (const [key, value] of Object.entries(plan.frontend.set)) {
|
|
298
295
|
if (webVars[key] !== value)
|
|
299
296
|
await railway(rw, ["variable", "set", key, "--stdin", "--service", names.frontend, "--skip-deploys"], { input: value });
|
|
300
297
|
}
|
|
@@ -357,7 +354,8 @@ export async function deployProject(args) {
|
|
|
357
354
|
if (unsupported.length) {
|
|
358
355
|
throw new UsageError(`The selected stacks do not declare Docker deployment support for ${unsupported.map(([slot]) => slot).join(", ")}.`);
|
|
359
356
|
}
|
|
360
|
-
const
|
|
357
|
+
const frontendKeys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
|
|
358
|
+
const compose = dockerComposeSource(Object.fromEntries(separateApps), frontendKeys);
|
|
361
359
|
if (args.dryRun) {
|
|
362
360
|
log.message(`Docker Compose will start ${separateApps.map(([slot, service]) => `${slot} (${service.dockerfile}, port ${service.port})`).join(" and ")}; the frontend proxies /api to the API over the Compose network.`);
|
|
363
361
|
outro("Dry run: nothing was written. Run deploy to generate compose.yaml.");
|
|
@@ -368,7 +366,8 @@ export async function deployProject(args) {
|
|
|
368
366
|
throw new UsageError("compose.yaml already exists. Review it and remove it before generating the stack configuration again.");
|
|
369
367
|
await writeFile(path, compose);
|
|
370
368
|
note(`Generated compose.yaml for ${separateApps.map(([slot]) => slot).join(" and ")}.`, "Docker Compose deployment");
|
|
371
|
-
|
|
369
|
+
const examples = [".env.example", ...separateApps.filter(([, service]) => service.role === "api").map(([, service]) => `${service.app}/.env.example`)];
|
|
370
|
+
outro(`Copy the variables of ${examples.join(" and ")} to .env.production and set their values, then run from the project root:\n docker compose --env-file .env.production up --build -d\n\nThe frontend is published on its declared port; the API stays on the private Compose network.`);
|
|
372
371
|
return;
|
|
373
372
|
}
|
|
374
373
|
if (separateApps.length > 1) {
|
package/dist/index.js
CHANGED
|
@@ -158,6 +158,7 @@ async function createProject(args) {
|
|
|
158
158
|
// The spinner runs while the manifest is read and again while files are written.
|
|
159
159
|
let spinning = "preparing";
|
|
160
160
|
let neonEnvFile = null;
|
|
161
|
+
let sharedEnvFiles = [];
|
|
161
162
|
let toolchains = [];
|
|
162
163
|
try {
|
|
163
164
|
const manifest = await readTemplateManifest(source.root);
|
|
@@ -185,9 +186,11 @@ async function createProject(args) {
|
|
|
185
186
|
git: args.git,
|
|
186
187
|
install: args.install,
|
|
187
188
|
});
|
|
188
|
-
// --infra neon: a free Neon database, its URLs written into the
|
|
189
|
+
// --infra neon: a free Neon database, its URLs written into the env file of the app that owns
|
|
190
|
+
// the database (the API, when the project has one).
|
|
189
191
|
toolchains = plan.toolchains;
|
|
190
|
-
neonEnvFile = plan.modules.some(({ id, option }) => id === "infra" && option === "neon") && args.neon ?
|
|
192
|
+
neonEnvFile = plan.modules.some(({ id, option }) => id === "infra" && option === "neon") && args.neon ? plan.databaseEnvFile : null;
|
|
193
|
+
sharedEnvFiles = plan.envFiles.map(({ to }) => to);
|
|
191
194
|
if (args.dryRun) {
|
|
192
195
|
const lines = [...describePlan(plan), ...(neonEnvFile ? [`Create a free Neon database and set its URLs in ${neonEnvFile}`] : [])];
|
|
193
196
|
log.message(lines.map((line) => `• ${line}`).join("\n"));
|
|
@@ -215,7 +218,7 @@ async function createProject(args) {
|
|
|
215
218
|
if (neonEnvFile) {
|
|
216
219
|
progress.start("Creating a free Neon database");
|
|
217
220
|
try {
|
|
218
|
-
const created = await provisionNeonDatabase(targetDirectory, neonEnvFile);
|
|
221
|
+
const created = await provisionNeonDatabase(targetDirectory, neonEnvFile, sharedEnvFiles);
|
|
219
222
|
progress.stop(describeNewDatabase(created, "pnpm clubedge"));
|
|
220
223
|
migrate = "db:migrate";
|
|
221
224
|
}
|