@clubedge/create-clubedge-app 0.33.0 → 0.34.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +15 -11
  3. package/package.json +3 -3
  4. package/template/.github/workflows/ci.yml +3 -0
  5. package/template/SETUP.md +4 -1
  6. package/template/_gitignore +7 -0
  7. package/template/apps/api/package.json +1 -1
  8. package/template/apps/api-fastapi/.env.example +8 -0
  9. package/template/apps/api-fastapi/.python-version +1 -0
  10. package/template/apps/api-fastapi/Dockerfile +24 -0
  11. package/template/apps/api-fastapi/clubedge.stack.json +39 -0
  12. package/template/apps/api-fastapi/package.json +20 -0
  13. package/template/apps/api-fastapi/pyproject.toml +40 -0
  14. package/template/apps/api-fastapi/src/clubedge_api/__init__.py +1 -0
  15. package/template/apps/api-fastapi/src/clubedge_api/__main__.py +16 -0
  16. package/template/apps/api-fastapi/src/clubedge_api/app.py +68 -0
  17. package/template/apps/api-fastapi/src/clubedge_api/auth.py +13 -0
  18. package/template/apps/api-fastapi/src/clubedge_api/check_env.py +12 -0
  19. package/template/apps/api-fastapi/src/clubedge_api/check_services.py +18 -0
  20. package/template/apps/api-fastapi/src/clubedge_api/db.py +19 -0
  21. package/template/apps/api-fastapi/src/clubedge_api/main.py +6 -0
  22. package/template/apps/api-fastapi/src/clubedge_api/server.py +31 -0
  23. package/template/apps/api-fastapi/src/clubedge_api/settings.py +35 -0
  24. package/template/apps/api-fastapi/src/config/site.json +11 -0
  25. package/template/apps/api-fastapi/tests/test_app.py +46 -0
  26. package/template/apps/start/package.json +1 -1
  27. package/template/apps/vite/package.json +1 -1
  28. package/template/apps/web/package.json +1 -1
  29. package/template/package.json +3 -3
  30. package/template/packages/auth/package.json +1 -1
  31. package/template/packages/auth-appwrite/package.json +1 -1
  32. package/template/packages/auth-better-auth/package.json +1 -1
  33. package/template/packages/auth-firebase/package.json +1 -1
  34. package/template/packages/auth-supabase/package.json +1 -1
  35. package/template/packages/cache/package.json +1 -1
  36. package/template/packages/cache-redis/package.json +1 -1
  37. package/template/packages/contract/package.json +4 -1
  38. package/template/packages/contract/scripts/check-api.mjs +78 -0
  39. package/template/packages/core/package.json +1 -1
  40. package/template/packages/db/package.json +1 -1
  41. package/template/packages/email/package.json +1 -1
  42. package/template/packages/email-resend/package.json +1 -1
  43. package/template/packages/email-smtp/package.json +1 -1
  44. package/template/packages/jobs/package.json +1 -1
  45. package/template/packages/jobs-bullmq/package.json +1 -1
  46. package/template/packages/jobs-inngest/package.json +1 -1
  47. package/template/packages/jobs-pg-boss/package.json +1 -1
  48. package/template/packages/local-db/package.json +1 -1
  49. package/template/packages/observability/package.json +1 -1
  50. package/template/packages/observability-otel/package.json +1 -1
  51. package/template/packages/observability-sentry/package.json +1 -1
  52. package/template/packages/payments/package.json +1 -1
  53. package/template/packages/payments-stripe/package.json +1 -1
  54. package/template/packages/storage/package.json +1 -1
  55. package/template/packages/storage-s3/package.json +1 -1
  56. package/template/packages/storage-supabase/package.json +1 -1
  57. package/template/packages/ui/package.json +1 -1
  58. package/template/pnpm-lock.yaml +6 -0
  59. package/template/turbo.json +6 -0
  60. package/template.lock.json +21 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.34.0
6
+
7
+ - **A FastAPI API**: default to Starter `v0.23.0` (#101). `--framework vite --api fastapi` creates the React + Vite frontend with an API in Python, managed with [uv](https://docs.astral.sh/uv/):
8
+ - The Starter added it as one folder that describes itself, and this CLI generates it without a code change: the API lands in `apps/api` as `@clubedge/api`, and the CLI runs `uv sync` after `pnpm install`.
9
+ - Its `package.json` scripts call uv, ruff, mypy, and pytest, so `pnpm dev`, `pnpm lint`, `pnpm typecheck`, and `pnpm test` run it like the other apps. `pnpm lint` and `pnpm test` now also run each app's own scripts, so apps in other languages are always checked.
10
+ - It passes the same contract suite as the Hono API, against the live server, through `clubedge-contract-check`.
11
+ - A first version: the app modules are `none` (cache: memory, deploy: Docker). Install uv first.
12
+ - CI generates the pair from the packed CLI. The smoke check waits for a separate API that starts after its frontend (#100).
13
+
5
14
  ## 0.33.0
6
15
 
7
16
  - **A frontend and a separate API**: default to Starter `v0.22.0` (#98). `--framework vite --api hono` creates a React + Vite frontend in `apps/web` and a Hono API in `apps/api`, joined by one HTTP contract:
package/README.md CHANGED
@@ -78,6 +78,9 @@ pnpm dlx @clubedge/create-clubedge-app my-app --framework vite --api hono
78
78
  | ------------------ | ----------------------------------------------------- | ----------- |
79
79
  | `--framework vite` | React + Vite single-page app (TanStack Router) | `apps/web` |
80
80
  | `--api hono` | Hono API on Node, reusing the Starter's packages | `apps/api` |
81
+ | `--api fastapi` | FastAPI API in Python, managed with [uv](https://docs.astral.sh/uv/) | `apps/api` |
82
+
83
+ A Python API needs uv; the CLI installs its dependencies with it after `pnpm install`, and its `package.json` scripts call uv, so `pnpm dev`, `pnpm lint`, and `pnpm test` run it like the other apps.
81
84
 
82
85
  They meet at one HTTP contract, `packages/contract/openapi.yaml`: the frontend calls the API only through the client generated from it, and every API passes the same conformance suite. `pnpm dev` starts both, and the frontend forwards `/api` to the API. A full-stack framework (`next`, `tanstack-start`) has its own server, so it takes no `--api`.
83
86
 
@@ -120,7 +123,7 @@ Usage:
120
123
 
121
124
  Options:
122
125
  --framework <id> App framework, such as next or tanstack-start (or vite, a frontend)
123
- --api <id> A separate API for a frontend, such as hono
126
+ --api <id> A separate API for a frontend, such as hono or fastapi
124
127
  --<module> <option> A module option, such as --auth none; the Starter lists its modules
125
128
  --ref <ref> Download this Starter tag, branch, or commit from GitHub instead
126
129
  (upgrade: the Starter to upgrade to)
@@ -147,7 +150,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
147
150
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
148
151
 
149
152
  # Reproduce a specific Starter release or commit
150
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.22.0
153
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.23.0
151
154
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
152
155
 
153
156
  # Test local Starter changes before releasing them
@@ -166,7 +169,7 @@ pnpm clubedge add email resend payments stripe # several modules as
166
169
  pnpm clubedge remove storage payments
167
170
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
168
171
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
169
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.22.0 # or a specific tag, branch, or commit
172
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.23.0 # or a specific tag, branch, or commit
170
173
  ```
171
174
 
172
175
  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.
@@ -277,27 +280,27 @@ Projects created with `--infra neon` also have `.github/workflows/preview.yml`,
277
280
  Each CLI release is tested against one specific Starter release:
278
281
 
279
282
  ```text
280
- create-clubedge-app v0.33.0
283
+ create-clubedge-app v0.34.0
281
284
  |
282
285
  v
283
- Clubedge Starter v0.22.0
284
- commit d0f3f9cdd4d568504cec4cf4681c7faf97842405
286
+ Clubedge Starter v0.23.0
287
+ commit 0183d091b7bffac92cd0073f12b5329e96fc183f
285
288
  ```
286
289
 
287
290
  The current relationship is:
288
291
 
289
292
  | CLI release | Default Starter release | Meaning |
290
293
  | --- | --- | --- |
291
- | `create-clubedge-app@0.33.0` | `clubedge-starter@v0.22.0` | The CLI's required, tested default |
294
+ | `create-clubedge-app@0.34.0` | `clubedge-starter@v0.23.0` | The CLI's required, tested default |
292
295
 
293
296
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
294
297
 
295
- The `v0.22.0` tag is expected to resolve to commit
296
- `d0f3f9cdd4d568504cec4cf4681c7faf97842405`. The CLI verifies this before
298
+ The `v0.23.0` tag is expected to resolve to commit
299
+ `0183d091b7bffac92cd0073f12b5329e96fc183f`. The CLI verifies this before
297
300
  scaffolding with the default reference and fails rather than silently using a
298
301
  moved tag.
299
302
 
300
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.22.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.
303
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.23.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.
301
304
 
302
305
  Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
303
306
 
@@ -352,7 +355,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
352
355
  1. Make and validate changes in `clubedge-starter`.
353
356
  2. Run lint, typecheck, unit tests, browser tests, and production build.
354
357
  3. Update the Starter package versions.
355
- 4. Commit `main`, create a release tag such as `v0.22.0`, and publish the Starter release.
358
+ 4. Commit `main`, create a release tag such as `v0.23.0`, and publish the Starter release.
356
359
 
357
360
  ### CLI maintainers
358
361
 
@@ -396,6 +399,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
396
399
  | 0.31.0 | v0.20.0 | Pluggable background jobs: `--jobs bullmq` and `--jobs inngest` next to pg-boss, and a conformance suite every provider passes |
397
400
  | 0.32.0 | v0.21.0 | Pluggable authentication: `--auth firebase` and `--auth appwrite` next to Supabase and Better Auth, and a conformance suite every provider passes |
398
401
  | 0.33.0 | v0.22.0 | Starter v0.22.0: stack packs (template schema 4), and a React + Vite frontend with a separate Hono API (`--framework vite --api hono`) |
402
+ | 0.34.0 | v0.23.0 | Starter v0.23.0: a FastAPI API in Python (`--framework vite --api fastapi`), generated with no CLI change |
399
403
  | next | | Show the Starter's version of a conflicted file next to yours |
400
404
 
401
405
  ## Community
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.33.0",
3
+ "version": "0.34.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.22.0",
26
- "starterCommit": "d0f3f9cdd4d568504cec4cf4681c7faf97842405"
25
+ "starterRef": "v0.23.0",
26
+ "starterCommit": "0183d091b7bffac92cd0073f12b5329e96fc183f"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -31,6 +31,9 @@ jobs:
31
31
  with:
32
32
  node-version: 22
33
33
  cache: pnpm
34
+ # Apps in other languages run their lint, typecheck, and tests with their own tools.
35
+ - uses: astral-sh/setup-uv@v10.3.0
36
+ if: ${{ hashFiles('apps/*/pyproject.toml') != '' }}
34
37
  - run: pnpm install --frozen-lockfile
35
38
  - run: pnpm format:check
36
39
  - run: pnpm lint
package/template/SETUP.md CHANGED
@@ -612,7 +612,10 @@ To change the API, edit `openapi.yaml` first, run `pnpm --filter @clubedge/contr
612
612
  API_URL=http://127.0.0.1:3001 pnpm exec vitest run packages/contract/src/remote.test.ts
613
613
  ```
614
614
 
615
- In production, run each app's image: the root `Dockerfile` (the frontend, served by Caddy, with `API_URL` set to the API's address) and `apps/api/Dockerfile`. `docker compose up --build` runs both. Run `node migrate.mjs` in the API's image before a new version starts.
615
+ In production, run each app's image: the root `Dockerfile` (the frontend, served by Caddy, with `API_URL` set to the API's address) and `apps/api/Dockerfile`. `docker compose up --build` runs both.
616
+
617
+ Run `node migrate.mjs` in the API's image before a new version starts. <!-- clubedge:only api=hono -->
618
+ The API is in Python and managed with [uv](https://docs.astral.sh/uv/): its `package.json` scripts call uv, ruff, mypy, and pytest, so `pnpm dev`, `pnpm lint`, `pnpm typecheck`, and `pnpm test` run it like the other apps. The database schema and migrations stay in `packages/db`: run `pnpm db:migrate` against the production database before a new version starts. <!-- clubedge:only api=fastapi -->
616
619
 
617
620
  <!-- clubedge:end -->
618
621
 
@@ -25,3 +25,10 @@ supabase/.branches/
25
25
  # Build output of the Vercel and Netlify presets
26
26
  .vercel/
27
27
  .netlify/
28
+
29
+ # Python apps
30
+ .venv/
31
+ __pycache__/
32
+ .mypy_cache/
33
+ .pytest_cache/
34
+ .ruff_cache/
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/api",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,8 @@
1
+ # The API. The frontend's dev server proxies /api here (its API_URL).
2
+ HOST=127.0.0.1
3
+ PORT=3001
4
+
5
+ # PostgreSQL. `pnpm infra:up` starts one on this URL.
6
+ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres
7
+ # Optional: the direct (unpooled) URL, for migrations behind a pooler.
8
+ DATABASE_URL_DIRECT=
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,24 @@
1
+ # Production image for the API. Build it from the repository root.
2
+ # Generated projects keep the API in apps/api; the starter repository builds it with
3
+ # docker build -f apps/api-fastapi/Dockerfile --build-arg APP_DIR=apps/api-fastapi .
4
+ # The database schema and its migrations live in packages/db: run `pnpm db:migrate` against
5
+ # the production database before a new version starts.
6
+ ARG APP_DIR=apps/api
7
+
8
+ FROM python:3.12-slim
9
+ COPY --from=ghcr.io/astral-sh/uv:0.13 /uv /uvx /bin/
10
+ ARG APP_DIR
11
+ WORKDIR /app
12
+ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
13
+
14
+ # Dependencies first, so their layer stays cached until pyproject.toml (or uv.lock) changes.
15
+ COPY ${APP_DIR}/pyproject.toml ${APP_DIR}/uv.lock* ./
16
+ RUN uv sync --no-dev --no-install-project
17
+ COPY ${APP_DIR}/src ./src
18
+ RUN uv sync --no-dev
19
+
20
+ RUN useradd --system --uid 1001 app
21
+ USER app
22
+ ENV HOST=0.0.0.0 PORT=3001 PATH="/app/.venv/bin:$PATH"
23
+ EXPOSE 3001
24
+ CMD ["python", "-m", "clubedge_api"]
@@ -0,0 +1,39 @@
1
+ {
2
+ "id": "fastapi",
3
+ "name": "FastAPI (Python)",
4
+ "role": "api",
5
+ "envExample": ".env.example",
6
+ "dockerfile": "Dockerfile",
7
+ "toolchain": {
8
+ "name": "uv",
9
+ "check": ["uv", "--version"],
10
+ "install": ["uv", "sync"],
11
+ "url": "https://docs.astral.sh/uv/getting-started/installation/"
12
+ },
13
+ "modules": {
14
+ "auth": {
15
+ "none": {}
16
+ },
17
+ "storage": {
18
+ "none": {}
19
+ },
20
+ "cache": {
21
+ "memory": {}
22
+ },
23
+ "email": {
24
+ "none": {}
25
+ },
26
+ "payments": {
27
+ "none": {}
28
+ },
29
+ "jobs": {
30
+ "none": {}
31
+ },
32
+ "observability": {
33
+ "none": {}
34
+ },
35
+ "deploy": {
36
+ "docker": {}
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@clubedge/api-fastapi",
3
+ "version": "0.23.0",
4
+ "private": true,
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "uv run python -m clubedge_api --reload",
9
+ "build": "uv run python -m compileall -q src",
10
+ "start": "uv run python -m clubedge_api",
11
+ "lint": "uv run ruff check . && uv run ruff format --check .",
12
+ "typecheck": "uv run mypy",
13
+ "test": "uv run pytest && clubedge-contract-check -- uv run python -m clubedge_api",
14
+ "check:services": "uv run python -m clubedge_api.check_services",
15
+ "check:env": "uv run python -m clubedge_api.check_env"
16
+ },
17
+ "dependencies": {
18
+ "@clubedge/contract": "workspace:*"
19
+ }
20
+ }
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "clubedge-api"
3
+ version = "0.23.0"
4
+ description = "The project's API, implementing packages/contract/openapi.yaml."
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "fastapi==0.146.0",
8
+ "psycopg[binary]==3.3.6",
9
+ "uvicorn==0.54.0",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "httpx==0.28.1",
15
+ "mypy==2.4.0",
16
+ "pytest==9.1.1",
17
+ "ruff==0.17.1",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/clubedge_api"]
26
+
27
+ [tool.ruff]
28
+ line-length = 100
29
+ target-version = "py312"
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E", "F", "I", "UP", "B", "SIM"]
33
+
34
+ [tool.mypy]
35
+ strict = true
36
+ files = ["src", "tests"]
37
+ mypy_path = "src"
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """The project's API: a FastAPI app implementing packages/contract/openapi.yaml."""
@@ -0,0 +1,16 @@
1
+ """Runs the API: `python -m clubedge_api`, with `--reload` in development."""
2
+
3
+ import sys
4
+
5
+ import uvicorn
6
+
7
+ from clubedge_api.settings import get_settings
8
+
9
+ if __name__ == "__main__":
10
+ settings = get_settings()
11
+ uvicorn.run(
12
+ "clubedge_api.main:app",
13
+ host=settings.host,
14
+ port=settings.port,
15
+ reload="--reload" in sys.argv,
16
+ )
@@ -0,0 +1,68 @@
1
+ """The routes, implementing packages/contract/openapi.yaml.
2
+
3
+ They receive what they need from the composition root (server.py), so tests can pass stand-ins.
4
+ """
5
+
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from typing import Any, TypedDict
10
+
11
+ from fastapi import FastAPI, Request
12
+ from fastapi.responses import JSONResponse
13
+ from starlette.exceptions import HTTPException
14
+
15
+
16
+ class User(TypedDict):
17
+ id: str
18
+ email: str
19
+ displayName: str | None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ApiDependencies:
24
+ service_id: str
25
+ is_ready: Callable[[], Awaitable[bool]]
26
+ get_current_user: Callable[[Request], Awaitable[User | None]]
27
+
28
+
29
+ def error(code: str, message: str, status: int) -> JSONResponse:
30
+ return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
31
+
32
+
33
+ def create_app(dependencies: ApiDependencies) -> FastAPI:
34
+ # The contract is openapi.yaml, so FastAPI's generated schema and docs are not served.
35
+ app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
36
+
37
+ @app.get("/api/health")
38
+ async def health() -> dict[str, Any]:
39
+ return {
40
+ "status": "ok",
41
+ "service": dependencies.service_id,
42
+ "timestamp": datetime.now(UTC).isoformat(),
43
+ }
44
+
45
+ @app.get("/api/ready")
46
+ async def ready() -> JSONResponse:
47
+ is_ready = await dependencies.is_ready()
48
+ return JSONResponse(
49
+ {"status": "ok" if is_ready else "unavailable"},
50
+ status_code=200 if is_ready else 503,
51
+ headers={"Cache-Control": "no-store"},
52
+ )
53
+
54
+ @app.get("/api/session")
55
+ async def session(request: Request) -> dict[str, Any]:
56
+ return {"user": await dependencies.get_current_user(request)}
57
+
58
+ @app.exception_handler(HTTPException)
59
+ async def http_error(request: Request, exc: HTTPException) -> JSONResponse:
60
+ if exc.status_code == 404:
61
+ return error("not_found", "No such API endpoint.", 404)
62
+ return error("http_error", str(exc.detail), exc.status_code)
63
+
64
+ @app.exception_handler(Exception)
65
+ async def internal_error(request: Request, exc: Exception) -> JSONResponse:
66
+ return error("internal_error", "Something went wrong.", 500)
67
+
68
+ return app
@@ -0,0 +1,13 @@
1
+ """The signed-in user for a request.
2
+
3
+ This API has no authentication module yet, so nobody is signed in; an auth provider replaces
4
+ this module the way the full-stack apps' variants do.
5
+ """
6
+
7
+ from fastapi import Request
8
+
9
+ from clubedge_api.app import User
10
+
11
+
12
+ async def get_current_user(request: Request) -> User | None:
13
+ return None
@@ -0,0 +1,12 @@
1
+ """Validates the API's production environment before a deploy: `pnpm check:env`."""
2
+
3
+ import sys
4
+
5
+ from clubedge_api.settings import get_settings
6
+
7
+ if __name__ == "__main__":
8
+ settings = get_settings()
9
+ if not settings.database_url:
10
+ print("DATABASE_URL is required.", file=sys.stderr)
11
+ sys.exit(1)
12
+ print("API environment is valid.")
@@ -0,0 +1,18 @@
1
+ """Checks that the services the API needs answer: `pnpm check:services`."""
2
+
3
+ import asyncio
4
+ import sys
5
+
6
+ from clubedge_api.db import is_database_ready
7
+ from clubedge_api.settings import get_settings
8
+
9
+ if __name__ == "__main__":
10
+ if asyncio.run(is_database_ready(get_settings().database_url)):
11
+ print("Database: reachable.")
12
+ sys.exit(0)
13
+ print(
14
+ "Database: not reachable. Check DATABASE_URL in apps/api/.env.local, "
15
+ "then start it (pnpm infra:up).",
16
+ file=sys.stderr,
17
+ )
18
+ sys.exit(1)
@@ -0,0 +1,19 @@
1
+ """PostgreSQL readiness. The schema and its migrations live in packages/db (pnpm db:migrate)."""
2
+
3
+ import asyncio
4
+
5
+ import psycopg
6
+
7
+
8
+ async def is_database_ready(url: str | None, timeout: float = 2.0) -> bool:
9
+ """Whether the database answers `select 1` in time; no URL is not ready either."""
10
+ if not url:
11
+ return False
12
+ try:
13
+ async with asyncio.timeout(timeout):
14
+ connection = await psycopg.AsyncConnection.connect(url, connect_timeout=2)
15
+ async with connection:
16
+ await connection.execute("select 1")
17
+ return True
18
+ except (OSError, TimeoutError, psycopg.Error):
19
+ return False
@@ -0,0 +1,6 @@
1
+ """The ASGI app uvicorn serves: clubedge_api.main:app."""
2
+
3
+ from clubedge_api.app import create_app
4
+ from clubedge_api.server import dependencies
5
+
6
+ app = create_app(dependencies())
@@ -0,0 +1,31 @@
1
+ """The composition root: the only place that reads the environment and picks providers."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from clubedge_api.app import ApiDependencies
8
+ from clubedge_api.auth import get_current_user
9
+ from clubedge_api.db import is_database_ready
10
+ from clubedge_api.settings import get_settings
11
+
12
+ # create-clubedge-app names the project here, like the frontend's site config.
13
+ SITE_CONFIG = Path(__file__).resolve().parents[1] / "config" / "site.json"
14
+
15
+
16
+ def site_config() -> dict[str, Any]:
17
+ config: dict[str, Any] = json.loads(SITE_CONFIG.read_text(encoding="utf-8"))
18
+ return config
19
+
20
+
21
+ def dependencies() -> ApiDependencies:
22
+ settings = get_settings()
23
+
24
+ async def is_ready() -> bool:
25
+ return await is_database_ready(settings.database_url)
26
+
27
+ return ApiDependencies(
28
+ service_id=str(site_config()["serviceId"]),
29
+ is_ready=is_ready,
30
+ get_current_user=get_current_user,
31
+ )
@@ -0,0 +1,35 @@
1
+ """The API's environment, read once. Locally, .env.local fills in what the shell doesn't set."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from functools import cache
6
+ from pathlib import Path
7
+
8
+
9
+ def load_env_file(path: Path) -> None:
10
+ """Sets KEY=value lines from `path` that the environment doesn't already have."""
11
+ if not path.is_file():
12
+ return
13
+ for line in path.read_text(encoding="utf-8").splitlines():
14
+ key, sep, value = line.strip().partition("=")
15
+ if not sep or key.startswith("#"):
16
+ continue
17
+ os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Settings:
22
+ # The frontend proxies /api here in development; containers listen on every interface.
23
+ host: str
24
+ port: int
25
+ database_url: str | None
26
+
27
+
28
+ @cache
29
+ def get_settings() -> Settings:
30
+ load_env_file(Path(".env.local"))
31
+ return Settings(
32
+ host=os.environ.get("HOST") or "127.0.0.1",
33
+ port=int(os.environ.get("PORT") or "3001"),
34
+ database_url=os.environ.get("DATABASE_URL") or None,
35
+ )
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "Clubedge Starter",
3
+ "shortName": "Clubedge",
4
+ "description": "A production-minded foundation for Clubedge applications.",
5
+ "serviceId": "clubedge-starter",
6
+ "workspaceLabel": "Starter workspace",
7
+ "links": {
8
+ "repository": "https://github.com/Clubedge/clubedge-starter",
9
+ "setupGuide": "https://github.com/Clubedge/clubedge-starter/blob/main/SETUP.md"
10
+ }
11
+ }
@@ -0,0 +1,46 @@
1
+ """The routes in process. The shared contract suite also runs against the live server
2
+ (`pnpm test`, through clubedge-contract-check)."""
3
+
4
+ from fastapi import Request
5
+ from fastapi.testclient import TestClient
6
+
7
+ from clubedge_api.app import ApiDependencies, User, create_app
8
+
9
+
10
+ def client(ready: bool) -> TestClient:
11
+ async def is_ready() -> bool:
12
+ return ready
13
+
14
+ async def no_user(request: Request) -> User | None:
15
+ return None
16
+
17
+ return TestClient(create_app(ApiDependencies("my-product", is_ready, no_user)))
18
+
19
+
20
+ def test_health_reports_the_service_and_the_time() -> None:
21
+ response = client(ready=False).get("/api/health")
22
+ assert response.status_code == 200
23
+ body = response.json()
24
+ assert body["status"] == "ok"
25
+ assert body["service"] == "my-product"
26
+ assert body["timestamp"]
27
+
28
+
29
+ def test_ready_matches_the_database_and_is_never_cached() -> None:
30
+ for ready, status, text in [(True, 200, "ok"), (False, 503, "unavailable")]:
31
+ response = client(ready).get("/api/ready")
32
+ assert response.status_code == status
33
+ assert response.json() == {"status": text}
34
+ assert "no-store" in response.headers["cache-control"]
35
+
36
+
37
+ def test_session_has_no_user_when_signed_out() -> None:
38
+ response = client(ready=False).get("/api/session")
39
+ assert response.status_code == 200
40
+ assert response.json() == {"user": None}
41
+
42
+
43
+ def test_unknown_paths_are_json_not_found_errors() -> None:
44
+ response = client(ready=False).get("/api/no-such-endpoint")
45
+ assert response.status_code == 404
46
+ assert response.json()["error"]["code"] == "not_found"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/vite",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/web",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clubedge-starter-v1",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -11,7 +11,7 @@
11
11
  "dev": "turbo run dev --filter=@clubedge/web",
12
12
  "build": "turbo run build",
13
13
  "start": "turbo run start --env-mode=loose --filter=@clubedge/web",
14
- "lint": "eslint .",
14
+ "lint": "eslint . && turbo run lint",
15
15
  "typecheck": "turbo run typecheck",
16
16
  "format": "prettier --write .",
17
17
  "format:check": "prettier --check .",
@@ -21,7 +21,7 @@
21
21
  "db:studio": "pnpm --filter @clubedge/db db:studio",
22
22
  "db:check": "pnpm -r --if-present run db:check",
23
23
  "db:seed": "pnpm --filter @clubedge/db db:seed",
24
- "test": "vitest run",
24
+ "test": "vitest run && turbo run test",
25
25
  "test:watch": "vitest",
26
26
  "test:e2e": "playwright test",
27
27
  "test:e2e:ui": "playwright test --ui",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-appwrite",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Appwrite provider for @clubedge/auth, run from the server with an API key and session cookies.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-better-auth",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Better Auth adapter for @clubedge/auth, storing users and sessions in your own PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-firebase",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Firebase Authentication provider for @clubedge/auth, run from the server with session cookies.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/auth-supabase",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Supabase Auth adapter for @clubedge/auth.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/cache-redis",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Redis rate limiter and cache for @clubedge/cache.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@clubedge/contract",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
+ "bin": {
9
+ "clubedge-contract-check": "./scripts/check-api.mjs"
10
+ },
8
11
  "exports": {
9
12
  ".": "./src/index.ts",
10
13
  "./conformance": "./src/conformance.ts",
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+ // Proves an API in any language against the contract: starts it, waits for /api/health, runs
3
+ // the conformance suite over HTTP (src/remote.test.ts), and stops it. An API's test script:
4
+ //
5
+ // clubedge-contract-check -- uv run python -m clubedge_api
6
+ //
7
+ // The API listens where HOST and PORT say; --port picks one that is free (default 3991).
8
+ import { spawn, spawnSync } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const windows = process.platform === "win32";
12
+ const args = process.argv.slice(2);
13
+ const separator = args.indexOf("--");
14
+ const command = separator === -1 ? [] : args.slice(separator + 1);
15
+ if (command.length === 0) {
16
+ console.error("Usage: clubedge-contract-check [--port <port>] -- <command that starts the API>");
17
+ process.exit(2);
18
+ }
19
+ const options = args.slice(0, separator);
20
+ const port = options.includes("--port") ? options[options.indexOf("--port") + 1] : "3991";
21
+ const url = `http://127.0.0.1:${port}`;
22
+ // scripts/ → packages/contract → packages → the workspace root, where Vitest runs.
23
+ const root = fileURLToPath(new URL("../../..", import.meta.url));
24
+
25
+ const server = spawn(command[0], command.slice(1), {
26
+ env: { ...process.env, HOST: "127.0.0.1", PORT: port },
27
+ stdio: "inherit",
28
+ // Its own process group, so stopping it also stops what it started (uv, then python).
29
+ detached: !windows,
30
+ shell: windows,
31
+ });
32
+
33
+ function stop() {
34
+ if (server.exitCode !== null || server.pid === undefined) return;
35
+ if (windows) spawnSync("taskkill", ["/pid", String(server.pid), "/t", "/f"], { stdio: "ignore" });
36
+ else {
37
+ try {
38
+ process.kill(-server.pid, "SIGTERM");
39
+ } catch {
40
+ // Already gone.
41
+ }
42
+ }
43
+ }
44
+ process.on("exit", stop);
45
+ for (const signal of ["SIGINT", "SIGTERM"]) {
46
+ process.on(signal, () => process.exit(130));
47
+ }
48
+
49
+ async function waitForHealth(timeoutMs = 90_000) {
50
+ const deadline = Date.now() + timeoutMs;
51
+ while (Date.now() < deadline) {
52
+ if (server.exitCode !== null)
53
+ throw new Error(`The API exited (${server.exitCode}) before it answered.`);
54
+ try {
55
+ const response = await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(2_000) });
56
+ if (response.ok) return;
57
+ } catch {
58
+ // Not listening yet.
59
+ }
60
+ await new Promise((resolve) => setTimeout(resolve, 500));
61
+ }
62
+ throw new Error(`The API did not answer ${url}/api/health in time.`);
63
+ }
64
+
65
+ try {
66
+ await waitForHealth();
67
+ } catch (error) {
68
+ console.error(error instanceof Error ? error.message : error);
69
+ process.exit(1);
70
+ }
71
+
72
+ const suite = spawnSync("pnpm", ["exec", "vitest", "run", "packages/contract/src/remote.test.ts"], {
73
+ cwd: root,
74
+ env: { ...process.env, API_URL: url },
75
+ stdio: "inherit",
76
+ shell: windows,
77
+ });
78
+ process.exit(suite.status ?? 1);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/core",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/db",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Email interface, console and in-memory providers, and message templates.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-resend",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Resend provider for @clubedge/email, through Resend's HTTP API.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/email-smtp",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "SMTP provider for @clubedge/email, for any SMTP server.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Background jobs interface: job definitions, a queue contract, and an in-memory queue.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs-bullmq",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "BullMQ provider for @clubedge/jobs: a job queue stored in Redis.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs-inngest",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Inngest provider for @clubedge/jobs: jobs run by Inngest, which calls the app for each one.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/jobs-pg-boss",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "pg-boss provider for @clubedge/jobs: a job queue stored in the project's PostgreSQL.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/local-db",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "PostgreSQL for development without Docker: PGlite served over the PostgreSQL protocol.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Observability interface: error reports and spans for @clubedge/core's telemetry hooks, and an in-memory provider for tests.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability-otel",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "OpenTelemetry provider for @clubedge/observability: traces and error logs exported over OTLP to any collector.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/observability-sentry",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Sentry provider for @clubedge/observability: server and browser errors, traces, and source map uploads.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/payments",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Provider-neutral payments: the PaymentProvider interface, billing logic, and subscription tables.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/payments-stripe",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Stripe provider for @clubedge/payments: Checkout, the customer portal, and verified webhooks.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-s3",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "S3-compatible adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/storage-supabase",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Supabase Storage adapter for @clubedge/storage.",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/ui",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -74,6 +74,12 @@ importers:
74
74
  specifier: ^5.9.2
75
75
  version: 5.9.3
76
76
 
77
+ apps/api-fastapi:
78
+ dependencies:
79
+ '@clubedge/contract':
80
+ specifier: workspace:*
81
+ version: link:../../packages/contract
82
+
77
83
  apps/start:
78
84
  dependencies:
79
85
  '@clubedge/auth':
@@ -13,6 +13,12 @@
13
13
  "cache": false,
14
14
  "persistent": true
15
15
  },
16
+ "lint": {
17
+ "outputs": []
18
+ },
19
+ "test": {
20
+ "cache": false
21
+ },
16
22
  "typecheck": {
17
23
  "dependsOn": ["^typecheck"],
18
24
  "outputs": []
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "repository": "Clubedge/clubedge-starter",
3
- "ref": "v0.22.0",
4
- "commit": "d0f3f9cdd4d568504cec4cf4681c7faf97842405",
3
+ "ref": "v0.23.0",
4
+ "commit": "0183d091b7bffac92cd0073f12b5329e96fc183f",
5
5
  "files": [
6
6
  ".dockerignore",
7
7
  ".env.example",
@@ -29,6 +29,24 @@
29
29
  "SECURITY.md",
30
30
  "SETUP.md",
31
31
  "SUPPORT.md",
32
+ "apps/api-fastapi/.env.example",
33
+ "apps/api-fastapi/.python-version",
34
+ "apps/api-fastapi/Dockerfile",
35
+ "apps/api-fastapi/clubedge.stack.json",
36
+ "apps/api-fastapi/package.json",
37
+ "apps/api-fastapi/pyproject.toml",
38
+ "apps/api-fastapi/src/clubedge_api/__init__.py",
39
+ "apps/api-fastapi/src/clubedge_api/__main__.py",
40
+ "apps/api-fastapi/src/clubedge_api/app.py",
41
+ "apps/api-fastapi/src/clubedge_api/auth.py",
42
+ "apps/api-fastapi/src/clubedge_api/check_env.py",
43
+ "apps/api-fastapi/src/clubedge_api/check_services.py",
44
+ "apps/api-fastapi/src/clubedge_api/db.py",
45
+ "apps/api-fastapi/src/clubedge_api/main.py",
46
+ "apps/api-fastapi/src/clubedge_api/server.py",
47
+ "apps/api-fastapi/src/clubedge_api/settings.py",
48
+ "apps/api-fastapi/src/config/site.json",
49
+ "apps/api-fastapi/tests/test_app.py",
32
50
  "apps/api/.env.example",
33
51
  "apps/api/Dockerfile",
34
52
  "apps/api/build.mjs",
@@ -344,6 +362,7 @@
344
362
  "packages/cache/tsconfig.json",
345
363
  "packages/contract/openapi.yaml",
346
364
  "packages/contract/package.json",
365
+ "packages/contract/scripts/check-api.mjs",
347
366
  "packages/contract/src/conformance.ts",
348
367
  "packages/contract/src/index.ts",
349
368
  "packages/contract/src/remote.test.ts",