@clubedge/create-clubedge-app 0.5.0 → 0.7.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 +12 -10
- package/dist/cli/args.js +2 -1
- package/dist/cli/ui.js +1 -0
- package/dist/core/customize.js +12 -3
- package/dist/core/lockfile-peers.js +174 -0
- 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 +28 -10
- package/template/README.md +45 -1
- package/template/SETUP.md +54 -1
- package/template/_gitignore +5 -0
- package/template/apps/start/.env.example +28 -10
- package/template/apps/start/package.json +4 -2
- package/template/apps/start/scripts/check-services.ts +128 -0
- package/template/apps/start/src/server/storage.ts +13 -0
- package/template/apps/web/package.json +5 -3
- package/template/apps/web/scripts/check-services.ts +128 -0
- package/template/apps/web/src/server/storage.ts +13 -0
- package/template/clubedge.template.json +52 -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/local-db/package.json +24 -0
- package/template/packages/local-db/scripts/serve.ts +66 -0
- package/template/packages/local-db/src/server.test.ts +119 -0
- package/template/packages/local-db/src/server.ts +153 -0
- package/template/packages/local-db/tsconfig.json +4 -0
- package/template/packages/storage/package.json +3 -2
- package/template/packages/storage/src/file-system.test.ts +110 -0
- package/template/packages/storage/src/file-system.ts +124 -0
- 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 +37 -9
- package/template/supabase/config.toml +40 -0
- package/template.lock.json +13 -2
|
@@ -2,13 +2,21 @@
|
|
|
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
|
|
11
|
+
# Local PostgreSQL (PGlite) that pnpm dev starts; no Docker needed. # clubedge:only infra=local
|
|
12
|
+
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5433/postgres # clubedge:only infra=local
|
|
7
13
|
|
|
8
14
|
# clubedge:if auth=supabase
|
|
9
15
|
# Supabase Auth (publishable key only; never use a service role key here).
|
|
10
16
|
# All auth runs on the server, so these need no browser prefix.
|
|
11
|
-
SUPABASE_URL=
|
|
17
|
+
SUPABASE_URL= # clubedge:only infra!=supabase
|
|
18
|
+
# Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
|
|
19
|
+
SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
|
|
12
20
|
SUPABASE_PUBLISHABLE_KEY=
|
|
13
21
|
# clubedge:end
|
|
14
22
|
|
|
@@ -21,20 +29,30 @@ BETTER_AUTH_SECRET=
|
|
|
21
29
|
# clubedge:if cache=redis
|
|
22
30
|
# Optional cache and rate limits (Redis protocol; rediss:// enables TLS). Without it, both use
|
|
23
31
|
# process memory. Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
|
|
24
|
-
REDIS_URL=
|
|
32
|
+
REDIS_URL= # clubedge:only infra!=docker
|
|
33
|
+
REDIS_URL=redis://localhost:6379 # clubedge:only infra=docker
|
|
25
34
|
# clubedge:end
|
|
26
35
|
|
|
27
36
|
# clubedge:if storage=s3
|
|
28
37
|
# 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=
|
|
38
|
+
STORAGE_BUCKET= # clubedge:only infra!=docker
|
|
39
|
+
STORAGE_REGION=auto # clubedge:only infra!=docker
|
|
40
|
+
STORAGE_ENDPOINT= # clubedge:only infra!=docker
|
|
41
|
+
STORAGE_ACCESS_KEY_ID= # clubedge:only infra!=docker
|
|
42
|
+
STORAGE_SECRET_ACCESS_KEY= # clubedge:only infra!=docker
|
|
43
|
+
# Local S3-compatible storage from docker-compose.dev.yml; it accepts any credentials. # clubedge:only infra=docker
|
|
44
|
+
STORAGE_BUCKET=app # clubedge:only infra=docker
|
|
45
|
+
STORAGE_REGION=us-east-1 # clubedge:only infra=docker
|
|
46
|
+
STORAGE_ENDPOINT=http://localhost:9090 # clubedge:only infra=docker
|
|
47
|
+
STORAGE_ACCESS_KEY_ID=local # clubedge:only infra=docker
|
|
48
|
+
STORAGE_SECRET_ACCESS_KEY=local-secret # clubedge:only infra=docker
|
|
34
49
|
STORAGE_PUBLIC_URL=
|
|
50
|
+
# Without an S3 service, files are stored in this folder (relative to the app). # clubedge:only infra=local
|
|
51
|
+
STORAGE_DIRECTORY=../../.data/storage # clubedge:only infra=local
|
|
35
52
|
# clubedge:end
|
|
36
53
|
|
|
37
54
|
# clubedge:if storage=supabase
|
|
38
55
|
# Supabase Storage bucket, accessed with the signed-in user's Supabase session
|
|
39
|
-
SUPABASE_STORAGE_BUCKET=
|
|
56
|
+
SUPABASE_STORAGE_BUCKET= # clubedge:only infra!=supabase
|
|
57
|
+
SUPABASE_STORAGE_BUCKET=app # clubedge:only infra=supabase
|
|
40
58
|
# clubedge:end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,128 @@
|
|
|
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
|
+
// clubedge:if storage=s3 && infra=local
|
|
12
|
+
import { createFileSystemStorage } from "@clubedge/storage/file-system";
|
|
13
|
+
// clubedge:end
|
|
14
|
+
|
|
15
|
+
type Check = { name: string; skip?: string; run: () => Promise<string> };
|
|
16
|
+
|
|
17
|
+
/** Services can take a few seconds to accept connections after `pnpm infra:up`. */
|
|
18
|
+
async function withRetry(run: () => Promise<string>, attempts = 15): Promise<string> {
|
|
19
|
+
for (let attempt = 1; ; attempt++) {
|
|
20
|
+
try {
|
|
21
|
+
return await run();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (attempt >= attempts) throw error;
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
|
31
|
+
const env = process.env;
|
|
32
|
+
|
|
33
|
+
const checks: Check[] = [
|
|
34
|
+
{
|
|
35
|
+
name: "PostgreSQL",
|
|
36
|
+
skip: env.DATABASE_URL ? undefined : "DATABASE_URL is not set",
|
|
37
|
+
async run() {
|
|
38
|
+
const db = createDb({ url: env.DATABASE_URL! });
|
|
39
|
+
try {
|
|
40
|
+
const [row] = await db.$client`select current_database() as name`;
|
|
41
|
+
// clubedge:if auth=better-auth
|
|
42
|
+
// Better Auth's tables exist once `pnpm db:migrate` has run.
|
|
43
|
+
await db.$client`select 1 from auth_user limit 1`;
|
|
44
|
+
// clubedge:end
|
|
45
|
+
return `connected to database "${row?.name}"`;
|
|
46
|
+
} finally {
|
|
47
|
+
await db.$client.end();
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
// clubedge:if cache=redis
|
|
52
|
+
{
|
|
53
|
+
name: "Redis",
|
|
54
|
+
skip: env.REDIS_URL ? undefined : "REDIS_URL is not set; the app uses process memory",
|
|
55
|
+
async run() {
|
|
56
|
+
const cache = createRedisCache(env.REDIS_URL!);
|
|
57
|
+
const value = { checkedAt: Date.now() };
|
|
58
|
+
await cache.set("check-services", value, 30);
|
|
59
|
+
const read = await cache.get<typeof value>("check-services");
|
|
60
|
+
if (read?.checkedAt !== value.checkedAt) throw new Error("read back a different value");
|
|
61
|
+
return "wrote and read a cached value";
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
// clubedge:end
|
|
65
|
+
// clubedge:if storage=s3 && infra=local
|
|
66
|
+
{
|
|
67
|
+
name: "Local folder storage",
|
|
68
|
+
skip: env.STORAGE_DIRECTORY ? undefined : "STORAGE_DIRECTORY is not set",
|
|
69
|
+
async run() {
|
|
70
|
+
const storage = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY! });
|
|
71
|
+
const key = `check-services/${Date.now()}.txt`;
|
|
72
|
+
await storage.upload({
|
|
73
|
+
key,
|
|
74
|
+
body: new TextEncoder().encode("ok"),
|
|
75
|
+
contentType: "text/plain",
|
|
76
|
+
});
|
|
77
|
+
const text = await new Response(await storage.download(key)).text();
|
|
78
|
+
await storage.delete(key);
|
|
79
|
+
if (text !== "ok") throw new Error(`read back "${text}"`);
|
|
80
|
+
return `wrote, read back, and deleted a file in ${env.STORAGE_DIRECTORY}`;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
// clubedge:end
|
|
84
|
+
// clubedge:if storage=s3
|
|
85
|
+
{
|
|
86
|
+
name: "File storage",
|
|
87
|
+
skip: env.STORAGE_BUCKET ? undefined : "STORAGE_BUCKET is not set",
|
|
88
|
+
async run() {
|
|
89
|
+
const storage = createS3Storage({
|
|
90
|
+
bucket: env.STORAGE_BUCKET,
|
|
91
|
+
region: env.STORAGE_REGION || "auto",
|
|
92
|
+
endpoint: env.STORAGE_ENDPOINT || undefined,
|
|
93
|
+
accessKeyId: env.STORAGE_ACCESS_KEY_ID || undefined,
|
|
94
|
+
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY || undefined,
|
|
95
|
+
});
|
|
96
|
+
const key = `check-services/${Date.now()}.txt`;
|
|
97
|
+
await storage.upload({
|
|
98
|
+
key,
|
|
99
|
+
body: new TextEncoder().encode("ok"),
|
|
100
|
+
contentType: "text/plain",
|
|
101
|
+
});
|
|
102
|
+
const text = await new Response(await storage.download(key)).text();
|
|
103
|
+
await storage.delete(key);
|
|
104
|
+
if (text !== "ok") throw new Error(`read back "${text}"`);
|
|
105
|
+
return `uploaded, read back, and deleted an object in "${env.STORAGE_BUCKET}"`;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
// clubedge:end
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
let failed = false;
|
|
112
|
+
for (const check of checks) {
|
|
113
|
+
if (check.skip) {
|
|
114
|
+
console.log(`- ${check.name}: skipped (${check.skip})`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
console.log(`✓ ${check.name}: ${await withRetry(check.run)}`);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
failed = true;
|
|
121
|
+
console.log(`✗ ${check.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Redis clients keep the process alive, so exit explicitly.
|
|
125
|
+
process.exit(failed ? 1 : 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
void main();
|
|
@@ -2,6 +2,9 @@ import "@tanstack/react-start/server-only";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { StorageProvider } from "@clubedge/storage";
|
|
4
4
|
import { createS3Storage } from "@clubedge/storage-s3";
|
|
5
|
+
// clubedge:if infra=local
|
|
6
|
+
import { createFileSystemStorage } from "@clubedge/storage/file-system";
|
|
7
|
+
// clubedge:end
|
|
5
8
|
import { optional, parseEnv } from "@/env/server";
|
|
6
9
|
|
|
7
10
|
export type { SignedUrlOptions, StorageProvider, UploadInput } from "@clubedge/storage";
|
|
@@ -14,6 +17,9 @@ const storageEnvSchema = z.object({
|
|
|
14
17
|
STORAGE_ACCESS_KEY_ID: optional(z.string()),
|
|
15
18
|
STORAGE_SECRET_ACCESS_KEY: optional(z.string()),
|
|
16
19
|
STORAGE_PUBLIC_URL: optional(z.url()),
|
|
20
|
+
// clubedge:if infra=local
|
|
21
|
+
STORAGE_DIRECTORY: optional(z.string()),
|
|
22
|
+
// clubedge:end
|
|
17
23
|
});
|
|
18
24
|
|
|
19
25
|
let provider: StorageProvider | undefined;
|
|
@@ -21,6 +27,13 @@ let provider: StorageProvider | undefined;
|
|
|
21
27
|
function resolveProvider(): StorageProvider {
|
|
22
28
|
if (provider) return provider;
|
|
23
29
|
const env = parseEnv(storageEnvSchema, "Storage");
|
|
30
|
+
// clubedge:if infra=local
|
|
31
|
+
// In development without an object storage service, a local folder stands in for S3.
|
|
32
|
+
if (env.STORAGE_DIRECTORY) {
|
|
33
|
+
provider = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY });
|
|
34
|
+
return provider;
|
|
35
|
+
}
|
|
36
|
+
// clubedge:end
|
|
24
37
|
provider = createS3Storage({
|
|
25
38
|
bucket: env.STORAGE_BUCKET || undefined,
|
|
26
39
|
region: env.STORAGE_REGION,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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,128 @@
|
|
|
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
|
+
// clubedge:if storage=s3 && infra=local
|
|
12
|
+
import { createFileSystemStorage } from "@clubedge/storage/file-system";
|
|
13
|
+
// clubedge:end
|
|
14
|
+
|
|
15
|
+
type Check = { name: string; skip?: string; run: () => Promise<string> };
|
|
16
|
+
|
|
17
|
+
/** Services can take a few seconds to accept connections after `pnpm infra:up`. */
|
|
18
|
+
async function withRetry(run: () => Promise<string>, attempts = 15): Promise<string> {
|
|
19
|
+
for (let attempt = 1; ; attempt++) {
|
|
20
|
+
try {
|
|
21
|
+
return await run();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (attempt >= attempts) throw error;
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
|
31
|
+
const env = process.env;
|
|
32
|
+
|
|
33
|
+
const checks: Check[] = [
|
|
34
|
+
{
|
|
35
|
+
name: "PostgreSQL",
|
|
36
|
+
skip: env.DATABASE_URL ? undefined : "DATABASE_URL is not set",
|
|
37
|
+
async run() {
|
|
38
|
+
const db = createDb({ url: env.DATABASE_URL! });
|
|
39
|
+
try {
|
|
40
|
+
const [row] = await db.$client`select current_database() as name`;
|
|
41
|
+
// clubedge:if auth=better-auth
|
|
42
|
+
// Better Auth's tables exist once `pnpm db:migrate` has run.
|
|
43
|
+
await db.$client`select 1 from auth_user limit 1`;
|
|
44
|
+
// clubedge:end
|
|
45
|
+
return `connected to database "${row?.name}"`;
|
|
46
|
+
} finally {
|
|
47
|
+
await db.$client.end();
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
// clubedge:if cache=redis
|
|
52
|
+
{
|
|
53
|
+
name: "Redis",
|
|
54
|
+
skip: env.REDIS_URL ? undefined : "REDIS_URL is not set; the app uses process memory",
|
|
55
|
+
async run() {
|
|
56
|
+
const cache = createRedisCache(env.REDIS_URL!);
|
|
57
|
+
const value = { checkedAt: Date.now() };
|
|
58
|
+
await cache.set("check-services", value, 30);
|
|
59
|
+
const read = await cache.get<typeof value>("check-services");
|
|
60
|
+
if (read?.checkedAt !== value.checkedAt) throw new Error("read back a different value");
|
|
61
|
+
return "wrote and read a cached value";
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
// clubedge:end
|
|
65
|
+
// clubedge:if storage=s3 && infra=local
|
|
66
|
+
{
|
|
67
|
+
name: "Local folder storage",
|
|
68
|
+
skip: env.STORAGE_DIRECTORY ? undefined : "STORAGE_DIRECTORY is not set",
|
|
69
|
+
async run() {
|
|
70
|
+
const storage = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY! });
|
|
71
|
+
const key = `check-services/${Date.now()}.txt`;
|
|
72
|
+
await storage.upload({
|
|
73
|
+
key,
|
|
74
|
+
body: new TextEncoder().encode("ok"),
|
|
75
|
+
contentType: "text/plain",
|
|
76
|
+
});
|
|
77
|
+
const text = await new Response(await storage.download(key)).text();
|
|
78
|
+
await storage.delete(key);
|
|
79
|
+
if (text !== "ok") throw new Error(`read back "${text}"`);
|
|
80
|
+
return `wrote, read back, and deleted a file in ${env.STORAGE_DIRECTORY}`;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
// clubedge:end
|
|
84
|
+
// clubedge:if storage=s3
|
|
85
|
+
{
|
|
86
|
+
name: "File storage",
|
|
87
|
+
skip: env.STORAGE_BUCKET ? undefined : "STORAGE_BUCKET is not set",
|
|
88
|
+
async run() {
|
|
89
|
+
const storage = createS3Storage({
|
|
90
|
+
bucket: env.STORAGE_BUCKET,
|
|
91
|
+
region: env.STORAGE_REGION || "auto",
|
|
92
|
+
endpoint: env.STORAGE_ENDPOINT || undefined,
|
|
93
|
+
accessKeyId: env.STORAGE_ACCESS_KEY_ID || undefined,
|
|
94
|
+
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY || undefined,
|
|
95
|
+
});
|
|
96
|
+
const key = `check-services/${Date.now()}.txt`;
|
|
97
|
+
await storage.upload({
|
|
98
|
+
key,
|
|
99
|
+
body: new TextEncoder().encode("ok"),
|
|
100
|
+
contentType: "text/plain",
|
|
101
|
+
});
|
|
102
|
+
const text = await new Response(await storage.download(key)).text();
|
|
103
|
+
await storage.delete(key);
|
|
104
|
+
if (text !== "ok") throw new Error(`read back "${text}"`);
|
|
105
|
+
return `uploaded, read back, and deleted an object in "${env.STORAGE_BUCKET}"`;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
// clubedge:end
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
let failed = false;
|
|
112
|
+
for (const check of checks) {
|
|
113
|
+
if (check.skip) {
|
|
114
|
+
console.log(`- ${check.name}: skipped (${check.skip})`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
console.log(`✓ ${check.name}: ${await withRetry(check.run)}`);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
failed = true;
|
|
121
|
+
console.log(`✗ ${check.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Redis clients keep the process alive, so exit explicitly.
|
|
125
|
+
process.exit(failed ? 1 : 0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
void main();
|
|
@@ -2,6 +2,9 @@ import "server-only";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { StorageProvider } from "@clubedge/storage";
|
|
4
4
|
import { createS3Storage } from "@clubedge/storage-s3";
|
|
5
|
+
// clubedge:if infra=local
|
|
6
|
+
import { createFileSystemStorage } from "@clubedge/storage/file-system";
|
|
7
|
+
// clubedge:end
|
|
5
8
|
import { optional, parseEnv } from "@/env/server";
|
|
6
9
|
|
|
7
10
|
export type { SignedUrlOptions, StorageProvider, UploadInput } from "@clubedge/storage";
|
|
@@ -14,6 +17,9 @@ const storageEnvSchema = z.object({
|
|
|
14
17
|
STORAGE_ACCESS_KEY_ID: optional(z.string()),
|
|
15
18
|
STORAGE_SECRET_ACCESS_KEY: optional(z.string()),
|
|
16
19
|
STORAGE_PUBLIC_URL: optional(z.url()),
|
|
20
|
+
// clubedge:if infra=local
|
|
21
|
+
STORAGE_DIRECTORY: optional(z.string()),
|
|
22
|
+
// clubedge:end
|
|
17
23
|
});
|
|
18
24
|
|
|
19
25
|
let provider: StorageProvider | undefined;
|
|
@@ -21,6 +27,13 @@ let provider: StorageProvider | undefined;
|
|
|
21
27
|
function resolveProvider(): StorageProvider {
|
|
22
28
|
if (provider) return provider;
|
|
23
29
|
const env = parseEnv(storageEnvSchema, "Storage");
|
|
30
|
+
// clubedge:if infra=local
|
|
31
|
+
// In development without an object storage service, a local folder stands in for S3.
|
|
32
|
+
if (env.STORAGE_DIRECTORY) {
|
|
33
|
+
provider = createFileSystemStorage({ directory: env.STORAGE_DIRECTORY });
|
|
34
|
+
return provider;
|
|
35
|
+
}
|
|
36
|
+
// clubedge:end
|
|
24
37
|
provider = createS3Storage({
|
|
25
38
|
bucket: env.STORAGE_BUCKET || undefined,
|
|
26
39
|
region: env.STORAGE_REGION,
|
|
@@ -160,6 +160,51 @@
|
|
|
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
|
+
"dev": "turbo run dev --filter=@clubedge/web",
|
|
173
|
+
"infra:up": "docker compose -f docker-compose.dev.yml up -d --wait",
|
|
174
|
+
"infra:down": "docker compose -f docker-compose.dev.yml down",
|
|
175
|
+
"infra:reset": "docker compose -f docker-compose.dev.yml down --volumes",
|
|
176
|
+
"infra:logs": "docker compose -f docker-compose.dev.yml logs --follow"
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"supabase": {
|
|
180
|
+
"name": "Supabase CLI",
|
|
181
|
+
"files": ["supabase"],
|
|
182
|
+
"scripts": {
|
|
183
|
+
"dev": "turbo run dev --filter=@clubedge/web",
|
|
184
|
+
"infra:up": "npx --yes supabase@2 start",
|
|
185
|
+
"infra:down": "npx --yes supabase@2 stop",
|
|
186
|
+
"infra:reset": "npx --yes supabase@2 stop --no-backup",
|
|
187
|
+
"infra:status": "npx --yes supabase@2 status"
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
"local": {
|
|
191
|
+
"name": "No Docker (PGlite and a local folder)",
|
|
192
|
+
"packages": {
|
|
193
|
+
"@clubedge/local-db": "packages/local-db"
|
|
194
|
+
},
|
|
195
|
+
"scripts": {
|
|
196
|
+
"dev": "turbo run dev --filter=@clubedge/web --filter=@clubedge/local-db",
|
|
197
|
+
"infra:up": "pnpm --filter @clubedge/local-db dev",
|
|
198
|
+
"infra:reset": "node -e \"require('node:fs').rmSync('.data', { recursive: true, force: true })\""
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
"none": {
|
|
202
|
+
"name": "None (bring your own services)",
|
|
203
|
+
"scripts": {
|
|
204
|
+
"dev": "turbo run dev --filter=@clubedge/web"
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
163
208
|
}
|
|
164
209
|
},
|
|
165
210
|
"conditional": [
|
|
@@ -169,12 +214,18 @@
|
|
|
169
214
|
"README.md",
|
|
170
215
|
"SETUP.md",
|
|
171
216
|
"apps/start/.env.example",
|
|
217
|
+
"apps/start/scripts/check-services.ts",
|
|
172
218
|
"apps/start/src/env/auth.ts",
|
|
173
219
|
"apps/start/src/routes/dashboard.tsx",
|
|
174
220
|
"apps/start/src/routes/index.tsx",
|
|
221
|
+
"apps/start/src/server/storage.ts",
|
|
222
|
+
"apps/web/scripts/check-services.ts",
|
|
175
223
|
"apps/web/src/app/dashboard/page.tsx",
|
|
176
224
|
"apps/web/src/app/page.tsx",
|
|
177
|
-
"apps/web/src/env/auth.ts"
|
|
225
|
+
"apps/web/src/env/auth.ts",
|
|
226
|
+
"apps/web/src/server/storage.ts",
|
|
227
|
+
"docker-compose.dev.yml",
|
|
228
|
+
"supabase/config.toml"
|
|
178
229
|
],
|
|
179
230
|
"exclude": [
|
|
180
231
|
".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.7.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",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clubedge/local-db",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "PostgreSQL for development without Docker: PGlite served over the PostgreSQL protocol.",
|
|
5
|
+
"private": true,
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/server.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx scripts/serve.ts",
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@electric-sql/pglite": "0.5.8"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"postgres": "^3.4.7",
|
|
21
|
+
"tsx": "^4.20.5",
|
|
22
|
+
"typescript": "^5.9.2"
|
|
23
|
+
}
|
|
24
|
+
}
|