@clubedge/create-clubedge-app 0.8.0 → 0.10.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 +14 -0
- package/README.md +15 -13
- package/dist/cli/args.js +60 -33
- package/dist/cli/ui.js +32 -8
- package/dist/core/env.js +10 -3
- package/dist/index.js +26 -5
- package/dist/modify.js +90 -13
- package/package.json +3 -3
- package/template/.env.example +20 -0
- package/template/README.md +22 -1
- package/template/SETUP.md +38 -4
- package/template/apps/start/.env.example +20 -0
- package/template/apps/start/package.json +4 -1
- package/template/apps/start/scripts/check-services.ts +38 -0
- package/template/apps/start/src/components/auth/auth-form-controls.tsx +8 -2
- package/template/apps/start/src/components/auth/auth-page.tsx +50 -0
- package/template/apps/start/src/components/auth/notice.tsx +33 -0
- package/template/apps/start/src/env/auth.ts +9 -1
- package/template/apps/start/src/lib/login-url.ts +10 -1
- package/template/apps/start/src/routes/auth/forgot-password.ts +11 -0
- package/template/apps/start/src/routes/auth/reset-password.ts +11 -0
- package/template/apps/start/src/routes/forgot-password.tsx +46 -0
- package/template/apps/start/src/routes/login.tsx +23 -32
- package/template/apps/start/src/routes/reset-password.tsx +52 -0
- package/template/apps/start/src/server/auth-email.ts +18 -0
- package/template/apps/start/src/server/auth-forms.ts +58 -2
- package/template/apps/start/src/server/auth.functions.ts +9 -5
- package/template/apps/start/src/server/email.ts +39 -0
- package/template/apps/start/src/server/variants/auth.better-auth.ts +8 -1
- package/template/apps/start/src/server/variants/email.resend.ts +39 -0
- package/template/apps/web/package.json +4 -1
- package/template/apps/web/scripts/check-services.ts +38 -0
- package/template/apps/web/src/app/actions/auth.ts +49 -2
- package/template/apps/web/src/app/auth/callback/route.ts +11 -2
- package/template/apps/web/src/app/forgot-password/page.tsx +40 -0
- package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +8 -2
- package/template/apps/web/src/app/login/_components/auth-page.tsx +50 -0
- package/template/apps/web/src/app/login/_components/notice.tsx +33 -0
- package/template/apps/web/src/app/login/page.tsx +23 -32
- package/template/apps/web/src/app/reset-password/page.tsx +47 -0
- package/template/apps/web/src/env/auth.ts +4 -0
- package/template/apps/web/src/lib/login-url.ts +10 -1
- package/template/apps/web/src/server/auth-email.ts +18 -0
- package/template/apps/web/src/server/email.ts +39 -0
- package/template/apps/web/src/server/variants/auth.better-auth.ts +8 -1
- package/template/apps/web/src/server/variants/email.resend.ts +39 -0
- package/template/clubedge.template.json +49 -2
- package/template/docker-compose.dev.yml +13 -0
- package/template/e2e/email-verification.spec.ts +29 -0
- package/template/e2e/inbox.ts +53 -0
- package/template/e2e/password-reset.spec.ts +48 -0
- package/template/package.json +2 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth/src/types.ts +19 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-better-auth/src/emails.ts +78 -0
- package/template/packages/auth-better-auth/src/index.test.ts +133 -2
- package/template/packages/auth-better-auth/src/index.ts +135 -7
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/auth-supabase/src/index.test.ts +45 -0
- package/template/packages/auth-supabase/src/index.ts +12 -0
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/email/package.json +18 -0
- package/template/packages/email/src/index.test.ts +57 -0
- package/template/packages/email/src/index.ts +50 -0
- package/template/packages/email/tsconfig.json +4 -0
- package/template/packages/email-resend/package.json +21 -0
- package/template/packages/email-resend/src/index.test.ts +56 -0
- package/template/packages/email-resend/src/index.ts +56 -0
- package/template/packages/email-resend/tsconfig.json +4 -0
- package/template/packages/email-smtp/package.json +22 -0
- package/template/packages/email-smtp/src/index.test.ts +73 -0
- package/template/packages/email-smtp/src/index.ts +26 -0
- package/template/packages/email-smtp/tsconfig.json +4 -0
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/pnpm-lock.yaml +53 -0
- package/template/supabase/config.toml +6 -1
- package/template.lock.json +34 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.10.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.9.0`, which adds the email module (`--email smtp|resend|none`), password reset for both auth providers, and email verification for Better Auth. `--email` needs no CLI code: it comes from the Starter's manifest.
|
|
8
|
+
- Fix a doubled blank line in `.env.local` when `add` or `remove` swaps one section of variables for another, such as switching `--email smtp` to `resend`.
|
|
9
|
+
- Take module flags from the Starter's manifest: any `--<module> <option>` flag is accepted and validated against the modules the Starter describes, so new Starter modules need no CLI release. A mistyped module lists the Starter's modules; an unknown flag without a value is still an error.
|
|
10
|
+
- Build the help text's framework and module lines, and the "customize the included services" prompt, from the Starter's manifest.
|
|
11
|
+
|
|
12
|
+
## 0.9.0
|
|
13
|
+
|
|
14
|
+
- Default to Starter `v0.8.0`, whose projects have a `clubedge` script: `pnpm clubedge add`, `remove`, and `upgrade`.
|
|
15
|
+
- `add` and `remove` ask for the module and option when they are not given, offering only options the project can use. Without a terminal, missing arguments remain an error.
|
|
16
|
+
- Messages suggest `pnpm clubedge ...` in projects with the `clubedge` script, and the outro after creating a project mentions it.
|
|
17
|
+
- Test the CLI on Windows in CI: its checks and tests, and generated projects with a module change.
|
|
18
|
+
|
|
5
19
|
## 0.8.0
|
|
6
20
|
|
|
7
21
|
- Add `add <module> <option>` and `remove <module>` to change the modules of an existing project. The CLI renders the project's recorded Starter commit with the current and the new selection and merges the difference into the project. Files you never changed are updated, your edits are kept, and lines both sides changed get conflict markers. The local env file keeps your values: only generated values follow the new selection, and new variables are added. A Git repository without uncommitted changes is required unless `--force` is passed, so the result can be reviewed and undone.
|
package/README.md
CHANGED
|
@@ -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.10.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
18
|
+
Clubedge Starter v0.9.0
|
|
19
|
+
commit e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5
|
|
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.10.0` | `clubedge-starter@v0.9.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.9.0` tag is expected to resolve to commit
|
|
31
|
+
`e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5`. 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.9.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
|
|
|
@@ -85,13 +85,14 @@ Either way, the app lives in `apps/web` and uses the same shared packages, so th
|
|
|
85
85
|
|
|
86
86
|
### Choosing services
|
|
87
87
|
|
|
88
|
-
Authentication, file storage, and
|
|
88
|
+
Authentication, file storage, caching, email, and local infrastructure 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. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
|
|
89
89
|
|
|
90
90
|
| Flag | Options | Default |
|
|
91
91
|
| ----------- | ------------------------------------------ | ---------- |
|
|
92
92
|
| `--auth` | `supabase`, `better-auth`, `none` | `supabase` |
|
|
93
93
|
| `--storage` | `s3`, `supabase` (needs Supabase Auth), `none` | `s3` |
|
|
94
94
|
| `--cache` | `redis` (falls back to memory without `REDIS_URL`), `memory` | `redis` |
|
|
95
|
+
| `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
|
|
95
96
|
| `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
|
|
96
97
|
|
|
97
98
|
```sh
|
|
@@ -136,7 +137,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
136
137
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
137
138
|
|
|
138
139
|
# Reproduce a specific Starter release or commit
|
|
139
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
140
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.9.0
|
|
140
141
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
141
142
|
|
|
142
143
|
# Test local Starter changes before releasing them
|
|
@@ -145,14 +146,15 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
|
|
145
146
|
|
|
146
147
|
## Change an existing project
|
|
147
148
|
|
|
148
|
-
Inside a project created by the CLI, `add` and `remove` change one module at a time, and `upgrade` moves the project to a newer Starter release:
|
|
149
|
+
Inside a project created by the CLI, `add` and `remove` change one module at a time, and `upgrade` moves the project to a newer Starter release. Projects from Starter v0.8.0 on have a `clubedge` script, so `pnpm clubedge add cache redis` works too. Without a module or option, `add` and `remove` ask for them:
|
|
149
150
|
|
|
150
151
|
```sh
|
|
152
|
+
pnpm clubedge add # choose a module and option from a list
|
|
151
153
|
pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add auth better-auth
|
|
152
154
|
pnpm dlx @clubedge/create-clubedge-app remove storage
|
|
153
155
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
154
156
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
155
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.
|
|
157
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.9.0 # or a specific tag, branch, or commit
|
|
156
158
|
```
|
|
157
159
|
|
|
158
160
|
The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
|
|
@@ -203,7 +205,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
|
|
|
203
205
|
1. Make and validate changes in `clubedge-starter`.
|
|
204
206
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
205
207
|
3. Update the Starter package versions.
|
|
206
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
208
|
+
4. Commit `main`, create a release tag such as `v0.9.0`, and publish the Starter release.
|
|
207
209
|
|
|
208
210
|
### CLI maintainers
|
|
209
211
|
|
|
@@ -217,7 +219,7 @@ The required CI job always tests the pinned default. The scheduled compatibility
|
|
|
217
219
|
|
|
218
220
|
## Contributing
|
|
219
221
|
|
|
220
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for development and pull request guidance. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
|
|
222
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development and pull request guidance, and [ROADMAP.md](ROADMAP.md) for the planned phases. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
|
|
221
223
|
|
|
222
224
|
## License
|
|
223
225
|
|
package/dist/cli/args.js
CHANGED
|
@@ -1,32 +1,70 @@
|
|
|
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", "infra"];
|
|
4
2
|
export class UsageError extends Error {
|
|
5
3
|
name = "UsageError";
|
|
6
4
|
}
|
|
5
|
+
/** The CLI's own long options; every other --name value flag selects a module option. */
|
|
6
|
+
const OPTIONS = {
|
|
7
|
+
framework: { type: "string" },
|
|
8
|
+
ref: { type: "string" },
|
|
9
|
+
"template-dir": { type: "string" },
|
|
10
|
+
install: { type: "boolean", default: true },
|
|
11
|
+
git: { type: "boolean", default: true },
|
|
12
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
13
|
+
"dry-run": { type: "boolean", default: false },
|
|
14
|
+
force: { type: "boolean", default: false },
|
|
15
|
+
help: { type: "boolean", short: "h", default: false },
|
|
16
|
+
version: { type: "boolean", short: "v", default: false },
|
|
17
|
+
};
|
|
18
|
+
const isOwnOption = (name) => name in OPTIONS || (name.startsWith("no-") && OPTIONS[name.slice(3)]?.type === "boolean");
|
|
19
|
+
/**
|
|
20
|
+
* Takes module flags (`--email smtp` or `--email=smtp`) out of the arguments, leaving the CLI's
|
|
21
|
+
* own options for the strict parser. A flag that is neither needs a value, so a stray unknown
|
|
22
|
+
* flag such as --verbose is still reported.
|
|
23
|
+
*/
|
|
24
|
+
function extractModuleFlags(argv) {
|
|
25
|
+
const rest = [];
|
|
26
|
+
const modules = {};
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const argument = argv[i];
|
|
29
|
+
if (argument === "--") {
|
|
30
|
+
rest.push(...argv.slice(i));
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
const match = /^--([^=]+)(?:=(.*))?$/.exec(argument);
|
|
34
|
+
if (!match || isOwnOption(match[1])) {
|
|
35
|
+
rest.push(argument);
|
|
36
|
+
// A string option's value is never a flag of its own.
|
|
37
|
+
if (match && match[2] === undefined && OPTIONS[match[1]]?.type === "string") {
|
|
38
|
+
if (argv[i + 1] !== undefined)
|
|
39
|
+
rest.push(argv[++i]);
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const [, name, inline] = match;
|
|
44
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name))
|
|
45
|
+
throw new UsageError(`Unknown option --${name}. Run with --help to see available options.`);
|
|
46
|
+
const next = argv[i + 1];
|
|
47
|
+
const value = inline ?? (next !== undefined && !next.startsWith("-") ? argv[++i] : undefined);
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
throw new UsageError(`Unknown option --${name}. Module flags take an option, such as --${name} none. Run with --help to see available options.`);
|
|
50
|
+
}
|
|
51
|
+
if (!value.trim())
|
|
52
|
+
throw new UsageError(`--${name} requires an option, such as none.`);
|
|
53
|
+
modules[name] = value.trim();
|
|
54
|
+
}
|
|
55
|
+
return { rest, modules };
|
|
56
|
+
}
|
|
7
57
|
export function parseCliArgs(argv) {
|
|
58
|
+
const { rest, modules } = extractModuleFlags(argv);
|
|
8
59
|
let parsed;
|
|
9
60
|
try {
|
|
10
61
|
parsed = parseArgs({
|
|
11
|
-
args:
|
|
62
|
+
args: rest,
|
|
12
63
|
allowPositionals: true,
|
|
13
64
|
allowNegative: true,
|
|
14
65
|
strict: true,
|
|
15
66
|
options: {
|
|
16
|
-
|
|
17
|
-
auth: { type: "string" },
|
|
18
|
-
storage: { type: "string" },
|
|
19
|
-
cache: { type: "string" },
|
|
20
|
-
infra: { type: "string" },
|
|
21
|
-
ref: { type: "string" },
|
|
22
|
-
"template-dir": { type: "string" },
|
|
23
|
-
install: { type: "boolean", default: true },
|
|
24
|
-
git: { type: "boolean", default: true },
|
|
25
|
-
yes: { type: "boolean", short: "y", default: false },
|
|
26
|
-
"dry-run": { type: "boolean", default: false },
|
|
27
|
-
force: { type: "boolean", default: false },
|
|
28
|
-
help: { type: "boolean", short: "h", default: false },
|
|
29
|
-
version: { type: "boolean", short: "v", default: false },
|
|
67
|
+
...OPTIONS,
|
|
30
68
|
},
|
|
31
69
|
});
|
|
32
70
|
}
|
|
@@ -39,7 +77,7 @@ export function parseCliArgs(argv) {
|
|
|
39
77
|
if (command.name === "create" && positionals.length > 1) {
|
|
40
78
|
throw new UsageError("Only one project directory can be provided.");
|
|
41
79
|
}
|
|
42
|
-
if (command.name !== "create" && (values.framework !== undefined ||
|
|
80
|
+
if (command.name !== "create" && (values.framework !== undefined || Object.keys(modules).length)) {
|
|
43
81
|
throw new UsageError("--framework and module flags only apply when creating a project; use add or remove to change one module.");
|
|
44
82
|
}
|
|
45
83
|
if ((command.name === "add" || command.name === "remove") && values.ref !== undefined) {
|
|
@@ -48,15 +86,6 @@ export function parseCliArgs(argv) {
|
|
|
48
86
|
if (values.framework !== undefined && !values.framework.trim()) {
|
|
49
87
|
throw new UsageError("--framework requires a framework id, such as next or tanstack-start.");
|
|
50
88
|
}
|
|
51
|
-
const modules = {};
|
|
52
|
-
for (const module of MODULE_FLAGS) {
|
|
53
|
-
const value = values[module];
|
|
54
|
-
if (value === undefined)
|
|
55
|
-
continue;
|
|
56
|
-
if (!value.trim())
|
|
57
|
-
throw new UsageError(`--${module} requires an option, such as none.`);
|
|
58
|
-
modules[module] = value.trim();
|
|
59
|
-
}
|
|
60
89
|
if (values.ref !== undefined && !values.ref.trim()) {
|
|
61
90
|
throw new UsageError("--ref requires a tag, branch, or commit.");
|
|
62
91
|
}
|
|
@@ -86,15 +115,13 @@ export function parseCliArgs(argv) {
|
|
|
86
115
|
function parseCommand(positionals) {
|
|
87
116
|
const [name, module, option, ...extra] = positionals;
|
|
88
117
|
if (name === "add") {
|
|
89
|
-
if (
|
|
90
|
-
throw new UsageError("Usage: add
|
|
91
|
-
}
|
|
118
|
+
if (extra.length)
|
|
119
|
+
throw new UsageError("Usage: add [module] [option], for example: add cache redis");
|
|
92
120
|
return { name, module, option };
|
|
93
121
|
}
|
|
94
122
|
if (name === "remove") {
|
|
95
|
-
if (
|
|
96
|
-
throw new UsageError("Usage: remove
|
|
97
|
-
}
|
|
123
|
+
if (option !== undefined)
|
|
124
|
+
throw new UsageError("Usage: remove [module], for example: remove storage");
|
|
98
125
|
return { name, module };
|
|
99
126
|
}
|
|
100
127
|
if (name === "upgrade") {
|
package/dist/cli/ui.js
CHANGED
|
@@ -52,7 +52,32 @@ export function showBanner() {
|
|
|
52
52
|
console.log(` ${dim("By Clubedge")} ${dim("·")} ${paint(gradientFrom, siteUrl)}`);
|
|
53
53
|
console.log("");
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
/** "a (default), b, or c" */
|
|
56
|
+
function choices(ids, defaultId) {
|
|
57
|
+
const labels = ids.map((id) => (id === defaultId ? `${id} (default)` : id));
|
|
58
|
+
return labels.length > 1 ? `${labels.slice(0, -1).join(", ")}${labels.length > 2 ? "," : ""} or ${labels.at(-1)}` : labels[0] ?? "";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The framework and module flags of a Starter, from its manifest, so new modules appear in the
|
|
62
|
+
* help without CLI changes. Without a manifest, a generic line explains module flags.
|
|
63
|
+
*/
|
|
64
|
+
export function selectionHelp(manifest) {
|
|
65
|
+
const line = (flag, text) => ` ${flag.padEnd(24)}${text}`;
|
|
66
|
+
if (!manifest) {
|
|
67
|
+
return [
|
|
68
|
+
line("--framework <id>", "App framework, such as next or tanstack-start"),
|
|
69
|
+
line("--<module> <option>", "A module option, such as --auth none; the Starter lists its modules"),
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
72
|
+
const frameworks = Object.keys(manifest.frameworks);
|
|
73
|
+
return [
|
|
74
|
+
...(frameworks.length > 1
|
|
75
|
+
? [line("--framework <id>", `App framework: ${choices(frameworks, manifest.defaultFramework)}`)]
|
|
76
|
+
: []),
|
|
77
|
+
...Object.entries(manifest.modules).map(([id, module]) => line(`--${id} <id>`, `${module.name}: ${choices(Object.keys(module.options), module.default)}`)),
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
export function helpText(manifest = null) {
|
|
56
81
|
return `
|
|
57
82
|
create-clubedge-app ${cliVersion}
|
|
58
83
|
|
|
@@ -61,21 +86,20 @@ This CLI release scaffolds Starter ${starterPin.starterRef} (commit ${starterPin
|
|
|
61
86
|
|
|
62
87
|
Usage:
|
|
63
88
|
pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
64
|
-
pnpm dlx @clubedge/create-clubedge-app add
|
|
65
|
-
pnpm dlx @clubedge/create-clubedge-app remove
|
|
89
|
+
pnpm dlx @clubedge/create-clubedge-app add [module] [option] (inside a project)
|
|
90
|
+
pnpm dlx @clubedge/create-clubedge-app remove [module] (inside a project)
|
|
66
91
|
pnpm dlx @clubedge/create-clubedge-app upgrade [--ref <tag>] (inside a project)
|
|
67
92
|
|
|
93
|
+
Inside a project with the clubedge script, use pnpm clubedge add, remove, or
|
|
94
|
+
upgrade. Without a module or option, add and remove ask for them.
|
|
95
|
+
|
|
68
96
|
add and remove change one module of an existing project, and upgrade moves it
|
|
69
97
|
to a newer Starter (this CLI's release unless --ref is given). The change is
|
|
70
98
|
merged into your code; lines you changed that it also changes get conflict
|
|
71
99
|
markers. Commit first so you can review the result with git diff.
|
|
72
100
|
|
|
73
101
|
Options:
|
|
74
|
-
|
|
75
|
-
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
76
|
-
--storage <id> File storage: s3 (default), supabase, or none
|
|
77
|
-
--cache <id> Cache and rate limits: redis (default) or memory
|
|
78
|
-
--infra <id> Local services: docker (default), supabase, local, or none
|
|
102
|
+
${selectionHelp(manifest)}
|
|
79
103
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
80
104
|
(upgrade: the Starter to upgrade to)
|
|
81
105
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
package/dist/core/env.js
CHANGED
|
@@ -76,10 +76,17 @@ export function updateEnvFile(local, baseExample, targetExample) {
|
|
|
76
76
|
while (anchor >= 0 && !keyOf(target[anchor]))
|
|
77
77
|
anchor--;
|
|
78
78
|
const position = anchor >= 0 ? find(lines, keyOf(target[anchor])) : -1;
|
|
79
|
-
if (position === -1)
|
|
79
|
+
if (position === -1) {
|
|
80
80
|
appended.push(...commentsAbove(target, at), line);
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const between = target.slice(anchor + 1, at + 1);
|
|
84
|
+
// A removed section can leave its blank separator after the anchor; reuse it rather than
|
|
85
|
+
// adding a second one.
|
|
86
|
+
// The last element is the end of the file after its final newline, not a blank line.
|
|
87
|
+
const followedByBlank = position + 1 < lines.length - 1 && lines[position + 1].trim() === "";
|
|
88
|
+
const reuseBlank = between[0]?.trim() === "" && followedByBlank;
|
|
89
|
+
lines.splice(position + (reuseBlank ? 2 : 1), 0, ...(reuseBlank ? between.slice(1) : between));
|
|
83
90
|
});
|
|
84
91
|
let text = lines.join(eol);
|
|
85
92
|
// Removed variables can leave blank lines in a row, as removed template blocks do.
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { cancel, confirm, intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts";
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
3
4
|
import process from "node:process";
|
|
4
5
|
import { parseCliArgs, UsageError } from "./cli/args.js";
|
|
5
6
|
import { dim, helpText, showBanner } from "./cli/ui.js";
|
|
6
7
|
import { assertTargetIsUsable, executePlan } from "./core/execute.js";
|
|
7
8
|
import { readTemplateManifest } from "./core/manifest.js";
|
|
8
9
|
import { createPlan, describePlan, selectFramework } from "./core/plan.js";
|
|
9
|
-
import { resolveTemplateSource } from "./core/source.js";
|
|
10
|
-
import { cliUrl, cliVersion, siteUrl } from "./package-info.js";
|
|
10
|
+
import { readBundledTemplate, resolveTemplateSource } from "./core/source.js";
|
|
11
|
+
import { cliUrl, cliVersion, siteUrl, starterPin } from "./package-info.js";
|
|
11
12
|
import { changeProject } from "./modify.js";
|
|
12
13
|
import { initializeGit } from "./steps/git.js";
|
|
13
14
|
import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
|
|
@@ -62,7 +63,9 @@ async function chooseModules(manifest, requested, interactive) {
|
|
|
62
63
|
if (!interactive || unasked.length === 0)
|
|
63
64
|
return requested;
|
|
64
65
|
const customize = await confirm({
|
|
65
|
-
message:
|
|
66
|
+
message: `Customize the included services (${Object.values(manifest.modules)
|
|
67
|
+
.map((module) => module.name.toLowerCase())
|
|
68
|
+
.join(", ")})?`,
|
|
66
69
|
initialValue: false,
|
|
67
70
|
});
|
|
68
71
|
if (isCancel(customize))
|
|
@@ -183,10 +186,28 @@ async function createProject(args) {
|
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
188
|
const directory = relative(process.cwd(), targetDirectory) || ".";
|
|
189
|
+
const scripts = JSON.parse(await readFile(join(targetDirectory, "package.json"), "utf8")).scripts ?? {};
|
|
190
|
+
const later = scripts.clubedge
|
|
191
|
+
? `Change modules later with \`pnpm clubedge add\` or \`pnpm clubedge remove\`, and get Starter updates with \`pnpm clubedge upgrade\`.\n\n`
|
|
192
|
+
: "";
|
|
186
193
|
outro(`Next steps:\n cd ${directory}\n ${pnpmCommand} dev\n\n` +
|
|
187
194
|
`See SETUP.md for database and provider configuration.\n\n` +
|
|
195
|
+
later +
|
|
188
196
|
`${dim("Docs")} ${cliUrl}\n${dim("Clubedge")} ${siteUrl}`);
|
|
189
197
|
}
|
|
198
|
+
/** The bundled Starter's manifest, for the help text; null in a checkout without a bundle. */
|
|
199
|
+
async function bundledManifest() {
|
|
200
|
+
try {
|
|
201
|
+
const bundled = await readBundledTemplate();
|
|
202
|
+
// A checkout can hold an older bundle; only the pinned release describes what this CLI creates.
|
|
203
|
+
if (!bundled || bundled.commit !== starterPin.starterCommit)
|
|
204
|
+
return null;
|
|
205
|
+
return await readTemplateManifest(bundled.root);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
190
211
|
export async function main(argv = process.argv.slice(2)) {
|
|
191
212
|
try {
|
|
192
213
|
const args = parseCliArgs(argv);
|
|
@@ -196,7 +217,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
196
217
|
}
|
|
197
218
|
showBanner();
|
|
198
219
|
if (args.help) {
|
|
199
|
-
console.log(helpText());
|
|
220
|
+
console.log(helpText(await bundledManifest()));
|
|
200
221
|
return;
|
|
201
222
|
}
|
|
202
223
|
assertSupportedNode();
|
package/dist/modify.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// `add`, `remove`, and `upgrade` change an existing project. The project's recorded Starter
|
|
2
2
|
// commit and modules (base) and the new state (target: other modules, or a newer Starter) are
|
|
3
3
|
// rendered in memory, and the difference is merged into the project (see core/update.ts).
|
|
4
|
-
import { confirm, isCancel, log, outro, spinner } from "@clack/prompts";
|
|
4
|
+
import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
5
7
|
import process from "node:process";
|
|
6
8
|
import { UsageError } from "./cli/args.js";
|
|
7
9
|
import { dim } from "./cli/ui.js";
|
|
@@ -13,6 +15,18 @@ import { applyUpdate, planUpdate } from "./core/update.js";
|
|
|
13
15
|
import { cliVersion, starterPin } from "./package-info.js";
|
|
14
16
|
import { installDependencies, resolvePnpm } from "./steps/install.js";
|
|
15
17
|
import { commandSucceeds, runCapture } from "./utils/process.js";
|
|
18
|
+
/** How to run the CLI in this project: its `clubedge` script when it has one. */
|
|
19
|
+
async function cliCommand(project) {
|
|
20
|
+
try {
|
|
21
|
+
const manifest = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
|
|
22
|
+
if (manifest.scripts?.clubedge)
|
|
23
|
+
return "pnpm clubedge";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Fall back to the full command below.
|
|
27
|
+
}
|
|
28
|
+
return "pnpm dlx @clubedge/create-clubedge-app";
|
|
29
|
+
}
|
|
16
30
|
/** The Starter revision the project was generated from. */
|
|
17
31
|
async function projectSource(project, templateDir) {
|
|
18
32
|
if (templateDir)
|
|
@@ -62,8 +76,66 @@ async function assertSafeToChange(project, args, interactive) {
|
|
|
62
76
|
if (isCancel(proceed) || !proceed)
|
|
63
77
|
throw new UsageError("Cancelled; nothing was changed.");
|
|
64
78
|
}
|
|
79
|
+
/** Whether an option's requirements hold for the project's other modules. */
|
|
80
|
+
function available(manifest, project, moduleId, optionId) {
|
|
81
|
+
const option = manifest.modules[moduleId].options[optionId];
|
|
82
|
+
return Object.entries(option.requires).every(([other, allowed]) => allowed.includes(project.modules[other] ?? ""));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Asks for whatever add or remove was not given: the module, then the option. Without a
|
|
86
|
+
* terminal, the missing arguments are an error. Resolves null when a prompt is cancelled.
|
|
87
|
+
*/
|
|
88
|
+
export async function completeCommand(manifest, project, command, interactive) {
|
|
89
|
+
if (command.name === "add" && command.module && command.option) {
|
|
90
|
+
return { name: "add", module: command.module, option: command.option };
|
|
91
|
+
}
|
|
92
|
+
if (command.name === "remove" && command.module)
|
|
93
|
+
return { name: "remove", module: command.module };
|
|
94
|
+
if (!interactive) {
|
|
95
|
+
throw new UsageError(command.name === "add"
|
|
96
|
+
? "Usage: add <module> <option>, for example: add cache redis. Run it in a terminal to choose from a list."
|
|
97
|
+
: "Usage: remove <module>, for example: remove storage. Run it in a terminal to choose from a list.");
|
|
98
|
+
}
|
|
99
|
+
let moduleId = command.module;
|
|
100
|
+
if (!moduleId) {
|
|
101
|
+
const choices = Object.entries(manifest.modules).filter(([id, module]) => command.name === "add" ? true : Boolean(module.options.none) && project.modules[id] !== "none");
|
|
102
|
+
if (!choices.length)
|
|
103
|
+
throw new UsageError("No module can be removed: every removable module is already none.");
|
|
104
|
+
const value = await select({
|
|
105
|
+
message: command.name === "add" ? "Which module do you want to add or switch?" : "Which module do you want to remove?",
|
|
106
|
+
options: choices.map(([id, module]) => ({
|
|
107
|
+
value: id,
|
|
108
|
+
label: module.name,
|
|
109
|
+
hint: `now: ${module.options[project.modules[id] ?? ""]?.name ?? project.modules[id] ?? "not set"}`,
|
|
110
|
+
})),
|
|
111
|
+
});
|
|
112
|
+
if (isCancel(value))
|
|
113
|
+
return null;
|
|
114
|
+
moduleId = value;
|
|
115
|
+
}
|
|
116
|
+
if (command.name === "remove")
|
|
117
|
+
return { name: "remove", module: moduleId };
|
|
118
|
+
const module = manifest.modules[moduleId];
|
|
119
|
+
if (!module) {
|
|
120
|
+
throw new UsageError(`Unknown module "${moduleId}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
|
|
121
|
+
}
|
|
122
|
+
const options = Object.keys(module.options).filter((id) => id !== project.modules[moduleId] && available(manifest, project, moduleId, id));
|
|
123
|
+
if (!options.length)
|
|
124
|
+
throw new UsageError(`${module.name} has no other option this project can use.`);
|
|
125
|
+
const option = await select({
|
|
126
|
+
message: `${module.name}: which option?`,
|
|
127
|
+
options: options.map((id) => ({
|
|
128
|
+
value: id,
|
|
129
|
+
label: module.options[id].name,
|
|
130
|
+
...(id === module.default ? { hint: "default" } : {}),
|
|
131
|
+
})),
|
|
132
|
+
});
|
|
133
|
+
if (isCancel(option))
|
|
134
|
+
return null;
|
|
135
|
+
return { name: "add", module: moduleId, option };
|
|
136
|
+
}
|
|
65
137
|
/** The selection after add or remove, validated against the Starter's manifest. */
|
|
66
|
-
function changedModules(manifest, project, command) {
|
|
138
|
+
function changedModules(manifest, project, command, cli) {
|
|
67
139
|
const module = manifest.modules[command.module];
|
|
68
140
|
if (!module) {
|
|
69
141
|
throw new UsageError(`Unknown module "${command.module}". Choose one of: ${Object.keys(manifest.modules).join(", ")}.`);
|
|
@@ -78,7 +150,7 @@ function changedModules(manifest, project, command) {
|
|
|
78
150
|
else {
|
|
79
151
|
if (!module.options.none) {
|
|
80
152
|
const others = Object.keys(module.options).filter((id) => id !== project.modules[command.module]);
|
|
81
|
-
throw new UsageError(`${module.name} cannot be removed. Switch it instead, for example: add ${command.module} ${others[0] ?? module.default}`);
|
|
153
|
+
throw new UsageError(`${module.name} cannot be removed. Switch it instead, for example: ${cli} add ${command.module} ${others[0] ?? module.default}`);
|
|
82
154
|
}
|
|
83
155
|
option = "none";
|
|
84
156
|
}
|
|
@@ -89,7 +161,7 @@ function changedModules(manifest, project, command) {
|
|
|
89
161
|
* The project's selection in a newer Starter. Modules the project predates get their "none"
|
|
90
162
|
* option where there is one, so an upgrade never adds a service on its own.
|
|
91
163
|
*/
|
|
92
|
-
export function upgradedModules(manifest, project) {
|
|
164
|
+
export function upgradedModules(manifest, project, cli = "pnpm clubedge") {
|
|
93
165
|
const requested = {};
|
|
94
166
|
const introduced = [];
|
|
95
167
|
for (const [id, module] of Object.entries(manifest.modules)) {
|
|
@@ -99,7 +171,7 @@ export function upgradedModules(manifest, project) {
|
|
|
99
171
|
introduced.push(id);
|
|
100
172
|
}
|
|
101
173
|
else if (!module.options[recorded]) {
|
|
102
|
-
throw new UsageError(`The new Starter no longer offers ${module.name} "${recorded}".
|
|
174
|
+
throw new UsageError(`The new Starter no longer offers ${module.name} "${recorded}". Switch to one of ${Object.keys(module.options).join(", ")} with ${cli} add ${id} <option> before upgrading.`);
|
|
103
175
|
}
|
|
104
176
|
else {
|
|
105
177
|
requested[id] = recorded;
|
|
@@ -154,11 +226,10 @@ function report(plan) {
|
|
|
154
226
|
}
|
|
155
227
|
}
|
|
156
228
|
/** Works out what the command changes; resolves null when the project is already there. */
|
|
157
|
-
async function planChange(project, command, base, target) {
|
|
158
|
-
const baseManifest = await readTemplateManifest(base.root);
|
|
229
|
+
async function planChange(project, command, base, baseManifest, target, cli) {
|
|
159
230
|
const baseSide = { source: asRecorded(base, project), manifest: baseManifest, modules: project.modules, cliVersion: project.cliVersion };
|
|
160
231
|
if (command.name !== "upgrade") {
|
|
161
|
-
const modules = changedModules(baseManifest, project, command);
|
|
232
|
+
const modules = changedModules(baseManifest, project, command, cli);
|
|
162
233
|
const changed = Object.keys(modules).filter((id) => modules[id] !== project.modules[id]);
|
|
163
234
|
if (!changed.length)
|
|
164
235
|
return null;
|
|
@@ -177,11 +248,11 @@ async function planChange(project, command, base, target) {
|
|
|
177
248
|
if (target.commit && target.commit === project.starterCommit)
|
|
178
249
|
return null;
|
|
179
250
|
const targetManifest = await readTemplateManifest(target.root);
|
|
180
|
-
const { modules, introduced, dropped } = upgradedModules(targetManifest, project);
|
|
251
|
+
const { modules, introduced, dropped } = upgradedModules(targetManifest, project, cli);
|
|
181
252
|
const notes = [
|
|
182
253
|
...introduced.map((id) => {
|
|
183
254
|
const module = targetManifest.modules[id];
|
|
184
|
-
return `Starter ${target.ref} adds ${module.name}; the project uses "${modules[id]}". Choose another option with: add ${id} <option>`;
|
|
255
|
+
return `Starter ${target.ref} adds ${module.name}; the project uses "${modules[id]}". Choose another option with: ${cli} add ${id} <option>`;
|
|
185
256
|
}),
|
|
186
257
|
...dropped.map((id) => `Starter ${target.ref} no longer has the ${id} module; its files are merged away like any other change.`),
|
|
187
258
|
];
|
|
@@ -214,14 +285,20 @@ export async function changeProject(args) {
|
|
|
214
285
|
const target = upgrading ? await upgradeSource(args) : null;
|
|
215
286
|
if (target)
|
|
216
287
|
sources.push(target);
|
|
217
|
-
const
|
|
288
|
+
const baseManifest = await readTemplateManifest(base.root);
|
|
289
|
+
progress.stop("Starter ready");
|
|
218
290
|
spinning = false;
|
|
291
|
+
const cli = await cliCommand(project);
|
|
292
|
+
const complete = command.name === "upgrade" ? command : await completeCommand(baseManifest, project, command, interactive);
|
|
293
|
+
if (!complete) {
|
|
294
|
+
outro("Cancelled; nothing was changed.");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const change = await planChange(project, complete, base, baseManifest, target, cli);
|
|
219
298
|
if (!change) {
|
|
220
|
-
progress.stop("Starter ready");
|
|
221
299
|
outro(upgrading ? `The project is already on Starter ${project.starterRef}.` : "The project already uses that selection; nothing to change.");
|
|
222
300
|
return;
|
|
223
301
|
}
|
|
224
|
-
progress.stop("Starter ready");
|
|
225
302
|
const plan = await planUpdate(project, change.base, change.target);
|
|
226
303
|
log.message(`${change.summary}\n\n${describe(plan) || "No file changes."}`);
|
|
227
304
|
if (args.dryRun) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"clubedge": {
|
|
24
24
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
25
|
-
"starterRef": "v0.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.9.0",
|
|
26
|
+
"starterCommit": "e858d0b5b60bbb05f1b6aed3f4d9e841ea2df2a5"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
package/template/.env.example
CHANGED
|
@@ -22,6 +22,8 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
|
|
22
22
|
# Better Auth keeps users and sessions in DATABASE_URL (run pnpm db:migrate first).
|
|
23
23
|
# Generate a secret with: openssl rand -base64 32
|
|
24
24
|
BETTER_AUTH_SECRET=
|
|
25
|
+
# true refuses sign-in until the email address is confirmed through the emailed link. # clubedge:only email!=none
|
|
26
|
+
BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=false # clubedge:only email!=none
|
|
25
27
|
# clubedge:end
|
|
26
28
|
|
|
27
29
|
# clubedge:if cache=redis
|
|
@@ -31,6 +33,24 @@ REDIS_URL= # clubedge:only infra!=docker
|
|
|
31
33
|
REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
|
|
32
34
|
# clubedge:end
|
|
33
35
|
|
|
36
|
+
# clubedge:if email=smtp
|
|
37
|
+
# Email over SMTP: any provider's SMTP endpoint (smtps:// for TLS). Without it, development
|
|
38
|
+
# prints emails to the server log and production refuses to send.
|
|
39
|
+
SMTP_URL= # clubedge:only infra!=docker|supabase
|
|
40
|
+
# Mailpit from docker-compose.dev.yml; read the emails at http://localhost:8025. # clubedge:only infra=docker
|
|
41
|
+
SMTP_URL=smtp://localhost:1025 # clubedge:only infra=docker
|
|
42
|
+
# The local Supabase inbox; read the emails at http://127.0.0.1:54324. # clubedge:only infra=supabase
|
|
43
|
+
SMTP_URL=smtp://127.0.0.1:54325 # clubedge:only infra=supabase
|
|
44
|
+
EMAIL_FROM=App <no-reply@example.com>
|
|
45
|
+
# clubedge:end
|
|
46
|
+
|
|
47
|
+
# clubedge:if email=resend
|
|
48
|
+
# Resend (https://resend.com). EMAIL_FROM must use a domain verified in Resend. Without the
|
|
49
|
+
# key, development prints emails to the server log and production refuses to send.
|
|
50
|
+
RESEND_API_KEY=
|
|
51
|
+
EMAIL_FROM=App <no-reply@example.com>
|
|
52
|
+
# clubedge:end
|
|
53
|
+
|
|
34
54
|
# clubedge:if storage=s3
|
|
35
55
|
# S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
|
|
36
56
|
STORAGE_BUCKET= # clubedge:only infra!=docker
|