create-caspian-app 1.4.6 → 1.5.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.
|
@@ -49,6 +49,8 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
49
49
|
- Let the running dev stack own generated outputs such as `public/css/styles.css`, `settings/component-map.json`, `settings/files-list.json`, `__pycache__/`, and `.pyc` files. Treat those as generated artifacts rather than authored source.
|
|
50
50
|
- Never treat `__pycache__/` directories or `.pyc` files as files to edit, regenerate on purpose, or keep in the final diff.
|
|
51
51
|
- Treat `settings/component-map.json` and `settings/files-list.json` as generated outputs owned by `settings/component-map.ts` and `settings/files-list.ts`; inspect them when needed, but do not hand-edit them.
|
|
52
|
+
- Keep development Python restarts graceful. `settings/python-server.ts` gives each child a private stdin control token and requests shutdown over that pipe before using force-kill as a timeout fallback; `main.py` translates the authenticated command into `uvicorn.Server.should_exit`. This ordering is what lets FastAPI lifespans close resources, especially the generated Prisma pool. Do not switch routine restarts back to immediate `taskkill /F` / `SIGKILL`, do not expose the control token over HTTP, and keep the child stdin pipe private to the orchestrator.
|
|
53
|
+
- When Prisma is enabled, keep both the `src.lib.prisma` import and `prisma_lifespan` registration behind `cfg.prisma` in `main.py`; a Prisma-disabled project may not contain that generated package. Entering the lifespan must remain lazy (no development connection is opened merely by startup), while shutdown must always await `prisma.disconnect()` so pooled connections close before the Python process exits.
|
|
52
54
|
- When `caspian.config.json` has `mcp: true`, treat `src/lib/mcp/mcp_server.py` as the app-owned FastMCP server and `src/lib/mcp/fastmcp.json` as the default MCP config. Use `npm run mcp` or `fastmcp run src/lib/mcp/fastmcp.json`; do not assume root `fastmcp.json` auto-discovery.
|
|
53
55
|
- Keep auth policy in `src/lib/auth/auth_config.py` and keep auth bootstrap, middleware wiring, and provider registration in `main.py`.
|
|
54
56
|
- Treat `casp.runtime_security` in `.venv/Lib/site-packages/casp/runtime_security.py` as package-owned runtime support for safe public-file serving: `PublicFilesMiddleware` maps every existing nested `public/**` file to its root-relative URL without per-directory routes, handles only `GET`/`HEAD`, rejects traversal and symlink escape, and falls through for missing files. It also owns restricted inline-media handling for configured user-upload directories, production session-secret enforcement, production-safe error messaging, fail-closed `APP_ENV` resolution via `is_production_environment()`, and baseline response headers including the Content-Security-Policy. Users should not customize this file during normal app work.
|
package/dist/AGENTS.md
CHANGED
|
@@ -289,6 +289,7 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
289
289
|
- Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: routes use `index.py`, layouts use `layout.py`, navigation loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
|
|
290
290
|
- **If navigation loading UI is wanted, it is `loading.py` — never hand-roll it.** The file itself is optional and most subtrees do not have one; this rule governs the implementation, not whether to add the feature. A spinner component, a global `isLoading` store, a `pp:navigation:start`/`pp:navigation:complete` listener, or a manual overlay built for route-to-route navigation is a reimplementation of the shipped runtime (`casp/loading.py` collects the files, `caspian_config.py` derives their URL scopes, the browser runtime resolves the closest ancestor scope and swaps the `pp-loading-content="true"` pane). The full contract is in the "Special files" block above; the shipped example is `src/app/dashboard/loading.py` plus the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`. In-page waits (RPC, submit, filter, upload) are `pp.state` in the owning component and are not this feature.
|
|
291
291
|
- When `caspian.config.json` has `prisma: true`, database reads and writes from Python routes, layouts, RPC actions, upload flows, auth flows, and helpers must use the generated Prisma Python ORM in `src/lib/prisma/**`. Do not create a separate database fetch layer with raw drivers, hand-written SQL helpers, JSON manifests, app-specific HTTP fetches, or browser-side data fetches to replace the ORM. Use raw SQL only as a narrow Prisma ORM fallback when the generated client cannot express a query clearly.
|
|
292
|
+
- Development restarts must preserve the database lifecycle. `settings/python-server.ts` sends an authenticated shutdown command through a private child stdin pipe and waits for Uvicorn to exit before force-kill is considered; `main.py` handles that command with `uvicorn.Server.should_exit`, and, only when `cfg.prisma` is true, conditionally imports the generated client and registers a Prisma lifespan that awaits `prisma.disconnect()` during shutdown. A Prisma-disabled project may have no `src.lib.prisma` package, so never move that import outside the feature gate. Keep Prisma's lifespan ahead of later lifespans so it exits last, do not make it connect eagerly, and do not replace the normal restart path with immediate `taskkill /F` / `SIGKILL`—that bypasses FastAPI cleanup and churns database connections during source edits.
|
|
292
293
|
- **After any `prisma/schema.prisma` change, exactly two commands are required, in order.** Step 1 — sync the database, pick one: `npx prisma migrate dev` (development default, creates and applies a migration) or `npx prisma db push` (migration-less direct sync). Step 2 — always: `npx ppy generate`, the **only** command that regenerates the Python ORM the app imports (`src/lib/prisma/__init__.py`, `db.py`, `models.py`, `settings/prisma-schema.json`). The two generators are different toolchains from the same schema: `npx prisma generate` builds the Node/TypeScript `@prisma/client` used only by `prisma/seed.ts` and writes zero Python — it is never a substitute for `npx ppy generate`. Never hand-write or patch the generated Python ORM instead of regenerating it; the generated client is ready to import from `src.lib.prisma`. See `node_modules/caspian-utils/dist/docs/database.md` "Two Generators, One Schema".
|
|
293
294
|
- Treat `npx prisma db seed` as a delicate, potentially destructive operation. In this workspace, seed scripts may clear tables before inserting fresh records. Before running that command, an AI agent must propose the exact command, warn that it can delete or overwrite database data including production data if the datasource is wrong, confirm the datasource when practical, and wait for explicit user approval.
|
|
294
295
|
- Component-first page composition is the highest-priority authoring rule for this workspace (see `.github/copilot-instructions.md`). Build pages as a short assembly of `x-*` chunk components (top menu, sidebar, header, content sections, cards, forms, footer) and keep each chunk's long markup inside its own focused single-file `html(...)` component, so the page template in `src/app/**/index.py` stays small instead of holding a wall of HTML. Plan the chunk breakdown before writing the route, not as a later cleanup pass.
|
package/dist/main.py
CHANGED
|
@@ -12,6 +12,8 @@ import os
|
|
|
12
12
|
import importlib.util
|
|
13
13
|
import re
|
|
14
14
|
import secrets
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
15
17
|
import traceback
|
|
16
18
|
import json
|
|
17
19
|
import math
|
|
@@ -79,6 +81,15 @@ from collections.abc import Callable
|
|
|
79
81
|
load_dotenv()
|
|
80
82
|
cfg = get_config()
|
|
81
83
|
|
|
84
|
+
# Prisma is optional. A project generated with `prisma: false` has no
|
|
85
|
+
# `src.lib.prisma` package, so both the import and lifespan registration must be
|
|
86
|
+
# behind the feature gate.
|
|
87
|
+
prisma: Any = None
|
|
88
|
+
if cfg.prisma:
|
|
89
|
+
from src.lib.prisma import prisma as configured_prisma
|
|
90
|
+
|
|
91
|
+
prisma = configured_prisma
|
|
92
|
+
|
|
82
93
|
# Declared before the MCP block below, which needs it to decide whether an
|
|
83
94
|
# unauthenticated endpoint or an open CORS policy is tolerable. Resolved
|
|
84
95
|
# fail-closed: only an explicit development APP_ENV turns the relaxations on.
|
|
@@ -270,6 +281,21 @@ setup_auth()
|
|
|
270
281
|
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[Any]]
|
|
271
282
|
|
|
272
283
|
|
|
284
|
+
@asynccontextmanager
|
|
285
|
+
async def prisma_lifespan(_app: FastAPI):
|
|
286
|
+
"""Close every generated Prisma connection during an orderly app shutdown.
|
|
287
|
+
|
|
288
|
+
The generated client connects lazily, so entering this lifespan does not open
|
|
289
|
+
a database connection. Its shutdown half is intentionally unconditional:
|
|
290
|
+
``disconnect()`` is idempotent and also cleans up a partially initialized pool.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
yield
|
|
294
|
+
finally:
|
|
295
|
+
if prisma is not None:
|
|
296
|
+
await prisma.disconnect()
|
|
297
|
+
|
|
298
|
+
|
|
273
299
|
def get_app_lifespans() -> list[LifespanFactory]:
|
|
274
300
|
"""
|
|
275
301
|
Register all application lifespan handlers here.
|
|
@@ -296,6 +322,11 @@ def get_app_lifespans() -> list[LifespanFactory]:
|
|
|
296
322
|
"""
|
|
297
323
|
lifespans: list[LifespanFactory] = []
|
|
298
324
|
|
|
325
|
+
# Keep the database alive until every later lifespan has shut down. This does
|
|
326
|
+
# not connect eagerly; it only guarantees cleanup when Uvicorn exits cleanly.
|
|
327
|
+
if cfg.prisma:
|
|
328
|
+
lifespans.append(prisma_lifespan)
|
|
329
|
+
|
|
299
330
|
# MCP lifecycle
|
|
300
331
|
# FastMCP needs its lifespan running so the MCP session manager starts.
|
|
301
332
|
if mcp_app is not None:
|
|
@@ -1604,13 +1635,63 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|
|
1604
1635
|
if not IS_PRODUCTION:
|
|
1605
1636
|
app.add_middleware(RequestDiagnosticsMiddleware)
|
|
1606
1637
|
|
|
1638
|
+
|
|
1639
|
+
def _consume_dev_control_stream(
|
|
1640
|
+
server: uvicorn.Server,
|
|
1641
|
+
expected_token: str,
|
|
1642
|
+
stream: Any,
|
|
1643
|
+
) -> None:
|
|
1644
|
+
"""Watch the private parent-process pipe for a graceful shutdown request."""
|
|
1645
|
+
for raw_line in stream:
|
|
1646
|
+
command, separator, token = raw_line.rstrip("\r\n").partition(":")
|
|
1647
|
+
if separator and command == "shutdown" and secrets.compare_digest(token, expected_token):
|
|
1648
|
+
server.should_exit = True
|
|
1649
|
+
return
|
|
1650
|
+
|
|
1651
|
+
# The development orchestrator owns this process. If its control pipe closes,
|
|
1652
|
+
# it has exited unexpectedly and the child must not remain orphaned.
|
|
1653
|
+
server.should_exit = True
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def _start_dev_control_listener(
|
|
1657
|
+
server: uvicorn.Server,
|
|
1658
|
+
expected_token: str,
|
|
1659
|
+
) -> threading.Thread:
|
|
1660
|
+
listener = threading.Thread(
|
|
1661
|
+
target=_consume_dev_control_stream,
|
|
1662
|
+
args=(server, expected_token, sys.stdin),
|
|
1663
|
+
name="caspian-dev-control",
|
|
1664
|
+
daemon=True,
|
|
1665
|
+
)
|
|
1666
|
+
listener.start()
|
|
1667
|
+
return listener
|
|
1668
|
+
|
|
1669
|
+
|
|
1607
1670
|
if __name__ == "__main__":
|
|
1608
1671
|
port = int(os.getenv("PORT", 5091))
|
|
1609
1672
|
workers = max(1, int(os.getenv("UVICORN_WORKERS", "1")))
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1673
|
+
dev_control_token = os.getenv("CASPIAN_DEV_CONTROL_TOKEN", "")
|
|
1674
|
+
|
|
1675
|
+
if dev_control_token:
|
|
1676
|
+
# The local orchestrator uses a private stdin pipe so graceful shutdown
|
|
1677
|
+
# works consistently on Windows, where SIGTERM is not available to Node
|
|
1678
|
+
# child processes. Development intentionally remains single-worker.
|
|
1679
|
+
server = uvicorn.Server(
|
|
1680
|
+
uvicorn.Config(
|
|
1681
|
+
"main:app",
|
|
1682
|
+
host="0.0.0.0",
|
|
1683
|
+
port=port,
|
|
1684
|
+
reload=False,
|
|
1685
|
+
workers=1,
|
|
1686
|
+
)
|
|
1687
|
+
)
|
|
1688
|
+
_start_dev_control_listener(server, dev_control_token)
|
|
1689
|
+
server.run()
|
|
1690
|
+
else:
|
|
1691
|
+
uvicorn.run(
|
|
1692
|
+
"main:app",
|
|
1693
|
+
host="0.0.0.0",
|
|
1694
|
+
port=port,
|
|
1695
|
+
reload=False,
|
|
1696
|
+
workers=workers,
|
|
1697
|
+
)
|