@clubedge/create-clubedge-app 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +9 -2
- package/dist/cli/ui.js +2 -2
- package/dist/core/deploy.js +95 -3
- package/dist/deploy-vercel.js +218 -0
- package/dist/deploy.js +4 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.28.0
|
|
6
|
+
|
|
7
|
+
- `pnpm clubedge deploy` deploys to Vercel (experimental) (#80), in projects created with `--deploy vercel`:
|
|
8
|
+
- It signs in with `vercel login` when you are signed out, then creates or reuses the project with `apps/web` as its Root Directory and writes `.vercelignore` so `.env` files stay on your machine.
|
|
9
|
+
- It sets the production variables from `.env.production` (or `--env-file`), the public URL, and a generated `BETTER_AUTH_SECRET`, then runs `vercel deploy --prod`. `DATABASE_URL` must point at your provider (Neon, Supabase, ...), since Vercel only runs the app.
|
|
10
|
+
- When Vercel names the app something other than `<project>.vercel.app`, it sets that URL and deploys again, since the build compiles it in.
|
|
11
|
+
- The README opens with the banner the CLI prints (#79).
|
|
12
|
+
- Still on Starter `v0.18.0`.
|
|
13
|
+
|
|
5
14
|
## 0.27.0
|
|
6
15
|
|
|
7
16
|
- 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,16 @@ With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or
|
|
|
205
207
|
|
|
206
208
|
In a project with `--deploy fly`, `pnpm clubedge deploy` does the same with [Fly's CLI](https://fly.io/docs/flyctl/install/) (install it first; it is not on npm) and your Fly login: it creates the app (`fly launch`, which writes its name and region into `fly.toml`; commit it), an unmanaged Postgres app attached as `DATABASE_URL` (one `shared-cpu-1x` machine and a 1 GB volume) and Upstash Redis with `cache redis`, each unless the env file has its URL, and imports the secrets (the public URL `https://<app>.fly.dev`, a kept `BETTER_AUTH_SECRET`, and `--env-file` values). Then `fly deploy` builds the Dockerfile with `NEXT_PUBLIC_APP_URL` as a build argument, runs the migrations as the release command, and waits for the health checks. Fly bills these on your plan; for Fly's Managed Postgres, create and attach it yourself before deploying. Running it again reuses everything.
|
|
207
209
|
|
|
210
|
+
### Deploy to Vercel (experimental)
|
|
211
|
+
|
|
212
|
+
In a project with `--deploy vercel`, `pnpm clubedge deploy` uses [Vercel's CLI](https://vercel.com/docs/cli) (the installed `vercel`, else the latest through npx) and your Vercel login, running `vercel login` first when you are signed out. Vercel only runs the app, so the database and the other services come from the env file: `DATABASE_URL` (your provider's pooled URL, for example Neon's or Supabase's) is required. It creates or reuses a project named after the package, built from `apps/web` with the whole workspace uploaded, and writes a `.vercelignore` when the project has none, so `.env` files stay on your machine (commit it). It sets the production variables: the env file's values, the public URL, and a generated `BETTER_AUTH_SECRET` with Better Auth. `STORAGE_DIRECTORY` is left out, since Vercel keeps no disk. Then `vercel deploy --prod` builds the app, after `check:env` and the migrations. When Vercel names the app something other than `https://<project>.vercel.app`, `deploy` sets that URL and deploys again, since the build compiles it in. Running it again reuses the project. Vercel bills usage on your plan.
|
|
213
|
+
|
|
208
214
|
## Version relationship
|
|
209
215
|
|
|
210
216
|
Each CLI release is tested against one specific Starter release:
|
|
211
217
|
|
|
212
218
|
```text
|
|
213
|
-
create-clubedge-app v0.
|
|
219
|
+
create-clubedge-app v0.28.0
|
|
214
220
|
|
|
|
215
221
|
v
|
|
216
222
|
Clubedge Starter v0.18.0
|
|
@@ -221,7 +227,7 @@ The current relationship is:
|
|
|
221
227
|
|
|
222
228
|
| CLI release | Default Starter release | Meaning |
|
|
223
229
|
| --- | --- | --- |
|
|
224
|
-
| `create-clubedge-app@0.
|
|
230
|
+
| `create-clubedge-app@0.28.0` | `clubedge-starter@v0.18.0` | The CLI's required, tested default |
|
|
225
231
|
|
|
226
232
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
227
233
|
|
|
@@ -322,6 +328,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
322
328
|
| 0.25.0 | v0.17.1 | `deploy` to Fly.io (experimental); weekly Dependabot updates |
|
|
323
329
|
| 0.26.0 | v0.17.1 | `deploy` uses your own databases (Supabase, Neon, Upstash) when the env file has their URLs, and reads `.env.production` |
|
|
324
330
|
| 0.27.0 | v0.18.0 | Starter v0.18.0: Vercel and Netlify builds stop when a production variable is missing (`pnpm check:env`) |
|
|
331
|
+
| 0.28.0 | v0.18.0 | `deploy` to Vercel (experimental) |
|
|
325
332
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
326
333
|
|
|
327
334
|
## Community
|
package/dist/cli/ui.js
CHANGED
|
@@ -124,10 +124,10 @@ ${selectionHelp(manifest)}
|
|
|
124
124
|
--dry-run Show what would be created or changed without writing anything
|
|
125
125
|
--force add/remove: change a project with uncommitted changes or without Git
|
|
126
126
|
--no-services doctor: skip pnpm check:services
|
|
127
|
-
--no-open try: do not open the
|
|
127
|
+
--no-open try, deploy (Vercel): do not open the URL in the browser
|
|
128
128
|
--claim try: open the page that claims the VM, to share the preview
|
|
129
129
|
--status try: the last VM's preview, time left, and claim link
|
|
130
|
-
--env-file <path> deploy
|
|
130
|
+
--env-file <path> deploy: production values (default: .env.production when there is one)
|
|
131
131
|
-y, --yes Accept defaults and never prompt (default directory: my-app)
|
|
132
132
|
--no-install Skip dependency installation
|
|
133
133
|
--no-git Skip Git repository initialization
|
package/dist/core/deploy.js
CHANGED
|
@@ -87,6 +87,9 @@ export function planVariables(input) {
|
|
|
87
87
|
for (const [key, value] of Object.entries(envFile)) {
|
|
88
88
|
if (value === "" || databaseKeys.includes(key))
|
|
89
89
|
continue;
|
|
90
|
+
// A development env file's localhost URL would send emailed links to this machine.
|
|
91
|
+
if (appUrlKeys.includes(key) && isLocalUrl(value))
|
|
92
|
+
continue;
|
|
90
93
|
set[key] = value;
|
|
91
94
|
}
|
|
92
95
|
Object.assign(set, external);
|
|
@@ -166,10 +169,10 @@ export function railwayCliVersion(output) {
|
|
|
166
169
|
const match = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(output);
|
|
167
170
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
168
171
|
}
|
|
169
|
-
export function meetsMinimumCli(version) {
|
|
172
|
+
export function meetsMinimumCli(version, minimum = minimumRailwayCli) {
|
|
170
173
|
for (let i = 0; i < 3; i++) {
|
|
171
|
-
if (version[i] !==
|
|
172
|
-
return version[i] >
|
|
174
|
+
if (version[i] !== minimum[i])
|
|
175
|
+
return version[i] > minimum[i];
|
|
173
176
|
}
|
|
174
177
|
return true;
|
|
175
178
|
}
|
|
@@ -246,3 +249,92 @@ export function flySecretsInput(values) {
|
|
|
246
249
|
.map(([key, value]) => `${key}=${value}`)
|
|
247
250
|
.join("\n");
|
|
248
251
|
}
|
|
252
|
+
// Vercel: the platform only runs the app. Databases, Redis, storage, and email come from the env file.
|
|
253
|
+
/** vercel@50.5.1 added `vercel api`, which deploy uses to create the project and set its variables. */
|
|
254
|
+
export const minimumVercelCli = [50, 5, 1];
|
|
255
|
+
/** Where Vercel builds: the app's folder, with the whole workspace uploaded. */
|
|
256
|
+
export const vercelRootDirectory = "apps/web";
|
|
257
|
+
/** The variables that hold the public URL: readable on Vercel ("plain"), so a later deploy can compare them. */
|
|
258
|
+
export const appUrlKeys = ["APP_URL", "NEXT_PUBLIC_APP_URL"];
|
|
259
|
+
/** A project in `vercel api /v9/projects/:name` output, or null. */
|
|
260
|
+
export function parseVercelProject(output) {
|
|
261
|
+
try {
|
|
262
|
+
const value = JSON.parse(output);
|
|
263
|
+
if (typeof value.id !== "string" || typeof value.name !== "string")
|
|
264
|
+
return null;
|
|
265
|
+
return { id: value.id, name: value.name, rootDirectory: typeof value.rootDirectory === "string" ? value.rootDirectory : null };
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** The project's production variables from `vercel api /v10/projects/:id/env`: plain values as they are, the rest as "(set)". */
|
|
272
|
+
export function parseVercelEnv(output) {
|
|
273
|
+
const values = {};
|
|
274
|
+
try {
|
|
275
|
+
const { envs } = JSON.parse(output);
|
|
276
|
+
for (const env of envs ?? []) {
|
|
277
|
+
const targets = Array.isArray(env.target) ? env.target : [env.target];
|
|
278
|
+
if (typeof env.key !== "string" || !targets.includes("production"))
|
|
279
|
+
continue;
|
|
280
|
+
values[env.key] = env.type === "plain" && typeof env.value === "string" ? env.value : "(set)";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Unknown output: every variable counts as unset, and the upsert sets them again.
|
|
285
|
+
}
|
|
286
|
+
return values;
|
|
287
|
+
}
|
|
288
|
+
/** The body of `POST /v10/projects/:id/env?upsert=true`: production variables, encrypted except the public URL. */
|
|
289
|
+
export function vercelEnvBody(values) {
|
|
290
|
+
return Object.entries(values).map(([key, value]) => ({
|
|
291
|
+
key,
|
|
292
|
+
value,
|
|
293
|
+
type: appUrlKeys.includes(key) ? "plain" : "encrypted",
|
|
294
|
+
target: ["production"],
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The public URL Vercel gave a production deployment, from `vercel api /v13/deployments/:url`: the
|
|
299
|
+
* shortest *.vercel.app alias that isn't the deployment's own URL or a Git branch's.
|
|
300
|
+
*/
|
|
301
|
+
export function vercelProductionUrl(output, deploymentUrl) {
|
|
302
|
+
const own = deploymentUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
|
|
303
|
+
try {
|
|
304
|
+
const { alias } = JSON.parse(output);
|
|
305
|
+
const names = (Array.isArray(alias) ? alias : [])
|
|
306
|
+
.filter((name) => typeof name === "string")
|
|
307
|
+
.map((name) => name.toLowerCase())
|
|
308
|
+
.filter((name) => name.endsWith(".vercel.app") && name !== own && !name.includes("-git-"))
|
|
309
|
+
.sort((a, b) => a.length - b.length);
|
|
310
|
+
return names[0] ? `https://${names[0]}` : null;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** The deployment URL `vercel deploy` prints on stdout (its last https URL). */
|
|
317
|
+
export function parseVercelDeploymentUrl(output) {
|
|
318
|
+
const urls = output.match(/https:\/\/[a-z0-9.-]+\.vercel\.app\b/gi);
|
|
319
|
+
return urls?.at(-1)?.toLowerCase() ?? null;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* .vercelignore for a project without one: the CLI uploads everything but a short list, which
|
|
323
|
+
* keeps .env.local out but not .env or .env.production. Uploading what Git would commit keeps
|
|
324
|
+
* secrets and build output out, as `try` does.
|
|
325
|
+
*/
|
|
326
|
+
export function vercelIgnoreSource(gitignore) {
|
|
327
|
+
const lines = gitignore.split(/\r?\n/).map((line) => line.trimEnd());
|
|
328
|
+
const required = [".env*", "!.env.example"].filter((line) => !lines.includes(line));
|
|
329
|
+
return [
|
|
330
|
+
"# What `vercel deploy` leaves out: what Git ignores, so secrets in .env files and build output",
|
|
331
|
+
"# stay on this machine. Vercel reads its variables from the project's settings.",
|
|
332
|
+
...lines.filter((line, index) => line || index < lines.length - 1),
|
|
333
|
+
...required,
|
|
334
|
+
"",
|
|
335
|
+
].join("\n");
|
|
336
|
+
}
|
|
337
|
+
/** Whether a .vercelignore keeps .env files out of the upload. */
|
|
338
|
+
export function ignoresEnvFiles(vercelignore) {
|
|
339
|
+
return vercelignore.split(/\r?\n/).some((line) => /^\/?(\*\*\/)?\.env(\*|\.\*)?\/?$/.test(line.trim()));
|
|
340
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// `deploy` for Vercel: the platform only runs the app, so the database, Redis, storage, and email
|
|
2
|
+
// come from the env file. With Vercel's CLI and your login, it creates or reuses the project (built
|
|
3
|
+
// from apps/web, with the whole workspace uploaded), sets the production variables, runs
|
|
4
|
+
// `vercel deploy --prod`, and prints the public URL. The build checks the variables (check:env)
|
|
5
|
+
// and runs the migrations before it builds.
|
|
6
|
+
import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { UsageError } from "./cli/args.js";
|
|
12
|
+
import { bold, dim } from "./cli/ui.js";
|
|
13
|
+
import { appUrlKeys, exampleKeys, externalDatabaseUrls, ignoresEnvFiles, isLocalUrl, meetsMinimumCli, minimumVercelCli, parseVercelDeploymentUrl, parseVercelEnv, parseVercelProject, planVariables, railwayCliVersion, serviceName, vercelEnvBody, vercelIgnoreSource, vercelProductionUrl, vercelRootDirectory, } from "./core/deploy.js";
|
|
14
|
+
import { pathExists } from "./utils/fs.js";
|
|
15
|
+
import { openUrl } from "./utils/open.js";
|
|
16
|
+
import { spawnCommand } from "./utils/process.js";
|
|
17
|
+
const vercelCliUrl = "https://vercel.com/docs/cli";
|
|
18
|
+
const vercelPricingUrl = "https://vercel.com/pricing";
|
|
19
|
+
const windows = process.platform === "win32";
|
|
20
|
+
/** Runs Vercel's CLI in the project's root; npm's .cmd shims need a shell on Windows. */
|
|
21
|
+
async function vercel(cli, args, cwd, options = {}) {
|
|
22
|
+
const [command, ...prefix] = cli.command;
|
|
23
|
+
const mode = options.mode ?? "capture";
|
|
24
|
+
const stdio = mode === "interactive" ? "inherit" : mode === "stream" ? ["pipe", "pipe", "inherit"] : ["pipe", "pipe", "pipe"];
|
|
25
|
+
return await new Promise((resolvePromise, reject) => {
|
|
26
|
+
const child = spawnCommand(command, [...prefix, ...args], { cwd, stdio: stdio }, windows);
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
|
|
30
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
|
|
31
|
+
child.once("error", reject);
|
|
32
|
+
child.once("close", (code) => {
|
|
33
|
+
if (code === 0)
|
|
34
|
+
resolvePromise(stdout.trim());
|
|
35
|
+
else
|
|
36
|
+
reject(new Error(`vercel ${args[0]} failed${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : "."}`));
|
|
37
|
+
});
|
|
38
|
+
if (child.stdin) {
|
|
39
|
+
child.stdin.on("error", () => { });
|
|
40
|
+
child.stdin.end(options.input ?? "");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const attempt = async (run) => run().catch(() => null);
|
|
45
|
+
async function vercelCli(cwd) {
|
|
46
|
+
for (const command of [["vercel"], ["npx", "--yes", "vercel@latest"]]) {
|
|
47
|
+
const cli = { command };
|
|
48
|
+
const version = railwayCliVersion((await attempt(() => vercel(cli, ["--version"], cwd))) ?? "");
|
|
49
|
+
if (version && meetsMinimumCli(version, minimumVercelCli))
|
|
50
|
+
return cli;
|
|
51
|
+
}
|
|
52
|
+
throw new UsageError(`deploy to Vercel needs Vercel's CLI ${minimumVercelCli.join(".")} or newer. Install it with \`npm install -g vercel\` (${vercelCliUrl}), then run deploy again.`);
|
|
53
|
+
}
|
|
54
|
+
/** The project `vercel link` recorded in .vercel/project.json, if any. */
|
|
55
|
+
async function linkedProject(root) {
|
|
56
|
+
try {
|
|
57
|
+
const linked = JSON.parse(await readFile(join(root, ".vercel", "project.json"), "utf8"));
|
|
58
|
+
return typeof linked.projectId === "string" ? linked.projectId : null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Writes .vercelignore when the project has none, so .env files never reach Vercel. */
|
|
65
|
+
async function ensureVercelIgnore(root) {
|
|
66
|
+
const path = join(root, ".vercelignore");
|
|
67
|
+
if (await pathExists(path)) {
|
|
68
|
+
if (!ignoresEnvFiles(await readFile(path, "utf8"))) {
|
|
69
|
+
log.warn("Your .vercelignore doesn't leave out .env files, so `vercel deploy` uploads them. Add `.env*` and `!.env.example` to it.");
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const gitignore = await readFile(join(root, ".gitignore"), "utf8").catch(() => "");
|
|
74
|
+
await writeFile(path, vercelIgnoreSource(gitignore));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/** Variables a serverless host must not get: STORAGE_DIRECTORY stores files on a disk Vercel doesn't keep. */
|
|
78
|
+
const serverlessExcluded = ["STORAGE_DIRECTORY"];
|
|
79
|
+
export async function deployToVercel(args, project, cli, fileValues) {
|
|
80
|
+
const root = project.root;
|
|
81
|
+
const skipped = serverlessExcluded.filter((key) => fileValues[key]);
|
|
82
|
+
if (skipped.length)
|
|
83
|
+
log.warn(`${skipped.join(", ")} stores files on the server's disk, which Vercel doesn't keep: not set. Files go to the storage bucket.`);
|
|
84
|
+
const envFile = Object.fromEntries(Object.entries(fileValues).filter(([key]) => !serverlessExcluded.includes(key)));
|
|
85
|
+
const keys = exampleKeys(await readFile(join(root, ".env.example"), "utf8").catch(() => "")).filter((key) => !serverlessExcluded.includes(key));
|
|
86
|
+
if (!(await pathExists(join(root, vercelRootDirectory, "vercel.json")))) {
|
|
87
|
+
throw new UsageError(`This project has no ${vercelRootDirectory}/vercel.json. Switch with \`${cli} add deploy vercel\`.`);
|
|
88
|
+
}
|
|
89
|
+
const name = serviceName(project.identity.packageName);
|
|
90
|
+
const external = externalDatabaseUrls(project.modules, envFile);
|
|
91
|
+
// A public URL of your own (a custom domain) wins; otherwise the one Vercel assigns.
|
|
92
|
+
const ownUrl = appUrlKeys.map((key) => envFile[key]).find((value) => value && !isLocalUrl(value));
|
|
93
|
+
const secret = () => randomBytes(32).toString("hex");
|
|
94
|
+
if (!external.DATABASE_URL) {
|
|
95
|
+
throw new UsageError("Vercel only runs the app, so the database comes from your provider (Supabase, Neon, ...): put DATABASE_URL in .env.production (its pooled URL), or pass --env-file, then run deploy again.");
|
|
96
|
+
}
|
|
97
|
+
if (args.dryRun) {
|
|
98
|
+
const preview = planVariables({ modules: project.modules, keys, url: ownUrl ?? `https://${name}.vercel.app`, existing: {}, envFile, secret: () => "<generated>", databases: {} });
|
|
99
|
+
const { NODE_ENV: _node, PORT: _port, ...values } = preview.set;
|
|
100
|
+
log.message([
|
|
101
|
+
`• Vercel project "${name}", built from ${vercelRootDirectory} with the whole workspace uploaded`,
|
|
102
|
+
`• Production variables: ${Object.keys(values).join(", ")}`,
|
|
103
|
+
...(preview.unset.length ? [`• Not set: ${preview.unset.join(", ")}`] : []),
|
|
104
|
+
"• .vercelignore (when missing): what Git ignores, so .env files stay on this machine",
|
|
105
|
+
"• vercel deploy --prod: check:env, the migrations, then the build",
|
|
106
|
+
].join("\n"));
|
|
107
|
+
outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
note([
|
|
111
|
+
"This deploys the project to Vercel, in your Vercel account:",
|
|
112
|
+
"",
|
|
113
|
+
`• A project "${name}" that builds ${vercelRootDirectory}. Vercel only runs the app: the database${project.modules.cache === "redis" ? ", Redis," : ""} and the other services come from the env file.`,
|
|
114
|
+
"• The production variables from the env file, the public URL, and a generated BETTER_AUTH_SECRET when the project uses Better Auth.",
|
|
115
|
+
"• vercel deploy --prod: the build checks the variables (check:env), runs the migrations against DATABASE_URL, then builds.",
|
|
116
|
+
"",
|
|
117
|
+
`Vercel bills usage on your plan (${vercelPricingUrl}). Running deploy again reuses the project.`,
|
|
118
|
+
].join("\n"), "Deploy to Vercel");
|
|
119
|
+
if (!args.yes) {
|
|
120
|
+
if (!process.stdin.isTTY)
|
|
121
|
+
throw new UsageError("deploy creates a project in your Vercel account. Run it in a terminal to confirm, or pass --yes.");
|
|
122
|
+
const answer = await confirm({ message: "Deploy?", initialValue: true });
|
|
123
|
+
if (isCancel(answer) || !answer) {
|
|
124
|
+
cancel("Nothing was deployed.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const progress = spinner();
|
|
129
|
+
progress.start("Preparing Vercel's CLI");
|
|
130
|
+
const vc = await vercelCli(root);
|
|
131
|
+
if (!(await attempt(() => vercel(vc, ["whoami"], root)))) {
|
|
132
|
+
progress.stop("Signing in to Vercel");
|
|
133
|
+
if (!process.stdin.isTTY)
|
|
134
|
+
throw new UsageError("Sign in to Vercel first with `vercel login`, or set VERCEL_TOKEN.");
|
|
135
|
+
await vercel(vc, ["login"], root, { mode: "interactive" });
|
|
136
|
+
progress.start("Signed in");
|
|
137
|
+
}
|
|
138
|
+
progress.message("Finding or creating the Vercel project");
|
|
139
|
+
const linked = await linkedProject(root);
|
|
140
|
+
let found = parseVercelProject((await attempt(() => vercel(vc, ["api", `/v9/projects/${linked ?? name}`], root))) ?? "");
|
|
141
|
+
if (!found) {
|
|
142
|
+
const created = await vercel(vc, ["api", "/v10/projects", "-X", "POST", "--input", "-"], root, {
|
|
143
|
+
input: JSON.stringify({ name, rootDirectory: vercelRootDirectory }),
|
|
144
|
+
});
|
|
145
|
+
found = parseVercelProject(created);
|
|
146
|
+
if (!found)
|
|
147
|
+
throw new Error(`Vercel created no project "${name}". Create it in the dashboard with the Root Directory ${vercelRootDirectory}, then run deploy again.`);
|
|
148
|
+
}
|
|
149
|
+
else if (found.rootDirectory !== vercelRootDirectory) {
|
|
150
|
+
progress.message(`Setting the project's Root Directory to ${vercelRootDirectory}`);
|
|
151
|
+
await vercel(vc, ["api", `/v9/projects/${found.id}`, "-X", "PATCH", "--input", "-"], root, {
|
|
152
|
+
input: JSON.stringify({ rootDirectory: vercelRootDirectory }),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (!linked)
|
|
156
|
+
await vercel(vc, ["link", "--yes", "--project", found.name], root);
|
|
157
|
+
const wroteIgnore = await ensureVercelIgnore(root);
|
|
158
|
+
progress.message("Setting the production variables");
|
|
159
|
+
const existing = parseVercelEnv((await attempt(() => vercel(vc, ["api", `/v10/projects/${found.id}/env`], root))) ?? "");
|
|
160
|
+
const keptUrl = appUrlKeys.map((key) => existing[key]).find((value) => value && value !== "(set)" && !isLocalUrl(value));
|
|
161
|
+
let url = ownUrl ?? keptUrl ?? `https://${found.name}.vercel.app`;
|
|
162
|
+
const plan = planVariables({ modules: project.modules, keys, url, existing, envFile, secret, databases: {} });
|
|
163
|
+
// Vercel sets NODE_ENV and the port itself.
|
|
164
|
+
const { NODE_ENV: _node, PORT: _port, ...values } = plan.set;
|
|
165
|
+
await vercel(vc, ["api", `/v10/projects/${found.id}/env?upsert=true`, "-X", "POST", "--input", "-"], root, {
|
|
166
|
+
input: JSON.stringify(vercelEnvBody(values)),
|
|
167
|
+
});
|
|
168
|
+
progress.stop(`Vercel is ready: the project "${found.name}"`);
|
|
169
|
+
if (wroteIgnore)
|
|
170
|
+
log.info("Commit .vercelignore: it keeps .env files and build output out of `vercel deploy`.");
|
|
171
|
+
if (plan.unset.length) {
|
|
172
|
+
log.warn(`Not set: ${plan.unset.join(", ")}. The build stops at check:env when one of them is required; the others only turn features off.`);
|
|
173
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
174
|
+
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
175
|
+
if (isCancel(proceed) || !proceed) {
|
|
176
|
+
cancel("Nothing was deployed. Add them to .env.production, then run deploy again: it reuses the project.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const deploy = async () => {
|
|
182
|
+
log.step("Deploying with vercel deploy --prod (build logs follow)");
|
|
183
|
+
try {
|
|
184
|
+
const output = await vercel(vc, ["deploy", "--prod", "--yes", "--logs"], root, { mode: "stream" });
|
|
185
|
+
const deployment = parseVercelDeploymentUrl(output);
|
|
186
|
+
if (!deployment)
|
|
187
|
+
throw new Error("no URL");
|
|
188
|
+
return deployment;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new Error("The deploy did not finish. The build log above says why (check:env names missing variables); `vercel inspect --logs <url>` shows it again. Fix the cause and run deploy again: it reuses the project.");
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const deployment = await deploy();
|
|
195
|
+
const details = await attempt(() => vercel(vc, ["api", `/v13/deployments/${deployment.replace(/^https:\/\//, "")}`], root));
|
|
196
|
+
const assigned = vercelProductionUrl(details ?? "", deployment);
|
|
197
|
+
// Next.js compiles the public URL into the build: when Vercel picked another name, set it and build again.
|
|
198
|
+
if (!ownUrl && assigned && assigned !== url) {
|
|
199
|
+
log.info(`Vercel named the app ${assigned}. Setting it as the public URL and deploying again, since the build compiles it in.`);
|
|
200
|
+
url = assigned;
|
|
201
|
+
await vercel(vc, ["api", `/v10/projects/${found.id}/env?upsert=true`, "-X", "POST", "--input", "-"], root, {
|
|
202
|
+
input: JSON.stringify(vercelEnvBody(Object.fromEntries(appUrlKeys.map((key) => [key, url])))),
|
|
203
|
+
});
|
|
204
|
+
await deploy();
|
|
205
|
+
}
|
|
206
|
+
note([
|
|
207
|
+
`${bold("URL")} ${url}`,
|
|
208
|
+
`${bold("Project")} ${found.name}`,
|
|
209
|
+
...(plan.unset.length ? ["", `${bold("Not set")} ${plan.unset.join(", ")}`] : []),
|
|
210
|
+
].join("\n"), "Deployed to Vercel");
|
|
211
|
+
if (args.open && process.stdin.isTTY && !args.yes && (await openUrl(url)))
|
|
212
|
+
log.info("Opened the app in your browser.");
|
|
213
|
+
outro([
|
|
214
|
+
`${dim("Logs")} vercel logs ${url}`,
|
|
215
|
+
`${dim("Dashboard")} vercel open`,
|
|
216
|
+
`${dim("Again")} ${cli} deploy ${dim("(reuses the project; .env.production adds variables)")}`,
|
|
217
|
+
].join("\n"));
|
|
218
|
+
}
|
package/dist/deploy.js
CHANGED
|
@@ -14,6 +14,7 @@ import { bold, dim } from "./cli/ui.js";
|
|
|
14
14
|
import { appPort, databaseVariables, databasesFor, deploymentStatus, exampleKeys, externalDatabaseUrls, finalStatuses, isLocalUrl, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planVariables, railwayCliVersion, railwayDatabases, railwayIacPath, railwayIacSource, railwayReferences, serviceName, serviceNames, } from "./core/deploy.js";
|
|
15
15
|
import { readProject } from "./core/project.js";
|
|
16
16
|
import { deployToFly } from "./deploy-fly.js";
|
|
17
|
+
import { deployToVercel } from "./deploy-vercel.js";
|
|
17
18
|
import { cliCommand } from "./modify.js";
|
|
18
19
|
import { resolvePnpm } from "./steps/install.js";
|
|
19
20
|
import { pathExists } from "./utils/fs.js";
|
|
@@ -160,8 +161,10 @@ export async function deployProject(args) {
|
|
|
160
161
|
const platform = project.modules.deploy ?? "docker";
|
|
161
162
|
if (platform === "fly")
|
|
162
163
|
return await deployToFly(args, project, cli, await readDeployEnv(args, project));
|
|
164
|
+
if (platform === "vercel")
|
|
165
|
+
return await deployToVercel(args, project, cli, await readDeployEnv(args, project));
|
|
163
166
|
if (platform !== "railway") {
|
|
164
|
-
throw new UsageError(`deploy sets up Railway
|
|
167
|
+
throw new UsageError(`deploy sets up Railway, Fly.io, and Vercel for now, and this project deploys with "${platform}". Switch with \`${cli} add deploy railway\` (or fly, or vercel), or follow ${deployDocsUrl} for ${platform}.`);
|
|
165
168
|
}
|
|
166
169
|
const service = serviceName(project.identity.packageName);
|
|
167
170
|
const keys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
|
package/dist/index.js
CHANGED
|
@@ -237,7 +237,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
237
237
|
await runDoctor(args);
|
|
238
238
|
}
|
|
239
239
|
else if (args.command.name === "deploy") {
|
|
240
|
-
intro("Deploy this project
|
|
240
|
+
intro("Deploy this project");
|
|
241
241
|
await deployProject(args);
|
|
242
242
|
}
|
|
243
243
|
else if (args.command.name === "try") {
|
package/package.json
CHANGED