@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +15 -10
  3. package/dist/cli/ui.js +1 -1
  4. package/dist/core/deploy.js +46 -4
  5. package/dist/deploy-fly.js +167 -0
  6. package/dist/deploy.js +19 -8
  7. package/dist/utils/process.js +19 -9
  8. package/package.json +3 -3
  9. package/template/.github/workflows/ci.yml +8 -1
  10. package/template/.github/workflows/deploy.yml +2 -0
  11. package/template/Dockerfile +6 -0
  12. package/template/SETUP.md +1 -1
  13. package/template/apps/start/package.json +1 -1
  14. package/template/apps/web/package.json +1 -1
  15. package/template/fly.toml +4 -0
  16. package/template/package.json +1 -1
  17. package/template/packages/auth/package.json +1 -1
  18. package/template/packages/auth-better-auth/package.json +1 -1
  19. package/template/packages/auth-supabase/package.json +1 -1
  20. package/template/packages/cache/package.json +1 -1
  21. package/template/packages/cache-redis/package.json +1 -1
  22. package/template/packages/core/package.json +1 -1
  23. package/template/packages/db/package.json +1 -1
  24. package/template/packages/email/package.json +1 -1
  25. package/template/packages/email-resend/package.json +1 -1
  26. package/template/packages/email-smtp/package.json +1 -1
  27. package/template/packages/jobs/package.json +1 -1
  28. package/template/packages/jobs-pg-boss/package.json +1 -1
  29. package/template/packages/local-db/package.json +1 -1
  30. package/template/packages/observability/package.json +1 -1
  31. package/template/packages/observability-otel/package.json +1 -1
  32. package/template/packages/observability-sentry/package.json +1 -1
  33. package/template/packages/payments/package.json +1 -1
  34. package/template/packages/payments-stripe/package.json +1 -1
  35. package/template/packages/storage/package.json +1 -1
  36. package/template/packages/storage-s3/package.json +1 -1
  37. package/template/packages/storage-supabase/package.json +1 -1
  38. package/template/packages/ui/package.json +1 -1
  39. 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.0
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.0 # or a specific tag, branch, or commit
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.24.0
213
+ create-clubedge-app v0.25.0
210
214
  |
211
215
  v
212
- Clubedge Starter v0.17.0
213
- commit 91be4541de5833527b52a8871a08578f63b89735
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.24.0` | `clubedge-starter@v0.17.0` | The CLI's required, tested default |
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.0` tag is expected to resolve to commit
225
- `91be4541de5833527b52a8871a08578f63b89735`. The CLI verifies this before
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.0`. 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.
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.0`, and publish the Starter release.
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 for the service, such as .env.production
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
@@ -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, existing, envFile, secret } = input;
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 provided = {
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 = spawn(file, [...args, "--version"], { shell, stdio: ["ignore", "pipe", "ignore"] });
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 = spawn(command, [...prefix, ...args], {
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\`, or follow ${deployDocsUrl} for ${platform}.`);
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();
@@ -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 = spawn(command, args, { cwd, stdio: "inherit", shell: useShell });
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 = spawn(command, args, {
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.on("data", (chunk) => (stdout += chunk.toString()));
29
- child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
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
- child.once("exit", (code, signal) => {
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.24.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.0",
26
- "starterCommit": "91be4541de5833527b52a8871a08578f63b89735"
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
- run: docker build --tag app:ci .
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:
@@ -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 -->
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/web",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
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]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clubedge-starter-v1",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-better-auth",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Better Auth adapter for @clubedge/auth, storing users and sessions in your own PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-supabase",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Supabase Auth adapter for @clubedge/auth.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache-redis",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Redis rate limiter and cache for @clubedge/cache.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/core",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/db",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Email interface, console and in-memory providers, and message templates.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-resend",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Resend provider for @clubedge/email, through Resend's HTTP API.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-smtp",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "SMTP provider for @clubedge/email, for any SMTP server.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Background jobs interface: job definitions, a queue contract, and an in-memory queue.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs-pg-boss",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "pg-boss provider for @clubedge/jobs: a job queue stored in the project's PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/local-db",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "PostgreSQL for development without Docker: PGlite served over the PostgreSQL protocol.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability",
3
- "version": "0.17.0",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability-otel",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "OpenTelemetry provider for @clubedge/observability: traces and error logs exported over OTLP to any collector.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability-sentry",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Sentry provider for @clubedge/observability: server and browser errors, traces, and source map uploads.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/payments",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Provider-neutral payments: the PaymentProvider interface, billing logic, and subscription tables.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/payments-stripe",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Stripe provider for @clubedge/payments: Checkout, the customer portal, and verified webhooks.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-s3",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "S3-compatible adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-supabase",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Supabase Storage adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/ui",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "repository": "Clubedge/clubedge-starter",
3
- "ref": "v0.17.0",
4
- "commit": "91be4541de5833527b52a8871a08578f63b89735",
3
+ "ref": "v0.17.1",
4
+ "commit": "d9d787b6750e862b9de81a3f8f36b7aa07757f7a",
5
5
  "files": [
6
6
  ".dockerignore",
7
7
  ".env.example",