@clubedge/create-clubedge-app 0.28.0 → 0.29.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.29.0
6
+
7
+ - `pnpm clubedge deploy` deploys to Netlify and Render (experimental) (#82), so it now covers Railway, Fly.io, Vercel, Netlify, and Render:
8
+ - Netlify: it signs in with `netlify login`, creates or reuses the site with `apps/web` as its base directory, imports the production variables from `.env.production`, the site URL, and a generated `BETTER_AUTH_SECRET`, then runs `netlify deploy --build --prod`. The build runs on your machine with the site's variables.
9
+ - Render: it signs in with an API key (`RENDER_API_KEY`, or pasted from the dashboard page it opens) and builds from your pushed Git branch. It creates or reuses a Docker web service that runs the migrations before each release and checks `/api/health`, as `render.yaml` does, on a starter instance, since pre-deploy commands need a paid type. It sets the variables and waits until the deploy is live.
10
+ - On both, `DATABASE_URL` comes from your provider (Neon, Supabase, ...).
11
+ - Still on Starter `v0.18.0`.
12
+
5
13
  ## 0.28.0
6
14
 
7
15
  - `pnpm clubedge deploy` deploys to Vercel (experimental) (#80), in projects created with `--deploy vercel`:
package/README.md CHANGED
@@ -211,12 +211,20 @@ In a project with `--deploy fly`, `pnpm clubedge deploy` does the same with [Fly
211
211
 
212
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
213
 
214
+ ### Deploy to Netlify (experimental)
215
+
216
+ In a project with `--deploy netlify`, `pnpm clubedge deploy` uses [Netlify's CLI](https://docs.netlify.com/cli/get-started/) (the installed `netlify`, else the latest through npx) and your Netlify login: `netlify login` opens the browser when you are signed out, and returns at once when you are signed in. As on Vercel, the services come from the env file, and `DATABASE_URL` (your provider's pooled URL) is required. It creates or reuses a site named after the package (with a short suffix when the name is taken, since Netlify's names are global), with `apps/web` as its base directory, and links the project to it in `.netlify/state.json`. It imports the production variables from a temporary file, so no value appears on a command line: the env file's values, the site's `https://<site>.netlify.app` URL, and a generated `BETTER_AUTH_SECRET` with Better Auth. Then `netlify deploy --build --prod` builds on your machine with the site's variables (`check:env`, the migrations, then the build) and uploads the result. Running it again reuses the site. Netlify bills usage on your plan.
217
+
218
+ ### Deploy to Render (experimental)
219
+
220
+ In a project with `--deploy render`, `pnpm clubedge deploy` uses Render's API. Render builds the Dockerfile from your Git repository, so push the project to GitHub, GitLab, or Bitbucket first, with Render's access to it granted in the dashboard; `deploy` warns about commits that aren't pushed. It signs in with an API key: `RENDER_API_KEY`, or else it opens the dashboard's API keys page and asks you to paste one. It creates or reuses a web service named after the package, on the Docker runtime and a starter instance (pre-deploy commands need a paid type), which runs `node /app/migrate.mjs` before each release, checks `/api/health`, and deploys again on each push, as `render.yaml` describes. `DATABASE_URL` comes from the env file (Render Postgres, Neon, Supabase, ...). It sets the production variables: the env file's values, the service's `*.onrender.com` URL, and a generated `BETTER_AUTH_SECRET` that later runs keep. Then it starts a deploy and waits until it is live. When Render names the app something other than `https://<service>.onrender.com`, `deploy` sets that URL and deploys again, since the build compiles it in. Running it again reuses the service. Render bills the instance on your plan.
221
+
214
222
  ## Version relationship
215
223
 
216
224
  Each CLI release is tested against one specific Starter release:
217
225
 
218
226
  ```text
219
- create-clubedge-app v0.28.0
227
+ create-clubedge-app v0.29.0
220
228
  |
221
229
  v
222
230
  Clubedge Starter v0.18.0
@@ -227,7 +235,7 @@ The current relationship is:
227
235
 
228
236
  | CLI release | Default Starter release | Meaning |
229
237
  | --- | --- | --- |
230
- | `create-clubedge-app@0.28.0` | `clubedge-starter@v0.18.0` | The CLI's required, tested default |
238
+ | `create-clubedge-app@0.29.0` | `clubedge-starter@v0.18.0` | The CLI's required, tested default |
231
239
 
232
240
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
233
241
 
@@ -329,6 +337,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
329
337
  | 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
338
  | 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
339
  | 0.28.0 | v0.18.0 | `deploy` to Vercel (experimental) |
340
+ | 0.29.0 | v0.18.0 | `deploy` to Netlify and Render (experimental) |
332
341
  | next | | Show the Starter's version of a conflicted file next to yours |
333
342
 
334
343
  ## Community
package/dist/cli/ui.js CHANGED
@@ -124,7 +124,7 @@ ${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, deploy (Vercel): do not open the URL in the browser
127
+ --no-open try, deploy (Vercel, Netlify, Render): 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
130
  --env-file <path> deploy: production values (default: .env.production when there is one)
@@ -338,3 +338,142 @@ export function vercelIgnoreSource(gitignore) {
338
338
  export function ignoresEnvFiles(vercelignore) {
339
339
  return vercelignore.split(/\r?\n/).some((line) => /^\/?(\*\*\/)?\.env(\*|\.\*)?\/?$/.test(line.trim()));
340
340
  }
341
+ /* ------------------------------------ Netlify ------------------------------------ */
342
+ // Netlify only runs the app, as Vercel does: databases, Redis, storage, and email come from the env file.
343
+ /** Where Netlify builds: the app's folder, which holds netlify.toml. */
344
+ export const netlifyBaseDirectory = "apps/web";
345
+ /** A Netlify site name: lowercase letters, digits, and dashes, at most 63 characters. */
346
+ export function netlifySiteName(name) {
347
+ return name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63).replace(/-+$/, "") || "web";
348
+ }
349
+ /** A site in `netlify api getSite` or `createSiteInTeam` output, or null. */
350
+ export function parseNetlifySite(output) {
351
+ try {
352
+ const value = JSON.parse(output);
353
+ if (typeof value.id !== "string" || typeof value.name !== "string")
354
+ return null;
355
+ const url = typeof value.ssl_url === "string" && value.ssl_url ? value.ssl_url : `https://${value.name}.netlify.app`;
356
+ const base = typeof value.build_settings?.base === "string" ? value.build_settings.base : null;
357
+ return { id: value.id, name: value.name, url: url.replace(/\/+$/, ""), base };
358
+ }
359
+ catch {
360
+ return null;
361
+ }
362
+ }
363
+ /** The team slugs in `netlify api listAccountsForUser` output. */
364
+ export function parseNetlifyAccounts(output) {
365
+ try {
366
+ const value = JSON.parse(output);
367
+ if (!Array.isArray(value))
368
+ return [];
369
+ return value.map((account) => account?.slug).filter((slug) => typeof slug === "string");
370
+ }
371
+ catch {
372
+ return [];
373
+ }
374
+ }
375
+ /**
376
+ * The file `netlify env:import` reads: KEY='value' lines. Each value is quoted with a quote it
377
+ * doesn't contain, so dotenv keeps it as it is (#, spaces, $); a line break can't be imported.
378
+ */
379
+ export function netlifyEnvFile(values) {
380
+ return Object.entries(values)
381
+ .map(([key, value]) => {
382
+ if (/[\r\n]/.test(value))
383
+ throw new Error(`${key} has a line break, which Netlify's env:import can't take. Set it in the site's settings.`);
384
+ const quote = ["'", '"', "`"].find((mark) => !value.includes(mark));
385
+ if (!quote)
386
+ throw new Error(`${key} contains every kind of quote, which Netlify's env:import can't take. Set it in the site's settings.`);
387
+ return `${key}=${quote}${value}${quote}`;
388
+ })
389
+ .join("\n")
390
+ .concat("\n");
391
+ }
392
+ /** The production URL in `netlify deploy --json` output (the last JSON object it prints). */
393
+ export function parseNetlifyDeploy(output) {
394
+ for (let index = output.lastIndexOf("{"); index >= 0; index = output.lastIndexOf("{", index - 1)) {
395
+ try {
396
+ const value = JSON.parse(output.slice(index));
397
+ const url = typeof value.url === "string" ? value.url : typeof value.deploy_url === "string" ? value.deploy_url : null;
398
+ if (url)
399
+ return url.replace(/\/+$/, "");
400
+ }
401
+ catch {
402
+ // Not the start of the JSON object: try the one before.
403
+ }
404
+ }
405
+ return null;
406
+ }
407
+ /* ------------------------------------ Render ------------------------------------ */
408
+ // Render builds the Dockerfile from the project's Git repository, through Render's API.
409
+ export const renderApiUrl = "https://api.render.com/v1";
410
+ /** Deploy statuses Render does not move on from. */
411
+ export const renderFinalStatuses = ["live", "deactivated", "build_failed", "update_failed", "canceled", "pre_deploy_failed"];
412
+ /**
413
+ * The https URL of a Git remote Render can build from (GitHub, GitLab, or Bitbucket), from an
414
+ * https or SSH remote; null for anything else.
415
+ */
416
+ export function renderRepoUrl(remote) {
417
+ const value = remote.trim();
418
+ const match = /^(?:git@|ssh:\/\/git@)(github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?\/?$/i.exec(value) ??
419
+ /^https:\/\/(?:[^@/]+@)?(github\.com|gitlab\.com|bitbucket\.org)\/(.+?)(?:\.git)?\/?$/i.exec(value);
420
+ return match ? `https://${match[1].toLowerCase()}/${match[2]}` : null;
421
+ }
422
+ /**
423
+ * The body of `POST /v1/services`: a Docker web service on a paid instance type, since pre-deploy
424
+ * commands (the migrations before each release) need one, as in render.yaml.
425
+ */
426
+ export function renderServiceBody(input) {
427
+ return {
428
+ type: "web_service",
429
+ name: input.name,
430
+ ownerId: input.ownerId,
431
+ repo: input.repo,
432
+ branch: input.branch,
433
+ autoDeploy: "yes",
434
+ serviceDetails: {
435
+ runtime: "docker",
436
+ plan: "starter",
437
+ healthCheckPath: "/api/health",
438
+ preDeployCommand: "node /app/migrate.mjs",
439
+ envSpecificDetails: { dockerfilePath: "./Dockerfile", dockerContext: "." },
440
+ },
441
+ envVars: Object.entries(input.values).map(([key, value]) => ({ key, value })),
442
+ };
443
+ }
444
+ /** A service from Render's API: `{ service }` items in a list, or the service itself. */
445
+ function renderService(value) {
446
+ const item = value?.service ?? value;
447
+ const service = item;
448
+ if (typeof service?.id !== "string" || typeof service.name !== "string")
449
+ return null;
450
+ const url = service.serviceDetails?.url;
451
+ return { id: service.id, name: service.name, url: typeof url === "string" && url ? url.replace(/\/+$/, "") : null };
452
+ }
453
+ /** The service named `name` in `GET /v1/services?name=...`, or the one `POST /v1/services` created. */
454
+ export function parseRenderService(body, name) {
455
+ if (Array.isArray(body)) {
456
+ return body.map(renderService).find((service) => service && (!name || service.name === name)) ?? null;
457
+ }
458
+ return renderService(body);
459
+ }
460
+ /** The workspaces in `GET /v1/owners`: `{ owner: { id, name } }` items. */
461
+ export function parseRenderOwners(body) {
462
+ if (!Array.isArray(body))
463
+ return [];
464
+ return body
465
+ .map((item) => item?.owner)
466
+ .filter((owner) => typeof owner?.id === "string" && typeof owner.name === "string");
467
+ }
468
+ /** The variable names and values in `GET /v1/services/:id/env-vars`: `{ envVar: { key, value } }` items. */
469
+ export function parseRenderEnv(body) {
470
+ if (!Array.isArray(body))
471
+ return {};
472
+ const values = {};
473
+ for (const item of body) {
474
+ const env = item?.envVar ?? item;
475
+ if (typeof env?.key === "string")
476
+ values[env.key] = typeof env.value === "string" ? env.value : "(set)";
477
+ }
478
+ return values;
479
+ }
@@ -0,0 +1,218 @@
1
+ // `deploy` for Netlify (experimental): the platform only runs the app, so the database, Redis,
2
+ // storage, and email come from the env file. With Netlify's CLI and your login, it creates or
3
+ // reuses the site (its base directory apps/web, where netlify.toml is), links the project to it,
4
+ // imports the production variables, and runs `netlify deploy --build --prod`. The build runs on
5
+ // this machine with the site's variables: check:env, the migrations, then Netlify's build.
6
+ import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
7
+ import { randomBytes } from "node:crypto";
8
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import process from "node:process";
12
+ import { UsageError } from "./cli/args.js";
13
+ import { bold, dim } from "./cli/ui.js";
14
+ import { appUrlKeys, exampleKeys, externalDatabaseUrls, isLocalUrl, netlifyBaseDirectory, netlifyEnvFile, netlifySiteName, parseNetlifyAccounts, parseNetlifyDeploy, parseNetlifySite, planVariables, serviceName, } from "./core/deploy.js";
15
+ import { pathExists } from "./utils/fs.js";
16
+ import { openUrl } from "./utils/open.js";
17
+ import { spawnCommand } from "./utils/process.js";
18
+ const netlifyCliUrl = "https://docs.netlify.com/cli/get-started/";
19
+ const netlifyPricingUrl = "https://www.netlify.com/pricing/";
20
+ const windows = process.platform === "win32";
21
+ /** Runs Netlify's CLI in the project's root; npm's .cmd shims need a shell on Windows. */
22
+ async function netlify(cli, args, cwd, options = {}) {
23
+ const [command, ...prefix] = cli.command;
24
+ const mode = options.mode ?? "capture";
25
+ const stdio = mode === "interactive" ? "inherit" : mode === "stream" ? ["ignore", "pipe", "inherit"] : ["ignore", "pipe", "pipe"];
26
+ return await new Promise((resolvePromise, reject) => {
27
+ const child = spawnCommand(command, [...prefix, ...args], { cwd, stdio: stdio }, windows);
28
+ let stdout = "";
29
+ let stderr = "";
30
+ child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
31
+ child.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
32
+ child.once("error", reject);
33
+ child.once("close", (code) => {
34
+ if (code === 0)
35
+ resolvePromise(stdout.trim());
36
+ else
37
+ reject(new Error(`netlify ${args[0]} failed${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : "."}`));
38
+ });
39
+ });
40
+ }
41
+ const attempt = async (run) => run().catch(() => null);
42
+ async function netlifyCli(cwd) {
43
+ for (const command of [["netlify"], ["npx", "--yes", "netlify-cli@latest"]]) {
44
+ const cli = { command };
45
+ if (await attempt(() => netlify(cli, ["--version"], cwd)))
46
+ return cli;
47
+ }
48
+ throw new UsageError(`deploy to Netlify needs Netlify's CLI. Install it with \`npm install -g netlify-cli\` (${netlifyCliUrl}), then run deploy again.`);
49
+ }
50
+ /** `netlify api <method>` with its parameters, parsed as JSON. */
51
+ async function api(cli, method, data, cwd) {
52
+ return await netlify(cli, ["api", method, ...(data ? ["--data", JSON.stringify(data)] : [])], cwd);
53
+ }
54
+ /** The site `netlify link` recorded in .netlify/state.json, if any. */
55
+ async function linkedSite(root) {
56
+ try {
57
+ const state = JSON.parse(await readFile(join(root, ".netlify", "state.json"), "utf8"));
58
+ return typeof state.siteId === "string" ? state.siteId : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /** The name of the app's package, which `--filter` selects in the workspace. */
65
+ async function appPackage(root) {
66
+ try {
67
+ const manifest = JSON.parse(await readFile(join(root, netlifyBaseDirectory, "package.json"), "utf8"));
68
+ return typeof manifest.name === "string" ? manifest.name : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** Variables a serverless host must not get: STORAGE_DIRECTORY stores files on a disk Netlify doesn't keep. */
75
+ const serverlessExcluded = ["STORAGE_DIRECTORY"];
76
+ export async function deployToNetlify(args, project, cli, fileValues) {
77
+ const root = project.root;
78
+ const skipped = serverlessExcluded.filter((key) => fileValues[key]);
79
+ if (skipped.length)
80
+ log.warn(`${skipped.join(", ")} stores files on the server's disk, which Netlify doesn't keep: not set. Files go to the storage bucket.`);
81
+ const envFile = Object.fromEntries(Object.entries(fileValues).filter(([key]) => !serverlessExcluded.includes(key)));
82
+ const keys = exampleKeys(await readFile(join(root, ".env.example"), "utf8").catch(() => "")).filter((key) => !serverlessExcluded.includes(key));
83
+ if (!(await pathExists(join(root, netlifyBaseDirectory, "netlify.toml")))) {
84
+ throw new UsageError(`This project has no ${netlifyBaseDirectory}/netlify.toml. Switch with \`${cli} add deploy netlify\`.`);
85
+ }
86
+ const name = netlifySiteName(serviceName(project.identity.packageName));
87
+ const external = externalDatabaseUrls(project.modules, envFile);
88
+ // A public URL of your own (a custom domain) wins; otherwise the site's *.netlify.app URL.
89
+ const ownUrl = appUrlKeys.map((key) => envFile[key]).find((value) => value && !isLocalUrl(value));
90
+ const secret = () => randomBytes(32).toString("hex");
91
+ if (!external.DATABASE_URL) {
92
+ throw new UsageError("Netlify 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.");
93
+ }
94
+ if (args.dryRun) {
95
+ const preview = planVariables({ modules: project.modules, keys, url: ownUrl ?? `https://${name}.netlify.app`, existing: {}, envFile, secret: () => "<generated>", databases: {} });
96
+ const { NODE_ENV: _node, PORT: _port, ...values } = preview.set;
97
+ log.message([
98
+ `• Netlify site "${name}", built from ${netlifyBaseDirectory}, linked in .netlify/state.json`,
99
+ `• Variables: ${Object.keys(values).join(", ")}`,
100
+ ...(preview.unset.length ? [`• Not set: ${preview.unset.join(", ")}`] : []),
101
+ "• netlify deploy --build --prod, on this machine: check:env, the migrations, then the build",
102
+ ].join("\n"));
103
+ outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
104
+ return;
105
+ }
106
+ note([
107
+ "This deploys the project to Netlify, in your Netlify account:",
108
+ "",
109
+ `• A site "${name}" that builds ${netlifyBaseDirectory}. Netlify only runs the app: the database${project.modules.cache === "redis" ? ", Redis," : ""} and the other services come from the env file.`,
110
+ "• The production variables from the env file, the public URL, and a generated BETTER_AUTH_SECRET when the project uses Better Auth.",
111
+ "• netlify deploy --build --prod builds on this machine with the site's variables: check:env, the migrations against DATABASE_URL, then the build, which it uploads.",
112
+ "",
113
+ `Netlify bills usage on your plan (${netlifyPricingUrl}). Running deploy again reuses the site.`,
114
+ ].join("\n"), "Deploy to Netlify");
115
+ if (!args.yes) {
116
+ if (!process.stdin.isTTY)
117
+ throw new UsageError("deploy creates a site in your Netlify account. Run it in a terminal to confirm, or pass --yes.");
118
+ const answer = await confirm({ message: "Deploy?", initialValue: true });
119
+ if (isCancel(answer) || !answer) {
120
+ cancel("Nothing was deployed.");
121
+ return;
122
+ }
123
+ }
124
+ const progress = spinner();
125
+ progress.start("Preparing Netlify's CLI");
126
+ const nl = await netlifyCli(root);
127
+ // `netlify login` returns at once when you are signed in; otherwise it opens the browser.
128
+ if (!process.env.NETLIFY_AUTH_TOKEN) {
129
+ progress.stop("Signing in to Netlify");
130
+ if (!process.stdin.isTTY && !(await attempt(() => api(nl, "getCurrentUser", null, root)))) {
131
+ throw new UsageError("Sign in to Netlify first with `netlify login`, or set NETLIFY_AUTH_TOKEN.");
132
+ }
133
+ await netlify(nl, ["login"], root, { mode: "interactive" });
134
+ progress.start("Signed in");
135
+ }
136
+ progress.message("Finding or creating the Netlify site");
137
+ const linked = await linkedSite(root);
138
+ const lookup = async (id) => parseNetlifySite((await attempt(() => api(nl, "getSite", { site_id: id }, root))) ?? "");
139
+ let site = linked ? await lookup(linked) : await lookup(`${name}.netlify.app`);
140
+ if (!site) {
141
+ const [team] = parseNetlifyAccounts((await attempt(() => api(nl, "listAccountsForUser", null, root))) ?? "");
142
+ if (!team)
143
+ throw new Error("Netlify listed no team for your account. Check `netlify status`, then run deploy again.");
144
+ // Site names are global on Netlify: when the package's is taken, add a short suffix.
145
+ for (const candidate of [name, `${name.slice(0, 58)}-${randomBytes(2).toString("hex")}`]) {
146
+ const body = { name: candidate, build_settings: { base: netlifyBaseDirectory } };
147
+ site = parseNetlifySite((await attempt(() => api(nl, "createSiteInTeam", { account_slug: team, body }, root))) ?? "");
148
+ if (site)
149
+ break;
150
+ }
151
+ if (!site)
152
+ throw new Error(`Netlify created no site "${name}". Create it in the dashboard with the base directory ${netlifyBaseDirectory}, link it with \`netlify link\`, then run deploy again.`);
153
+ }
154
+ else if (site.base !== netlifyBaseDirectory) {
155
+ progress.message(`Setting the site's base directory to ${netlifyBaseDirectory}`);
156
+ await api(nl, "updateSite", { site_id: site.id, body: { build_settings: { base: netlifyBaseDirectory } } }, root);
157
+ }
158
+ if (linked !== site.id)
159
+ await netlify(nl, ["link", "--id", site.id], root);
160
+ progress.message("Setting the production variables");
161
+ const existing = await attempt(() => netlify(nl, ["env:list", "--json", "--context", "production"], root));
162
+ let current = {};
163
+ try {
164
+ current = JSON.parse(existing ?? "{}");
165
+ }
166
+ catch {
167
+ // Unknown output: every variable counts as unset, and the import sets them again.
168
+ }
169
+ const url = ownUrl ?? site.url;
170
+ const plan = planVariables({ modules: project.modules, keys, url, existing: current, envFile, secret, databases: {} });
171
+ // Netlify sets NODE_ENV and the port itself.
172
+ const { NODE_ENV: _node, PORT: _port, ...values } = plan.set;
173
+ // env:import reads a file: the values never appear on a command line. It stays private and goes away after.
174
+ const directory = await mkdtemp(join(tmpdir(), "clubedge-netlify-"));
175
+ try {
176
+ const file = join(directory, "production.env");
177
+ await writeFile(file, netlifyEnvFile(values), { mode: 0o600 });
178
+ await netlify(nl, ["env:import", file], root);
179
+ }
180
+ finally {
181
+ await rm(directory, { recursive: true, force: true });
182
+ }
183
+ progress.stop(`Netlify is ready: the site "${site.name}"`);
184
+ if (plan.unset.length) {
185
+ 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.`);
186
+ if (process.stdin.isTTY && !args.yes) {
187
+ const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
188
+ if (isCancel(proceed) || !proceed) {
189
+ cancel("Nothing was deployed. Add them to .env.production, then run deploy again: it reuses the site.");
190
+ return;
191
+ }
192
+ }
193
+ }
194
+ log.step("Deploying with netlify deploy --build --prod (build logs follow)");
195
+ const filter = await appPackage(root);
196
+ let deployed = null;
197
+ try {
198
+ const output = await netlify(nl, ["deploy", "--build", "--prod", "--json", ...(filter ? ["--filter", filter] : [])], root, { mode: "stream" });
199
+ deployed = parseNetlifyDeploy(output);
200
+ }
201
+ catch {
202
+ throw new Error("The deploy did not finish. The build log above says why (check:env names missing variables). Fix the cause and run deploy again: it reuses the site.");
203
+ }
204
+ if (!deployed)
205
+ log.warn("Netlify printed no deploy URL. Check the site's deploys with `netlify open`.");
206
+ note([
207
+ `${bold("URL")} ${url}`,
208
+ `${bold("Site")} ${site.name}`,
209
+ ...(plan.unset.length ? ["", `${bold("Not set")} ${plan.unset.join(", ")}`] : []),
210
+ ].join("\n"), "Deployed to Netlify");
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")} netlify logs`,
215
+ `${dim("Dashboard")} netlify open`,
216
+ `${dim("Again")} ${cli} deploy ${dim("(reuses the site; .env.production adds variables)")}`,
217
+ ].join("\n"));
218
+ }
@@ -0,0 +1,204 @@
1
+ // `deploy` for Render (experimental): Render builds the Dockerfile from the project's Git repository,
2
+ // so the branch must be pushed. With an API key (RENDER_API_KEY, or pasted when deploy asks), it
3
+ // creates or reuses a Docker web service that runs the migrations before each release and checks
4
+ // /api/health, as render.yaml does, sets the production variables, deploys, and waits until the
5
+ // release is live. Render has no database here: DATABASE_URL comes from the env file.
6
+ import { cancel, confirm, isCancel, log, note, outro, password, select, spinner } from "@clack/prompts";
7
+ import { randomBytes } from "node:crypto";
8
+ import { readFile } 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, isLocalUrl, parseRenderEnv, parseRenderOwners, parseRenderService, planVariables, renderApiUrl, renderFinalStatuses, renderRepoUrl, renderServiceBody, serviceName, } from "./core/deploy.js";
14
+ import { openUrl } from "./utils/open.js";
15
+ import { runCapture } from "./utils/process.js";
16
+ const renderKeysUrl = "https://dashboard.render.com/u/settings#api-keys";
17
+ const renderPricingUrl = "https://render.com/pricing";
18
+ /** Calls Render's API; an error names the request and Render's message. */
19
+ async function render(key, method, path, body) {
20
+ const response = await fetch(`${renderApiUrl}${path}`, {
21
+ method,
22
+ headers: { authorization: `Bearer ${key}`, accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }) },
23
+ body: body === undefined ? undefined : JSON.stringify(body),
24
+ });
25
+ const text = await response.text();
26
+ if (!response.ok) {
27
+ let message = text.trim();
28
+ try {
29
+ message = JSON.parse(text).message ?? message;
30
+ }
31
+ catch {
32
+ // Not JSON: Render's text as it is.
33
+ }
34
+ throw new Error(`Render's API answered ${response.status} to ${method} ${path.replace(/\?.*$/, "")}${message ? `: ${message}` : "."}`);
35
+ }
36
+ return (text ? JSON.parse(text) : null);
37
+ }
38
+ /** The Git remote and branch Render builds, after checking the branch is pushed. */
39
+ async function gitSource(root) {
40
+ const git = (...argv) => runCapture("git", argv, root);
41
+ const remote = await git("remote", "get-url", "origin").catch(() => "");
42
+ const repo = renderRepoUrl(remote);
43
+ if (!repo) {
44
+ throw new UsageError("Render builds from your Git repository: push this project to GitHub, GitLab, or Bitbucket as `origin`, then run deploy again.");
45
+ }
46
+ const branch = await git("rev-parse", "--abbrev-ref", "HEAD").catch(() => "");
47
+ if (!branch || branch === "HEAD") {
48
+ throw new UsageError("Render builds a branch of your Git repository: commit the project on a branch and push it, then run deploy again.");
49
+ }
50
+ const ahead = await git("rev-list", "--count", `origin/${branch}..HEAD`).catch(() => null);
51
+ if (ahead === null)
52
+ throw new UsageError(`Render builds the branch ${branch} from ${repo}, which isn't pushed yet. Run \`git push -u origin ${branch}\`, then deploy again.`);
53
+ if (ahead !== "0")
54
+ log.warn(`${ahead} commit(s) on ${branch} aren't pushed: Render builds what ${repo} has. Run \`git push\` to include them.`);
55
+ return { repo, branch };
56
+ }
57
+ /** The API key: RENDER_API_KEY, else pasted from the dashboard's API keys page. */
58
+ async function apiKey() {
59
+ if (process.env.RENDER_API_KEY)
60
+ return process.env.RENDER_API_KEY;
61
+ if (!process.stdin.isTTY)
62
+ throw new UsageError(`Set RENDER_API_KEY to a Render API key (${renderKeysUrl}), then run deploy again.`);
63
+ log.info(`Render signs deploy in with an API key. Create one on the page that opens (${renderKeysUrl}) and paste it here.`);
64
+ await openUrl(renderKeysUrl);
65
+ const key = await password({ message: "Render API key", validate: (value) => (value?.trim() ? undefined : "Paste the key from Render's dashboard.") });
66
+ if (isCancel(key))
67
+ throw new UsageError("Nothing was deployed.");
68
+ log.info("Set RENDER_API_KEY to it, and deploy won't ask next time.");
69
+ return key.trim();
70
+ }
71
+ /** The workspace for the service: the only one, or the one you pick. */
72
+ async function workspace(key, args) {
73
+ const owners = parseRenderOwners(await render(key, "GET", "/owners?limit=100"));
74
+ if (!owners.length)
75
+ throw new Error("Render listed no workspace for this API key.");
76
+ if (owners.length === 1 || args.yes || !process.stdin.isTTY)
77
+ return owners[0].id;
78
+ const picked = await select({ message: "Render workspace", options: owners.map((owner) => ({ value: owner.id, label: owner.name })) });
79
+ if (isCancel(picked))
80
+ throw new UsageError("Nothing was deployed.");
81
+ return picked;
82
+ }
83
+ export async function deployToRender(args, project, cli, envFile) {
84
+ const root = project.root;
85
+ const keys = exampleKeys(await readFile(join(root, ".env.example"), "utf8").catch(() => ""));
86
+ const name = serviceName(project.identity.packageName);
87
+ const external = externalDatabaseUrls(project.modules, envFile);
88
+ // A public URL of your own (a custom domain) wins; otherwise the service's *.onrender.com URL.
89
+ const ownUrl = appUrlKeys.map((key) => envFile[key]).find((value) => value && !isLocalUrl(value));
90
+ const secret = () => randomBytes(32).toString("hex");
91
+ if (!external.DATABASE_URL) {
92
+ throw new UsageError("deploy runs the app on Render, and the database comes from your provider (Render Postgres, Neon, Supabase, ...): put DATABASE_URL in .env.production, or pass --env-file, then run deploy again.");
93
+ }
94
+ const source = await gitSource(root);
95
+ if (args.dryRun) {
96
+ const preview = planVariables({ modules: project.modules, keys, url: ownUrl ?? `https://${name}.onrender.com`, existing: {}, envFile, secret: () => "<generated>", databases: {} });
97
+ const { NODE_ENV: _node, PORT: _port, ...values } = preview.set;
98
+ log.message([
99
+ `• Render web service "${name}" (Docker, starter instance), built from ${source.repo} (${source.branch})`,
100
+ `• Variables: ${Object.keys(values).join(", ")}`,
101
+ ...(preview.unset.length ? [`• Not set: ${preview.unset.join(", ")}`] : []),
102
+ "• A deploy: the image build, the migrations before the release, the /api/health check",
103
+ ].join("\n"));
104
+ outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
105
+ return;
106
+ }
107
+ note([
108
+ "This deploys the project to Render, in your Render account:",
109
+ "",
110
+ `• A web service "${name}" that builds the Dockerfile from ${source.repo} (${source.branch}), and deploys again on each push.`,
111
+ "• A starter instance: pre-deploy commands, which run the migrations before each release, need a paid instance type.",
112
+ "• The production variables from the env file, the public URL, and a generated BETTER_AUTH_SECRET when the project uses Better Auth.",
113
+ "• Render builds the image, runs the migrations, and checks /api/health; the first build takes several minutes.",
114
+ "",
115
+ `Render bills the instance on your plan (${renderPricingUrl}). Running deploy again reuses the service.`,
116
+ ].join("\n"), "Deploy to Render");
117
+ if (!args.yes) {
118
+ if (!process.stdin.isTTY)
119
+ throw new UsageError("deploy creates a service in your Render account. Run it in a terminal to confirm, or pass --yes.");
120
+ const answer = await confirm({ message: "Deploy?", initialValue: true });
121
+ if (isCancel(answer) || !answer) {
122
+ cancel("Nothing was deployed.");
123
+ return;
124
+ }
125
+ }
126
+ const key = await apiKey();
127
+ const owner = await workspace(key, args);
128
+ const progress = spinner();
129
+ progress.start("Finding or creating the Render service");
130
+ let service = parseRenderService(await render(key, "GET", `/services?name=${encodeURIComponent(name)}&ownerId=${encodeURIComponent(owner)}&limit=20`), name);
131
+ let deployId = null;
132
+ let url = ownUrl ?? service?.url ?? `https://${name}.onrender.com`;
133
+ const current = service ? parseRenderEnv(await render(key, "GET", `/services/${service.id}/env-vars?limit=100`)) : {};
134
+ const plan = planVariables({ modules: project.modules, keys, url, existing: current, envFile, secret, databases: {} });
135
+ // Render sets the port, and the Dockerfile NODE_ENV.
136
+ const { NODE_ENV: _node, PORT: _port, ...values } = plan.set;
137
+ if (!service) {
138
+ const created = await render(key, "POST", "/services", renderServiceBody({ name, ownerId: owner, ...source, values }));
139
+ service = parseRenderService(created);
140
+ if (!service)
141
+ throw new Error(`Render created no service "${name}". Create it in the dashboard (New, then Blueprint, with render.yaml), then run deploy again.`);
142
+ deployId = typeof created.deployId === "string" ? created.deployId : null;
143
+ // Render picks another subdomain when the name is taken: the build compiles the public URL in.
144
+ if (!ownUrl && service.url && service.url !== url) {
145
+ url = service.url;
146
+ log.info(`Render named the app ${url}. Setting it as the public URL and deploying again, since the build compiles it in.`);
147
+ for (const urlKey of appUrlKeys)
148
+ await render(key, "PUT", `/services/${service.id}/env-vars/${urlKey}`, { value: url });
149
+ // The deploy that creating the service started builds the old URL in, and would run the
150
+ // migrations alongside the next one: cancel it first. It may have ended already.
151
+ if (deployId)
152
+ await render(key, "POST", `/services/${service.id}/deploys/${deployId}/cancel`).catch(() => null);
153
+ deployId = null;
154
+ }
155
+ }
156
+ else {
157
+ progress.message("Setting the production variables");
158
+ for (const [envKey, value] of Object.entries(values)) {
159
+ if (current[envKey] === value)
160
+ continue;
161
+ await render(key, "PUT", `/services/${service.id}/env-vars/${envKey}`, { value });
162
+ }
163
+ }
164
+ progress.stop(`Render is ready: the service "${service.name}"`);
165
+ if (plan.unset.length) {
166
+ log.warn(`Not set: ${plan.unset.join(", ")}. The app starts without them, but the features that use them fail until they are set.`);
167
+ }
168
+ const deploying = spinner();
169
+ deploying.start("Starting the deploy");
170
+ if (!deployId) {
171
+ const started = await render(key, "POST", `/services/${service.id}/deploys`, { clearCache: "do_not_clear" });
172
+ deployId = typeof started?.id === "string" ? started.id : null;
173
+ }
174
+ if (!deployId)
175
+ throw new Error(`Render started no deploy. Start one in the dashboard, or run deploy again.`);
176
+ const begun = Date.now();
177
+ let status = "created";
178
+ while (!renderFinalStatuses.includes(status)) {
179
+ if (Date.now() - begun > 45 * 60_000)
180
+ break;
181
+ const elapsed = Math.round((Date.now() - begun) / 1000);
182
+ deploying.message(`Render: ${status.replaceAll("_", " ")} (${Math.floor(elapsed / 60)}m ${elapsed % 60}s; the first build takes several minutes)`);
183
+ await new Promise((wait) => setTimeout(wait, 10_000));
184
+ const deploy = await render(key, "GET", `/services/${service.id}/deploys/${deployId}`).catch(() => null);
185
+ if (typeof deploy?.status === "string")
186
+ status = deploy.status;
187
+ }
188
+ if (status !== "live") {
189
+ deploying.stop(`The deploy ended as ${status.replaceAll("_", " ")}`);
190
+ throw new Error(`Render's deploy ended as ${status}. Its logs in the dashboard say why (https://dashboard.render.com/web/${service.id}). Fix the cause and run deploy again: it reuses the service.`);
191
+ }
192
+ deploying.stop("Deployed: the migrations ran and /api/health answered");
193
+ note([
194
+ `${bold("URL")} ${url}`,
195
+ `${bold("Service")} ${service.name}`,
196
+ ...(plan.unset.length ? ["", `${bold("Not set")} ${plan.unset.join(", ")}`, dim("Set the ones your modules use in the service's Environment settings.")] : []),
197
+ ].join("\n"), "Deployed to Render");
198
+ if (args.open && process.stdin.isTTY && !args.yes && (await openUrl(url)))
199
+ log.info("Opened the app in your browser.");
200
+ outro([
201
+ `${dim("Dashboard")} https://dashboard.render.com/web/${service.id}`,
202
+ `${dim("Again")} ${cli} deploy ${dim("(reuses the service; .env.production adds variables)")}`,
203
+ ].join("\n"));
204
+ }
package/dist/deploy.js CHANGED
@@ -14,6 +14,8 @@ 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 { deployToNetlify } from "./deploy-netlify.js";
18
+ import { deployToRender } from "./deploy-render.js";
17
19
  import { deployToVercel } from "./deploy-vercel.js";
18
20
  import { cliCommand } from "./modify.js";
19
21
  import { resolvePnpm } from "./steps/install.js";
@@ -163,8 +165,12 @@ export async function deployProject(args) {
163
165
  return await deployToFly(args, project, cli, await readDeployEnv(args, project));
164
166
  if (platform === "vercel")
165
167
  return await deployToVercel(args, project, cli, await readDeployEnv(args, project));
168
+ if (platform === "netlify")
169
+ return await deployToNetlify(args, project, cli, await readDeployEnv(args, project));
170
+ if (platform === "render")
171
+ return await deployToRender(args, project, cli, await readDeployEnv(args, project));
166
172
  if (platform !== "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}.`);
173
+ throw new UsageError(`deploy sets up Railway, Fly.io, Vercel, Netlify, and Render, and this project deploys with "${platform}". Switch with \`${cli} add deploy railway\` (or fly, vercel, netlify, render), or follow ${deployDocsUrl} for ${platform}.`);
168
174
  }
169
175
  const service = serviceName(project.identity.packageName);
170
176
  const keys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",