@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +18 -10
  3. package/dist/cli/ui.js +2 -2
  4. package/dist/core/deploy.js +95 -3
  5. package/dist/deploy-vercel.js +218 -0
  6. package/dist/deploy.js +4 -1
  7. package/dist/index.js +1 -1
  8. package/package.json +3 -3
  9. package/template/README.md +3 -2
  10. package/template/SETUP.md +4 -0
  11. package/template/apps/start/netlify.toml +3 -2
  12. package/template/apps/start/package.json +3 -2
  13. package/template/apps/start/scripts/check-env.ts +34 -0
  14. package/template/apps/start/src/env/production.test.ts +65 -0
  15. package/template/apps/start/src/env/production.ts +97 -0
  16. package/template/apps/start/vercel.json +1 -1
  17. package/template/apps/web/netlify.toml +3 -2
  18. package/template/apps/web/package.json +3 -2
  19. package/template/apps/web/scripts/check-env.ts +34 -0
  20. package/template/apps/web/src/env/production.test.ts +65 -0
  21. package/template/apps/web/src/env/production.ts +97 -0
  22. package/template/apps/web/vercel.json +1 -1
  23. package/template/clubedge.template.json +3 -0
  24. package/template/package.json +2 -1
  25. package/template/packages/auth/package.json +1 -1
  26. package/template/packages/auth-better-auth/package.json +1 -1
  27. package/template/packages/auth-supabase/package.json +1 -1
  28. package/template/packages/cache/package.json +1 -1
  29. package/template/packages/cache-redis/package.json +1 -1
  30. package/template/packages/core/package.json +1 -1
  31. package/template/packages/db/package.json +1 -1
  32. package/template/packages/email/package.json +1 -1
  33. package/template/packages/email-resend/package.json +1 -1
  34. package/template/packages/email-smtp/package.json +1 -1
  35. package/template/packages/jobs/package.json +1 -1
  36. package/template/packages/jobs-pg-boss/package.json +1 -1
  37. package/template/packages/local-db/package.json +1 -1
  38. package/template/packages/observability/package.json +1 -1
  39. package/template/packages/observability-otel/package.json +1 -1
  40. package/template/packages/observability-sentry/package.json +1 -1
  41. package/template/packages/payments/package.json +1 -1
  42. package/template/packages/payments-stripe/package.json +1 -1
  43. package/template/packages/storage/package.json +1 -1
  44. package/template/packages/storage-s3/package.json +1 -1
  45. package/template/packages/storage-supabase/package.json +1 -1
  46. package/template/packages/ui/package.json +1 -1
  47. 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.17.1
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.17.1 # or a specific tag, branch, or commit
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.26.0
219
+ create-clubedge-app v0.28.0
214
220
  |
215
221
  v
216
- Clubedge Starter v0.17.1
217
- commit d9d787b6750e862b9de81a3f8f36b7aa07757f7a
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.26.0` | `clubedge-starter@v0.17.1` | The CLI's required, tested default |
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.17.1` tag is expected to resolve to commit
229
- `d9d787b6750e862b9de81a3f8f36b7aa07757f7a`. The CLI verifies this before
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.17.1`. 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.
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.17.1`, and publish the Starter release.
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 preview in the browser
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 (Railway, Fly.io): production values, such as .env.production
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
@@ -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] !== minimumRailwayCli[i])
172
- return version[i] > minimumRailwayCli[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 and Fly.io for now, and this project deploys with "${platform}". Switch with \`${cli} add deploy railway\` (or fly), or follow ${deployDocsUrl} for ${platform}.`);
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 to Railway");
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.26.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.17.1",
26
- "starterCommit": "d9d787b6750e862b9de81a3f8f36b7aa07757f7a"
25
+ "starterRef": "v0.18.0",
26
+ "starterCommit": "bcd084d7d2d2aac0600ccb35491f48c251fb3a74"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -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`: migrations run during the build. <!-- clubedge:only deploy=vercel -->
80
- - Deployment to Netlify with `apps/web/netlify.toml`: migrations run during the build. <!-- clubedge:only deploy=netlify -->
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
- # Migrations run during the build, against the DATABASE_URL set for the deploy context.
5
- command = "pnpm --dir ../.. db:migrate && pnpm run build"
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.17.1",
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();