@clubedge/create-clubedge-app 0.5.0 → 0.6.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 +7 -0
- package/README.md +12 -10
- package/dist/cli/args.js +2 -1
- package/dist/cli/ui.js +1 -0
- package/dist/core/customize.js +7 -1
- package/dist/core/manifest.js +1 -0
- package/dist/core/plan.js +15 -1
- package/dist/core/source.js +12 -9
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/template/.env.example +24 -10
- package/template/README.md +38 -1
- package/template/SETUP.md +43 -1
- package/template/_gitignore +3 -0
- package/template/apps/start/.env.example +24 -10
- package/template/apps/start/package.json +4 -2
- package/template/apps/start/scripts/check-services.ts +106 -0
- package/template/apps/web/package.json +5 -3
- package/template/apps/web/scripts/check-services.ts +106 -0
- package/template/clubedge.template.json +34 -1
- package/template/docker-compose.dev.yml +42 -0
- package/template/package.json +7 -2
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-supabase/package.json +1 -1
- 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/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-s3/src/index.test.ts +22 -0
- package/template/packages/storage-s3/src/index.ts +7 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/playwright.config.ts +3 -1
- package/template/pnpm-lock.yaml +6 -0
- package/template/supabase/config.toml +40 -0
- package/template.lock.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.6.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.6.0`, which adds the local infrastructure module and `pnpm check:services`.
|
|
8
|
+
- Add `--infra docker|supabase|none`: Docker Compose services, the Supabase CLI's local stack, or no local services. The environment examples point at the chosen services.
|
|
9
|
+
- Let module options set root `package.json` scripts; scripts that only left-out options define are removed, so each project gets the `infra:*` scripts for its option.
|
|
10
|
+
- Fix Starter tag verification on Windows, where an annotated tag resolved to the tag object instead of its commit and scaffolding a downloaded Starter release failed.
|
|
11
|
+
|
|
5
12
|
## 0.5.0
|
|
6
13
|
|
|
7
14
|
- Default to Starter `v0.5.0`, which adds optional modules, Better Auth, and template schema 3.
|
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.6.0
|
|
16
16
|
|
|
|
17
17
|
v
|
|
18
|
-
Clubedge Starter v0.
|
|
19
|
-
commit
|
|
18
|
+
Clubedge Starter v0.6.0
|
|
19
|
+
commit bf5a5e801254816455cbb837902283b788ab7595
|
|
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.6.0` | `clubedge-starter@v0.6.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.6.0` tag is expected to resolve to commit
|
|
31
|
+
`bf5a5e801254816455cbb837902283b788ab7595`. 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.6.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
|
|
|
@@ -92,6 +92,7 @@ Authentication, file storage, and caching are modules. Each has a default, and a
|
|
|
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
|
+
| `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `none` | `docker` |
|
|
95
96
|
|
|
96
97
|
```sh
|
|
97
98
|
# Only the monorepo, UI, database, and tooling
|
|
@@ -109,6 +110,7 @@ Usage: pnpm dlx @clubedge/create-clubedge-app [project-directory] [options]
|
|
|
109
110
|
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
110
111
|
--storage <id> File storage: s3 (default), supabase, or none
|
|
111
112
|
--cache <id> Cache and rate limits: redis (default) or memory
|
|
113
|
+
--infra <id> Local services: docker (default), supabase, or none
|
|
112
114
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
113
115
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
114
116
|
--dry-run Show what would be created without writing anything
|
|
@@ -134,7 +136,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
134
136
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
135
137
|
|
|
136
138
|
# Reproduce a specific Starter release or commit
|
|
137
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
139
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.6.0
|
|
138
140
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
139
141
|
|
|
140
142
|
# Test local Starter changes before releasing them
|
|
@@ -145,7 +147,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --template-dir ../clubedge-starter
|
|
|
145
147
|
|
|
146
148
|
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.
|
|
147
149
|
|
|
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.
|
|
150
|
+
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, the root `package.json` scripts it sets, 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.
|
|
149
151
|
|
|
150
152
|
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:
|
|
151
153
|
|
|
@@ -179,7 +181,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
|
|
|
179
181
|
1. Make and validate changes in `clubedge-starter`.
|
|
180
182
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
181
183
|
3. Update the Starter package versions.
|
|
182
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
184
|
+
4. Commit `main`, create a release tag such as `v0.6.0`, and publish the Starter release.
|
|
183
185
|
|
|
184
186
|
### CLI maintainers
|
|
185
187
|
|
package/dist/cli/args.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
/** Module flags the CLI accepts. Which options exist is up to the Starter's manifest. */
|
|
3
|
-
export const MODULE_FLAGS = ["auth", "storage", "cache"];
|
|
3
|
+
export const MODULE_FLAGS = ["auth", "storage", "cache", "infra"];
|
|
4
4
|
export class UsageError extends Error {
|
|
5
5
|
name = "UsageError";
|
|
6
6
|
}
|
|
@@ -17,6 +17,7 @@ export function parseCliArgs(argv) {
|
|
|
17
17
|
auth: { type: "string" },
|
|
18
18
|
storage: { type: "string" },
|
|
19
19
|
cache: { type: "string" },
|
|
20
|
+
infra: { type: "string" },
|
|
20
21
|
ref: { type: "string" },
|
|
21
22
|
"template-dir": { type: "string" },
|
|
22
23
|
install: { type: "boolean", default: true },
|
package/dist/cli/ui.js
CHANGED
|
@@ -67,6 +67,7 @@ Options:
|
|
|
67
67
|
--auth <id> Authentication: supabase (default), better-auth, or none
|
|
68
68
|
--storage <id> File storage: s3 (default), supabase, or none
|
|
69
69
|
--cache <id> Cache and rate limits: redis (default) or memory
|
|
70
|
+
--infra <id> Local services: docker (default), supabase, or none
|
|
70
71
|
--ref <ref> Download this Starter tag, branch, or commit from GitHub instead
|
|
71
72
|
--template-dir <path> Scaffold from a local Starter checkout (for Starter development)
|
|
72
73
|
--dry-run Show what would be created without writing anything
|
package/dist/core/customize.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// Pure text transforms applied to template files. Keeping them free of I/O makes each one easy
|
|
2
2
|
// to test and lets --dry-run describe them without touching the disk.
|
|
3
3
|
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
4
|
-
export function customizeRootManifest(source, { packageName }, provenance, dockerImage) {
|
|
4
|
+
export function customizeRootManifest(source, { packageName }, provenance, dockerImage, scripts = { set: {}, remove: [] }) {
|
|
5
5
|
const manifest = JSON.parse(source);
|
|
6
6
|
manifest.name = packageName;
|
|
7
|
+
if (manifest.scripts && typeof manifest.scripts === "object") {
|
|
8
|
+
for (const name of scripts.remove)
|
|
9
|
+
delete manifest.scripts[name];
|
|
10
|
+
}
|
|
11
|
+
if (Object.keys(scripts.set).length)
|
|
12
|
+
manifest.scripts = { ...manifest.scripts, ...scripts.set };
|
|
7
13
|
manifest.clubedge = {
|
|
8
14
|
cliVersion: provenance.cliVersion,
|
|
9
15
|
starterRepository: provenance.starterRepository,
|
package/dist/core/manifest.js
CHANGED
|
@@ -102,6 +102,7 @@ function parseModules(value) {
|
|
|
102
102
|
files: optionalStringArray(option, "files", optionPrefix),
|
|
103
103
|
replace: optionalStringRecord(option, "replace", optionPrefix),
|
|
104
104
|
requires: parseRequires(option.requires, optionPrefix),
|
|
105
|
+
scripts: optionalStringRecord(option, "scripts", optionPrefix),
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
const defaultOption = requireString(entry, "default", prefix);
|
package/dist/core/plan.js
CHANGED
|
@@ -97,8 +97,22 @@ function resolveModuleFiles(manifest, selection) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
// Selected options set their scripts; scripts only left-out options define are removed.
|
|
101
|
+
const scripts = { set: {}, remove: [] };
|
|
102
|
+
const offeredScripts = new Set();
|
|
103
|
+
for (const [id, module] of Object.entries(manifest.modules)) {
|
|
104
|
+
for (const [optionId, option] of Object.entries(module.options)) {
|
|
105
|
+
for (const [name, command] of Object.entries(option.scripts)) {
|
|
106
|
+
offeredScripts.add(name);
|
|
107
|
+
if (selection[id] === optionId)
|
|
108
|
+
scripts.set[name] = command;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
scripts.remove = [...offeredScripts].filter((name) => !(name in scripts.set));
|
|
100
113
|
const removedPackages = [...offeredPackages].filter(([name]) => !usedPackages.has(name));
|
|
101
114
|
return {
|
|
115
|
+
scripts,
|
|
102
116
|
removedPackageNames: removedPackages.map(([name]) => name),
|
|
103
117
|
removedPackageDirectories: removedPackages.map(([, directory]) => directory),
|
|
104
118
|
replacedTargets: new Set(replacements.values()),
|
|
@@ -165,7 +179,7 @@ export function createPlan(input) {
|
|
|
165
179
|
edits.push({
|
|
166
180
|
path: "package.json",
|
|
167
181
|
description: `Name the package "${identity.packageName}" and record its origin`,
|
|
168
|
-
apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage),
|
|
182
|
+
apply: (text) => customizeRootManifest(text, identity, provenance, manifest.dockerImage, moduleFiles.scripts),
|
|
169
183
|
});
|
|
170
184
|
}
|
|
171
185
|
const appManifest = `${manifest.app}/package.json`;
|
package/dist/core/source.js
CHANGED
|
@@ -50,15 +50,18 @@ export async function readDirectoryTemplate(directory) {
|
|
|
50
50
|
}
|
|
51
51
|
/** Resolves a tag to its commit with `git ls-remote`, following annotated tags. */
|
|
52
52
|
export async function resolveTagCommit(repository, tag) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
53
|
+
// List every tag rather than passing a `refs/tags/<tag>^{}` pattern: on Windows the command
|
|
54
|
+
// runs through cmd.exe, which strips `^`, so the peeled ref would never match.
|
|
55
|
+
const output = await runCapture("git", ["ls-remote", "--tags", `https://github.com/${repository}.git`]);
|
|
56
|
+
return pickTagCommit(output, tag);
|
|
57
|
+
}
|
|
58
|
+
/** Picks a tag's commit from `git ls-remote` output, preferring the peeled ref of an annotated tag. */
|
|
59
|
+
export function pickTagCommit(output, tag) {
|
|
60
|
+
const refs = new Map(output
|
|
61
|
+
.split("\n")
|
|
62
|
+
.map((line) => line.trim().split(/\s+/))
|
|
63
|
+
.map(([id, ref]) => [ref, id]));
|
|
64
|
+
return refs.get(`refs/tags/${tag}^{}`) ?? refs.get(`refs/tags/${tag}`);
|
|
62
65
|
}
|
|
63
66
|
export async function downloadGitHubTemplate(ref, { repository = starterPin.starterRepository, expectedCommit } = {}) {
|
|
64
67
|
if (expectedCommit) {
|
package/dist/index.js
CHANGED
|
@@ -61,7 +61,7 @@ async function chooseModules(manifest, requested, interactive) {
|
|
|
61
61
|
if (!interactive || unasked.length === 0)
|
|
62
62
|
return requested;
|
|
63
63
|
const customize = await confirm({
|
|
64
|
-
message: "Customize the included services (auth, storage, cache)?",
|
|
64
|
+
message: "Customize the included services (auth, storage, cache, local infrastructure)?",
|
|
65
65
|
initialValue: false,
|
|
66
66
|
});
|
|
67
67
|
if (isCancel(customize))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.6.0",
|
|
26
|
+
"starterCommit": "bf5a5e801254816455cbb837902283b788ab7595"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
package/template/.env.example
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
NODE_ENV=development
|
|
2
2
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
3
3
|
|
|
4
|
-
# Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db).
|
|
5
|
-
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
|
|
4
|
+
# Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db). # clubedge:only infra=none
|
|
5
|
+
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres # clubedge:only infra=none
|
|
6
|
+
# Local Supabase database, started by pnpm infra:up. # clubedge:only infra=supabase
|
|
7
|
+
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres # clubedge:only infra=supabase
|
|
8
|
+
# Local PostgreSQL from docker-compose.dev.yml, started by pnpm infra:up. # clubedge:only infra=docker
|
|
9
|
+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app # clubedge:only infra=docker
|
|
6
10
|
|
|
7
11
|
# clubedge:if auth=supabase
|
|
8
12
|
# Supabase Auth (publishable key only; never put a service role key in the browser)
|
|
9
|
-
NEXT_PUBLIC_SUPABASE_URL=
|
|
13
|
+
NEXT_PUBLIC_SUPABASE_URL= # clubedge:only infra!=supabase
|
|
14
|
+
# Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
|
|
15
|
+
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
|
|
10
16
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
|
11
17
|
# clubedge:end
|
|
12
18
|
|
|
@@ -19,20 +25,28 @@ BETTER_AUTH_SECRET=
|
|
|
19
25
|
# clubedge:if cache=redis
|
|
20
26
|
# Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
|
|
21
27
|
# process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
|
|
22
|
-
REDIS_URL=
|
|
28
|
+
REDIS_URL= # clubedge:only infra!=docker
|
|
29
|
+
REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
|
|
23
30
|
# clubedge:end
|
|
24
31
|
|
|
25
32
|
# clubedge:if storage=s3
|
|
26
33
|
# S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
|
|
27
|
-
STORAGE_BUCKET=
|
|
28
|
-
STORAGE_REGION=auto
|
|
29
|
-
STORAGE_ENDPOINT=
|
|
30
|
-
STORAGE_ACCESS_KEY_ID=
|
|
31
|
-
STORAGE_SECRET_ACCESS_KEY=
|
|
34
|
+
STORAGE_BUCKET= # clubedge:only infra!=docker
|
|
35
|
+
STORAGE_REGION=auto # clubedge:only infra!=docker
|
|
36
|
+
STORAGE_ENDPOINT= # clubedge:only infra!=docker
|
|
37
|
+
STORAGE_ACCESS_KEY_ID= # clubedge:only infra!=docker
|
|
38
|
+
STORAGE_SECRET_ACCESS_KEY= # clubedge:only infra!=docker
|
|
39
|
+
# Local S3-compatible storage from docker-compose.dev.yml; it accepts any credentials. # clubedge:only infra=docker
|
|
40
|
+
STORAGE_BUCKET=app # clubedge:only infra=docker
|
|
41
|
+
STORAGE_REGION=us-east-1 # clubedge:only infra=docker
|
|
42
|
+
STORAGE_ENDPOINT=http://localhost:9090 # clubedge:only infra=docker
|
|
43
|
+
STORAGE_ACCESS_KEY_ID=local # clubedge:only infra=docker
|
|
44
|
+
STORAGE_SECRET_ACCESS_KEY=local-secret # clubedge:only infra=docker
|
|
32
45
|
STORAGE_PUBLIC_URL=
|
|
33
46
|
# clubedge:end
|
|
34
47
|
|
|
35
48
|
# clubedge:if storage=supabase
|
|
36
49
|
# Supabase Storage bucket, accessed with the signed-in user's Supabase session
|
|
37
|
-
SUPABASE_STORAGE_BUCKET=
|
|
50
|
+
SUPABASE_STORAGE_BUCKET= # clubedge:only infra!=supabase
|
|
51
|
+
SUPABASE_STORAGE_BUCKET=app # clubedge:only infra=supabase
|
|
38
52
|
# clubedge:end
|
package/template/README.md
CHANGED
|
@@ -93,7 +93,36 @@ macOS/Linux:
|
|
|
93
93
|
cp .env.example apps/web/.env.local
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
<!-- clubedge:if infra=docker -->
|
|
97
|
+
|
|
98
|
+
Start PostgreSQL and the other local services with Docker, apply the migrations, and check that the app can reach every service. The example environment already points at them.
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
pnpm infra:up
|
|
102
|
+
pnpm db:migrate
|
|
103
|
+
pnpm check:services
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
<!-- clubedge:end -->
|
|
107
|
+
<!-- clubedge:if infra=supabase -->
|
|
108
|
+
|
|
109
|
+
Start the local Supabase stack (it needs Docker), copy the publishable key that `pnpm infra:status` prints into `apps/web/.env.local`, and apply the migrations.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
pnpm infra:up
|
|
113
|
+
pnpm infra:status
|
|
114
|
+
pnpm db:migrate
|
|
115
|
+
pnpm check:services
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
<!-- clubedge:end -->
|
|
119
|
+
<!-- clubedge:if infra=none -->
|
|
120
|
+
|
|
121
|
+
Set `DATABASE_URL` and your providers' settings in `apps/web/.env.local`, then run `pnpm db:migrate` and `pnpm check:services`.
|
|
122
|
+
|
|
123
|
+
<!-- clubedge:end -->
|
|
124
|
+
|
|
125
|
+
Then start the app. [SETUP.md](SETUP.md) describes every variable and provider.
|
|
97
126
|
|
|
98
127
|
```sh
|
|
99
128
|
pnpm dev
|
|
@@ -125,6 +154,14 @@ Run these from the repository root:
|
|
|
125
154
|
|
|
126
155
|
Playwright's first run may require installing Chromium with `pnpm exec playwright install chromium`. CI runs the browser checks automatically.
|
|
127
156
|
|
|
157
|
+
Local services:
|
|
158
|
+
|
|
159
|
+
- `pnpm check:services`: check every configured service with a real round trip.
|
|
160
|
+
- `pnpm infra:up` and `pnpm infra:down`: start and stop the local services from `docker-compose.dev.yml`. <!-- clubedge:only infra=docker -->
|
|
161
|
+
- `pnpm infra:reset`: stop them and delete their data. `pnpm infra:logs` follows their logs. <!-- clubedge:only infra=docker -->
|
|
162
|
+
- `pnpm infra:up` and `pnpm infra:down`: start and stop the local Supabase stack. <!-- clubedge:only infra=supabase -->
|
|
163
|
+
- `pnpm infra:status`: print its URLs and keys. `pnpm infra:reset` stops it and deletes its data. <!-- clubedge:only infra=supabase -->
|
|
164
|
+
|
|
128
165
|
<!-- clubedge:if starter-repository -->
|
|
129
166
|
|
|
130
167
|
## Working on this repository
|
package/template/SETUP.md
CHANGED
|
@@ -31,7 +31,10 @@ macOS/Linux:
|
|
|
31
31
|
cp .env.example apps/web/.env.local
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The example file
|
|
34
|
+
The example file points at the local services described in [Local services](#local-services) and leaves provider keys blank, so the app builds and starts before anything is configured. <!-- clubedge:only infra!=none -->
|
|
35
|
+
The example file leaves provider URLs and keys blank so the app can build and start without connecting to real services. At minimum, set `DATABASE_URL`. <!-- clubedge:only infra=none -->
|
|
36
|
+
|
|
37
|
+
The variables are:
|
|
35
38
|
|
|
36
39
|
- `DATABASE_URL` (required): PostgreSQL connection URL, such as a Supabase transaction pooler URL.
|
|
37
40
|
- `NEXT_PUBLIC_APP_URL` (recommended): the app's origin, defaulting to `http://localhost:3000`. <!-- clubedge:only framework=next -->
|
|
@@ -83,6 +86,45 @@ Put the result in `BETTER_AUTH_SECRET`. Until it is set, the dashboard stays vie
|
|
|
83
86
|
|
|
84
87
|
<!-- clubedge:end -->
|
|
85
88
|
|
|
89
|
+
## Local services
|
|
90
|
+
|
|
91
|
+
<!-- clubedge:if infra=docker -->
|
|
92
|
+
|
|
93
|
+
`docker-compose.dev.yml` runs the services this project uses on your machine, listening on `127.0.0.1` only. Install Docker Desktop (or Docker Engine with Compose), then:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
pnpm infra:up # start and wait until every service is healthy
|
|
97
|
+
pnpm db:migrate # create the tables
|
|
98
|
+
pnpm check:services
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- PostgreSQL 17 on port 5432, database `app`, user and password `postgres`. Data survives restarts in a Docker volume.
|
|
102
|
+
- Redis on port 6379. <!-- clubedge:only cache=redis -->
|
|
103
|
+
- S3-compatible storage (adobe/s3mock) on port 9090 with the bucket `app`. It accepts any credentials and is for development only. <!-- clubedge:only storage=s3 -->
|
|
104
|
+
|
|
105
|
+
`pnpm infra:down` stops the services, `pnpm infra:reset` also deletes their data, and `pnpm infra:logs` follows their logs. The file is for development; in production, point the same variables at managed services.
|
|
106
|
+
|
|
107
|
+
<!-- clubedge:end -->
|
|
108
|
+
<!-- clubedge:if infra=supabase -->
|
|
109
|
+
|
|
110
|
+
`supabase/config.toml` configures the Supabase CLI's local stack: PostgreSQL, Auth, Storage, the Studio dashboard, and an inbox that captures emails. The CLI runs it in Docker, so install Docker Desktop first. Then:
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
pnpm infra:up # start the stack (the first run downloads its images)
|
|
114
|
+
pnpm infra:status # print the local URLs and keys
|
|
115
|
+
pnpm db:migrate
|
|
116
|
+
pnpm check:services
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The database is on port 54322 and the API on port 54321, matching the example environment. Copy the publishable key from `pnpm infra:status` into the Supabase variables. Email confirmation is off locally, so sign-up signs the user in directly. `pnpm infra:down` stops the stack and `pnpm infra:reset` also deletes its data.
|
|
120
|
+
|
|
121
|
+
<!-- clubedge:end -->
|
|
122
|
+
<!-- clubedge:if infra=none -->
|
|
123
|
+
|
|
124
|
+
This project does not include local service definitions. Point `DATABASE_URL` and the provider variables at services you run yourself or at hosted ones, such as Supabase or Neon for PostgreSQL and Upstash for Redis, then run `pnpm check:services` to confirm the app can reach them.
|
|
125
|
+
|
|
126
|
+
<!-- clubedge:end -->
|
|
127
|
+
|
|
86
128
|
## Set up the database
|
|
87
129
|
|
|
88
130
|
Application tables are managed with Drizzle. Keep one migration source of truth for application tables: do not also create an independent Supabase migration history for those same tables.
|
package/template/_gitignore
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
# development JSX that crashes the production server. `vite dev` and `vite build` set it.
|
|
3
3
|
APP_URL=http://localhost:3000
|
|
4
4
|
|
|
5
|
-
# Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db).
|
|
6
|
-
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
|
|
5
|
+
# Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db). # clubedge:only infra=none
|
|
6
|
+
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres # clubedge:only infra=none
|
|
7
|
+
# Local Supabase database, started by pnpm infra:up. # clubedge:only infra=supabase
|
|
8
|
+
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres # clubedge:only infra=supabase
|
|
9
|
+
# Local PostgreSQL from docker-compose.dev.yml, started by pnpm infra:up. # clubedge:only infra=docker
|
|
10
|
+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app # clubedge:only infra=docker
|
|
7
11
|
|
|
8
12
|
# clubedge:if auth=supabase
|
|
9
13
|
# Supabase Auth (publishable key only; never use a service role key here).
|
|
10
14
|
# All auth runs on the server, so these need no browser prefix.
|
|
11
|
-
SUPABASE_URL=
|
|
15
|
+
SUPABASE_URL= # clubedge:only infra!=supabase
|
|
16
|
+
# Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
|
|
17
|
+
SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
|
|
12
18
|
SUPABASE_PUBLISHABLE_KEY=
|
|
13
19
|
# clubedge:end
|
|
14
20
|
|
|
@@ -21,20 +27,28 @@ BETTER_AUTH_SECRET=
|
|
|
21
27
|
# clubedge:if cache=redis
|
|
22
28
|
# Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
|
|
23
29
|
# process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
|
|
24
|
-
REDIS_URL=
|
|
30
|
+
REDIS_URL= # clubedge:only infra!=docker
|
|
31
|
+
REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
|
|
25
32
|
# clubedge:end
|
|
26
33
|
|
|
27
34
|
# clubedge:if storage=s3
|
|
28
35
|
# S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
|
|
29
|
-
STORAGE_BUCKET=
|
|
30
|
-
STORAGE_REGION=auto
|
|
31
|
-
STORAGE_ENDPOINT=
|
|
32
|
-
STORAGE_ACCESS_KEY_ID=
|
|
33
|
-
STORAGE_SECRET_ACCESS_KEY=
|
|
36
|
+
STORAGE_BUCKET= # clubedge:only infra!=docker
|
|
37
|
+
STORAGE_REGION=auto # clubedge:only infra!=docker
|
|
38
|
+
STORAGE_ENDPOINT= # clubedge:only infra!=docker
|
|
39
|
+
STORAGE_ACCESS_KEY_ID= # clubedge:only infra!=docker
|
|
40
|
+
STORAGE_SECRET_ACCESS_KEY= # clubedge:only infra!=docker
|
|
41
|
+
# Local S3-compatible storage from docker-compose.dev.yml; it accepts any credentials. # clubedge:only infra=docker
|
|
42
|
+
STORAGE_BUCKET=app # clubedge:only infra=docker
|
|
43
|
+
STORAGE_REGION=us-east-1 # clubedge:only infra=docker
|
|
44
|
+
STORAGE_ENDPOINT=http://localhost:9090 # clubedge:only infra=docker
|
|
45
|
+
STORAGE_ACCESS_KEY_ID=local # clubedge:only infra=docker
|
|
46
|
+
STORAGE_SECRET_ACCESS_KEY=local-secret # clubedge:only infra=docker
|
|
34
47
|
STORAGE_PUBLIC_URL=
|
|
35
48
|
# clubedge:end
|
|
36
49
|
|
|
37
50
|
# clubedge:if storage=supabase
|
|
38
51
|
# Supabase Storage bucket, accessed with the signed-in user's Supabase session
|
|
39
|
-
SUPABASE_STORAGE_BUCKET=
|
|
52
|
+
SUPABASE_STORAGE_BUCKET= # clubedge:only infra!=supabase
|
|
53
|
+
SUPABASE_STORAGE_BUCKET=app # clubedge:only infra=supabase
|
|
40
54
|
# clubedge:end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"dev": "vite dev",
|
|
9
9
|
"build": "vite build",
|
|
10
10
|
"start": "node --env-file-if-exists=.env.local .output/server/index.mjs",
|
|
11
|
-
"typecheck": "tsr generate && tsc --noEmit"
|
|
11
|
+
"typecheck": "tsr generate && tsc --noEmit",
|
|
12
|
+
"check:services": "tsx scripts/check-services.ts"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
15
|
"@clubedge/auth": "workspace:*",
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"@vitejs/plugin-react": "^6.1.2",
|
|
39
40
|
"nitro": "3.0.260903-beta",
|
|
40
41
|
"tailwindcss": "^4.1.13",
|
|
42
|
+
"tsx": "^4.20.5",
|
|
41
43
|
"vite": "^8.3.4"
|
|
42
44
|
}
|
|
43
45
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Checks every service this project uses with a real round trip: `pnpm check:services`.
|
|
2
|
+
// It reads the app's .env.local like the app does, so it confirms the app's own settings.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { createDb } from "@clubedge/db";
|
|
5
|
+
// clubedge:if cache=redis
|
|
6
|
+
import { createRedisCache } from "@clubedge/cache-redis";
|
|
7
|
+
// clubedge:end
|
|
8
|
+
// clubedge:if storage=s3
|
|
9
|
+
import { createS3Storage } from "@clubedge/storage-s3";
|
|
10
|
+
// clubedge:end
|
|
11
|
+
|
|
12
|
+
type Check = { name: string; skip?: string; run: () => Promise<string> };
|
|
13
|
+
|
|
14
|
+
/** Services can take a few seconds to accept connections after `pnpm infra:up`. */
|
|
15
|
+
async function withRetry(run: () => Promise<string>, attempts = 15): Promise<string> {
|
|
16
|
+
for (let attempt = 1; ; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
return await run();
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (attempt >= attempts) throw error;
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
|
28
|
+
const env = process.env;
|
|
29
|
+
|
|
30
|
+
const checks: Check[] = [
|
|
31
|
+
{
|
|
32
|
+
name: "PostgreSQL",
|
|
33
|
+
skip: env.DATABASE_URL ? undefined : "DATABASE_URL is not set",
|
|
34
|
+
async run() {
|
|
35
|
+
const db = createDb({ url: env.DATABASE_URL! });
|
|
36
|
+
try {
|
|
37
|
+
const [row] = await db.$client`select current_database() as name`;
|
|
38
|
+
// clubedge:if auth=better-auth
|
|
39
|
+
// Better Auth's tables exist once `pnpm db:migrate` has run.
|
|
40
|
+
await db.$client`select 1 from auth_user limit 1`;
|
|
41
|
+
// clubedge:end
|
|
42
|
+
return `connected to database "${row?.name}"`;
|
|
43
|
+
} finally {
|
|
44
|
+
await db.$client.end();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
// clubedge:if cache=redis
|
|
49
|
+
{
|
|
50
|
+
name: "Redis",
|
|
51
|
+
skip: env.REDIS_URL ? undefined : "REDIS_URL is not set; the app uses process memory",
|
|
52
|
+
async run() {
|
|
53
|
+
const cache = createRedisCache(env.REDIS_URL!);
|
|
54
|
+
const value = { checkedAt: Date.now() };
|
|
55
|
+
await cache.set("check-services", value, 30);
|
|
56
|
+
const read = await cache.get<typeof value>("check-services");
|
|
57
|
+
if (read?.checkedAt !== value.checkedAt) throw new Error("read back a different value");
|
|
58
|
+
return "wrote and read a cached value";
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
// clubedge:end
|
|
62
|
+
// clubedge:if storage=s3
|
|
63
|
+
{
|
|
64
|
+
name: "File storage",
|
|
65
|
+
skip: env.STORAGE_BUCKET ? undefined : "STORAGE_BUCKET is not set",
|
|
66
|
+
async run() {
|
|
67
|
+
const storage = createS3Storage({
|
|
68
|
+
bucket: env.STORAGE_BUCKET,
|
|
69
|
+
region: env.STORAGE_REGION || "auto",
|
|
70
|
+
endpoint: env.STORAGE_ENDPOINT || undefined,
|
|
71
|
+
accessKeyId: env.STORAGE_ACCESS_KEY_ID || undefined,
|
|
72
|
+
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY || undefined,
|
|
73
|
+
});
|
|
74
|
+
const key = `check-services/${Date.now()}.txt`;
|
|
75
|
+
await storage.upload({
|
|
76
|
+
key,
|
|
77
|
+
body: new TextEncoder().encode("ok"),
|
|
78
|
+
contentType: "text/plain",
|
|
79
|
+
});
|
|
80
|
+
const text = await new Response(await storage.download(key)).text();
|
|
81
|
+
await storage.delete(key);
|
|
82
|
+
if (text !== "ok") throw new Error(`read back "${text}"`);
|
|
83
|
+
return `uploaded, read back, and deleted an object in "${env.STORAGE_BUCKET}"`;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
// clubedge:end
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
let failed = false;
|
|
90
|
+
for (const check of checks) {
|
|
91
|
+
if (check.skip) {
|
|
92
|
+
console.log(`- ${check.name}: skipped (${check.skip})`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
console.log(`✓ ${check.name}: ${await withRetry(check.run)}`);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
failed = true;
|
|
99
|
+
console.log(`✗ ${check.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Redis clients keep the process alive, so exit explicitly.
|
|
103
|
+
process.exit(failed ? 1 : 0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
void main();
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "next dev",
|
|
8
8
|
"build": "next build",
|
|
9
9
|
"start": "node scripts/start-standalone.mjs",
|
|
10
|
-
"typecheck": "tsc --noEmit"
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"check:services": "tsx scripts/check-services.ts"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
14
|
"@clubedge/auth": "workspace:*",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"@tailwindcss/postcss": "^4.1.13",
|
|
34
35
|
"@types/react": "^19.1.13",
|
|
35
36
|
"@types/react-dom": "^19.1.9",
|
|
36
|
-
"tailwindcss": "^4.1.13"
|
|
37
|
+
"tailwindcss": "^4.1.13",
|
|
38
|
+
"tsx": "^4.20.5"
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Checks every service this project uses with a real round trip: `pnpm check:services`.
|
|
2
|
+
// It reads the app's .env.local like the app does, so it confirms the app's own settings.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { createDb } from "@clubedge/db";
|
|
5
|
+
// clubedge:if cache=redis
|
|
6
|
+
import { createRedisCache } from "@clubedge/cache-redis";
|
|
7
|
+
// clubedge:end
|
|
8
|
+
// clubedge:if storage=s3
|
|
9
|
+
import { createS3Storage } from "@clubedge/storage-s3";
|
|
10
|
+
// clubedge:end
|
|
11
|
+
|
|
12
|
+
type Check = { name: string; skip?: string; run: () => Promise<string> };
|
|
13
|
+
|
|
14
|
+
/** Services can take a few seconds to accept connections after `pnpm infra:up`. */
|
|
15
|
+
async function withRetry(run: () => Promise<string>, attempts = 15): Promise<string> {
|
|
16
|
+
for (let attempt = 1; ; attempt++) {
|
|
17
|
+
try {
|
|
18
|
+
return await run();
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (attempt >= attempts) throw error;
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
|
28
|
+
const env = process.env;
|
|
29
|
+
|
|
30
|
+
const checks: Check[] = [
|
|
31
|
+
{
|
|
32
|
+
name: "PostgreSQL",
|
|
33
|
+
skip: env.DATABASE_URL ? undefined : "DATABASE_URL is not set",
|
|
34
|
+
async run() {
|
|
35
|
+
const db = createDb({ url: env.DATABASE_URL! });
|
|
36
|
+
try {
|
|
37
|
+
const [row] = await db.$client`select current_database() as name`;
|
|
38
|
+
// clubedge:if auth=better-auth
|
|
39
|
+
// Better Auth's tables exist once `pnpm db:migrate` has run.
|
|
40
|
+
await db.$client`select 1 from auth_user limit 1`;
|
|
41
|
+
// clubedge:end
|
|
42
|
+
return `connected to database "${row?.name}"`;
|
|
43
|
+
} finally {
|
|
44
|
+
await db.$client.end();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
// clubedge:if cache=redis
|
|
49
|
+
{
|
|
50
|
+
name: "Redis",
|
|
51
|
+
skip: env.REDIS_URL ? undefined : "REDIS_URL is not set; the app uses process memory",
|
|
52
|
+
async run() {
|
|
53
|
+
const cache = createRedisCache(env.REDIS_URL!);
|
|
54
|
+
const value = { checkedAt: Date.now() };
|
|
55
|
+
await cache.set("check-services", value, 30);
|
|
56
|
+
const read = await cache.get<typeof value>("check-services");
|
|
57
|
+
if (read?.checkedAt !== value.checkedAt) throw new Error("read back a different value");
|
|
58
|
+
return "wrote and read a cached value";
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
// clubedge:end
|
|
62
|
+
// clubedge:if storage=s3
|
|
63
|
+
{
|
|
64
|
+
name: "File storage",
|
|
65
|
+
skip: env.STORAGE_BUCKET ? undefined : "STORAGE_BUCKET is not set",
|
|
66
|
+
async run() {
|
|
67
|
+
const storage = createS3Storage({
|
|
68
|
+
bucket: env.STORAGE_BUCKET,
|
|
69
|
+
region: env.STORAGE_REGION || "auto",
|
|
70
|
+
endpoint: env.STORAGE_ENDPOINT || undefined,
|
|
71
|
+
accessKeyId: env.STORAGE_ACCESS_KEY_ID || undefined,
|
|
72
|
+
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY || undefined,
|
|
73
|
+
});
|
|
74
|
+
const key = `check-services/${Date.now()}.txt`;
|
|
75
|
+
await storage.upload({
|
|
76
|
+
key,
|
|
77
|
+
body: new TextEncoder().encode("ok"),
|
|
78
|
+
contentType: "text/plain",
|
|
79
|
+
});
|
|
80
|
+
const text = await new Response(await storage.download(key)).text();
|
|
81
|
+
await storage.delete(key);
|
|
82
|
+
if (text !== "ok") throw new Error(`read back "${text}"`);
|
|
83
|
+
return `uploaded, read back, and deleted an object in "${env.STORAGE_BUCKET}"`;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
// clubedge:end
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
let failed = false;
|
|
90
|
+
for (const check of checks) {
|
|
91
|
+
if (check.skip) {
|
|
92
|
+
console.log(`- ${check.name}: skipped (${check.skip})`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
console.log(`✓ ${check.name}: ${await withRetry(check.run)}`);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
failed = true;
|
|
99
|
+
console.log(`✗ ${check.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Redis clients keep the process alive, so exit explicitly.
|
|
103
|
+
process.exit(failed ? 1 : 0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
void main();
|
|
@@ -160,6 +160,35 @@
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
|
+
},
|
|
164
|
+
"infra": {
|
|
165
|
+
"name": "Local infrastructure",
|
|
166
|
+
"default": "docker",
|
|
167
|
+
"options": {
|
|
168
|
+
"docker": {
|
|
169
|
+
"name": "Docker Compose",
|
|
170
|
+
"files": ["docker-compose.dev.yml"],
|
|
171
|
+
"scripts": {
|
|
172
|
+
"infra:up": "docker compose -f docker-compose.dev.yml up -d --wait",
|
|
173
|
+
"infra:down": "docker compose -f docker-compose.dev.yml down",
|
|
174
|
+
"infra:reset": "docker compose -f docker-compose.dev.yml down --volumes",
|
|
175
|
+
"infra:logs": "docker compose -f docker-compose.dev.yml logs --follow"
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"supabase": {
|
|
179
|
+
"name": "Supabase CLI",
|
|
180
|
+
"files": ["supabase"],
|
|
181
|
+
"scripts": {
|
|
182
|
+
"infra:up": "npx --yes supabase@2 start",
|
|
183
|
+
"infra:down": "npx --yes supabase@2 stop",
|
|
184
|
+
"infra:reset": "npx --yes supabase@2 stop --no-backup",
|
|
185
|
+
"infra:status": "npx --yes supabase@2 status"
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"none": {
|
|
189
|
+
"name": "None (bring your own services)"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
163
192
|
}
|
|
164
193
|
},
|
|
165
194
|
"conditional": [
|
|
@@ -169,12 +198,16 @@
|
|
|
169
198
|
"README.md",
|
|
170
199
|
"SETUP.md",
|
|
171
200
|
"apps/start/.env.example",
|
|
201
|
+
"apps/start/scripts/check-services.ts",
|
|
172
202
|
"apps/start/src/env/auth.ts",
|
|
173
203
|
"apps/start/src/routes/dashboard.tsx",
|
|
174
204
|
"apps/start/src/routes/index.tsx",
|
|
205
|
+
"apps/web/scripts/check-services.ts",
|
|
175
206
|
"apps/web/src/app/dashboard/page.tsx",
|
|
176
207
|
"apps/web/src/app/page.tsx",
|
|
177
|
-
"apps/web/src/env/auth.ts"
|
|
208
|
+
"apps/web/src/env/auth.ts",
|
|
209
|
+
"docker-compose.dev.yml",
|
|
210
|
+
"supabase/config.toml"
|
|
178
211
|
],
|
|
179
212
|
"exclude": [
|
|
180
213
|
".github/workflows/starter.yml",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Local services for development. `pnpm infra:up` starts them and waits until they are ready,
|
|
2
|
+
# `pnpm infra:down` stops them, and `pnpm infra:reset` also deletes their data. Ports listen on
|
|
3
|
+
# 127.0.0.1 only. This file is for development; production uses managed services.
|
|
4
|
+
services:
|
|
5
|
+
postgres:
|
|
6
|
+
image: postgres:17
|
|
7
|
+
environment:
|
|
8
|
+
POSTGRES_USER: postgres
|
|
9
|
+
POSTGRES_PASSWORD: postgres
|
|
10
|
+
POSTGRES_DB: app
|
|
11
|
+
ports:
|
|
12
|
+
- "127.0.0.1:5432:5432"
|
|
13
|
+
volumes:
|
|
14
|
+
- postgres-data:/var/lib/postgresql/data
|
|
15
|
+
healthcheck:
|
|
16
|
+
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
|
|
17
|
+
interval: 2s
|
|
18
|
+
timeout: 5s
|
|
19
|
+
retries: 30
|
|
20
|
+
# clubedge:if cache=redis
|
|
21
|
+
redis:
|
|
22
|
+
image: redis:8
|
|
23
|
+
ports:
|
|
24
|
+
- "127.0.0.1:6379:6379"
|
|
25
|
+
healthcheck:
|
|
26
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
27
|
+
interval: 2s
|
|
28
|
+
timeout: 5s
|
|
29
|
+
retries: 30
|
|
30
|
+
# clubedge:end
|
|
31
|
+
# clubedge:if storage=s3
|
|
32
|
+
# S3-compatible storage for development, with the "app" bucket created at startup.
|
|
33
|
+
storage:
|
|
34
|
+
image: adobe/s3mock:5
|
|
35
|
+
environment:
|
|
36
|
+
COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS: app
|
|
37
|
+
ports:
|
|
38
|
+
- "127.0.0.1:9090:9090"
|
|
39
|
+
# clubedge:end
|
|
40
|
+
|
|
41
|
+
volumes:
|
|
42
|
+
postgres-data:
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clubedge-starter-v1",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
@@ -27,7 +27,12 @@
|
|
|
27
27
|
"test:e2e:ui": "playwright test --ui",
|
|
28
28
|
"docker:dev": "docker compose up --build",
|
|
29
29
|
"docker:build": "docker build -t clubedge-starter .",
|
|
30
|
-
"docker:start": "docker run --rm -p 3000:3000 --env-file apps/web/.env.local clubedge-starter"
|
|
30
|
+
"docker:start": "docker run --rm -p 3000:3000 --env-file apps/web/.env.local clubedge-starter",
|
|
31
|
+
"check:services": "pnpm --filter @clubedge/web check:services",
|
|
32
|
+
"infra:up": "docker compose -f docker-compose.dev.yml up -d --wait",
|
|
33
|
+
"infra:down": "docker compose -f docker-compose.dev.yml down",
|
|
34
|
+
"infra:reset": "docker compose -f docker-compose.dev.yml down --volumes",
|
|
35
|
+
"infra:logs": "docker compose -f docker-compose.dev.yml logs --follow"
|
|
31
36
|
},
|
|
32
37
|
"devDependencies": {
|
|
33
38
|
"@playwright/test": "^1.55.0",
|
|
@@ -37,6 +37,28 @@ describe("createS3Storage", () => {
|
|
|
37
37
|
expect(S3Client).toHaveBeenCalledTimes(1);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
it("uses path-style addressing with a custom endpoint unless told otherwise", async () => {
|
|
41
|
+
send.mockResolvedValue({});
|
|
42
|
+
await createS3Storage({ bucket: "files", region: "auto" }).delete("a.txt");
|
|
43
|
+
await createS3Storage({
|
|
44
|
+
bucket: "files",
|
|
45
|
+
region: "auto",
|
|
46
|
+
endpoint: "http://localhost:9090",
|
|
47
|
+
}).delete("a.txt");
|
|
48
|
+
await createS3Storage({
|
|
49
|
+
bucket: "files",
|
|
50
|
+
region: "auto",
|
|
51
|
+
endpoint: "https://r2.example",
|
|
52
|
+
forcePathStyle: false,
|
|
53
|
+
}).delete("a.txt");
|
|
54
|
+
|
|
55
|
+
expect(vi.mocked(S3Client).mock.calls.map(([options]) => options?.forcePathStyle)).toEqual([
|
|
56
|
+
false,
|
|
57
|
+
true,
|
|
58
|
+
false,
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
40
62
|
it("uploads to the configured bucket and builds the public URL", async () => {
|
|
41
63
|
const storage = createS3Storage({
|
|
42
64
|
bucket: "files",
|
|
@@ -13,6 +13,11 @@ export interface S3StorageConfig {
|
|
|
13
13
|
bucket?: string;
|
|
14
14
|
region: string;
|
|
15
15
|
endpoint?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Address buckets as https://endpoint/bucket rather than https://bucket.endpoint. Defaults to
|
|
18
|
+
* true with a custom endpoint, which local servers (s3mock), MinIO, and R2 all accept.
|
|
19
|
+
*/
|
|
20
|
+
forcePathStyle?: boolean;
|
|
16
21
|
accessKeyId?: string;
|
|
17
22
|
secretAccessKey?: string;
|
|
18
23
|
/** Public base URL for uploaded objects, such as a CDN or R2 public bucket. */
|
|
@@ -25,7 +30,7 @@ function bodyForS3(body: UploadInput["body"]) {
|
|
|
25
30
|
return body;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
/** Works with AWS S3 and S3-compatible services such as Cloudflare R2 and
|
|
33
|
+
/** Works with AWS S3 and S3-compatible services such as Cloudflare R2, MinIO, and s3mock. */
|
|
29
34
|
export function createS3Storage(config: S3StorageConfig): StorageProvider {
|
|
30
35
|
let s3Client: S3Client | undefined;
|
|
31
36
|
|
|
@@ -33,6 +38,7 @@ export function createS3Storage(config: S3StorageConfig): StorageProvider {
|
|
|
33
38
|
s3Client ??= new S3Client({
|
|
34
39
|
region: config.region,
|
|
35
40
|
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
41
|
+
forcePathStyle: config.forcePathStyle ?? Boolean(config.endpoint),
|
|
36
42
|
credentials:
|
|
37
43
|
config.accessKeyId && config.secretAccessKey
|
|
38
44
|
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
|
@@ -13,7 +13,9 @@ export default defineConfig({
|
|
|
13
13
|
url: "http://127.0.0.1:3000",
|
|
14
14
|
reuseExistingServer: !process.env.CI,
|
|
15
15
|
env: {
|
|
16
|
-
|
|
16
|
+
// A placeholder unless set, so pages render without a database.
|
|
17
|
+
DATABASE_URL:
|
|
18
|
+
process.env.DATABASE_URL ?? "postgresql://postgres:postgres@127.0.0.1:5432/postgres",
|
|
17
19
|
// Next.js reads NEXT_PUBLIC_APP_URL; TanStack Start reads APP_URL.
|
|
18
20
|
NEXT_PUBLIC_APP_URL: "http://127.0.0.1:3000",
|
|
19
21
|
APP_URL: "http://127.0.0.1:3000",
|
package/template/pnpm-lock.yaml
CHANGED
|
@@ -114,6 +114,9 @@ importers:
|
|
|
114
114
|
tailwindcss:
|
|
115
115
|
specifier: ^4.1.13
|
|
116
116
|
version: 4.3.3
|
|
117
|
+
tsx:
|
|
118
|
+
specifier: ^4.20.5
|
|
119
|
+
version: 4.23.15
|
|
117
120
|
vite:
|
|
118
121
|
specifier: ^8.3.4
|
|
119
122
|
version: 8.3.4(@types/node@22.20.5)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.15)
|
|
@@ -187,6 +190,9 @@ importers:
|
|
|
187
190
|
tailwindcss:
|
|
188
191
|
specifier: ^4.1.13
|
|
189
192
|
version: 4.3.3
|
|
193
|
+
tsx:
|
|
194
|
+
specifier: ^4.20.5
|
|
195
|
+
version: 4.23.15
|
|
190
196
|
|
|
191
197
|
packages/auth:
|
|
192
198
|
dependencies:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Local Supabase stack for development: `pnpm infra:up` runs `supabase start`, and
|
|
2
|
+
# `pnpm infra:status` prints the local URLs and keys. Docs: https://supabase.com/docs/guides/local-development
|
|
3
|
+
project_id = "clubedge-app"
|
|
4
|
+
|
|
5
|
+
[api]
|
|
6
|
+
port = 54321
|
|
7
|
+
|
|
8
|
+
[db]
|
|
9
|
+
port = 54322
|
|
10
|
+
major_version = 17
|
|
11
|
+
|
|
12
|
+
[studio]
|
|
13
|
+
port = 54323
|
|
14
|
+
|
|
15
|
+
# Captures sign-up and password emails locally; open it from `pnpm infra:status`.
|
|
16
|
+
[inbucket]
|
|
17
|
+
port = 54324
|
|
18
|
+
|
|
19
|
+
[auth]
|
|
20
|
+
site_url = "http://localhost:3000"
|
|
21
|
+
additional_redirect_urls = ["http://localhost:3000/auth/callback", "http://127.0.0.1:3000/auth/callback"]
|
|
22
|
+
|
|
23
|
+
[auth.email]
|
|
24
|
+
# Sign-up signs the user in directly during local development.
|
|
25
|
+
enable_confirmations = false
|
|
26
|
+
|
|
27
|
+
# clubedge:if storage=supabase
|
|
28
|
+
[storage.buckets.app]
|
|
29
|
+
public = false
|
|
30
|
+
# clubedge:end
|
|
31
|
+
|
|
32
|
+
# Not used by the app; disabled so the stack starts faster.
|
|
33
|
+
[realtime]
|
|
34
|
+
enabled = false
|
|
35
|
+
|
|
36
|
+
[edge_runtime]
|
|
37
|
+
enabled = false
|
|
38
|
+
|
|
39
|
+
[analytics]
|
|
40
|
+
enabled = false
|
package/template.lock.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repository": "Clubedge/clubedge-starter",
|
|
3
|
-
"ref": "v0.
|
|
4
|
-
"commit": "
|
|
3
|
+
"ref": "v0.6.0",
|
|
4
|
+
"commit": "bf5a5e801254816455cbb837902283b788ab7595",
|
|
5
5
|
"files": [
|
|
6
6
|
".dockerignore",
|
|
7
7
|
".env.example",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"apps/start/package.json",
|
|
33
33
|
"apps/start/public/.well-known/logo.svg",
|
|
34
34
|
"apps/start/public/favicon.ico",
|
|
35
|
+
"apps/start/scripts/check-services.ts",
|
|
35
36
|
"apps/start/src/components/auth/auth-form-controls.tsx",
|
|
36
37
|
"apps/start/src/components/branding/clubedge-mark.tsx",
|
|
37
38
|
"apps/start/src/components/dashboard/account-button.tsx",
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
"apps/web/postcss.config.mjs",
|
|
88
89
|
"apps/web/public/.gitkeep",
|
|
89
90
|
"apps/web/public/.well-known/logo.svg",
|
|
91
|
+
"apps/web/scripts/check-services.ts",
|
|
90
92
|
"apps/web/scripts/start-standalone.mjs",
|
|
91
93
|
"apps/web/src/app/actions/auth.ts",
|
|
92
94
|
"apps/web/src/app/api/health/route.ts",
|
|
@@ -127,6 +129,7 @@
|
|
|
127
129
|
"apps/web/src/variants/proxy.better-auth.ts",
|
|
128
130
|
"apps/web/tsconfig.json",
|
|
129
131
|
"clubedge.template.json",
|
|
132
|
+
"docker-compose.dev.yml",
|
|
130
133
|
"docker-compose.yml",
|
|
131
134
|
"e2e/app.ts",
|
|
132
135
|
"e2e/auth-flow.spec.ts",
|
|
@@ -219,6 +222,7 @@
|
|
|
219
222
|
"playwright.config.ts",
|
|
220
223
|
"pnpm-lock.yaml",
|
|
221
224
|
"pnpm-workspace.yaml",
|
|
225
|
+
"supabase/config.toml",
|
|
222
226
|
"tsconfig.base.json",
|
|
223
227
|
"turbo.json",
|
|
224
228
|
"vitest.config.ts"
|