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
- uvicorn.run(
1611
- "main:app",
1612
- host="0.0.0.0",
1613
- port=port,
1614
- reload=False,
1615
- workers=workers,
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
+ )
@@ -296,7 +296,7 @@ async function shutdown(exitCode: number): Promise<void> {
296
296
  bs.exit();
297
297
  }
298
298
 
299
- stopPythonServer();
299
+ await stopPythonServer();
300
300
  process.exit(exitCode);
301
301
  }
302
302
 
@@ -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 killProcessTree(prev);
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
- if (prev) killProcessTree(prev);
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 the pure helper functions in `main.py`.
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.4.7",
3
+ "version": "1.5.1",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",