@clubedge/create-clubedge-app 0.3.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 (143) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +49 -13
  3. package/dist/cli/args.js +20 -0
  4. package/dist/cli/ui.js +6 -0
  5. package/dist/core/customize.js +167 -1
  6. package/dist/core/manifest.js +130 -3
  7. package/dist/core/plan.js +203 -3
  8. package/dist/index.js +91 -7
  9. package/dist/utils/fs.js +3 -0
  10. package/package.json +5 -4
  11. package/template/.dockerignore +3 -0
  12. package/template/.env.example +19 -6
  13. package/template/.github/workflows/ci.yml +34 -4
  14. package/template/.prettierignore +1 -0
  15. package/template/AGENTS.md +11 -3
  16. package/template/CONTRIBUTING.md +2 -2
  17. package/template/README.md +73 -54
  18. package/template/SETUP.md +112 -40
  19. package/template/_gitignore +6 -0
  20. package/template/apps/start/.env.example +40 -0
  21. package/template/apps/start/AGENTS.md +11 -0
  22. package/template/apps/start/Dockerfile +38 -0
  23. package/template/apps/start/components.json +21 -0
  24. package/template/apps/start/package.json +43 -0
  25. package/template/apps/start/public/.well-known/logo.svg +1 -0
  26. package/template/apps/start/public/favicon.ico +0 -0
  27. package/template/apps/start/src/components/auth/auth-form-controls.tsx +108 -0
  28. package/template/apps/start/src/components/branding/clubedge-mark.tsx +24 -0
  29. package/template/apps/start/src/components/dashboard/account-button.tsx +33 -0
  30. package/template/apps/start/src/components/dashboard/app-sidebar.tsx +90 -0
  31. package/template/apps/start/src/components/dashboard/variants/account-button.none.tsx +9 -0
  32. package/template/apps/start/src/components/landing/copy-command.tsx +46 -0
  33. package/template/apps/start/src/components/theme/theme-provider.tsx +41 -0
  34. package/template/apps/start/src/components/theme/theme-toggle.tsx +21 -0
  35. package/template/apps/start/src/config/site.json +11 -0
  36. package/template/apps/start/src/config/site.ts +20 -0
  37. package/template/apps/start/src/env/auth.test.ts +26 -0
  38. package/template/apps/start/src/env/auth.ts +46 -0
  39. package/template/apps/start/src/env/server.test.ts +32 -0
  40. package/template/apps/start/src/env/server.ts +43 -0
  41. package/template/apps/start/src/lib/login-url.test.ts +16 -0
  42. package/template/apps/start/src/lib/login-url.ts +16 -0
  43. package/template/apps/start/src/router.tsx +27 -0
  44. package/template/apps/start/src/routes/__root.tsx +59 -0
  45. package/template/apps/start/src/routes/api/health.ts +15 -0
  46. package/template/apps/start/src/routes/auth/callback.ts +10 -0
  47. package/template/apps/start/src/routes/auth/sign-in.ts +11 -0
  48. package/template/apps/start/src/routes/auth/sign-out.ts +11 -0
  49. package/template/apps/start/src/routes/auth/sign-up.ts +11 -0
  50. package/template/apps/start/src/routes/dashboard.tsx +361 -0
  51. package/template/apps/start/src/routes/index.tsx +237 -0
  52. package/template/apps/start/src/routes/login.tsx +164 -0
  53. package/template/apps/start/src/server/auth-forms.test.ts +169 -0
  54. package/template/apps/start/src/server/auth-forms.ts +118 -0
  55. package/template/apps/start/src/server/auth.functions.ts +27 -0
  56. package/template/apps/start/src/server/auth.ts +51 -0
  57. package/template/apps/start/src/server/cache.test.ts +65 -0
  58. package/template/apps/start/src/server/cache.ts +58 -0
  59. package/template/apps/start/src/server/cookies.test.ts +41 -0
  60. package/template/apps/start/src/server/cookies.ts +31 -0
  61. package/template/apps/start/src/server/db.ts +16 -0
  62. package/template/apps/start/src/server/storage.ts +42 -0
  63. package/template/apps/start/src/server/users.ts +17 -0
  64. package/template/apps/start/src/server/variants/auth.better-auth.ts +57 -0
  65. package/template/apps/start/src/server/variants/auth.functions.none.ts +9 -0
  66. package/template/apps/start/src/server/variants/auth.none.ts +28 -0
  67. package/template/apps/start/src/server/variants/cache.memory.ts +26 -0
  68. package/template/apps/start/src/server/variants/storage.supabase.ts +35 -0
  69. package/template/apps/start/src/start.ts +40 -0
  70. package/template/apps/start/src/styles.css +2 -0
  71. package/template/apps/start/tsconfig.json +19 -0
  72. package/template/apps/start/tsr.config.json +7 -0
  73. package/template/apps/start/vite.config.ts +27 -0
  74. package/template/apps/web/next.config.ts +9 -9
  75. package/template/apps/web/package.json +7 -2
  76. package/template/{scripts → apps/web/scripts}/start-standalone.mjs +1 -2
  77. package/template/apps/web/src/app/dashboard/_components/account-button.tsx +1 -1
  78. package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +1 -1
  79. package/template/apps/web/src/app/dashboard/_components/variants/account-button.none.tsx +9 -0
  80. package/template/apps/web/src/app/dashboard/page.tsx +18 -8
  81. package/template/apps/web/src/app/login/page.tsx +5 -4
  82. package/template/apps/web/src/app/page.tsx +7 -3
  83. package/template/apps/web/src/env/auth.ts +48 -0
  84. package/template/apps/web/src/env/client.ts +1 -8
  85. package/template/apps/web/src/env/server.test.ts +27 -4
  86. package/template/apps/web/src/env/server.ts +19 -29
  87. package/template/apps/web/src/proxy.ts +5 -8
  88. package/template/apps/web/src/server/auth.ts +14 -26
  89. package/template/apps/web/src/server/cache.ts +36 -16
  90. package/template/apps/web/src/server/cookies.ts +18 -0
  91. package/template/apps/web/src/server/storage.ts +22 -19
  92. package/template/apps/web/src/server/variants/auth.better-auth.ts +63 -0
  93. package/template/apps/web/src/server/variants/auth.none.ts +26 -0
  94. package/template/apps/web/src/server/variants/cache.memory.ts +26 -0
  95. package/template/apps/web/src/server/variants/storage.supabase.ts +35 -0
  96. package/template/apps/web/src/variants/proxy.better-auth.ts +46 -0
  97. package/template/clubedge.template.json +173 -2
  98. package/template/e2e/app.ts +12 -0
  99. package/template/e2e/auth-flow.spec.ts +29 -0
  100. package/template/{apps/web/e2e → e2e}/auth.spec.ts +2 -12
  101. package/template/e2e/home.spec.ts +24 -0
  102. package/template/eslint.config.mjs +6 -1
  103. package/template/package.json +6 -6
  104. package/template/packages/auth/package.json +3 -6
  105. package/template/packages/auth-better-auth/drizzle/0000_initial.sql +50 -0
  106. package/template/packages/auth-better-auth/drizzle/meta/0000_snapshot.json +320 -0
  107. package/template/packages/auth-better-auth/drizzle/meta/_journal.json +13 -0
  108. package/template/packages/auth-better-auth/drizzle.config.ts +12 -0
  109. package/template/packages/auth-better-auth/package.json +31 -0
  110. package/template/packages/auth-better-auth/scripts/migrate.ts +14 -0
  111. package/template/packages/auth-better-auth/src/index.test.ts +118 -0
  112. package/template/packages/auth-better-auth/src/index.ts +135 -0
  113. package/template/packages/auth-better-auth/src/schema.ts +74 -0
  114. package/template/packages/auth-better-auth/tsconfig.json +4 -0
  115. package/template/packages/auth-supabase/package.json +24 -0
  116. package/template/packages/{auth/src/supabase.test.ts → auth-supabase/src/index.test.ts} +2 -2
  117. package/template/packages/{auth/src/supabase.ts → auth-supabase/src/index.ts} +1 -1
  118. package/template/packages/auth-supabase/tsconfig.json +4 -0
  119. package/template/packages/cache/package.json +1 -4
  120. package/template/packages/cache/src/index.ts +3 -13
  121. package/template/packages/cache/src/memory-cache.test.ts +40 -0
  122. package/template/packages/cache/src/memory-cache.ts +37 -0
  123. package/template/packages/cache-redis/package.json +22 -0
  124. package/template/packages/{cache → cache-redis}/src/index.test.ts +6 -15
  125. package/template/packages/{cache/src/redis.ts → cache-redis/src/index.ts} +2 -6
  126. package/template/packages/cache-redis/tsconfig.json +4 -0
  127. package/template/packages/core/package.json +1 -1
  128. package/template/packages/db/package.json +3 -2
  129. package/template/packages/storage/package.json +2 -9
  130. package/template/packages/storage-s3/package.json +23 -0
  131. package/template/packages/{storage/src/s3.test.ts → storage-s3/src/index.test.ts} +1 -1
  132. package/template/packages/{storage/src/s3.ts → storage-s3/src/index.ts} +1 -1
  133. package/template/packages/storage-s3/tsconfig.json +4 -0
  134. package/template/packages/storage-supabase/package.json +22 -0
  135. package/template/packages/{storage/src/supabase.ts → storage-supabase/src/index.ts} +1 -1
  136. package/template/packages/storage-supabase/tsconfig.json +4 -0
  137. package/template/packages/ui/package.json +1 -1
  138. package/template/playwright.config.ts +6 -2
  139. package/template/pnpm-lock.yaml +1912 -27
  140. package/template/turbo.json +1 -1
  141. package/template/vitest.config.ts +2 -0
  142. package/template.lock.json +96 -12
  143. package/template/apps/web/e2e/home.spec.ts +0 -15
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
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
+
14
+ ## 0.4.0
15
+
16
+ - Default to Starter `v0.4.0`, which adds the TanStack Start app and template schema 2.
17
+ - Add `--framework next|tanstack-start`, and ask which framework to use when the Starter offers several. Next.js stays the default.
18
+ - Read template schema 2, which describes each framework's app, environment example, and Dockerfile. The selected app is moved to `apps/web`, the others are left out, and `pnpm-lock.yaml` is kept in step so frozen installs work.
19
+ - Record the chosen framework in the generated `package.json` and `README.md`.
20
+ - Test generated projects for both frameworks in CI and in the release smoke test.
21
+
5
22
  ## 0.3.0
6
23
 
7
24
  - Default to Starter `v0.3.0`, which adds the `clubedge.template.json` template manifest.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/Clubedge/create-clubedge-app/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Clubedge/create-clubedge-app/actions/workflows/ci.yml)
4
4
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
5
5
 
6
- Create a Next.js application from the [Clubedge Starter](https://github.com/Clubedge/clubedge-starter) reference implementation.
6
+ Create a Next.js or TanStack Start application from the [Clubedge Starter](https://github.com/Clubedge/clubedge-starter) reference implementation.
7
7
 
8
8
  The CLI downloads a tested Starter release, names the generated project, creates `apps/web/.env.local` from the example, initializes Git, and installs dependencies. The Starter remains the source of truth for the generated architecture.
9
9
 
@@ -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.3.0
15
+ create-clubedge-app v0.5.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.3.0
19
- commit 60f1691920c91a5cf83e480a3ce98c3ddefdef55
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.3.0` | `clubedge-starter@v0.3.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.3.0` tag is expected to resolve to commit
31
- `60f1691920c91a5cf83e480a3ce98c3ddefdef55`. 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.3.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
 
@@ -68,11 +68,47 @@ When no destination is provided, the CLI asks where to create the project:
68
68
  pnpm dlx @clubedge/create-clubedge-app
69
69
  ```
70
70
 
71
+ ### Choosing a framework
72
+
73
+ The Starter ships the same application for two React frameworks. Next.js is the default; pick TanStack Start with `--framework`, or choose interactively when the Starter offers more than one:
74
+
75
+ ```sh
76
+ pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
77
+ ```
78
+
79
+ | `--framework` | App | Production output |
80
+ | ---------------- | --------------------------- | ----------------------------- |
81
+ | `next` | Next.js App Router | Next.js standalone build |
82
+ | `tanstack-start` | TanStack Start with Vite | Nitro `.output` server bundle |
83
+
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
+
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
+
71
103
  ## Options
72
104
 
73
105
  ```text
74
106
  Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
75
107
 
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
76
112
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
77
113
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
78
114
  --dry-run Show what would be created without writing anything
@@ -85,20 +121,20 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
85
121
 
86
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.
87
123
 
88
- Every generated project records its CLI version, Starter repository, Starter ref, and Starter commit 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`.
89
125
 
90
126
  ```sh
91
127
  # Preview the files and edits without writing anything
92
128
  pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
93
129
 
94
130
  # Non-interactive, for scripts and CI
95
- pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git
131
+ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstack-start
96
132
 
97
133
  # Try an unreleased Starter change
98
134
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
99
135
 
100
136
  # Reproduce a specific Starter release or commit
101
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.3.0
137
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.5.0
102
138
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
103
139
 
104
140
  # Test local Starter changes before releasing them
@@ -109,7 +145,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
109
145
 
110
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.
111
147
 
112
- 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. 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.
113
149
 
114
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:
115
151
 
@@ -143,7 +179,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
143
179
  1. Make and validate changes in `clubedge-starter`.
144
180
  2. Run lint, typecheck, unit tests, browser tests, and production build.
145
181
  3. Update the Starter package versions.
146
- 4. Commit `main`, create a release tag such as `v0.3.0`, and publish the Starter release.
182
+ 4. Commit `main`, create a release tag such as `v0.5.0`, and publish the Starter release.
147
183
 
148
184
  ### CLI maintainers
149
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
  }
@@ -11,6 +13,10 @@ export function parseCliArgs(argv) {
11
13
  allowNegative: true,
12
14
  strict: true,
13
15
  options: {
16
+ framework: { type: "string" },
17
+ auth: { type: "string" },
18
+ storage: { type: "string" },
19
+ cache: { type: "string" },
14
20
  ref: { type: "string" },
15
21
  "template-dir": { type: "string" },
16
22
  install: { type: "boolean", default: true },
@@ -29,6 +35,18 @@ export function parseCliArgs(argv) {
29
35
  const { values, positionals } = parsed;
30
36
  if (positionals.length > 1)
31
37
  throw new UsageError("Only one project directory can be provided.");
38
+ if (values.framework !== undefined && !values.framework.trim()) {
39
+ throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
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
+ }
32
50
  if (values.ref !== undefined && !values.ref.trim()) {
33
51
  throw new UsageError("--ref requires a tag, branch, or commit.");
34
52
  }
@@ -37,6 +55,8 @@ export function parseCliArgs(argv) {
37
55
  }
38
56
  return {
39
57
  projectDirectory: positionals[0],
58
+ framework: values.framework?.trim(),
59
+ modules,
40
60
  ref: values.ref,
41
61
  templateDir: values["template-dir"],
42
62
  install: values.install,
package/dist/cli/ui.js CHANGED
@@ -63,6 +63,10 @@ Usage:
63
63
  pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
64
64
 
65
65
  Options:
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
66
70
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
67
71
  --template-dir <path> Scaffold from a local Starter checkout (for Starter development)
68
72
  --dry-run Show what would be created without writing anything
@@ -74,6 +78,8 @@ Options:
74
78
 
75
79
  Examples:
76
80
  pnpm dlx @clubedge/create-clubedge-app my-app
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
77
83
  pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
78
84
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
79
85
  pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
@@ -9,6 +9,8 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
9
9
  starterRepository: provenance.starterRepository,
10
10
  starterRef: provenance.starterRef,
11
11
  ...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
12
+ ...(provenance.framework ? { framework: provenance.framework } : {}),
13
+ ...(provenance.modules ? { modules: provenance.modules } : {}),
12
14
  };
13
15
  for (const script of ["docker:build", "docker:start"]) {
14
16
  if (typeof manifest.scripts?.[script] === "string") {
@@ -21,9 +23,13 @@ export function customizeReadme(source, { displayName }, provenance) {
21
23
  const commitLine = provenance.starterCommit
22
24
  ? `\n- Starter commit: \`${provenance.starterCommit}\``
23
25
  : "";
26
+ const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
27
+ const modulesLine = provenance.moduleSummary?.length
28
+ ? `\n- Modules: ${provenance.moduleSummary.join(", ")}`
29
+ : "";
24
30
  const origin = `## Generated from\n\n` +
25
31
  `- Starter repository: \`${provenance.starterRepository}\`\n` +
26
- `- Starter ref: \`${provenance.starterRef}\`${commitLine}\n` +
32
+ `- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}${modulesLine}\n` +
27
33
  `- CLI version: \`${provenance.cliVersion}\``;
28
34
  // Only the first heading is the project title; leave every other line as written.
29
35
  return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
@@ -54,3 +60,163 @@ export function customizeLegacySource(source, { displayName }) {
54
60
  .replaceAll(">Clubedge<", `>${displayName}<`)
55
61
  .replaceAll("Starter workspace", "Application workspace");
56
62
  }
63
+ /** Gives the selected framework's app the package name generated projects use. */
64
+ export function customizeAppManifest(source, appPackage) {
65
+ const manifest = JSON.parse(source);
66
+ manifest.name = appPackage;
67
+ return json(manifest);
68
+ }
69
+ /**
70
+ * Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
71
+ * works. Importers are keyed by workspace path at two-space indentation; package entries that
72
+ * are no longer used are harmless, and pnpm prunes them on the next lockfile update.
73
+ */
74
+ export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
75
+ const output = [];
76
+ let inImporters = 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;
82
+ for (const line of source.split("\n")) {
83
+ if (/^\S/.test(line)) {
84
+ inImporters = line === "importers:";
85
+ skippingImporter = skippingDependency = false;
86
+ pendingGroup = null;
87
+ output.push(line);
88
+ continue;
89
+ }
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)
115
+ continue;
116
+ if (pendingGroup !== null) {
117
+ output.push(pendingGroup);
118
+ pendingGroup = null;
119
+ }
120
+ output.push(line);
121
+ continue;
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;
130
+ output.push(line);
131
+ }
132
+ return output.join("\n");
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,14 @@
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 = 1;
4
+ export const SUPPORTED_SCHEMA_VERSION = 3;
5
+ /** Schema 1 Starters ship a single Next.js app at the generated location. */
6
+ function singleNextFramework(app, envExample) {
7
+ return {
8
+ defaultFramework: "next",
9
+ frameworks: { next: { name: "Next.js", app, envExample, dockerfile: "Dockerfile" } },
10
+ };
11
+ }
5
12
  /** Starter releases before v0.3.0 have no manifest; this describes their layout. */
6
13
  export const legacyManifest = {
7
14
  schemaVersion: 1,
@@ -11,6 +18,9 @@ export const legacyManifest = {
11
18
  siteConfig: "apps/web/src/config/site.json",
12
19
  env: { example: ".env.example", target: "apps/web/.env.local" },
13
20
  dockerImage: "clubedge-starter",
21
+ ...singleNextFramework("apps/web", ".env.example"),
22
+ modules: {},
23
+ conditional: [],
14
24
  exclude: [],
15
25
  };
16
26
  export class ManifestError extends Error {
@@ -43,17 +53,134 @@ export function parseTemplateManifest(source) {
43
53
  if (data.exclude !== undefined && !isStringArray(data.exclude)) {
44
54
  throw new ManifestError(`${MANIFEST_FILE}: exclude must be an array of paths.`);
45
55
  }
56
+ const app = requireString(data, "app");
57
+ const envExample = requireString(env, "example", "env.");
46
58
  return {
47
59
  schemaVersion,
48
60
  name: requireString(data, "name"),
49
61
  packageManager: "pnpm",
50
- app: requireString(data, "app"),
62
+ app,
63
+ ...(schemaVersion >= 2 ? { appPackage: requireString(data, "appPackage") } : {}),
51
64
  siteConfig: requireString(data, "siteConfig"),
52
- env: { example: requireString(env, "example", "env."), target: requireString(env, "target", "env.") },
65
+ env: { example: envExample, target: requireString(env, "target", "env.") },
53
66
  dockerImage: requireString(data, "dockerImage"),
67
+ ...(schemaVersion >= 2 ? parseFrameworks(data) : singleNextFramework(app, envExample)),
68
+ modules: schemaVersion >= 3 ? parseModules(data.modules) : {},
69
+ conditional: schemaVersion >= 3 ? optionalStringArray(data, "conditional") : [],
54
70
  exclude: data.exclude ?? [],
55
71
  };
56
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
+ }
158
+ function parseFrameworks(data) {
159
+ const { frameworks } = data;
160
+ if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
161
+ throw new ManifestError(`${MANIFEST_FILE}: frameworks must describe at least one framework.`);
162
+ }
163
+ const parsed = {};
164
+ for (const [id, entry] of Object.entries(frameworks)) {
165
+ if (!ID.test(id)) {
166
+ throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid framework id.`);
167
+ }
168
+ if (!isRecord(entry))
169
+ throw new ManifestError(`${MANIFEST_FILE}: frameworks.${id} must be an object.`);
170
+ const prefix = `frameworks.${id}.`;
171
+ parsed[id] = {
172
+ name: requireString(entry, "name", prefix),
173
+ app: requireString(entry, "app", prefix),
174
+ envExample: requireString(entry, "envExample", prefix),
175
+ dockerfile: requireString(entry, "dockerfile", prefix),
176
+ };
177
+ }
178
+ const defaultFramework = requireString(data, "defaultFramework");
179
+ if (!parsed[defaultFramework]) {
180
+ throw new ManifestError(`${MANIFEST_FILE}: defaultFramework "${defaultFramework}" is not in frameworks.`);
181
+ }
182
+ return { defaultFramework, frameworks: parsed };
183
+ }
57
184
  export async function readTemplateManifest(templateRoot) {
58
185
  let source;
59
186
  try {