@clubedge/create-clubedge-app 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +31 -11
- package/dist/cli/args.js +15 -0
- package/dist/cli/ui.js +4 -0
- package/dist/core/customize.js +142 -15
- package/dist/core/manifest.js +91 -2
- package/dist/core/plan.js +143 -8
- package/dist/index.js +50 -1
- package/package.json +3 -3
- package/template/.env.example +19 -6
- package/template/.github/workflows/ci.yml +16 -33
- package/template/AGENTS.md +11 -4
- package/template/CONTRIBUTING.md +1 -1
- package/template/README.md +69 -63
- package/template/SETUP.md +94 -44
- package/template/_gitignore +2 -0
- package/template/apps/start/.env.example +18 -5
- package/template/apps/start/components.json +21 -0
- package/template/apps/start/package.json +8 -2
- package/template/apps/start/src/components/dashboard/account-button.tsx +1 -1
- package/template/apps/start/src/components/dashboard/app-sidebar.tsx +1 -1
- package/template/apps/start/src/components/dashboard/variants/account-button.none.tsx +9 -0
- package/template/apps/start/src/env/auth.test.ts +26 -0
- package/template/apps/start/src/env/auth.ts +46 -0
- package/template/apps/start/src/env/server.test.ts +0 -16
- package/template/apps/start/src/env/server.ts +18 -42
- package/template/apps/start/src/routes/dashboard.tsx +18 -8
- package/template/apps/start/src/routes/index.tsx +7 -3
- package/template/apps/start/src/routes/login.tsx +1 -1
- package/template/apps/start/src/server/auth.ts +2 -2
- package/template/apps/start/src/server/cache.test.ts +65 -0
- package/template/apps/start/src/server/cache.ts +36 -16
- package/template/apps/start/src/server/storage.ts +22 -19
- package/template/apps/start/src/server/variants/auth.better-auth.ts +57 -0
- package/template/apps/start/src/server/variants/auth.functions.none.ts +9 -0
- package/template/apps/start/src/server/variants/auth.none.ts +28 -0
- package/template/apps/start/src/server/variants/cache.memory.ts +26 -0
- package/template/apps/start/src/server/variants/storage.supabase.ts +35 -0
- package/template/apps/start/tsr.config.json +7 -0
- package/template/apps/web/next.config.ts +9 -9
- package/template/apps/web/package.json +6 -1
- package/template/apps/web/src/app/dashboard/_components/account-button.tsx +1 -1
- package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +1 -1
- package/template/apps/web/src/app/dashboard/_components/variants/account-button.none.tsx +9 -0
- package/template/apps/web/src/app/dashboard/page.tsx +18 -8
- package/template/apps/web/src/app/login/page.tsx +5 -4
- package/template/apps/web/src/app/page.tsx +7 -3
- package/template/apps/web/src/env/auth.ts +48 -0
- package/template/apps/web/src/env/client.ts +1 -8
- package/template/apps/web/src/env/server.test.ts +27 -4
- package/template/apps/web/src/env/server.ts +19 -29
- package/template/apps/web/src/proxy.ts +5 -8
- package/template/apps/web/src/server/auth.ts +14 -26
- package/template/apps/web/src/server/cache.ts +36 -16
- package/template/apps/web/src/server/cookies.ts +18 -0
- package/template/apps/web/src/server/storage.ts +22 -19
- package/template/apps/web/src/server/variants/auth.better-auth.ts +63 -0
- package/template/apps/web/src/server/variants/auth.none.ts +26 -0
- package/template/apps/web/src/server/variants/cache.memory.ts +26 -0
- package/template/apps/web/src/server/variants/storage.supabase.ts +35 -0
- package/template/apps/web/src/variants/proxy.better-auth.ts +46 -0
- package/template/clubedge.template.json +157 -2
- package/template/e2e/auth-flow.spec.ts +29 -0
- package/template/e2e/auth.spec.ts +2 -12
- package/template/e2e/home.spec.ts +14 -5
- package/template/eslint.config.mjs +2 -1
- package/template/package.json +4 -4
- package/template/packages/auth/package.json +3 -6
- package/template/packages/auth-better-auth/drizzle/0000_initial.sql +50 -0
- package/template/packages/auth-better-auth/drizzle/meta/0000_snapshot.json +320 -0
- package/template/packages/auth-better-auth/drizzle/meta/_journal.json +13 -0
- package/template/packages/auth-better-auth/drizzle.config.ts +12 -0
- package/template/packages/auth-better-auth/package.json +31 -0
- package/template/packages/auth-better-auth/scripts/migrate.ts +14 -0
- package/template/packages/auth-better-auth/src/index.test.ts +118 -0
- package/template/packages/auth-better-auth/src/index.ts +135 -0
- package/template/packages/auth-better-auth/src/schema.ts +74 -0
- package/template/packages/auth-better-auth/tsconfig.json +4 -0
- package/template/packages/auth-supabase/package.json +24 -0
- package/template/packages/{auth/src/supabase.test.ts → auth-supabase/src/index.test.ts} +2 -2
- package/template/packages/{auth/src/supabase.ts → auth-supabase/src/index.ts} +1 -1
- package/template/packages/auth-supabase/tsconfig.json +4 -0
- package/template/packages/cache/package.json +1 -4
- package/template/packages/cache/src/index.ts +3 -13
- package/template/packages/cache/src/memory-cache.test.ts +40 -0
- package/template/packages/cache/src/memory-cache.ts +37 -0
- package/template/packages/cache-redis/package.json +22 -0
- package/template/packages/{cache → cache-redis}/src/index.test.ts +6 -15
- package/template/packages/{cache/src/redis.ts → cache-redis/src/index.ts} +2 -6
- package/template/packages/cache-redis/tsconfig.json +4 -0
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +3 -2
- package/template/packages/storage/package.json +2 -9
- package/template/packages/storage-s3/package.json +23 -0
- package/template/packages/{storage/src/s3.test.ts → storage-s3/src/index.test.ts} +1 -1
- package/template/packages/{storage/src/s3.ts → storage-s3/src/index.ts} +1 -1
- package/template/packages/storage-s3/tsconfig.json +4 -0
- package/template/packages/storage-supabase/package.json +22 -0
- package/template/packages/{storage/src/supabase.ts → storage-supabase/src/index.ts} +1 -1
- package/template/packages/storage-supabase/tsconfig.json +4 -0
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +524 -2
- package/template.lock.json +49 -10
- 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,96 @@ 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
|
+
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
|
+
};
|
|
110
|
+
}
|
|
35
111
|
export function createPlan(input) {
|
|
36
112
|
const { manifest, identity, source } = input;
|
|
37
113
|
const framework = selectFramework(manifest, input.framework);
|
|
114
|
+
const selection = selectModules(manifest, input.modules);
|
|
38
115
|
const others = Object.entries(manifest.frameworks)
|
|
39
116
|
.filter(([id]) => id !== framework.id)
|
|
40
117
|
.map(([, other]) => other);
|
|
41
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
|
+
}));
|
|
42
126
|
const provenance = {
|
|
43
127
|
cliVersion: input.cliVersion,
|
|
44
128
|
starterRepository: source.repository,
|
|
45
129
|
starterRef: source.ref,
|
|
46
130
|
...(source.commit ? { starterCommit: source.commit } : {}),
|
|
47
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
|
+
: {}),
|
|
48
138
|
};
|
|
139
|
+
const place = (path) => relocate(path, manifest, framework, others);
|
|
49
140
|
const files = [];
|
|
50
141
|
for (const file of source.files) {
|
|
51
|
-
|
|
142
|
+
const path = file.to;
|
|
143
|
+
if (isExcluded(path, manifest.exclude))
|
|
52
144
|
continue;
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
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 });
|
|
56
161
|
}
|
|
57
162
|
const has = (path) => files.some(({ to }) => to === path);
|
|
58
163
|
const edits = [];
|
|
@@ -72,17 +177,43 @@ export function createPlan(input) {
|
|
|
72
177
|
apply: (text) => customizeAppManifest(text, appPackage),
|
|
73
178
|
});
|
|
74
179
|
}
|
|
75
|
-
|
|
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)) {
|
|
76
193
|
const changes = {
|
|
77
|
-
remove: others.map((other) => other.app).filter((app) => app !== framework.app),
|
|
194
|
+
remove: [...others.map((other) => other.app).filter((app) => app !== framework.app), ...removedPackageDirectories],
|
|
78
195
|
...(framework.app !== manifest.app ? { rename: { from: framework.app, to: manifest.app } } : {}),
|
|
196
|
+
removeDependencies: removedPackageNames,
|
|
79
197
|
};
|
|
80
198
|
edits.push({
|
|
81
199
|
path: LOCKFILE,
|
|
82
|
-
description:
|
|
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`,
|
|
83
203
|
apply: (text) => customizeLockfile(text, changes),
|
|
84
204
|
});
|
|
85
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
|
+
}
|
|
86
217
|
if (has("README.md")) {
|
|
87
218
|
edits.push({
|
|
88
219
|
path: "README.md",
|
|
@@ -111,6 +242,7 @@ export function createPlan(input) {
|
|
|
111
242
|
identity,
|
|
112
243
|
provenance,
|
|
113
244
|
framework: { id: framework.id, name: framework.name },
|
|
245
|
+
modules,
|
|
114
246
|
files,
|
|
115
247
|
edits,
|
|
116
248
|
envFile: has(manifest.env.example) ? { from: manifest.env.example, to: manifest.env.target } : null,
|
|
@@ -122,6 +254,9 @@ export function describePlan(plan) {
|
|
|
122
254
|
return [
|
|
123
255
|
`Create ${plan.targetDirectory}`,
|
|
124
256
|
...(plan.provenance.framework ? [`Use ${plan.framework.name}`] : []),
|
|
257
|
+
...(plan.modules.length
|
|
258
|
+
? [`Use ${plan.modules.map(({ name, optionName }) => `${name}: ${optionName}`).join(", ")}`]
|
|
259
|
+
: []),
|
|
125
260
|
`Copy ${plan.files.length} files from ${plan.provenance.starterRepository}@${plan.provenance.starterRef}` +
|
|
126
261
|
(plan.provenance.starterCommit ? ` (${plan.provenance.starterCommit.slice(0, 8)})` : ""),
|
|
127
262
|
...plan.edits.map((edit) => `Edit ${edit.path}: ${edit.description}`),
|
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)?",
|
|
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.
|
|
3
|
+
"version": "0.5.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.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.5.0",
|
|
26
|
+
"starterCommit": "d9375c2c3b520323ff0255ef8ec65da19251c3c1"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
package/template/.env.example
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
16
|
-
|
|
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
|
-
#
|
|
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,35 +23,28 @@ jobs:
|
|
|
23
23
|
- run: pnpm lint
|
|
24
24
|
- run: pnpm typecheck
|
|
25
25
|
- run: pnpm test
|
|
26
|
-
#
|
|
27
|
-
- run: cp .env.example apps/web/.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
|
|
29
|
+
- name: Serve the production build
|
|
30
30
|
run: |
|
|
31
31
|
set -euo pipefail
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
package/template/AGENTS.md
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Architecture rules
|
|
4
4
|
|
|
5
|
-
- `packages/
|
|
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
|
-
-
|
|
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
|
|
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
|
package/template/CONTRIBUTING.md
CHANGED
|
@@ -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
|
|
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.
|