@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/dist/core/plan.js CHANGED
@@ -1,16 +1,164 @@
1
- import { customizeLegacySource, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
1
+ import { applyConditionals, customizeAppManifest, customizeLegacySource, customizeLockfile, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, removePackageDependencies, } from "./customize.js";
2
+ export class FrameworkError extends Error {
3
+ name = "FrameworkError";
4
+ }
5
+ export class ModuleError extends Error {
6
+ name = "ModuleError";
7
+ }
8
+ const LOCKFILE = "pnpm-lock.yaml";
9
+ const isWithin = (path, directory) => path === directory || path.startsWith(`${directory.replace(/\/$/, "")}/`);
2
10
  function isExcluded(path, exclude) {
3
- return exclude.some((entry) => path === entry || path.startsWith(`${entry.replace(/\/$/, "")}/`));
11
+ return exclude.some((entry) => isWithin(path, entry));
12
+ }
13
+ /** Resolves the requested framework, or the default, with a helpful error for unknown ids. */
14
+ export function selectFramework(manifest, requested) {
15
+ const id = requested ?? manifest.defaultFramework;
16
+ const framework = manifest.frameworks[id];
17
+ if (!framework) {
18
+ const choices = Object.keys(manifest.frameworks).join(", ");
19
+ throw new FrameworkError(`Unknown framework "${id}". This Starter offers: ${choices}.`);
20
+ }
21
+ return { id, ...framework };
22
+ }
23
+ /**
24
+ * Resolves one option per module from the request and the defaults, then checks every
25
+ * option's requirements, so an invalid combination fails before anything is written.
26
+ */
27
+ export function selectModules(manifest, requested = {}) {
28
+ const unknownModule = Object.keys(requested).find((id) => !manifest.modules[id]);
29
+ if (unknownModule !== undefined) {
30
+ const offered = Object.keys(manifest.modules);
31
+ throw new ModuleError(offered.length
32
+ ? `Unknown module "${unknownModule}". This Starter offers: ${offered.join(", ")}.`
33
+ : `This Starter does not offer module choices; remove --${unknownModule}.`);
34
+ }
35
+ const selection = {};
36
+ for (const [id, module] of Object.entries(manifest.modules)) {
37
+ const option = requested[id] ?? module.default;
38
+ if (!module.options[option]) {
39
+ throw new ModuleError(`Unknown ${id} option "${option}". Choose one of: ${Object.keys(module.options).join(", ")}.`);
40
+ }
41
+ selection[id] = option;
42
+ }
43
+ for (const [id, option] of Object.entries(selection)) {
44
+ const { name, requires } = manifest.modules[id].options[option];
45
+ for (const [requiredModule, allowed] of Object.entries(requires)) {
46
+ if (!allowed.includes(selection[requiredModule])) {
47
+ const needed = allowed
48
+ .map((value) => `${manifest.modules[requiredModule].options[value].name} (--${requiredModule} ${value})`)
49
+ .join(" or ");
50
+ throw new ModuleError(`${name} needs ${needed}.`);
51
+ }
52
+ }
53
+ }
54
+ return selection;
55
+ }
56
+ /**
57
+ * Where a Starter file lands in the generated project, or null when it belongs to a framework
58
+ * that was not selected. The selected app moves to `manifest.app`, and its environment example
59
+ * and Dockerfile move to the generated project's locations.
60
+ */
61
+ function relocate(path, manifest, selected, others) {
62
+ if (path === selected.envExample)
63
+ return manifest.env.example;
64
+ if (path === selected.dockerfile)
65
+ return "Dockerfile";
66
+ if (isWithin(path, selected.app))
67
+ return manifest.app + path.slice(selected.app.length);
68
+ const belongsToOther = others.some((other) => isWithin(path, other.app) || path === other.envExample || path === other.dockerfile);
69
+ return belongsToOther ? null : path;
70
+ }
71
+ /** Works out which files, packages, and variants the selected module options keep. */
72
+ function resolveModuleFiles(manifest, selection) {
73
+ const ownedPaths = new Set();
74
+ const keptPaths = new Set();
75
+ const usedPackages = new Map();
76
+ const offeredPackages = new Map();
77
+ const variantSources = new Set();
78
+ /** Variant source path to the default file it replaces. */
79
+ const replacements = new Map();
80
+ for (const [id, module] of Object.entries(manifest.modules)) {
81
+ for (const [optionId, option] of Object.entries(module.options)) {
82
+ const selected = selection[id] === optionId;
83
+ for (const path of option.files) {
84
+ ownedPaths.add(path);
85
+ if (selected)
86
+ keptPaths.add(path);
87
+ }
88
+ for (const [name, directory] of Object.entries(option.packages)) {
89
+ offeredPackages.set(name, directory);
90
+ if (selected)
91
+ usedPackages.set(name, directory);
92
+ }
93
+ for (const [target, variant] of Object.entries(option.replace)) {
94
+ variantSources.add(variant);
95
+ if (selected)
96
+ replacements.set(variant, target);
97
+ }
98
+ }
99
+ }
100
+ const removedPackages = [...offeredPackages].filter(([name]) => !usedPackages.has(name));
101
+ return {
102
+ removedPackageNames: removedPackages.map(([name]) => name),
103
+ removedPackageDirectories: removedPackages.map(([, directory]) => directory),
104
+ replacedTargets: new Set(replacements.values()),
105
+ replacements,
106
+ variantSources,
107
+ isDroppedOwnedPath: (path) => [...ownedPaths].some((owned) => isWithin(path, owned)) &&
108
+ ![...keptPaths].some((kept) => isWithin(path, kept)),
109
+ };
4
110
  }
5
111
  export function createPlan(input) {
6
112
  const { manifest, identity, source } = input;
113
+ const framework = selectFramework(manifest, input.framework);
114
+ const selection = selectModules(manifest, input.modules);
115
+ const others = Object.entries(manifest.frameworks)
116
+ .filter(([id]) => id !== framework.id)
117
+ .map(([, other]) => other);
118
+ const multipleFrameworks = others.length > 0;
119
+ const moduleFiles = resolveModuleFiles(manifest, selection);
120
+ const modules = Object.entries(selection).map(([id, option]) => ({
121
+ id,
122
+ name: manifest.modules[id].name,
123
+ option,
124
+ optionName: manifest.modules[id].options[option].name,
125
+ }));
7
126
  const provenance = {
8
127
  cliVersion: input.cliVersion,
9
128
  starterRepository: source.repository,
10
129
  starterRef: source.ref,
11
130
  ...(source.commit ? { starterCommit: source.commit } : {}),
131
+ ...(multipleFrameworks ? { framework: framework.id, frameworkName: framework.name } : {}),
132
+ ...(modules.length
133
+ ? {
134
+ modules: Object.fromEntries(modules.map(({ id, option }) => [id, option])),
135
+ moduleSummary: modules.map(({ name, optionName }) => `${name}: ${optionName}`),
136
+ }
137
+ : {}),
12
138
  };
13
- const files = source.files.filter(({ to }) => !isExcluded(to, manifest.exclude) && to !== manifest.env.target);
139
+ const place = (path) => relocate(path, manifest, framework, others);
140
+ const files = [];
141
+ for (const file of source.files) {
142
+ const path = file.to;
143
+ if (isExcluded(path, manifest.exclude))
144
+ continue;
145
+ let target = path;
146
+ if (moduleFiles.variantSources.has(path)) {
147
+ // A variant is only ever copied over the default file it replaces.
148
+ const replaced = moduleFiles.replacements.get(path);
149
+ target = replaced === undefined ? null : place(replaced);
150
+ }
151
+ else if (moduleFiles.replacedTargets.has(path) ||
152
+ moduleFiles.isDroppedOwnedPath(path) ||
153
+ moduleFiles.removedPackageDirectories.some((directory) => isWithin(path, directory))) {
154
+ target = null;
155
+ }
156
+ else {
157
+ target = place(path);
158
+ }
159
+ if (target !== null && target !== manifest.env.target)
160
+ files.push({ from: file.from, to: target });
161
+ }
14
162
  const has = (path) => files.some(({ to }) => to === path);
15
163
  const edits = [];
16
164
  if (has("package.json")) {
@@ -20,6 +168,52 @@ export function createPlan(input) {
20
168
  apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
21
169
  });
22
170
  }
171
+ const appManifest = `${manifest.app}/package.json`;
172
+ if (manifest.appPackage && framework.app !== manifest.app && has(appManifest)) {
173
+ const appPackage = manifest.appPackage;
174
+ edits.push({
175
+ path: appManifest,
176
+ description: `Name the ${framework.name} app "${appPackage}"`,
177
+ apply: (text) => customizeAppManifest(text, appPackage),
178
+ });
179
+ }
180
+ const { removedPackageNames, removedPackageDirectories } = moduleFiles;
181
+ if (removedPackageNames.length) {
182
+ for (const { to } of files) {
183
+ if (to === "package.json" || !to.endsWith("/package.json"))
184
+ continue;
185
+ edits.push({
186
+ path: to,
187
+ description: `Drop dependencies on left-out packages`,
188
+ apply: (text) => removePackageDependencies(text, removedPackageNames),
189
+ });
190
+ }
191
+ }
192
+ if ((multipleFrameworks || removedPackageNames.length) && has(LOCKFILE)) {
193
+ const changes = {
194
+ remove: [...others.map((other) => other.app).filter((app) => app !== framework.app), ...removedPackageDirectories],
195
+ ...(framework.app !== manifest.app ? { rename: { from: framework.app, to: manifest.app } } : {}),
196
+ removeDependencies: removedPackageNames,
197
+ };
198
+ edits.push({
199
+ path: LOCKFILE,
200
+ description: removedPackageNames.length
201
+ ? `Keep only the ${framework.name} app and the selected modules in the lockfile`
202
+ : `Keep only the ${framework.name} app in the lockfile`,
203
+ apply: (text) => customizeLockfile(text, changes),
204
+ });
205
+ }
206
+ const context = { framework: framework.id, ...selection };
207
+ for (const path of manifest.conditional) {
208
+ const target = moduleFiles.replacedTargets.has(path) ? null : place(path);
209
+ if (target === null || !has(target))
210
+ continue;
211
+ edits.push({
212
+ path: target,
213
+ description: "Keep the sections for the selected framework and modules",
214
+ apply: (text) => applyConditionals(text, context, target),
215
+ });
216
+ }
23
217
  if (has("README.md")) {
24
218
  edits.push({
25
219
  path: "README.md",
@@ -47,6 +241,8 @@ export function createPlan(input) {
47
241
  targetDirectory: input.targetDirectory,
48
242
  identity,
49
243
  provenance,
244
+ framework: { id: framework.id, name: framework.name },
245
+ modules,
50
246
  files,
51
247
  edits,
52
248
  envFile: has(manifest.env.example) ? { from: manifest.env.example, to: manifest.env.target } : null,
@@ -57,6 +253,10 @@ export function createPlan(input) {
57
253
  export function describePlan(plan) {
58
254
  return [
59
255
  `Create ${plan.targetDirectory}`,
256
+ ...(plan.provenance.framework ? [`Use ${plan.framework.name}`] : []),
257
+ ...(plan.modules.length
258
+ ? [`Use ${plan.modules.map(({ name, optionName }) => `${name}: ${optionName}`).join(", ")}`]
259
+ : []),
60
260
  `Copy ${plan.files.length} files from ${plan.provenance.starterRepository}@${plan.provenance.starterRef}` +
61
261
  (plan.provenance.starterCommit ? ` (${plan.provenance.starterCommit.slice(0, 8)})` : ""),
62
262
  ...plan.edits.map((edit) => `Edit ${edit.path}: ${edit.description}`),
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
- import { cancel, intro, isCancel, log, outro, spinner, text } from "@clack/prompts";
1
+ import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
2
2
  import { relative, resolve } from "node:path";
3
3
  import process from "node:process";
4
4
  import { parseCliArgs, UsageError } from "./cli/args.js";
5
5
  import { dim, helpText, showBanner } from "./cli/ui.js";
6
6
  import { assertTargetIsUsable, executePlan } from "./core/execute.js";
7
7
  import { readTemplateManifest } from "./core/manifest.js";
8
- import { createPlan, describePlan } from "./core/plan.js";
8
+ import { createPlan, describePlan, selectFramework } from "./core/plan.js";
9
9
  import { resolveTemplateSource } from "./core/source.js";
10
10
  import { cliUrl, cliVersion, siteUrl } from "./package-info.js";
11
11
  import { initializeGit } from "./steps/git.js";
@@ -30,6 +30,70 @@ async function askProjectDirectory() {
30
30
  }
31
31
  return value;
32
32
  }
33
+ /**
34
+ * The framework from --framework, a prompt when the Starter offers several, or the default.
35
+ * Returns null when the prompt is cancelled.
36
+ */
37
+ async function chooseFramework(manifest, requested, interactive) {
38
+ if (requested)
39
+ return selectFramework(manifest, requested).id;
40
+ const ids = Object.keys(manifest.frameworks);
41
+ if (!interactive || ids.length < 2)
42
+ return manifest.defaultFramework;
43
+ const value = await select({
44
+ message: "Which framework should the app use?",
45
+ initialValue: manifest.defaultFramework,
46
+ options: ids.map((id) => ({
47
+ value: id,
48
+ label: manifest.frameworks[id].name,
49
+ ...(id === manifest.defaultFramework ? { hint: "default" } : {}),
50
+ })),
51
+ });
52
+ return isCancel(value) ? null : value;
53
+ }
54
+ /**
55
+ * Module options from flags, plus prompts for the rest when the visitor wants to customize.
56
+ * Options whose requirements are not met by earlier answers are not offered. Returns null
57
+ * when a prompt is cancelled.
58
+ */
59
+ async function chooseModules(manifest, requested, interactive) {
60
+ const unasked = Object.keys(manifest.modules).filter((id) => requested[id] === undefined);
61
+ if (!interactive || unasked.length === 0)
62
+ return requested;
63
+ const customize = await confirm({
64
+ message: "Customize the included services (auth, storage, cache)?",
65
+ initialValue: false,
66
+ });
67
+ if (isCancel(customize))
68
+ return null;
69
+ if (!customize)
70
+ return requested;
71
+ const chosen = { ...requested };
72
+ for (const [id, module] of Object.entries(manifest.modules)) {
73
+ if (chosen[id] !== undefined)
74
+ continue;
75
+ const available = Object.entries(module.options).filter(([, option]) => Object.entries(option.requires).every(([other, allowed]) => {
76
+ const value = chosen[other] ?? requested[other];
77
+ // Modules answered later are checked when the plan is created.
78
+ return value === undefined || allowed.includes(value);
79
+ }));
80
+ const value = await select({
81
+ message: module.name,
82
+ initialValue: available.some(([optionId]) => optionId === module.default)
83
+ ? module.default
84
+ : available[0][0],
85
+ options: available.map(([optionId, option]) => ({
86
+ value: optionId,
87
+ label: option.name,
88
+ ...(optionId === module.default ? { hint: "default" } : {}),
89
+ })),
90
+ });
91
+ if (isCancel(value))
92
+ return null;
93
+ chosen[id] = value;
94
+ }
95
+ return chosen;
96
+ }
33
97
  async function createProject(args) {
34
98
  const interactive = Boolean(process.stdin.isTTY) && !args.yes;
35
99
  const requested = args.projectDirectory ?? (interactive ? await askProjectDirectory() : "my-app");
@@ -43,29 +107,49 @@ async function createProject(args) {
43
107
  progress.stop("Could not prepare the Starter");
44
108
  throw error;
45
109
  });
110
+ // The spinner runs while the manifest is read and again while files are written.
111
+ let spinning = "preparing";
46
112
  try {
47
113
  const manifest = await readTemplateManifest(source.root);
114
+ progress.stop(`Starter ${source.ref} ready`);
115
+ spinning = null;
116
+ const framework = await chooseFramework(manifest, args.framework, interactive);
117
+ if (framework === null) {
118
+ cancel("Operation cancelled.");
119
+ return;
120
+ }
121
+ const modules = await chooseModules(manifest, args.modules, interactive);
122
+ if (modules === null) {
123
+ cancel("Operation cancelled.");
124
+ return;
125
+ }
48
126
  const plan = createPlan({
49
127
  targetDirectory,
50
128
  identity,
51
129
  source,
52
130
  manifest,
131
+ framework,
132
+ modules,
53
133
  cliVersion,
54
134
  git: args.git,
55
135
  install: args.install,
56
136
  });
57
137
  if (args.dryRun) {
58
- progress.stop("Dry run: nothing was written");
59
138
  log.message(describePlan(plan).map((line) => `• ${line}`).join("\n"));
60
- outro("Run again without --dry-run to create the project.");
139
+ outro("Dry run: nothing was written. Run again without --dry-run to create the project.");
61
140
  return;
62
141
  }
63
- progress.message(`Writing ${plan.files.length} files`);
142
+ spinning = "writing";
143
+ progress.start(`Writing ${plan.files.length} files`);
64
144
  await executePlan(plan, source);
65
- progress.stop(`Created ${identity.packageName} from Starter ${source.ref}`);
145
+ progress.stop(`Created ${identity.packageName} with ${plan.framework.name} from Starter ${source.ref}`);
146
+ spinning = null;
66
147
  }
67
148
  catch (error) {
68
- progress.stop("Project scaffold failed; nothing was left behind");
149
+ if (spinning === "preparing")
150
+ progress.stop("Could not read the Starter");
151
+ if (spinning === "writing")
152
+ progress.stop("Project scaffold failed; nothing was left behind");
69
153
  throw error;
70
154
  }
71
155
  finally {
package/dist/utils/fs.js CHANGED
@@ -33,6 +33,9 @@ function mapBasename(path, map) {
33
33
  const ignoredDirectories = new Set([
34
34
  ".git",
35
35
  ".next",
36
+ ".nitro",
37
+ ".output",
38
+ ".tanstack",
36
39
  ".turbo",
37
40
  ".pnpm-store",
38
41
  "node_modules",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.3.0",
4
- "description": "Create an application from the Clubedge Starter reference implementation.",
3
+ "version": "0.5.0",
4
+ "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
7
7
  "bin": {
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.3.0",
26
- "starterCommit": "60f1691920c91a5cf83e480a3ce98c3ddefdef55"
25
+ "starterRef": "v0.5.0",
26
+ "starterCommit": "d9375c2c3b520323ff0255ef8ec65da19251c3c1"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "create-clubedge-app",
39
39
  "clubedge",
40
40
  "nextjs",
41
+ "tanstack-start",
41
42
  "starter",
42
43
  "scaffolding"
43
44
  ],
@@ -6,3 +6,6 @@ node_modules
6
6
  .env*
7
7
  coverage
8
8
  playwright-report
9
+ .output
10
+ .nitro
11
+ .tanstack
@@ -1,25 +1,38 @@
1
1
  NODE_ENV=development
2
2
  NEXT_PUBLIC_APP_URL=http://localhost:3000
3
3
 
4
- # Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in src/db).
4
+ # Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db).
5
5
  DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
6
6
 
7
+ # clubedge:if auth=supabase
7
8
  # Supabase Auth (publishable key only; never put a service role key in the browser)
8
9
  NEXT_PUBLIC_SUPABASE_URL=
9
10
  NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
11
+ # clubedge:end
10
12
 
11
- # Optional cache and rate limits (Redis protocol; rediss:// enables TLS)
12
- # Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
13
+ # clubedge:if auth=better-auth
14
+ # Better Auth keeps users and sessions in DATABASE_URL (run pnpm db:migrate first).
15
+ # Generate a secret with: openssl rand -base64 32
16
+ BETTER_AUTH_SECRET=
17
+ # clubedge:end
18
+
19
+ # clubedge:if cache=redis
20
+ # Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
21
+ # process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
13
22
  REDIS_URL=
23
+ # clubedge:end
14
24
 
15
- # Storage adapter: s3 (works with AWS S3 and Cloudflare R2) or supabase
16
- STORAGE_PROVIDER=s3
25
+ # clubedge:if storage=s3
26
+ # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
17
27
  STORAGE_BUCKET=
18
28
  STORAGE_REGION=auto
19
29
  STORAGE_ENDPOINT=
20
30
  STORAGE_ACCESS_KEY_ID=
21
31
  STORAGE_SECRET_ACCESS_KEY=
22
32
  STORAGE_PUBLIC_URL=
33
+ # clubedge:end
23
34
 
24
- # Supabase Storage adapter
35
+ # clubedge:if storage=supabase
36
+ # Supabase Storage bucket, accessed with the signed-in user's Supabase session
25
37
  SUPABASE_STORAGE_BUCKET=
38
+ # clubedge:end
@@ -23,21 +23,51 @@ jobs:
23
23
  - run: pnpm lint
24
24
  - run: pnpm typecheck
25
25
  - run: pnpm test
26
- - run: pnpm exec playwright install --with-deps chromium
27
- - run: pnpm test:e2e
26
+ # Build the way the app is developed: with the env example copied into place.
27
+ - run: cp .env.example apps/web/.env.local
28
28
  - run: pnpm build
29
+ - name: Serve the production build
30
+ run: |
31
+ set -euo pipefail
32
+ pnpm start > "$RUNNER_TEMP/app.log" 2>&1 &
33
+ trap 'tail -n 50 "$RUNNER_TEMP/app.log"' EXIT
34
+ for attempt in $(seq 1 30); do
35
+ curl --fail --silent --output /dev/null http://127.0.0.1:3000/api/health && break
36
+ sleep 1
37
+ done
38
+ for path in / /dashboard /api/health; do
39
+ curl --fail --silent --show-error --output /dev/null "http://127.0.0.1:3000$path"
40
+ done
41
+ # clubedge:if auth!=none
42
+ curl --fail --silent --show-error --output /dev/null http://127.0.0.1:3000/login
43
+ # clubedge:end
29
44
  - run: pnpm audit --audit-level high
30
45
 
46
+ e2e:
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ - uses: pnpm/action-setup@v4
51
+ - uses: actions/setup-node@v4
52
+ with:
53
+ node-version: 22
54
+ cache: pnpm
55
+ - run: pnpm install --frozen-lockfile
56
+ - run: pnpm exec playwright install --with-deps chromium
57
+ - run: pnpm test:e2e
58
+
31
59
  docker:
32
60
  runs-on: ubuntu-latest
33
61
  steps:
34
62
  - uses: actions/checkout@v4
35
63
  - name: Build the production image
36
- run: docker build --tag clubedge-starter:ci .
64
+ run: docker build --tag app:ci .
37
65
  - name: Start the image and check health
38
66
  run: |
39
67
  set -euo pipefail
40
- docker run --detach --name app --publish 3000:3000 --env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres clubedge-starter:ci
68
+ docker run --detach --name app --publish 3000:3000 \
69
+ --env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
70
+ app:ci
41
71
  trap 'docker logs app; docker rm --force app' EXIT
42
72
  for attempt in $(seq 1 30); do
43
73
  if curl --fail --silent http://127.0.0.1:3000/api/health; then
@@ -1 +1,2 @@
1
1
  pnpm-lock.yaml
2
+ **/routeTree.gen.ts
@@ -2,13 +2,19 @@
2
2
 
3
3
  ## Architecture rules
4
4
 
5
- - `packages/{core,auth,cache,db,storage}` are framework-agnostic. Never import `next`, `react`, `server-only`, `@tanstack/*`, or app code (`@/…`) there. Accept configuration, clients, and cookies as function arguments.
5
+ - Every package except `packages/ui` is framework-agnostic. Never import `next`, `react`, `server-only`, `@tanstack/*`, or app code (`@/…`) there. Accept configuration, clients, and cookies as function arguments.
6
+ - Interfaces live in `packages/{auth,storage,cache}`; each provider is its own package holding that provider's SDK, so replacing a provider means swapping one package. Keep provider SDKs out of the interface packages.
7
+ - Each provider module in `src/server/` validates its own environment variables with `parseEnv`; `src/env/server.ts` holds only the core variables.
6
8
  - `packages/core` depends on no other workspace package.
7
- - `apps/web/src/server/` is the composition root: the only place that reads environment variables, bridges Next.js cookies to `CookieStore`, and picks providers. Routes, server actions, and components import from `@/server/*`, never from `@supabase/*`, `@aws-sdk/*`, `redis`, or `postgres`.
9
+ - The app's `src/server/` is its composition root: the only place that reads environment variables, bridges the framework's cookies to `CookieStore`, and picks providers. Routes, server actions, server functions, and components import from `@/server/*`, never from a provider SDK such as `@supabase/*`, `@aws-sdk/*`, `redis`, or `postgres`.
10
+ - `apps/web` (Next.js) and `apps/start` (TanStack Start) are the same product. Make user-facing changes in both, keep their markup and redirects identical, and run the shared browser suite against each (`E2E_APP=start pnpm test:e2e`). <!-- clubedge:only starter-repository -->
11
+ - Files listed under `conditional` in `clubedge.template.json` contain `clubedge:if` blocks and `clubedge:only` lines that create-clubedge-app keeps or drops per framework and module. Keep every block present for the default selection, and use a file under `variants/` when two options need different code. <!-- clubedge:only starter-repository -->
8
12
  - Expected failures (bad credentials, invalid codes) are returned as `Result` values from `@clubedge/core`; exceptions are for unexpected errors.
9
- - Project identity (name, description, links) lives in `apps/web/src/config/site.json`; `create-clubedge-app` rewrites that file, so do not hard-code the project name elsewhere.
13
+ - Project identity (name, description, links) lives in `src/config/site.json`, so do not hard-code the project name elsewhere.
10
14
  - `pnpm lint` enforces these boundaries. Run `pnpm lint`, `pnpm typecheck`, and `pnpm test` before finishing a change.
11
15
 
16
+ <!-- clubedge:if framework=next -->
17
+
12
18
  <!-- BEGIN:nextjs-agent-rules -->
13
19
 
14
20
  ## This is NOT the Next.js you know
@@ -19,6 +25,8 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
19
25
 
20
26
  <!-- END:nextjs-agent-rules -->
21
27
 
28
+ <!-- clubedge:end -->
29
+
22
30
  <!-- BEGIN:turborepo-agent-rules -->
23
31
 
24
32
  # This is NOT the Turborepo you know
@@ -33,8 +33,8 @@ Run `pnpm test:e2e` for changes that affect browser behavior. It requires a loca
33
33
 
34
34
  ## Project conventions
35
35
 
36
- - Keep application code in `apps/web`, reusable shadcn/ui code in `packages/ui`, and framework-agnostic infrastructure in `packages/{core,auth,db,storage,cache}`.
37
- - Reach providers through `apps/web/src/server` rather than scattering provider SDK calls through routes and components. `pnpm lint` enforces these boundaries.
36
+ - Keep application code in `apps/web` (Next.js) and `apps/start` (TanStack Start), reusable shadcn/ui code in `packages/ui`, and framework-agnostic interfaces in `packages/{core,auth,db,storage,cache}` with each provider in its own package (`auth-supabase`, `storage-s3`, `storage-supabase`, `cache-redis`). A user-facing change belongs in both apps.
37
+ - Reach providers through each app's `src/server` rather than scattering provider SDK calls through routes and components. `pnpm lint` enforces these boundaries.
38
38
  - Keep server secrets out of client code and out of committed files. Update `.env.example` when adding configuration, using placeholders only.
39
39
  - For database changes, update the Drizzle schema and generate, review, and commit the migration. Do not maintain a second migration history for the same application tables.
40
40
  - For shared UI changes, preserve compatibility between both `components.json` files and use the established package exports.