create-caspian-app 1.4.7 → 1.5.1
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
|
+
)
|
|
@@ -3,9 +3,12 @@ import { platform } from "os";
|
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { Socket } from "net";
|
|
6
|
+
import { randomBytes } from "crypto";
|
|
6
7
|
|
|
7
8
|
let pythonProcess: ChildProcess | null = null;
|
|
9
|
+
let pythonControlToken: string | null = null;
|
|
8
10
|
let isRestarting = false;
|
|
11
|
+
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 7000;
|
|
9
12
|
|
|
10
13
|
function isWindows(): boolean {
|
|
11
14
|
return platform() === "win32";
|
|
@@ -114,23 +117,68 @@ async function killProcessTree(child: ChildProcess): Promise<void> {
|
|
|
114
117
|
}
|
|
115
118
|
}
|
|
116
119
|
|
|
120
|
+
export function requestGracefulShutdown(
|
|
121
|
+
child: ChildProcess,
|
|
122
|
+
controlToken: string | null,
|
|
123
|
+
timeout = GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
if (child.exitCode !== null) return Promise.resolve(true);
|
|
126
|
+
const stdin = child.stdin;
|
|
127
|
+
if (!controlToken || !stdin?.writable) return Promise.resolve(false);
|
|
128
|
+
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
let settled = false;
|
|
131
|
+
const finish = (stopped: boolean) => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
settled = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
child.off("exit", onExit);
|
|
136
|
+
child.off("close", onExit);
|
|
137
|
+
resolve(stopped);
|
|
138
|
+
};
|
|
139
|
+
const onExit = () => finish(true);
|
|
140
|
+
const timer = setTimeout(() => finish(false), timeout);
|
|
141
|
+
|
|
142
|
+
child.once("exit", onExit);
|
|
143
|
+
child.once("close", onExit);
|
|
144
|
+
stdin.write(`shutdown:${controlToken}\n`, (error) => {
|
|
145
|
+
if (error) finish(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function stopPythonProcess(
|
|
151
|
+
child: ChildProcess,
|
|
152
|
+
controlToken: string | null,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const stoppedGracefully = await requestGracefulShutdown(child, controlToken);
|
|
155
|
+
if (stoppedGracefully) return;
|
|
156
|
+
|
|
157
|
+
console.warn(
|
|
158
|
+
"Warning: Python server did not stop gracefully; forcing process termination.",
|
|
159
|
+
);
|
|
160
|
+
await killProcessTree(child);
|
|
161
|
+
}
|
|
162
|
+
|
|
117
163
|
function spawnPython(port: number, browserSyncPort?: number): ChildProcess {
|
|
118
164
|
const pythonPath = getVenvPythonPath();
|
|
119
165
|
const args = ["-u", "main.py"];
|
|
120
166
|
|
|
121
167
|
console.log(`-> Starting Python server on port ${port}...`);
|
|
122
168
|
|
|
169
|
+
pythonControlToken = randomBytes(32).toString("hex");
|
|
123
170
|
const env = {
|
|
124
171
|
...process.env,
|
|
125
172
|
PYTHONUNBUFFERED: "1",
|
|
126
173
|
PORT: String(port),
|
|
174
|
+
CASPIAN_DEV_CONTROL_TOKEN: pythonControlToken,
|
|
127
175
|
...(browserSyncPort
|
|
128
176
|
? { CASPIAN_BROWSER_SYNC_PORT: String(browserSyncPort) }
|
|
129
177
|
: {}),
|
|
130
178
|
};
|
|
131
179
|
|
|
132
180
|
const child = spawn(pythonPath, args, {
|
|
133
|
-
stdio: "inherit",
|
|
181
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
134
182
|
shell: false,
|
|
135
183
|
detached: !isWindows(),
|
|
136
184
|
env,
|
|
@@ -158,10 +206,12 @@ export async function restartPythonServer(
|
|
|
158
206
|
try {
|
|
159
207
|
console.log("-> Restarting Python server...");
|
|
160
208
|
const prev = pythonProcess;
|
|
209
|
+
const prevControlToken = pythonControlToken;
|
|
161
210
|
pythonProcess = null;
|
|
211
|
+
pythonControlToken = null;
|
|
162
212
|
|
|
163
213
|
if (prev) {
|
|
164
|
-
await
|
|
214
|
+
await stopPythonProcess(prev, prevControlToken);
|
|
165
215
|
await waitForPortRelease(port);
|
|
166
216
|
}
|
|
167
217
|
|
|
@@ -171,10 +221,12 @@ export async function restartPythonServer(
|
|
|
171
221
|
}
|
|
172
222
|
}
|
|
173
223
|
|
|
174
|
-
export function stopPythonServer(): void {
|
|
224
|
+
export async function stopPythonServer(): Promise<void> {
|
|
175
225
|
const prev = pythonProcess;
|
|
226
|
+
const prevControlToken = pythonControlToken;
|
|
176
227
|
pythonProcess = null;
|
|
177
|
-
|
|
228
|
+
pythonControlToken = null;
|
|
229
|
+
if (prev) await stopPythonProcess(prev, prevControlToken);
|
|
178
230
|
}
|
|
179
231
|
|
|
180
232
|
export async function waitForHttpHealth(
|
|
@@ -1,13 +1,76 @@
|
|
|
1
|
-
"""Unit tests for
|
|
1
|
+
"""Unit tests for focused helper and lifecycle functions in `main.py`.
|
|
2
2
|
|
|
3
3
|
These cover the app-owned logic that has no external dependencies: env
|
|
4
4
|
parsing, query-param coercion, and the component-deferral HTML transform.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import inspect
|
|
8
|
+
import io
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from types import SimpleNamespace
|
|
8
11
|
from typing import Optional
|
|
12
|
+
from unittest.mock import AsyncMock
|
|
9
13
|
|
|
10
14
|
import main
|
|
15
|
+
from conftest import run_async
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestPrismaLifespan:
|
|
19
|
+
def test_disconnects_on_shutdown(self, monkeypatch):
|
|
20
|
+
disconnect = AsyncMock()
|
|
21
|
+
monkeypatch.setattr(main.prisma, "disconnect", disconnect)
|
|
22
|
+
|
|
23
|
+
async def exercise_lifespan():
|
|
24
|
+
async with main.prisma_lifespan(main.app):
|
|
25
|
+
disconnect.assert_not_awaited()
|
|
26
|
+
|
|
27
|
+
run_async(exercise_lifespan())
|
|
28
|
+
disconnect.assert_awaited_once_with()
|
|
29
|
+
|
|
30
|
+
def test_disabled_prisma_is_not_registered(self, monkeypatch):
|
|
31
|
+
monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=False))
|
|
32
|
+
|
|
33
|
+
assert main.prisma_lifespan not in main.get_app_lifespans()
|
|
34
|
+
|
|
35
|
+
def test_disconnects_when_another_lifespan_raises(self, monkeypatch):
|
|
36
|
+
disconnect = AsyncMock()
|
|
37
|
+
monkeypatch.setattr(main.prisma, "disconnect", disconnect)
|
|
38
|
+
|
|
39
|
+
async def exercise_lifespan():
|
|
40
|
+
try:
|
|
41
|
+
async with main.prisma_lifespan(main.app):
|
|
42
|
+
raise RuntimeError("shutdown path")
|
|
43
|
+
except RuntimeError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
run_async(exercise_lifespan())
|
|
47
|
+
disconnect.assert_awaited_once_with()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestDevControlPipe:
|
|
51
|
+
def test_valid_shutdown_command_stops_server(self):
|
|
52
|
+
server = SimpleNamespace(should_exit=False)
|
|
53
|
+
|
|
54
|
+
main._consume_dev_control_stream(
|
|
55
|
+
server,
|
|
56
|
+
"expected-token",
|
|
57
|
+
io.StringIO("shutdown:expected-token\n"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
assert server.should_exit is True
|
|
61
|
+
|
|
62
|
+
def test_ignores_invalid_commands_until_pipe_closes(self):
|
|
63
|
+
server = SimpleNamespace(should_exit=False)
|
|
64
|
+
|
|
65
|
+
main._consume_dev_control_stream(
|
|
66
|
+
server,
|
|
67
|
+
"expected-token",
|
|
68
|
+
io.StringIO("shutdown:wrong-token\nnoop:expected-token\n"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# EOF means the owning development orchestrator exited, so an otherwise
|
|
72
|
+
# orphaned child still shuts down cleanly.
|
|
73
|
+
assert server.should_exit is True
|
|
11
74
|
|
|
12
75
|
|
|
13
76
|
class TestEnvParsing:
|