@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.
- package/CHANGELOG.md +17 -0
- package/README.md +49 -13
- package/dist/cli/args.js +20 -0
- package/dist/cli/ui.js +6 -0
- package/dist/core/customize.js +167 -1
- package/dist/core/manifest.js +130 -3
- package/dist/core/plan.js +203 -3
- package/dist/index.js +91 -7
- package/dist/utils/fs.js +3 -0
- package/package.json +5 -4
- package/template/.dockerignore +3 -0
- package/template/.env.example +19 -6
- package/template/.github/workflows/ci.yml +34 -4
- package/template/.prettierignore +1 -0
- package/template/AGENTS.md +11 -3
- package/template/CONTRIBUTING.md +2 -2
- package/template/README.md +73 -54
- package/template/SETUP.md +112 -40
- package/template/_gitignore +6 -0
- package/template/apps/start/.env.example +40 -0
- package/template/apps/start/AGENTS.md +11 -0
- package/template/apps/start/Dockerfile +38 -0
- package/template/apps/start/components.json +21 -0
- package/template/apps/start/package.json +43 -0
- package/template/apps/start/public/.well-known/logo.svg +1 -0
- package/template/apps/start/public/favicon.ico +0 -0
- package/template/apps/start/src/components/auth/auth-form-controls.tsx +108 -0
- package/template/apps/start/src/components/branding/clubedge-mark.tsx +24 -0
- package/template/apps/start/src/components/dashboard/account-button.tsx +33 -0
- package/template/apps/start/src/components/dashboard/app-sidebar.tsx +90 -0
- package/template/apps/start/src/components/dashboard/variants/account-button.none.tsx +9 -0
- package/template/apps/start/src/components/landing/copy-command.tsx +46 -0
- package/template/apps/start/src/components/theme/theme-provider.tsx +41 -0
- package/template/apps/start/src/components/theme/theme-toggle.tsx +21 -0
- package/template/apps/start/src/config/site.json +11 -0
- package/template/apps/start/src/config/site.ts +20 -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 +32 -0
- package/template/apps/start/src/env/server.ts +43 -0
- package/template/apps/start/src/lib/login-url.test.ts +16 -0
- package/template/apps/start/src/lib/login-url.ts +16 -0
- package/template/apps/start/src/router.tsx +27 -0
- package/template/apps/start/src/routes/__root.tsx +59 -0
- package/template/apps/start/src/routes/api/health.ts +15 -0
- package/template/apps/start/src/routes/auth/callback.ts +10 -0
- package/template/apps/start/src/routes/auth/sign-in.ts +11 -0
- package/template/apps/start/src/routes/auth/sign-out.ts +11 -0
- package/template/apps/start/src/routes/auth/sign-up.ts +11 -0
- package/template/apps/start/src/routes/dashboard.tsx +361 -0
- package/template/apps/start/src/routes/index.tsx +237 -0
- package/template/apps/start/src/routes/login.tsx +164 -0
- package/template/apps/start/src/server/auth-forms.test.ts +169 -0
- package/template/apps/start/src/server/auth-forms.ts +118 -0
- package/template/apps/start/src/server/auth.functions.ts +27 -0
- package/template/apps/start/src/server/auth.ts +51 -0
- package/template/apps/start/src/server/cache.test.ts +65 -0
- package/template/apps/start/src/server/cache.ts +58 -0
- package/template/apps/start/src/server/cookies.test.ts +41 -0
- package/template/apps/start/src/server/cookies.ts +31 -0
- package/template/apps/start/src/server/db.ts +16 -0
- package/template/apps/start/src/server/storage.ts +42 -0
- package/template/apps/start/src/server/users.ts +17 -0
- 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/src/start.ts +40 -0
- package/template/apps/start/src/styles.css +2 -0
- package/template/apps/start/tsconfig.json +19 -0
- package/template/apps/start/tsr.config.json +7 -0
- package/template/apps/start/vite.config.ts +27 -0
- package/template/apps/web/next.config.ts +9 -9
- package/template/apps/web/package.json +7 -2
- package/template/{scripts → apps/web/scripts}/start-standalone.mjs +1 -2
- 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 +173 -2
- package/template/e2e/app.ts +12 -0
- package/template/e2e/auth-flow.spec.ts +29 -0
- package/template/{apps/web/e2e → e2e}/auth.spec.ts +2 -12
- package/template/e2e/home.spec.ts +24 -0
- package/template/eslint.config.mjs +6 -1
- package/template/package.json +6 -6
- 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/playwright.config.ts +6 -2
- package/template/pnpm-lock.yaml +1912 -27
- package/template/turbo.json +1 -1
- package/template/vitest.config.ts +2 -0
- package/template.lock.json +96 -12
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Create
|
|
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.
|
|
26
|
-
"starterCommit": "
|
|
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
|
],
|
package/template/.dockerignore
CHANGED
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,21 +23,51 @@ jobs:
|
|
|
23
23
|
- run: pnpm lint
|
|
24
24
|
- run: pnpm typecheck
|
|
25
25
|
- run: pnpm test
|
|
26
|
-
|
|
27
|
-
- run:
|
|
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
|
|
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
|
|
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
|
package/template/.prettierignore
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -2,13 +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
|
-
- `
|
|
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 `
|
|
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
|
package/template/CONTRIBUTING.md
CHANGED
|
@@ -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
|
|
37
|
-
- Reach providers through `
|
|
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.
|