@clubedge/create-clubedge-app 0.24.0 → 0.25.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 +15 -10
- package/dist/cli/ui.js +1 -1
- package/dist/core/deploy.js +46 -4
- package/dist/deploy-fly.js +167 -0
- package/dist/deploy.js +19 -8
- package/dist/utils/process.js +19 -9
- package/package.json +3 -3
- package/template/.github/workflows/ci.yml +8 -1
- package/template/.github/workflows/deploy.yml +2 -0
- package/template/Dockerfile +6 -0
- package/template/SETUP.md +1 -1
- package/template/apps/start/package.json +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/fly.toml +4 -0
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs-pg-boss/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/observability/package.json +1 -1
- package/template/packages/observability-otel/package.json +1 -1
- package/template/packages/observability-sentry/package.json +1 -1
- package/template/packages/payments/package.json +1 -1
- package/template/packages/payments-stripe/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template.lock.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.25.0
|
|
6
|
+
|
|
7
|
+
- `deploy` for Fly.io (experimental) (#71): in a project with `deploy fly`, `pnpm clubedge deploy` uses Fly's CLI and your Fly login.
|
|
8
|
+
- 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`.
|
|
9
|
+
- 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.
|
|
10
|
+
- `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.
|
|
11
|
+
- Fly's Managed Postgres (from $38/month) is a manual choice: create and attach it before deploying.
|
|
12
|
+
- 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.
|
|
13
|
+
- Still on Starter `v0.17.1`.
|
|
14
|
+
|
|
15
|
+
## 0.24.1
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- `deploy` lists the settings still unset before the build instead of after the release, and asks whether to deploy anyway (#64).
|
|
19
|
+
- No more Node DEP0190 warning on Windows: commands that need the shell get one quoted command line (#64).
|
|
20
|
+
- The showcase is re-recorded after each release (#63).
|
|
21
|
+
|
|
5
22
|
## 0.24.0
|
|
6
23
|
|
|
7
24
|
- Default to Starter `v0.17.0` (#61), which adds:
|
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
127
127
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
128
128
|
|
|
129
129
|
# Reproduce a specific Starter release or commit
|
|
130
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.17.
|
|
130
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.17.1
|
|
131
131
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
132
132
|
|
|
133
133
|
# Test local Starter changes before releasing them
|
|
@@ -146,7 +146,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
146
146
|
pnpm clubedge remove storage payments
|
|
147
147
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
148
148
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
149
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.17.
|
|
149
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.17.1 # or a specific tag, branch, or commit
|
|
150
150
|
```
|
|
151
151
|
|
|
152
152
|
The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
|
|
@@ -201,32 +201,36 @@ pnpm clubedge deploy --dry-run # show the plan, create nothin
|
|
|
201
201
|
|
|
202
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.
|
|
203
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), Upstash Redis with `cache redis`, 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
|
+
|
|
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.25.0
|
|
210
214
|
|
|
|
211
215
|
v
|
|
212
|
-
Clubedge Starter v0.17.
|
|
213
|
-
commit
|
|
216
|
+
Clubedge Starter v0.17.1
|
|
217
|
+
commit d9d787b6750e862b9de81a3f8f36b7aa07757f7a
|
|
214
218
|
```
|
|
215
219
|
|
|
216
220
|
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.25.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
|
|
|
224
|
-
The `v0.17.
|
|
225
|
-
`
|
|
228
|
+
The `v0.17.1` tag is expected to resolve to commit
|
|
229
|
+
`d9d787b6750e862b9de81a3f8f36b7aa07757f7a`. The CLI verifies this before
|
|
226
230
|
scaffolding with the default reference and fails rather than silently using a
|
|
227
231
|
moved tag.
|
|
228
232
|
|
|
229
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.17.
|
|
233
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.17.1`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
230
234
|
|
|
231
235
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
232
236
|
|
|
@@ -281,7 +285,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
281
285
|
1. Make and validate changes in `clubedge-starter`.
|
|
282
286
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
283
287
|
3. Update the Starter package versions.
|
|
284
|
-
4. Commit `main`, create a release tag such as `v0.17.
|
|
288
|
+
4. Commit `main`, create a release tag such as `v0.17.1`, and publish the Starter release.
|
|
285
289
|
|
|
286
290
|
### CLI maintainers
|
|
287
291
|
|
|
@@ -315,6 +319,7 @@ 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 |
|
|
318
323
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
319
324
|
|
|
320
325
|
## 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
|
@@ -43,14 +43,14 @@ export function parseEnvFile(text) {
|
|
|
43
43
|
* reported as unset; values the service already has count as set.
|
|
44
44
|
*/
|
|
45
45
|
export function planVariables(input) {
|
|
46
|
-
const { modules, keys, url,
|
|
46
|
+
const { modules, keys, url, envFile, secret, attached = [] } = input;
|
|
47
|
+
const existing = { ...input.existing, ...Object.fromEntries(attached.map((key) => [key, "(attached)"])) };
|
|
47
48
|
const reference = (database, variable) => `\${{${database}.${variable}}}`;
|
|
48
|
-
const
|
|
49
|
-
NODE_ENV: "production",
|
|
50
|
-
PORT: String(appPort),
|
|
49
|
+
const databases = input.databases ?? {
|
|
51
50
|
DATABASE_URL: reference(railwayDatabases.postgres, "DATABASE_URL"),
|
|
52
51
|
...(modules.cache === "redis" ? { REDIS_URL: reference(railwayDatabases.redis, "REDIS_URL") } : {}),
|
|
53
52
|
};
|
|
53
|
+
const provided = { NODE_ENV: "production", PORT: String(appPort), ...databases };
|
|
54
54
|
// The public URL under both names: TanStack Start reads APP_URL, Next.js NEXT_PUBLIC_APP_URL.
|
|
55
55
|
const set = { ...provided, APP_URL: url, NEXT_PUBLIC_APP_URL: url };
|
|
56
56
|
if (modules.auth === "better-auth" && !existing.BETTER_AUTH_SECRET)
|
|
@@ -174,3 +174,45 @@ export default defineRailway(() => {
|
|
|
174
174
|
});
|
|
175
175
|
`;
|
|
176
176
|
}
|
|
177
|
+
/* ------------------------------------ Fly.io ------------------------------------ */
|
|
178
|
+
/** The public URL Fly gives an app. */
|
|
179
|
+
export const flyAppUrl = (app) => `https://${app}.fly.dev`;
|
|
180
|
+
/** A top-level `key = "value"` in fly.toml, which `fly launch` writes (app, primary_region). */
|
|
181
|
+
export function flyTomlValue(toml, key) {
|
|
182
|
+
for (const line of toml.split(/\r?\n/)) {
|
|
183
|
+
if (/^\s*\[/.test(line))
|
|
184
|
+
return null; // top-level keys come before the first table
|
|
185
|
+
const match = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']+)["']`).exec(line);
|
|
186
|
+
if (match)
|
|
187
|
+
return match[1];
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
/** The Redis URL in `fly redis create` or `fly redis status` output. */
|
|
192
|
+
export function parseRedisUrl(output) {
|
|
193
|
+
return /\b(rediss?:\/\/[^\s"'`]+)/.exec(output)?.[1] ?? null;
|
|
194
|
+
}
|
|
195
|
+
/** The secret names in `fly secrets list --json`: objects with Name (or name), or a plain list. */
|
|
196
|
+
export function flySecretNames(output) {
|
|
197
|
+
try {
|
|
198
|
+
const value = JSON.parse(output);
|
|
199
|
+
if (!Array.isArray(value))
|
|
200
|
+
return [];
|
|
201
|
+
return value
|
|
202
|
+
.map((item) => (typeof item === "string" ? item : (item?.Name ?? item?.name)))
|
|
203
|
+
.filter((name) => typeof name === "string");
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** `fly secrets import` input: NAME=VALUE lines. A value with a line break can't be imported this way. */
|
|
210
|
+
export function flySecretsInput(values) {
|
|
211
|
+
for (const [key, value] of Object.entries(values)) {
|
|
212
|
+
if (/[\r\n]/.test(value))
|
|
213
|
+
throw new Error(`${key} has a line break, which Fly's secrets import can't take. Set it with fly secrets set.`);
|
|
214
|
+
}
|
|
215
|
+
return Object.entries(values)
|
|
216
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
217
|
+
.join("\n");
|
|
218
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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, sets the secrets,
|
|
3
|
+
// and runs `fly deploy`, which waits for the release command (migrations) and the health checks.
|
|
4
|
+
import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { UsageError } from "./cli/args.js";
|
|
10
|
+
import { bold, dim } from "./cli/ui.js";
|
|
11
|
+
import { exampleKeys, flyAppUrl, flySecretNames, flySecretsInput, flyTomlValue, parseEnvFile, parseRedisUrl, planVariables, serviceName, } from "./core/deploy.js";
|
|
12
|
+
import { spawnCommand } from "./utils/process.js";
|
|
13
|
+
const flyInstallUrl = "https://fly.io/docs/flyctl/install/";
|
|
14
|
+
const flyPricingUrl = "https://fly.io/docs/about/pricing/";
|
|
15
|
+
/** Runs Fly's CLI (a binary, so no shell); values go through stdin. */
|
|
16
|
+
async function fly(command, args, cwd, options = {}) {
|
|
17
|
+
return await new Promise((resolvePromise, reject) => {
|
|
18
|
+
const child = spawnCommand(command, args, { cwd, stdio: options.inherit ? "inherit" : ["pipe", "pipe", "pipe"] }, false);
|
|
19
|
+
let stdout = "";
|
|
20
|
+
let stderr = "";
|
|
21
|
+
child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
|
|
22
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
|
|
23
|
+
child.once("error", reject);
|
|
24
|
+
child.once("close", (code) => {
|
|
25
|
+
if (code === 0)
|
|
26
|
+
resolvePromise(`${stdout}\n${stderr}`.trim());
|
|
27
|
+
else
|
|
28
|
+
reject(new Error(`fly ${args[0]} failed${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : "."}`));
|
|
29
|
+
});
|
|
30
|
+
if (child.stdin) {
|
|
31
|
+
child.stdin.on("error", () => { });
|
|
32
|
+
child.stdin.end(options.input ?? "");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const attempt = async (run) => run().catch(() => null);
|
|
37
|
+
/** Fly's CLI: `fly`, or its other name `flyctl`. */
|
|
38
|
+
async function flyCli(cwd) {
|
|
39
|
+
for (const name of ["fly", "flyctl"]) {
|
|
40
|
+
if (await attempt(() => fly(name, ["version"], cwd)))
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
throw new UsageError(`deploy to Fly.io needs Fly's CLI. Install it (${flyInstallUrl}), then run deploy again.`);
|
|
44
|
+
}
|
|
45
|
+
export async function deployToFly(args, project, cli) {
|
|
46
|
+
const root = project.root;
|
|
47
|
+
const tomlPath = join(root, "fly.toml");
|
|
48
|
+
const toml = await readFile(tomlPath, "utf8").catch(() => "");
|
|
49
|
+
if (!toml)
|
|
50
|
+
throw new UsageError(`This project has no fly.toml. Switch with \`${cli} add deploy fly\`.`);
|
|
51
|
+
const named = flyTomlValue(toml, "app");
|
|
52
|
+
// Fly app names are global: a new app gets a short random suffix.
|
|
53
|
+
const app = named ?? `${serviceName(project.identity.packageName)}-${randomBytes(2).toString("hex")}`;
|
|
54
|
+
const redis = project.modules.cache === "redis";
|
|
55
|
+
const keys = exampleKeys(await readFile(join(root, ".env.example"), "utf8").catch(() => ""));
|
|
56
|
+
const envFile = args.envFile ? parseEnvFile(await readFile(resolve(process.cwd(), args.envFile), "utf8")) : {};
|
|
57
|
+
const secret = () => randomBytes(32).toString("hex");
|
|
58
|
+
const url = flyAppUrl(app);
|
|
59
|
+
if (args.dryRun) {
|
|
60
|
+
const preview = planVariables({ modules: project.modules, keys, url, existing: {}, envFile, secret: () => "<generated>", databases: {}, attached: ["DATABASE_URL", ...(redis ? ["REDIS_URL"] : [])] });
|
|
61
|
+
log.message([
|
|
62
|
+
`• Fly app "${app}"${named ? " (from fly.toml)" : ", written to fly.toml"}, at ${url}`,
|
|
63
|
+
`• Postgres "${app}-db" (unmanaged, one small machine), attached as DATABASE_URL${redis ? `; Upstash Redis "${app}-redis" as REDIS_URL` : ""}`,
|
|
64
|
+
`• Secrets: ${Object.keys(preview.set).join(", ")}`,
|
|
65
|
+
...(preview.unset.length ? [`• Not set: ${preview.unset.join(", ")}`] : []),
|
|
66
|
+
`• fly deploy, with NEXT_PUBLIC_APP_URL as a build argument: migrations, then health checks`,
|
|
67
|
+
].join("\n"));
|
|
68
|
+
outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
note([
|
|
72
|
+
"This deploys the project to Fly.io, in your Fly account (experimental):",
|
|
73
|
+
"",
|
|
74
|
+
`• The app "${app}", a Postgres app "${app}-db" (unmanaged: one shared-cpu-1x machine and a 1 GB volume)${redis ? `, and Upstash Redis "${app}-redis"` : ""}.`,
|
|
75
|
+
"• The secrets Fly can't provide come from --env-file; the ones still unset are listed before the build.",
|
|
76
|
+
"• fly deploy builds the Dockerfile, runs the migrations as the release command, and waits for the health checks.",
|
|
77
|
+
"",
|
|
78
|
+
`Fly bills these on your plan (${flyPricingUrl}). For Fly's Managed Postgres instead, create it yourself and attach it before deploying.`,
|
|
79
|
+
].join("\n"), "Deploy to Fly.io");
|
|
80
|
+
if (!args.yes) {
|
|
81
|
+
if (!process.stdin.isTTY)
|
|
82
|
+
throw new UsageError("deploy creates resources in your Fly account. Run it in a terminal to confirm, or pass --yes.");
|
|
83
|
+
const answer = await confirm({ message: "Deploy?", initialValue: true });
|
|
84
|
+
if (isCancel(answer) || !answer) {
|
|
85
|
+
cancel("Nothing was deployed.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const progress = spinner();
|
|
90
|
+
progress.start("Finding Fly's CLI");
|
|
91
|
+
const flyBin = await flyCli(root);
|
|
92
|
+
if (!(await attempt(() => fly(flyBin, ["auth", "whoami"], root)))) {
|
|
93
|
+
progress.stop("Signing in to Fly.io");
|
|
94
|
+
if (!process.stdin.isTTY)
|
|
95
|
+
throw new UsageError("Sign in to Fly.io first with `fly auth login`, or set FLY_API_TOKEN.");
|
|
96
|
+
await fly(flyBin, ["auth", "login"], root, { inherit: true });
|
|
97
|
+
progress.start("Signed in");
|
|
98
|
+
}
|
|
99
|
+
let launched = false;
|
|
100
|
+
if (!named) {
|
|
101
|
+
progress.message(`Creating the app "${app}"`);
|
|
102
|
+
await fly(flyBin, ["launch", "--no-deploy", "--copy-config", "--name", app, "--org", "personal", "--yes", "--no-db", "--no-redis", "--no-object-storage", "--ha=false"], root);
|
|
103
|
+
launched = true;
|
|
104
|
+
}
|
|
105
|
+
const region = flyTomlValue(await readFile(tomlPath, "utf8"), "primary_region");
|
|
106
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
107
|
+
const secrets = new Set(flySecretNames((await attempt(() => fly(flyBin, ["secrets", "list", "--app", app, "--json"], root))) ?? ""));
|
|
108
|
+
const database = `${app}-db`;
|
|
109
|
+
if (!(await attempt(() => fly(flyBin, ["status", "--app", database], root)))) {
|
|
110
|
+
progress.message(`Creating Postgres "${database}"`);
|
|
111
|
+
await fly(flyBin, ["postgres", "create", "--name", database, "--org", "personal", ...regionArgs, "--initial-cluster-size", "1", "--vm-size", "shared-cpu-1x", "--volume-size", "1", "--autostart"], root);
|
|
112
|
+
}
|
|
113
|
+
if (!secrets.has("DATABASE_URL")) {
|
|
114
|
+
progress.message("Attaching Postgres (DATABASE_URL)");
|
|
115
|
+
await fly(flyBin, ["postgres", "attach", database, "--app", app, "--yes"], root);
|
|
116
|
+
}
|
|
117
|
+
let redisUrl = null;
|
|
118
|
+
if (redis && !secrets.has("REDIS_URL")) {
|
|
119
|
+
progress.message(`Creating Upstash Redis "${app}-redis"`);
|
|
120
|
+
redisUrl = parseRedisUrl(await fly(flyBin, ["redis", "create", "--name", `${app}-redis`, "--org", "personal", ...regionArgs, "--no-replicas", "--enable-eviction"], root));
|
|
121
|
+
}
|
|
122
|
+
progress.message("Setting the secrets");
|
|
123
|
+
const plan = planVariables({
|
|
124
|
+
modules: project.modules,
|
|
125
|
+
keys,
|
|
126
|
+
url,
|
|
127
|
+
existing: Object.fromEntries([...secrets].map((name) => [name, "(set)"])),
|
|
128
|
+
envFile,
|
|
129
|
+
secret,
|
|
130
|
+
databases: redisUrl ? { REDIS_URL: redisUrl } : {},
|
|
131
|
+
attached: ["DATABASE_URL", ...(redis && !redisUrl && secrets.has("REDIS_URL") ? ["REDIS_URL"] : [])],
|
|
132
|
+
});
|
|
133
|
+
// The image sets NODE_ENV and fly.toml the port: the rest are secrets, staged for the deploy.
|
|
134
|
+
const { NODE_ENV: _node, PORT: _port, ...values } = plan.set;
|
|
135
|
+
await fly(flyBin, ["secrets", "import", "--app", app, "--stage"], root, { input: flySecretsInput(values) });
|
|
136
|
+
progress.stop(`Fly.io is ready: ${url}`);
|
|
137
|
+
if (launched)
|
|
138
|
+
log.info(`Commit fly.toml: fly launch wrote the app's name ("${app}") and region into it.`);
|
|
139
|
+
if (plan.unset.length) {
|
|
140
|
+
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.`);
|
|
141
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
142
|
+
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
143
|
+
if (isCancel(proceed) || !proceed) {
|
|
144
|
+
cancel("Nothing was deployed. Set them, then run deploy again: it reuses everything.");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
log.step("Deploying with fly deploy (build logs follow; the migrations run as the release command)");
|
|
150
|
+
try {
|
|
151
|
+
// Next.js compiles the public URL into the build, so it goes in as a build argument too.
|
|
152
|
+
await fly(flyBin, ["deploy", "--app", app, "--remote-only", "--build-arg", `NEXT_PUBLIC_APP_URL=${url}`], root, { inherit: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
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.`);
|
|
156
|
+
}
|
|
157
|
+
note([
|
|
158
|
+
`${bold("URL")} ${url}`,
|
|
159
|
+
`${bold("App")} ${app}`,
|
|
160
|
+
...(plan.unset.length ? ["", `${bold("Not set")} ${plan.unset.join(", ")}`] : []),
|
|
161
|
+
].join("\n"), "Deployed to Fly.io");
|
|
162
|
+
outro([
|
|
163
|
+
`${dim("Logs")} fly logs --app ${app}`,
|
|
164
|
+
`${dim("Status")} fly status --app ${app}`,
|
|
165
|
+
`${dim("Again")} ${cli} deploy ${dim("(reuses the app; --env-file to add secrets)")}`,
|
|
166
|
+
].join("\n"));
|
|
167
|
+
}
|
package/dist/deploy.js
CHANGED
|
@@ -12,10 +12,11 @@ import { UsageError } from "./cli/args.js";
|
|
|
12
12
|
import { bold, dim } from "./cli/ui.js";
|
|
13
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
14
|
import { readProject } from "./core/project.js";
|
|
15
|
+
import { deployToFly } from "./deploy-fly.js";
|
|
15
16
|
import { cliCommand } from "./modify.js";
|
|
16
17
|
import { resolvePnpm } from "./steps/install.js";
|
|
17
18
|
import { pathExists } from "./utils/fs.js";
|
|
18
|
-
import { run } from "./utils/process.js";
|
|
19
|
+
import { run, spawnCommand } from "./utils/process.js";
|
|
19
20
|
const railwayTermsUrl = "https://railway.com/legal/terms";
|
|
20
21
|
const deployDocsUrl = "https://starter.clubedge.live/docs/starter/deploy";
|
|
21
22
|
const windows = process.platform === "win32";
|
|
@@ -23,7 +24,7 @@ const binaryName = windows ? "railway.exe" : "railway";
|
|
|
23
24
|
async function cliVersion(command, shell) {
|
|
24
25
|
return await new Promise((resolveVersion) => {
|
|
25
26
|
const [file, ...args] = command;
|
|
26
|
-
const child =
|
|
27
|
+
const child = spawnCommand(file, [...args, "--version"], { stdio: ["ignore", "pipe", "ignore"] }, shell);
|
|
27
28
|
let out = "";
|
|
28
29
|
child.stdout?.on("data", (chunk) => (out += chunk.toString()));
|
|
29
30
|
child.once("error", () => resolveVersion(null));
|
|
@@ -89,11 +90,7 @@ async function railwayCli() {
|
|
|
89
90
|
async function railway(cli, args, options = {}) {
|
|
90
91
|
const [command, ...prefix] = cli.command;
|
|
91
92
|
return await new Promise((resolvePromise, reject) => {
|
|
92
|
-
const child =
|
|
93
|
-
shell: cli.shell,
|
|
94
|
-
env: { ...process.env, _: cli.executable },
|
|
95
|
-
stdio: options.inherit ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
96
|
-
});
|
|
93
|
+
const child = spawnCommand(command, [...prefix, ...args], { env: { ...process.env, _: cli.executable }, stdio: options.inherit ? "inherit" : ["pipe", "pipe", "pipe"] }, cli.shell);
|
|
97
94
|
let stdout = "";
|
|
98
95
|
let stderr = "";
|
|
99
96
|
child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
|
|
@@ -136,8 +133,10 @@ export async function deployProject(args) {
|
|
|
136
133
|
const project = await readProject(process.cwd());
|
|
137
134
|
const cli = await cliCommand(project);
|
|
138
135
|
const platform = project.modules.deploy ?? "docker";
|
|
136
|
+
if (platform === "fly")
|
|
137
|
+
return await deployToFly(args, project, cli);
|
|
139
138
|
if (platform !== "railway") {
|
|
140
|
-
throw new UsageError(`deploy sets up Railway for now, and this project deploys with "${platform}". Switch with \`${cli} add deploy railway
|
|
139
|
+
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}.`);
|
|
141
140
|
}
|
|
142
141
|
const service = serviceName(project.identity.packageName);
|
|
143
142
|
const databases = databasesFor(project.modules);
|
|
@@ -243,6 +242,18 @@ export async function deployProject(args) {
|
|
|
243
242
|
if (changed.length) {
|
|
244
243
|
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).`);
|
|
245
244
|
}
|
|
245
|
+
// Before the build, not after it: the app starts without these, but the features that use them
|
|
246
|
+
// fail until they are set, and a build takes minutes.
|
|
247
|
+
if (plan.unset.length) {
|
|
248
|
+
log.warn(`Not set on the service: ${plan.unset.join(", ")}. The app starts without them, but the features that use them fail until they are set: pass --env-file, or run railway variable set KEY --stdin --service ${service}.`);
|
|
249
|
+
if (process.stdin.isTTY && !args.yes) {
|
|
250
|
+
const proceed = await confirm({ message: "Deploy anyway?", initialValue: true });
|
|
251
|
+
if (isCancel(proceed) || !proceed) {
|
|
252
|
+
cancel("Nothing was deployed. Set them, then run deploy again: it reuses everything.");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
246
257
|
// Start the deploy detached, then follow its status: `railway up` can return before the build
|
|
247
258
|
// ends, and only Railway's final status says whether the release went live.
|
|
248
259
|
const deploying = spinner();
|
package/dist/utils/process.js
CHANGED
|
@@ -2,10 +2,23 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
// Package manager shims on Windows are .cmd files, which need a shell to start.
|
|
4
4
|
const useShell = process.platform === "win32";
|
|
5
|
+
/** An argument as one word for cmd.exe: plain words as they are, anything else in double quotes. */
|
|
6
|
+
export function quoteForCmd(argument) {
|
|
7
|
+
return /^[\w@+=:,./\\-]+$/.test(argument) ? argument : `"${argument.replaceAll('"', '""')}"`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Starts a command, through the shell on Windows. There it passes one quoted command line, not an
|
|
11
|
+
* argument list: Node warns (DEP0190) when arguments are only concatenated for a shell.
|
|
12
|
+
*/
|
|
13
|
+
export function spawnCommand(command, args, options, shell = useShell) {
|
|
14
|
+
if (!shell)
|
|
15
|
+
return spawn(command, args, options);
|
|
16
|
+
return spawn([command, ...args].map(quoteForCmd).join(" "), { ...options, shell: true });
|
|
17
|
+
}
|
|
5
18
|
/** Runs a command with inherited output. Rejects on a non-zero exit. */
|
|
6
19
|
export async function run(command, args, cwd) {
|
|
7
20
|
await new Promise((resolve, reject) => {
|
|
8
|
-
const child =
|
|
21
|
+
const child = spawnCommand(command, args, { cwd, stdio: "inherit" });
|
|
9
22
|
child.once("error", reject);
|
|
10
23
|
child.once("exit", (code, signal) => {
|
|
11
24
|
if (code === 0)
|
|
@@ -18,17 +31,14 @@ export async function run(command, args, cwd) {
|
|
|
18
31
|
/** Runs a command and returns trimmed stdout. Rejects with stderr on a non-zero exit. */
|
|
19
32
|
export async function runCapture(command, args, cwd) {
|
|
20
33
|
return await new Promise((resolve, reject) => {
|
|
21
|
-
const child =
|
|
22
|
-
cwd,
|
|
23
|
-
shell: useShell,
|
|
24
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
-
});
|
|
34
|
+
const child = spawnCommand(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
26
35
|
let stdout = "";
|
|
27
36
|
let stderr = "";
|
|
28
|
-
child.stdout
|
|
29
|
-
child.stderr
|
|
37
|
+
child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
|
|
38
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
|
|
30
39
|
child.once("error", reject);
|
|
31
|
-
|
|
40
|
+
// close, not exit: it fires once stdout and stderr are fully read.
|
|
41
|
+
child.once("close", (code, signal) => {
|
|
32
42
|
if (code === 0) {
|
|
33
43
|
resolve(stdout.trim());
|
|
34
44
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"clubedge": {
|
|
24
24
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
25
|
-
"starterRef": "v0.17.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.17.1",
|
|
26
|
+
"starterCommit": "d9d787b6750e862b9de81a3f8f36b7aa07757f7a"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
|
@@ -72,7 +72,14 @@ jobs:
|
|
|
72
72
|
steps:
|
|
73
73
|
- uses: actions/checkout@v7
|
|
74
74
|
- name: Build the production image
|
|
75
|
-
|
|
75
|
+
# A public URL of its own, to check that it reaches the build.
|
|
76
|
+
run: docker build --build-arg NEXT_PUBLIC_APP_URL=https://app.ci.invalid --tag app:ci .
|
|
77
|
+
# clubedge:if framework=next
|
|
78
|
+
- name: Check that the public URL is compiled into the build
|
|
79
|
+
# Next.js inlines NEXT_PUBLIC_* at build time, server code included; without the build
|
|
80
|
+
# argument, sign-in links and Stripe return URLs would point at localhost.
|
|
81
|
+
run: docker run --rm --entrypoint sh app:ci -c 'grep -rlq "https://app.ci.invalid" /app/apps/web/.next/server'
|
|
82
|
+
# clubedge:end
|
|
76
83
|
- name: Run the image's migrations twice; the second run changes nothing
|
|
77
84
|
run: |
|
|
78
85
|
set -euo pipefail
|
|
@@ -37,6 +37,8 @@ jobs:
|
|
|
37
37
|
push: true
|
|
38
38
|
tags: ${{ steps.meta.outputs.tags }}
|
|
39
39
|
labels: ${{ steps.meta.outputs.labels }}
|
|
40
|
+
# Compiled into the Next.js build: set the repository variable to your public URL.
|
|
41
|
+
build-args: NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL }}
|
|
40
42
|
# clubedge:end
|
|
41
43
|
# clubedge:if deploy=fly
|
|
42
44
|
fly:
|
package/template/Dockerfile
CHANGED
|
@@ -17,6 +17,12 @@ COPY --from=pruner /app/out/json/ .
|
|
|
17
17
|
RUN pnpm install --frozen-lockfile
|
|
18
18
|
COPY --from=pruner /app/out/full/ .
|
|
19
19
|
COPY tsconfig.base.json ./
|
|
20
|
+
# Next.js compiles NEXT_PUBLIC_* values into the build, server code included (sign-in email links,
|
|
21
|
+
# Stripe return URLs), so the public URL is a build argument. Railway and Render pass it from the
|
|
22
|
+
# service's variables; elsewhere, --build-arg NEXT_PUBLIC_APP_URL=https://your.domain. Unset, the
|
|
23
|
+
# app falls back to http://localhost:3000.
|
|
24
|
+
ARG NEXT_PUBLIC_APP_URL
|
|
25
|
+
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
|
20
26
|
RUN pnpm build
|
|
21
27
|
# The production migrator (node /app/migrate.mjs): one bundled file plus every package's
|
|
22
28
|
# migrations, so release commands can migrate before a new version starts.
|
package/template/SETUP.md
CHANGED
|
@@ -438,7 +438,7 @@ Netlify runs no worker for [background jobs](#background-jobs). Set `JOBS_WORKER
|
|
|
438
438
|
Production needs these variables:
|
|
439
439
|
|
|
440
440
|
- `DATABASE_URL`, pointing at a managed PostgreSQL database.
|
|
441
|
-
- `NEXT_PUBLIC_APP_URL`, the deployed origin. <!-- clubedge:only framework=next -->
|
|
441
|
+
- `NEXT_PUBLIC_APP_URL`, the deployed origin. Next.js compiles it into the build, server code included (sign-in links, Stripe return URLs), so it must be set when the image is built: Railway and Render pass the service's variable to the Docker build; on Fly.io set it under `[build.args]` in `fly.toml`; with the Docker workflow, set the repository variable `NEXT_PUBLIC_APP_URL`. <!-- clubedge:only framework=next -->
|
|
442
442
|
- `APP_URL`, the deployed origin. <!-- clubedge:only framework=tanstack-start -->
|
|
443
443
|
- `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=next && auth=supabase -->
|
|
444
444
|
- `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=tanstack-start && auth=supabase -->
|
package/template/fly.toml
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
# and adds its name and region here; set secrets with `fly secrets set`, then run `fly deploy`.
|
|
3
3
|
[build]
|
|
4
4
|
dockerfile = "Dockerfile"
|
|
5
|
+
# Next.js compiles NEXT_PUBLIC_APP_URL into the build (sign-in links, Stripe return URLs), so a
|
|
6
|
+
# Next.js app sets it here rather than as a secret:
|
|
7
|
+
# [build.args]
|
|
8
|
+
# NEXT_PUBLIC_APP_URL = "https://<app>.fly.dev"
|
|
5
9
|
|
|
6
10
|
# Runs in a temporary machine with the new image before it replaces the running one.
|
|
7
11
|
[deploy]
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/observability",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Observability interface: error reports and spans for @clubedge/core's telemetry hooks, and an in-memory provider for tests.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"license": "Apache-2.0",
|
package/template.lock.json
CHANGED