@clubedge/create-clubedge-app 0.26.0 → 0.28.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 +17 -0
- package/README.md +18 -10
- package/dist/cli/ui.js +2 -2
- package/dist/core/deploy.js +95 -3
- package/dist/deploy-vercel.js +218 -0
- package/dist/deploy.js +4 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/template/README.md +3 -2
- package/template/SETUP.md +4 -0
- package/template/apps/start/netlify.toml +3 -2
- package/template/apps/start/package.json +3 -2
- package/template/apps/start/scripts/check-env.ts +34 -0
- package/template/apps/start/src/env/production.test.ts +65 -0
- package/template/apps/start/src/env/production.ts +97 -0
- package/template/apps/start/vercel.json +1 -1
- package/template/apps/web/netlify.toml +3 -2
- package/template/apps/web/package.json +3 -2
- package/template/apps/web/scripts/check-env.ts +34 -0
- package/template/apps/web/src/env/production.test.ts +65 -0
- package/template/apps/web/src/env/production.ts +97 -0
- package/template/apps/web/vercel.json +1 -1
- package/template/clubedge.template.json +3 -0
- package/template/package.json +2 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/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/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-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.lock.json +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.28.0
|
|
6
|
+
|
|
7
|
+
- `pnpm clubedge deploy` deploys to Vercel (experimental) (#80), in projects created with `--deploy vercel`:
|
|
8
|
+
- It signs in with `vercel login` when you are signed out, then creates or reuses the project with `apps/web` as its Root Directory and writes `.vercelignore` so `.env` files stay on your machine.
|
|
9
|
+
- It sets the production variables from `.env.production` (or `--env-file`), the public URL, and a generated `BETTER_AUTH_SECRET`, then runs `vercel deploy --prod`. `DATABASE_URL` must point at your provider (Neon, Supabase, ...), since Vercel only runs the app.
|
|
10
|
+
- When Vercel names the app something other than `<project>.vercel.app`, it sets that URL and deploys again, since the build compiles it in.
|
|
11
|
+
- The README opens with the banner the CLI prints (#79).
|
|
12
|
+
- Still on Starter `v0.18.0`.
|
|
13
|
+
|
|
14
|
+
## 0.27.0
|
|
15
|
+
|
|
16
|
+
- Default to Starter `v0.18.0` (#77), which stops Vercel and Netlify builds when a production variable is missing:
|
|
17
|
+
- On those hosts the platform only runs the app, and the database, Redis, storage, and email come from your providers. `pnpm check:env` now runs first in the build command of `vercel.json` and `netlify.toml`, before migrations.
|
|
18
|
+
- It lists each variable the project's modules need that is unset, points at `localhost`, or isn't an absolute URL, and stops the build. That includes `.env.example`'s `example.com` sender in `EMAIL_FROM`.
|
|
19
|
+
- Generated projects check only their own modules. `SKIP_ENV_CHECK=1` builds anyway.
|
|
20
|
+
- If you deploy to Vercel or Netlify: run `pnpm clubedge upgrade`, then set the variables for every environment you build (Production and Preview).
|
|
21
|
+
|
|
5
22
|
## 0.26.0
|
|
6
23
|
|
|
7
24
|
- `deploy` uses your own databases when the env file has their URLs (#74). The platform hosts everything as before when it doesn't.
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Clubedge/create-clubedge-app/main/.github/assets/cover.png" alt="Terminal running pnpm dlx @clubedge/create-clubedge-app my-app and printing the CLUBEDGE STARTER banner" width="100%">
|
|
4
|
+
|
|
3
5
|
# create-clubedge-app
|
|
4
6
|
|
|
5
7
|
**Create a production-ready Next.js or TanStack Start app with one command, and keep it up to date.**
|
|
@@ -127,7 +129,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
127
129
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
128
130
|
|
|
129
131
|
# Reproduce a specific Starter release or commit
|
|
130
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
132
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.18.0
|
|
131
133
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
132
134
|
|
|
133
135
|
# Test local Starter changes before releasing them
|
|
@@ -146,7 +148,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
146
148
|
pnpm clubedge remove storage payments
|
|
147
149
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
148
150
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
149
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.
|
|
151
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.18.0 # or a specific tag, branch, or commit
|
|
150
152
|
```
|
|
151
153
|
|
|
152
154
|
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.
|
|
@@ -205,32 +207,36 @@ With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or
|
|
|
205
207
|
|
|
206
208
|
In a project with `--deploy fly`, `pnpm clubedge deploy` does the same with [Fly's CLI](https://fly.io/docs/flyctl/install/) (install it first; it is not on npm) and your Fly login: it creates the app (`fly launch`, which writes its name and region into `fly.toml`; commit it), an unmanaged Postgres app attached as `DATABASE_URL` (one `shared-cpu-1x` machine and a 1 GB volume) and Upstash Redis with `cache redis`, each unless the env file has its URL, and imports the secrets (the public URL `https://<app>.fly.dev`, a kept `BETTER_AUTH_SECRET`, and `--env-file` values). Then `fly deploy` builds the Dockerfile with `NEXT_PUBLIC_APP_URL` as a build argument, runs the migrations as the release command, and waits for the health checks. Fly bills these on your plan; for Fly's Managed Postgres, create and attach it yourself before deploying. Running it again reuses everything.
|
|
207
209
|
|
|
210
|
+
### Deploy to Vercel (experimental)
|
|
211
|
+
|
|
212
|
+
In a project with `--deploy vercel`, `pnpm clubedge deploy` uses [Vercel's CLI](https://vercel.com/docs/cli) (the installed `vercel`, else the latest through npx) and your Vercel login, running `vercel login` first when you are signed out. Vercel only runs the app, so the database and the other services come from the env file: `DATABASE_URL` (your provider's pooled URL, for example Neon's or Supabase's) is required. It creates or reuses a project named after the package, built from `apps/web` with the whole workspace uploaded, and writes a `.vercelignore` when the project has none, so `.env` files stay on your machine (commit it). It sets the production variables: the env file's values, the public URL, and a generated `BETTER_AUTH_SECRET` with Better Auth. `STORAGE_DIRECTORY` is left out, since Vercel keeps no disk. Then `vercel deploy --prod` builds the app, after `check:env` and the migrations. When Vercel names the app something other than `https://<project>.vercel.app`, `deploy` sets that URL and deploys again, since the build compiles it in. Running it again reuses the project. Vercel bills usage on your plan.
|
|
213
|
+
|
|
208
214
|
## Version relationship
|
|
209
215
|
|
|
210
216
|
Each CLI release is tested against one specific Starter release:
|
|
211
217
|
|
|
212
218
|
```text
|
|
213
|
-
create-clubedge-app v0.
|
|
219
|
+
create-clubedge-app v0.28.0
|
|
214
220
|
|
|
|
215
221
|
v
|
|
216
|
-
Clubedge Starter v0.
|
|
217
|
-
commit
|
|
222
|
+
Clubedge Starter v0.18.0
|
|
223
|
+
commit bcd084d7d2d2aac0600ccb35491f48c251fb3a74
|
|
218
224
|
```
|
|
219
225
|
|
|
220
226
|
The current relationship is:
|
|
221
227
|
|
|
222
228
|
| CLI release | Default Starter release | Meaning |
|
|
223
229
|
| --- | --- | --- |
|
|
224
|
-
| `create-clubedge-app@0.
|
|
230
|
+
| `create-clubedge-app@0.28.0` | `clubedge-starter@v0.18.0` | The CLI's required, tested default |
|
|
225
231
|
|
|
226
232
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
227
233
|
|
|
228
|
-
The `v0.
|
|
229
|
-
`
|
|
234
|
+
The `v0.18.0` tag is expected to resolve to commit
|
|
235
|
+
`bcd084d7d2d2aac0600ccb35491f48c251fb3a74`. The CLI verifies this before
|
|
230
236
|
scaffolding with the default reference and fails rather than silently using a
|
|
231
237
|
moved tag.
|
|
232
238
|
|
|
233
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.
|
|
239
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.18.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.
|
|
234
240
|
|
|
235
241
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
236
242
|
|
|
@@ -285,7 +291,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
285
291
|
1. Make and validate changes in `clubedge-starter`.
|
|
286
292
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
287
293
|
3. Update the Starter package versions.
|
|
288
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
294
|
+
4. Commit `main`, create a release tag such as `v0.18.0`, and publish the Starter release.
|
|
289
295
|
|
|
290
296
|
### CLI maintainers
|
|
291
297
|
|
|
@@ -321,6 +327,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
321
327
|
| 0.24.0 | v0.17.0 | Starter v0.17.0: Railway configuration as code (`.railway/railway.ts`) instead of `railway.json` |
|
|
322
328
|
| 0.25.0 | v0.17.1 | `deploy` to Fly.io (experimental); weekly Dependabot updates |
|
|
323
329
|
| 0.26.0 | v0.17.1 | `deploy` uses your own databases (Supabase, Neon, Upstash) when the env file has their URLs, and reads `.env.production` |
|
|
330
|
+
| 0.27.0 | v0.18.0 | Starter v0.18.0: Vercel and Netlify builds stop when a production variable is missing (`pnpm check:env`) |
|
|
331
|
+
| 0.28.0 | v0.18.0 | `deploy` to Vercel (experimental) |
|
|
324
332
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
325
333
|
|
|
326
334
|
## Community
|
package/dist/cli/ui.js
CHANGED
|
@@ -124,10 +124,10 @@ ${selectionHelp(manifest)}
|
|
|
124
124
|
--dry-run Show what would be created or changed without writing anything
|
|
125
125
|
--force add/remove: change a project with uncommitted changes or without Git
|
|
126
126
|
--no-services doctor: skip pnpm check:services
|
|
127
|
-
--no-open try: do not open the
|
|
127
|
+
--no-open try, deploy (Vercel): do not open the URL in the browser
|
|
128
128
|
--claim try: open the page that claims the VM, to share the preview
|
|
129
129
|
--status try: the last VM's preview, time left, and claim link
|
|
130
|
-
--env-file <path> deploy
|
|
130
|
+
--env-file <path> deploy: production values (default: .env.production when there is one)
|
|
131
131
|
-y, --yes Accept defaults and never prompt (default directory: my-app)
|
|
132
132
|
--no-install Skip dependency installation
|
|
133
133
|
--no-git Skip Git repository initialization
|
package/dist/core/deploy.js
CHANGED
|
@@ -87,6 +87,9 @@ export function planVariables(input) {
|
|
|
87
87
|
for (const [key, value] of Object.entries(envFile)) {
|
|
88
88
|
if (value === "" || databaseKeys.includes(key))
|
|
89
89
|
continue;
|
|
90
|
+
// A development env file's localhost URL would send emailed links to this machine.
|
|
91
|
+
if (appUrlKeys.includes(key) && isLocalUrl(value))
|
|
92
|
+
continue;
|
|
90
93
|
set[key] = value;
|
|
91
94
|
}
|
|
92
95
|
Object.assign(set, external);
|
|
@@ -166,10 +169,10 @@ export function railwayCliVersion(output) {
|
|
|
166
169
|
const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(output);
|
|
167
170
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
168
171
|
}
|
|
169
|
-
export function meetsMinimumCli(version) {
|
|
172
|
+
export function meetsMinimumCli(version, minimum = minimumRailwayCli) {
|
|
170
173
|
for (let i = 0; i < 3; i++) {
|
|
171
|
-
if (version[i] !==
|
|
172
|
-
return version[i] >
|
|
174
|
+
if (version[i] !== minimum[i])
|
|
175
|
+
return version[i] > minimum[i];
|
|
173
176
|
}
|
|
174
177
|
return true;
|
|
175
178
|
}
|
|
@@ -246,3 +249,92 @@ export function flySecretsInput(values) {
|
|
|
246
249
|
.map(([key, value]) => `${key}=${value}`)
|
|
247
250
|
.join("\n");
|
|
248
251
|
}
|
|
252
|
+
// Vercel: the platform only runs the app. Databases, Redis, storage, and email come from the env file.
|
|
253
|
+
/** vercel@50.5.1 added `vercel api`, which deploy uses to create the project and set its variables. */
|
|
254
|
+
export const minimumVercelCli = [50, 5, 1];
|
|
255
|
+
/** Where Vercel builds: the app's folder, with the whole workspace uploaded. */
|
|
256
|
+
export const vercelRootDirectory = "apps/web";
|
|
257
|
+
/** The variables that hold the public URL: readable on Vercel ("plain"), so a later deploy can compare them. */
|
|
258
|
+
export const appUrlKeys = ["APP_URL", "NEXT_PUBLIC_APP_URL"];
|
|
259
|
+
/** A project in `vercel api /v9/projects/:name` output, or null. */
|
|
260
|
+
export function parseVercelProject(output) {
|
|
261
|
+
try {
|
|
262
|
+
const value = JSON.parse(output);
|
|
263
|
+
if (typeof value.id !== "string" || typeof value.name !== "string")
|
|
264
|
+
return null;
|
|
265
|
+
return { id: value.id, name: value.name, rootDirectory: typeof value.rootDirectory === "string" ? value.rootDirectory : null };
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** The project's production variables from `vercel api /v10/projects/:id/env`: plain values as they are, the rest as "(set)". */
|
|
272
|
+
export function parseVercelEnv(output) {
|
|
273
|
+
const values = {};
|
|
274
|
+
try {
|
|
275
|
+
const { envs } = JSON.parse(output);
|
|
276
|
+
for (const env of envs ?? []) {
|
|
277
|
+
const targets = Array.isArray(env.target) ? env.target : [env.target];
|
|
278
|
+
if (typeof env.key !== "string" || !targets.includes("production"))
|
|
279
|
+
continue;
|
|
280
|
+
values[env.key] = env.type === "plain" && typeof env.value === "string" ? env.value : "(set)";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Unknown output: every variable counts as unset, and the upsert sets them again.
|
|
285
|
+
}
|
|
286
|
+
return values;
|
|
287
|
+
}
|
|
288
|
+
/** The body of `POST /v10/projects/:id/env?upsert=true`: production variables, encrypted except the public URL. */
|
|
289
|
+
export function vercelEnvBody(values) {
|
|
290
|
+
return Object.entries(values).map(([key, value]) => ({
|
|
291
|
+
key,
|
|
292
|
+
value,
|
|
293
|
+
type: appUrlKeys.includes(key) ? "plain" : "encrypted",
|
|
294
|
+
target: ["production"],
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The public URL Vercel gave a production deployment, from `vercel api /v13/deployments/:url`: the
|
|
299
|
+
* shortest *.vercel.app alias that isn't the deployment's own URL or a Git branch's.
|
|
300
|
+
*/
|
|
301
|
+
export function vercelProductionUrl(output, deploymentUrl) {
|
|
302
|
+
const own = deploymentUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
|
|
303
|
+
try {
|
|
304
|
+
const { alias } = JSON.parse(output);
|
|
305
|
+
const names = (Array.isArray(alias) ? alias : [])
|
|
306
|
+
.filter((name) => typeof name === "string")
|
|
307
|
+
.map((name) => name.toLowerCase())
|
|
308
|
+
.filter((name) => name.endsWith(".vercel.app") && name !== own && !name.includes("-git-"))
|
|
309
|
+
.sort((a, b) => a.length - b.length);
|
|
310
|
+
return names[0] ? `https://${names[0]}` : null;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** The deployment URL `vercel deploy` prints on stdout (its last https URL). */
|
|
317
|
+
export function parseVercelDeploymentUrl(output) {
|
|
318
|
+
const urls = output.match(/https:\/\/[a-z0-9.-]+\.vercel\.app\b/gi);
|
|
319
|
+
return urls?.at(-1)?.toLowerCase() ?? null;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* .vercelignore for a project without one: the CLI uploads everything but a short list, which
|
|
323
|
+
* keeps .env.local out but not .env or .env.production. Uploading what Git would commit keeps
|
|
324
|
+
* secrets and build output out, as `try` does.
|
|
325
|
+
*/
|
|
326
|
+
export function vercelIgnoreSource(gitignore) {
|
|
327
|
+
const lines = gitignore.split(/\r?\n/).map((line) => line.trimEnd());
|
|
328
|
+
const required = [".env*", "!.env.example"].filter((line) => !lines.includes(line));
|
|
329
|
+
return [
|
|
330
|
+
"# What `vercel deploy` leaves out: what Git ignores, so secrets in .env files and build output",
|
|
331
|
+
"# stay on this machine. Vercel reads its variables from the project's settings.",
|
|
332
|
+
...lines.filter((line, index) => line || index < lines.length - 1),
|
|
333
|
+
...required,
|
|
334
|
+
"",
|
|
335
|
+
].join("\n");
|
|
336
|
+
}
|
|
337
|
+
/** Whether a .vercelignore keeps .env files out of the upload. */
|
|
338
|
+
export function ignoresEnvFiles(vercelignore) {
|
|
339
|
+
return vercelignore.split(/\r?\n/).some((line) => /^\/?(\*\*\/)?\.env(\*|\.\*)?\/?$/.test(line.trim()));
|
|
340
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// `deploy` for Vercel: the platform only runs the app, so the database, Redis, storage, and email
|
|
2
|
+
// come from the env file. With Vercel's CLI and your login, it creates or reuses the project (built
|
|
3
|
+
// from apps/web, with the whole workspace uploaded), sets the production variables, runs
|
|
4
|
+
// `vercel deploy --prod`, and prints the public URL. The build checks the variables (check:env)
|
|
5
|
+
// and runs the migrations before it builds.
|
|
6
|
+
import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { UsageError } from "./cli/args.js";
|
|
12
|
+
import { bold, dim } from "./cli/ui.js";
|
|
13
|
+
import { appUrlKeys, exampleKeys, externalDatabaseUrls, ignoresEnvFiles, isLocalUrl, meetsMinimumCli, minimumVercelCli, parseVercelDeploymentUrl, parseVercelEnv, parseVercelProject, planVariables, railwayCliVersion, serviceName, vercelEnvBody, vercelIgnoreSource, vercelProductionUrl, vercelRootDirectory, } from "./core/deploy.js";
|
|
14
|
+
import { pathExists } from "./utils/fs.js";
|
|
15
|
+
import { openUrl } from "./utils/open.js";
|
|
16
|
+
import { spawnCommand } from "./utils/process.js";
|
|
17
|
+
const vercelCliUrl = "https://vercel.com/docs/cli";
|
|
18
|
+
const vercelPricingUrl = "https://vercel.com/pricing";
|
|
19
|
+
const windows = process.platform === "win32";
|
|
20
|
+
/** Runs Vercel's CLI in the project's root; npm's .cmd shims need a shell on Windows. */
|
|
21
|
+
async function vercel(cli, args, cwd, options = {}) {
|
|
22
|
+
const [command, ...prefix] = cli.command;
|
|
23
|
+
const mode = options.mode ?? "capture";
|
|
24
|
+
const stdio = mode === "interactive" ? "inherit" : mode === "stream" ? ["pipe", "pipe", "inherit"] : ["pipe", "pipe", "pipe"];
|
|
25
|
+
return await new Promise((resolvePromise, reject) => {
|
|
26
|
+
const child = spawnCommand(command, [...prefix, ...args], { cwd, stdio: stdio }, windows);
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
|
|
30
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
|
|
31
|
+
child.once("error", reject);
|
|
32
|
+
child.once("close", (code) => {
|
|
33
|
+
if (code === 0)
|
|
34
|
+
resolvePromise(stdout.trim());
|
|
35
|
+
else
|
|
36
|
+
reject(new Error(`vercel ${args[0]} failed${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : "."}`));
|
|
37
|
+
});
|
|
38
|
+
if (child.stdin) {
|
|
39
|
+
child.stdin.on("error", () => { });
|
|
40
|
+
child.stdin.end(options.input ?? "");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const attempt = async (run) => run().catch(() => null);
|
|
45
|
+
async function vercelCli(cwd) {
|
|
46
|
+
for (const command of [["vercel"], ["npx", "--yes", "vercel@latest"]]) {
|
|
47
|
+
const cli = { command };
|
|
48
|
+
const version = railwayCliVersion((await attempt(() => vercel(cli, ["--version"], cwd))) ?? "");
|
|
49
|
+
if (version && meetsMinimumCli(version, minimumVercelCli))
|
|
50
|
+
return cli;
|
|
51
|
+
}
|
|
52
|
+
throw new UsageError(`deploy to Vercel needs Vercel's CLI ${minimumVercelCli.join(".")} or newer. Install it with \`npm install -g vercel\` (${vercelCliUrl}), then run deploy again.`);
|
|
53
|
+
}
|
|
54
|
+
/** The project `vercel link` recorded in .vercel/project.json, if any. */
|
|
55
|
+
async function linkedProject(root) {
|
|
56
|
+
try {
|
|
57
|
+
const linked = JSON.parse(await readFile(join(root, ".vercel", "project.json"), "utf8"));
|
|
58
|
+
return typeof linked.projectId === "string" ? linked.projectId : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Writes .vercelignore when the project has none, so .env files never reach Vercel. */
|
|
65
|
+
async function ensureVercelIgnore(root) {
|
|
66
|
+
const path = join(root, ".vercelignore");
|
|
67
|
+
if (await pathExists(path)) {
|
|
68
|
+
if (!ignoresEnvFiles(await readFile(path, "utf8"))) {
|
|
69
|
+
log.warn("Your .vercelignore doesn't leave out .env files, so `vercel deploy` uploads them. Add `.env*` and `!.env.example` to it.");
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const gitignore = await readFile(join(root, ".gitignore"), "utf8").catch(() => "");
|
|
74
|
+
await writeFile(path, vercelIgnoreSource(gitignore));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/** Variables a serverless host must not get: STORAGE_DIRECTORY stores files on a disk Vercel doesn't keep. */
|
|
78
|
+
const serverlessExcluded = ["STORAGE_DIRECTORY"];
|
|
79
|
+
export async function deployToVercel(args, project, cli, fileValues) {
|
|
80
|
+
const root = project.root;
|
|
81
|
+
const skipped = serverlessExcluded.filter((key) => fileValues[key]);
|
|
82
|
+
if (skipped.length)
|
|
83
|
+
log.warn(`${skipped.join(", ")} stores files on the server's disk, which Vercel doesn't keep: not set. Files go to the storage bucket.`);
|
|
84
|
+
const envFile = Object.fromEntries(Object.entries(fileValues).filter(([key]) => !serverlessExcluded.includes(key)));
|
|
85
|
+
const keys = exampleKeys(await readFile(join(root, ".env.example"), "utf8").catch(() => "")).filter((key) => !serverlessExcluded.includes(key));
|
|
86
|
+
if (!(await pathExists(join(root, vercelRootDirectory, "vercel.json")))) {
|
|
87
|
+
throw new UsageError(`This project has no ${vercelRootDirectory}/vercel.json. Switch with \`${cli} add deploy vercel\`.`);
|
|
88
|
+
}
|
|
89
|
+
const name = serviceName(project.identity.packageName);
|
|
90
|
+
const external = externalDatabaseUrls(project.modules, envFile);
|
|
91
|
+
// A public URL of your own (a custom domain) wins; otherwise the one Vercel assigns.
|
|
92
|
+
const ownUrl = appUrlKeys.map((key) => envFile[key]).find((value) => value && !isLocalUrl(value));
|
|
93
|
+
const secret = () => randomBytes(32).toString("hex");
|
|
94
|
+
if (!external.DATABASE_URL) {
|
|
95
|
+
throw new UsageError("Vercel only runs the app, so the database comes from your provider (Supabase, Neon, ...): put DATABASE_URL in .env.production (its pooled URL), or pass --env-file, then run deploy again.");
|
|
96
|
+
}
|
|
97
|
+
if (args.dryRun) {
|
|
98
|
+
const preview = planVariables({ modules: project.modules, keys, url: ownUrl ?? `https://${name}.vercel.app`, existing: {}, envFile, secret: () => "<generated>", databases: {} });
|
|
99
|
+
const { NODE_ENV: _node, PORT: _port, ...values } = preview.set;
|
|
100
|
+
log.message([
|
|
101
|
+
`• Vercel project "${name}", built from ${vercelRootDirectory} with the whole workspace uploaded`,
|
|
102
|
+
`• Production variables: ${Object.keys(values).join(", ")}`,
|
|
103
|
+
...(preview.unset.length ? [`• Not set: ${preview.unset.join(", ")}`] : []),
|
|
104
|
+
"• .vercelignore (when missing): what Git ignores, so .env files stay on this machine",
|
|
105
|
+
"• vercel deploy --prod: check:env, the migrations, then the build",
|
|
106
|
+
].join("\n"));
|
|
107
|
+
outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
note([
|
|
111
|
+
"This deploys the project to Vercel, in your Vercel account:",
|
|
112
|
+
"",
|
|
113
|
+
`• A project "${name}" that builds ${vercelRootDirectory}. Vercel only runs the app: the database${project.modules.cache === "redis" ? ", Redis," : ""} and the other services come from the env file.`,
|
|
114
|
+
"• The production variables from the env file, the public URL, and a generated BETTER_AUTH_SECRET when the project uses Better Auth.",
|
|
115
|
+
"• vercel deploy --prod: the build checks the variables (check:env), runs the migrations against DATABASE_URL, then builds.",
|
|
116
|
+
"",
|
|
117
|
+
`Vercel bills usage on your plan (${vercelPricingUrl}). Running deploy again reuses the project.`,
|
|
118
|
+
].join("\n"), "Deploy to Vercel");
|
|
119
|
+
if (!args.yes) {
|
|
120
|
+
if (!process.stdin.isTTY)
|
|
121
|
+
throw new UsageError("deploy creates a project in your Vercel account. Run it in a terminal to confirm, or pass --yes.");
|
|
122
|
+
const answer = await confirm({ message: "Deploy?", initialValue: true });
|
|
123
|
+
if (isCancel(answer) || !answer) {
|
|
124
|
+
cancel("Nothing was deployed.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const progress = spinner();
|
|
129
|
+
progress.start("Preparing Vercel's CLI");
|
|
130
|
+
const vc = await vercelCli(root);
|
|
131
|
+
if (!(await attempt(() => vercel(vc, ["whoami"], root)))) {
|
|
132
|
+
progress.stop("Signing in to Vercel");
|
|
133
|
+
if (!process.stdin.isTTY)
|
|
134
|
+
throw new UsageError("Sign in to Vercel first with `vercel login`, or set VERCEL_TOKEN.");
|
|
135
|
+
await vercel(vc, ["login"], root, { mode: "interactive" });
|
|
136
|
+
progress.start("Signed in");
|
|
137
|
+
}
|
|
138
|
+
progress.message("Finding or creating the Vercel project");
|
|
139
|
+
const linked = await linkedProject(root);
|
|
140
|
+
let found = parseVercelProject((await attempt(() => vercel(vc, ["api", `/v9/projects/${linked ?? name}`], root))) ?? "");
|
|
141
|
+
if (!found) {
|
|
142
|
+
const created = await vercel(vc, ["api", "/v10/projects", "-X", "POST", "--input", "-"], root, {
|
|
143
|
+
input: JSON.stringify({ name, rootDirectory: vercelRootDirectory }),
|
|
144
|
+
});
|
|
145
|
+
found = parseVercelProject(created);
|
|
146
|
+
if (!found)
|
|
147
|
+
throw new Error(`Vercel created no project "${name}". Create it in the dashboard with the Root Directory ${vercelRootDirectory}, then run deploy again.`);
|
|
148
|
+
}
|
|
149
|
+
else if (found.rootDirectory !== vercelRootDirectory) {
|
|
150
|
+
progress.message(`Setting the project's Root Directory to ${vercelRootDirectory}`);
|
|
151
|
+
await vercel(vc, ["api", `/v9/projects/${found.id}`, "-X", "PATCH", "--input", "-"], root, {
|
|
152
|
+
input: JSON.stringify({ rootDirectory: vercelRootDirectory }),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (!linked)
|
|
156
|
+
await vercel(vc, ["link", "--yes", "--project", found.name], root);
|
|
157
|
+
const wroteIgnore = await ensureVercelIgnore(root);
|
|
158
|
+
progress.message("Setting the production variables");
|
|
159
|
+
const existing = parseVercelEnv((await attempt(() => vercel(vc, ["api", `/v10/projects/${found.id}/env`], root))) ?? "");
|
|
160
|
+
const keptUrl = appUrlKeys.map((key) => existing[key]).find((value) => value && value !== "(set)" && !isLocalUrl(value));
|
|
161
|
+
let url = ownUrl ?? keptUrl ?? `https://${found.name}.vercel.app`;
|
|
162
|
+
const plan = planVariables({ modules: project.modules, keys, url, existing, envFile, secret, databases: {} });
|
|
163
|
+
// Vercel sets NODE_ENV and the port itself.
|
|
164
|
+
const { NODE_ENV: _node, PORT: _port, ...values } = plan.set;
|
|
165
|
+
await vercel(vc, ["api", `/v10/projects/${found.id}/env?upsert=true`, "-X", "POST", "--input", "-"], root, {
|
|
166
|
+
input: JSON.stringify(vercelEnvBody(values)),
|
|
167
|
+
});
|
|
168
|
+
progress.stop(`Vercel is ready: the project "${found.name}"`);
|
|
169
|
+
if (wroteIgnore)
|
|
170
|
+
log.info("Commit .vercelignore: it keeps .env files and build output out of `vercel deploy`.");
|
|
171
|
+
if (plan.unset.length) {
|
|
172
|
+
log.warn(`Not set: ${plan.unset.join(", ")}. The build stops at check:env when one of them is required; the others only turn features off.`);
|
|
173
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
174
|
+
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
175
|
+
if (isCancel(proceed) || !proceed) {
|
|
176
|
+
cancel("Nothing was deployed. Add them to .env.production, then run deploy again: it reuses the project.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const deploy = async () => {
|
|
182
|
+
log.step("Deploying with vercel deploy --prod (build logs follow)");
|
|
183
|
+
try {
|
|
184
|
+
const output = await vercel(vc, ["deploy", "--prod", "--yes", "--logs"], root, { mode: "stream" });
|
|
185
|
+
const deployment = parseVercelDeploymentUrl(output);
|
|
186
|
+
if (!deployment)
|
|
187
|
+
throw new Error("no URL");
|
|
188
|
+
return deployment;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new Error("The deploy did not finish. The build log above says why (check:env names missing variables); `vercel inspect --logs <url>` shows it again. Fix the cause and run deploy again: it reuses the project.");
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const deployment = await deploy();
|
|
195
|
+
const details = await attempt(() => vercel(vc, ["api", `/v13/deployments/${deployment.replace(/^https:\/\//, "")}`], root));
|
|
196
|
+
const assigned = vercelProductionUrl(details ?? "", deployment);
|
|
197
|
+
// Next.js compiles the public URL into the build: when Vercel picked another name, set it and build again.
|
|
198
|
+
if (!ownUrl && assigned && assigned !== url) {
|
|
199
|
+
log.info(`Vercel named the app ${assigned}. Setting it as the public URL and deploying again, since the build compiles it in.`);
|
|
200
|
+
url = assigned;
|
|
201
|
+
await vercel(vc, ["api", `/v10/projects/${found.id}/env?upsert=true`, "-X", "POST", "--input", "-"], root, {
|
|
202
|
+
input: JSON.stringify(vercelEnvBody(Object.fromEntries(appUrlKeys.map((key) => [key, url])))),
|
|
203
|
+
});
|
|
204
|
+
await deploy();
|
|
205
|
+
}
|
|
206
|
+
note([
|
|
207
|
+
`${bold("URL")} ${url}`,
|
|
208
|
+
`${bold("Project")} ${found.name}`,
|
|
209
|
+
...(plan.unset.length ? ["", `${bold("Not set")} ${plan.unset.join(", ")}`] : []),
|
|
210
|
+
].join("\n"), "Deployed to Vercel");
|
|
211
|
+
if (args.open && process.stdin.isTTY && !args.yes && (await openUrl(url)))
|
|
212
|
+
log.info("Opened the app in your browser.");
|
|
213
|
+
outro([
|
|
214
|
+
`${dim("Logs")} vercel logs ${url}`,
|
|
215
|
+
`${dim("Dashboard")} vercel open`,
|
|
216
|
+
`${dim("Again")} ${cli} deploy ${dim("(reuses the project; .env.production adds variables)")}`,
|
|
217
|
+
].join("\n"));
|
|
218
|
+
}
|
package/dist/deploy.js
CHANGED
|
@@ -14,6 +14,7 @@ import { bold, dim } from "./cli/ui.js";
|
|
|
14
14
|
import { appPort, databaseVariables, 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
15
|
import { readProject } from "./core/project.js";
|
|
16
16
|
import { deployToFly } from "./deploy-fly.js";
|
|
17
|
+
import { deployToVercel } from "./deploy-vercel.js";
|
|
17
18
|
import { cliCommand } from "./modify.js";
|
|
18
19
|
import { resolvePnpm } from "./steps/install.js";
|
|
19
20
|
import { pathExists } from "./utils/fs.js";
|
|
@@ -160,8 +161,10 @@ export async function deployProject(args) {
|
|
|
160
161
|
const platform = project.modules.deploy ?? "docker";
|
|
161
162
|
if (platform === "fly")
|
|
162
163
|
return await deployToFly(args, project, cli, await readDeployEnv(args, project));
|
|
164
|
+
if (platform === "vercel")
|
|
165
|
+
return await deployToVercel(args, project, cli, await readDeployEnv(args, project));
|
|
163
166
|
if (platform !== "railway") {
|
|
164
|
-
throw new UsageError(`deploy sets up Railway
|
|
167
|
+
throw new UsageError(`deploy sets up Railway, Fly.io, and Vercel for now, and this project deploys with "${platform}". Switch with \`${cli} add deploy railway\` (or fly, or vercel), or follow ${deployDocsUrl} for ${platform}.`);
|
|
165
168
|
}
|
|
166
169
|
const service = serviceName(project.identity.packageName);
|
|
167
170
|
const keys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
|
package/dist/index.js
CHANGED
|
@@ -237,7 +237,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
237
237
|
await runDoctor(args);
|
|
238
238
|
}
|
|
239
239
|
else if (args.command.name === "deploy") {
|
|
240
|
-
intro("Deploy this project
|
|
240
|
+
intro("Deploy this project");
|
|
241
241
|
await deployProject(args);
|
|
242
242
|
}
|
|
243
243
|
else if (args.command.name === "try") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"clubedge": {
|
|
24
24
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
25
|
-
"starterRef": "v0.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.18.0",
|
|
26
|
+
"starterCommit": "bcd084d7d2d2aac0600ccb35491f48c251fb3a74"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
package/template/README.md
CHANGED
|
@@ -76,8 +76,8 @@ Every combination in the CI matrix is generated as a real project and runs its f
|
|
|
76
76
|
- Deployment to Fly.io with `fly.toml`: migrations run as the release command. <!-- clubedge:only deploy=fly -->
|
|
77
77
|
- Deployment to Railway with `.railway/railway.ts`: migrations run before each release, and `pnpm clubedge deploy` sets up the project. <!-- clubedge:only deploy=railway -->
|
|
78
78
|
- Deployment to Render with a `render.yaml` Blueprint: migrations run before each deploy. <!-- clubedge:only deploy=render -->
|
|
79
|
-
- Deployment to Vercel with `apps/web/vercel.json`:
|
|
80
|
-
- Deployment to Netlify with `apps/web/netlify.toml`:
|
|
79
|
+
- Deployment to Vercel with `apps/web/vercel.json`: the build stops when a production variable is missing, then runs migrations. <!-- clubedge:only deploy=vercel -->
|
|
80
|
+
- Deployment to Netlify with `apps/web/netlify.toml`: the build stops when a production variable is missing, then runs migrations. <!-- clubedge:only deploy=netlify -->
|
|
81
81
|
|
|
82
82
|
**Quality**
|
|
83
83
|
|
|
@@ -270,6 +270,7 @@ Playwright's first run may require installing Chromium with `pnpm exec playwrigh
|
|
|
270
270
|
Local services:
|
|
271
271
|
|
|
272
272
|
- `pnpm check:services`: check every configured service with a real round trip.
|
|
273
|
+
- `pnpm check:env`: check that the environment has every production variable the project's modules need. Vercel and Netlify builds run it first. <!-- clubedge:only deploy=vercel|netlify -->
|
|
273
274
|
- `pnpm infra:up` and `pnpm infra:down`: start and stop the local services from `docker-compose.dev.yml`. <!-- clubedge:only infra=docker -->
|
|
274
275
|
- `pnpm infra:reset`: stop them and delete their data. `pnpm infra:logs` follows their logs. <!-- clubedge:only infra=docker -->
|
|
275
276
|
- `pnpm infra:up` and `pnpm infra:down`: start and stop the local Supabase stack. <!-- clubedge:only infra=supabase -->
|
package/template/SETUP.md
CHANGED
|
@@ -408,6 +408,8 @@ Render runs `node /app/migrate.mjs` as the pre-deploy command before each deploy
|
|
|
408
408
|
`apps/web/vercel.json` deploys the app to [Vercel](https://vercel.com). Import the repository, set the project's Root Directory to `apps/web`, add the production variables listed below, and deploy. Vercel installs the whole pnpm workspace and builds the app. <!-- clubedge:only framework=next -->
|
|
409
409
|
`apps/web/vercel.json` deploys the app to [Vercel](https://vercel.com). Import the repository, set the project's Root Directory to `apps/web`, add the production variables listed below, and deploy. The build detects Vercel and writes Vercel's Build Output, with server routes as functions. <!-- clubedge:only framework=tanstack-start -->
|
|
410
410
|
|
|
411
|
+
Vercel only runs the app: the database, Redis, file storage, and email come from your providers (for example Supabase, Upstash, and Cloudflare R2). The build command starts with `pnpm check:env`, which stops the build when a variable the project's modules need is unset, or points at `localhost`, and lists each one. Set them for every environment you deploy (Production and Preview); `SKIP_ENV_CHECK=1` builds anyway.
|
|
412
|
+
|
|
411
413
|
Vercel has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deployment's environment. Give Preview deployments their own database, or remove the migration step from `buildCommand` and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
|
|
412
414
|
|
|
413
415
|
Vercel runs no worker for [background jobs](#background-jobs). Set `CRON_SECRET` and add `"crons": [{ "path": "/api/jobs/run", "schedule": "* * * * *" }]` to `vercel.json`; Vercel Cron sends the secret itself. Cron jobs more frequent than daily need a paid Vercel plan; otherwise call the endpoint from another scheduler. <!-- clubedge:only jobs=pg-boss -->
|
|
@@ -418,6 +420,8 @@ Vercel runs no worker for [background jobs](#background-jobs). Set `CRON_SECRET`
|
|
|
418
420
|
`apps/web/netlify.toml` deploys the app to [Netlify](https://www.netlify.com). Import the repository, set the site's Base directory to `apps/web`, add the production variables listed below, and deploy. Netlify installs its Next.js runtime automatically. <!-- clubedge:only framework=next -->
|
|
419
421
|
`apps/web/netlify.toml` deploys the app to [Netlify](https://www.netlify.com). Import the repository, set the site's Base directory to `apps/web`, add the production variables listed below, and deploy. The build detects Netlify and writes its functions and static assets. <!-- clubedge:only framework=tanstack-start -->
|
|
420
422
|
|
|
423
|
+
Netlify only runs the app: the database, Redis, file storage, and email come from your providers (for example Supabase, Upstash, and Cloudflare R2). The build command starts with `pnpm check:env`, which stops the build when a variable the project's modules need is unset, or points at `localhost`, and lists each one. Set them for every deploy context you build (production and deploy previews); `SKIP_ENV_CHECK=1` builds anyway.
|
|
424
|
+
|
|
421
425
|
Netlify has no step that runs before a release, so the build command applies migrations (`pnpm db:migrate`) against the `DATABASE_URL` of the deploy context. Give deploy previews their own database, or remove the migration step from the build command and run migrations yourself. Serverless functions open a connection per instance: use your provider's pooled connection URL (for example Supabase's transaction pooler or Neon's pooled endpoint).
|
|
422
426
|
|
|
423
427
|
Netlify runs no worker for [background jobs](#background-jobs). Set `JOBS_WORKER=off` and `CRON_SECRET`, then call `/api/jobs/run` every minute from a [scheduled function](https://docs.netlify.com/build/functions/scheduled-functions/) or another scheduler, with the header `Authorization: Bearer <CRON_SECRET>`. <!-- clubedge:only jobs=pg-boss -->
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Netlify (https://docs.netlify.com/configure-builds/file-based-configuration/). Set the site's
|
|
2
2
|
# base directory to apps/web; the build detects Netlify and writes its functions and assets.
|
|
3
3
|
[build]
|
|
4
|
-
#
|
|
5
|
-
|
|
4
|
+
# check:env stops the build when a variable the project needs is unset; then migrations run,
|
|
5
|
+
# against the DATABASE_URL set for the deploy context.
|
|
6
|
+
command = "pnpm run check:env && pnpm --dir ../.. db:migrate && pnpm run build"
|
|
6
7
|
publish = "dist"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"build": "vite build",
|
|
10
10
|
"start": "node --env-file-if-exists=.env.local .output/server/index.mjs",
|
|
11
11
|
"typecheck": "tsr generate && tsc --noEmit",
|
|
12
|
-
"check:services": "tsx scripts/check-services.ts"
|
|
12
|
+
"check:services": "tsx scripts/check-services.ts",
|
|
13
|
+
"check:env": "tsx scripts/check-env.ts"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"@clubedge/auth": "workspace:*",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Checks the production variables before a deploy build: `pnpm check:env`. Vercel's and Netlify's
|
|
2
|
+
// build commands run it first, so a missing key stops the build. It reads the build's environment,
|
|
3
|
+
// not .env.local; SKIP_ENV_CHECK=1 builds anyway.
|
|
4
|
+
import { checkProductionEnv } from "../src/env/production";
|
|
5
|
+
|
|
6
|
+
function main() {
|
|
7
|
+
if (process.env.SKIP_ENV_CHECK === "1") {
|
|
8
|
+
console.warn(
|
|
9
|
+
"check:env skipped (SKIP_ENV_CHECK=1): missing variables fail at runtime instead.",
|
|
10
|
+
);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const { missing, local, invalid } = checkProductionEnv(process.env);
|
|
14
|
+
if (!missing.length && !local.length && !invalid.length) {
|
|
15
|
+
console.log("✓ check:env: every production variable this project needs is set.");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const lines = [
|
|
19
|
+
...missing.map(({ key, use }) => `✗ ${key} is not set (${use})`),
|
|
20
|
+
...local.map((key) => `✗ ${key} points at localhost, which the deployed app can't reach`),
|
|
21
|
+
...invalid.map((problem) => `✗ ${problem}`),
|
|
22
|
+
];
|
|
23
|
+
console.error(
|
|
24
|
+
[
|
|
25
|
+
"check:env: the production environment is incomplete, so the build stops here.",
|
|
26
|
+
...lines,
|
|
27
|
+
"Fix them in the host's environment variables for this environment (Production or Preview),",
|
|
28
|
+
"then deploy again. SKIP_ENV_CHECK=1 builds anyway.",
|
|
29
|
+
].join("\n"),
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
main();
|