@clubedge/create-clubedge-app 0.8.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 (85) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +15 -13
  3. package/dist/cli/args.js +60 -33
  4. package/dist/cli/ui.js +32 -8
  5. package/dist/core/env.js +10 -3
  6. package/dist/index.js +26 -5
  7. package/dist/modify.js +90 -13
  8. package/package.json +3 -3
  9. package/template/.env.example +20 -0
  10. package/template/README.md +22 -1
  11. package/template/SETUP.md +38 -4
  12. package/template/apps/start/.env.example +20 -0
  13. package/template/apps/start/package.json +4 -1
  14. package/template/apps/start/scripts/check-services.ts +38 -0
  15. package/template/apps/start/src/components/auth/auth-form-controls.tsx +8 -2
  16. package/template/apps/start/src/components/auth/auth-page.tsx +50 -0
  17. package/template/apps/start/src/components/auth/notice.tsx +33 -0
  18. package/template/apps/start/src/env/auth.ts +9 -1
  19. package/template/apps/start/src/lib/login-url.ts +10 -1
  20. package/template/apps/start/src/routes/auth/forgot-password.ts +11 -0
  21. package/template/apps/start/src/routes/auth/reset-password.ts +11 -0
  22. package/template/apps/start/src/routes/forgot-password.tsx +46 -0
  23. package/template/apps/start/src/routes/login.tsx +23 -32
  24. package/template/apps/start/src/routes/reset-password.tsx +52 -0
  25. package/template/apps/start/src/server/auth-email.ts +18 -0
  26. package/template/apps/start/src/server/auth-forms.ts +58 -2
  27. package/template/apps/start/src/server/auth.functions.ts +9 -5
  28. package/template/apps/start/src/server/email.ts +39 -0
  29. package/template/apps/start/src/server/variants/auth.better-auth.ts +8 -1
  30. package/template/apps/start/src/server/variants/email.resend.ts +39 -0
  31. package/template/apps/web/package.json +4 -1
  32. package/template/apps/web/scripts/check-services.ts +38 -0
  33. package/template/apps/web/src/app/actions/auth.ts +49 -2
  34. package/template/apps/web/src/app/auth/callback/route.ts +11 -2
  35. package/template/apps/web/src/app/forgot-password/page.tsx +40 -0
  36. package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +8 -2
  37. package/template/apps/web/src/app/login/_components/auth-page.tsx +50 -0
  38. package/template/apps/web/src/app/login/_components/notice.tsx +33 -0
  39. package/template/apps/web/src/app/login/page.tsx +23 -32
  40. package/template/apps/web/src/app/reset-password/page.tsx +47 -0
  41. package/template/apps/web/src/env/auth.ts +4 -0
  42. package/template/apps/web/src/lib/login-url.ts +10 -1
  43. package/template/apps/web/src/server/auth-email.ts +18 -0
  44. package/template/apps/web/src/server/email.ts +39 -0
  45. package/template/apps/web/src/server/variants/auth.better-auth.ts +8 -1
  46. package/template/apps/web/src/server/variants/email.resend.ts +39 -0
  47. package/template/clubedge.template.json +49 -2
  48. package/template/docker-compose.dev.yml +13 -0
  49. package/template/e2e/email-verification.spec.ts +29 -0
  50. package/template/e2e/inbox.ts +53 -0
  51. package/template/e2e/password-reset.spec.ts +48 -0
  52. package/template/package.json +2 -1
  53. package/template/packages/auth/package.json +1 -1
  54. package/template/packages/auth/src/types.ts +19 -1
  55. package/template/packages/auth-better-auth/package.json +1 -1
  56. package/template/packages/auth-better-auth/src/emails.ts +78 -0
  57. package/template/packages/auth-better-auth/src/index.test.ts +133 -2
  58. package/template/packages/auth-better-auth/src/index.ts +135 -7
  59. package/template/packages/auth-supabase/package.json +1 -1
  60. package/template/packages/auth-supabase/src/index.test.ts +45 -0
  61. package/template/packages/auth-supabase/src/index.ts +12 -0
  62. package/template/packages/cache/package.json +1 -1
  63. package/template/packages/cache-redis/package.json +1 -1
  64. package/template/packages/core/package.json +1 -1
  65. package/template/packages/db/package.json +1 -1
  66. package/template/packages/email/package.json +18 -0
  67. package/template/packages/email/src/index.test.ts +57 -0
  68. package/template/packages/email/src/index.ts +50 -0
  69. package/template/packages/email/tsconfig.json +4 -0
  70. package/template/packages/email-resend/package.json +21 -0
  71. package/template/packages/email-resend/src/index.test.ts +56 -0
  72. package/template/packages/email-resend/src/index.ts +56 -0
  73. package/template/packages/email-resend/tsconfig.json +4 -0
  74. package/template/packages/email-smtp/package.json +22 -0
  75. package/template/packages/email-smtp/src/index.test.ts +73 -0
  76. package/template/packages/email-smtp/src/index.ts +26 -0
  77. package/template/packages/email-smtp/tsconfig.json +4 -0
  78. package/template/packages/local-db/package.json +1 -1
  79. package/template/packages/storage/package.json +1 -1
  80. package/template/packages/storage-s3/package.json +1 -1
  81. package/template/packages/storage-supabase/package.json +1 -1
  82. package/template/packages/ui/package.json +1 -1
  83. package/template/pnpm-lock.yaml +53 -0
  84. package/template/supabase/config.toml +6 -1
  85. package/template.lock.json +34 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
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
+
12
+ ## 0.9.0
13
+
14
+ - Default to Starter `v0.8.0`, whose projects have a `clubedge` script: `pnpm clubedge add`, `remove`, and `upgrade`.
15
+ - `add` and `remove` ask for the module and option when they are not given, offering only options the project can use. Without a terminal, missing arguments remain an error.
16
+ - Messages suggest `pnpm clubedge ...` in projects with the `clubedge` script, and the outro after creating a project mentions it.
17
+ - Test the CLI on Windows in CI: its checks and tests, and generated projects with a module change.
18
+
5
19
  ## 0.8.0
6
20
 
7
21
  - Add `add <module> <option>` and `remove <module>` to change the modules of an existing project. The CLI renders the project's recorded Starter commit with the current and the new selection and merges the difference into the project. Files you never changed are updated, your edits are kept, and lines both sides changed get conflict markers. The local env file keeps your values: only generated values follow the new selection, and new variables are added. A Git repository without uncommitted changes is required unless `--force` is passed, so the result can be reviewed and undone.
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.8.0
15
+ create-clubedge-app v0.10.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.7.0
19
- commit 3b8114760b8151c2538b57c06620ff2b884f2d56
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.8.0` | `clubedge-starter@v0.7.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.7.0` tag is expected to resolve to commit
31
- `3b8114760b8151c2538b57c06620ff2b884f2d56`. 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.7.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.7.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
@@ -145,14 +146,15 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
145
146
 
146
147
  ## Change an existing project
147
148
 
148
- Inside a project created by the CLI, `add` and `remove` change one module at a time, and `upgrade` moves the project to a newer Starter release:
149
+ Inside a project created by the CLI, `add` and `remove` change one module at a time, 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:
149
150
 
150
151
  ```sh
152
+ pnpm clubedge add # choose a module and option from a list
151
153
  pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add auth better-auth
152
154
  pnpm dlx @clubedge/create-clubedge-app remove storage
153
155
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
154
156
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
155
- 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
156
158
  ```
157
159
 
158
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.
@@ -203,7 +205,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
203
205
  1. Make and validate changes in `clubedge-starter`.
204
206
  2. Run lint, typecheck, unit tests, browser tests, and production build.
205
207
  3. Update the Starter package versions.
206
- 4. Commit `main`, create a release tag such as `v0.7.0`, and publish the Starter release.
208
+ 4. Commit `main`, create a release tag such as `v0.9.0`, and publish the Starter release.
207
209
 
208
210
  ### CLI maintainers
209
211
 
@@ -217,7 +219,7 @@ The required CI job always tests the pinned default. The scheduled compatibility
217
219
 
218
220
  ## Contributing
219
221
 
220
- 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).
221
223
 
222
224
  ## License
223
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
  }
@@ -86,15 +115,13 @@ export function parseCliArgs(argv) {
86
115
  function parseCommand(positionals) {
87
116
  const [name, module, option, ...extra] = positionals;
88
117
  if (name === "add") {
89
- if (!module || !option || extra.length) {
90
- throw new UsageError("Usage: add <module> <option>, for example: add cache redis");
91
- }
118
+ if (extra.length)
119
+ throw new UsageError("Usage: add [module] [option], for example: add cache redis");
92
120
  return { name, module, option };
93
121
  }
94
122
  if (name === "remove") {
95
- if (!module || option !== undefined) {
96
- throw new UsageError("Usage: remove <module>, for example: remove storage");
97
- }
123
+ if (option !== undefined)
124
+ throw new UsageError("Usage: remove [module], for example: remove storage");
98
125
  return { name, module };
99
126
  }
100
127
  if (name === "upgrade") {
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
 
@@ -61,21 +86,20 @@ This CLI release scaffolds Starter ${starterPin.starterRef} (commit ${starterPin
61
86
 
62
87
  Usage:
63
88
  pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
64
- pnpm dlx @clubedge/create-clubedge-app add <module> <option> (inside a project)
65
- pnpm dlx @clubedge/create-clubedge-app remove <module> (inside a project)
89
+ pnpm dlx @clubedge/create-clubedge-app add [module] [option] (inside a project)
90
+ pnpm dlx @clubedge/create-clubedge-app remove [module] (inside a project)
66
91
  pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
67
92
 
93
+ Inside a project with the clubedge script, use pnpm clubedge add, remove, or
94
+ upgrade. Without a module or option, add and remove ask for them.
95
+
68
96
  add and remove change one module of an existing project, and upgrade moves it
69
97
  to a newer Starter (this CLI's release unless --ref is given). The change is
70
98
  merged into your code; lines you changed that it also changes get conflict
71
99
  markers. Commit first so you can review the result with git diff.
72
100
 
73
101
  Options:
74
- --framework <id> App framework: next (default) or tanstack-start
75
- --auth <id> Authentication: supabase (default), better-auth, or none
76
- --storage <id> File storage: s3 (default), supabase, or none
77
- --cache <id> Cache and rate limits: redis (default) or memory
78
- --infra <id> Local services: docker (default), supabase, local, or none
102
+ ${selectionHelp(manifest)}
79
103
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
80
104
  (upgrade: the Starter to upgrade to)
81
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
@@ -1,13 +1,14 @@
1
1
  import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
2
- import { relative, resolve } from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
3
4
  import process from "node:process";
4
5
  import { parseCliArgs, UsageError } from "./cli/args.js";
5
6
  import { dim, helpText, showBanner } from "./cli/ui.js";
6
7
  import { assertTargetIsUsable, executePlan } from "./core/execute.js";
7
8
  import { readTemplateManifest } from "./core/manifest.js";
8
9
  import { createPlan, describePlan, selectFramework } from "./core/plan.js";
9
- import { resolveTemplateSource } from "./core/source.js";
10
- 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";
11
12
  import { changeProject } from "./modify.js";
12
13
  import { initializeGit } from "./steps/git.js";
13
14
  import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
@@ -62,7 +63,9 @@ async function chooseModules(manifest, requested, interactive) {
62
63
  if (!interactive || unasked.length === 0)
63
64
  return requested;
64
65
  const customize = await confirm({
65
- 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(", ")})?`,
66
69
  initialValue: false,
67
70
  });
68
71
  if (isCancel(customize))
@@ -183,10 +186,28 @@ async function createProject(args) {
183
186
  }
184
187
  }
185
188
  const directory = relative(process.cwd(), targetDirectory) || ".";
189
+ const scripts = JSON.parse(await readFile(join(targetDirectory, "package.json"), "utf8")).scripts ?? {};
190
+ const later = scripts.clubedge
191
+ ? `Change modules later with \`pnpm clubedge add\` or \`pnpm clubedge remove\`, and get Starter updates with \`pnpm clubedge upgrade\`.\n\n`
192
+ : "";
186
193
  outro(`Next steps:\n cd ${directory}\n ${pnpmCommand} dev\n\n` +
187
194
  `See SETUP.md for database and provider configuration.\n\n` +
195
+ later +
188
196
  `${dim("Docs")} ${cliUrl}\n${dim("Clubedge")} ${siteUrl}`);
189
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
+ }
190
211
  export async function main(argv = process.argv.slice(2)) {
191
212
  try {
192
213
  const args = parseCliArgs(argv);
@@ -196,7 +217,7 @@ export async function main(argv = process.argv.slice(2)) {
196
217
  }
197
218
  showBanner();
198
219
  if (args.help) {
199
- console.log(helpText());
220
+ console.log(helpText(await bundledManifest()));
200
221
  return;
201
222
  }
202
223
  assertSupportedNode();
package/dist/modify.js CHANGED
@@ -1,7 +1,9 @@
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, spinner } from "@clack/prompts";
4
+ import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts";
5
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
5
7
  import process from "node:process";
6
8
  import { UsageError } from "./cli/args.js";
7
9
  import { dim } from "./cli/ui.js";
@@ -13,6 +15,18 @@ import { applyUpdate, planUpdate } from "./core/update.js";
13
15
  import { cliVersion, starterPin } from "./package-info.js";
14
16
  import { installDependencies, resolvePnpm } from "./steps/install.js";
15
17
  import { commandSucceeds, runCapture } from "./utils/process.js";
18
+ /** How to run the CLI in this project: its `clubedge` script when it has one. */
19
+ async function cliCommand(project) {
20
+ try {
21
+ const manifest = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
22
+ if (manifest.scripts?.clubedge)
23
+ return "pnpm clubedge";
24
+ }
25
+ catch {
26
+ // Fall back to the full command below.
27
+ }
28
+ return "pnpm dlx @clubedge/create-clubedge-app";
29
+ }
16
30
  /** The Starter revision the project was generated from. */
17
31
  async function projectSource(project, templateDir) {
18
32
  if (templateDir)
@@ -62,8 +76,66 @@ async function assertSafeToChange(project, args, interactive) {
62
76
  if (isCancel(proceed) || !proceed)
63
77
  throw new UsageError("Cancelled; nothing was changed.");
64
78
  }
79
+ /** Whether an option's requirements hold for the project's other modules. */
80
+ function available(manifest, project, moduleId, optionId) {
81
+ const option = manifest.modules[moduleId].options[optionId];
82
+ return Object.entries(option.requires).every(([other, allowed]) => allowed.includes(project.modules[other] ?? ""));
83
+ }
84
+ /**
85
+ * Asks for whatever add or remove was not given: the module, then the option. Without a
86
+ * terminal, the missing arguments are an error. Resolves null when a prompt is cancelled.
87
+ */
88
+ export async function completeCommand(manifest, project, command, interactive) {
89
+ if (command.name === "add" && command.module && command.option) {
90
+ return { name: "add", module: command.module, option: command.option };
91
+ }
92
+ if (command.name === "remove" && command.module)
93
+ return { name: "remove", module: command.module };
94
+ if (!interactive) {
95
+ throw new UsageError(command.name === "add"
96
+ ? "Usage: add <module> <option>, for example: add cache redis. Run it in a terminal to choose from a list."
97
+ : "Usage: remove <module>, for example: remove storage. Run it in a terminal to choose from a list.");
98
+ }
99
+ let moduleId = command.module;
100
+ if (!moduleId) {
101
+ const choices = Object.entries(manifest.modules).filter(([id, module]) => command.name === "add" ? true : Boolean(module.options.none) && project.modules[id] !== "none");
102
+ if (!choices.length)
103
+ throw new UsageError("No module can be removed: every removable module is already none.");
104
+ const value = await select({
105
+ message: command.name === "add" ? "Which module do you want to add or switch?" : "Which module do you want to remove?",
106
+ options: choices.map(([id, module]) => ({
107
+ value: id,
108
+ label: module.name,
109
+ hint: `now: ${module.options[project.modules[id] ?? ""]?.name ?? project.modules[id] ?? "not set"}`,
110
+ })),
111
+ });
112
+ if (isCancel(value))
113
+ return null;
114
+ moduleId = value;
115
+ }
116
+ if (command.name === "remove")
117
+ return { name: "remove", module: moduleId };
118
+ const module = manifest.modules[moduleId];
119
+ if (!module) {
120
+ throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
121
+ }
122
+ const options = Object.keys(module.options).filter((id) => id !== project.modules[moduleId] && available(manifest, project, moduleId, id));
123
+ if (!options.length)
124
+ throw new UsageError(`${module.name} has no other option this project can use.`);
125
+ const option = await select({
126
+ message: `${module.name}: which option?`,
127
+ options: options.map((id) => ({
128
+ value: id,
129
+ label: module.options[id].name,
130
+ ...(id === module.default ? { hint: "default" } : {}),
131
+ })),
132
+ });
133
+ if (isCancel(option))
134
+ return null;
135
+ return { name: "add", module: moduleId, option };
136
+ }
65
137
  /** The selection after add or remove, validated against the Starter's manifest. */
66
- function changedModules(manifest, project, command) {
138
+ function changedModules(manifest, project, command, cli) {
67
139
  const module = manifest.modules[command.module];
68
140
  if (!module) {
69
141
  throw new UsageError(`Unknown module "${command.module}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
@@ -78,7 +150,7 @@ function changedModules(manifest, project, command) {
78
150
  else {
79
151
  if (!module.options.none) {
80
152
  const others = Object.keys(module.options).filter((id) => id !== project.modules[command.module]);
81
- throw new UsageError(`${module.name} cannot be removed. Switch it instead, for example: add ${command.module} ${others[0] ?? module.default}`);
153
+ throw new UsageError(`${module.name} cannot be removed. Switch it instead, for example: ${cli} add ${command.module} ${others[0] ?? module.default}`);
82
154
  }
83
155
  option = "none";
84
156
  }
@@ -89,7 +161,7 @@ function changedModules(manifest, project, command) {
89
161
  * The project's selection in a newer Starter. Modules the project predates get their "none"
90
162
  * option where there is one, so an upgrade never adds a service on its own.
91
163
  */
92
- export function upgradedModules(manifest, project) {
164
+ export function upgradedModules(manifest, project, cli = "pnpm clubedge") {
93
165
  const requested = {};
94
166
  const introduced = [];
95
167
  for (const [id, module] of Object.entries(manifest.modules)) {
@@ -99,7 +171,7 @@ export function upgradedModules(manifest, project) {
99
171
  introduced.push(id);
100
172
  }
101
173
  else if (!module.options[recorded]) {
102
- throw new UsageError(`The new Starter no longer offers ${module.name} "${recorded}". Choose one of: ${Object.keys(module.options).join(", ")}, with add ${id} <option>, before upgrading.`);
174
+ throw new UsageError(`The new Starter no longer offers ${module.name} "${recorded}". Switch to one of ${Object.keys(module.options).join(", ")} with ${cli} add ${id} <option> before upgrading.`);
103
175
  }
104
176
  else {
105
177
  requested[id] = recorded;
@@ -154,11 +226,10 @@ function report(plan) {
154
226
  }
155
227
  }
156
228
  /** Works out what the command changes; resolves null when the project is already there. */
157
- async function planChange(project, command, base, target) {
158
- const baseManifest = await readTemplateManifest(base.root);
229
+ async function planChange(project, command, base, baseManifest, target, cli) {
159
230
  const baseSide = { source: asRecorded(base, project), manifest: baseManifest, modules: project.modules, cliVersion: project.cliVersion };
160
231
  if (command.name !== "upgrade") {
161
- const modules = changedModules(baseManifest, project, command);
232
+ const modules = changedModules(baseManifest, project, command, cli);
162
233
  const changed = Object.keys(modules).filter((id) => modules[id] !== project.modules[id]);
163
234
  if (!changed.length)
164
235
  return null;
@@ -177,11 +248,11 @@ async function planChange(project, command, base, target) {
177
248
  if (target.commit && target.commit === project.starterCommit)
178
249
  return null;
179
250
  const targetManifest = await readTemplateManifest(target.root);
180
- const { modules, introduced, dropped } = upgradedModules(targetManifest, project);
251
+ const { modules, introduced, dropped } = upgradedModules(targetManifest, project, cli);
181
252
  const notes = [
182
253
  ...introduced.map((id) => {
183
254
  const module = targetManifest.modules[id];
184
- return `Starter ${target.ref} adds ${module.name}; the project uses "${modules[id]}". Choose another option with: add ${id} <option>`;
255
+ return `Starter ${target.ref} adds ${module.name}; the project uses "${modules[id]}". Choose another option with: ${cli} add ${id} <option>`;
185
256
  }),
186
257
  ...dropped.map((id) => `Starter ${target.ref} no longer has the ${id} module; its files are merged away like any other change.`),
187
258
  ];
@@ -214,14 +285,20 @@ export async function changeProject(args) {
214
285
  const target = upgrading ? await upgradeSource(args) : null;
215
286
  if (target)
216
287
  sources.push(target);
217
- const change = await planChange(project, command, base, target);
288
+ const baseManifest = await readTemplateManifest(base.root);
289
+ progress.stop("Starter ready");
218
290
  spinning = false;
291
+ const cli = await cliCommand(project);
292
+ const complete = command.name === "upgrade" ? command : await completeCommand(baseManifest, project, command, interactive);
293
+ if (!complete) {
294
+ outro("Cancelled; nothing was changed.");
295
+ return;
296
+ }
297
+ const change = await planChange(project, complete, base, baseManifest, target, cli);
219
298
  if (!change) {
220
- progress.stop("Starter ready");
221
299
  outro(upgrading ? `The project is already on Starter ${project.starterRef}.` : "The project already uses that selection; nothing to change.");
222
300
  return;
223
301
  }
224
- progress.stop("Starter ready");
225
302
  const plan = await planUpdate(project, change.base, change.target);
226
303
  log.message(`${change.summary}\n\n${describe(plan) || "No file changes."}`);
227
304
  if (args.dryRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.8.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.7.0",
26
- "starterCommit": "3b8114760b8151c2538b57c06620ff2b884f2d56"
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