@clubedge/create-clubedge-app 0.24.1 → 0.26.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 +22 -0
- package/README.md +11 -5
- package/dist/cli/ui.js +1 -1
- package/dist/core/deploy.js +89 -17
- package/dist/deploy-fly.js +179 -0
- package/dist/deploy.js +61 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.26.0
|
|
6
|
+
|
|
7
|
+
- `deploy` uses your own databases when the env file has their URLs (#74). The platform hosts everything as before when it doesn't.
|
|
8
|
+
- With `DATABASE_URL` or `REDIS_URL` in the env file (Supabase, Neon, Upstash), `deploy` creates no Railway or Fly database for it, sets your URL, and the platform only runs the app. Each URL counts on its own, so only `DATABASE_URL` keeps Redis on the platform.
|
|
9
|
+
- `deploy` reads `.env.production` from the project's root when there is one and no `--env-file` is given. An `--env-file` that doesn't exist is now a clear error.
|
|
10
|
+
- URLs to `localhost` (a development env file) are ignored with a warning.
|
|
11
|
+
- A later run without the env file keeps the database URL the service already has, instead of creating the platform's database and switching the app over. On Railway, `deploy` stops before changing anything if it can't list an existing service's variables. On Fly, an existing `DATABASE_URL` secret skips creating `<app>-db`.
|
|
12
|
+
- When the app moves from Railway's database to its own, `deploy` notes that the old one is unused; it never deletes it.
|
|
13
|
+
- The showcase records a `try` walkthrough on a free Railway VM (#73).
|
|
14
|
+
- `pnpm release` counts squash-merged pull requests in the changelog draft (#75).
|
|
15
|
+
- Still on Starter `v0.17.1`.
|
|
16
|
+
|
|
17
|
+
## 0.25.0
|
|
18
|
+
|
|
19
|
+
- `deploy` for Fly.io (experimental) (#71): in a project with `deploy fly`, `pnpm clubedge deploy` uses Fly's CLI and your Fly login.
|
|
20
|
+
- It creates or reuses 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`.
|
|
21
|
+
- It imports the secrets through stdin: the public URL `https://<app>.fly.dev`, a `BETTER_AUTH_SECRET` kept on later runs, and `--env-file` values; the ones still unset are listed before the build.
|
|
22
|
+
- `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.
|
|
23
|
+
- Fly's Managed Postgres (from $38/month) is a manual choice: create and attach it before deploying.
|
|
24
|
+
- Weekly Dependabot updates for the CLI's npm dependencies and GitHub Actions (#67). The Starter gets the same, without passing it on to generated projects, which receive updates as upgrade pull requests.
|
|
25
|
+
- Still on Starter `v0.17.1`.
|
|
26
|
+
|
|
5
27
|
## 0.24.1
|
|
6
28
|
|
|
7
29
|
- Default to Starter `v0.17.1` (#65), which fixes deployed Next.js apps sending sign-in email links and Stripe return URLs to `http://localhost:3000`: the Dockerfile now takes `NEXT_PUBLIC_APP_URL` as a build argument, which Railway and Render pass from the service's variables. `pnpm clubedge deploy` already sets it on Railway.
|
package/README.md
CHANGED
|
@@ -194,19 +194,23 @@ When the app is ready, the CLI opens the preview in your browser (`--no-open` tu
|
|
|
194
194
|
In a project created with `--deploy railway` (or switched with `pnpm clubedge add deploy railway`), one command does the first deploy:
|
|
195
195
|
|
|
196
196
|
```sh
|
|
197
|
-
pnpm clubedge deploy # asks before creating anything
|
|
198
|
-
pnpm clubedge deploy --env-file .env.
|
|
197
|
+
pnpm clubedge deploy # asks before creating anything; reads .env.production
|
|
198
|
+
pnpm clubedge deploy --env-file .env.staging # with another file of production keys
|
|
199
199
|
pnpm clubedge deploy --dry-run # show the plan, create nothing
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
-
With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package, Postgres (and Redis with `cache redis`), the app's service, and a `*.up.railway.app` domain. It sets the variables it can: `DATABASE_URL` and `REDIS_URL` as references to those databases, `APP_URL` and `NEXT_PUBLIC_APP_URL`, `PORT`, and a generated `BETTER_AUTH_SECRET` that later runs keep. Values from `--env-file` come on top; the variables `.env.example` lists that nothing sets are reported, for the modules that need them (storage, email, payments). The service's settings (the migrations before each release, the `/api/health` check) go in `.railway/railway.ts`, Railway's configuration as code, which replaces `railway.json` (Railway stops reading it on 2026-12-01): `deploy` writes it when the project has none, adds Railway's TypeScript SDK (`railway`) as a dev dependency, and applies it with `railway config apply`; commit them. Then `railway up` builds the Dockerfile, and `deploy` waits for Railway's final status before printing the URL. It needs Railway CLI 5.42.1 or newer: an installed `railway`, else one npx already downloaded, else it downloads one. Running `deploy` again reuses everything, so it also adds variables and ships a new release. Railway bills usage on your plan.
|
|
202
|
+
With [Railway's CLI](https://docs.railway.com/cli) (the installed `railway`, or `npx @railway/cli`) and your Railway login, it creates or reuses a project named after the package, Postgres (and Redis with `cache redis`), the app's service, and a `*.up.railway.app` domain. It sets the variables it can: `DATABASE_URL` and `REDIS_URL` as references to those databases, `APP_URL` and `NEXT_PUBLIC_APP_URL`, `PORT`, and a generated `BETTER_AUTH_SECRET` that later runs keep. Values from the env file (`.env.production` when there is one, or `--env-file`) come on top. With a `DATABASE_URL` or `REDIS_URL` in it (Supabase, Neon, Upstash), `deploy` creates no Railway database for it and the app uses yours; URLs to `localhost` are ignored, and a later run without the file keeps the URL the service has. The variables `.env.example` lists that nothing sets are reported, for the modules that need them (storage, email, payments). The service's settings (the migrations before each release, the `/api/health` check) go in `.railway/railway.ts`, Railway's configuration as code, which replaces `railway.json` (Railway stops reading it on 2026-12-01): `deploy` writes it when the project has none, adds Railway's TypeScript SDK (`railway`) as a dev dependency, and applies it with `railway config apply`; commit them. Then `railway up` builds the Dockerfile, and `deploy` waits for Railway's final status before printing the URL. It needs Railway CLI 5.42.1 or newer: an installed `railway`, else one npx already downloaded, else it downloads one. Running `deploy` again reuses everything, so it also adds variables and ships a new release. Railway bills usage on your plan.
|
|
203
|
+
|
|
204
|
+
### Deploy to Fly.io (experimental)
|
|
205
|
+
|
|
206
|
+
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.
|
|
203
207
|
|
|
204
208
|
## Version relationship
|
|
205
209
|
|
|
206
210
|
Each CLI release is tested against one specific Starter release:
|
|
207
211
|
|
|
208
212
|
```text
|
|
209
|
-
create-clubedge-app v0.
|
|
213
|
+
create-clubedge-app v0.26.0
|
|
210
214
|
|
|
|
211
215
|
v
|
|
212
216
|
Clubedge Starter v0.17.1
|
|
@@ -217,7 +221,7 @@ The current relationship is:
|
|
|
217
221
|
|
|
218
222
|
| CLI release | Default Starter release | Meaning |
|
|
219
223
|
| --- | --- | --- |
|
|
220
|
-
| `create-clubedge-app@0.
|
|
224
|
+
| `create-clubedge-app@0.26.0` | `clubedge-starter@v0.17.1` | The CLI's required, tested default |
|
|
221
225
|
|
|
222
226
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
223
227
|
|
|
@@ -315,6 +319,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
315
319
|
| 0.22.0 | v0.16.1 | `pnpm clubedge deploy`: a guided first deploy to Railway; faster Docker builds |
|
|
316
320
|
| 0.23.0 | v0.16.1 | `deploy` moves Railway projects to `.railway/railway.ts` before Railway stops reading `railway.json` |
|
|
317
321
|
| 0.24.0 | v0.17.0 | Starter v0.17.0: Railway configuration as code (`.railway/railway.ts`) instead of `railway.json` |
|
|
322
|
+
| 0.25.0 | v0.17.1 | `deploy` to Fly.io (experimental); weekly Dependabot updates |
|
|
323
|
+
| 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` |
|
|
318
324
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
319
325
|
|
|
320
326
|
## Community
|
package/dist/cli/ui.js
CHANGED
|
@@ -127,7 +127,7 @@ ${selectionHelp(manifest)}
|
|
|
127
127
|
--no-open try: do not open the preview 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: production values
|
|
130
|
+
--env-file <path> deploy (Railway, Fly.io): production values, such as .env.production
|
|
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
|
@@ -9,9 +9,40 @@ export function serviceName(packageName) {
|
|
|
9
9
|
const name = packageName.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
10
10
|
return name || "web";
|
|
11
11
|
}
|
|
12
|
-
/** The
|
|
13
|
-
export
|
|
14
|
-
|
|
12
|
+
/** The variable each database's URL goes in. */
|
|
13
|
+
export const databaseVariables = { postgres: "DATABASE_URL", redis: "REDIS_URL" };
|
|
14
|
+
/** A URL to this machine: from a development env file, unreachable from the deployed app. */
|
|
15
|
+
export function isLocalUrl(value) {
|
|
16
|
+
const host = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?(\[[^\]]*\]|[^:/?#]*)/i.exec(value.trim())?.[1]?.toLowerCase() ?? "";
|
|
17
|
+
return ["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "host.docker.internal"].includes(host) || host.endsWith(".localhost");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The database URLs an env file provides, for databases hosted elsewhere (Supabase, Neon, Upstash):
|
|
21
|
+
* deploy uses them instead of creating the platform's own. Only the databases the project uses
|
|
22
|
+
* count, and not empty or local URLs.
|
|
23
|
+
*/
|
|
24
|
+
export function externalDatabaseUrls(modules, envFile) {
|
|
25
|
+
const urls = {};
|
|
26
|
+
for (const database of databasesFor(modules)) {
|
|
27
|
+
const key = databaseVariables[database];
|
|
28
|
+
const value = envFile[key];
|
|
29
|
+
if (value && !isLocalUrl(value))
|
|
30
|
+
urls[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return urls;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The databases a project needs from the platform: Postgres always (Drizzle), Redis with the redis
|
|
36
|
+
* cache, except the ones whose URL comes from elsewhere (`external`: variable names).
|
|
37
|
+
*/
|
|
38
|
+
export function databasesFor(modules, external = []) {
|
|
39
|
+
const skip = new Set(external);
|
|
40
|
+
const needed = modules.cache === "redis" ? ["postgres", "redis"] : ["postgres"];
|
|
41
|
+
return needed.filter((database) => !skip.has(databaseVariables[database]));
|
|
42
|
+
}
|
|
43
|
+
/** References to Railway's databases, for the service's variables. */
|
|
44
|
+
export function railwayReferences(databases) {
|
|
45
|
+
return Object.fromEntries(databases.map((database) => [databaseVariables[database], `\${{${railwayDatabases[database]}.${databaseVariables[database]}}}`]));
|
|
15
46
|
}
|
|
16
47
|
/** The variable names a project's .env.example lists, in order. */
|
|
17
48
|
export function exampleKeys(example) {
|
|
@@ -36,30 +67,29 @@ export function parseEnvFile(text) {
|
|
|
36
67
|
return values;
|
|
37
68
|
}
|
|
38
69
|
/**
|
|
39
|
-
* The service's production variables. The CLI provides what it can:
|
|
40
|
-
* databases, a generated Better Auth secret (unless the service already
|
|
41
|
-
* and the port. Values from --env-file come next and win over those
|
|
42
|
-
*
|
|
43
|
-
* reported as unset; values the service already has count as set.
|
|
70
|
+
* The service's production variables. The CLI provides what it can: the database URLs (references
|
|
71
|
+
* to Railway's databases by default), a generated Better Auth secret (unless the service already
|
|
72
|
+
* has one), the public URL, and the port. Values from --env-file come next and win over those;
|
|
73
|
+
* their database URLs replace the platform's, except local ones (a development env file).
|
|
74
|
+
* Everything else the example lists is reported as unset; values the service already has count as set.
|
|
44
75
|
*/
|
|
45
76
|
export function planVariables(input) {
|
|
46
|
-
const { modules, keys, url,
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
DATABASE_URL: reference(railwayDatabases.postgres, "DATABASE_URL"),
|
|
52
|
-
...(modules.cache === "redis" ? { REDIS_URL: reference(railwayDatabases.redis, "REDIS_URL") } : {}),
|
|
53
|
-
};
|
|
77
|
+
const { modules, keys, url, envFile, secret, attached = [] } = input;
|
|
78
|
+
const existing = { ...input.existing, ...Object.fromEntries(attached.map((key) => [key, "(attached)"])) };
|
|
79
|
+
const external = externalDatabaseUrls(modules, envFile);
|
|
80
|
+
const databases = input.databases ?? railwayReferences(databasesFor(modules, Object.keys(external)));
|
|
81
|
+
const provided = { NODE_ENV: "production", PORT: String(appPort), ...databases };
|
|
54
82
|
// The public URL under both names: TanStack Start reads APP_URL, Next.js NEXT_PUBLIC_APP_URL.
|
|
55
83
|
const set = { ...provided, APP_URL: url, NEXT_PUBLIC_APP_URL: url };
|
|
56
84
|
if (modules.auth === "better-auth" && !existing.BETTER_AUTH_SECRET)
|
|
57
85
|
set.BETTER_AUTH_SECRET = secret();
|
|
86
|
+
const databaseKeys = Object.values(databaseVariables);
|
|
58
87
|
for (const [key, value] of Object.entries(envFile)) {
|
|
59
|
-
if (
|
|
88
|
+
if (value === "" || databaseKeys.includes(key))
|
|
60
89
|
continue;
|
|
61
90
|
set[key] = value;
|
|
62
91
|
}
|
|
92
|
+
Object.assign(set, external);
|
|
63
93
|
const unset = keys.filter((key) => set[key] === undefined && !existing[key]);
|
|
64
94
|
return { set, unset };
|
|
65
95
|
}
|
|
@@ -174,3 +204,45 @@ export default defineRailway(() => {
|
|
|
174
204
|
});
|
|
175
205
|
`;
|
|
176
206
|
}
|
|
207
|
+
/* ------------------------------------ Fly.io ------------------------------------ */
|
|
208
|
+
/** The public URL Fly gives an app. */
|
|
209
|
+
export const flyAppUrl = (app) => `https://${app}.fly.dev`;
|
|
210
|
+
/** A top-level `key = "value"` in fly.toml, which `fly launch` writes (app, primary_region). */
|
|
211
|
+
export function flyTomlValue(toml, key) {
|
|
212
|
+
for (const line of toml.split(/\r?\n/)) {
|
|
213
|
+
if (/^\s*\[/.test(line))
|
|
214
|
+
return null; // top-level keys come before the first table
|
|
215
|
+
const match = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']+)["']`).exec(line);
|
|
216
|
+
if (match)
|
|
217
|
+
return match[1];
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/** The Redis URL in `fly redis create` or `fly redis status` output. */
|
|
222
|
+
export function parseRedisUrl(output) {
|
|
223
|
+
return /\b(rediss?:\/\/[^\s"'`]+)/.exec(output)?.[1] ?? null;
|
|
224
|
+
}
|
|
225
|
+
/** The secret names in `fly secrets list --json`: objects with Name (or name), or a plain list. */
|
|
226
|
+
export function flySecretNames(output) {
|
|
227
|
+
try {
|
|
228
|
+
const value = JSON.parse(output);
|
|
229
|
+
if (!Array.isArray(value))
|
|
230
|
+
return [];
|
|
231
|
+
return value
|
|
232
|
+
.map((item) => (typeof item === "string" ? item : (item?.Name ?? item?.name)))
|
|
233
|
+
.filter((name) => typeof name === "string");
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** `fly secrets import` input: NAME=VALUE lines. A value with a line break can't be imported this way. */
|
|
240
|
+
export function flySecretsInput(values) {
|
|
241
|
+
for (const [key, value] of Object.entries(values)) {
|
|
242
|
+
if (/[\r\n]/.test(value))
|
|
243
|
+
throw new Error(`${key} has a line break, which Fly's secrets import can't take. Set it with fly secrets set.`);
|
|
244
|
+
}
|
|
245
|
+
return Object.entries(values)
|
|
246
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
247
|
+
.join("\n");
|
|
248
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// `deploy` for Fly.io (experimental): creates or reuses the app (written to fly.toml), an
|
|
2
|
+
// unmanaged Postgres attached as DATABASE_URL, Upstash Redis with cache redis (each unless the env
|
|
3
|
+
// file points it elsewhere), sets the secrets, and runs `fly deploy`, which waits for the release
|
|
4
|
+
// command (migrations) and the health checks.
|
|
5
|
+
import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import { UsageError } from "./cli/args.js";
|
|
11
|
+
import { bold, dim } from "./cli/ui.js";
|
|
12
|
+
import { exampleKeys, externalDatabaseUrls, flyAppUrl, flySecretNames, flySecretsInput, flyTomlValue, parseRedisUrl, planVariables, serviceName, } from "./core/deploy.js";
|
|
13
|
+
import { spawnCommand } from "./utils/process.js";
|
|
14
|
+
const flyInstallUrl = "https://fly.io/docs/flyctl/install/";
|
|
15
|
+
const flyPricingUrl = "https://fly.io/docs/about/pricing/";
|
|
16
|
+
/** Runs Fly's CLI (a binary, so no shell); values go through stdin. */
|
|
17
|
+
async function fly(command, args, cwd, options = {}) {
|
|
18
|
+
return await new Promise((resolvePromise, reject) => {
|
|
19
|
+
const child = spawnCommand(command, args, { cwd, stdio: options.inherit ? "inherit" : ["pipe", "pipe", "pipe"] }, false);
|
|
20
|
+
let stdout = "";
|
|
21
|
+
let stderr = "";
|
|
22
|
+
child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
|
|
23
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
|
|
24
|
+
child.once("error", reject);
|
|
25
|
+
child.once("close", (code) => {
|
|
26
|
+
if (code === 0)
|
|
27
|
+
resolvePromise(`${stdout}\n${stderr}`.trim());
|
|
28
|
+
else
|
|
29
|
+
reject(new Error(`fly ${args[0]} failed${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : "."}`));
|
|
30
|
+
});
|
|
31
|
+
if (child.stdin) {
|
|
32
|
+
child.stdin.on("error", () => { });
|
|
33
|
+
child.stdin.end(options.input ?? "");
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const attempt = async (run) => run().catch(() => null);
|
|
38
|
+
/** Fly's CLI: `fly`, or its other name `flyctl`. */
|
|
39
|
+
async function flyCli(cwd) {
|
|
40
|
+
for (const name of ["fly", "flyctl"]) {
|
|
41
|
+
if (await attempt(() => fly(name, ["version"], cwd)))
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
throw new UsageError(`deploy to Fly.io needs Fly's CLI. Install it (${flyInstallUrl}), then run deploy again.`);
|
|
45
|
+
}
|
|
46
|
+
export async function deployToFly(args, project, cli, envFile) {
|
|
47
|
+
const root = project.root;
|
|
48
|
+
const tomlPath = join(root, "fly.toml");
|
|
49
|
+
const toml = await readFile(tomlPath, "utf8").catch(() => "");
|
|
50
|
+
if (!toml)
|
|
51
|
+
throw new UsageError(`This project has no fly.toml. Switch with \`${cli} add deploy fly\`.`);
|
|
52
|
+
const named = flyTomlValue(toml, "app");
|
|
53
|
+
// Fly app names are global: a new app gets a short random suffix.
|
|
54
|
+
const app = named ?? `${serviceName(project.identity.packageName)}-${randomBytes(2).toString("hex")}`;
|
|
55
|
+
const keys = exampleKeys(await readFile(join(root, ".env.example"), "utf8").catch(() => ""));
|
|
56
|
+
const external = externalDatabaseUrls(project.modules, envFile);
|
|
57
|
+
// The databases to create on Fly: the ones the env file doesn't point elsewhere.
|
|
58
|
+
const postgres = !external.DATABASE_URL;
|
|
59
|
+
const redis = project.modules.cache === "redis" && !external.REDIS_URL;
|
|
60
|
+
const elsewhere = Object.keys(external);
|
|
61
|
+
const hostedLine = [
|
|
62
|
+
...(postgres ? [`a Postgres app "${app}-db" (unmanaged: one shared-cpu-1x machine and a 1 GB volume)`] : []),
|
|
63
|
+
...(redis ? [`Upstash Redis "${app}-redis"`] : []),
|
|
64
|
+
];
|
|
65
|
+
const secret = () => randomBytes(32).toString("hex");
|
|
66
|
+
const url = flyAppUrl(app);
|
|
67
|
+
if (args.dryRun) {
|
|
68
|
+
const preview = planVariables({ modules: project.modules, keys, url, existing: {}, envFile, secret: () => "<generated>", databases: {}, attached: [...(postgres ? ["DATABASE_URL"] : []), ...(redis ? ["REDIS_URL"] : [])] });
|
|
69
|
+
log.message([
|
|
70
|
+
`• Fly app "${app}"${named ? " (from fly.toml)" : ", written to fly.toml"}, at ${url}`,
|
|
71
|
+
...(postgres ? [`• Postgres "${app}-db" (unmanaged, one small machine), attached as DATABASE_URL`] : []),
|
|
72
|
+
...(redis ? [`• Upstash Redis "${app}-redis" as REDIS_URL`] : []),
|
|
73
|
+
...(elsewhere.length ? [`• ${elsewhere.join(" and ")} from the env file, instead of Fly's databases`] : []),
|
|
74
|
+
`• Secrets: ${Object.keys(preview.set).join(", ")}`,
|
|
75
|
+
...(preview.unset.length ? [`• Not set: ${preview.unset.join(", ")}`] : []),
|
|
76
|
+
`• fly deploy, with NEXT_PUBLIC_APP_URL as a build argument: migrations, then health checks`,
|
|
77
|
+
].join("\n"));
|
|
78
|
+
outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
note([
|
|
82
|
+
"This deploys the project to Fly.io, in your Fly account (experimental):",
|
|
83
|
+
"",
|
|
84
|
+
`• The app "${app}"${hostedLine.length ? `, ${hostedLine.join(", and ")}` : ""}.`,
|
|
85
|
+
...(elsewhere.length ? [`• ${elsewhere.join(" and ")} from the env file: those databases stay where they are, and Fly runs the app.`] : []),
|
|
86
|
+
"• The secrets Fly can't provide come from --env-file; the ones still unset are listed before the build.",
|
|
87
|
+
"• fly deploy builds the Dockerfile, runs the migrations as the release command, and waits for the health checks.",
|
|
88
|
+
"",
|
|
89
|
+
`Fly bills these on your plan (${flyPricingUrl}). For Fly's Managed Postgres instead, create it yourself and attach it before deploying.`,
|
|
90
|
+
].join("\n"), "Deploy to Fly.io");
|
|
91
|
+
if (!args.yes) {
|
|
92
|
+
if (!process.stdin.isTTY)
|
|
93
|
+
throw new UsageError("deploy creates resources in your Fly account. Run it in a terminal to confirm, or pass --yes.");
|
|
94
|
+
const answer = await confirm({ message: "Deploy?", initialValue: true });
|
|
95
|
+
if (isCancel(answer) || !answer) {
|
|
96
|
+
cancel("Nothing was deployed.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const progress = spinner();
|
|
101
|
+
progress.start("Finding Fly's CLI");
|
|
102
|
+
const flyBin = await flyCli(root);
|
|
103
|
+
if (!(await attempt(() => fly(flyBin, ["auth", "whoami"], root)))) {
|
|
104
|
+
progress.stop("Signing in to Fly.io");
|
|
105
|
+
if (!process.stdin.isTTY)
|
|
106
|
+
throw new UsageError("Sign in to Fly.io first with `fly auth login`, or set FLY_API_TOKEN.");
|
|
107
|
+
await fly(flyBin, ["auth", "login"], root, { inherit: true });
|
|
108
|
+
progress.start("Signed in");
|
|
109
|
+
}
|
|
110
|
+
let launched = false;
|
|
111
|
+
if (!named) {
|
|
112
|
+
progress.message(`Creating the app "${app}"`);
|
|
113
|
+
await fly(flyBin, ["launch", "--no-deploy", "--copy-config", "--name", app, "--org", "personal", "--yes", "--no-db", "--no-redis", "--no-object-storage", "--ha=false"], root);
|
|
114
|
+
launched = true;
|
|
115
|
+
}
|
|
116
|
+
const region = flyTomlValue(await readFile(tomlPath, "utf8"), "primary_region");
|
|
117
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
118
|
+
const secrets = new Set(flySecretNames((await attempt(() => fly(flyBin, ["secrets", "list", "--app", app, "--json"], root))) ?? ""));
|
|
119
|
+
// A DATABASE_URL secret already set (Fly's attach, or an earlier env file) is kept as it is.
|
|
120
|
+
const database = `${app}-db`;
|
|
121
|
+
if (postgres && !secrets.has("DATABASE_URL") && !(await attempt(() => fly(flyBin, ["status", "--app", database], root)))) {
|
|
122
|
+
progress.message(`Creating Postgres "${database}"`);
|
|
123
|
+
await fly(flyBin, ["postgres", "create", "--name", database, "--org", "personal", ...regionArgs, "--initial-cluster-size", "1", "--vm-size", "shared-cpu-1x", "--volume-size", "1", "--autostart"], root);
|
|
124
|
+
}
|
|
125
|
+
if (postgres && !secrets.has("DATABASE_URL")) {
|
|
126
|
+
progress.message("Attaching Postgres (DATABASE_URL)");
|
|
127
|
+
await fly(flyBin, ["postgres", "attach", database, "--app", app, "--yes"], root);
|
|
128
|
+
}
|
|
129
|
+
let redisUrl = null;
|
|
130
|
+
if (redis && !secrets.has("REDIS_URL")) {
|
|
131
|
+
progress.message(`Creating Upstash Redis "${app}-redis"`);
|
|
132
|
+
redisUrl = parseRedisUrl(await fly(flyBin, ["redis", "create", "--name", `${app}-redis`, "--org", "personal", ...regionArgs, "--no-replicas", "--enable-eviction"], root));
|
|
133
|
+
}
|
|
134
|
+
progress.message("Setting the secrets");
|
|
135
|
+
const plan = planVariables({
|
|
136
|
+
modules: project.modules,
|
|
137
|
+
keys,
|
|
138
|
+
url,
|
|
139
|
+
existing: Object.fromEntries([...secrets].map((name) => [name, "(set)"])),
|
|
140
|
+
envFile,
|
|
141
|
+
secret,
|
|
142
|
+
databases: redisUrl ? { REDIS_URL: redisUrl } : {},
|
|
143
|
+
attached: [...(postgres ? ["DATABASE_URL"] : []), ...(redis && !redisUrl && secrets.has("REDIS_URL") ? ["REDIS_URL"] : [])],
|
|
144
|
+
});
|
|
145
|
+
// The image sets NODE_ENV and fly.toml the port: the rest are secrets, staged for the deploy.
|
|
146
|
+
const { NODE_ENV: _node, PORT: _port, ...values } = plan.set;
|
|
147
|
+
await fly(flyBin, ["secrets", "import", "--app", app, "--stage"], root, { input: flySecretsInput(values) });
|
|
148
|
+
progress.stop(`Fly.io is ready: ${url}`);
|
|
149
|
+
if (launched)
|
|
150
|
+
log.info(`Commit fly.toml: fly launch wrote the app's name ("${app}") and region into it.`);
|
|
151
|
+
if (plan.unset.length) {
|
|
152
|
+
log.warn(`Not set: ${plan.unset.join(", ")}. The app starts without them, but the features that use them fail until they are set: pass --env-file, or run fly secrets set.`);
|
|
153
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
154
|
+
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
155
|
+
if (isCancel(proceed) || !proceed) {
|
|
156
|
+
cancel("Nothing was deployed. Set them, then run deploy again: it reuses everything.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
log.step("Deploying with fly deploy (build logs follow; the migrations run as the release command)");
|
|
162
|
+
try {
|
|
163
|
+
// Next.js compiles the public URL into the build, so it goes in as a build argument too.
|
|
164
|
+
await fly(flyBin, ["deploy", "--app", app, "--remote-only", "--build-arg", `NEXT_PUBLIC_APP_URL=${url}`], root, { inherit: true });
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new Error(`The deploy did not finish. Read the logs with \`fly logs --app ${app}\`, fix the cause, and run deploy again: it reuses what exists.`);
|
|
168
|
+
}
|
|
169
|
+
note([
|
|
170
|
+
`${bold("URL")} ${url}`,
|
|
171
|
+
`${bold("App")} ${app}`,
|
|
172
|
+
...(plan.unset.length ? ["", `${bold("Not set")} ${plan.unset.join(", ")}`] : []),
|
|
173
|
+
].join("\n"), "Deployed to Fly.io");
|
|
174
|
+
outro([
|
|
175
|
+
`${dim("Logs")} fly logs --app ${app}`,
|
|
176
|
+
`${dim("Status")} fly status --app ${app}`,
|
|
177
|
+
`${dim("Again")} ${cli} deploy ${dim("(reuses the app; --env-file to add secrets)")}`,
|
|
178
|
+
].join("\n"));
|
|
179
|
+
}
|
package/dist/deploy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `deploy`: a guided first deploy to Railway, with Railway's own CLI. It creates (or reuses) the
|
|
2
|
-
// project,
|
|
3
|
-
// `railway up`; Railway then builds the image, runs the
|
|
2
|
+
// project, the databases the env file doesn't point elsewhere, the app's service and domain, sets
|
|
3
|
+
// the production variables, and runs `railway up`; Railway then builds the image, runs the
|
|
4
|
+
// migrations, and checks /api/health.
|
|
4
5
|
import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
6
7
|
import { randomBytes } from "node:crypto";
|
|
@@ -10,14 +11,37 @@ import { dirname, join, resolve } from "node:path";
|
|
|
10
11
|
import process from "node:process";
|
|
11
12
|
import { UsageError } from "./cli/args.js";
|
|
12
13
|
import { bold, dim } from "./cli/ui.js";
|
|
13
|
-
import { appPort, databasesFor, deploymentStatus, exampleKeys, finalStatuses, meetsMinimumCli, minimumRailwayCli, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planVariables, railwayCliVersion, railwayDatabases, railwayIacPath, railwayIacSource, serviceName, serviceNames, } from "./core/deploy.js";
|
|
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";
|
|
14
15
|
import { readProject } from "./core/project.js";
|
|
16
|
+
import { deployToFly } from "./deploy-fly.js";
|
|
15
17
|
import { cliCommand } from "./modify.js";
|
|
16
18
|
import { resolvePnpm } from "./steps/install.js";
|
|
17
19
|
import { pathExists } from "./utils/fs.js";
|
|
18
20
|
import { run, spawnCommand } from "./utils/process.js";
|
|
19
21
|
const railwayTermsUrl = "https://railway.com/legal/terms";
|
|
20
22
|
const deployDocsUrl = "https://starter.clubedge.live/docs/starter/deploy";
|
|
23
|
+
/**
|
|
24
|
+
* The production env file: --env-file, else the project's .env.production when there is one. Its
|
|
25
|
+
* values go on top of what the platform provides; its database URLs replace the platform's databases.
|
|
26
|
+
*/
|
|
27
|
+
async function readDeployEnv(args, project) {
|
|
28
|
+
const path = args.envFile ? resolve(process.cwd(), args.envFile) : join(project.root, ".env.production");
|
|
29
|
+
if (!(await pathExists(path))) {
|
|
30
|
+
if (args.envFile)
|
|
31
|
+
throw new UsageError(`The env file ${args.envFile} does not exist.`);
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
if (!args.envFile)
|
|
35
|
+
log.info("Using .env.production (pass --env-file for another file).");
|
|
36
|
+
const values = parseEnvFile(await readFile(path, "utf8"));
|
|
37
|
+
const local = databasesFor(project.modules)
|
|
38
|
+
.map((database) => databaseVariables[database])
|
|
39
|
+
.filter((key) => values[key] && isLocalUrl(values[key]));
|
|
40
|
+
if (local.length) {
|
|
41
|
+
log.warn(`${local.join(" and ")} in the env file point at this machine, which the deployed app can't reach: ignored, so deploy creates the platform's own.`);
|
|
42
|
+
}
|
|
43
|
+
return values;
|
|
44
|
+
}
|
|
21
45
|
const windows = process.platform === "win32";
|
|
22
46
|
const binaryName = windows ? "railway.exe" : "railway";
|
|
23
47
|
async function cliVersion(command, shell) {
|
|
@@ -110,12 +134,14 @@ async function railway(cli, args, options = {}) {
|
|
|
110
134
|
});
|
|
111
135
|
}
|
|
112
136
|
const attempt = async (run) => run().catch(() => null);
|
|
113
|
-
async function agree(args, service, databases) {
|
|
137
|
+
async function agree(args, service, databases, external) {
|
|
138
|
+
const hosted = databases.map((db) => railwayDatabases[db]).join(" and ");
|
|
114
139
|
note([
|
|
115
140
|
"This deploys the project to Railway, in your Railway account:",
|
|
116
141
|
"",
|
|
117
|
-
`• A project with ${
|
|
118
|
-
"
|
|
142
|
+
`• A project${hosted ? ` with ${hosted}` : ""}, and a service "${service}" with a public *.up.railway.app domain.`,
|
|
143
|
+
...(external.length ? [`• ${external.join(" and ")} from the env file: those databases stay where they are, and Railway runs the app.`] : []),
|
|
144
|
+
"• The production variables Railway can provide; your provider keys come from the env file.",
|
|
119
145
|
"• Railway builds the Dockerfile, runs the migrations before the release, and checks /api/health; the first build takes several minutes.",
|
|
120
146
|
`• The service's settings go in ${railwayIacPath}, which replaces railway.json; the railway package is added to read it.`,
|
|
121
147
|
"",
|
|
@@ -132,18 +158,22 @@ export async function deployProject(args) {
|
|
|
132
158
|
const project = await readProject(process.cwd());
|
|
133
159
|
const cli = await cliCommand(project);
|
|
134
160
|
const platform = project.modules.deploy ?? "docker";
|
|
161
|
+
if (platform === "fly")
|
|
162
|
+
return await deployToFly(args, project, cli, await readDeployEnv(args, project));
|
|
135
163
|
if (platform !== "railway") {
|
|
136
|
-
throw new UsageError(`deploy sets up Railway for now, and this project deploys with "${platform}". Switch with \`${cli} add deploy railway
|
|
164
|
+
throw new UsageError(`deploy sets up Railway and Fly.io for now, and this project deploys with "${platform}". Switch with \`${cli} add deploy railway\` (or fly), or follow ${deployDocsUrl} for ${platform}.`);
|
|
137
165
|
}
|
|
138
166
|
const service = serviceName(project.identity.packageName);
|
|
139
|
-
const databases = databasesFor(project.modules);
|
|
140
167
|
const keys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
|
|
141
|
-
const envFile =
|
|
168
|
+
const envFile = await readDeployEnv(args, project);
|
|
169
|
+
const external = Object.keys(externalDatabaseUrls(project.modules, envFile));
|
|
170
|
+
const databases = databasesFor(project.modules, external);
|
|
142
171
|
const secret = () => randomBytes(32).toString("hex");
|
|
143
172
|
if (args.dryRun) {
|
|
144
173
|
const preview = planVariables({ modules: project.modules, keys, url: "https://<service>.up.railway.app", existing: {}, envFile, secret: () => "<generated>" });
|
|
145
174
|
log.message([
|
|
146
|
-
`• Railway project "${service}" with ${databases.join(" and ")}, and the service "${service}"`,
|
|
175
|
+
`• Railway project "${service}"${databases.length ? ` with ${databases.map((db) => railwayDatabases[db]).join(" and ")}` : ""}, and the service "${service}"`,
|
|
176
|
+
...(external.length ? [`• ${external.join(" and ")} from the env file, instead of Railway's databases`] : []),
|
|
147
177
|
`• A *.up.railway.app domain for port ${appPort}`,
|
|
148
178
|
`• ${railwayIacPath} (written if missing, with Railway's SDK added), applied with railway config apply`,
|
|
149
179
|
`• Variables: ${Object.keys(preview.set).join(", ")}`,
|
|
@@ -153,7 +183,7 @@ export async function deployProject(args) {
|
|
|
153
183
|
outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
|
|
154
184
|
return;
|
|
155
185
|
}
|
|
156
|
-
if (!(await agree(args, service, databases))) {
|
|
186
|
+
if (!(await agree(args, service, databases, external))) {
|
|
157
187
|
cancel("Nothing was deployed.");
|
|
158
188
|
return;
|
|
159
189
|
}
|
|
@@ -182,7 +212,19 @@ export async function deployProject(args) {
|
|
|
182
212
|
catch {
|
|
183
213
|
// Unknown status format: create what is missing, as for a new project.
|
|
184
214
|
}
|
|
185
|
-
|
|
215
|
+
// A database URL the service already has without that database in the project points elsewhere
|
|
216
|
+
// (an earlier --env-file): keep it. A new service has nothing to list yet; an existing one must
|
|
217
|
+
// list its variables, or deploy could create Railway's database and switch the app over.
|
|
218
|
+
const variables = await attempt(() => railway(rw, ["variable", "list", "--service", service, "--json"]));
|
|
219
|
+
if (variables === null && existing.includes(service)) {
|
|
220
|
+
throw new Error(`Railway didn't list the variables of the service "${service}", so deploy changed nothing. Run deploy again; if it keeps failing, check \`railway variable list --service ${service}\`.`);
|
|
221
|
+
}
|
|
222
|
+
const current = parseVariables(variables ?? "");
|
|
223
|
+
const kept = databasesFor(project.modules, external)
|
|
224
|
+
.filter((database) => current[databaseVariables[database]] && !existing.includes(railwayDatabases[database]))
|
|
225
|
+
.map((database) => databaseVariables[database]);
|
|
226
|
+
const hosted = databasesFor(project.modules, [...external, ...kept]);
|
|
227
|
+
for (const database of hosted) {
|
|
186
228
|
if (existing.includes(railwayDatabases[database]))
|
|
187
229
|
continue;
|
|
188
230
|
progress.message(`Adding ${railwayDatabases[database]}`);
|
|
@@ -200,8 +242,7 @@ export async function deployProject(args) {
|
|
|
200
242
|
throw new Error("Railway created no *.up.railway.app domain. Create one in the service's settings, then run deploy again.");
|
|
201
243
|
const url = `https://${domain}`;
|
|
202
244
|
progress.message("Setting the production variables");
|
|
203
|
-
const
|
|
204
|
-
const plan = planVariables({ modules: project.modules, keys, url, existing: current, envFile, secret });
|
|
245
|
+
const plan = planVariables({ modules: project.modules, keys, url, existing: current, envFile, secret, databases: railwayReferences(hosted) });
|
|
205
246
|
for (const [key, value] of Object.entries(plan.set)) {
|
|
206
247
|
if (current[key] === value)
|
|
207
248
|
continue;
|
|
@@ -236,6 +277,12 @@ export async function deployProject(args) {
|
|
|
236
277
|
progress.message(`Applying ${railwayIacPath}`);
|
|
237
278
|
await railway(rw, ["config", "apply", "--yes"]);
|
|
238
279
|
progress.stop(`Railway is ready: ${url}`);
|
|
280
|
+
const unused = databasesFor(project.modules)
|
|
281
|
+
.filter((database) => !hosted.includes(database) && existing.includes(railwayDatabases[database]))
|
|
282
|
+
.map((database) => railwayDatabases[database]);
|
|
283
|
+
if (unused.length) {
|
|
284
|
+
log.info(`The app no longer uses Railway's ${unused.join(" and ")}: its URL comes from elsewhere now. Delete it in the dashboard (railway open) if you don't need its data.`);
|
|
285
|
+
}
|
|
239
286
|
if (changed.length) {
|
|
240
287
|
log.info(`Commit ${changed.join(", ")}: Railway reads the service's settings from ${railwayIacPath} now. railway.json is no longer needed (Railway stops reading it on 2026-12-01).`);
|
|
241
288
|
}
|
package/package.json
CHANGED