@clubedge/create-clubedge-app 0.19.1 → 0.21.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 +21 -0
  2. package/README.md +20 -18
  3. package/dist/cli/args.js +7 -0
  4. package/dist/cli/ui.js +2 -0
  5. package/dist/core/merge.js +10 -3
  6. package/dist/core/railway.js +22 -1
  7. package/dist/core/update.js +25 -0
  8. package/dist/modify.js +49 -27
  9. package/dist/try.js +99 -7
  10. package/package.json +5 -4
  11. package/template/.github/workflows/ci.yml +10 -10
  12. package/template/.github/workflows/clubedge-upgrade.yml +134 -0
  13. package/template/.github/workflows/deploy.yml +19 -19
  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,27 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.21.0
6
+
7
+ - Default to Starter `v0.16.0` (#52), which adds:
8
+ - 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.
9
+ - GitHub Actions on their Node 24 versions in `ci.yml` and `deploy.yml`, with Linux jobs on Ubuntu 24.04.
10
+ - Steadier tests: PGlite starts once per test file, with room for slow CI runners.
11
+ - 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.
12
+ - CI names the generated project's failing check, in an annotation and the job summary (#50).
13
+
14
+ ## 0.20.0
15
+
16
+ - Conflicts are easier to resolve after `add`, `remove`, and `upgrade`:
17
+ - The Starter's whole new version of each conflicted file is saved in `.clubedge/incoming/<file>`, and the report prints a `git diff --no-index` command to compare it with yours. Files left for you to check by hand get theirs too, when the Starter has one. Git ignores the folder; each change replaces the previous change's files.
18
+ - Without a module on the command line, the `add` and `remove` menus pick several modules at once; `add` then asks for each one's option, and the changes apply together.
19
+ - Sharing a `try` preview:
20
+ - When the app is ready, `try` asks whether to share it. Claiming the VM, a free Railway sign-up, is what makes the URL work for anyone, so it opens the claim page. `--claim` does that without asking; scripts are never asked.
21
+ - The closing message explains Railway's "Access denied" page and prints a port-forward command that opens the app at http://localhost:3000 from any network.
22
+ - `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.
23
+ - Release workflow: GitHub Actions on their Node 24 versions, Linux jobs on Ubuntu 24.04, a sturdier wait for npm in the smoke tests, and GitHub Release pages created from this changelog.
24
+ - Still on Starter `v0.15.0`.
25
+
5
26
  ## 0.19.1
6
27
 
7
28
  - `try` now says who can open the preview as Railway does: until the VM is claimed, only the IP address that created it. Another device on mobile data, a VPN, or anyone you send the link to gets Railway's "Access denied" page; claiming makes the URL shareable.
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.0
131
131
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
132
132
 
133
133
  # Test local Starter changes before releasing them
@@ -136,7 +136,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
136
136
 
137
137
  ## Change an existing project
138
138
 
139
- Inside a project created by the CLI, `add` and `remove` change modules, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them:
139
+ Inside a project created by the CLI, `add` and `remove` change modules, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them, with a list where you can pick several modules at once:
140
140
 
141
141
  ```sh
142
142
  pnpm clubedge add # choose a module and option from a list
@@ -146,13 +146,13 @@ 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.0 # 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.
153
153
 
154
154
  - Files you never changed are updated, added, or deleted.
155
- - Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve.
155
+ - Files you changed keep your edits. Where the module change touches the same lines, the file gets conflict markers (`<<<<<<< yours` ... `>>>>>>> clubedge`) for you to resolve. The Starter's whole new version of each such file is saved in `.clubedge/incoming/` to compare with yours (`git diff --no-index <file> .clubedge/incoming/<file>`); Git ignores that folder, so delete it when you are done.
156
156
  - Your own files are never touched.
157
157
  - `apps/web/.env.local` keeps your values. Values still at their generated defaults follow the new selection, and new variables are added.
158
158
  - `pnpm-lock.yaml` is replaced if you never changed your dependencies; otherwise `pnpm install` reconciles it.
@@ -187,34 +187,34 @@ The VM has no Docker, so the project uses `--infra local` (PGlite) and Better Au
187
187
 
188
188
  Inside a project, `pnpm clubedge try` uploads that project instead, to share work in progress without deploying it. It sends what Git would commit (tracked files, plus untracked ones that are not ignored) and never `.env` files other than examples, `node_modules`, or build output; on the VM, the env file is made from `.env.example` as `create` does. The project must use `infra local` or `none`, and not Supabase Auth; `pnpm clubedge add infra local` switches a Docker project. Add `--dry-run` to list the files without uploading anything.
189
189
 
190
- When the app is ready, the CLI opens the preview in your browser; `--no-open` turns that off. 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.
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
192
  ## Version relationship
193
193
 
194
194
  Each CLI release is tested against one specific Starter release:
195
195
 
196
196
  ```text
197
- create-clubedge-app v0.19.1
197
+ create-clubedge-app v0.21.0
198
198
  |
199
199
  v
200
- Clubedge Starter v0.15.0
201
- commit 239f9e9fd575165d72f70a91592cafbd43bb7574
200
+ Clubedge Starter v0.16.0
201
+ commit e233aa68ddc8c21df6cf2b01b9e26876d2e4aeca
202
202
  ```
203
203
 
204
204
  The current relationship is:
205
205
 
206
206
  | CLI release | Default Starter release | Meaning |
207
207
  | --- | --- | --- |
208
- | `create-clubedge-app@0.19.1` | `clubedge-starter@v0.15.0` | The CLI's required, tested default |
208
+ | `create-clubedge-app@0.21.0` | `clubedge-starter@v0.16.0` | The CLI's required, tested default |
209
209
 
210
210
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
211
211
 
212
- The `v0.15.0` tag is expected to resolve to commit
213
- `239f9e9fd575165d72f70a91592cafbd43bb7574`. The CLI verifies this before
212
+ The `v0.16.0` tag is expected to resolve to commit
213
+ `e233aa68ddc8c21df6cf2b01b9e26876d2e4aeca`. The CLI verifies this before
214
214
  scaffolding with the default reference and fails rather than silently using a
215
215
  moved tag.
216
216
 
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.
217
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.16.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.
218
218
 
219
219
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
220
220
 
@@ -269,15 +269,15 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
269
269
  1. Make and validate changes in `clubedge-starter`.
270
270
  2. Run lint, typecheck, unit tests, browser tests, and production build.
271
271
  3. Update the Starter package versions.
272
- 4. Commit `main`, create a release tag such as `v0.15.0`, and publish the Starter release.
272
+ 4. Commit `main`, create a release tag such as `v0.16.0`, and publish the Starter release.
273
273
 
274
274
  ### CLI maintainers
275
275
 
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.
276
+ 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.
277
+ 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.
278
+ 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.
279
+
280
+ 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
281
 
282
282
  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
283
 
@@ -298,6 +298,8 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
298
298
  | 0.17.0 | v0.15.0 | Docs-style dashboard: sidebar with a section switcher and account card, page in a scrolling card, phone drawer |
299
299
  | 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
300
  | 0.19.0 | v0.15.0 | `pnpm clubedge try` uploads an existing project to the VM; the preview opens in the browser |
301
+ | 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` |
302
+ | 0.21.0 | v0.16.0 | Starter v0.16.0: upgrades delivered as pull requests, Node 24 actions; release tooling for maintainers |
301
303
  | next | | Show the Starter's version of a conflicted file next to yours |
302
304
 
303
305
  ## Community
package/dist/cli/args.js CHANGED
@@ -14,6 +14,8 @@ const OPTIONS = {
14
14
  force: { type: "boolean", default: false },
15
15
  services: { type: "boolean", default: true },
16
16
  open: { type: "boolean", default: true },
17
+ claim: { type: "boolean", default: false },
18
+ status: { type: "boolean", default: false },
17
19
  help: { type: "boolean", short: "h", default: false },
18
20
  version: { type: "boolean", short: "v", default: false },
19
21
  };
@@ -87,6 +89,9 @@ export function parseCliArgs(argv) {
87
89
  if (!creates && command.name !== "upgrade" && values.ref !== undefined) {
88
90
  throw new UsageError("--ref only applies when creating or upgrading a project.");
89
91
  }
92
+ if (command.name !== "try" && (values.claim || values.status)) {
93
+ throw new UsageError("--claim and --status only apply to try.");
94
+ }
90
95
  if (command.name === "try" && values["template-dir"] !== undefined) {
91
96
  throw new UsageError("--template-dir does not apply to try: the VM downloads the Starter itself.");
92
97
  }
@@ -113,6 +118,8 @@ export function parseCliArgs(argv) {
113
118
  force: values.force,
114
119
  services: values.services,
115
120
  open: values.open,
121
+ claim: values.claim,
122
+ status: values.status,
116
123
  help: values.help,
117
124
  version: values.version,
118
125
  };
package/dist/cli/ui.js CHANGED
@@ -124,6 +124,8 @@ ${selectionHelp(manifest)}
124
124
  --force add/remove: change a project with uncommitted changes or without Git
125
125
  --no-services doctor: skip pnpm check:services
126
126
  --no-open try: do not open the preview in the browser
127
+ --claim try: open the page that claims the VM, to share the preview
128
+ --status try: the last VM's preview, time left, and claim link
127
129
  -y, --yes Accept defaults and never prompt (default directory: my-app)
128
130
  --no-install Skip dependency installation
129
131
  --no-git Skip Git repository initialization
@@ -53,9 +53,11 @@ export function mergeText(current, base, target) {
53
53
  function merged(path, current, base, target, binaryReason) {
54
54
  const result = mergeText(current, base, target);
55
55
  if (!result)
56
- return { path, kind: "keep", reason: binaryReason };
56
+ return { path, kind: "keep", reason: binaryReason, incoming: target };
57
57
  const content = withLineEndings(result.text, current);
58
- return { path, kind: result.conflict ? "conflict" : "merge", content };
58
+ if (result.conflict)
59
+ return { path, kind: "conflict", content, incoming: withLineEndings(normalize(target), current) };
60
+ return { path, kind: "merge", content };
59
61
  }
60
62
  /**
61
63
  * Decides what happens to every file the base or the target contains. Files only the project
@@ -89,7 +91,12 @@ export function planMerge(base, target, current) {
89
91
  }
90
92
  if (before && after) {
91
93
  if (!mine) {
92
- actions.push({ path, kind: "keep", reason: "You deleted this file, and the new selection changes it. Restore it if you need the change." });
94
+ actions.push({
95
+ path,
96
+ kind: "keep",
97
+ reason: "You deleted this file, and the new selection changes it. Restore it if you need the change.",
98
+ incoming: after,
99
+ });
93
100
  }
94
101
  else if (sameContent(mine, before)) {
95
102
  const content = isBinary(after) ? after : withLineEndings(normalize(after), mine);
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import process from "node:process";
5
5
  import { UsageError } from "../cli/args.js";
6
6
  /*
@@ -61,6 +61,27 @@ export function railwayKeyPath(env = process.env, platform = process.platform) {
61
61
  const configHome = env.XDG_CONFIG_HOME || (platform === "win32" && env.APPDATA ? env.APPDATA : join(homedir(), ".config"));
62
62
  return join(configHome, "clubedge", "railway_ed25519");
63
63
  }
64
+ /** Saved next to the CLI's key, in its config directory. */
65
+ export function lastVmPath(env = process.env, platform = process.platform) {
66
+ return join(dirname(railwayKeyPath(env, platform)), "railway-vm.json");
67
+ }
68
+ /** Railway keeps an unclaimed VM claimable for 24 hours after its build window. */
69
+ export const claimWindowMs = 24 * 60 * 60 * 1000;
70
+ /**
71
+ * Where a VM stands: running (inside its 60 minutes), claimable (past them, still claimable), or
72
+ * gone. Connecting to a VM that is gone would create a new one, so status never does.
73
+ */
74
+ export function vmPhase(vm, now = new Date()) {
75
+ const expires = new Date(vm.expiresAt).getTime();
76
+ const claimUntil = new Date(expires + claimWindowMs);
77
+ const minutesLeft = Math.max(0, Math.round((expires - now.getTime()) / 60_000));
78
+ const phase = now.getTime() < expires ? "running" : now.getTime() < claimUntil.getTime() ? "claimable" : "gone";
79
+ return { phase, minutesLeft, claimUntil };
80
+ }
81
+ /** A local port forward to the app on the VM: works from this machine whatever its IP, nothing shared. */
82
+ export function portForwardCommand(keyPath) {
83
+ return `ssh -i "${keyPath}" -N -L 3000:127.0.0.1:3000 ${railwayHost}`;
84
+ }
64
85
  /**
65
86
  * The modules for a project on the VM. It has no Docker, so the database is PGlite (--infra
66
87
  * local) and sign-in is Better Auth; email is off, since nothing there receives it. Flags the
@@ -7,6 +7,11 @@ import { updateEnvFile } from "./env.js";
7
7
  import { planMerge, sameContent } from "./merge.js";
8
8
  import { readProjectFiles, renderProject } from "./project.js";
9
9
  const LOCKFILE = "pnpm-lock.yaml";
10
+ /**
11
+ * Where the Starter's versions of files left to resolve are saved, next to the project's own,
12
+ * for comparing by hand. The folder ignores itself, so it never shows in git status or a commit.
13
+ */
14
+ export const INCOMING_DIR = ".clubedge/incoming";
10
15
  export async function planUpdate(project, base, target) {
11
16
  const render = (side) => renderProject({
12
17
  source: side.source,
@@ -64,8 +69,28 @@ async function removeEmptyFolders(root, path) {
64
69
  folder = dirname(folder);
65
70
  }
66
71
  }
72
+ /** The Starter's version of each file left for you: conflicts, and kept files it has a version of. */
73
+ export function incomingFiles(plan) {
74
+ return plan.actions.flatMap((action) => (action.kind === "conflict" || action.kind === "keep") && action.incoming ? [{ path: action.path, content: action.incoming }] : []);
75
+ }
76
+ /** Replaces the previous change's incoming files with this change's. */
77
+ async function writeIncoming(plan) {
78
+ const folder = join(plan.project.root, INCOMING_DIR);
79
+ await rm(folder, { recursive: true, force: true });
80
+ const files = incomingFiles(plan);
81
+ if (!files.length)
82
+ return;
83
+ await mkdir(dirname(folder), { recursive: true });
84
+ await writeFile(join(dirname(folder), ".gitignore"), "# Written by create-clubedge-app: the Starter's versions of files to compare. Delete when done.\n*\n");
85
+ for (const file of files) {
86
+ const path = join(folder, file.path);
87
+ await mkdir(dirname(path), { recursive: true });
88
+ await writeFile(path, file.content);
89
+ }
90
+ }
67
91
  export async function applyUpdate(plan) {
68
92
  const { root } = plan.project;
93
+ await writeIncoming(plan);
69
94
  for (const action of plan.actions) {
70
95
  const path = join(root, action.path);
71
96
  if (action.kind === "delete") {
package/dist/modify.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // `add`, `remove`, and `upgrade` change an existing project. The project's recorded Starter
2
2
  // commit and modules (base) and the new state (target: other modules, or a newer Starter) are
3
3
  // rendered in memory, and the difference is merged into the project (see core/update.ts).
4
- import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts";
4
+ import { confirm, isCancel, log, multiselect, outro, select, spinner } from "@clack/prompts";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import process from "node:process";
@@ -14,7 +14,7 @@ import { describeReleases, fetchReleases, releasesBetween } from "./core/release
14
14
  import { selectModules } from "./core/plan.js";
15
15
  import { readProject } from "./core/project.js";
16
16
  import { downloadGitHubTemplate, readDirectoryTemplate, resolveTemplateSource, } from "./core/source.js";
17
- import { applyUpdate, planUpdate } from "./core/update.js";
17
+ import { applyUpdate, INCOMING_DIR, planUpdate } from "./core/update.js";
18
18
  import { cliVersion, starterPin } from "./package-info.js";
19
19
  import { installDependencies, resolvePnpm } from "./steps/install.js";
20
20
  import { commandSucceeds, runCapture } from "./utils/process.js";
@@ -99,13 +99,17 @@ export async function completeCommand(manifest, project, command, interactive) {
99
99
  ? "Usage: add <module> <option>, for example: add cache redis. Run it in a terminal to choose from a list."
100
100
  : "Usage: remove <module>, for example: remove storage. Run it in a terminal to choose from a list.");
101
101
  }
102
- let moduleId = command.name === "add" ? command.changes[0]?.module : undefined;
103
- if (!moduleId) {
102
+ // The modules: named on the command line, or picked from a list (several at once).
103
+ let moduleIds = command.name === "add" ? command.changes.map((change) => change.module) : [];
104
+ if (!moduleIds.length) {
104
105
  const choices = Object.entries(manifest.modules).filter(([id, module]) => command.name === "add" ? true : Boolean(module.options.none) && project.modules[id] !== "none");
105
106
  if (!choices.length)
106
107
  throw new UsageError("No module can be removed: every removable module is already none.");
107
- const value = await select({
108
- message: command.name === "add" ? "Which module do you want to add or switch?" : "Which module do you want to remove?",
108
+ const value = await multiselect({
109
+ message: command.name === "add"
110
+ ? "Which modules do you want to add or switch? (space to pick, enter to confirm)"
111
+ : "Which modules do you want to remove? (space to pick, enter to confirm)",
112
+ required: true,
109
113
  options: choices.map(([id, module]) => ({
110
114
  value: id,
111
115
  label: module.name,
@@ -114,28 +118,39 @@ export async function completeCommand(manifest, project, command, interactive) {
114
118
  });
115
119
  if (isCancel(value))
116
120
  return null;
117
- moduleId = value;
121
+ moduleIds = value;
118
122
  }
119
123
  if (command.name === "remove")
120
- return { name: "remove", modules: [moduleId] };
121
- const module = manifest.modules[moduleId];
122
- if (!module) {
123
- throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
124
+ return { name: "remove", modules: moduleIds };
125
+ // Then an option for each module that was not given one. They apply together, so options that
126
+ // only work together (Supabase Auth with Supabase Storage) can be picked in one go.
127
+ const changes = [];
128
+ for (const moduleId of moduleIds) {
129
+ const given = command.changes.find((change) => change.module === moduleId)?.option;
130
+ if (given) {
131
+ changes.push({ module: moduleId, option: given });
132
+ continue;
133
+ }
134
+ const module = manifest.modules[moduleId];
135
+ if (!module) {
136
+ throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
137
+ }
138
+ const options = Object.keys(module.options).filter((id) => id !== project.modules[moduleId] && (moduleIds.length > 1 || available(manifest, project, moduleId, id)));
139
+ if (!options.length)
140
+ throw new UsageError(`${module.name} has no other option this project can use.`);
141
+ const option = await select({
142
+ message: `${module.name}: which option?`,
143
+ options: options.map((id) => ({
144
+ value: id,
145
+ label: module.options[id].name,
146
+ ...(id === module.default ? { hint: "default" } : {}),
147
+ })),
148
+ });
149
+ if (isCancel(option))
150
+ return null;
151
+ changes.push({ module: moduleId, option });
124
152
  }
125
- const options = Object.keys(module.options).filter((id) => id !== project.modules[moduleId] && available(manifest, project, moduleId, id));
126
- if (!options.length)
127
- throw new UsageError(`${module.name} has no other option this project can use.`);
128
- const option = await select({
129
- message: `${module.name}: which option?`,
130
- options: options.map((id) => ({
131
- value: id,
132
- label: module.options[id].name,
133
- ...(id === module.default ? { hint: "default" } : {}),
134
- })),
135
- });
136
- if (isCancel(option))
137
- return null;
138
- return { name: "add", changes: [{ module: moduleId, option }] };
153
+ return { name: "add", changes };
139
154
  }
140
155
  /**
141
156
  * The selection after add or remove, validated against the Starter's manifest. Every change
@@ -213,17 +228,24 @@ function describe(plan) {
213
228
  function report(plan) {
214
229
  const conflicts = plan.actions.filter((action) => action.kind === "conflict");
215
230
  const kept = plan.actions.filter((action) => action.kind === "keep");
231
+ const incoming = (path) => `${INCOMING_DIR}/${path}`;
216
232
  if (conflicts.length) {
217
233
  const blocks = conflicts.map((action) => countConflicts(action.content));
218
234
  const total = blocks.reduce((sum, count) => sum + count, 0);
235
+ const first = conflicts[0].path;
219
236
  log.warn(`Resolve ${total} ${total === 1 ? "conflict" : "conflicts"} in ${conflicts.length} ${conflicts.length === 1 ? "file" : "files"}. ` +
220
237
  "Each is marked <<<<<<< yours ... ======= ... >>>>>>> clubedge: keep your lines, the Starter's, or a mix, then delete the markers.\n" +
221
238
  conflicts
222
239
  .map((action, i) => ` ${action.path} ${dim(`(${blocks[i]} ${blocks[i] === 1 ? "block" : "blocks"})`)}`)
223
- .join("\n"));
240
+ .join("\n") +
241
+ `\n\nThe Starter's whole new version of each is in ${INCOMING_DIR}/, to compare with yours:\n` +
242
+ ` git diff --no-index ${first} ${incoming(first)}\n` +
243
+ dim(`Git ignores ${INCOMING_DIR.split("/")[0]}/; delete it when you are done.`));
224
244
  }
225
245
  if (kept.length) {
226
- log.info(`Check these files by hand:\n${kept.map((action) => ` ${action.path}: ${action.reason}`).join("\n")}`);
246
+ log.info(`Check these files by hand:\n${kept
247
+ .map((action) => ` ${action.path}: ${action.reason}${action.incoming ? dim(` The Starter's version: ${incoming(action.path)}`) : ""}`)
248
+ .join("\n")}`);
227
249
  }
228
250
  if (plan.env && !plan.env.update) {
229
251
  log.info(`${plan.env.path} does not exist yet; create it from the example file.`);
package/dist/try.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cancel, confirm, isCancel, log, note, outro, spinner } from "@clack/prompts";
1
+ import { cancel, confirm, isCancel, log, note, outro, select, spinner } from "@clack/prompts";
2
2
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { userInfo } from "node:os";
4
4
  import { dirname } from "node:path";
@@ -6,7 +6,7 @@ import process from "node:process";
6
6
  import ssh2 from "ssh2";
7
7
  import { UsageError } from "./cli/args.js";
8
8
  import { bold, dim } from "./cli/ui.js";
9
- import { hostKeyFingerprint, parseRailwayStatus, projectTryProblem, proxySource, railwayFreeVmUrl, railwayHost, railwayKeyPath, railwayTermsUrl, remoteArchivePath, remoteDevLog, remoteLog, remoteName, remoteProxyPath, remoteScriptPath, shellQuote, trustedHostKeys, tryCreateArgs, tryModules, tryScript, } from "./core/railway.js";
9
+ import { hostKeyFingerprint, lastVmPath, portForwardCommand, parseRailwayStatus, projectTryProblem, proxySource, railwayFreeVmUrl, railwayHost, railwayKeyPath, railwayTermsUrl, remoteArchivePath, remoteDevLog, remoteLog, remoteName, remoteProxyPath, remoteScriptPath, shellQuote, trustedHostKeys, tryCreateArgs, tryModules, tryScript, vmPhase, } from "./core/railway.js";
10
10
  import { ProjectError, readProject } from "./core/project.js";
11
11
  import { uploadableFiles } from "./core/upload.js";
12
12
  import { cliVersion } from "./package-info.js";
@@ -177,7 +177,94 @@ async function agree(args, keyPath, what) {
177
177
  const answer = await confirm({ message: "Create the VM?", initialValue: true });
178
178
  return !isCancel(answer) && answer;
179
179
  }
180
+ async function saveLastVm(vm) {
181
+ const path = lastVmPath();
182
+ await mkdir(dirname(path), { recursive: true });
183
+ await writeFile(path, `${JSON.stringify(vm, null, 2)}\n`);
184
+ }
185
+ async function readLastVm() {
186
+ try {
187
+ return JSON.parse(await readFile(lastVmPath(), "utf8"));
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ /**
194
+ * Claiming moves the VM into a Railway account, which is what makes the preview shareable: offer
195
+ * it at a terminal, or open it straight away with --claim. Nothing happens in scripts without it.
196
+ */
197
+ async function offerClaim(args, atTerminal, claimUrl) {
198
+ let claim = args.claim;
199
+ if (!claim && atTerminal && !args.yes) {
200
+ const answer = await select({
201
+ message: "Share the preview with others?",
202
+ initialValue: "later",
203
+ options: [
204
+ { value: "later", label: "Not now", hint: "only you can open it, from this connection" },
205
+ { value: "claim", label: "Claim the VM", hint: "free Railway sign-up; then the URL works for anyone" },
206
+ ],
207
+ });
208
+ claim = !isCancel(answer) && answer === "claim";
209
+ }
210
+ if (!claim)
211
+ return;
212
+ if (atTerminal && (await openUrl(claimUrl)))
213
+ log.info("Opened the claim page. Sign up, pick a workspace, and the VM, its files, and its URL move to your account.");
214
+ else
215
+ log.info(`Claim the VM here: ${claimUrl}`);
216
+ }
217
+ /**
218
+ * The last VM, from what the CLI saved: its preview, time left, and claim link. It connects only
219
+ * while the VM is inside its 60 minutes, since connecting to a VM that is gone creates a new one.
220
+ */
221
+ async function showStatus(keyPath) {
222
+ const last = await readLastVm();
223
+ if (!last) {
224
+ outro("No VM yet. Run try to create one.");
225
+ return;
226
+ }
227
+ const { phase, minutesLeft: left, claimUntil } = vmPhase(last);
228
+ if (phase === "gone") {
229
+ outro(`The last VM is gone: its claim window ended ${claimUntil.toLocaleString()}. Run try to create a new one.`);
230
+ return;
231
+ }
232
+ let app = dim("not checked");
233
+ if (phase === "running") {
234
+ const progress = spinner();
235
+ progress.start("Checking the app on the VM");
236
+ try {
237
+ const connection = await connect(await readFile(keyPath, "utf8"));
238
+ try {
239
+ const { stdout } = await exec(connection, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ || true");
240
+ app = /^[1-4]\d\d$/.test(stdout.trim()) ? "running" : "not answering";
241
+ }
242
+ finally {
243
+ connection.end();
244
+ }
245
+ progress.stop("Checked the app");
246
+ }
247
+ catch (error) {
248
+ progress.stop("Could not reach the VM");
249
+ app = dim(error instanceof Error ? error.message : String(error));
250
+ }
251
+ }
252
+ note([
253
+ `${bold("Preview")} ${last.previewUrl}`,
254
+ `${bold("Project")} ${last.directory}`,
255
+ `${bold("App")} ${phase === "running" ? app : "stopped: the 60 minutes are over"}`,
256
+ phase === "running"
257
+ ? `${bold("Time")} ${left} minutes left (until ${new Date(last.expiresAt).toLocaleTimeString()})`
258
+ : `${bold("Time")} claimable until ${claimUntil.toLocaleString()}`,
259
+ `${bold("Claim")} ${last.claimUrl}`,
260
+ ].join("\n"), "Your VM on Railway");
261
+ outro(phase === "running"
262
+ ? "Until you claim the VM, only this IP address can open the preview. Claim it to share the URL."
263
+ : "Claim the VM to keep it: its files and URL move to your Railway account.");
264
+ }
180
265
  export async function tryOnRailway(args) {
266
+ if (args.status)
267
+ return await showStatus(railwayKeyPath());
181
268
  const what = await plan(args);
182
269
  const keyPath = railwayKeyPath();
183
270
  if (args.dryRun) {
@@ -255,14 +342,19 @@ export async function tryOnRailway(args) {
255
342
  `${bold("Claim")} ${vm.claimUrl}`,
256
343
  ].join("\n"), "Your app on Railway");
257
344
  log.warn("Keep the claim link private: whoever opens it takes over the VM, its files, and its URL.");
258
- // Only for a person at a terminal: scripts and CI get the URL above, and nothing opens.
259
- if (args.open && process.stdout.isTTY && (await openUrl(vm.previewUrl)))
345
+ await saveLastVm({ previewUrl: vm.previewUrl, claimUrl: vm.claimUrl, expiresAt: vm.expiresAt.toISOString(), directory: `/app/${directory}` });
346
+ // Only for a person at a terminal: scripts and CI get the URLs above, and nothing opens.
347
+ const atTerminal = Boolean(process.stdin.isTTY && process.stdout.isTTY);
348
+ if (args.open && atTerminal && (await openUrl(vm.previewUrl)))
260
349
  log.info("Opened the preview in your browser.");
350
+ await offerClaim(args, atTerminal, vm.claimUrl);
261
351
  outro([
262
- "Until you claim the VM, the preview opens only from the IP address that created it. Claiming moves it to your Railway account and makes the URL shareable.",
352
+ "Until you claim the VM, only you can open the preview, from the IP address that created it. Another device on mobile data, a VPN, or anyone you send it to gets Railway's \"Access denied\" page. Claiming makes the URL shareable.",
263
353
  "",
264
- `${dim("Shell")} ssh -i "${keyPath}" ${railwayHost}`,
265
- `${dim("Logs")} ssh -i "${keyPath}" ${railwayHost} tail -f ${remoteDevLog}`,
354
+ `${dim("Status")} ${dim("pnpm dlx @clubedge/create-clubedge-app try --status")}`,
355
+ `${dim("Local")} ${portForwardCommand(keyPath)} ${dim("then http://localhost:3000, from any network")}`,
356
+ `${dim("Shell")} ssh -i "${keyPath}" ${railwayHost}`,
357
+ `${dim("Logs")} ssh -i "${keyPath}" ${railwayHost} tail -f ${remoteDevLog}`,
266
358
  ].join("\n"));
267
359
  }
268
360
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.19.1",
3
+ "version": "0.21.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.0",
26
+ "starterCommit": "e233aa68ddc8c21df6cf2b01b9e26876d2e4aeca"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
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.0",
4
+ "commit": "e233aa68ddc8c21df6cf2b01b9e26876d2e4aeca",
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",