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