@clubedge/create-clubedge-app 0.9.0 → 0.10.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 (84) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +13 -12
  3. package/dist/cli/args.js +56 -27
  4. package/dist/cli/ui.js +27 -6
  5. package/dist/core/env.js +10 -3
  6. package/dist/index.js +19 -4
  7. package/package.json +3 -3
  8. package/template/.env.example +20 -0
  9. package/template/README.md +14 -1
  10. package/template/SETUP.md +38 -4
  11. package/template/apps/start/.env.example +20 -0
  12. package/template/apps/start/package.json +4 -1
  13. package/template/apps/start/scripts/check-services.ts +38 -0
  14. package/template/apps/start/src/components/auth/auth-form-controls.tsx +8 -2
  15. package/template/apps/start/src/components/auth/auth-page.tsx +50 -0
  16. package/template/apps/start/src/components/auth/notice.tsx +33 -0
  17. package/template/apps/start/src/env/auth.ts +9 -1
  18. package/template/apps/start/src/lib/login-url.ts +10 -1
  19. package/template/apps/start/src/routes/auth/forgot-password.ts +11 -0
  20. package/template/apps/start/src/routes/auth/reset-password.ts +11 -0
  21. package/template/apps/start/src/routes/forgot-password.tsx +46 -0
  22. package/template/apps/start/src/routes/login.tsx +23 -32
  23. package/template/apps/start/src/routes/reset-password.tsx +52 -0
  24. package/template/apps/start/src/server/auth-email.ts +18 -0
  25. package/template/apps/start/src/server/auth-forms.ts +58 -2
  26. package/template/apps/start/src/server/auth.functions.ts +9 -5
  27. package/template/apps/start/src/server/email.ts +39 -0
  28. package/template/apps/start/src/server/variants/auth.better-auth.ts +8 -1
  29. package/template/apps/start/src/server/variants/email.resend.ts +39 -0
  30. package/template/apps/web/package.json +4 -1
  31. package/template/apps/web/scripts/check-services.ts +38 -0
  32. package/template/apps/web/src/app/actions/auth.ts +49 -2
  33. package/template/apps/web/src/app/auth/callback/route.ts +11 -2
  34. package/template/apps/web/src/app/forgot-password/page.tsx +40 -0
  35. package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +8 -2
  36. package/template/apps/web/src/app/login/_components/auth-page.tsx +50 -0
  37. package/template/apps/web/src/app/login/_components/notice.tsx +33 -0
  38. package/template/apps/web/src/app/login/page.tsx +23 -32
  39. package/template/apps/web/src/app/reset-password/page.tsx +47 -0
  40. package/template/apps/web/src/env/auth.ts +4 -0
  41. package/template/apps/web/src/lib/login-url.ts +10 -1
  42. package/template/apps/web/src/server/auth-email.ts +18 -0
  43. package/template/apps/web/src/server/email.ts +39 -0
  44. package/template/apps/web/src/server/variants/auth.better-auth.ts +8 -1
  45. package/template/apps/web/src/server/variants/email.resend.ts +39 -0
  46. package/template/clubedge.template.json +49 -2
  47. package/template/docker-compose.dev.yml +13 -0
  48. package/template/e2e/email-verification.spec.ts +29 -0
  49. package/template/e2e/inbox.ts +53 -0
  50. package/template/e2e/password-reset.spec.ts +48 -0
  51. package/template/package.json +1 -1
  52. package/template/packages/auth/package.json +1 -1
  53. package/template/packages/auth/src/types.ts +19 -1
  54. package/template/packages/auth-better-auth/package.json +1 -1
  55. package/template/packages/auth-better-auth/src/emails.ts +78 -0
  56. package/template/packages/auth-better-auth/src/index.test.ts +133 -2
  57. package/template/packages/auth-better-auth/src/index.ts +135 -7
  58. package/template/packages/auth-supabase/package.json +1 -1
  59. package/template/packages/auth-supabase/src/index.test.ts +45 -0
  60. package/template/packages/auth-supabase/src/index.ts +12 -0
  61. package/template/packages/cache/package.json +1 -1
  62. package/template/packages/cache-redis/package.json +1 -1
  63. package/template/packages/core/package.json +1 -1
  64. package/template/packages/db/package.json +1 -1
  65. package/template/packages/email/package.json +18 -0
  66. package/template/packages/email/src/index.test.ts +57 -0
  67. package/template/packages/email/src/index.ts +50 -0
  68. package/template/packages/email/tsconfig.json +4 -0
  69. package/template/packages/email-resend/package.json +21 -0
  70. package/template/packages/email-resend/src/index.test.ts +56 -0
  71. package/template/packages/email-resend/src/index.ts +56 -0
  72. package/template/packages/email-resend/tsconfig.json +4 -0
  73. package/template/packages/email-smtp/package.json +22 -0
  74. package/template/packages/email-smtp/src/index.test.ts +73 -0
  75. package/template/packages/email-smtp/src/index.ts +26 -0
  76. package/template/packages/email-smtp/tsconfig.json +4 -0
  77. package/template/packages/local-db/package.json +1 -1
  78. package/template/packages/storage/package.json +1 -1
  79. package/template/packages/storage-s3/package.json +1 -1
  80. package/template/packages/storage-supabase/package.json +1 -1
  81. package/template/packages/ui/package.json +1 -1
  82. package/template/pnpm-lock.yaml +53 -0
  83. package/template/supabase/config.toml +6 -1
  84. package/template.lock.json +34 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.10.0
6
+
7
+ - Default to Starter `v0.9.0`, which adds the email module (`--email smtp|resend|none`), password reset for both auth providers, and email verification for Better Auth. `--email` needs no CLI code: it comes from the Starter's manifest.
8
+ - Fix a doubled blank line in `.env.local` when `add` or `remove` swaps one section of variables for another, such as switching `--email smtp` to `resend`.
9
+ - Take module flags from the Starter's manifest: any `--<module> <option>` flag is accepted and validated against the modules the Starter describes, so new Starter modules need no CLI release. A mistyped module lists the Starter's modules; an unknown flag without a value is still an error.
10
+ - Build the help text's framework and module lines, and the "customize the included services" prompt, from the Starter's manifest.
11
+
5
12
  ## 0.9.0
6
13
 
7
14
  - Default to Starter `v0.8.0`, whose projects have a `clubedge` script: `pnpm clubedge add`, `remove`, and `upgrade`.
package/README.md CHANGED
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
12
12
  Each CLI release is tested against one specific Starter release:
13
13
 
14
14
  ```text
15
- create-clubedge-app v0.9.0
15
+ create-clubedge-app v0.10.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.8.0
19
- commit 0b77577a3fe77617c84e250d8b220169b72a297e
18
+ Clubedge Starter v0.9.0
19
+ commit e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5
20
20
  ```
21
21
 
22
22
  The current relationship is:
23
23
 
24
24
  | CLI release | Default Starter release | Meaning |
25
25
  | --- | --- | --- |
26
- | `create-clubedge-app@0.9.0` | `clubedge-starter@v0.8.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.10.0` | `clubedge-starter@v0.9.0` | The CLI's required, tested default |
27
27
 
28
28
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
29
29
 
30
- The `v0.8.0` tag is expected to resolve to commit
31
- `0b77577a3fe77617c84e250d8b220169b72a297e`. The CLI verifies this before
30
+ The `v0.9.0` tag is expected to resolve to commit
31
+ `e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5`. The CLI verifies this before
32
32
  scaffolding with the default reference and fails rather than silently using a
33
33
  moved tag.
34
34
 
35
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.8.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.
35
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.9.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.
36
36
 
37
37
  ## Requirements
38
38
 
@@ -85,13 +85,14 @@ Either way, the app lives in `apps/web` and uses the same shared packages, so th
85
85
 
86
86
  ### Choosing services
87
87
 
88
- Authentication, file storage, and caching are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries.
88
+ Authentication, file storage, caching, email, and local infrastructure are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
89
89
 
90
90
  | Flag | Options | Default |
91
91
  | ----------- | ------------------------------------------ | ---------- |
92
92
  | `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
93
93
  | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
94
94
  | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
95
+ | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
95
96
  | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
96
97
 
97
98
  ```sh
@@ -136,7 +137,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
136
137
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
137
138
 
138
139
  # Reproduce a specific Starter release or commit
139
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.8.0
140
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.9.0
140
141
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
141
142
 
142
143
  # Test local Starter changes before releasing them
@@ -153,7 +154,7 @@ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add aut
153
154
  pnpm dlx @clubedge/create-clubedge-app remove storage
154
155
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
155
156
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
156
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.8.0 # or a specific tag, branch, or commit
157
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.9.0 # or a specific tag, branch, or commit
157
158
  ```
158
159
 
159
160
  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.
@@ -204,7 +205,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
204
205
  1. Make and validate changes in `clubedge-starter`.
205
206
  2. Run lint, typecheck, unit tests, browser tests, and production build.
206
207
  3. Update the Starter package versions.
207
- 4. Commit `main`, create a release tag such as `v0.8.0`, and publish the Starter release.
208
+ 4. Commit `main`, create a release tag such as `v0.9.0`, and publish the Starter release.
208
209
 
209
210
  ### CLI maintainers
210
211
 
@@ -218,7 +219,7 @@ The required CI job always tests the pinned default. The scheduled compatibility
218
219
 
219
220
  ## Contributing
220
221
 
221
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development and pull request guidance. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
222
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development and pull request guidance, and [ROADMAP.md](ROADMAP.md) for the planned phases. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
222
223
 
223
224
  ## License
224
225
 
package/dist/cli/args.js CHANGED
@@ -1,32 +1,70 @@
1
1
  import { parseArgs } from "node:util";
2
- /** Module flags the CLI accepts. Which options exist is up to the Starter's manifest. */
3
- export const MODULE_FLAGS = ["auth", "storage", "cache", "infra"];
4
2
  export class UsageError extends Error {
5
3
  name = "UsageError";
6
4
  }
5
+ /** The CLI's own long options; every other --name value flag selects a module option. */
6
+ const OPTIONS = {
7
+ framework: { type: "string" },
8
+ ref: { type: "string" },
9
+ "template-dir": { type: "string" },
10
+ install: { type: "boolean", default: true },
11
+ git: { type: "boolean", default: true },
12
+ yes: { type: "boolean", short: "y", default: false },
13
+ "dry-run": { type: "boolean", default: false },
14
+ force: { type: "boolean", default: false },
15
+ help: { type: "boolean", short: "h", default: false },
16
+ version: { type: "boolean", short: "v", default: false },
17
+ };
18
+ const isOwnOption = (name) => name in OPTIONS || (name.startsWith("no-") && OPTIONS[name.slice(3)]?.type === "boolean");
19
+ /**
20
+ * Takes module flags (`--email smtp` or `--email=smtp`) out of the arguments, leaving the CLI's
21
+ * own options for the strict parser. A flag that is neither needs a value, so a stray unknown
22
+ * flag such as --verbose is still reported.
23
+ */
24
+ function extractModuleFlags(argv) {
25
+ const rest = [];
26
+ const modules = {};
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const argument = argv[i];
29
+ if (argument === "--") {
30
+ rest.push(...argv.slice(i));
31
+ break;
32
+ }
33
+ const match = /^--([^=]+)(?:=(.*))?$/.exec(argument);
34
+ if (!match || isOwnOption(match[1])) {
35
+ rest.push(argument);
36
+ // A string option's value is never a flag of its own.
37
+ if (match && match[2] === undefined && OPTIONS[match[1]]?.type === "string") {
38
+ if (argv[i + 1] !== undefined)
39
+ rest.push(argv[++i]);
40
+ }
41
+ continue;
42
+ }
43
+ const [, name, inline] = match;
44
+ if (!/^[a-z][a-z0-9-]*$/.test(name))
45
+ throw new UsageError(`Unknown option --${name}. Run with --help to see available options.`);
46
+ const next = argv[i + 1];
47
+ const value = inline ?? (next !== undefined && !next.startsWith("-") ? argv[++i] : undefined);
48
+ if (value === undefined) {
49
+ throw new UsageError(`Unknown option --${name}. Module flags take an option, such as --${name} none. Run with --help to see available options.`);
50
+ }
51
+ if (!value.trim())
52
+ throw new UsageError(`--${name} requires an option, such as none.`);
53
+ modules[name] = value.trim();
54
+ }
55
+ return { rest, modules };
56
+ }
7
57
  export function parseCliArgs(argv) {
58
+ const { rest, modules } = extractModuleFlags(argv);
8
59
  let parsed;
9
60
  try {
10
61
  parsed = parseArgs({
11
- args: argv,
62
+ args: rest,
12
63
  allowPositionals: true,
13
64
  allowNegative: true,
14
65
  strict: true,
15
66
  options: {
16
- framework: { type: "string" },
17
- auth: { type: "string" },
18
- storage: { type: "string" },
19
- cache: { type: "string" },
20
- infra: { type: "string" },
21
- ref: { type: "string" },
22
- "template-dir": { type: "string" },
23
- install: { type: "boolean", default: true },
24
- git: { type: "boolean", default: true },
25
- yes: { type: "boolean", short: "y", default: false },
26
- "dry-run": { type: "boolean", default: false },
27
- force: { type: "boolean", default: false },
28
- help: { type: "boolean", short: "h", default: false },
29
- version: { type: "boolean", short: "v", default: false },
67
+ ...OPTIONS,
30
68
  },
31
69
  });
32
70
  }
@@ -39,7 +77,7 @@ export function parseCliArgs(argv) {
39
77
  if (command.name === "create" && positionals.length > 1) {
40
78
  throw new UsageError("Only one project directory can be provided.");
41
79
  }
42
- if (command.name !== "create" && (values.framework !== undefined || MODULE_FLAGS.some((id) => values[id]))) {
80
+ if (command.name !== "create" && (values.framework !== undefined || Object.keys(modules).length)) {
43
81
  throw new UsageError("--framework and module flags only apply when creating a project; use add or remove to change one module.");
44
82
  }
45
83
  if ((command.name === "add" || command.name === "remove") && values.ref !== undefined) {
@@ -48,15 +86,6 @@ export function parseCliArgs(argv) {
48
86
  if (values.framework !== undefined && !values.framework.trim()) {
49
87
  throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
50
88
  }
51
- const modules = {};
52
- for (const module of MODULE_FLAGS) {
53
- const value = values[module];
54
- if (value === undefined)
55
- continue;
56
- if (!value.trim())
57
- throw new UsageError(`--${module} requires an option, such as none.`);
58
- modules[module] = value.trim();
59
- }
60
89
  if (values.ref !== undefined && !values.ref.trim()) {
61
90
  throw new UsageError("--ref requires a tag, branch, or commit.");
62
91
  }
package/dist/cli/ui.js CHANGED
@@ -52,7 +52,32 @@ export function showBanner() {
52
52
  console.log(` ${dim("By Clubedge")} ${dim("·")} ${paint(gradientFrom, siteUrl)}`);
53
53
  console.log("");
54
54
  }
55
- export function helpText() {
55
+ /** "a (default), b, or c" */
56
+ function choices(ids, defaultId) {
57
+ const labels = ids.map((id) => (id === defaultId ? `${id} (default)` : id));
58
+ return labels.length > 1 ? `${labels.slice(0, -1).join(", ")}${labels.length > 2 ? "," : ""} or ${labels.at(-1)}` : labels[0] ?? "";
59
+ }
60
+ /**
61
+ * The framework and module flags of a Starter, from its manifest, so new modules appear in the
62
+ * help without CLI changes. Without a manifest, a generic line explains module flags.
63
+ */
64
+ export function selectionHelp(manifest) {
65
+ const line = (flag, text) => ` ${flag.padEnd(24)}${text}`;
66
+ if (!manifest) {
67
+ return [
68
+ line("--framework <id>", "App framework, such as next or tanstack-start"),
69
+ line("--<module> <option>", "A module option, such as --auth none; the Starter lists its modules"),
70
+ ].join("\n");
71
+ }
72
+ const frameworks = Object.keys(manifest.frameworks);
73
+ return [
74
+ ...(frameworks.length > 1
75
+ ? [line("--framework <id>", `App framework: ${choices(frameworks, manifest.defaultFramework)}`)]
76
+ : []),
77
+ ...Object.entries(manifest.modules).map(([id, module]) => line(`--${id} <id>`, `${module.name}: ${choices(Object.keys(module.options), module.default)}`)),
78
+ ].join("\n");
79
+ }
80
+ export function helpText(manifest = null) {
56
81
  return `
57
82
  create-clubedge-app ${cliVersion}
58
83
 
@@ -74,11 +99,7 @@ Usage:
74
99
  markers. Commit first so you can review the result with git diff.
75
100
 
76
101
  Options:
77
- --framework <id> App framework: next (default) or tanstack-start
78
- --auth <id> Authentication: supabase (default), better-auth, or none
79
- --storage <id> File storage: s3 (default), supabase, or none
80
- --cache <id> Cache and rate limits: redis (default) or memory
81
- --infra <id> Local services: docker (default), supabase, local, or none
102
+ ${selectionHelp(manifest)}
82
103
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
83
104
  (upgrade: the Starter to upgrade to)
84
105
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
package/dist/core/env.js CHANGED
@@ -76,10 +76,17 @@ export function updateEnvFile(local, baseExample, targetExample) {
76
76
  while (anchor >= 0 && !keyOf(target[anchor]))
77
77
  anchor--;
78
78
  const position = anchor >= 0 ? find(lines, keyOf(target[anchor])) : -1;
79
- if (position === -1)
79
+ if (position === -1) {
80
80
  appended.push(...commentsAbove(target, at), line);
81
- else
82
- lines.splice(position + 1, 0, ...target.slice(anchor + 1, at + 1));
81
+ return;
82
+ }
83
+ const between = target.slice(anchor + 1, at + 1);
84
+ // A removed section can leave its blank separator after the anchor; reuse it rather than
85
+ // adding a second one.
86
+ // The last element is the end of the file after its final newline, not a blank line.
87
+ const followedByBlank = position + 1 < lines.length - 1 && lines[position + 1].trim() === "";
88
+ const reuseBlank = between[0]?.trim() === "" && followedByBlank;
89
+ lines.splice(position + (reuseBlank ? 2 : 1), 0, ...(reuseBlank ? between.slice(1) : between));
83
90
  });
84
91
  let text = lines.join(eol);
85
92
  // Removed variables can leave blank lines in a row, as removed template blocks do.
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ import { dim, helpText, showBanner } from "./cli/ui.js";
7
7
  import { assertTargetIsUsable, executePlan } from "./core/execute.js";
8
8
  import { readTemplateManifest } from "./core/manifest.js";
9
9
  import { createPlan, describePlan, selectFramework } from "./core/plan.js";
10
- import { resolveTemplateSource } from "./core/source.js";
11
- import { cliUrl, cliVersion, siteUrl } from "./package-info.js";
10
+ import { readBundledTemplate, resolveTemplateSource } from "./core/source.js";
11
+ import { cliUrl, cliVersion, siteUrl, starterPin } from "./package-info.js";
12
12
  import { changeProject } from "./modify.js";
13
13
  import { initializeGit } from "./steps/git.js";
14
14
  import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
@@ -63,7 +63,9 @@ async function chooseModules(manifest, requested, interactive) {
63
63
  if (!interactive || unasked.length === 0)
64
64
  return requested;
65
65
  const customize = await confirm({
66
- message: "Customize the included services (auth, storage, cache, local infrastructure)?",
66
+ message: `Customize the included services (${Object.values(manifest.modules)
67
+ .map((module) => module.name.toLowerCase())
68
+ .join(", ")})?`,
67
69
  initialValue: false,
68
70
  });
69
71
  if (isCancel(customize))
@@ -193,6 +195,19 @@ async function createProject(args) {
193
195
  later +
194
196
  `${dim("Docs")} ${cliUrl}\n${dim("Clubedge")} ${siteUrl}`);
195
197
  }
198
+ /** The bundled Starter's manifest, for the help text; null in a checkout without a bundle. */
199
+ async function bundledManifest() {
200
+ try {
201
+ const bundled = await readBundledTemplate();
202
+ // A checkout can hold an older bundle; only the pinned release describes what this CLI creates.
203
+ if (!bundled || bundled.commit !== starterPin.starterCommit)
204
+ return null;
205
+ return await readTemplateManifest(bundled.root);
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
196
211
  export async function main(argv = process.argv.slice(2)) {
197
212
  try {
198
213
  const args = parseCliArgs(argv);
@@ -202,7 +217,7 @@ export async function main(argv = process.argv.slice(2)) {
202
217
  }
203
218
  showBanner();
204
219
  if (args.help) {
205
- console.log(helpText());
220
+ console.log(helpText(await bundledManifest()));
206
221
  return;
207
222
  }
208
223
  assertSupportedNode();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.9.0",
3
+ "version": "0.10.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.8.0",
26
- "starterCommit": "0b77577a3fe77617c84e250d8b220169b72a297e"
25
+ "starterRef": "v0.9.0",
26
+ "starterCommit": "e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -22,6 +22,8 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
22
22
  # Better Auth keeps users and sessions in DATABASE_URL (run pnpm db:migrate first).
23
23
  # Generate a secret with: openssl rand -base64 32
24
24
  BETTER_AUTH_SECRET=
25
+ # true refuses sign-in until the email address is confirmed through the emailed link. # clubedge:only email!=none
26
+ BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=false # clubedge:only email!=none
25
27
  # clubedge:end
26
28
 
27
29
  # clubedge:if cache=redis
@@ -31,6 +33,24 @@ REDIS_URL= # clubedge:only infra!=docker
31
33
  REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
32
34
  # clubedge:end
33
35
 
36
+ # clubedge:if email=smtp
37
+ # Email over SMTP: any provider's SMTP endpoint (smtps:// for TLS). Without it, development
38
+ # prints emails to the server log and production refuses to send.
39
+ SMTP_URL= # clubedge:only infra!=docker|supabase
40
+ # Mailpit from docker-compose.dev.yml; read the emails at http://localhost:8025. # clubedge:only infra=docker
41
+ SMTP_URL=smtp://localhost:1025 # clubedge:only infra=docker
42
+ # The local Supabase inbox; read the emails at http://127.0.0.1:54324. # clubedge:only infra=supabase
43
+ SMTP_URL=smtp://127.0.0.1:54325 # clubedge:only infra=supabase
44
+ EMAIL_FROM=App <no-reply@example.com>
45
+ # clubedge:end
46
+
47
+ # clubedge:if email=resend
48
+ # Resend (https://resend.com). EMAIL_FROM must use a domain verified in Resend. Without the
49
+ # key, development prints emails to the server log and production refuses to send.
50
+ RESEND_API_KEY=
51
+ EMAIL_FROM=App <no-reply@example.com>
52
+ # clubedge:end
53
+
34
54
  # clubedge:if storage=s3
35
55
  # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
36
56
  STORAGE_BUCKET= # clubedge:only infra!=docker
@@ -7,7 +7,7 @@ Clubedge Starter is a production-oriented, modular React application foundation.
7
7
 
8
8
  <!-- clubedge:if starter-repository -->
9
9
 
10
- This repository contains the same application for two frameworks, Next.js (`apps/web`) and TanStack Start (`apps/start`), plus every optional module. [create-clubedge-app](https://github.com/Clubedge/create-clubedge-app) generates a project with one framework and only the modules you choose; sections of this README marked for a framework or module appear only in projects that use it. Contributions and bug reports are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). Repository maintainers can use [PUBLISHING.md](PUBLISHING.md) for the GitHub launch checklist.
10
+ This repository contains the same application for two frameworks, Next.js (`apps/web`) and TanStack Start (`apps/start`), plus every optional module. [create-clubedge-app](https://github.com/Clubedge/create-clubedge-app) generates a project with one framework and only the modules you choose; sections of this README marked for a framework or module appear only in projects that use it. Contributions and bug reports are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). Repository maintainers can use [PUBLISHING.md](PUBLISHING.md) for the GitHub launch checklist, and [ROADMAP.md](ROADMAP.md) describes the planned phases.
11
11
 
12
12
  <!-- clubedge:end -->
13
13
 
@@ -19,10 +19,14 @@ This repository contains the same application for two frameworks, Next.js (`apps
19
19
  - Drizzle ORM and PostgreSQL schema, migrations, and seed commands.
20
20
  - Supabase Auth with server-verified sessions in secure cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=supabase -->
21
21
  - Better Auth with users and sessions in your own PostgreSQL, secure session cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=better-auth -->
22
+ - Password reset by email, and email verification that can be required before sign-in. <!-- clubedge:only auth=better-auth && email!=none -->
23
+ - Password reset by email, sent by Supabase. <!-- clubedge:only auth=supabase -->
22
24
  - S3-compatible storage adapter for AWS S3, Cloudflare R2, and MinIO. <!-- clubedge:only storage=s3 -->
23
25
  - Supabase Storage adapter that uses the signed-in user's session. <!-- clubedge:only storage=supabase -->
24
26
  - Fixed-window rate limiter and cache on Redis, falling back to process memory without `REDIS_URL`. <!-- clubedge:only cache=redis -->
25
27
  - Fixed-window rate limiter and cache in process memory, suited to a single server instance. <!-- clubedge:only cache=memory -->
28
+ - Email over SMTP, for any provider or a local inbox; development prints emails to the server log until SMTP is configured. <!-- clubedge:only email=smtp -->
29
+ - Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
26
30
  - Zod environment validation, security headers, structured errors, and `/api/health`.
27
31
  - Docker support, GitHub Actions CI, Vitest, Playwright, ESLint, and Prettier.
28
32
 
@@ -47,6 +51,12 @@ flowchart TB
47
51
  %% clubedge:if cache=redis
48
52
  App -->|cache and rate limits| Redis[(Redis, or memory)]
49
53
  %% clubedge:end
54
+ %% clubedge:if email=smtp
55
+ App -->|email interface| SMTP[SMTP server, or the log]
56
+ %% clubedge:end
57
+ %% clubedge:if email=resend
58
+ App -->|email interface| Resend[Resend, or the log]
59
+ %% clubedge:end
50
60
  %% clubedge:if storage=s3
51
61
  App -->|storage interface| S3[S3-compatible storage: AWS S3, R2, MinIO]
52
62
  %% clubedge:end
@@ -201,6 +211,9 @@ The TanStack Start app is `apps/start`, run with `pnpm --filter @clubedge/start
201
211
  - `packages/storage-supabase/`: Supabase Storage adapter <!-- clubedge:only storage=supabase -->
202
212
  - `packages/cache/`: `Cache` and `RateLimiter` interfaces with in-memory implementations
203
213
  - `packages/cache-redis/`: Redis rate limiter and cache <!-- clubedge:only cache=redis -->
214
+ - `packages/email/`: `EmailProvider` interface with console and in-memory providers <!-- clubedge:only email!=none -->
215
+ - `packages/email-smtp/`: SMTP email provider <!-- clubedge:only email=smtp -->
216
+ - `packages/email-resend/`: Resend email provider <!-- clubedge:only email=resend -->
204
217
  - `packages/ui/`: shared shadcn/ui components, utilities, and global theme styles
205
218
  - `.github/`: CI workflow, issue forms, and pull request template
206
219
  - `SETUP.md`, `CONTRIBUTING.md`, `SECURITY.md`: setup, contribution, and security guides
package/template/SETUP.md CHANGED
@@ -43,6 +43,8 @@ The variables are:
43
43
  - `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` (for sign-in): see [Supabase Auth](#supabase-auth). <!-- clubedge:only framework=tanstack-start && auth=supabase -->
44
44
  - `BETTER_AUTH_SECRET` (for sign-in): see [Better Auth](#better-auth). <!-- clubedge:only auth=better-auth -->
45
45
  - `REDIS_URL` (optional): see [Redis](#redis). <!-- clubedge:only cache=redis -->
46
+ - `SMTP_URL` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=smtp -->
47
+ - `RESEND_API_KEY` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=resend -->
46
48
  - `STORAGE_*` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=s3 -->
47
49
  - `SUPABASE_STORAGE_BUCKET` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=supabase -->
48
50
 
@@ -62,13 +64,13 @@ The app is available at <http://localhost:3000> and the liveness endpoint at <ht
62
64
 
63
65
  Create or select a Supabase project and copy its URL and publishable key from the project's API settings into the Supabase variables above. Authentication runs on the server, which validates every user with Supabase; never use a service role key.
64
66
 
65
- In Supabase Auth URL configuration, add this redirect URL for local email confirmation:
67
+ In Supabase Auth URL configuration, add this redirect URL for local email confirmation and password reset links:
66
68
 
67
69
  ```text
68
- http://localhost:3000/auth/callback
70
+ http://localhost:3000/auth/callback**
69
71
  ```
70
72
 
71
- For deployed environments, add your deployed origin followed by `/auth/callback`. Until the Supabase variables are set, the dashboard stays viewable as a demo and the login page explains what is missing; once they are set, the dashboard redirects signed-out visitors to the login page.
73
+ For deployed environments, add your deployed origin followed by `/auth/callback**`. The `**` lets links carry the page to open next, such as `/reset-password`. Supabase sends the confirmation and password reset emails itself; configure its SMTP settings in the Supabase project to send them from your own domain. Until the Supabase variables are set, the dashboard stays viewable as a demo and the login page explains what is missing; once they are set, the dashboard redirects signed-out visitors to the login page.
72
74
 
73
75
  <!-- clubedge:end -->
74
76
 
@@ -82,7 +84,13 @@ Better Auth keeps users, passwords, and sessions in your PostgreSQL database, in
82
84
  openssl rand -base64 32
83
85
  ```
84
86
 
85
- Put the result in `BETTER_AUTH_SECRET`. Until it is set, the dashboard stays viewable as a demo and the login page explains what is missing; once it is set, the dashboard redirects signed-out visitors to the login page. Sign-up signs the user in immediately; add email verification in `packages/auth-better-auth` if your application needs it. Signing in again on another device creates another session row, and signing out deletes it.
87
+ Put the result in `BETTER_AUTH_SECRET`. Until it is set, the dashboard stays viewable as a demo and the login page explains what is missing; once it is set, the dashboard redirects signed-out visitors to the login page. Signing in again on another device creates another session row, and signing out deletes it.
88
+
89
+ Sign-up signs the user in immediately; add email verification in `packages/auth-better-auth` if your application needs it. <!-- clubedge:only email=none -->
90
+ Better Auth sends its emails through the [email module](#email): <!-- clubedge:only email!=none -->
91
+
92
+ - **Password reset:** "Forgot password?" on the login page emails a link to `/reset-password`, valid for one hour and usable once. <!-- clubedge:only email!=none -->
93
+ - **Email verification:** every new account gets a confirmation link. Sign-up signs the user in right away unless `BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=true`, which refuses sign-in until the link is opened. <!-- clubedge:only email!=none -->
86
94
 
87
95
  <!-- clubedge:end -->
88
96
 
@@ -101,6 +109,7 @@ pnpm check:services
101
109
  - PostgreSQL 17 on port 5432, database `app`, user and password `postgres`. Data survives restarts in a Docker volume.
102
110
  - Redis on port 6379. <!-- clubedge:only cache=redis -->
103
111
  - S3-compatible storage (adobe/s3mock) on port 9090 with the bucket `app`. It accepts any credentials and is for development only. <!-- clubedge:only storage=s3 -->
112
+ - Mailpit, which catches every email the app sends: SMTP on port 1025, and the inbox at http://localhost:8025. <!-- clubedge:only email=smtp -->
104
113
 
105
114
  `pnpm infra:down` stops the services, `pnpm infra:reset` also deletes their data, and `pnpm infra:logs` follows their logs. The file is for development; in production, point the same variables at managed services.
106
115
 
@@ -118,6 +127,8 @@ pnpm check:services
118
127
 
119
128
  The database is on port 54322 and the API on port 54321, matching the example environment. Copy the publishable key from `pnpm infra:status` into the Supabase variables. Email confirmation is off locally, so sign-up signs the user in directly. `pnpm infra:down` stops the stack and `pnpm infra:reset` also deletes its data.
120
129
 
130
+ The app's own email goes to the same inbox over SMTP, on port 54325. <!-- clubedge:only email=smtp -->
131
+
121
132
  <!-- clubedge:end -->
122
133
  <!-- clubedge:if infra=local -->
123
134
 
@@ -126,6 +137,7 @@ This project runs its development services without Docker or any other installat
126
137
  - **PostgreSQL:** `pnpm dev` also starts [PGlite](https://pglite.dev), PostgreSQL compiled to WebAssembly, and serves it on `127.0.0.1:5433`. Each start applies the migrations. Data is kept in `.data/pglite`. `pnpm infra:up` runs the database on its own, for `pnpm check:services` or browser tests.
127
138
  - **Files:** while `STORAGE_DIRECTORY` is set, uploads are stored in that folder (`.data/storage`) instead of S3. Set the S3 variables and remove `STORAGE_DIRECTORY` to use object storage. <!-- clubedge:only storage=s3 -->
128
139
  - **Cache:** without `REDIS_URL`, the cache and rate limits use process memory. <!-- clubedge:only cache=redis -->
140
+ - **Email:** without a provider configured, emails are printed to the `pnpm dev` output, links included. <!-- clubedge:only email!=none -->
129
141
 
130
142
  PGlite runs a single database session, so connections take turns. That is plenty for development but not for production: deploy against a managed PostgreSQL by setting `DATABASE_URL`. Supabase Auth and Supabase Storage need a Supabase project, local or hosted. Better Auth works fully offline. `pnpm infra:reset` deletes everything in `.data/`.
131
143
 
@@ -174,6 +186,28 @@ Without `REDIS_URL`, the rate limiter and cache use process memory. Memory limit
174
186
 
175
187
  The rate limiter and cache keep their state in process memory, which suits a single server instance. Limits and cached values are not shared between instances and reset on restart.
176
188
 
189
+ <!-- clubedge:end -->
190
+ <!-- clubedge:if email!=none -->
191
+
192
+ ## Email
193
+
194
+ The app sends email through the `EmailProvider` in `apps/web/src/server/email.ts`. Server code imports `email` from `@/server/email` and calls `email.send({ to, subject, text, html })`.
195
+
196
+ <!-- clubedge:end -->
197
+ <!-- clubedge:if email=smtp -->
198
+
199
+ Set `SMTP_URL` to any SMTP server, for example `smtps://user:password@smtp.example.com:465`, and `EMAIL_FROM` to the sender, as `Name <address>` or an address. Most email providers (Resend, Postmark, Amazon SES, SendGrid, Mailgun) offer an SMTP endpoint.
200
+
201
+ <!-- clubedge:end -->
202
+ <!-- clubedge:if email=resend -->
203
+
204
+ Set `RESEND_API_KEY` to a [Resend](https://resend.com) API key and `EMAIL_FROM` to a sender on a domain you verified in Resend, as `Name <address>` or an address. A key limited to sending is enough.
205
+
206
+ <!-- clubedge:end -->
207
+ <!-- clubedge:if email!=none -->
208
+
209
+ Without a provider configured, development prints each email to the server log, so you can open links from the terminal, and production refuses to send. `pnpm check:services` confirms that the provider accepts the settings without sending anything.
210
+
177
211
  <!-- clubedge:end -->
178
212
  <!-- clubedge:if storage!=none -->
179
213
 
@@ -24,6 +24,8 @@ SUPABASE_PUBLISHABLE_KEY=
24
24
  # Better Auth keeps users and sessions in DATABASE_URL (run pnpm db:migrate first).
25
25
  # Generate a secret with: openssl rand -base64 32
26
26
  BETTER_AUTH_SECRET=
27
+ # true refuses sign-in until the email address is confirmed through the emailed link. # clubedge:only email!=none
28
+ BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=false # clubedge:only email!=none
27
29
  # clubedge:end
28
30
 
29
31
  # clubedge:if cache=redis
@@ -33,6 +35,24 @@ REDIS_URL= # clubedge:only infra!=docker
33
35
  REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
34
36
  # clubedge:end
35
37
 
38
+ # clubedge:if email=smtp
39
+ # Email over SMTP: any provider's SMTP endpoint (smtps:// for TLS). Without it, development
40
+ # prints emails to the server log and production refuses to send.
41
+ SMTP_URL= # clubedge:only infra!=docker|supabase
42
+ # Mailpit from docker-compose.dev.yml; read the emails at http://localhost:8025. # clubedge:only infra=docker
43
+ SMTP_URL=smtp://localhost:1025 # clubedge:only infra=docker
44
+ # The local Supabase inbox; read the emails at http://127.0.0.1:54324. # clubedge:only infra=supabase
45
+ SMTP_URL=smtp://127.0.0.1:54325 # clubedge:only infra=supabase
46
+ EMAIL_FROM=App <no-reply@example.com>
47
+ # clubedge:end
48
+
49
+ # clubedge:if email=resend
50
+ # Resend (https://resend.com). EMAIL_FROM must use a domain verified in Resend. Without the
51
+ # key, development prints emails to the server log and production refuses to send.
52
+ RESEND_API_KEY=
53
+ EMAIL_FROM=App <no-reply@example.com>
54
+ # clubedge:end
55
+
36
56
  # clubedge:if storage=s3
37
57
  # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
38
58
  STORAGE_BUCKET= # clubedge:only infra!=docker
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -19,6 +19,9 @@
19
19
  "@clubedge/cache-redis": "workspace:*",
20
20
  "@clubedge/core": "workspace:*",
21
21
  "@clubedge/db": "workspace:*",
22
+ "@clubedge/email": "workspace:*",
23
+ "@clubedge/email-resend": "workspace:*",
24
+ "@clubedge/email-smtp": "workspace:*",
22
25
  "@clubedge/storage": "workspace:*",
23
26
  "@clubedge/storage-s3": "workspace:*",
24
27
  "@clubedge/storage-supabase": "workspace:*",
@@ -8,6 +8,12 @@ import { createRedisCache } from "@clubedge/cache-redis";
8
8
  // clubedge:if storage=s3
9
9
  import { createS3Storage } from "@clubedge/storage-s3";
10
10
  // clubedge:end
11
+ // clubedge:if email=smtp
12
+ import { createSmtpEmail } from "@clubedge/email-smtp";
13
+ // clubedge:end
14
+ // clubedge:if email=resend
15
+ import { createResendEmail } from "@clubedge/email-resend";
16
+ // clubedge:end
11
17
  // clubedge:if storage=s3 && infra=local
12
18
  import { createFileSystemStorage } from "@clubedge/storage/file-system";
13
19
  // clubedge:end
@@ -62,6 +68,38 @@ async function main() {
62
68
  },
63
69
  },
64
70
  // clubedge:end
71
+ // clubedge:if email=smtp
72
+ {
73
+ name: "Email (SMTP)",
74
+ skip: env.SMTP_URL ? undefined : "SMTP_URL is not set; development prints emails to the log",
75
+ async run() {
76
+ // Connects and authenticates without sending anything.
77
+ const email = createSmtpEmail({
78
+ url: env.SMTP_URL!,
79
+ from: env.EMAIL_FROM || "no-reply@example.com",
80
+ });
81
+ await email.verify();
82
+ return `connected to ${new URL(env.SMTP_URL!).host}`;
83
+ },
84
+ },
85
+ // clubedge:end
86
+ // clubedge:if email=resend
87
+ {
88
+ name: "Email (Resend)",
89
+ skip: env.RESEND_API_KEY
90
+ ? undefined
91
+ : "RESEND_API_KEY is not set; development prints emails to the log",
92
+ async run() {
93
+ // Checks the API key without sending anything.
94
+ const email = createResendEmail({
95
+ apiKey: env.RESEND_API_KEY!,
96
+ from: env.EMAIL_FROM || "no-reply@example.com",
97
+ });
98
+ await email.verify();
99
+ return "API key accepted";
100
+ },
101
+ },
102
+ // clubedge:end
65
103
  // clubedge:if storage=s3 && infra=local
66
104
  {
67
105
  name: "Local folder storage",