@clubedge/create-clubedge-app 0.27.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 +17 -0
- package/README.md +18 -2
- package/dist/cli/ui.js +2 -2
- package/dist/core/deploy.js +234 -3
- package/dist/deploy-netlify.js +218 -0
- package/dist/deploy-render.js +204 -0
- package/dist/deploy-vercel.js +218 -0
- package/dist/deploy.js +10 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
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.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
|
+
|
|
13
|
+
## 0.28.0
|
|
14
|
+
|
|
15
|
+
- `pnpm clubedge deploy` deploys to Vercel (experimental) (#80), in projects created with `--deploy vercel`:
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- When Vercel names the app something other than `<project>.vercel.app`, it sets that URL and deploys again, since the build compiles it in.
|
|
19
|
+
- The README opens with the banner the CLI prints (#79).
|
|
20
|
+
- Still on Starter `v0.18.0`.
|
|
21
|
+
|
|
5
22
|
## 0.27.0
|
|
6
23
|
|
|
7
24
|
- Default to Starter `v0.18.0` (#77), which stops Vercel and Netlify builds when a production variable is missing:
|
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.**
|
|
@@ -205,12 +207,24 @@ 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
|
+
|
|
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
|
+
|
|
208
222
|
## Version relationship
|
|
209
223
|
|
|
210
224
|
Each CLI release is tested against one specific Starter release:
|
|
211
225
|
|
|
212
226
|
```text
|
|
213
|
-
create-clubedge-app v0.
|
|
227
|
+
create-clubedge-app v0.29.0
|
|
214
228
|
|
|
|
215
229
|
v
|
|
216
230
|
Clubedge Starter v0.18.0
|
|
@@ -221,7 +235,7 @@ The current relationship is:
|
|
|
221
235
|
|
|
222
236
|
| CLI release | Default Starter release | Meaning |
|
|
223
237
|
| --- | --- | --- |
|
|
224
|
-
| `create-clubedge-app@0.
|
|
238
|
+
| `create-clubedge-app@0.29.0` | `clubedge-starter@v0.18.0` | The CLI's required, tested default |
|
|
225
239
|
|
|
226
240
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
227
241
|
|
|
@@ -322,6 +336,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
322
336
|
| 0.25.0 | v0.17.1 | `deploy` to Fly.io (experimental); weekly Dependabot updates |
|
|
323
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` |
|
|
324
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`) |
|
|
339
|
+
| 0.28.0 | v0.18.0 | `deploy` to Vercel (experimental) |
|
|
340
|
+
| 0.29.0 | v0.18.0 | `deploy` to Netlify and Render (experimental) |
|
|
325
341
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
326
342
|
|
|
327
343
|
## 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, 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
|
-
--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,231 @@ 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
|
+
}
|
|
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
|
+
}
|
|
@@ -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,9 @@ 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";
|
|
19
|
+
import { deployToVercel } from "./deploy-vercel.js";
|
|
17
20
|
import { cliCommand } from "./modify.js";
|
|
18
21
|
import { resolvePnpm } from "./steps/install.js";
|
|
19
22
|
import { pathExists } from "./utils/fs.js";
|
|
@@ -160,8 +163,14 @@ export async function deployProject(args) {
|
|
|
160
163
|
const platform = project.modules.deploy ?? "docker";
|
|
161
164
|
if (platform === "fly")
|
|
162
165
|
return await deployToFly(args, project, cli, await readDeployEnv(args, project));
|
|
166
|
+
if (platform === "vercel")
|
|
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));
|
|
163
172
|
if (platform !== "railway") {
|
|
164
|
-
throw new UsageError(`deploy sets up Railway
|
|
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}.`);
|
|
165
174
|
}
|
|
166
175
|
const service = serviceName(project.identity.packageName);
|
|
167
176
|
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