@clubedge/create-clubedge-app 0.4.0 → 0.5.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 (104) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +31 -11
  3. package/dist/cli/args.js +15 -0
  4. package/dist/cli/ui.js +4 -0
  5. package/dist/core/customize.js +142 -15
  6. package/dist/core/manifest.js +91 -2
  7. package/dist/core/plan.js +143 -8
  8. package/dist/index.js +50 -1
  9. package/package.json +3 -3
  10. package/template/.env.example +19 -6
  11. package/template/.github/workflows/ci.yml +16 -33
  12. package/template/AGENTS.md +11 -4
  13. package/template/CONTRIBUTING.md +1 -1
  14. package/template/README.md +69 -63
  15. package/template/SETUP.md +94 -44
  16. package/template/_gitignore +2 -0
  17. package/template/apps/start/.env.example +18 -5
  18. package/template/apps/start/components.json +21 -0
  19. package/template/apps/start/package.json +8 -2
  20. package/template/apps/start/src/components/dashboard/account-button.tsx +1 -1
  21. package/template/apps/start/src/components/dashboard/app-sidebar.tsx +1 -1
  22. package/template/apps/start/src/components/dashboard/variants/account-button.none.tsx +9 -0
  23. package/template/apps/start/src/env/auth.test.ts +26 -0
  24. package/template/apps/start/src/env/auth.ts +46 -0
  25. package/template/apps/start/src/env/server.test.ts +0 -16
  26. package/template/apps/start/src/env/server.ts +18 -42
  27. package/template/apps/start/src/routes/dashboard.tsx +18 -8
  28. package/template/apps/start/src/routes/index.tsx +7 -3
  29. package/template/apps/start/src/routes/login.tsx +1 -1
  30. package/template/apps/start/src/server/auth.ts +2 -2
  31. package/template/apps/start/src/server/cache.test.ts +65 -0
  32. package/template/apps/start/src/server/cache.ts +36 -16
  33. package/template/apps/start/src/server/storage.ts +22 -19
  34. package/template/apps/start/src/server/variants/auth.better-auth.ts +57 -0
  35. package/template/apps/start/src/server/variants/auth.functions.none.ts +9 -0
  36. package/template/apps/start/src/server/variants/auth.none.ts +28 -0
  37. package/template/apps/start/src/server/variants/cache.memory.ts +26 -0
  38. package/template/apps/start/src/server/variants/storage.supabase.ts +35 -0
  39. package/template/apps/start/tsr.config.json +7 -0
  40. package/template/apps/web/next.config.ts +9 -9
  41. package/template/apps/web/package.json +6 -1
  42. package/template/apps/web/src/app/dashboard/_components/account-button.tsx +1 -1
  43. package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +1 -1
  44. package/template/apps/web/src/app/dashboard/_components/variants/account-button.none.tsx +9 -0
  45. package/template/apps/web/src/app/dashboard/page.tsx +18 -8
  46. package/template/apps/web/src/app/login/page.tsx +5 -4
  47. package/template/apps/web/src/app/page.tsx +7 -3
  48. package/template/apps/web/src/env/auth.ts +48 -0
  49. package/template/apps/web/src/env/client.ts +1 -8
  50. package/template/apps/web/src/env/server.test.ts +27 -4
  51. package/template/apps/web/src/env/server.ts +19 -29
  52. package/template/apps/web/src/proxy.ts +5 -8
  53. package/template/apps/web/src/server/auth.ts +14 -26
  54. package/template/apps/web/src/server/cache.ts +36 -16
  55. package/template/apps/web/src/server/cookies.ts +18 -0
  56. package/template/apps/web/src/server/storage.ts +22 -19
  57. package/template/apps/web/src/server/variants/auth.better-auth.ts +63 -0
  58. package/template/apps/web/src/server/variants/auth.none.ts +26 -0
  59. package/template/apps/web/src/server/variants/cache.memory.ts +26 -0
  60. package/template/apps/web/src/server/variants/storage.supabase.ts +35 -0
  61. package/template/apps/web/src/variants/proxy.better-auth.ts +46 -0
  62. package/template/clubedge.template.json +157 -2
  63. package/template/e2e/auth-flow.spec.ts +29 -0
  64. package/template/e2e/auth.spec.ts +2 -12
  65. package/template/e2e/home.spec.ts +14 -5
  66. package/template/eslint.config.mjs +2 -1
  67. package/template/package.json +4 -4
  68. package/template/packages/auth/package.json +3 -6
  69. package/template/packages/auth-better-auth/drizzle/0000_initial.sql +50 -0
  70. package/template/packages/auth-better-auth/drizzle/meta/0000_snapshot.json +320 -0
  71. package/template/packages/auth-better-auth/drizzle/meta/_journal.json +13 -0
  72. package/template/packages/auth-better-auth/drizzle.config.ts +12 -0
  73. package/template/packages/auth-better-auth/package.json +31 -0
  74. package/template/packages/auth-better-auth/scripts/migrate.ts +14 -0
  75. package/template/packages/auth-better-auth/src/index.test.ts +118 -0
  76. package/template/packages/auth-better-auth/src/index.ts +135 -0
  77. package/template/packages/auth-better-auth/src/schema.ts +74 -0
  78. package/template/packages/auth-better-auth/tsconfig.json +4 -0
  79. package/template/packages/auth-supabase/package.json +24 -0
  80. package/template/packages/{auth/src/supabase.test.ts → auth-supabase/src/index.test.ts} +2 -2
  81. package/template/packages/{auth/src/supabase.ts → auth-supabase/src/index.ts} +1 -1
  82. package/template/packages/auth-supabase/tsconfig.json +4 -0
  83. package/template/packages/cache/package.json +1 -4
  84. package/template/packages/cache/src/index.ts +3 -13
  85. package/template/packages/cache/src/memory-cache.test.ts +40 -0
  86. package/template/packages/cache/src/memory-cache.ts +37 -0
  87. package/template/packages/cache-redis/package.json +22 -0
  88. package/template/packages/{cache → cache-redis}/src/index.test.ts +6 -15
  89. package/template/packages/{cache/src/redis.ts → cache-redis/src/index.ts} +2 -6
  90. package/template/packages/cache-redis/tsconfig.json +4 -0
  91. package/template/packages/core/package.json +1 -1
  92. package/template/packages/db/package.json +3 -2
  93. package/template/packages/storage/package.json +2 -9
  94. package/template/packages/storage-s3/package.json +23 -0
  95. package/template/packages/{storage/src/s3.test.ts → storage-s3/src/index.test.ts} +1 -1
  96. package/template/packages/{storage/src/s3.ts → storage-s3/src/index.ts} +1 -1
  97. package/template/packages/storage-s3/tsconfig.json +4 -0
  98. package/template/packages/storage-supabase/package.json +22 -0
  99. package/template/packages/{storage/src/supabase.ts → storage-supabase/src/index.ts} +1 -1
  100. package/template/packages/storage-supabase/tsconfig.json +4 -0
  101. package/template/packages/ui/package.json +1 -1
  102. package/template/pnpm-lock.yaml +524 -2
  103. package/template.lock.json +49 -10
  104. package/template/apps/start/src/routeTree.gen.ts +0 -220
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.5.0
6
+
7
+ - Default to Starter `v0.5.0`, which adds optional modules, Better Auth, and template schema 3.
8
+ - Add `--auth`, `--storage`, and `--cache`, and offer to customize them interactively. Options whose requirements are not met (Supabase Storage without Supabase Auth) are refused with an explanation.
9
+ - Read template schema 3 modules: unselected options' packages, files, provider dependencies, environment variables, and lockfile entries are removed, and selected variants replace the default files.
10
+ - Process `clubedge:if` blocks in the Starter's documentation, environment examples, and sources, so a project only describes what it contains.
11
+ - Support Mermaid (`%%`) markers and single-line `clubedge:only` conditions, and leave no blank lines at file edges after removing blocks.
12
+ - Record the selected modules in the generated `package.json` and `README.md`. The smoke test skips the sign-up check for projects without authentication.
13
+
5
14
  ## 0.4.0
6
15
 
7
16
  - Default to Starter `v0.4.0`, which adds the TanStack Start app and template schema 2.
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.4.0
15
+ create-clubedge-app v0.5.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.4.0
19
- commit fc01d33bb1a3944e099a1750679be87a96ff9a22
18
+ Clubedge Starter v0.5.0
19
+ commit d9375c2c3b520323ff0255ef8ec65da19251c3c1
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.4.0` | `clubedge-starter@v0.4.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.5.0` | `clubedge-starter@v0.5.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.4.0` tag is expected to resolve to commit
31
- `fc01d33bb1a3944e099a1750679be87a96ff9a22`. The CLI verifies this before
30
+ The `v0.5.0` tag is expected to resolve to commit
31
+ `d9375c2c3b520323ff0255ef8ec65da19251c3c1`. 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.4.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.5.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
 
@@ -83,12 +83,32 @@ pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
83
83
 
84
84
  Either way, the app lives in `apps/web` and uses the same shared packages, so the project layout, root scripts, and Docker commands are identical.
85
85
 
86
+ ### Choosing services
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.
89
+
90
+ | Flag | Options | Default |
91
+ | ----------- | ------------------------------------------ | ---------- |
92
+ | `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
93
+ | `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
94
+ | `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
95
+
96
+ ```sh
97
+ # Only the monorepo, UI, database, and tooling
98
+ pnpm dlx @clubedge/create-clubedge-app my-app --auth none --storage none --cache memory
99
+ ```
100
+
101
+ Interactive runs ask whether to customize these after the framework question. The options come from the Starter's manifest, so `--help` lists the ones the bundled Starter offers.
102
+
86
103
  ## Options
87
104
 
88
105
  ```text
89
106
  Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
90
107
 
91
108
  --framework <id> App framework: next (default) or tanstack-start
109
+ --auth <id> Authentication: supabase (default), better-auth, or none
110
+ --storage <id> File storage: s3 (default), supabase, or none
111
+ --cache <id> Cache and rate limits: redis (default) or memory
92
112
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
93
113
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
94
114
  --dry-run Show what would be created without writing anything
@@ -101,7 +121,7 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
101
121
 
102
122
  The target directory must be empty or not exist. The CLI never deletes or overwrites existing files, and if scaffolding fails it removes whatever it wrote, so a failed run leaves nothing behind.
103
123
 
104
- Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, and framework in `package.json` and `README.md`.
124
+ Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
105
125
 
106
126
  ```sh
107
127
  # Preview the files and edits without writing anything
@@ -114,7 +134,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
114
134
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
115
135
 
116
136
  # Reproduce a specific Starter release or commit
117
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.4.0
137
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.5.0
118
138
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
119
139
 
120
140
  # Test local Starter changes before releasing them
@@ -125,7 +145,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
125
145
 
126
146
  Each CLI release bundles one Starter release. When the release is built, `scripts/bundle-template.mjs` clones the pinned Starter tag, verifies that it resolves to the pinned commit, and copies its tracked files into the package (`template/` and `template.lock.json`). Running the CLI then needs no GitHub access for the default Starter.
127
147
 
128
- The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
148
+ The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
129
149
 
130
150
  The CLI first builds a plan (the files to copy and the edits to make), which `--dry-run` prints, and then applies it. The source is organized the same way:
131
151
 
@@ -159,7 +179,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
159
179
  1. Make and validate changes in `clubedge-starter`.
160
180
  2. Run lint, typecheck, unit tests, browser tests, and production build.
161
181
  3. Update the Starter package versions.
162
- 4. Commit `main`, create a release tag such as `v0.4.0`, and publish the Starter release.
182
+ 4. Commit `main`, create a release tag such as `v0.5.0`, and publish the Starter release.
163
183
 
164
184
  ### CLI maintainers
165
185
 
package/dist/cli/args.js CHANGED
@@ -1,4 +1,6 @@
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"];
2
4
  export class UsageError extends Error {
3
5
  name = "UsageError";
4
6
  }
@@ -12,6 +14,9 @@ export function parseCliArgs(argv) {
12
14
  strict: true,
13
15
  options: {
14
16
  framework: { type: "string" },
17
+ auth: { type: "string" },
18
+ storage: { type: "string" },
19
+ cache: { type: "string" },
15
20
  ref: { type: "string" },
16
21
  "template-dir": { type: "string" },
17
22
  install: { type: "boolean", default: true },
@@ -33,6 +38,15 @@ export function parseCliArgs(argv) {
33
38
  if (values.framework !== undefined && !values.framework.trim()) {
34
39
  throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
35
40
  }
41
+ const modules = {};
42
+ for (const module of MODULE_FLAGS) {
43
+ const value = values[module];
44
+ if (value === undefined)
45
+ continue;
46
+ if (!value.trim())
47
+ throw new UsageError(`--${module} requires an option, such as none.`);
48
+ modules[module] = value.trim();
49
+ }
36
50
  if (values.ref !== undefined && !values.ref.trim()) {
37
51
  throw new UsageError("--ref requires a tag, branch, or commit.");
38
52
  }
@@ -42,6 +56,7 @@ export function parseCliArgs(argv) {
42
56
  return {
43
57
  projectDirectory: positionals[0],
44
58
  framework: values.framework?.trim(),
59
+ modules,
45
60
  ref: values.ref,
46
61
  templateDir: values["template-dir"],
47
62
  install: values.install,
package/dist/cli/ui.js CHANGED
@@ -64,6 +64,9 @@ Usage:
64
64
 
65
65
  Options:
66
66
  --framework <id> App framework: next (default) or tanstack-start
67
+ --auth <id> Authentication: supabase (default), better-auth, or none
68
+ --storage <id> File storage: s3 (default), supabase, or none
69
+ --cache <id> Cache and rate limits: redis (default) or memory
67
70
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
68
71
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
69
72
  --dry-run Show what would be created without writing anything
@@ -76,6 +79,7 @@ Options:
76
79
  Examples:
77
80
  pnpm dlx @clubedge/create-clubedge-app my-app
78
81
  pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
82
+ pnpm dlx @clubedge/create-clubedge-app my-app --auth none --storage none --cache memory
79
83
  pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
80
84
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
81
85
  pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
@@ -10,6 +10,7 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
10
10
  starterRef: provenance.starterRef,
11
11
  ...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
12
12
  ...(provenance.framework ? { framework: provenance.framework } : {}),
13
+ ...(provenance.modules ? { modules: provenance.modules } : {}),
13
14
  };
14
15
  for (const script of ["docker:build", "docker:start"]) {
15
16
  if (typeof manifest.scripts?.[script] === "string") {
@@ -23,9 +24,12 @@ export function customizeReadme(source, { displayName }, provenance) {
23
24
  ? `\n- Starter commit: \`${provenance.starterCommit}\``
24
25
  : "";
25
26
  const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
27
+ const modulesLine = provenance.moduleSummary?.length
28
+ ? `\n- Modules: ${provenance.moduleSummary.join(", ")}`
29
+ : "";
26
30
  const origin = `## Generated from\n\n` +
27
31
  `- Starter repository: \`${provenance.starterRepository}\`\n` +
28
- `- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}\n` +
32
+ `- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}${modulesLine}\n` +
29
33
  `- CLI version: \`${provenance.cliVersion}\``;
30
34
  // Only the first heading is the project title; leave every other line as written.
31
35
  return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
@@ -67,29 +71,152 @@ export function customizeAppManifest(source, appPackage) {
67
71
  * works. Importers are keyed by workspace path at two-space indentation; package entries that
68
72
  * are no longer used are harmless, and pnpm prunes them on the next lockfile update.
69
73
  */
70
- export function customizeLockfile(source, { remove, rename }) {
74
+ export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
71
75
  const output = [];
72
76
  let inImporters = false;
73
- let skipping = false;
77
+ let skippingImporter = false;
78
+ let skippingDependency = false;
79
+ // A dependency group header (" dependencies:") is held until it gets an entry, so a group
80
+ // whose entries were all removed disappears instead of becoming an empty YAML value.
81
+ let pendingGroup = null;
74
82
  for (const line of source.split("\n")) {
75
83
  if (/^\S/.test(line)) {
76
84
  inImporters = line === "importers:";
77
- skipping = false;
85
+ skippingImporter = skippingDependency = false;
86
+ pendingGroup = null;
87
+ output.push(line);
88
+ continue;
78
89
  }
79
- else if (inImporters) {
80
- // An importer key, with its value on the next lines or inline ("packages/core: {}").
81
- const key = /^ {2}([^\s:][^:]*):(?: |$)/.exec(line)?.[1];
82
- if (key !== undefined) {
83
- skipping = remove.includes(key);
84
- if (!skipping && key === rename?.from) {
85
- output.push(` ${rename.to}:`);
86
- continue;
87
- }
88
- }
89
- if (skipping)
90
+ if (!inImporters) {
91
+ output.push(line);
92
+ continue;
93
+ }
94
+ // An importer key, with its value on the next lines or inline ("packages/core: {}").
95
+ const importer = /^ {2}([^\s:][^:]*):(?: |$)/.exec(line)?.[1];
96
+ if (importer !== undefined) {
97
+ skippingImporter = remove.includes(importer);
98
+ skippingDependency = false;
99
+ pendingGroup = null;
100
+ if (!skippingImporter)
101
+ output.push(importer === rename?.from ? ` ${rename.to}:` : line);
102
+ continue;
103
+ }
104
+ if (skippingImporter)
105
+ continue;
106
+ if (/^ {4}\S/.test(line)) {
107
+ skippingDependency = false;
108
+ pendingGroup = line;
109
+ continue;
110
+ }
111
+ const dependency = /^ {6}(['"]?)([^'":]+)\1:\s*$/.exec(line)?.[2];
112
+ if (dependency !== undefined) {
113
+ skippingDependency = removeDependencies.includes(dependency);
114
+ if (skippingDependency)
90
115
  continue;
116
+ if (pendingGroup !== null) {
117
+ output.push(pendingGroup);
118
+ pendingGroup = null;
119
+ }
120
+ output.push(line);
121
+ continue;
91
122
  }
123
+ if (skippingDependency && /^ {8}/.test(line))
124
+ continue;
125
+ if (pendingGroup !== null && line.trim() !== "") {
126
+ output.push(pendingGroup);
127
+ pendingGroup = null;
128
+ }
129
+ skippingDependency = false;
92
130
  output.push(line);
93
131
  }
94
132
  return output.join("\n");
95
133
  }
134
+ export class ConditionalError extends Error {
135
+ name = "ConditionalError";
136
+ }
137
+ // A marker line in any comment style the Starter uses: //, #, %% (Mermaid), <!-- -->, /* */,
138
+ // or {/* */}.
139
+ const MARKER = /^\s*(?:\/\/|#|%%|<!--|\{?\/\*)\s*clubedge:(if|end)\b\s*(.*?)\s*(?:-->|\*\/\}?)?\s*$/;
140
+ // A single line kept only when its condition holds: "- Item <!-- clubedge:only auth=supabase -->".
141
+ const LINE_MARKER = /\s*(?:\/\/|#|<!--|\{?\/\*)\s*clubedge:only\s+(.+?)\s*(?:-->|\*\/\}?)?\s*$/;
142
+ function evaluateCondition(expression, selection, location) {
143
+ // Maintainer-only sections of the Starter repository never reach a generated project.
144
+ if (expression.trim() === "starter-repository")
145
+ return false;
146
+ return expression.split("&&").every((part) => {
147
+ const match = /^\s*([a-z][a-z0-9-]*)\s*(!?=)\s*([a-z0-9|-]+)\s*$/.exec(part);
148
+ if (!match)
149
+ throw new ConditionalError(`${location}: cannot read the condition "${expression}".`);
150
+ const [, key, operator, values] = match;
151
+ const selected = selection[key];
152
+ if (selected === undefined)
153
+ throw new ConditionalError(`${location}: unknown key "${key}".`);
154
+ const matches = values.split("|").includes(selected);
155
+ return operator === "=" ? matches : !matches;
156
+ });
157
+ }
158
+ /**
159
+ * Keeps or drops `clubedge:if <condition>` ... `clubedge:end` blocks for the selection and
160
+ * always removes the marker lines. Conditions look like `auth=none`, `storage!=none`,
161
+ * `auth=supabase|better-auth`, or several joined with `&&`. Blocks do not nest.
162
+ */
163
+ export function applyConditionals(source, selection, path = "file") {
164
+ const output = [];
165
+ let open = null;
166
+ let removed = false;
167
+ source.split("\n").forEach((line, index) => {
168
+ const marker = MARKER.exec(line);
169
+ const location = `${path}:${index + 1}`;
170
+ if (marker?.[1] === "if") {
171
+ if (open)
172
+ throw new ConditionalError(`${location}: clubedge:if blocks cannot nest.`);
173
+ open = { keep: evaluateCondition(marker[2] ?? "", selection, location), line: index + 1 };
174
+ removed = removed || !open.keep;
175
+ return;
176
+ }
177
+ if (marker?.[1] === "end") {
178
+ if (!open)
179
+ throw new ConditionalError(`${location}: clubedge:end without clubedge:if.`);
180
+ open = null;
181
+ return;
182
+ }
183
+ if (open && !open.keep)
184
+ return;
185
+ const lineMarker = LINE_MARKER.exec(line);
186
+ if (lineMarker && line.trim() !== lineMarker[0].trim()) {
187
+ if (!evaluateCondition(lineMarker[1], selection, location)) {
188
+ removed = true;
189
+ return;
190
+ }
191
+ output.push(line.slice(0, lineMarker.index));
192
+ return;
193
+ }
194
+ output.push(line);
195
+ });
196
+ if (open)
197
+ throw new ConditionalError(`${path}:${open.line}: clubedge:if is never closed.`);
198
+ const text = output.join("\n");
199
+ if (!removed)
200
+ return text;
201
+ // A removed block can leave two blank lines in a row, or blank lines at the start or end of
202
+ // the file; formatted sources never have them.
203
+ const collapsed = text.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
204
+ return source.endsWith("\n") ? collapsed.replace(/\n+$/, "\n") : collapsed;
205
+ }
206
+ /** Removes dependencies on workspace packages that were left out of the project. */
207
+ export function removePackageDependencies(source, names) {
208
+ const manifest = JSON.parse(source);
209
+ let changed = false;
210
+ for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
211
+ const dependencies = manifest[field];
212
+ if (!dependencies || typeof dependencies !== "object")
213
+ continue;
214
+ for (const name of names) {
215
+ if (name in dependencies) {
216
+ delete dependencies[name];
217
+ changed = true;
218
+ }
219
+ }
220
+ }
221
+ return changed ? json(manifest) : source;
222
+ }
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  export const MANIFEST_FILE = "clubedge.template.json";
4
- export const SUPPORTED_SCHEMA_VERSION = 2;
4
+ export const SUPPORTED_SCHEMA_VERSION = 3;
5
5
  /** Schema 1 Starters ship a single Next.js app at the generated location. */
6
6
  function singleNextFramework(app, envExample) {
7
7
  return {
@@ -19,6 +19,8 @@ export const legacyManifest = {
19
19
  env: { example: ".env.example", target: "apps/web/.env.local" },
20
20
  dockerImage: "clubedge-starter",
21
21
  ...singleNextFramework("apps/web", ".env.example"),
22
+ modules: {},
23
+ conditional: [],
22
24
  exclude: [],
23
25
  };
24
26
  export class ManifestError extends Error {
@@ -63,9 +65,96 @@ export function parseTemplateManifest(source) {
63
65
  env: { example: envExample, target: requireString(env, "target", "env.") },
64
66
  dockerImage: requireString(data, "dockerImage"),
65
67
  ...(schemaVersion >= 2 ? parseFrameworks(data) : singleNextFramework(app, envExample)),
68
+ modules: schemaVersion >= 3 ? parseModules(data.modules) : {},
69
+ conditional: schemaVersion >= 3 ? optionalStringArray(data, "conditional") : [],
66
70
  exclude: data.exclude ?? [],
67
71
  };
68
72
  }
73
+ const ID = /^[a-z][a-z0-9-]*$/;
74
+ function parseModules(value) {
75
+ if (value === undefined)
76
+ return {};
77
+ if (!isRecord(value))
78
+ throw new ManifestError(`${MANIFEST_FILE}: modules must be an object.`);
79
+ const modules = {};
80
+ for (const [id, entry] of Object.entries(value)) {
81
+ if (!ID.test(id) || id === "framework") {
82
+ throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid module id.`);
83
+ }
84
+ if (!isRecord(entry))
85
+ throw new ManifestError(`${MANIFEST_FILE}: modules.${id} must be an object.`);
86
+ const prefix = `modules.${id}.`;
87
+ const optionsValue = entry.options;
88
+ if (!isRecord(optionsValue) || Object.keys(optionsValue).length === 0) {
89
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}options must describe at least one option.`);
90
+ }
91
+ const options = {};
92
+ for (const [optionId, option] of Object.entries(optionsValue)) {
93
+ const optionPrefix = `${prefix}options.${optionId}.`;
94
+ if (!ID.test(optionId)) {
95
+ throw new ManifestError(`${MANIFEST_FILE}: "${optionId}" is not a valid option id in ${prefix.slice(0, -1)}.`);
96
+ }
97
+ if (!isRecord(option))
98
+ throw new ManifestError(`${MANIFEST_FILE}: ${optionPrefix.slice(0, -1)} must be an object.`);
99
+ options[optionId] = {
100
+ name: requireString(option, "name", optionPrefix),
101
+ packages: optionalStringRecord(option, "packages", optionPrefix),
102
+ files: optionalStringArray(option, "files", optionPrefix),
103
+ replace: optionalStringRecord(option, "replace", optionPrefix),
104
+ requires: parseRequires(option.requires, optionPrefix),
105
+ };
106
+ }
107
+ const defaultOption = requireString(entry, "default", prefix);
108
+ if (!options[defaultOption]) {
109
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}default "${defaultOption}" is not in its options.`);
110
+ }
111
+ modules[id] = { name: requireString(entry, "name", prefix), default: defaultOption, options };
112
+ }
113
+ // Requirements must name real modules and options, or a selection could never be valid.
114
+ for (const [id, module] of Object.entries(modules)) {
115
+ for (const [optionId, option] of Object.entries(module.options)) {
116
+ for (const [requiredModule, allowed] of Object.entries(option.requires)) {
117
+ const unknown = allowed.find((value) => !modules[requiredModule]?.options[value]);
118
+ if (unknown !== undefined) {
119
+ throw new ManifestError(`${MANIFEST_FILE}: modules.${id}.options.${optionId}.requires names unknown option ${requiredModule}=${unknown}.`);
120
+ }
121
+ }
122
+ }
123
+ }
124
+ return modules;
125
+ }
126
+ function parseRequires(value, prefix) {
127
+ if (value === undefined)
128
+ return {};
129
+ if (!isRecord(value))
130
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}requires must be an object.`);
131
+ const requires = {};
132
+ for (const [module, allowed] of Object.entries(value)) {
133
+ const list = typeof allowed === "string" ? [allowed] : allowed;
134
+ if (!isStringArray(list) || list.length === 0) {
135
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}requires.${module} must name one or more options.`);
136
+ }
137
+ requires[module] = list;
138
+ }
139
+ return requires;
140
+ }
141
+ function optionalStringArray(record, key, prefix = "") {
142
+ const value = record[key];
143
+ if (value === undefined)
144
+ return [];
145
+ if (!isStringArray(value))
146
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must be an array of paths.`);
147
+ return value;
148
+ }
149
+ function optionalStringRecord(record, key, prefix = "") {
150
+ const value = record[key];
151
+ if (value === undefined)
152
+ return {};
153
+ if (!isRecord(value) || !Object.values(value).every((item) => typeof item === "string" && item)) {
154
+ throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must map names to paths.`);
155
+ }
156
+ return value;
157
+ }
69
158
  function parseFrameworks(data) {
70
159
  const { frameworks } = data;
71
160
  if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
@@ -73,7 +162,7 @@ function parseFrameworks(data) {
73
162
  }
74
163
  const parsed = {};
75
164
  for (const [id, entry] of Object.entries(frameworks)) {
76
- if (!/^[a-z][a-z0-9-]*$/.test(id)) {
165
+ if (!ID.test(id)) {
77
166
  throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid framework id.`);
78
167
  }
79
168
  if (!isRecord(entry))