@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/dist/core/plan.js CHANGED
@@ -1,7 +1,10 @@
1
- import { customizeAppManifest, customizeLegacySource, customizeLockfile, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
1
+ import { applyConditionals, customizeAppManifest, customizeLegacySource, customizeLockfile, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, removePackageDependencies, } from "./customize.js";
2
2
  export class FrameworkError extends Error {
3
3
  name = "FrameworkError";
4
4
  }
5
+ export class ModuleError extends Error {
6
+ name = "ModuleError";
7
+ }
5
8
  const LOCKFILE = "pnpm-lock.yaml";
6
9
  const isWithin = (path, directory) => path === directory || path.startsWith(`${directory.replace(/\/$/, "")}/`);
7
10
  function isExcluded(path, exclude) {
@@ -17,6 +20,39 @@ export function selectFramework(manifest, requested) {
17
20
  }
18
21
  return { id, ...framework };
19
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
+ }
20
56
  /**
21
57
  * Where a Starter file lands in the generated project, or null when it belongs to a framework
22
58
  * that was not selected. The selected app moves to `manifest.app`, and its environment example
@@ -32,27 +68,110 @@ function relocate(path, manifest, selected, others) {
32
68
  const belongsToOther = others.some((other) => isWithin(path, other.app) || path === other.envExample || path === other.dockerfile);
33
69
  return belongsToOther ? null : path;
34
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
+ // Selected options set their scripts; scripts only left-out options define are removed.
101
+ const scripts = { set: {}, remove: [] };
102
+ const offeredScripts = new Set();
103
+ for (const [id, module] of Object.entries(manifest.modules)) {
104
+ for (const [optionId, option] of Object.entries(module.options)) {
105
+ for (const [name, command] of Object.entries(option.scripts)) {
106
+ offeredScripts.add(name);
107
+ if (selection[id] === optionId)
108
+ scripts.set[name] = command;
109
+ }
110
+ }
111
+ }
112
+ scripts.remove = [...offeredScripts].filter((name) => !(name in scripts.set));
113
+ const removedPackages = [...offeredPackages].filter(([name]) => !usedPackages.has(name));
114
+ return {
115
+ scripts,
116
+ removedPackageNames: removedPackages.map(([name]) => name),
117
+ removedPackageDirectories: removedPackages.map(([, directory]) => directory),
118
+ replacedTargets: new Set(replacements.values()),
119
+ replacements,
120
+ variantSources,
121
+ isDroppedOwnedPath: (path) => [...ownedPaths].some((owned) => isWithin(path, owned)) &&
122
+ ![...keptPaths].some((kept) => isWithin(path, kept)),
123
+ };
124
+ }
35
125
  export function createPlan(input) {
36
126
  const { manifest, identity, source } = input;
37
127
  const framework = selectFramework(manifest, input.framework);
128
+ const selection = selectModules(manifest, input.modules);
38
129
  const others = Object.entries(manifest.frameworks)
39
130
  .filter(([id]) => id !== framework.id)
40
131
  .map(([, other]) => other);
41
132
  const multipleFrameworks = others.length > 0;
133
+ const moduleFiles = resolveModuleFiles(manifest, selection);
134
+ const modules = Object.entries(selection).map(([id, option]) => ({
135
+ id,
136
+ name: manifest.modules[id].name,
137
+ option,
138
+ optionName: manifest.modules[id].options[option].name,
139
+ }));
42
140
  const provenance = {
43
141
  cliVersion: input.cliVersion,
44
142
  starterRepository: source.repository,
45
143
  starterRef: source.ref,
46
144
  ...(source.commit ? { starterCommit: source.commit } : {}),
47
145
  ...(multipleFrameworks ? { framework: framework.id, frameworkName: framework.name } : {}),
146
+ ...(modules.length
147
+ ? {
148
+ modules: Object.fromEntries(modules.map(({ id, option }) => [id, option])),
149
+ moduleSummary: modules.map(({ name, optionName }) => `${name}: ${optionName}`),
150
+ }
151
+ : {}),
48
152
  };
153
+ const place = (path) => relocate(path, manifest, framework, others);
49
154
  const files = [];
50
155
  for (const file of source.files) {
51
- if (isExcluded(file.to, manifest.exclude))
156
+ const path = file.to;
157
+ if (isExcluded(path, manifest.exclude))
52
158
  continue;
53
- const to = relocate(file.to, manifest, framework, others);
54
- if (to !== null && to !== manifest.env.target)
55
- files.push({ from: file.from, to });
159
+ let target = path;
160
+ if (moduleFiles.variantSources.has(path)) {
161
+ // A variant is only ever copied over the default file it replaces.
162
+ const replaced = moduleFiles.replacements.get(path);
163
+ target = replaced === undefined ? null : place(replaced);
164
+ }
165
+ else if (moduleFiles.replacedTargets.has(path) ||
166
+ moduleFiles.isDroppedOwnedPath(path) ||
167
+ moduleFiles.removedPackageDirectories.some((directory) => isWithin(path, directory))) {
168
+ target = null;
169
+ }
170
+ else {
171
+ target = place(path);
172
+ }
173
+ if (target !== null && target !== manifest.env.target)
174
+ files.push({ from: file.from, to: target });
56
175
  }
57
176
  const has = (path) => files.some(({ to }) => to === path);
58
177
  const edits = [];
@@ -60,7 +179,7 @@ export function createPlan(input) {
60
179
  edits.push({
61
180
  path: "package.json",
62
181
  description: `Name the package "${identity.packageName}" and record its origin`,
63
- apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
182
+ apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage, moduleFiles.scripts),
64
183
  });
65
184
  }
66
185
  const appManifest = `${manifest.app}/package.json`;
@@ -72,17 +191,43 @@ export function createPlan(input) {
72
191
  apply: (text) => customizeAppManifest(text, appPackage),
73
192
  });
74
193
  }
75
- if (multipleFrameworks && has(LOCKFILE)) {
194
+ const { removedPackageNames, removedPackageDirectories } = moduleFiles;
195
+ if (removedPackageNames.length) {
196
+ for (const { to } of files) {
197
+ if (to === "package.json" || !to.endsWith("/package.json"))
198
+ continue;
199
+ edits.push({
200
+ path: to,
201
+ description: `Drop dependencies on left-out packages`,
202
+ apply: (text) => removePackageDependencies(text, removedPackageNames),
203
+ });
204
+ }
205
+ }
206
+ if ((multipleFrameworks || removedPackageNames.length) && has(LOCKFILE)) {
76
207
  const changes = {
77
- remove: others.map((other) => other.app).filter((app) => app !== framework.app),
208
+ remove: [...others.map((other) => other.app).filter((app) => app !== framework.app), ...removedPackageDirectories],
78
209
  ...(framework.app !== manifest.app ? { rename: { from: framework.app, to: manifest.app } } : {}),
210
+ removeDependencies: removedPackageNames,
79
211
  };
80
212
  edits.push({
81
213
  path: LOCKFILE,
82
- description: `Keep only the ${framework.name} app in the lockfile`,
214
+ description: removedPackageNames.length
215
+ ? `Keep only the ${framework.name} app and the selected modules in the lockfile`
216
+ : `Keep only the ${framework.name} app in the lockfile`,
83
217
  apply: (text) => customizeLockfile(text, changes),
84
218
  });
85
219
  }
220
+ const context = { framework: framework.id, ...selection };
221
+ for (const path of manifest.conditional) {
222
+ const target = moduleFiles.replacedTargets.has(path) ? null : place(path);
223
+ if (target === null || !has(target))
224
+ continue;
225
+ edits.push({
226
+ path: target,
227
+ description: "Keep the sections for the selected framework and modules",
228
+ apply: (text) => applyConditionals(text, context, target),
229
+ });
230
+ }
86
231
  if (has("README.md")) {
87
232
  edits.push({
88
233
  path: "README.md",
@@ -111,6 +256,7 @@ export function createPlan(input) {
111
256
  identity,
112
257
  provenance,
113
258
  framework: { id: framework.id, name: framework.name },
259
+ modules,
114
260
  files,
115
261
  edits,
116
262
  envFile: has(manifest.env.example) ? { from: manifest.env.example, to: manifest.env.target } : null,
@@ -122,6 +268,9 @@ export function describePlan(plan) {
122
268
  return [
123
269
  `Create ${plan.targetDirectory}`,
124
270
  ...(plan.provenance.framework ? [`Use ${plan.framework.name}`] : []),
271
+ ...(plan.modules.length
272
+ ? [`Use ${plan.modules.map(({ name, optionName }) => `${name}: ${optionName}`).join(", ")}`]
273
+ : []),
125
274
  `Copy ${plan.files.length} files from ${plan.provenance.starterRepository}@${plan.provenance.starterRef}` +
126
275
  (plan.provenance.starterCommit ? ` (${plan.provenance.starterCommit.slice(0, 8)})` : ""),
127
276
  ...plan.edits.map((edit) => `Edit ${edit.path}: ${edit.description}`),
@@ -50,15 +50,18 @@ export async function readDirectoryTemplate(directory) {
50
50
  }
51
51
  /** Resolves a tag to its commit with `git ls-remote`, following annotated tags. */
52
52
  export async function resolveTagCommit(repository, tag) {
53
- const output = await runCapture("git", [
54
- "ls-remote",
55
- `https://github.com/${repository}.git`,
56
- `refs/tags/${tag}^{}`,
57
- `refs/tags/${tag}`,
58
- ]);
59
- const lines = output.split("\n").map((line) => line.split(/\s+/));
60
- const peeled = lines.find(([, ref]) => ref?.endsWith("^{}"));
61
- return (peeled ?? lines[0])?.[0] || undefined;
53
+ // List every tag rather than passing a `refs/tags/<tag>^{}` pattern: on Windows the command
54
+ // runs through cmd.exe, which strips `^`, so the peeled ref would never match.
55
+ const output = await runCapture("git", ["ls-remote", "--tags", `https://github.com/${repository}.git`]);
56
+ return pickTagCommit(output, tag);
57
+ }
58
+ /** Picks a tag's commit from `git ls-remote` output, preferring the peeled ref of an annotated tag. */
59
+ export function pickTagCommit(output, tag) {
60
+ const refs = new Map(output
61
+ .split("\n")
62
+ .map((line) => line.trim().split(/\s+/))
63
+ .map(([id, ref]) => [ref, id]));
64
+ return refs.get(`refs/tags/${tag}^{}`) ?? refs.get(`refs/tags/${tag}`);
62
65
  }
63
66
  export async function downloadGitHubTemplate(ref, { repository = starterPin.starterRepository, expectedCommit } = {}) {
64
67
  if (expectedCommit) {
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cancel, intro, isCancel, log, outro, select, 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";
@@ -51,6 +51,49 @@ async function chooseFramework(manifest, requested, interactive) {
51
51
  });
52
52
  return isCancel(value) ? null : value;
53
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, local infrastructure)?",
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
+ }
54
97
  async function createProject(args) {
55
98
  const interactive = Boolean(process.stdin.isTTY) && !args.yes;
56
99
  const requested = args.projectDirectory ?? (interactive ? await askProjectDirectory() : "my-app");
@@ -75,12 +118,18 @@ async function createProject(args) {
75
118
  cancel("Operation cancelled.");
76
119
  return;
77
120
  }
121
+ const modules = await chooseModules(manifest, args.modules, interactive);
122
+ if (modules === null) {
123
+ cancel("Operation cancelled.");
124
+ return;
125
+ }
78
126
  const plan = createPlan({
79
127
  targetDirectory,
80
128
  identity,
81
129
  source,
82
130
  manifest,
83
131
  framework,
132
+ modules,
84
133
  cliVersion,
85
134
  git: args.git,
86
135
  install: args.install,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.4.0",
26
- "starterCommit": "fc01d33bb1a3944e099a1750679be87a96ff9a22"
25
+ "starterRef": "v0.6.0",
26
+ "starterCommit": "bf5a5e801254816455cbb837902283b788ab7595"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -1,25 +1,52 @@
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).
5
- DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
4
+ # Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db). # clubedge:only infra=none
5
+ DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres # clubedge:only infra=none
6
+ # Local Supabase database, started by pnpm infra:up. # clubedge:only infra=supabase
7
+ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres # clubedge:only infra=supabase
8
+ # Local PostgreSQL from docker-compose.dev.yml, started by pnpm infra:up. # clubedge:only infra=docker
9
+ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app # clubedge:only infra=docker
6
10
 
11
+ # clubedge:if auth=supabase
7
12
  # Supabase Auth (publishable key only; never put a service role key in the browser)
8
- NEXT_PUBLIC_SUPABASE_URL=
13
+ NEXT_PUBLIC_SUPABASE_URL= # clubedge:only infra!=supabase
14
+ # Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
15
+ NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
9
16
  NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
17
+ # clubedge:end
10
18
 
11
- # Optional cache and rate limits (Redis protocol; rediss:// enables TLS)
12
- # Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
13
- REDIS_URL=
19
+ # clubedge:if auth=better-auth
20
+ # Better Auth keeps users and sessions in DATABASE_URL (run pnpm db:migrate first).
21
+ # Generate a secret with: openssl rand -base64 32
22
+ BETTER_AUTH_SECRET=
23
+ # clubedge:end
14
24
 
15
- # Storage adapter: s3 (works with AWS S3 and Cloudflare R2) or supabase
16
- STORAGE_PROVIDER=s3
17
- STORAGE_BUCKET=
18
- STORAGE_REGION=auto
19
- STORAGE_ENDPOINT=
20
- STORAGE_ACCESS_KEY_ID=
21
- STORAGE_SECRET_ACCESS_KEY=
25
+ # clubedge:if cache=redis
26
+ # Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
27
+ # process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
28
+ REDIS_URL= # clubedge:only infra!=docker
29
+ REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
30
+ # clubedge:end
31
+
32
+ # clubedge:if storage=s3
33
+ # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
34
+ STORAGE_BUCKET= # clubedge:only infra!=docker
35
+ STORAGE_REGION=auto # clubedge:only infra!=docker
36
+ STORAGE_ENDPOINT= # clubedge:only infra!=docker
37
+ STORAGE_ACCESS_KEY_ID= # clubedge:only infra!=docker
38
+ STORAGE_SECRET_ACCESS_KEY= # clubedge:only infra!=docker
39
+ # Local S3-compatible storage from docker-compose.dev.yml; it accepts any credentials. # clubedge:only infra=docker
40
+ STORAGE_BUCKET=app # clubedge:only infra=docker
41
+ STORAGE_REGION=us-east-1 # clubedge:only infra=docker
42
+ STORAGE_ENDPOINT=http://localhost:9090 # clubedge:only infra=docker
43
+ STORAGE_ACCESS_KEY_ID=local # clubedge:only infra=docker
44
+ STORAGE_SECRET_ACCESS_KEY=local-secret # clubedge:only infra=docker
22
45
  STORAGE_PUBLIC_URL=
46
+ # clubedge:end
23
47
 
24
- # Supabase Storage adapter
25
- SUPABASE_STORAGE_BUCKET=
48
+ # clubedge:if storage=supabase
49
+ # Supabase Storage bucket, accessed with the signed-in user's Supabase session
50
+ SUPABASE_STORAGE_BUCKET= # clubedge:only infra!=supabase
51
+ SUPABASE_STORAGE_BUCKET=app # clubedge:only infra=supabase
52
+ # clubedge:end
@@ -23,35 +23,28 @@ jobs:
23
23
  - run: pnpm lint
24
24
  - run: pnpm typecheck
25
25
  - run: pnpm test
26
- # Generated projects build with the env example copied into place; build the same way.
27
- - run: cp .env.example apps/web/.env.local && cp apps/start/.env.example apps/start/.env.local
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 both production builds
29
+ - name: Serve the production build
30
30
  run: |
31
31
  set -euo pipefail
32
- PORT=3001 pnpm --filter @clubedge/web start > "$RUNNER_TEMP/web.log" 2>&1 &
33
- PORT=3002 pnpm --filter @clubedge/start start > "$RUNNER_TEMP/start.log" 2>&1 &
34
- trap 'tail -n 50 "$RUNNER_TEMP/web.log" "$RUNNER_TEMP/start.log"' EXIT
35
- for port in 3001 3002; do
36
- for attempt in $(seq 1 30); do
37
- curl --fail --silent --output /dev/null "http://127.0.0.1:$port/api/health" && break
38
- sleep 1
39
- done
40
- for path in / /login /dashboard /api/health; do
41
- curl --fail --silent --show-error --output /dev/null "http://127.0.0.1:$port$path"
42
- done
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"
43
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
44
44
  - run: pnpm audit --audit-level high
45
45
 
46
- # The same browser suite runs against every framework.
47
46
  e2e:
48
47
  runs-on: ubuntu-latest
49
- strategy:
50
- fail-fast: false
51
- matrix:
52
- app: [web, start]
53
- env:
54
- E2E_APP: ${{ matrix.app }}
55
48
  steps:
56
49
  - uses: actions/checkout@v4
57
50
  - uses: pnpm/action-setup@v4
@@ -65,26 +58,16 @@ jobs:
65
58
 
66
59
  docker:
67
60
  runs-on: ubuntu-latest
68
- strategy:
69
- fail-fast: false
70
- matrix:
71
- include:
72
- - app: web
73
- build: docker build --tag clubedge-starter:ci .
74
- - app: start
75
- build: >-
76
- docker build --tag clubedge-starter:ci --file apps/start/Dockerfile
77
- --build-arg APP_DIR=apps/start --build-arg APP_PACKAGE=@clubedge/start .
78
61
  steps:
79
62
  - uses: actions/checkout@v4
80
63
  - name: Build the production image
81
- run: ${{ matrix.build }}
64
+ run: docker build --tag app:ci .
82
65
  - name: Start the image and check health
83
66
  run: |
84
67
  set -euo pipefail
85
68
  docker run --detach --name app --publish 3000:3000 \
86
69
  --env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
87
- clubedge-starter:ci
70
+ app:ci
88
71
  trap 'docker logs app; docker rm --force app' EXIT
89
72
  for attempt in $(seq 1 30); do
90
73
  if curl --fail --silent http://127.0.0.1:3000/api/health; then
@@ -2,14 +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
- - Each 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 `@supabase/*`, `@aws-sdk/*`, `redis`, or `postgres`.
8
- - `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`).
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 -->
9
12
  - Expected failures (bad credentials, invalid codes) are returned as `Result` values from `@clubedge/core`; exceptions are for unexpected errors.
10
- - Project identity (name, description, links) lives in each app's `src/config/site.json` (keep them identical); `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.
11
14
  - `pnpm lint` enforces these boundaries. Run `pnpm lint`, `pnpm typecheck`, and `pnpm test` before finishing a change.
12
15
 
16
+ <!-- clubedge:if framework=next -->
17
+
13
18
  <!-- BEGIN:nextjs-agent-rules -->
14
19
 
15
20
  ## This is NOT the Next.js you know
@@ -20,6 +25,8 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
20
25
 
21
26
  <!-- END:nextjs-agent-rules -->
22
27
 
28
+ <!-- clubedge:end -->
29
+
23
30
  <!-- BEGIN:turborepo-agent-rules -->
24
31
 
25
32
  # This is NOT the Turborepo you know
@@ -33,7 +33,7 @@ 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` (Next.js) and `apps/start` (TanStack Start), reusable shadcn/ui code in `packages/ui`, and framework-agnostic infrastructure in `packages/{core,auth,db,storage,cache}`. A user-facing change belongs in both apps.
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
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.