@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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.5.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.5.0`, which adds optional modules, Better Auth, and template schema 3.
|
|
8
|
+
- Add `--auth`, `--storage`, and `--cache`, and offer to customize them interactively. Options whose requirements are not met (Supabase Storage without Supabase Auth) are refused with an explanation.
|
|
9
|
+
- Read template schema 3 modules: unselected options' packages, files, provider dependencies, environment variables, and lockfile entries are removed, and selected variants replace the default files.
|
|
10
|
+
- Process `clubedge:if` blocks in the Starter's documentation, environment examples, and sources, so a project only describes what it contains.
|
|
11
|
+
- Support Mermaid (`%%`) markers and single-line `clubedge:only` conditions, and leave no blank lines at file edges after removing blocks.
|
|
12
|
+
- Record the selected modules in the generated `package.json` and `README.md`. The smoke test skips the sign-up check for projects without authentication.
|
|
13
|
+
|
|
14
|
+
## 0.4.0
|
|
15
|
+
|
|
16
|
+
- Default to Starter `v0.4.0`, which adds the TanStack Start app and template schema 2.
|
|
17
|
+
- Add `--framework next|tanstack-start`, and ask which framework to use when the Starter offers several. Next.js stays the default.
|
|
18
|
+
- 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.
|
|
19
|
+
- Record the chosen framework in the generated `package.json` and `README.md`.
|
|
20
|
+
- Test generated projects for both frameworks in CI and in the release smoke test.
|
|
21
|
+
|
|
5
22
|
## 0.3.0
|
|
6
23
|
|
|
7
24
|
- 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.5.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
18
|
+
Clubedge Starter v0.5.0
|
|
19
|
+
commit d9375c2c3b520323ff0255ef8ec65da19251c3c1
|
|
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.5.0` | `clubedge-starter@v0.5.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.5.0` tag is expected to resolve to commit
|
|
31
|
+
`d9375c2c3b520323ff0255ef8ec65da19251c3c1`. 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.5.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,47 @@ 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
|
+
|
|
86
|
+
### Choosing services
|
|
87
|
+
|
|
88
|
+
Authentication, file storage, and caching are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries.
|
|
89
|
+
|
|
90
|
+
| Flag | Options | Default |
|
|
91
|
+
| ----------- | ------------------------------------------ | ---------- |
|
|
92
|
+
| `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
|
|
93
|
+
| `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
|
|
94
|
+
| `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
# Only the monorepo, UI, database, and tooling
|
|
98
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --auth none --storage none --cache memory
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Interactive runs ask whether to customize these after the framework question. The options come from the Starter's manifest, so `--help` lists the ones the bundled Starter offers.
|
|
102
|
+
|
|
71
103
|
## Options
|
|
72
104
|
|
|
73
105
|
```text
|
|
74
106
|
Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
75
107
|
|
|
108
|
+
--framework <id> App framework: next (default) or tanstack-start
|
|
109
|
+
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
110
|
+
--storage <id> File storage: s3 (default), supabase, or none
|
|
111
|
+
--cache <id> Cache and rate limits: redis (default) or memory
|
|
76
112
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
77
113
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
78
114
|
--dry-run Show what would be created without writing anything
|
|
@@ -85,20 +121,20 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
|
85
121
|
|
|
86
122
|
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
123
|
|
|
88
|
-
Every generated project records its CLI version, Starter repository, Starter ref,
|
|
124
|
+
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
89
125
|
|
|
90
126
|
```sh
|
|
91
127
|
# Preview the files and edits without writing anything
|
|
92
128
|
pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
|
|
93
129
|
|
|
94
130
|
# Non-interactive, for scripts and CI
|
|
95
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git
|
|
131
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstack-start
|
|
96
132
|
|
|
97
133
|
# Try an unreleased Starter change
|
|
98
134
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
99
135
|
|
|
100
136
|
# Reproduce a specific Starter release or commit
|
|
101
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
137
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.5.0
|
|
102
138
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
103
139
|
|
|
104
140
|
# Test local Starter changes before releasing them
|
|
@@ -109,7 +145,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
|
|
109
145
|
|
|
110
146
|
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
147
|
|
|
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.
|
|
148
|
+
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. Schema 3 adds `modules`: for each option, the workspace packages it uses, the files it owns, the variant files that replace defaults, and the options it requires. Files listed under `conditional` contain `clubedge:if <condition>` / `clubedge:end` blocks (in `#`, `//`, `<!-- -->`, or JSX comments) that the CLI keeps or drops for the selection, always removing the markers; `clubedge:if starter-repository` marks sections for Starter maintainers only. 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
149
|
|
|
114
150
|
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
151
|
|
|
@@ -143,7 +179,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
|
|
|
143
179
|
1. Make and validate changes in `clubedge-starter`.
|
|
144
180
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
145
181
|
3. Update the Starter package versions.
|
|
146
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
182
|
+
4. Commit `main`, create a release tag such as `v0.5.0`, and publish the Starter release.
|
|
147
183
|
|
|
148
184
|
### CLI maintainers
|
|
149
185
|
|
package/dist/cli/args.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
+
/** Module flags the CLI accepts. Which options exist is up to the Starter's manifest. */
|
|
3
|
+
export const MODULE_FLAGS = ["auth", "storage", "cache"];
|
|
2
4
|
export class UsageError extends Error {
|
|
3
5
|
name = "UsageError";
|
|
4
6
|
}
|
|
@@ -11,6 +13,10 @@ export function parseCliArgs(argv) {
|
|
|
11
13
|
allowNegative: true,
|
|
12
14
|
strict: true,
|
|
13
15
|
options: {
|
|
16
|
+
framework: { type: "string" },
|
|
17
|
+
auth: { type: "string" },
|
|
18
|
+
storage: { type: "string" },
|
|
19
|
+
cache: { type: "string" },
|
|
14
20
|
ref: { type: "string" },
|
|
15
21
|
"template-dir": { type: "string" },
|
|
16
22
|
install: { type: "boolean", default: true },
|
|
@@ -29,6 +35,18 @@ export function parseCliArgs(argv) {
|
|
|
29
35
|
const { values, positionals } = parsed;
|
|
30
36
|
if (positionals.length > 1)
|
|
31
37
|
throw new UsageError("Only one project directory can be provided.");
|
|
38
|
+
if (values.framework !== undefined && !values.framework.trim()) {
|
|
39
|
+
throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
|
|
40
|
+
}
|
|
41
|
+
const modules = {};
|
|
42
|
+
for (const module of MODULE_FLAGS) {
|
|
43
|
+
const value = values[module];
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
if (!value.trim())
|
|
47
|
+
throw new UsageError(`--${module} requires an option, such as none.`);
|
|
48
|
+
modules[module] = value.trim();
|
|
49
|
+
}
|
|
32
50
|
if (values.ref !== undefined && !values.ref.trim()) {
|
|
33
51
|
throw new UsageError("--ref requires a tag, branch, or commit.");
|
|
34
52
|
}
|
|
@@ -37,6 +55,8 @@ export function parseCliArgs(argv) {
|
|
|
37
55
|
}
|
|
38
56
|
return {
|
|
39
57
|
projectDirectory: positionals[0],
|
|
58
|
+
framework: values.framework?.trim(),
|
|
59
|
+
modules,
|
|
40
60
|
ref: values.ref,
|
|
41
61
|
templateDir: values["template-dir"],
|
|
42
62
|
install: values.install,
|
package/dist/cli/ui.js
CHANGED
|
@@ -63,6 +63,10 @@ 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
|
|
67
|
+
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
68
|
+
--storage <id> File storage: s3 (default), supabase, or none
|
|
69
|
+
--cache <id> Cache and rate limits: redis (default) or memory
|
|
66
70
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
67
71
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
68
72
|
--dry-run Show what would be created without writing anything
|
|
@@ -74,6 +78,8 @@ Options:
|
|
|
74
78
|
|
|
75
79
|
Examples:
|
|
76
80
|
pnpm dlx @clubedge/create-clubedge-app my-app
|
|
81
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --framework tanstack-start
|
|
82
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --auth none --storage none --cache memory
|
|
77
83
|
pnpm dlx @clubedge/create-clubedge-app my-app --dry-run
|
|
78
84
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
79
85
|
pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
package/dist/core/customize.js
CHANGED
|
@@ -9,6 +9,8 @@ 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 } : {}),
|
|
13
|
+
...(provenance.modules ? { modules: provenance.modules } : {}),
|
|
12
14
|
};
|
|
13
15
|
for (const script of ["docker:build", "docker:start"]) {
|
|
14
16
|
if (typeof manifest.scripts?.[script] === "string") {
|
|
@@ -21,9 +23,13 @@ export function customizeReadme(source, { displayName }, provenance) {
|
|
|
21
23
|
const commitLine = provenance.starterCommit
|
|
22
24
|
? `\n- Starter commit: \`${provenance.starterCommit}\``
|
|
23
25
|
: "";
|
|
26
|
+
const frameworkLine = provenance.frameworkName ? `\n- Framework: ${provenance.frameworkName}` : "";
|
|
27
|
+
const modulesLine = provenance.moduleSummary?.length
|
|
28
|
+
? `\n- Modules: ${provenance.moduleSummary.join(", ")}`
|
|
29
|
+
: "";
|
|
24
30
|
const origin = `## Generated from\n\n` +
|
|
25
31
|
`- Starter repository: \`${provenance.starterRepository}\`\n` +
|
|
26
|
-
`- Starter ref: \`${provenance.starterRef}\`${commitLine}\n` +
|
|
32
|
+
`- Starter ref: \`${provenance.starterRef}\`${commitLine}${frameworkLine}${modulesLine}\n` +
|
|
27
33
|
`- CLI version: \`${provenance.cliVersion}\``;
|
|
28
34
|
// Only the first heading is the project title; leave every other line as written.
|
|
29
35
|
return source.replace(/^# .+$/m, `# ${displayName}\n\n${origin}`);
|
|
@@ -54,3 +60,163 @@ export function customizeLegacySource(source, { displayName }) {
|
|
|
54
60
|
.replaceAll(">Clubedge<", `>${displayName}<`)
|
|
55
61
|
.replaceAll("Starter workspace", "Application workspace");
|
|
56
62
|
}
|
|
63
|
+
/** Gives the selected framework's app the package name generated projects use. */
|
|
64
|
+
export function customizeAppManifest(source, appPackage) {
|
|
65
|
+
const manifest = JSON.parse(source);
|
|
66
|
+
manifest.name = appPackage;
|
|
67
|
+
return json(manifest);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Keeps pnpm-lock.yaml in step with the selected framework so `pnpm install --frozen-lockfile`
|
|
71
|
+
* works. Importers are keyed by workspace path at two-space indentation; package entries that
|
|
72
|
+
* are no longer used are harmless, and pnpm prunes them on the next lockfile update.
|
|
73
|
+
*/
|
|
74
|
+
export function customizeLockfile(source, { remove, rename, removeDependencies = [] }) {
|
|
75
|
+
const output = [];
|
|
76
|
+
let inImporters = false;
|
|
77
|
+
let skippingImporter = false;
|
|
78
|
+
let skippingDependency = false;
|
|
79
|
+
// A dependency group header (" dependencies:") is held until it gets an entry, so a group
|
|
80
|
+
// whose entries were all removed disappears instead of becoming an empty YAML value.
|
|
81
|
+
let pendingGroup = null;
|
|
82
|
+
for (const line of source.split("\n")) {
|
|
83
|
+
if (/^\S/.test(line)) {
|
|
84
|
+
inImporters = line === "importers:";
|
|
85
|
+
skippingImporter = skippingDependency = false;
|
|
86
|
+
pendingGroup = null;
|
|
87
|
+
output.push(line);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!inImporters) {
|
|
91
|
+
output.push(line);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// An importer key, with its value on the next lines or inline ("packages/core: {}").
|
|
95
|
+
const importer = /^ {2}([^\s:][^:]*):(?: |$)/.exec(line)?.[1];
|
|
96
|
+
if (importer !== undefined) {
|
|
97
|
+
skippingImporter = remove.includes(importer);
|
|
98
|
+
skippingDependency = false;
|
|
99
|
+
pendingGroup = null;
|
|
100
|
+
if (!skippingImporter)
|
|
101
|
+
output.push(importer === rename?.from ? ` ${rename.to}:` : line);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (skippingImporter)
|
|
105
|
+
continue;
|
|
106
|
+
if (/^ {4}\S/.test(line)) {
|
|
107
|
+
skippingDependency = false;
|
|
108
|
+
pendingGroup = line;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const dependency = /^ {6}(['"]?)([^'":]+)\1:\s*$/.exec(line)?.[2];
|
|
112
|
+
if (dependency !== undefined) {
|
|
113
|
+
skippingDependency = removeDependencies.includes(dependency);
|
|
114
|
+
if (skippingDependency)
|
|
115
|
+
continue;
|
|
116
|
+
if (pendingGroup !== null) {
|
|
117
|
+
output.push(pendingGroup);
|
|
118
|
+
pendingGroup = null;
|
|
119
|
+
}
|
|
120
|
+
output.push(line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (skippingDependency && /^ {8}/.test(line))
|
|
124
|
+
continue;
|
|
125
|
+
if (pendingGroup !== null && line.trim() !== "") {
|
|
126
|
+
output.push(pendingGroup);
|
|
127
|
+
pendingGroup = null;
|
|
128
|
+
}
|
|
129
|
+
skippingDependency = false;
|
|
130
|
+
output.push(line);
|
|
131
|
+
}
|
|
132
|
+
return output.join("\n");
|
|
133
|
+
}
|
|
134
|
+
export class ConditionalError extends Error {
|
|
135
|
+
name = "ConditionalError";
|
|
136
|
+
}
|
|
137
|
+
// A marker line in any comment style the Starter uses: //, #, %% (Mermaid), <!-- -->, /* */,
|
|
138
|
+
// or {/* */}.
|
|
139
|
+
const MARKER = /^\s*(?:\/\/|#|%%|<!--|\{?\/\*)\s*clubedge:(if|end)\b\s*(.*?)\s*(?:-->|\*\/\}?)?\s*$/;
|
|
140
|
+
// A single line kept only when its condition holds: "- Item <!-- clubedge:only auth=supabase -->".
|
|
141
|
+
const LINE_MARKER = /\s*(?:\/\/|#|<!--|\{?\/\*)\s*clubedge:only\s+(.+?)\s*(?:-->|\*\/\}?)?\s*$/;
|
|
142
|
+
function evaluateCondition(expression, selection, location) {
|
|
143
|
+
// Maintainer-only sections of the Starter repository never reach a generated project.
|
|
144
|
+
if (expression.trim() === "starter-repository")
|
|
145
|
+
return false;
|
|
146
|
+
return expression.split("&&").every((part) => {
|
|
147
|
+
const match = /^\s*([a-z][a-z0-9-]*)\s*(!?=)\s*([a-z0-9|-]+)\s*$/.exec(part);
|
|
148
|
+
if (!match)
|
|
149
|
+
throw new ConditionalError(`${location}: cannot read the condition "${expression}".`);
|
|
150
|
+
const [, key, operator, values] = match;
|
|
151
|
+
const selected = selection[key];
|
|
152
|
+
if (selected === undefined)
|
|
153
|
+
throw new ConditionalError(`${location}: unknown key "${key}".`);
|
|
154
|
+
const matches = values.split("|").includes(selected);
|
|
155
|
+
return operator === "=" ? matches : !matches;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Keeps or drops `clubedge:if <condition>` ... `clubedge:end` blocks for the selection and
|
|
160
|
+
* always removes the marker lines. Conditions look like `auth=none`, `storage!=none`,
|
|
161
|
+
* `auth=supabase|better-auth`, or several joined with `&&`. Blocks do not nest.
|
|
162
|
+
*/
|
|
163
|
+
export function applyConditionals(source, selection, path = "file") {
|
|
164
|
+
const output = [];
|
|
165
|
+
let open = null;
|
|
166
|
+
let removed = false;
|
|
167
|
+
source.split("\n").forEach((line, index) => {
|
|
168
|
+
const marker = MARKER.exec(line);
|
|
169
|
+
const location = `${path}:${index + 1}`;
|
|
170
|
+
if (marker?.[1] === "if") {
|
|
171
|
+
if (open)
|
|
172
|
+
throw new ConditionalError(`${location}: clubedge:if blocks cannot nest.`);
|
|
173
|
+
open = { keep: evaluateCondition(marker[2] ?? "", selection, location), line: index + 1 };
|
|
174
|
+
removed = removed || !open.keep;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (marker?.[1] === "end") {
|
|
178
|
+
if (!open)
|
|
179
|
+
throw new ConditionalError(`${location}: clubedge:end without clubedge:if.`);
|
|
180
|
+
open = null;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (open && !open.keep)
|
|
184
|
+
return;
|
|
185
|
+
const lineMarker = LINE_MARKER.exec(line);
|
|
186
|
+
if (lineMarker && line.trim() !== lineMarker[0].trim()) {
|
|
187
|
+
if (!evaluateCondition(lineMarker[1], selection, location)) {
|
|
188
|
+
removed = true;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
output.push(line.slice(0, lineMarker.index));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
output.push(line);
|
|
195
|
+
});
|
|
196
|
+
if (open)
|
|
197
|
+
throw new ConditionalError(`${path}:${open.line}: clubedge:if is never closed.`);
|
|
198
|
+
const text = output.join("\n");
|
|
199
|
+
if (!removed)
|
|
200
|
+
return text;
|
|
201
|
+
// A removed block can leave two blank lines in a row, or blank lines at the start or end of
|
|
202
|
+
// the file; formatted sources never have them.
|
|
203
|
+
const collapsed = text.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
|
|
204
|
+
return source.endsWith("\n") ? collapsed.replace(/\n+$/, "\n") : collapsed;
|
|
205
|
+
}
|
|
206
|
+
/** Removes dependencies on workspace packages that were left out of the project. */
|
|
207
|
+
export function removePackageDependencies(source, names) {
|
|
208
|
+
const manifest = JSON.parse(source);
|
|
209
|
+
let changed = false;
|
|
210
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
211
|
+
const dependencies = manifest[field];
|
|
212
|
+
if (!dependencies || typeof dependencies !== "object")
|
|
213
|
+
continue;
|
|
214
|
+
for (const name of names) {
|
|
215
|
+
if (name in dependencies) {
|
|
216
|
+
delete dependencies[name];
|
|
217
|
+
changed = true;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return changed ? json(manifest) : source;
|
|
222
|
+
}
|
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 = 3;
|
|
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,9 @@ 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"),
|
|
22
|
+
modules: {},
|
|
23
|
+
conditional: [],
|
|
14
24
|
exclude: [],
|
|
15
25
|
};
|
|
16
26
|
export class ManifestError extends Error {
|
|
@@ -43,17 +53,134 @@ export function parseTemplateManifest(source) {
|
|
|
43
53
|
if (data.exclude !== undefined && !isStringArray(data.exclude)) {
|
|
44
54
|
throw new ManifestError(`${MANIFEST_FILE}: exclude must be an array of paths.`);
|
|
45
55
|
}
|
|
56
|
+
const app = requireString(data, "app");
|
|
57
|
+
const envExample = requireString(env, "example", "env.");
|
|
46
58
|
return {
|
|
47
59
|
schemaVersion,
|
|
48
60
|
name: requireString(data, "name"),
|
|
49
61
|
packageManager: "pnpm",
|
|
50
|
-
app
|
|
62
|
+
app,
|
|
63
|
+
...(schemaVersion >= 2 ? { appPackage: requireString(data, "appPackage") } : {}),
|
|
51
64
|
siteConfig: requireString(data, "siteConfig"),
|
|
52
|
-
env: { example:
|
|
65
|
+
env: { example: envExample, target: requireString(env, "target", "env.") },
|
|
53
66
|
dockerImage: requireString(data, "dockerImage"),
|
|
67
|
+
...(schemaVersion >= 2 ? parseFrameworks(data) : singleNextFramework(app, envExample)),
|
|
68
|
+
modules: schemaVersion >= 3 ? parseModules(data.modules) : {},
|
|
69
|
+
conditional: schemaVersion >= 3 ? optionalStringArray(data, "conditional") : [],
|
|
54
70
|
exclude: data.exclude ?? [],
|
|
55
71
|
};
|
|
56
72
|
}
|
|
73
|
+
const ID = /^[a-z][a-z0-9-]*$/;
|
|
74
|
+
function parseModules(value) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return {};
|
|
77
|
+
if (!isRecord(value))
|
|
78
|
+
throw new ManifestError(`${MANIFEST_FILE}: modules must be an object.`);
|
|
79
|
+
const modules = {};
|
|
80
|
+
for (const [id, entry] of Object.entries(value)) {
|
|
81
|
+
if (!ID.test(id) || id === "framework") {
|
|
82
|
+
throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid module id.`);
|
|
83
|
+
}
|
|
84
|
+
if (!isRecord(entry))
|
|
85
|
+
throw new ManifestError(`${MANIFEST_FILE}: modules.${id} must be an object.`);
|
|
86
|
+
const prefix = `modules.${id}.`;
|
|
87
|
+
const optionsValue = entry.options;
|
|
88
|
+
if (!isRecord(optionsValue) || Object.keys(optionsValue).length === 0) {
|
|
89
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}options must describe at least one option.`);
|
|
90
|
+
}
|
|
91
|
+
const options = {};
|
|
92
|
+
for (const [optionId, option] of Object.entries(optionsValue)) {
|
|
93
|
+
const optionPrefix = `${prefix}options.${optionId}.`;
|
|
94
|
+
if (!ID.test(optionId)) {
|
|
95
|
+
throw new ManifestError(`${MANIFEST_FILE}: "${optionId}" is not a valid option id in ${prefix.slice(0, -1)}.`);
|
|
96
|
+
}
|
|
97
|
+
if (!isRecord(option))
|
|
98
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${optionPrefix.slice(0, -1)} must be an object.`);
|
|
99
|
+
options[optionId] = {
|
|
100
|
+
name: requireString(option, "name", optionPrefix),
|
|
101
|
+
packages: optionalStringRecord(option, "packages", optionPrefix),
|
|
102
|
+
files: optionalStringArray(option, "files", optionPrefix),
|
|
103
|
+
replace: optionalStringRecord(option, "replace", optionPrefix),
|
|
104
|
+
requires: parseRequires(option.requires, optionPrefix),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const defaultOption = requireString(entry, "default", prefix);
|
|
108
|
+
if (!options[defaultOption]) {
|
|
109
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}default "${defaultOption}" is not in its options.`);
|
|
110
|
+
}
|
|
111
|
+
modules[id] = { name: requireString(entry, "name", prefix), default: defaultOption, options };
|
|
112
|
+
}
|
|
113
|
+
// Requirements must name real modules and options, or a selection could never be valid.
|
|
114
|
+
for (const [id, module] of Object.entries(modules)) {
|
|
115
|
+
for (const [optionId, option] of Object.entries(module.options)) {
|
|
116
|
+
for (const [requiredModule, allowed] of Object.entries(option.requires)) {
|
|
117
|
+
const unknown = allowed.find((value) => !modules[requiredModule]?.options[value]);
|
|
118
|
+
if (unknown !== undefined) {
|
|
119
|
+
throw new ManifestError(`${MANIFEST_FILE}: modules.${id}.options.${optionId}.requires names unknown option ${requiredModule}=${unknown}.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return modules;
|
|
125
|
+
}
|
|
126
|
+
function parseRequires(value, prefix) {
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
return {};
|
|
129
|
+
if (!isRecord(value))
|
|
130
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}requires must be an object.`);
|
|
131
|
+
const requires = {};
|
|
132
|
+
for (const [module, allowed] of Object.entries(value)) {
|
|
133
|
+
const list = typeof allowed === "string" ? [allowed] : allowed;
|
|
134
|
+
if (!isStringArray(list) || list.length === 0) {
|
|
135
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}requires.${module} must name one or more options.`);
|
|
136
|
+
}
|
|
137
|
+
requires[module] = list;
|
|
138
|
+
}
|
|
139
|
+
return requires;
|
|
140
|
+
}
|
|
141
|
+
function optionalStringArray(record, key, prefix = "") {
|
|
142
|
+
const value = record[key];
|
|
143
|
+
if (value === undefined)
|
|
144
|
+
return [];
|
|
145
|
+
if (!isStringArray(value))
|
|
146
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must be an array of paths.`);
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function optionalStringRecord(record, key, prefix = "") {
|
|
150
|
+
const value = record[key];
|
|
151
|
+
if (value === undefined)
|
|
152
|
+
return {};
|
|
153
|
+
if (!isRecord(value) || !Object.values(value).every((item) => typeof item === "string" && item)) {
|
|
154
|
+
throw new ManifestError(`${MANIFEST_FILE}: ${prefix}${key} must map names to paths.`);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
function parseFrameworks(data) {
|
|
159
|
+
const { frameworks } = data;
|
|
160
|
+
if (!isRecord(frameworks) || Object.keys(frameworks).length === 0) {
|
|
161
|
+
throw new ManifestError(`${MANIFEST_FILE}: frameworks must describe at least one framework.`);
|
|
162
|
+
}
|
|
163
|
+
const parsed = {};
|
|
164
|
+
for (const [id, entry] of Object.entries(frameworks)) {
|
|
165
|
+
if (!ID.test(id)) {
|
|
166
|
+
throw new ManifestError(`${MANIFEST_FILE}: "${id}" is not a valid framework id.`);
|
|
167
|
+
}
|
|
168
|
+
if (!isRecord(entry))
|
|
169
|
+
throw new ManifestError(`${MANIFEST_FILE}: frameworks.${id} must be an object.`);
|
|
170
|
+
const prefix = `frameworks.${id}.`;
|
|
171
|
+
parsed[id] = {
|
|
172
|
+
name: requireString(entry, "name", prefix),
|
|
173
|
+
app: requireString(entry, "app", prefix),
|
|
174
|
+
envExample: requireString(entry, "envExample", prefix),
|
|
175
|
+
dockerfile: requireString(entry, "dockerfile", prefix),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const defaultFramework = requireString(data, "defaultFramework");
|
|
179
|
+
if (!parsed[defaultFramework]) {
|
|
180
|
+
throw new ManifestError(`${MANIFEST_FILE}: defaultFramework "${defaultFramework}" is not in frameworks.`);
|
|
181
|
+
}
|
|
182
|
+
return { defaultFramework, frameworks: parsed };
|
|
183
|
+
}
|
|
57
184
|
export async function readTemplateManifest(templateRoot) {
|
|
58
185
|
let source;
|
|
59
186
|
try {
|