@clubedge/create-clubedge-app 0.3.0 → 0.4.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 +8 -0
- package/README.md +29 -13
- package/dist/cli/args.js +5 -0
- package/dist/cli/ui.js +2 -0
- package/dist/core/customize.js +40 -1
- package/dist/core/manifest.js +41 -3
- package/dist/core/plan.js +68 -3
- package/dist/index.js +42 -7
- package/dist/utils/fs.js +3 -0
- package/package.json +5 -4
- package/template/.dockerignore +3 -0
- package/template/.github/workflows/ci.yml +51 -4
- package/template/.prettierignore +1 -0
- package/template/AGENTS.md +3 -2
- package/template/CONTRIBUTING.md +2 -2
- package/template/README.md +18 -5
- package/template/SETUP.md +23 -1
- package/template/_gitignore +4 -0
- package/template/apps/start/.env.example +27 -0
- package/template/apps/start/AGENTS.md +11 -0
- package/template/apps/start/Dockerfile +38 -0
- package/template/apps/start/package.json +37 -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/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/server.test.ts +48 -0
- package/template/apps/start/src/env/server.ts +67 -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/routeTree.gen.ts +220 -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 +351 -0
- package/template/apps/start/src/routes/index.tsx +233 -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.ts +38 -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 +39 -0
- package/template/apps/start/src/server/users.ts +17 -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/vite.config.ts +27 -0
- package/template/apps/web/package.json +2 -2
- package/template/{scripts → apps/web/scripts}/start-standalone.mjs +1 -2
- package/template/clubedge.template.json +17 -1
- package/template/e2e/app.ts +12 -0
- package/template/{apps/web/e2e → e2e}/auth.spec.ts +2 -2
- package/template/eslint.config.mjs +4 -0
- package/template/package.json +3 -3
- package/template/packages/auth/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/playwright.config.ts +6 -2
- package/template/pnpm-lock.yaml +1382 -19
- package/template/turbo.json +1 -1
- package/template/vitest.config.ts +2 -0
- package/template.lock.json +50 -5
- /package/template/{apps/web/e2e → e2e}/home.spec.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.4.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.4.0`, which adds the TanStack Start app and template schema 2.
|
|
8
|
+
- Add `--framework next|tanstack-start`, and ask which framework to use when the Starter offers several. Next.js stays the default.
|
|
9
|
+
- Read template schema 2, which describes each framework's app, environment example, and Dockerfile. The selected app is moved to `apps/web`, the others are left out, and `pnpm-lock.yaml` is kept in step so frozen installs work.
|
|
10
|
+
- Record the chosen framework in the generated `package.json` and `README.md`.
|
|
11
|
+
- Test generated projects for both frameworks in CI and in the release smoke test.
|
|
12
|
+
|
|
5
13
|
## 0.3.0
|
|
6
14
|
|
|
7
15
|
- Default to Starter `v0.3.0`, which adds the `clubedge.template.json` template manifest.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/Clubedge/create-clubedge-app/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
|
-
Create a Next.js application from the [Clubedge Starter](https://github.com/Clubedge/clubedge-starter) reference implementation.
|
|
6
|
+
Create a Next.js or TanStack Start application from the [Clubedge Starter](https://github.com/Clubedge/clubedge-starter) reference implementation.
|
|
7
7
|
|
|
8
8
|
The CLI downloads a tested Starter release, names the generated project, creates `apps/web/.env.local` from the example, initializes Git, and installs dependencies. The Starter remains the source of truth for the generated architecture.
|
|
9
9
|
|
|
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
|
|
|
12
12
|
Each CLI release is tested against one specific Starter release:
|
|
13
13
|
|
|
14
14
|
```text
|
|
15
|
-
create-clubedge-app v0.
|
|
15
|
+
create-clubedge-app v0.4.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
18
|
+
Clubedge Starter v0.4.0
|
|
19
|
+
commit fc01d33bb1a3944e099a1750679be87a96ff9a22
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
The current relationship is:
|
|
23
23
|
|
|
24
24
|
| CLI release | Default Starter release | Meaning |
|
|
25
25
|
| --- | --- | --- |
|
|
26
|
-
| `create-clubedge-app@0.
|
|
26
|
+
| `create-clubedge-app@0.4.0` | `clubedge-starter@v0.4.0` | The CLI's required, tested default |
|
|
27
27
|
|
|
28
28
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
29
29
|
|
|
30
|
-
The `v0.
|
|
31
|
-
`
|
|
30
|
+
The `v0.4.0` tag is expected to resolve to commit
|
|
31
|
+
`fc01d33bb1a3944e099a1750679be87a96ff9a22`. The CLI verifies this before
|
|
32
32
|
scaffolding with the default reference and fails rather than silently using a
|
|
33
33
|
moved tag.
|
|
34
34
|
|
|
35
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.
|
|
35
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.4.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
36
36
|
|
|
37
37
|
## Requirements
|
|
38
38
|
|
|
@@ -68,11 +68,27 @@ When no destination is provided, the CLI asks where to create the project:
|
|
|
68
68
|
pnpm dlx @clubedge/create-clubedge-app
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
### Choosing a framework
|
|
72
|
+
|
|
73
|
+
The Starter ships the same application for two React frameworks. Next.js is the default; pick TanStack Start with `--framework`, or choose interactively when the Starter offers more than one:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| `--framework` | App | Production output |
|
|
80
|
+
| ---------------- | --------------------------- | ----------------------------- |
|
|
81
|
+
| `next` | Next.js App Router | Next.js standalone build |
|
|
82
|
+
| `tanstack-start` | TanStack Start with Vite | Nitro `.output` server bundle |
|
|
83
|
+
|
|
84
|
+
Either way, the app lives in `apps/web` and uses the same shared packages, so the project layout, root scripts, and Docker commands are identical.
|
|
85
|
+
|
|
71
86
|
## Options
|
|
72
87
|
|
|
73
88
|
```text
|
|
74
89
|
Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
75
90
|
|
|
91
|
+
--framework <id> App framework: next (default) or tanstack-start
|
|
76
92
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
77
93
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
78
94
|
--dry-run Show what would be created without writing anything
|
|
@@ -85,20 +101,20 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
|
85
101
|
|
|
86
102
|
The target directory must be empty or not exist. The CLI never deletes or overwrites existing files, and if scaffolding fails it removes whatever it wrote, so a failed run leaves nothing behind.
|
|
87
103
|
|
|
88
|
-
Every generated project records its CLI version, Starter repository, Starter ref,
|
|
104
|
+
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, and framework in `package.json` and `README.md`.
|
|
89
105
|
|
|
90
106
|
```sh
|
|
91
107
|
# Preview the files and edits without writing anything
|
|
92
108
|
pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
|
|
93
109
|
|
|
94
110
|
# Non-interactive, for scripts and CI
|
|
95
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git
|
|
111
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstack-start
|
|
96
112
|
|
|
97
113
|
# Try an unreleased Starter change
|
|
98
114
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
99
115
|
|
|
100
116
|
# Reproduce a specific Starter release or commit
|
|
101
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
117
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.4.0
|
|
102
118
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
103
119
|
|
|
104
120
|
# Test local Starter changes before releasing them
|
|
@@ -109,7 +125,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
|
|
109
125
|
|
|
110
126
|
Each CLI release bundles one Starter release. When the release is built, `scripts/bundle-template.mjs` clones the pinned Starter tag, verifies that it resolves to the pinned commit, and copies its tracked files into the package (`template/` and `template.lock.json`). Running the CLI then needs no GitHub access for the default Starter.
|
|
111
127
|
|
|
112
|
-
The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
|
|
128
|
+
The Starter describes itself in `clubedge.template.json`: its app directory, site config, environment files, Docker image name, and files that stay out of generated projects. Schema 2 manifests also list each framework's app directory, environment example, and Dockerfile. The CLI moves the selected framework's app to `apps/web` (renaming its package to `@clubedge/web`), moves its environment example and Dockerfile to the project root, leaves the other frameworks out, and removes their entries from `pnpm-lock.yaml` so `pnpm install --frozen-lockfile` keeps working. The CLI refuses a Starter whose manifest schema is newer than it understands and asks you to update. Starters before v0.3.0 have no manifest and use the previous layout.
|
|
113
129
|
|
|
114
130
|
The CLI first builds a plan (the files to copy and the edits to make), which `--dry-run` prints, and then applies it. The source is organized the same way:
|
|
115
131
|
|
|
@@ -143,7 +159,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
|
|
|
143
159
|
1. Make and validate changes in `clubedge-starter`.
|
|
144
160
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
145
161
|
3. Update the Starter package versions.
|
|
146
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
162
|
+
4. Commit `main`, create a release tag such as `v0.4.0`, and publish the Starter release.
|
|
147
163
|
|
|
148
164
|
### CLI maintainers
|
|
149
165
|
|
package/dist/cli/args.js
CHANGED
|
@@ -11,6 +11,7 @@ export function parseCliArgs(argv) {
|
|
|
11
11
|
allowNegative: true,
|
|
12
12
|
strict: true,
|
|
13
13
|
options: {
|
|
14
|
+
framework: { type: "string" },
|
|
14
15
|
ref: { type: "string" },
|
|
15
16
|
"template-dir": { type: "string" },
|
|
16
17
|
install: { type: "boolean", default: true },
|
|
@@ -29,6 +30,9 @@ export function parseCliArgs(argv) {
|
|
|
29
30
|
const { values, positionals } = parsed;
|
|
30
31
|
if (positionals.length > 1)
|
|
31
32
|
throw new UsageError("Only one project directory can be provided.");
|
|
33
|
+
if (values.framework !== undefined && !values.framework.trim()) {
|
|
34
|
+
throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
|
|
35
|
+
}
|
|
32
36
|
if (values.ref !== undefined && !values.ref.trim()) {
|
|
33
37
|
throw new UsageError("--ref requires a tag, branch, or commit.");
|
|
34
38
|
}
|
|
@@ -37,6 +41,7 @@ export function parseCliArgs(argv) {
|
|
|
37
41
|
}
|
|
38
42
|
return {
|
|
39
43
|
projectDirectory: positionals[0],
|
|
44
|
+
framework: values.framework?.trim(),
|
|
40
45
|
ref: values.ref,
|
|
41
46
|
templateDir: values["template-dir"],
|
|
42
47
|
install: values.install,
|
package/dist/cli/ui.js
CHANGED
|
@@ -63,6 +63,7 @@ Usage:
|
|
|
63
63
|
pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
64
64
|
|
|
65
65
|
Options:
|
|
66
|
+
--framework <id> App framework: next (default) or tanstack-start
|
|
66
67
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
67
68
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
68
69
|
--dry-run Show what would be created without writing anything
|
|
@@ -74,6 +75,7 @@ Options:
|
|
|
74
75
|
|
|
75
76
|
Examples:
|
|
76
77
|
pnpm dlx @clubedge/create-clubedge-app my-app
|
|
78
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
|
|
77
79
|
pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
|
|
78
80
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
79
81
|
pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
package/dist/core/customize.js
CHANGED
|
@@ -9,6 +9,7 @@ export function customizeRootManifest(source, { packageName }, provenance, docke
|
|
|
9
9
|
starterRepository: provenance.starterRepository,
|
|
10
10
|
starterRef: provenance.starterRef,
|
|
11
11
|
...(provenance.starterCommit ? { starterCommit: provenance.starterCommit } : {}),
|
|
12
|
+
...(provenance.framework ? { framework: provenance.framework } : {}),
|
|
12
13
|
};
|
|
13
14
|
for (const script of ["docker:build", "docker:start"]) {
|
|
14
15
|
if (typeof manifest.scripts?.[script] === "string") {
|
|
@@ -21,9 +22,10 @@ export function customizeReadme(source, { displayName }, provenance) {
|
|
|
21
22
|
const commitLine = provenance.starterCommit
|
|
22
23
|
? `\n- Starter commit: \`${provenance.starterCommit}\``
|
|
23
24
|
: "";
|
|
25
|
+
const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
|
|
24
26
|
const origin = `## Generated from\n\n` +
|
|
25
27
|
`- Starter repository: \`${provenance.starterRepository}\`\n` +
|
|
26
|
-
`- Starter ref: \`${provenance.starterRef}\`${commitLine}\n` +
|
|
28
|
+
`- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}\n` +
|
|
27
29
|
`- CLI version: \`${provenance.cliVersion}\``;
|
|
28
30
|
// Only the first heading is the project title; leave every other line as written.
|
|
29
31
|
return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
|
|
@@ -54,3 +56,40 @@ export function customizeLegacySource(source, { displayName }) {
|
|
|
54
56
|
.replaceAll(">Clubedge<", `>${displayName}<`)
|
|
55
57
|
.replaceAll("Starter workspace", "Application workspace");
|
|
56
58
|
}
|
|
59
|
+
/** Gives the selected framework's app the package name generated projects use. */
|
|
60
|
+
export function customizeAppManifest(source, appPackage) {
|
|
61
|
+
const manifest = JSON.parse(source);
|
|
62
|
+
manifest.name = appPackage;
|
|
63
|
+
return json(manifest);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
|
|
67
|
+
* works. Importers are keyed by workspace path at two-space indentation; package entries that
|
|
68
|
+
* are no longer used are harmless, and pnpm prunes them on the next lockfile update.
|
|
69
|
+
*/
|
|
70
|
+
export function customizeLockfile(source, { remove, rename }) {
|
|
71
|
+
const output = [];
|
|
72
|
+
let inImporters = false;
|
|
73
|
+
let skipping = false;
|
|
74
|
+
for (const line of source.split("\n")) {
|
|
75
|
+
if (/^\S/.test(line)) {
|
|
76
|
+
inImporters = line === "importers:";
|
|
77
|
+
skipping = false;
|
|
78
|
+
}
|
|
79
|
+
else if (inImporters) {
|
|
80
|
+
// An importer key, with its value on the next lines or inline ("packages/core: {}").
|
|
81
|
+
const key = /^ {2}([^\s:][^:]*):(?: |$)/.exec(line)?.[1];
|
|
82
|
+
if (key !== undefined) {
|
|
83
|
+
skipping = remove.includes(key);
|
|
84
|
+
if (!skipping && key === rename?.from) {
|
|
85
|
+
output.push(` ${rename.to}:`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (skipping)
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
output.push(line);
|
|
93
|
+
}
|
|
94
|
+
return output.join("\n");
|
|
95
|
+
}
|
package/dist/core/manifest.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
export const MANIFEST_FILE = "clubedge.template.json";
|
|
4
|
-
export const SUPPORTED_SCHEMA_VERSION =
|
|
4
|
+
export const SUPPORTED_SCHEMA_VERSION = 2;
|
|
5
|
+
/** Schema 1 Starters ship a single Next.js app at the generated location. */
|
|
6
|
+
function singleNextFramework(app, envExample) {
|
|
7
|
+
return {
|
|
8
|
+
defaultFramework: "next",
|
|
9
|
+
frameworks: { next: { name: "Next.js", app, envExample, dockerfile: "Dockerfile" } },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
5
12
|
/** Starter releases before v0.3.0 have no manifest; this describes their layout. */
|
|
6
13
|
export const legacyManifest = {
|
|
7
14
|
schemaVersion: 1,
|
|
@@ -11,6 +18,7 @@ export const legacyManifest = {
|
|
|
11
18
|
siteConfig: "apps/web/src/config/site.json",
|
|
12
19
|
env: { example: ".env.example", target: "apps/web/.env.local" },
|
|
13
20
|
dockerImage: "clubedge-starter",
|
|
21
|
+
...singleNextFramework("apps/web", ".env.example"),
|
|
14
22
|
exclude: [],
|
|
15
23
|
};
|
|
16
24
|
export class ManifestError extends Error {
|
|
@@ -43,17 +51,47 @@ export function parseTemplateManifest(source) {
|
|
|
43
51
|
if (data.exclude !== undefined && !isStringArray(data.exclude)) {
|
|
44
52
|
throw new ManifestError(`${MANIFEST_FILE}: exclude must be an array of paths.`);
|
|
45
53
|
}
|
|
54
|
+
const app = requireString(data, "app");
|
|
55
|
+
const envExample = requireString(env, "example", "env.");
|
|
46
56
|
return {
|
|
47
57
|
schemaVersion,
|
|
48
58
|
name: requireString(data, "name"),
|
|
49
59
|
packageManager: "pnpm",
|
|
50
|
-
app
|
|
60
|
+
app,
|
|
61
|
+
...(schemaVersion >= 2 ? { appPackage: requireString(data, "appPackage") } : {}),
|
|
51
62
|
siteConfig: requireString(data, "siteConfig"),
|
|
52
|
-
env: { example:
|
|
63
|
+
env: { example: envExample, target: requireString(env, "target", "env.") },
|
|
53
64
|
dockerImage: requireString(data, "dockerImage"),
|
|
65
|
+
...(schemaVersion >= 2 ? parseFrameworks(data) : singleNextFramework(app, envExample)),
|
|
54
66
|
exclude: data.exclude ?? [],
|
|
55
67
|
};
|
|
56
68
|
}
|
|
69
|
+
function parseFrameworks(data) {
|
|
70
|
+
const { frameworks } = data;
|
|
71
|
+
if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
|
|
72
|
+
throw new ManifestError(`${MANIFEST_FILE}: frameworks must describe at least one framework.`);
|
|
73
|
+
}
|
|
74
|
+
const parsed = {};
|
|
75
|
+
for (const [id, entry] of Object.entries(frameworks)) {
|
|
76
|
+
if (!/^[a-z][a-z0-9-]*$/.test(id)) {
|
|
77
|
+
throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid framework id.`);
|
|
78
|
+
}
|
|
79
|
+
if (!isRecord(entry))
|
|
80
|
+
throw new ManifestError(`${MANIFEST_FILE}: frameworks.${id} must be an object.`);
|
|
81
|
+
const prefix = `frameworks.${id}.`;
|
|
82
|
+
parsed[id] = {
|
|
83
|
+
name: requireString(entry, "name", prefix),
|
|
84
|
+
app: requireString(entry, "app", prefix),
|
|
85
|
+
envExample: requireString(entry, "envExample", prefix),
|
|
86
|
+
dockerfile: requireString(entry, "dockerfile", prefix),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const defaultFramework = requireString(data, "defaultFramework");
|
|
90
|
+
if (!parsed[defaultFramework]) {
|
|
91
|
+
throw new ManifestError(`${MANIFEST_FILE}: defaultFramework "${defaultFramework}" is not in frameworks.`);
|
|
92
|
+
}
|
|
93
|
+
return { defaultFramework, frameworks: parsed };
|
|
94
|
+
}
|
|
57
95
|
export async function readTemplateManifest(templateRoot) {
|
|
58
96
|
let source;
|
|
59
97
|
try {
|
package/dist/core/plan.js
CHANGED
|
@@ -1,16 +1,59 @@
|
|
|
1
|
-
import { customizeLegacySource, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
|
|
1
|
+
import { customizeAppManifest, customizeLegacySource, customizeLockfile, customizeReadme, customizeRootManifest, customizeSiteConfig, legacyBrandedFiles, } from "./customize.js";
|
|
2
|
+
export class FrameworkError extends Error {
|
|
3
|
+
name = "FrameworkError";
|
|
4
|
+
}
|
|
5
|
+
const LOCKFILE = "pnpm-lock.yaml";
|
|
6
|
+
const isWithin = (path, directory) => path === directory || path.startsWith(`${directory.replace(/\/$/, "")}/`);
|
|
2
7
|
function isExcluded(path, exclude) {
|
|
3
|
-
return exclude.some((entry) => path
|
|
8
|
+
return exclude.some((entry) => isWithin(path, entry));
|
|
9
|
+
}
|
|
10
|
+
/** Resolves the requested framework, or the default, with a helpful error for unknown ids. */
|
|
11
|
+
export function selectFramework(manifest, requested) {
|
|
12
|
+
const id = requested ?? manifest.defaultFramework;
|
|
13
|
+
const framework = manifest.frameworks[id];
|
|
14
|
+
if (!framework) {
|
|
15
|
+
const choices = Object.keys(manifest.frameworks).join(", ");
|
|
16
|
+
throw new FrameworkError(`Unknown framework "${id}". This Starter offers: ${choices}.`);
|
|
17
|
+
}
|
|
18
|
+
return { id, ...framework };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Where a Starter file lands in the generated project, or null when it belongs to a framework
|
|
22
|
+
* that was not selected. The selected app moves to `manifest.app`, and its environment example
|
|
23
|
+
* and Dockerfile move to the generated project's locations.
|
|
24
|
+
*/
|
|
25
|
+
function relocate(path, manifest, selected, others) {
|
|
26
|
+
if (path === selected.envExample)
|
|
27
|
+
return manifest.env.example;
|
|
28
|
+
if (path === selected.dockerfile)
|
|
29
|
+
return "Dockerfile";
|
|
30
|
+
if (isWithin(path, selected.app))
|
|
31
|
+
return manifest.app + path.slice(selected.app.length);
|
|
32
|
+
const belongsToOther = others.some((other) => isWithin(path, other.app) || path === other.envExample || path === other.dockerfile);
|
|
33
|
+
return belongsToOther ? null : path;
|
|
4
34
|
}
|
|
5
35
|
export function createPlan(input) {
|
|
6
36
|
const { manifest, identity, source } = input;
|
|
37
|
+
const framework = selectFramework(manifest, input.framework);
|
|
38
|
+
const others = Object.entries(manifest.frameworks)
|
|
39
|
+
.filter(([id]) => id !== framework.id)
|
|
40
|
+
.map(([, other]) => other);
|
|
41
|
+
const multipleFrameworks = others.length > 0;
|
|
7
42
|
const provenance = {
|
|
8
43
|
cliVersion: input.cliVersion,
|
|
9
44
|
starterRepository: source.repository,
|
|
10
45
|
starterRef: source.ref,
|
|
11
46
|
...(source.commit ? { starterCommit: source.commit } : {}),
|
|
47
|
+
...(multipleFrameworks ? { framework: framework.id, frameworkName: framework.name } : {}),
|
|
12
48
|
};
|
|
13
|
-
const files =
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const file of source.files) {
|
|
51
|
+
if (isExcluded(file.to, manifest.exclude))
|
|
52
|
+
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 });
|
|
56
|
+
}
|
|
14
57
|
const has = (path) => files.some(({ to }) => to === path);
|
|
15
58
|
const edits = [];
|
|
16
59
|
if (has("package.json")) {
|
|
@@ -20,6 +63,26 @@ export function createPlan(input) {
|
|
|
20
63
|
apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
|
|
21
64
|
});
|
|
22
65
|
}
|
|
66
|
+
const appManifest = `${manifest.app}/package.json`;
|
|
67
|
+
if (manifest.appPackage && framework.app !== manifest.app && has(appManifest)) {
|
|
68
|
+
const appPackage = manifest.appPackage;
|
|
69
|
+
edits.push({
|
|
70
|
+
path: appManifest,
|
|
71
|
+
description: `Name the ${framework.name} app "${appPackage}"`,
|
|
72
|
+
apply: (text) => customizeAppManifest(text, appPackage),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (multipleFrameworks && has(LOCKFILE)) {
|
|
76
|
+
const changes = {
|
|
77
|
+
remove: others.map((other) => other.app).filter((app) => app !== framework.app),
|
|
78
|
+
...(framework.app !== manifest.app ? { rename: { from: framework.app, to: manifest.app } } : {}),
|
|
79
|
+
};
|
|
80
|
+
edits.push({
|
|
81
|
+
path: LOCKFILE,
|
|
82
|
+
description: `Keep only the ${framework.name} app in the lockfile`,
|
|
83
|
+
apply: (text) => customizeLockfile(text, changes),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
23
86
|
if (has("README.md")) {
|
|
24
87
|
edits.push({
|
|
25
88
|
path: "README.md",
|
|
@@ -47,6 +110,7 @@ export function createPlan(input) {
|
|
|
47
110
|
targetDirectory: input.targetDirectory,
|
|
48
111
|
identity,
|
|
49
112
|
provenance,
|
|
113
|
+
framework: { id: framework.id, name: framework.name },
|
|
50
114
|
files,
|
|
51
115
|
edits,
|
|
52
116
|
envFile: has(manifest.env.example) ? { from: manifest.env.example, to: manifest.env.target } : null,
|
|
@@ -57,6 +121,7 @@ export function createPlan(input) {
|
|
|
57
121
|
export function describePlan(plan) {
|
|
58
122
|
return [
|
|
59
123
|
`Create ${plan.targetDirectory}`,
|
|
124
|
+
...(plan.provenance.framework ? [`Use ${plan.framework.name}`] : []),
|
|
60
125
|
`Copy ${plan.files.length} files from ${plan.provenance.starterRepository}@${plan.provenance.starterRef}` +
|
|
61
126
|
(plan.provenance.starterCommit ? ` (${plan.provenance.starterCommit.slice(0, 8)})` : ""),
|
|
62
127
|
...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, 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,27 @@ 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
|
+
}
|
|
33
54
|
async function createProject(args) {
|
|
34
55
|
const interactive = Boolean(process.stdin.isTTY) && !args.yes;
|
|
35
56
|
const requested = args.projectDirectory ?? (interactive ? await askProjectDirectory() : "my-app");
|
|
@@ -43,29 +64,43 @@ async function createProject(args) {
|
|
|
43
64
|
progress.stop("Could not prepare the Starter");
|
|
44
65
|
throw error;
|
|
45
66
|
});
|
|
67
|
+
// The spinner runs while the manifest is read and again while files are written.
|
|
68
|
+
let spinning = "preparing";
|
|
46
69
|
try {
|
|
47
70
|
const manifest = await readTemplateManifest(source.root);
|
|
71
|
+
progress.stop(`Starter ${source.ref} ready`);
|
|
72
|
+
spinning = null;
|
|
73
|
+
const framework = await chooseFramework(manifest, args.framework, interactive);
|
|
74
|
+
if (framework === null) {
|
|
75
|
+
cancel("Operation cancelled.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
48
78
|
const plan = createPlan({
|
|
49
79
|
targetDirectory,
|
|
50
80
|
identity,
|
|
51
81
|
source,
|
|
52
82
|
manifest,
|
|
83
|
+
framework,
|
|
53
84
|
cliVersion,
|
|
54
85
|
git: args.git,
|
|
55
86
|
install: args.install,
|
|
56
87
|
});
|
|
57
88
|
if (args.dryRun) {
|
|
58
|
-
progress.stop("Dry run: nothing was written");
|
|
59
89
|
log.message(describePlan(plan).map((line) => `• ${line}`).join("\n"));
|
|
60
|
-
outro("Run again without --dry-run to create the project.");
|
|
90
|
+
outro("Dry run: nothing was written. Run again without --dry-run to create the project.");
|
|
61
91
|
return;
|
|
62
92
|
}
|
|
63
|
-
|
|
93
|
+
spinning = "writing";
|
|
94
|
+
progress.start(`Writing ${plan.files.length} files`);
|
|
64
95
|
await executePlan(plan, source);
|
|
65
|
-
progress.stop(`Created ${identity.packageName} from Starter ${source.ref}`);
|
|
96
|
+
progress.stop(`Created ${identity.packageName} with ${plan.framework.name} from Starter ${source.ref}`);
|
|
97
|
+
spinning = null;
|
|
66
98
|
}
|
|
67
99
|
catch (error) {
|
|
68
|
-
|
|
100
|
+
if (spinning === "preparing")
|
|
101
|
+
progress.stop("Could not read the Starter");
|
|
102
|
+
if (spinning === "writing")
|
|
103
|
+
progress.stop("Project scaffold failed; nothing was left behind");
|
|
69
104
|
throw error;
|
|
70
105
|
}
|
|
71
106
|
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.4.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.4.0",
|
|
26
|
+
"starterCommit": "fc01d33bb1a3944e099a1750679be87a96ff9a22"
|
|
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
|
@@ -23,21 +23,68 @@ jobs:
|
|
|
23
23
|
- run: pnpm lint
|
|
24
24
|
- run: pnpm typecheck
|
|
25
25
|
- run: pnpm test
|
|
26
|
-
|
|
27
|
-
- run:
|
|
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
|
|
28
28
|
- run: pnpm build
|
|
29
|
+
- name: Serve both production builds
|
|
30
|
+
run: |
|
|
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
|
|
43
|
+
done
|
|
29
44
|
- run: pnpm audit --audit-level high
|
|
30
45
|
|
|
46
|
+
# The same browser suite runs against every framework.
|
|
47
|
+
e2e:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
strategy:
|
|
50
|
+
fail-fast: false
|
|
51
|
+
matrix:
|
|
52
|
+
app: [web, start]
|
|
53
|
+
env:
|
|
54
|
+
E2E_APP: ${{ matrix.app }}
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
- uses: pnpm/action-setup@v4
|
|
58
|
+
- uses: actions/setup-node@v4
|
|
59
|
+
with:
|
|
60
|
+
node-version: 22
|
|
61
|
+
cache: pnpm
|
|
62
|
+
- run: pnpm install --frozen-lockfile
|
|
63
|
+
- run: pnpm exec playwright install --with-deps chromium
|
|
64
|
+
- run: pnpm test:e2e
|
|
65
|
+
|
|
31
66
|
docker:
|
|
32
67
|
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 .
|
|
33
78
|
steps:
|
|
34
79
|
- uses: actions/checkout@v4
|
|
35
80
|
- name: Build the production image
|
|
36
|
-
run:
|
|
81
|
+
run: ${{ matrix.build }}
|
|
37
82
|
- name: Start the image and check health
|
|
38
83
|
run: |
|
|
39
84
|
set -euo pipefail
|
|
40
|
-
docker run --detach --name app --publish 3000:3000
|
|
85
|
+
docker run --detach --name app --publish 3000:3000 \
|
|
86
|
+
--env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \
|
|
87
|
+
clubedge-starter:ci
|
|
41
88
|
trap 'docker logs app; docker rm --force app' EXIT
|
|
42
89
|
for attempt in $(seq 1 30); do
|
|
43
90
|
if curl --fail --silent http://127.0.0.1:3000/api/health; then
|
package/template/.prettierignore
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
|
|
5
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.
|
|
6
6
|
- `packages/core` depends on no other workspace package.
|
|
7
|
-
- `
|
|
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`).
|
|
8
9
|
- 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 `
|
|
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.
|
|
10
11
|
- `pnpm lint` enforces these boundaries. Run `pnpm lint`, `pnpm typecheck`, and `pnpm test` before finishing a change.
|
|
11
12
|
|
|
12
13
|
<!-- BEGIN:nextjs-agent-rules -->
|
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 infrastructure in `packages/{core,auth,db,storage,cache}`. 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.
|