@clubedge/create-clubedge-app 0.20.0 → 0.22.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 (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -15
  3. package/dist/cli/args.js +10 -0
  4. package/dist/cli/ui.js +3 -1
  5. package/dist/core/deploy.js +131 -0
  6. package/dist/deploy.js +186 -0
  7. package/dist/index.js +5 -0
  8. package/package.json +5 -4
  9. package/template/.github/workflows/ci.yml +10 -10
  10. package/template/.github/workflows/clubedge-upgrade.yml +134 -0
  11. package/template/.github/workflows/deploy.yml +19 -19
  12. package/template/Dockerfile +5 -5
  13. package/template/apps/start/Dockerfile +4 -4
  14. package/template/apps/start/package.json +1 -1
  15. package/template/apps/web/package.json +1 -1
  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/local-db/src/server.test.ts +3 -2
  31. package/template/packages/observability/package.json +1 -1
  32. package/template/packages/observability-otel/package.json +1 -1
  33. package/template/packages/observability-sentry/package.json +1 -1
  34. package/template/packages/payments/package.json +1 -1
  35. package/template/packages/payments/src/billing.test.ts +27 -4
  36. package/template/packages/payments-stripe/package.json +1 -1
  37. package/template/packages/storage/package.json +1 -1
  38. package/template/packages/storage-s3/package.json +1 -1
  39. package/template/packages/storage-supabase/package.json +1 -1
  40. package/template/packages/ui/package.json +1 -1
  41. package/template.lock.json +3 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.22.0
6
+
7
+ - Add `deploy`: `pnpm clubedge deploy` does the first deploy of a project that uses `deploy railway`, with Railway's CLI and your Railway login (#55).
8
+ - It asks first, then creates or reuses the Railway project, Postgres (and Redis with `cache redis`), the app's service, and a `*.up.railway.app` domain.
9
+ - It sets the variables it can: the database references, `APP_URL` and `NEXT_PUBLIC_APP_URL`, `PORT`, and a generated `BETTER_AUTH_SECRET` that later runs keep. `--env-file .env.production` adds your provider keys; the ones still unset are listed.
10
+ - It follows the deployment until Railway's final status: the migrations run before the release and `/api/health` is checked. On a failure it names the logs commands. Running it again reuses everything.
11
+ - Railway only for now; `--dry-run` shows the plan.
12
+ - Default to Starter `v0.16.1` (#57): both Dockerfiles install dependencies in the build stage, so `node_modules` is no longer copied between stages, which made builds slow and timed out a first build on Railway.
13
+ - Release drafts list a Starter pin pull request once (#54).
14
+
15
+ ## 0.21.0
16
+
17
+ - Default to Starter `v0.16.0` (#52), which adds:
18
+ - Starter upgrades as pull requests: every project gets `.github/workflows/clubedge-upgrade.yml`, which checks every Monday for a newer Starter release and opens a pull request with `pnpm clubedge upgrade`'s result, the release notes, and anything left to resolve, so the project's own CI tests the upgrade before it is merged.
19
+ - GitHub Actions on their Node 24 versions in `ci.yml` and `deploy.yml`, with Linux jobs on Ubuntu 24.04.
20
+ - Steadier tests: PGlite starts once per test file, with room for slow CI runners.
21
+ - Release tooling for maintainers (#51): `pnpm release patch|minor|major` opens the release pull request with a drafted changelog, `pnpm release tag` publishes it, and the Starter pin workflow opens a pull request when the Starter publishes a release.
22
+ - CI names the generated project's failing check, in an annotation and the job summary (#50).
23
+
5
24
  ## 0.20.0
6
25
 
7
26
  - Conflicts are easier to resolve after `add`, `remove`, and `upgrade`:
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.15.0
130
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.16.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.15.0 # or a specific tag, branch, or commit
149
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.16.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.
@@ -189,32 +189,44 @@ Inside a project, `pnpm clubedge try` uploads that project instead, to share wor
189
189
 
190
190
  When the app is ready, the CLI opens the preview in your browser (`--no-open` turns that off) and asks whether to share it: claiming the VM, a free Railway sign-up, is what makes the URL work for anyone. `--claim` opens the claim page without asking. `try --status` shows the last VM's preview, time left, and claim link; it connects only while the VM is running, so it never creates a new one. To open the app from another network yourself, forward it: `ssh -i <key> -N -L 3000:127.0.0.1:3000 railway.new`, then http://localhost:3000 (the CLI prints the exact command). The CLI asks before creating anything (`--yes` agrees), keeps its own SSH key in your config directory (`~/.config/clubedge`, or `%APPDATA%\clubedge` on Windows) apart from `~/.ssh`, and connects only to Railway's known host key. Railway's [terms](https://railway.com/legal/terms) apply, and Railway can change or pause its free VM.
191
191
 
192
+ ## Deploy to Railway
193
+
194
+ In a project created with `--deploy railway` (or switched with `pnpm clubedge add deploy railway`), one command does the first deploy:
195
+
196
+ ```sh
197
+ pnpm clubedge deploy # asks before creating anything
198
+ pnpm clubedge deploy --env-file .env.production # with your production provider keys
199
+ pnpm clubedge deploy --dry-run # show the plan, create nothing
200
+ ```
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). Then `railway up` builds the Dockerfile, Railway runs the migrations before the release and checks `/api/health`, and the URL is printed. Running `deploy` again reuses everything, so it also adds variables and ships a new release. Railway bills usage on your plan.
203
+
192
204
  ## Version relationship
193
205
 
194
206
  Each CLI release is tested against one specific Starter release:
195
207
 
196
208
  ```text
197
- create-clubedge-app v0.20.0
209
+ create-clubedge-app v0.22.0
198
210
  |
199
211
  v
200
- Clubedge Starter v0.15.0
201
- commit 239f9e9fd575165d72f70a91592cafbd43bb7574
212
+ Clubedge Starter v0.16.1
213
+ commit 72ff89e53a55a4e4a6262b364ce73be3b1c7a6b9
202
214
  ```
203
215
 
204
216
  The current relationship is:
205
217
 
206
218
  | CLI release | Default Starter release | Meaning |
207
219
  | --- | --- | --- |
208
- | `create-clubedge-app@0.20.0` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
220
+ | `create-clubedge-app@0.22.0` | `clubedge-starter@v0.16.1` | The CLI's required, tested default |
209
221
 
210
222
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
211
223
 
212
- The `v0.15.0` tag is expected to resolve to commit
213
- `239f9e9fd575165d72f70a91592cafbd43bb7574`. The CLI verifies this before
224
+ The `v0.16.1` tag is expected to resolve to commit
225
+ `72ff89e53a55a4e4a6262b364ce73be3b1c7a6b9`. The CLI verifies this before
214
226
  scaffolding with the default reference and fails rather than silently using a
215
227
  moved tag.
216
228
 
217
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.15.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.
229
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.16.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.
218
230
 
219
231
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
220
232
 
@@ -269,15 +281,15 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
269
281
  1. Make and validate changes in `clubedge-starter`.
270
282
  2. Run lint, typecheck, unit tests, browser tests, and production build.
271
283
  3. Update the Starter package versions.
272
- 4. Commit `main`, create a release tag such as `v0.15.0`, and publish the Starter release.
284
+ 4. Commit `main`, create a release tag such as `v0.16.1`, and publish the Starter release.
273
285
 
274
286
  ### CLI maintainers
275
287
 
276
- 1. Set `clubedge.starterRef` and `clubedge.starterCommit` in [`package.json`](package.json) to the new tested Starter tag, and bump `version`.
277
- 2. Update the version relationship table and changelog.
278
- 3. Generate a clean project from that exact tag.
279
- 4. Run install, lint, typecheck, unit tests, browser tests, production build, and runtime smoke checks.
280
- 5. Publish a new CLI version only after the generated-project verification passes.
288
+ 1. When the Starter publishes a release, the [Starter pin](.github/workflows/starter-pin.yml) workflow opens a pull request that moves `clubedge.starterRef` and `clubedge.starterCommit` in [`package.json`](package.json), and the docs that name them, to the new tag (daily, or run it by hand; locally: `pnpm release pin vX.Y.Z`). Its CI generates clean projects from that exact tag and runs install, lint, typecheck, unit tests, browser tests, production build, and runtime smoke checks. Merge it once they pass.
289
+ 2. `pnpm release patch|minor|major` (from an up-to-date `main`; add `--dry-run` to preview) opens the release pull request: it bumps `version`, updates the version relationship here and in CONTRIBUTING, adds the release table rows for a minor or major release, and drafts the changelog entry from the pull requests merged since the last tag. Edit the draft in that pull request: it becomes the GitHub Release page.
290
+ 3. After it is merged, `pnpm release tag` tags `main`. The Release workflow checks and publishes it to npm, creates the GitHub Release page from the changelog entry, and smoke-tests the published package.
291
+
292
+ The pin workflow needs **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests**, or a `STARTER_PIN_TOKEN` secret (a fine-grained token with contents and pull requests write), which also lets its pull request start CI by itself.
281
293
 
282
294
  The required CI job always tests the pinned default. The scheduled compatibility job tests Starter `main` early, but unreleased Starter changes must not change the stable CLI default.
283
295
 
@@ -299,6 +311,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
299
311
  | 0.18.0 | v0.15.0 | `try`: a new project on a free, temporary Railway VM, with a preview URL and a claim link (experimental) |
300
312
  | 0.19.0 | v0.15.0 | `pnpm clubedge try` uploads an existing project to the VM; the preview opens in the browser |
301
313
  | 0.20.0 | v0.15.0 | The Starter's version of each conflicted file in `.clubedge/incoming/`, several modules at once, sharing a `try` preview by claiming the VM, `try --status` |
314
+ | 0.21.0 | v0.16.0 | Starter v0.16.0: upgrades delivered as pull requests, Node 24 actions; release tooling for maintainers |
315
+ | 0.22.0 | v0.16.1 | `pnpm clubedge deploy`: a guided first deploy to Railway; faster Docker builds |
302
316
  | next | | Show the Starter's version of a conflicted file next to yours |
303
317
 
304
318
  ## Community
package/dist/cli/args.js CHANGED
@@ -16,6 +16,7 @@ const OPTIONS = {
16
16
  open: { type: "boolean", default: true },
17
17
  claim: { type: "boolean", default: false },
18
18
  status: { type: "boolean", default: false },
19
+ "env-file": { type: "string" },
19
20
  help: { type: "boolean", short: "h", default: false },
20
21
  version: { type: "boolean", short: "v", default: false },
21
22
  };
@@ -89,6 +90,9 @@ export function parseCliArgs(argv) {
89
90
  if (!creates && command.name !== "upgrade" && values.ref !== undefined) {
90
91
  throw new UsageError("--ref only applies when creating or upgrading a project.");
91
92
  }
93
+ if (command.name !== "deploy" && values["env-file"] !== undefined) {
94
+ throw new UsageError("--env-file only applies to deploy.");
95
+ }
92
96
  if (command.name !== "try" && (values.claim || values.status)) {
93
97
  throw new UsageError("--claim and --status only apply to try.");
94
98
  }
@@ -120,6 +124,7 @@ export function parseCliArgs(argv) {
120
124
  open: values.open,
121
125
  claim: values.claim,
122
126
  status: values.status,
127
+ envFile: values["env-file"],
123
128
  help: values.help,
124
129
  version: values.version,
125
130
  };
@@ -156,6 +161,11 @@ function parseCommand(positionals) {
156
161
  throw new UsageError(`Usage: ${name}, run inside a project.`);
157
162
  return { name };
158
163
  }
164
+ if (name === "deploy") {
165
+ if (module !== undefined)
166
+ throw new UsageError("Usage: deploy [--env-file <path>], run inside a project.");
167
+ return { name };
168
+ }
159
169
  if (name === "try") {
160
170
  if (module !== undefined) {
161
171
  throw new UsageError("Usage: try [--framework <id>] [module flags] (a new project), or try inside a project to upload it.");
package/dist/cli/ui.js CHANGED
@@ -92,9 +92,10 @@ Usage:
92
92
  pnpm dlx @clubedge/create-clubedge-app status (inside a project)
93
93
  pnpm dlx @clubedge/create-clubedge-app doctor (inside a project)
94
94
  pnpm dlx @clubedge/create-clubedge-app try [options] (experimental)
95
+ pnpm dlx @clubedge/create-clubedge-app deploy [options] (inside a project)
95
96
 
96
97
  Inside a project with the clubedge script, use pnpm clubedge add, remove,
97
- upgrade, status, doctor, or try. Without a module or option, add and remove ask
98
+ upgrade, status, doctor, try, or deploy. Without a module or option, add and remove ask
98
99
  for them.
99
100
 
100
101
  add and remove change modules of an existing project, several at once as one
@@ -126,6 +127,7 @@ ${selectionHelp(manifest)}
126
127
  --no-open try: do not open the preview in the browser
127
128
  --claim try: open the page that claims the VM, to share the preview
128
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
129
131
  -y, --yes Accept defaults and never prompt (default directory: my-app)
130
132
  --no-install Skip dependency installation
131
133
  --no-git Skip Git repository initialization
@@ -0,0 +1,131 @@
1
+ // What `deploy` sets up on Railway for a project, as pure functions: the service name, the
2
+ // production variables, and reading the Railway CLI's output. src/deploy.ts runs the CLI.
3
+ /** The port the production image listens on (the Starter's Dockerfile sets PORT=3000). */
4
+ export const appPort = 3000;
5
+ /** Railway's names for the databases `railway add --database` creates, used in references. */
6
+ export const railwayDatabases = { postgres: "Postgres", redis: "Redis" };
7
+ /** A Railway service name from the package name: lowercase letters, digits, and dashes. */
8
+ export function serviceName(packageName) {
9
+ const name = packageName.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
10
+ return name || "web";
11
+ }
12
+ /** The databases a project needs on Railway: Postgres always (Drizzle), Redis with the redis cache. */
13
+ export function databasesFor(modules) {
14
+ return modules.cache === "redis" ? ["postgres", "redis"] : ["postgres"];
15
+ }
16
+ /** The variable names a project's .env.example lists, in order. */
17
+ export function exampleKeys(example) {
18
+ const keys = example
19
+ .split(/\r?\n/)
20
+ .map((line) => /^([A-Z][A-Z0-9_]*)=/.exec(line.trim())?.[1])
21
+ .filter((key) => Boolean(key));
22
+ return [...new Set(keys)];
23
+ }
24
+ /** KEY=value lines of an env file; quotes around a value are removed, comments skipped. */
25
+ export function parseEnvFile(text) {
26
+ const values = {};
27
+ for (const line of text.split(/\r?\n/)) {
28
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
29
+ if (!match)
30
+ continue;
31
+ const raw = match[2].trim();
32
+ // A quoted value keeps everything inside the quotes, and may still have a comment after them.
33
+ const quoted = /^(["'])(.*?)\1\s*(?:#.*)?$/.exec(raw);
34
+ values[match[1]] = quoted ? quoted[2] : raw.replace(/\s+#.*$/, "");
35
+ }
36
+ return values;
37
+ }
38
+ /**
39
+ * The service's production variables. The CLI provides what it can: references to Railway's
40
+ * databases, a generated Better Auth secret (unless the service already has one), the public URL,
41
+ * and the port. Values from --env-file come next and win over those, except the database
42
+ * references, which must point at Railway's databases. Everything else the example lists is
43
+ * reported as unset; values the service already has count as set.
44
+ */
45
+ export function planVariables(input) {
46
+ const { modules, keys, url, existing, envFile, secret } = input;
47
+ const reference = (database, variable) => `\${{${database}.${variable}}}`;
48
+ const provided = {
49
+ NODE_ENV: "production",
50
+ PORT: String(appPort),
51
+ DATABASE_URL: reference(railwayDatabases.postgres, "DATABASE_URL"),
52
+ ...(modules.cache === "redis" ? { REDIS_URL: reference(railwayDatabases.redis, "REDIS_URL") } : {}),
53
+ };
54
+ // The public URL under both names: TanStack Start reads APP_URL, Next.js NEXT_PUBLIC_APP_URL.
55
+ const set = { ...provided, APP_URL: url, NEXT_PUBLIC_APP_URL: url };
56
+ if (modules.auth === "better-auth" && !existing.BETTER_AUTH_SECRET)
57
+ set.BETTER_AUTH_SECRET = secret();
58
+ for (const [key, value] of Object.entries(envFile)) {
59
+ if (key === "DATABASE_URL" || key === "REDIS_URL" || value === "")
60
+ continue;
61
+ set[key] = value;
62
+ }
63
+ const unset = keys.filter((key) => set[key] === undefined && !existing[key]);
64
+ return { set, unset };
65
+ }
66
+ /** The public domain in `railway domain` output (JSON or text). */
67
+ export function parseRailwayDomain(output) {
68
+ const domain = /([a-z0-9-]+(?:\.[a-z0-9-]+)*\.up\.railway\.app)/i.exec(output)?.[1];
69
+ return domain ? domain.toLowerCase() : null;
70
+ }
71
+ /** The service names in `railway status --json`, wherever the CLI puts them. */
72
+ export function serviceNames(status) {
73
+ const names = new Set();
74
+ const visit = (value, inServices) => {
75
+ if (Array.isArray(value))
76
+ return value.forEach((item) => visit(item, inServices));
77
+ if (!value || typeof value !== "object")
78
+ return;
79
+ for (const [key, child] of Object.entries(value)) {
80
+ if (inServices && key === "name" && typeof child === "string")
81
+ names.add(child);
82
+ visit(child, inServices || key === "services");
83
+ }
84
+ };
85
+ visit(status, false);
86
+ return [...names];
87
+ }
88
+ /** `railway variable list --json`: a KEY -> value object, or an array of { name, value }. */
89
+ export function parseVariables(output) {
90
+ try {
91
+ const value = JSON.parse(output);
92
+ if (Array.isArray(value)) {
93
+ return Object.fromEntries(value
94
+ .filter((item) => typeof item?.name === "string")
95
+ .map((item) => [item.name, String(item.value ?? "")]));
96
+ }
97
+ if (value && typeof value === "object") {
98
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, String(child ?? "")]));
99
+ }
100
+ }
101
+ catch {
102
+ // Not JSON: no variables known.
103
+ }
104
+ return {};
105
+ }
106
+ /** Deployment statuses Railway does not move on from. */
107
+ export const finalStatuses = ["SUCCESS", "FAILED", "CRASHED", "REMOVED", "SKIPPED"];
108
+ /** The deployment `railway up` started, from the dashboard link it prints (`...?id=<uuid>`). */
109
+ export function parseDeploymentId(output) {
110
+ return /[?&]id=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(output)?.[1] ?? null;
111
+ }
112
+ /**
113
+ * A deployment's status from `railway deployment list --json` (newest first): the one with `id`,
114
+ * or else the newest created at or after `since`. Null while Railway does not list it yet.
115
+ */
116
+ export function deploymentStatus(output, id, since) {
117
+ let list;
118
+ try {
119
+ list = JSON.parse(output);
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ if (!Array.isArray(list))
125
+ return null;
126
+ const deployments = list.filter((item) => typeof item?.id === "string" && typeof item?.status === "string");
127
+ const found = id
128
+ ? deployments.find((deployment) => deployment.id === id)
129
+ : deployments.find((deployment) => deployment.createdAt && new Date(deployment.createdAt).getTime() >= since.getTime() - 5_000);
130
+ return found ? { id: found.id, status: found.status } : null;
131
+ }
package/dist/deploy.js ADDED
@@ -0,0 +1,186 @@
1
+ // `deploy`: a guided first deploy to Railway, with Railway's own CLI. It creates (or reuses) the
2
+ // project, its databases, the app's service and domain, sets the production variables, and runs
3
+ // `railway up`; Railway then builds the image, runs the migrations, and checks /api/health.
4
+ import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
5
+ import { spawn } from "node:child_process";
6
+ import { randomBytes } from "node:crypto";
7
+ import { readFile } from "node:fs/promises";
8
+ import { join, resolve } 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 { appPort, databasesFor, deploymentStatus, exampleKeys, finalStatuses, parseDeploymentId, parseEnvFile, parseRailwayDomain, parseVariables, planVariables, railwayDatabases, serviceName, serviceNames, } from "./core/deploy.js";
13
+ import { readProject } from "./core/project.js";
14
+ import { cliCommand } from "./modify.js";
15
+ import { commandSucceeds } from "./utils/process.js";
16
+ const railwayTermsUrl = "https://railway.com/legal/terms";
17
+ const deployDocsUrl = "https://starter.clubedge.live/docs/starter/deploy";
18
+ // Package manager shims (npx.cmd, railway.cmd) need a shell on Windows. Every argument passed
19
+ // here is a plain word; values go through stdin, so the shell never sees them.
20
+ const useShell = process.platform === "win32";
21
+ /** The Railway CLI: the installed `railway`, or the npm package through npx. */
22
+ async function railwayCli() {
23
+ if (await commandSucceeds("railway", ["--version"]))
24
+ return ["railway"];
25
+ return ["npx", "--yes", "@railway/cli"];
26
+ }
27
+ async function railway(cli, args, options = {}) {
28
+ const [command, ...prefix] = cli;
29
+ return await new Promise((resolvePromise, reject) => {
30
+ const child = spawn(command, [...prefix, ...args], {
31
+ shell: useShell,
32
+ stdio: options.inherit ? "inherit" : ["pipe", "pipe", "pipe"],
33
+ });
34
+ let stdout = "";
35
+ let stderr = "";
36
+ child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
37
+ child.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
38
+ child.once("error", reject);
39
+ // close, not exit: it fires once stdout and stderr are fully read, so JSON output is never cut off.
40
+ child.once("close", (code) => {
41
+ if (code === 0)
42
+ resolvePromise(stdout.trim());
43
+ else
44
+ reject(new Error(`railway ${args[0]} failed${stderr.trim() ? `: ${stderr.trim().split("\n").slice(-3).join(" ")}` : "."}`));
45
+ });
46
+ if (child.stdin) {
47
+ // A command that exits before reading its input must not crash the CLI with EPIPE.
48
+ child.stdin.on("error", () => { });
49
+ child.stdin.end(options.input ?? "");
50
+ }
51
+ });
52
+ }
53
+ const attempt = async (run) => run().catch(() => null);
54
+ async function agree(args, service, databases) {
55
+ note([
56
+ "This deploys the project to Railway, in your Railway account:",
57
+ "",
58
+ `• A project with ${databases.map((db) => railwayDatabases[db]).join(" and ")}, and a service "${service}" with a public *.up.railway.app domain.`,
59
+ "• The production variables Railway can provide; your provider keys come from --env-file.",
60
+ "• Railway builds the Dockerfile, runs the migrations before the release, and checks /api/health; the first build takes several minutes.",
61
+ "",
62
+ `Railway bills usage on your plan. Running deploy again reuses all of it. Railway's terms: ${railwayTermsUrl}`,
63
+ ].join("\n"), "Deploy to Railway");
64
+ if (args.yes)
65
+ return true;
66
+ if (!process.stdin.isTTY)
67
+ throw new UsageError("deploy creates resources in your Railway account. Run it in a terminal to confirm, or pass --yes.");
68
+ const answer = await confirm({ message: "Deploy?", initialValue: true });
69
+ return !isCancel(answer) && answer;
70
+ }
71
+ export async function deployProject(args) {
72
+ const project = await readProject(process.cwd());
73
+ const cli = await cliCommand(project);
74
+ const platform = project.modules.deploy ?? "docker";
75
+ if (platform !== "railway") {
76
+ 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}.`);
77
+ }
78
+ const service = serviceName(project.identity.packageName);
79
+ const databases = databasesFor(project.modules);
80
+ const keys = exampleKeys(await readFile(join(project.root, ".env.example"), "utf8").catch(() => ""));
81
+ const envFile = args.envFile ? parseEnvFile(await readFile(resolve(process.cwd(), args.envFile), "utf8")) : {};
82
+ const secret = () => randomBytes(32).toString("hex");
83
+ if (args.dryRun) {
84
+ const preview = planVariables({ modules: project.modules, keys, url: "https://<service>.up.railway.app", existing: {}, envFile, secret: () => "<generated>" });
85
+ log.message([
86
+ `• Railway project "${service}" with ${databases.join(" and ")}, and the service "${service}"`,
87
+ `• A *.up.railway.app domain for port ${appPort}`,
88
+ `• Variables: ${Object.keys(preview.set).join(", ")}`,
89
+ ...(preview.unset.length ? [`• Not set: ${preview.unset.join(", ")}`] : []),
90
+ `• railway up: build, migrations, health check`,
91
+ ].join("\n"));
92
+ outro("Dry run: nothing was created. Run again without --dry-run to deploy.");
93
+ return;
94
+ }
95
+ if (!(await agree(args, service, databases))) {
96
+ cancel("Nothing was deployed.");
97
+ return;
98
+ }
99
+ const progress = spinner();
100
+ progress.start("Preparing the Railway CLI");
101
+ const rw = await railwayCli();
102
+ // The project's directory, so Railway links it there.
103
+ process.chdir(project.root);
104
+ if (!(await attempt(() => railway(rw, ["whoami"])))) {
105
+ progress.stop("Signing in to Railway");
106
+ if (!process.stdin.isTTY)
107
+ throw new UsageError("Sign in to Railway first with `railway login`, or set RAILWAY_API_TOKEN.");
108
+ await railway(rw, ["login"], { inherit: true });
109
+ progress.start("Signed in");
110
+ }
111
+ progress.message("Finding or creating the Railway project");
112
+ let status = await attempt(() => railway(rw, ["status", "--json"]));
113
+ if (!status) {
114
+ await railway(rw, ["init", "--name", service, "--json"]);
115
+ status = await attempt(() => railway(rw, ["status", "--json"]));
116
+ }
117
+ let existing = [];
118
+ try {
119
+ existing = serviceNames(JSON.parse(status ?? "{}"));
120
+ }
121
+ catch {
122
+ // Unknown status format: create what is missing, as for a new project.
123
+ }
124
+ for (const database of databases) {
125
+ if (existing.includes(railwayDatabases[database]))
126
+ continue;
127
+ progress.message(`Adding ${railwayDatabases[database]}`);
128
+ await railway(rw, ["add", "--database", database]);
129
+ }
130
+ if (!existing.includes(service)) {
131
+ progress.message(`Adding the service "${service}"`);
132
+ await railway(rw, ["add", "--service", service]);
133
+ }
134
+ progress.message("Creating the public domain");
135
+ const listed = await attempt(() => railway(rw, ["domain", "list", "--service", service]));
136
+ const domain = parseRailwayDomain(listed ?? "") ??
137
+ parseRailwayDomain(await railway(rw, ["domain", "--service", service, "--port", String(appPort), "--json"]));
138
+ if (!domain)
139
+ throw new Error("Railway created no *.up.railway.app domain. Create one in the service's settings, then run deploy again.");
140
+ const url = `https://${domain}`;
141
+ progress.message("Setting the production variables");
142
+ const current = parseVariables((await attempt(() => railway(rw, ["variable", "list", "--service", service, "--json"]))) ?? "");
143
+ const plan = planVariables({ modules: project.modules, keys, url, existing: current, envFile, secret });
144
+ for (const [key, value] of Object.entries(plan.set)) {
145
+ if (current[key] === value)
146
+ continue;
147
+ await railway(rw, ["variable", "set", key, "--stdin", "--service", service, "--skip-deploys"], { input: value });
148
+ }
149
+ progress.stop(`Railway is ready: ${url}`);
150
+ // Start the deploy detached, then follow its status: `railway up` can return before the build
151
+ // ends, and only Railway's final status says whether the release went live.
152
+ const deploying = spinner();
153
+ deploying.start("Uploading the project");
154
+ const started = new Date();
155
+ const upload = await railway(rw, ["up", "--service", service, "--detach"]);
156
+ const id = parseDeploymentId(upload);
157
+ let deployment = "QUEUED";
158
+ const deadline = Date.now() + 45 * 60_000;
159
+ while (!finalStatuses.includes(deployment)) {
160
+ if (Date.now() > deadline)
161
+ break;
162
+ const elapsed = Math.round((Date.now() - started.getTime()) / 1000);
163
+ deploying.message(`Railway: ${deployment.toLowerCase()} (${Math.floor(elapsed / 60)}m ${elapsed % 60}s; the first build takes several minutes)`);
164
+ await new Promise((wait) => setTimeout(wait, 10_000));
165
+ const list = await attempt(() => railway(rw, ["deployment", "list", "--service", service, "--json"]));
166
+ deployment = deploymentStatus(list ?? "", id, started)?.status ?? deployment;
167
+ }
168
+ if (deployment !== "SUCCESS") {
169
+ deploying.stop(`The deploy ended as ${deployment}`);
170
+ throw new Error([
171
+ `Railway's deploy ended as ${deployment}. Read why with \`railway logs --build --service ${service}\` (the build) or \`railway logs --service ${service}\` (the app and its health check).`,
172
+ "A build that ran out of time (DeadlineExceeded) usually passes on a second try. Otherwise fix the cause and run deploy again: it reuses what exists.",
173
+ ].join("\n"));
174
+ }
175
+ deploying.stop("Deployed: the migrations ran and /api/health answered");
176
+ note([
177
+ `${bold("URL")} ${url}`,
178
+ `${bold("Service")} ${service}`,
179
+ ...(plan.unset.length ? ["", `${bold("Not set")} ${plan.unset.join(", ")}`, dim("Set the ones your modules use: railway variable set KEY --stdin, or the dashboard.")] : []),
180
+ ].join("\n"), "Deployed to Railway");
181
+ outro([
182
+ `${dim("Logs")} railway logs --service ${service}`,
183
+ `${dim("Dashboard")} railway open`,
184
+ `${dim("Again")} ${cli} deploy ${dim("(reuses the project; --env-file to add variables)")}`,
185
+ ].join("\n"));
186
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { readBundledTemplate, resolveTemplateSource } from "./core/source.js";
11
11
  import { cliUrl, cliVersion, siteUrl, starterPin } from "./package-info.js";
12
12
  import { runDoctor, showStatus } from "./inspect.js";
13
13
  import { changeProject } from "./modify.js";
14
+ import { deployProject } from "./deploy.js";
14
15
  import { tryOnRailway } from "./try.js";
15
16
  import { initializeGit } from "./steps/git.js";
16
17
  import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
@@ -235,6 +236,10 @@ export async function main(argv = process.argv.slice(2)) {
235
236
  intro("Check this project's setup");
236
237
  await runDoctor(args);
237
238
  }
239
+ else if (args.command.name === "deploy") {
240
+ intro("Deploy this project to Railway");
241
+ await deployProject(args);
242
+ }
238
243
  else if (args.command.name === "try") {
239
244
  intro("Try a project on a free Railway VM");
240
245
  await tryOnRailway(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.20.0",
3
+ "version": "0.22.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.15.0",
26
- "starterCommit": "239f9e9fd575165d72f70a91592cafbd43bb7574"
25
+ "starterRef": "v0.16.1",
26
+ "starterCommit": "72ff89e53a55a4e4a6262b364ce73be3b1c7a6b9"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -48,7 +48,8 @@
48
48
  "test": "vitest run",
49
49
  "bundle-template": "node scripts/bundle-template.mjs",
50
50
  "start": "pnpm build && node ./bin/create.mjs",
51
- "prepack": "pnpm build"
51
+ "prepack": "pnpm build",
52
+ "release": "node scripts/release.mjs"
52
53
  },
53
54
  "dependencies": {
54
55
  "@clack/prompts": "^1.8.1",
@@ -8,13 +8,13 @@ permissions:
8
8
  contents: read
9
9
  jobs:
10
10
  verify:
11
- runs-on: ubuntu-latest
11
+ runs-on: ubuntu-24.04
12
12
  env:
13
13
  DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres
14
14
  steps:
15
- - uses: actions/checkout@v4
16
- - uses: pnpm/action-setup@v4
17
- - uses: actions/setup-node@v4
15
+ - uses: actions/checkout@v7
16
+ - uses: pnpm/action-setup@v6
17
+ - uses: actions/setup-node@v7
18
18
  with:
19
19
  node-version: 22
20
20
  cache: pnpm
@@ -44,11 +44,11 @@ jobs:
44
44
  - run: pnpm audit --audit-level high
45
45
 
46
46
  e2e:
47
- runs-on: ubuntu-latest
47
+ runs-on: ubuntu-24.04
48
48
  steps:
49
- - uses: actions/checkout@v4
50
- - uses: pnpm/action-setup@v4
51
- - uses: actions/setup-node@v4
49
+ - uses: actions/checkout@v7
50
+ - uses: pnpm/action-setup@v6
51
+ - uses: actions/setup-node@v7
52
52
  with:
53
53
  node-version: 22
54
54
  cache: pnpm
@@ -57,7 +57,7 @@ jobs:
57
57
  - run: pnpm test:e2e
58
58
 
59
59
  docker:
60
- runs-on: ubuntu-latest
60
+ runs-on: ubuntu-24.04
61
61
  services:
62
62
  postgres:
63
63
  image: postgres:17
@@ -70,7 +70,7 @@ jobs:
70
70
  options: >-
71
71
  --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
72
72
  steps:
73
- - uses: actions/checkout@v4
73
+ - uses: actions/checkout@v7
74
74
  - name: Build the production image
75
75
  run: docker build --tag app:ci .
76
76
  - name: Run the image's migrations twice; the second run changes nothing
@@ -0,0 +1,134 @@
1
+ name: Starter upgrade
2
+
3
+ # Checks every Monday for a newer Clubedge Starter release. When there is one, it runs
4
+ # `pnpm clubedge upgrade` and opens a pull request with the release notes and anything left for
5
+ # you to resolve, so your CI tests the upgrade before you merge it. Run it by hand from the
6
+ # Actions tab, or delete this file to turn it off.
7
+ #
8
+ # It needs Settings -> Actions -> General -> "Allow GitHub Actions to create and approve pull
9
+ # requests", or a CLUBEDGE_UPGRADE_TOKEN secret (a fine-grained token with contents and pull
10
+ # requests write), which also lets the pull request start your CI by itself.
11
+ on:
12
+ schedule:
13
+ - cron: "17 7 * * 1"
14
+ workflow_dispatch:
15
+
16
+ permissions:
17
+ contents: write
18
+ pull-requests: write
19
+ actions: write
20
+
21
+ concurrency:
22
+ group: clubedge-upgrade
23
+ cancel-in-progress: false
24
+
25
+ jobs:
26
+ upgrade:
27
+ # The Starter's own repository is the template every project comes from, not a project.
28
+ if: github.repository != 'Clubedge/clubedge-starter'
29
+ runs-on: ubuntu-24.04
30
+ steps:
31
+ - uses: actions/checkout@v7
32
+ - uses: pnpm/action-setup@v6
33
+ - uses: actions/setup-node@v7
34
+ with:
35
+ node-version: 22
36
+ - name: Upgrade to the newest Starter release
37
+ id: upgrade
38
+ shell: bash
39
+ run: |
40
+ before=$(node -p "require('./package.json').clubedge?.starterRef ?? ''")
41
+ if [ -z "$before" ]; then
42
+ echo "package.json records no Starter release: this project was not created by create-clubedge-app."
43
+ exit 1
44
+ fi
45
+ # The newest CLI upgrades to the Starter release it is pinned to.
46
+ pnpm dlx @clubedge/create-clubedge-app@latest upgrade --no-install 2>&1 | tee "$RUNNER_TEMP/upgrade.log"
47
+ after=$(node -p "require('./package.json').clubedge.starterRef")
48
+ if [ "$after" = "$before" ] && [ -z "$(git status --porcelain)" ]; then
49
+ echo "Already on the newest Starter release, $before."
50
+ exit 0
51
+ fi
52
+ branch="clubedge/upgrade-$after"
53
+ if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
54
+ echo "$branch already exists: its pull request is open, or was closed on purpose."
55
+ exit 0
56
+ fi
57
+ # The lockfile follows the upgraded package.json files; a conflict there can stop it.
58
+ install="updated"
59
+ pnpm install --no-frozen-lockfile >"$RUNNER_TEMP/install.log" 2>&1 || install="failed"
60
+ {
61
+ echo "before=$before"
62
+ echo "after=$after"
63
+ echo "branch=$branch"
64
+ echo "install=$install"
65
+ if [ -d .clubedge/incoming ]; then echo "incoming=true"; fi
66
+ } >>"$GITHUB_OUTPUT"
67
+ - name: Keep the Starter's versions of conflicted files
68
+ if: steps.upgrade.outputs.incoming == 'true'
69
+ uses: actions/upload-artifact@v7
70
+ with:
71
+ name: starter-versions
72
+ path: .clubedge/incoming/
73
+ include-hidden-files: true
74
+ - name: Open the pull request
75
+ if: steps.upgrade.outputs.branch != ''
76
+ shell: bash
77
+ env:
78
+ GH_TOKEN: ${{ secrets.CLUBEDGE_UPGRADE_TOKEN || github.token }}
79
+ OWN_TOKEN: ${{ secrets.CLUBEDGE_UPGRADE_TOKEN != '' }}
80
+ BEFORE: ${{ steps.upgrade.outputs.before }}
81
+ AFTER: ${{ steps.upgrade.outputs.after }}
82
+ BRANCH: ${{ steps.upgrade.outputs.branch }}
83
+ INSTALL: ${{ steps.upgrade.outputs.install }}
84
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
85
+ run: |
86
+ git config user.name "github-actions[bot]"
87
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
88
+ git switch --create "$BRANCH"
89
+ git add --all
90
+ git commit --quiet --message "chore: upgrade to Clubedge Starter $AFTER"
91
+ git push --quiet --set-upstream origin "$BRANCH"
92
+
93
+ repository=$(node -p "require('./package.json').clubedge.starterRepository ?? 'Clubedge/clubedge-starter'")
94
+ conflicts=$(git grep -l '^<<<<<<< yours' -- . || true)
95
+ {
96
+ echo "Upgrades this project from Clubedge Starter \`$BEFORE\` to [\`$AFTER\`](https://github.com/$repository/releases/tag/$AFTER), with \`pnpm clubedge upgrade\`. Your own edits are merged in; files you never changed take the new version."
97
+ echo
98
+ if [ -n "$conflicts" ]; then
99
+ echo "### Conflicts to resolve"
100
+ echo
101
+ echo "These files have lines both you and the Starter changed, marked \`<<<<<<< yours\` ... \`>>>>>>> clubedge\`. Keep your lines, the Starter's, or a mix, then delete the markers:"
102
+ echo
103
+ printf -- '- `%s`\n' $conflicts
104
+ echo
105
+ echo "The Starter's whole new version of each is in the **starter-versions** artifact of [the run]($RUN_URL), to compare with yours."
106
+ echo
107
+ fi
108
+ if [ "$INSTALL" = "failed" ]; then
109
+ echo "### pnpm install"
110
+ echo
111
+ echo "\`pnpm install\` did not complete on the upgraded project, usually because of a conflict in a \`package.json\`. Resolve it, then run \`pnpm install\` and commit \`pnpm-lock.yaml\`."
112
+ echo
113
+ fi
114
+ echo "### Before merging"
115
+ echo
116
+ echo "- Read the [release notes](https://github.com/$repository/releases/tag/$AFTER) for anything to do by hand."
117
+ echo "- Run \`pnpm db:migrate\` for new migrations, and your tests."
118
+ echo
119
+ echo "<details><summary>Upgrade output</summary>"
120
+ echo
121
+ echo '```'
122
+ tail -n 80 "$RUNNER_TEMP/upgrade.log"
123
+ echo '```'
124
+ echo
125
+ echo "</details>"
126
+ echo
127
+ echo "Opened by the Starter upgrade workflow (\`.github/workflows/clubedge-upgrade.yml\`)."
128
+ } >"$RUNNER_TEMP/body.md"
129
+ gh pr create --base "${{ github.event.repository.default_branch }}" --head "$BRANCH" \
130
+ --title "chore: upgrade to Clubedge Starter $AFTER" --body-file "$RUNNER_TEMP/body.md"
131
+ # Pull requests opened with the default token start no workflows: start CI on the branch.
132
+ if [ "$OWN_TOKEN" != "true" ] && [ -f .github/workflows/ci.yml ]; then
133
+ gh workflow run ci.yml --ref "$BRANCH" || echo "Start CI on $BRANCH by hand."
134
+ fi
@@ -12,26 +12,26 @@ jobs:
12
12
  # Builds the production image and publishes it to this repository's GitHub Container Registry
13
13
  # as ghcr.io/<owner>/<repository>, tagged with the commit and latest. No secret is needed.
14
14
  image:
15
- runs-on: ubuntu-latest
15
+ runs-on: ubuntu-24.04
16
16
  permissions:
17
17
  contents: read
18
18
  packages: write
19
19
  steps:
20
- - uses: actions/checkout@v4
21
- - uses: docker/setup-buildx-action@v3
22
- - uses: docker/login-action@v3
20
+ - uses: actions/checkout@v7
21
+ - uses: docker/setup-buildx-action@v4
22
+ - uses: docker/login-action@v4
23
23
  with:
24
24
  registry: ghcr.io
25
25
  username: ${{ github.actor }}
26
26
  password: ${{ secrets.GITHUB_TOKEN }}
27
27
  - id: meta
28
- uses: docker/metadata-action@v5
28
+ uses: docker/metadata-action@v6
29
29
  with:
30
30
  images: ghcr.io/${{ github.repository }}
31
31
  tags: |
32
32
  type=sha
33
33
  type=raw,value=latest
34
- - uses: docker/build-push-action@v6
34
+ - uses: docker/build-push-action@v7
35
35
  with:
36
36
  context: .
37
37
  push: true
@@ -40,9 +40,9 @@ jobs:
40
40
  # clubedge:end
41
41
  # clubedge:if deploy=fly
42
42
  fly:
43
- runs-on: ubuntu-latest
43
+ runs-on: ubuntu-24.04
44
44
  steps:
45
- - uses: actions/checkout@v4
45
+ - uses: actions/checkout@v7
46
46
  - uses: superfly/flyctl-actions/setup-flyctl@master
47
47
  # Builds on Fly's builders and runs the release command (migrations) before switching.
48
48
  - run: flyctl deploy --remote-only
@@ -51,9 +51,9 @@ jobs:
51
51
  # clubedge:end
52
52
  # clubedge:if deploy=railway
53
53
  railway:
54
- runs-on: ubuntu-latest
54
+ runs-on: ubuntu-24.04
55
55
  steps:
56
- - uses: actions/checkout@v4
56
+ - uses: actions/checkout@v7
57
57
  # Uploads this checkout; Railway builds the image and runs the pre-deploy migrations.
58
58
  - run: npx --yes @railway/cli@4 up --ci --service "$RAILWAY_SERVICE"
59
59
  env:
@@ -62,7 +62,7 @@ jobs:
62
62
  # clubedge:end
63
63
  # clubedge:if deploy=render
64
64
  render:
65
- runs-on: ubuntu-latest
65
+ runs-on: ubuntu-24.04
66
66
  steps:
67
67
  # The service's deploy hook starts a deploy of its branch, with its pre-deploy migrations.
68
68
  - run: curl --fail --silent --show-error --request POST "$RENDER_DEPLOY_HOOK_URL"
@@ -71,15 +71,15 @@ jobs:
71
71
  # clubedge:end
72
72
  # clubedge:if deploy=vercel
73
73
  vercel:
74
- runs-on: ubuntu-latest
74
+ runs-on: ubuntu-24.04
75
75
  env:
76
76
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
77
77
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
78
78
  VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
79
79
  steps:
80
- - uses: actions/checkout@v4
81
- - uses: pnpm/action-setup@v4
82
- - uses: actions/setup-node@v4
80
+ - uses: actions/checkout@v7
81
+ - uses: pnpm/action-setup@v6
82
+ - uses: actions/setup-node@v7
83
83
  with:
84
84
  node-version: 22
85
85
  cache: pnpm
@@ -91,14 +91,14 @@ jobs:
91
91
  # clubedge:end
92
92
  # clubedge:if deploy=netlify
93
93
  netlify:
94
- runs-on: ubuntu-latest
94
+ runs-on: ubuntu-24.04
95
95
  env:
96
96
  NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
97
97
  NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
98
98
  steps:
99
- - uses: actions/checkout@v4
100
- - uses: pnpm/action-setup@v4
101
- - uses: actions/setup-node@v4
99
+ - uses: actions/checkout@v7
100
+ - uses: pnpm/action-setup@v6
101
+ - uses: actions/setup-node@v7
102
102
  with:
103
103
  node-version: 22
104
104
  cache: pnpm
@@ -8,13 +8,13 @@ FROM base AS pruner
8
8
  COPY . .
9
9
  RUN pnpm dlx turbo@^2.5.0 prune @clubedge/web --docker
10
10
 
11
- FROM base AS deps
12
- COPY --from=pruner /app/out/json/ .
13
- RUN pnpm install --frozen-lockfile
14
-
11
+ # Install in the stage that builds: copying node_modules from a separate install stage (many
12
+ # small files) took minutes on some builders, and timed out a Railway build. The manifests come
13
+ # first, so the install layer is still cached until a dependency changes.
15
14
  FROM base AS builder
16
15
  ENV NEXT_TELEMETRY_DISABLED=1
17
- COPY --from=deps /app/ .
16
+ COPY --from=pruner /app/out/json/ .
17
+ RUN pnpm install --frozen-lockfile
18
18
  COPY --from=pruner /app/out/full/ .
19
19
  COPY tsconfig.base.json ./
20
20
  RUN pnpm build
@@ -16,12 +16,12 @@ ARG APP_PACKAGE
16
16
  COPY . .
17
17
  RUN pnpm dlx turbo@^2.5.0 prune ${APP_PACKAGE} --docker
18
18
 
19
- FROM base AS deps
19
+ # Install in the stage that builds: copying node_modules from a separate install stage (many
20
+ # small files) took minutes on some builders, and timed out a Railway build. The manifests come
21
+ # first, so the install layer is still cached until a dependency changes.
22
+ FROM base AS builder
20
23
  COPY --from=pruner /app/out/json/ .
21
24
  RUN pnpm install --frozen-lockfile
22
-
23
- FROM base AS builder
24
- COPY --from=deps /app/ .
25
25
  COPY --from=pruner /app/out/full/ .
26
26
  COPY tsconfig.base.json ./
27
27
  RUN pnpm build
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.1",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clubedge-starter-v1",
3
- "version": "0.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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",
@@ -8,11 +8,12 @@ describe("serveLocalPostgres", () => {
8
8
  let server: LocalPostgresServer;
9
9
  let url: string;
10
10
 
11
+ // Starting PGlite takes seconds on a busy CI runner.
11
12
  beforeAll(async () => {
12
13
  db = await PGlite.create();
13
14
  server = await serveLocalPostgres(db, { port: 0 });
14
15
  url = `postgresql://postgres:postgres@127.0.0.1:${server.port}/postgres`;
15
- });
16
+ }, 30_000);
16
17
 
17
18
  afterAll(async () => {
18
19
  await server.close();
@@ -115,5 +116,5 @@ describe("LocalPostgresServer.close", () => {
115
116
  await client.end({ timeout: 0 });
116
117
  await db.close();
117
118
  }
118
- });
119
+ }, 30_000);
119
120
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability",
3
- "version": "0.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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",
@@ -2,7 +2,7 @@ import { PGlite } from "@electric-sql/pglite";
2
2
  import { drizzle } from "drizzle-orm/pglite";
3
3
  import { migrate } from "drizzle-orm/pglite/migrator";
4
4
  import { fileURLToPath } from "node:url";
5
- import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { users, type Database } from "@clubedge/db";
7
7
  import { createBilling, findSubscription, isEntitled, NoBillingAccountError } from "./billing";
8
8
  import { subscriptions } from "./schema";
@@ -10,7 +10,7 @@ import type { PaymentEvent, PaymentProvider, SubscriptionSnapshot } from "./type
10
10
 
11
11
  const folder = (path: string) => fileURLToPath(new URL(path, import.meta.url));
12
12
 
13
- /** A fresh PostgreSQL (PGlite) with the app's and the payment migrations applied. */
13
+ /** A PostgreSQL (PGlite) with the app's and the payment migrations applied. */
14
14
  async function database() {
15
15
  const client = await PGlite.create();
16
16
  const db = drizzle({ client });
@@ -20,7 +20,19 @@ async function database() {
20
20
  migrationsTable: "__drizzle_migrations_payments",
21
21
  });
22
22
  // The query builder is the same as the postgres.js driver's that the app uses.
23
- return db as unknown as Database;
23
+ return { client, db: db as unknown as Database };
24
+ }
25
+
26
+ /** Empties the app's tables; the migration records live in the drizzle schema and stay. */
27
+ async function emptyTables(client: PGlite) {
28
+ const { rows } = await client.query<{ name: string }>(
29
+ "select tablename as name from pg_tables where schemaname = 'public'",
30
+ );
31
+ if (rows.length) {
32
+ await client.exec(
33
+ `truncate ${rows.map(({ name }) => `"${name}"`).join(", ")} restart identity cascade`,
34
+ );
35
+ }
24
36
  }
25
37
 
26
38
  /** A provider whose next webhook event the test chooses. */
@@ -53,11 +65,22 @@ const snapshot = (
53
65
  const request = { body: "{}", headers: new Headers() };
54
66
 
55
67
  describe("createBilling", () => {
68
+ let client: PGlite;
56
69
  let db: Database;
57
70
  let userId: string;
58
71
 
72
+ // One database for the file: starting PGlite and migrating takes seconds on a busy CI runner,
73
+ // so each test starts from empty tables instead of a new database.
74
+ beforeAll(async () => {
75
+ ({ client, db } = await database());
76
+ }, 60_000);
77
+
78
+ afterAll(async () => {
79
+ await client.close();
80
+ });
81
+
59
82
  beforeEach(async () => {
60
- db = await database();
83
+ await emptyTables(client);
61
84
  const [user] = await db
62
85
  .insert(users)
63
86
  .values({ email: "ada@example.com" })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/payments-stripe",
3
- "version": "0.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
3
+ "version": "0.16.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.15.0",
4
- "commit": "239f9e9fd575165d72f70a91592cafbd43bb7574",
3
+ "ref": "v0.16.1",
4
+ "commit": "72ff89e53a55a4e4a6262b364ce73be3b1c7a6b9",
5
5
  "files": [
6
6
  ".dockerignore",
7
7
  ".env.example",
@@ -11,6 +11,7 @@
11
11
  ".github/ISSUE_TEMPLATE/feature_request.yml",
12
12
  ".github/pull_request_template.md",
13
13
  ".github/workflows/ci.yml",
14
+ ".github/workflows/clubedge-upgrade.yml",
14
15
  ".github/workflows/deploy.yml",
15
16
  "_gitignore",
16
17
  ".prettierignore",