@clubedge/create-clubedge-app 0.9.0 → 0.11.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 +12 -0
- package/README.md +14 -12
- package/dist/cli/args.js +56 -27
- package/dist/cli/ui.js +27 -6
- package/dist/core/env.js +10 -3
- package/dist/index.js +19 -4
- package/package.json +3 -3
- package/template/.env.example +20 -0
- package/template/.github/workflows/ci.yml +18 -0
- package/template/.github/workflows/deploy.yml +109 -0
- package/template/Dockerfile +5 -0
- package/template/README.md +21 -1
- package/template/SETUP.md +122 -5
- package/template/_gitignore +3 -0
- package/template/apps/start/.env.example +20 -0
- package/template/apps/start/Dockerfile +5 -0
- package/template/apps/start/netlify.toml +6 -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/api/ready.ts +23 -0
- 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/start/vercel.json +5 -0
- package/template/apps/web/netlify.toml +6 -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/api/ready/route.ts +18 -0
- 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/apps/web/vercel.json +5 -0
- package/template/clubedge.template.json +80 -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/e2e/ready.spec.ts +11 -0
- package/template/fly.toml +25 -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 +7 -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 +7 -1
- package/template/packages/db/src/client.test.ts +28 -0
- package/template/packages/db/src/client.ts +19 -0
- package/template/packages/db/src/index.ts +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 +56 -0
- package/template/railway.json +13 -0
- package/template/render.yaml +66 -0
- package/template/scripts/build-migrator.mjs +51 -0
- package/template/scripts/migrate.mjs +30 -0
- package/template/supabase/config.toml +6 -1
- package/template.lock.json +48 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.11.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.10.0`, which adds the deploy module (`--deploy docker|fly|railway|render|vercel|netlify`), a migrator in the production image, a manual deploy workflow, and the `/api/ready` readiness endpoint. `--deploy` needs no CLI code: it comes from the Starter's manifest.
|
|
8
|
+
- Test Fly.io and Vercel projects in CI and a Netlify project in the release smoke test.
|
|
9
|
+
|
|
10
|
+
## 0.10.0
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
- 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`.
|
|
14
|
+
- 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.
|
|
15
|
+
- Build the help text's framework and module lines, and the "customize the included services" prompt, from the Starter's manifest.
|
|
16
|
+
|
|
5
17
|
## 0.9.0
|
|
6
18
|
|
|
7
19
|
- Default to Starter `v0.8.0`, whose projects have a `clubedge` script: `pnpm clubedge add`, `remove`, and `upgrade`.
|
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.11.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
18
|
+
Clubedge Starter v0.10.0
|
|
19
|
+
commit 9d8fec71b3851c2d615735fb37edb6bb0e568761
|
|
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.11.0` | `clubedge-starter@v0.10.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.10.0` tag is expected to resolve to commit
|
|
31
|
+
`9d8fec71b3851c2d615735fb37edb6bb0e568761`. 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.10.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,15 @@ 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, local infrastructure, and deployment 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` |
|
|
96
|
+
| `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
|
|
95
97
|
| `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
|
|
96
98
|
|
|
97
99
|
```sh
|
|
@@ -136,7 +138,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
136
138
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
137
139
|
|
|
138
140
|
# Reproduce a specific Starter release or commit
|
|
139
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
141
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.10.0
|
|
140
142
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
141
143
|
|
|
142
144
|
# Test local Starter changes before releasing them
|
|
@@ -153,7 +155,7 @@ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add aut
|
|
|
153
155
|
pnpm dlx @clubedge/create-clubedge-app remove storage
|
|
154
156
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
155
157
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
156
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.
|
|
158
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.10.0 # or a specific tag, branch, or commit
|
|
157
159
|
```
|
|
158
160
|
|
|
159
161
|
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.
|
|
@@ -204,7 +206,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
|
|
|
204
206
|
1. Make and validate changes in `clubedge-starter`.
|
|
205
207
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
206
208
|
3. Update the Starter package versions.
|
|
207
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
209
|
+
4. Commit `main`, create a release tag such as `v0.10.0`, and publish the Starter release.
|
|
208
210
|
|
|
209
211
|
### CLI maintainers
|
|
210
212
|
|
|
@@ -218,7 +220,7 @@ The required CI job always tests the pinned default. The scheduled compatibility
|
|
|
218
220
|
|
|
219
221
|
## Contributing
|
|
220
222
|
|
|
221
|
-
See [CONTRIBUTING.md](CONTRIBUTING.md) for development and pull request guidance. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
|
|
223
|
+
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).
|
|
222
224
|
|
|
223
225
|
## License
|
|
224
226
|
|
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
|
}
|
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
|
|
|
@@ -74,11 +99,7 @@ Usage:
|
|
|
74
99
|
markers. Commit first so you can review the result with git diff.
|
|
75
100
|
|
|
76
101
|
Options:
|
|
77
|
-
|
|
78
|
-
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
79
|
-
--storage <id> File storage: s3 (default), supabase, or none
|
|
80
|
-
--cache <id> Cache and rate limits: redis (default) or memory
|
|
81
|
-
--infra <id> Local services: docker (default), supabase, local, or none
|
|
102
|
+
${selectionHelp(manifest)}
|
|
82
103
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
83
104
|
(upgrade: the Starter to upgrade to)
|
|
84
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
|
@@ -7,8 +7,8 @@ import { dim, helpText, showBanner } from "./cli/ui.js";
|
|
|
7
7
|
import { assertTargetIsUsable, executePlan } from "./core/execute.js";
|
|
8
8
|
import { readTemplateManifest } from "./core/manifest.js";
|
|
9
9
|
import { createPlan, describePlan, selectFramework } from "./core/plan.js";
|
|
10
|
-
import { resolveTemplateSource } from "./core/source.js";
|
|
11
|
-
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";
|
|
12
12
|
import { changeProject } from "./modify.js";
|
|
13
13
|
import { initializeGit } from "./steps/git.js";
|
|
14
14
|
import { detectLauncher, installDependencies, resolvePnpm } from "./steps/install.js";
|
|
@@ -63,7 +63,9 @@ async function chooseModules(manifest, requested, interactive) {
|
|
|
63
63
|
if (!interactive || unasked.length === 0)
|
|
64
64
|
return requested;
|
|
65
65
|
const customize = await confirm({
|
|
66
|
-
message:
|
|
66
|
+
message: `Customize the included services (${Object.values(manifest.modules)
|
|
67
|
+
.map((module) => module.name.toLowerCase())
|
|
68
|
+
.join(", ")})?`,
|
|
67
69
|
initialValue: false,
|
|
68
70
|
});
|
|
69
71
|
if (isCancel(customize))
|
|
@@ -193,6 +195,19 @@ async function createProject(args) {
|
|
|
193
195
|
later +
|
|
194
196
|
`${dim("Docs")} ${cliUrl}\n${dim("Clubedge")} ${siteUrl}`);
|
|
195
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
|
+
}
|
|
196
211
|
export async function main(argv = process.argv.slice(2)) {
|
|
197
212
|
try {
|
|
198
213
|
const args = parseCliArgs(argv);
|
|
@@ -202,7 +217,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
202
217
|
}
|
|
203
218
|
showBanner();
|
|
204
219
|
if (args.help) {
|
|
205
|
-
console.log(helpText());
|
|
220
|
+
console.log(helpText(await bundledManifest()));
|
|
206
221
|
return;
|
|
207
222
|
}
|
|
208
223
|
assertSupportedNode();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.10.0",
|
|
26
|
+
"starterCommit": "9d8fec71b3851c2d615735fb37edb6bb0e568761"
|
|
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
|
|
@@ -58,10 +58,28 @@ jobs:
|
|
|
58
58
|
|
|
59
59
|
docker:
|
|
60
60
|
runs-on: ubuntu-latest
|
|
61
|
+
services:
|
|
62
|
+
postgres:
|
|
63
|
+
image: postgres:17
|
|
64
|
+
env:
|
|
65
|
+
POSTGRES_USER: postgres
|
|
66
|
+
POSTGRES_PASSWORD: postgres
|
|
67
|
+
POSTGRES_DB: app
|
|
68
|
+
ports:
|
|
69
|
+
- 5432:5432
|
|
70
|
+
options: >-
|
|
71
|
+
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
|
|
61
72
|
steps:
|
|
62
73
|
- uses: actions/checkout@v4
|
|
63
74
|
- name: Build the production image
|
|
64
75
|
run: docker build --tag app:ci .
|
|
76
|
+
- name: Run the image's migrations twice; the second run changes nothing
|
|
77
|
+
run: |
|
|
78
|
+
set -euo pipefail
|
|
79
|
+
for run in 1 2; do
|
|
80
|
+
docker run --rm --network host --env DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/app app:ci node /app/migrate.mjs
|
|
81
|
+
done
|
|
82
|
+
PGPASSWORD=postgres psql --host 127.0.0.1 --username postgres --dbname app --command "select count(*) from public.users"
|
|
65
83
|
- name: Start the image and check health
|
|
66
84
|
run: |
|
|
67
85
|
set -euo pipefail
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Deploys on demand: Actions > Deploy > Run workflow. SETUP.md lists the secrets it needs.
|
|
2
|
+
name: Deploy
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
concurrency:
|
|
8
|
+
group: deploy
|
|
9
|
+
cancel-in-progress: false
|
|
10
|
+
jobs:
|
|
11
|
+
# clubedge:if deploy=docker
|
|
12
|
+
# Builds the production image and publishes it to this repository's GitHub Container Registry
|
|
13
|
+
# as ghcr.io/<owner>/<repository>, tagged with the commit and latest. No secret is needed.
|
|
14
|
+
image:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
contents: read
|
|
18
|
+
packages: write
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: docker/setup-buildx-action@v3
|
|
22
|
+
- uses: docker/login-action@v3
|
|
23
|
+
with:
|
|
24
|
+
registry: ghcr.io
|
|
25
|
+
username: ${{ github.actor }}
|
|
26
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
27
|
+
- id: meta
|
|
28
|
+
uses: docker/metadata-action@v5
|
|
29
|
+
with:
|
|
30
|
+
images: ghcr.io/${{ github.repository }}
|
|
31
|
+
tags: |
|
|
32
|
+
type=sha
|
|
33
|
+
type=raw,value=latest
|
|
34
|
+
- uses: docker/build-push-action@v6
|
|
35
|
+
with:
|
|
36
|
+
context: .
|
|
37
|
+
push: true
|
|
38
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
39
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
40
|
+
# clubedge:end
|
|
41
|
+
# clubedge:if deploy=fly
|
|
42
|
+
fly:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v4
|
|
46
|
+
- uses: superfly/flyctl-actions/setup-flyctl@master
|
|
47
|
+
# Builds on Fly's builders and runs the release command (migrations) before switching.
|
|
48
|
+
- run: flyctl deploy --remote-only
|
|
49
|
+
env:
|
|
50
|
+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
|
51
|
+
# clubedge:end
|
|
52
|
+
# clubedge:if deploy=railway
|
|
53
|
+
railway:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
# Uploads this checkout; Railway builds the image and runs the pre-deploy migrations.
|
|
58
|
+
- run: npx --yes @railway/cli@4 up --ci --service "$RAILWAY_SERVICE"
|
|
59
|
+
env:
|
|
60
|
+
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
|
|
61
|
+
RAILWAY_SERVICE: ${{ vars.RAILWAY_SERVICE }}
|
|
62
|
+
# clubedge:end
|
|
63
|
+
# clubedge:if deploy=render
|
|
64
|
+
render:
|
|
65
|
+
runs-on: ubuntu-latest
|
|
66
|
+
steps:
|
|
67
|
+
# The service's deploy hook starts a deploy of its branch, with its pre-deploy migrations.
|
|
68
|
+
- run: curl --fail --silent --show-error --request POST "$RENDER_DEPLOY_HOOK_URL"
|
|
69
|
+
env:
|
|
70
|
+
RENDER_DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}
|
|
71
|
+
# clubedge:end
|
|
72
|
+
# clubedge:if deploy=vercel
|
|
73
|
+
vercel:
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
env:
|
|
76
|
+
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
|
77
|
+
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
|
78
|
+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
|
79
|
+
steps:
|
|
80
|
+
- uses: actions/checkout@v4
|
|
81
|
+
- uses: pnpm/action-setup@v4
|
|
82
|
+
- uses: actions/setup-node@v4
|
|
83
|
+
with:
|
|
84
|
+
node-version: 22
|
|
85
|
+
cache: pnpm
|
|
86
|
+
# Builds here with the project's production settings and variables (including the
|
|
87
|
+
# migrations in its build command), then uploads the result.
|
|
88
|
+
- run: npx --yes vercel@latest pull --yes --environment=production --token="$VERCEL_TOKEN"
|
|
89
|
+
- run: npx --yes vercel@latest build --prod --token="$VERCEL_TOKEN"
|
|
90
|
+
- run: npx --yes vercel@latest deploy --prebuilt --prod --token="$VERCEL_TOKEN"
|
|
91
|
+
# clubedge:end
|
|
92
|
+
# clubedge:if deploy=netlify
|
|
93
|
+
netlify:
|
|
94
|
+
runs-on: ubuntu-latest
|
|
95
|
+
env:
|
|
96
|
+
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
|
97
|
+
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
|
98
|
+
steps:
|
|
99
|
+
- uses: actions/checkout@v4
|
|
100
|
+
- uses: pnpm/action-setup@v4
|
|
101
|
+
- uses: actions/setup-node@v4
|
|
102
|
+
with:
|
|
103
|
+
node-version: 22
|
|
104
|
+
cache: pnpm
|
|
105
|
+
- run: pnpm install --frozen-lockfile
|
|
106
|
+
# Builds with netlify.toml (including its migrations) and the site's variables, then deploys.
|
|
107
|
+
- run: npx --yes netlify-cli@latest deploy --build --prod
|
|
108
|
+
working-directory: apps/web
|
|
109
|
+
# clubedge:end
|
package/template/Dockerfile
CHANGED
|
@@ -18,6 +18,10 @@ COPY --from=deps /app/ .
|
|
|
18
18
|
COPY --from=pruner /app/out/full/ .
|
|
19
19
|
COPY tsconfig.base.json ./
|
|
20
20
|
RUN pnpm build
|
|
21
|
+
# The production migrator (node /app/migrate.mjs): one bundled file plus every package's
|
|
22
|
+
# migrations, so release commands can migrate before a new version starts.
|
|
23
|
+
COPY scripts/migrate.mjs scripts/build-migrator.mjs ./scripts/
|
|
24
|
+
RUN node scripts/build-migrator.mjs /app/.migrator
|
|
21
25
|
|
|
22
26
|
FROM node:22-alpine AS runner
|
|
23
27
|
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
|
|
@@ -26,6 +30,7 @@ RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
|
|
26
30
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
|
27
31
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
|
28
32
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
|
33
|
+
COPY --from=builder --chown=nextjs:nodejs /app/.migrator ./
|
|
29
34
|
WORKDIR /app/apps/web
|
|
30
35
|
USER nextjs
|
|
31
36
|
EXPOSE 3000
|
package/template/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Clubedge Starter is a production-oriented, modular React application foundation.
|
|
|
7
7
|
|
|
8
8
|
<!-- clubedge:if starter-repository -->
|
|
9
9
|
|
|
10
|
-
This repository contains the same application for two frameworks, Next.js (`apps/web`) and TanStack Start (`apps/start`), plus every optional module. [create-clubedge-app](https://github.com/Clubedge/create-clubedge-app) generates a project with one framework and only the modules you choose; sections of this README marked for a framework or module appear only in projects that use it. Contributions and bug reports are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). Repository maintainers can use [PUBLISHING.md](PUBLISHING.md) for the GitHub launch checklist.
|
|
10
|
+
This repository contains the same application for two frameworks, Next.js (`apps/web`) and TanStack Start (`apps/start`), plus every optional module. [create-clubedge-app](https://github.com/Clubedge/create-clubedge-app) generates a project with one framework and only the modules you choose; sections of this README marked for a framework or module appear only in projects that use it. Contributions and bug reports are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). Repository maintainers can use [PUBLISHING.md](PUBLISHING.md) for the GitHub launch checklist, and [ROADMAP.md](ROADMAP.md) describes the planned phases.
|
|
11
11
|
|
|
12
12
|
<!-- clubedge:end -->
|
|
13
13
|
|
|
@@ -19,12 +19,23 @@ This repository contains the same application for two frameworks, Next.js (`apps
|
|
|
19
19
|
- Drizzle ORM and PostgreSQL schema, migrations, and seed commands.
|
|
20
20
|
- Supabase Auth with server-verified sessions in secure cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=supabase -->
|
|
21
21
|
- Better Auth with users and sessions in your own PostgreSQL, secure session cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=better-auth -->
|
|
22
|
+
- Password reset by email, and email verification that can be required before sign-in. <!-- clubedge:only auth=better-auth && email!=none -->
|
|
23
|
+
- Password reset by email, sent by Supabase. <!-- clubedge:only auth=supabase -->
|
|
22
24
|
- S3-compatible storage adapter for AWS S3, Cloudflare R2, and MinIO. <!-- clubedge:only storage=s3 -->
|
|
23
25
|
- Supabase Storage adapter that uses the signed-in user's session. <!-- clubedge:only storage=supabase -->
|
|
24
26
|
- Fixed-window rate limiter and cache on Redis, falling back to process memory without `REDIS_URL`. <!-- clubedge:only cache=redis -->
|
|
25
27
|
- Fixed-window rate limiter and cache in process memory, suited to a single server instance. <!-- clubedge:only cache=memory -->
|
|
28
|
+
- Email over SMTP, for any provider or a local inbox; development prints emails to the server log until SMTP is configured. <!-- clubedge:only email=smtp -->
|
|
29
|
+
- Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
|
|
26
30
|
- Zod environment validation, security headers, structured errors, and `/api/health`.
|
|
27
31
|
- Docker support, GitHub Actions CI, Vitest, Playwright, ESLint, and Prettier.
|
|
32
|
+
- A production image that includes its own migrator (`node /app/migrate.mjs`), for running migrations before each release.
|
|
33
|
+
- A manual deploy workflow for the chosen platform, and a readiness endpoint (`/api/ready`) that checks the database.
|
|
34
|
+
- Deployment to Fly.io with `fly.toml`: migrations run as the release command. <!-- clubedge:only deploy=fly -->
|
|
35
|
+
- Deployment to Railway with `railway.json`: migrations run before each deploy. <!-- clubedge:only deploy=railway -->
|
|
36
|
+
- Deployment to Render with a `render.yaml` Blueprint: migrations run before each deploy. <!-- clubedge:only deploy=render -->
|
|
37
|
+
- Deployment to Vercel with `apps/web/vercel.json`: migrations run during the build. <!-- clubedge:only deploy=vercel -->
|
|
38
|
+
- Deployment to Netlify with `apps/web/netlify.toml`: migrations run during the build. <!-- clubedge:only deploy=netlify -->
|
|
28
39
|
|
|
29
40
|
## Architecture
|
|
30
41
|
|
|
@@ -47,6 +58,12 @@ flowchart TB
|
|
|
47
58
|
%% clubedge:if cache=redis
|
|
48
59
|
App -->|cache and rate limits| Redis[(Redis, or memory)]
|
|
49
60
|
%% clubedge:end
|
|
61
|
+
%% clubedge:if email=smtp
|
|
62
|
+
App -->|email interface| SMTP[SMTP server, or the log]
|
|
63
|
+
%% clubedge:end
|
|
64
|
+
%% clubedge:if email=resend
|
|
65
|
+
App -->|email interface| Resend[Resend, or the log]
|
|
66
|
+
%% clubedge:end
|
|
50
67
|
%% clubedge:if storage=s3
|
|
51
68
|
App -->|storage interface| S3[S3-compatible storage: AWS S3, R2, MinIO]
|
|
52
69
|
%% clubedge:end
|
|
@@ -201,6 +218,9 @@ The TanStack Start app is `apps/start`, run with `pnpm --filter @clubedge/start
|
|
|
201
218
|
- `packages/storage-supabase/`: Supabase Storage adapter <!-- clubedge:only storage=supabase -->
|
|
202
219
|
- `packages/cache/`: `Cache` and `RateLimiter` interfaces with in-memory implementations
|
|
203
220
|
- `packages/cache-redis/`: Redis rate limiter and cache <!-- clubedge:only cache=redis -->
|
|
221
|
+
- `packages/email/`: `EmailProvider` interface with console and in-memory providers <!-- clubedge:only email!=none -->
|
|
222
|
+
- `packages/email-smtp/`: SMTP email provider <!-- clubedge:only email=smtp -->
|
|
223
|
+
- `packages/email-resend/`: Resend email provider <!-- clubedge:only email=resend -->
|
|
204
224
|
- `packages/ui/`: shared shadcn/ui components, utilities, and global theme styles
|
|
205
225
|
- `.github/`: CI workflow, issue forms, and pull request template
|
|
206
226
|
- `SETUP.md`, `CONTRIBUTING.md`, `SECURITY.md`: setup, contribution, and security guides
|