create-caspian-app 1.0.3 → 1.0.5

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.
@@ -51,7 +51,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
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
52
  - 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
53
  - Keep auth policy in `src/lib/auth/auth_config.py` and keep auth bootstrap, middleware wiring, and provider registration in `main.py`.
54
- - Treat `casp.runtime_security` in `.venv/Lib/site-packages/casp/runtime_security.py` as package-owned runtime support for safe public-file serving (including attachment mode for user uploads), 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.
54
+ - 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.
55
55
  - In app-owned starter config like this workspace, routes start public because `src/lib/auth/auth_config.py` sets `is_all_routes_private=False` by default.
56
56
  - Decide route privacy in `src/lib/auth/auth_config.py` at app setup time: use `is_all_routes_private=True` when only a few routes should stay public, otherwise keep `is_all_routes_private=False` and list the protected routes in `private_routes`.
57
57
  - In all-private mode, keep public exceptions in `public_routes`; the runtime defaults keep `/` public and keep `auth_routes=["/signin", "/signup"]` public.
@@ -78,6 +78,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
78
78
  - In grouped section layouts with separate shell and content scrolling, put `pp-reset-scroll="true"` on the content scroll container that should reset on child-route navigation, usually the main pane. Leave persistent shell scrollers such as sidebars or rails unmarked so SPA navigation can preserve their scroll position.
79
79
  - When a single route needs to affect a wrapping layout, have `page()` return `(render_page(__file__, page_context), {"dashboard_body_class": ...})` and consume that value as `{{ layout.dashboard_body_class }}` in `layout.html`. Use `layout.py` when the same prop should apply across a whole subtree.
80
80
  - For file uploads and file-manager flows, keep browser interaction in route templates, keep upload and delete `@rpc()` actions in the owning `src/app/**/index.py`, keep shared storage and persistence helpers in `src/lib/**`, store metadata in Prisma, and store browser-accessible blobs under `public/uploads/**` when the files should be served directly.
81
+ - `public/uploads/**` is protected because `main.py` maps the top-level `uploads` directory to `INLINE_SAFE_UPLOAD_MEDIA_TYPES` in `PublicFilesMiddleware.inline_safe_subdirectories`. Before writing untrusted runtime uploads into any other top-level public directory, add that directory to the same restricted-inline mapping; otherwise trusted first-party public-file behavior would serve executable HTML or SVG inline.
81
82
  - Local upload helpers should create `public/uploads` on demand when it does not exist yet; do not assume the folder is committed ahead of time.
82
83
  - When runtime uploads write into `public/uploads/**`, keep the public-root-relative entry `uploads` in `settings/bs-config.ts` `PUBLIC_IGNORE_DIRS` so `npm run dev` does not reload on each upload.
83
84
  - For logout flows, prefer `pp.rpc("signout")` backed by `@rpc(require_auth=True)` from page-level or component-level UI. Use a dedicated signout route only for plain form POST, no-JavaScript fallback, or other full-navigation edge cases.
@@ -103,8 +104,8 @@ This is the top architectural requirement for this workspace. Treat it as a hard
103
104
  - Treat `main.py` as the repo source of truth for FastAPI setup, auth bootstrap, middleware wiring, route registration, cache defaults, and error handlers.
104
105
  - `main.py` finalizes every rendered page through `finalize_html(...)` = `transform_scripts(...)` then `defer_component_roots(...)`. `defer_component_roots(...)` wraps each outermost `pp-component` root in an inert `<template pp-component>` so the browser never parses raw `{...}` placeholders as live DOM; the PulsePoint `mount()` bootstrap materializes them back before scanning. Because of this deferral, `{...}` is safe in any attribute or position (SVG `d`/`viewBox`/`points`, `src`/`href`, form `value`/date/number/color, table/select text). Do not add per-tag workarounds to dodge browser first-paint validation (static-path `hidden` toggles, `data-*` URL holders, `hidden`-gated `<img src>`, or SSR-resolved initial values). Keep `pp-style` (source-file tooling) and the controlled form-field `value`/`checked`/`defaultvalue`/`<textarea>` rewrites (attribute-vs-property correctness); those exist for reasons deferral does not replace.
105
106
  - Treat `main.py` as the source of truth for app-owned WebSocket endpoints, origin validation, idle timeouts, maximum socket message size, JSON message handling, close codes, and broadcast-channel wiring.
106
- - When the app factors response-header hardening or safe static-file behavior into app-owned helpers, treat `main.py` plus those imported helpers as the runtime source of truth together.
107
- - Preserve the effective middleware execution order unless the task explicitly changes request semantics: `SecurityHeadersMiddleware -> SessionMiddleware -> CSRFMiddleware -> AuthMiddleware -> RPCMiddleware`.
107
+ - Treat `main.py` plus imported package-owned helpers such as `casp.runtime_security` as the runtime source of truth for response-header hardening and public-file behavior.
108
+ - Preserve the production effective middleware execution order unless the task explicitly changes request semantics: `SecurityHeadersMiddleware -> PublicFilesMiddleware -> RateLimitMiddleware -> BodySizeLimitMiddleware -> SessionMiddleware -> CSRFMiddleware -> AuthMiddleware -> RPCMiddleware -> route`. In development, `RequestDiagnosticsMiddleware` is outermost. Existing public-file `GET`/`HEAD` requests stop at `PublicFilesMiddleware`; missing paths fall through the remaining stack.
108
109
  - Do not move normal file upload or file-manager behavior into `main.py`; keep those actions in the owning route `index.py` and shared helpers in `src/lib/**`.
109
110
  - Document route param behavior exactly as implemented here.
110
111
  - Do not use `main.py` alone to infer whether optional features are enabled; confirm that in `caspian.config.json` first.
@@ -158,7 +159,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
158
159
  ### `settings/build-static.py` and `settings/serve-static.py`
159
160
 
160
161
  - Treat these as the app-owned static-export tooling (not shipped `casp` runtime). `settings/build-static.py` is the SSG exporter; `settings/serve-static.py` is the preview server. Document behavior against these files, `package.json`, and `settings/project-name.ts`, not against a `casp` module.
161
- - `settings/build-static.py` boots the real app via Starlette `TestClient` and iterates `get_files_index()` (which reads `settings/files-list.json`) to render every static route to `static/<route>/index.html`, then copies public asset trees. Keep its "warn & skip" scope policy: dynamic routes need `static_paths` in their `index.py`, and auth-gated / non-200 / non-HTML routes are reported and skipped rather than written broken. Preserve `APP_ENV=development` so the build needs no production secrets.
162
+ - `settings/build-static.py` boots the real app via Starlette `TestClient` and iterates `get_files_index()` (which reads `settings/files-list.json`) to render every static route to `static/<route>/index.html`, then mirrors the complete `public/**` tree into `static/`. Keep its "warn & skip" scope policy: dynamic routes need `static_paths` in their `index.py`, and auth-gated / non-200 / non-HTML routes are reported and skipped rather than written broken. Preserve `APP_ENV=development` so the build needs no production secrets.
162
163
  - Keep `npm run static` composed as `npm run build && uv run python settings/build-static.py` so `projectName` regenerates `settings/files-list.json` and `settings/component-map.json` before the exporter walks the route index. Do not change it to run only `tailwind:build`.
163
164
  - `settings/serve-static.py` must keep its robustness and safety contract: serve only `static/`, bind loopback `127.0.0.1` by default (network exposure only via `HOST=0.0.0.0`), auto-select a free port by genuinely binding upward from the preferred start port (default 8000; `PORT`/`PORT_TRIES` overrides) with `SO_REUSEADDR` disabled so an occupied Windows port truly fails instead of silently colliding, fail fast when `static/` is unbuilt, and shut down cleanly on Ctrl+C. Do not reintroduce a hardcoded-port `python -m http.server` one-liner.
164
165
  - Treat `static/` as generated output; do not hand-edit exported HTML. Fix the source route, component, or asset and re-export.
@@ -214,7 +215,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
214
215
  - Treat these files as framework internals.
215
216
  - Only change them when the task is explicitly about Caspian core behavior, installed-runtime debugging, or documentation that must match the installed implementation.
216
217
  - If behavior changes here, update the matching docs under `node_modules/caspian-utils/dist/docs/`.
217
- - `casp/runtime_security.py` owns framework-managed safe public-file serving, baseline response headers including the CSP (`build_content_security_policy()`, overridable via `CONTENT_SECURITY_POLICY`), production-safe error messages, fail-closed `APP_ENV` resolution, and production session-secret enforcement used by `main.py`.
218
+ - `casp/runtime_security.py` owns framework-managed safe public-file serving through `PublicFilesMiddleware` and `resolve_safe_public_path`, configured upload attachment-mode behavior, baseline response headers including the CSP (`build_content_security_policy()`, overridable via `CONTENT_SECURITY_POLICY`), production-safe error messages, fail-closed `APP_ENV` resolution, and production session-secret enforcement used by `main.py`.
218
219
 
219
220
  ### `.github/instructions/**/*.instructions.md`
220
221
 
package/dist/AGENTS.md CHANGED
@@ -1,3 +1,5 @@
1
+ <!-- caspian:start -->
2
+
1
3
  # Caspian Agent Guide
2
4
 
3
5
  ## Purpose
@@ -65,6 +67,7 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
65
67
  - Local Caspian docs live under `node_modules/caspian-utils/dist/docs/`.
66
68
  - Workspace file instructions live under `.github/instructions/**/*.instructions.md` when the repo needs task- or library-specific AI guidance that should not be always-on.
67
69
  - Use `node_modules/caspian-utils/dist/docs/core-runtime-map.md` when a behavior is controlled by `main.py`, package-owned runtime helpers such as `.venv/Lib/site-packages/casp/runtime_security.py`, or other `.venv/Lib/site-packages/casp/**` files and the owning file is not obvious yet.
70
+ - Treat `public/` as a URL-root mapping, not a directory registry: an existing `public/icons/app.png` is served as `/icons/app.png` without a per-directory route, mount, or prefix list in `main.py`. `PublicFilesMiddleware` handles only `GET`/`HEAD`, resolves paths beneath `public/`, rejects traversal and symlink escape, and falls through when no file exists. Keep it inside `SecurityHeadersMiddleware` and outside rate limiting, body parsing, sessions, CSRF, auth, RPC, and page routing.
68
71
  - Use `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` when a behavior is controlled by the shipped PulsePoint browser runtime and the task names state, effects, refs, context, portals, directives, `pp.rpc`, uploads, streaming, SPA navigation, or scroll restoration.
69
72
  - Use `node_modules/caspian-utils/dist/docs/websockets.md` when the task names WebSockets, live bidirectional channels, socket origin checks, socket auth/session behavior, broadcast managers, or native browser `WebSocket` clients.
70
73
  - Use `node_modules/caspian-utils/dist/docs/file-conventions.md` when the task asks what belongs in `index.html`, `index.py`, `layout.html`, `layout.py`, `loading.html`, `not-found.html`, or `error.html`.
@@ -109,12 +112,12 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
109
112
  - **Server-interpolated values must never carry live PulsePoint syntax.** The Jinja `Environment` in `casp.layout` runs a `finalize` hook that encodes `{`/`}` as `&#123;`/`&#125;` on every non-`Markup` value, because `TemplateCompiler` compiles the rendered DOM and would otherwise execute a stored `{fetch(...)}` as JavaScript. `Markup` is the trust boundary: `| safe`, `get_attributes(...)`, `merge_classes(...)`, the `json`/`dump` filters, and rendered layout children all return `Markup` and legitimately keep their braces. A new helper that emits PulsePoint syntax must return `Markup`, and a helper that formats user data must not.
110
113
  - **Never cache an authenticated render.** `CacheHandler` keys on the URI alone, so `is_request_cacheable(request)` gates both the read and the write; a route's `Cache(...)` cannot override it.
111
114
  - **RPC payload keys are filtered against the function signature** (`_filter_call_kwargs`), so a parameter is client-settable only when declared. Declaring `**kwargs` opts into the whole payload — do that only deliberately.
112
- - **`/uploads` serves user content in attachment mode** via `inline_safe_media_types`; only real image types render inline. Do not reuse that argument for first-party `/css`, `/js`, or `/assets`, which must stay inline.
115
+ - **Configured public upload directories serve user content in attachment mode** via `PublicFilesMiddleware.inline_safe_subdirectories`; only allow-listed real image types render inline. This workspace configures `uploads` for `public/uploads/**`. If another top-level public directory will receive untrusted runtime uploads, add it to that mapping before storing files there. Do not apply attachment mode to trusted first-party assets, which must stay inline.
113
116
  - **`/mcp` requires `MCP_AUTH_TOKEN`.** With no token the endpoint stays open in development and returns 503 in production. It is mounted outside the routing tree, so `AuthMiddleware` does not protect it.
114
- - Security-relevant environment variables beyond the pre-existing set: `MCP_AUTH_TOKEN`, `RATE_LIMIT_PAGES` (default `200/minute`; static assets and `/health` are exempt), `CONTENT_SECURITY_POLICY` (replaces the default policy wholesale), `MAX_WEBSOCKET_CONNECTIONS`, `MAX_WEBSOCKET_MESSAGES_PER_WINDOW`, `WEBSOCKET_RATE_WINDOW_SECONDS`, and `WEBSOCKET_ALLOWED_ORIGINS` (required in production — the same-origin fallback is derived from the client-supplied Host header and is development-only).
117
+ - Security-relevant environment variables beyond the pre-existing set: `MCP_AUTH_TOKEN`, `RATE_LIMIT_PAGES` (default `200/minute`; existing public-file `GET`/`HEAD` requests are served before the limiter and `/health` is explicitly exempt), `CONTENT_SECURITY_POLICY` (replaces the default policy wholesale), `MAX_WEBSOCKET_CONNECTIONS`, `MAX_WEBSOCKET_MESSAGES_PER_WINDOW`, `WEBSOCKET_RATE_WINDOW_SECONDS`, and `WEBSOCKET_ALLOWED_ORIGINS` (required in production — the same-origin fallback is derived from the client-supplied Host header and is development-only).
115
118
  - Coverage lives in `tests/test_layout.py` (brace escaping), `tests/test_runtime_security.py` (environment + CSP + path safety), `tests/test_request_security.py` (cache eligibility, page rate limit, socket message rate, CSRF cookie scope), `tests/test_mcp_security.py`, `tests/test_upload_serving.py`, `tests/test_websocket_security.py`, `tests/test_rpc.py`, and `tests/test_auth.py`.
116
119
 
117
- - This workspace supports static HTML export (SSG, like Next.js `output: export`) as an app-owned build convention on top of Caspian, driven by two `settings/` scripts and two `package.json` scripts. `npm run static` builds metadata + Tailwind then exports every static route through `settings/build-static.py` (which boots the app via Starlette `TestClient` and writes `static/<route>/index.html` plus copied public assets). Its skip policy is "warn & skip": dynamic routes are only pre-rendered when their `index.py` exports `static_paths` (the `getStaticPaths` equivalent), and auth-gated / non-200 / non-HTML routes are reported and skipped. `npm run static:serve` runs `settings/serve-static.py`, a hardened preview server that serves only `static/`, binds loopback `127.0.0.1` by default (network exposure is opt-in via `HOST=0.0.0.0`), and auto-selects a free port by walking upward from a preferred default (8000, overridable via `PORT`) so an occupied port never aborts the preview. Keep `static/`, `settings/files-list.json`, and `settings/component-map.json` treated as generated outputs. `pp.rpc()`, auth, WebSockets, streaming, and per-request server data are inert in a static export. See `node_modules/caspian-utils/dist/docs/static-export.md`.
120
+ - This workspace supports static HTML export (SSG, like Next.js `output: export`) as an app-owned build convention on top of Caspian, driven by two `settings/` scripts and two `package.json` scripts. `npm run static` builds metadata + Tailwind then exports every static route through `settings/build-static.py` (which boots the app via Starlette `TestClient`, writes `static/<route>/index.html`, and mirrors the complete `public/**` tree into `static/`). Its skip policy is "warn & skip": dynamic routes are only pre-rendered when their `index.py` exports `static_paths` (the `getStaticPaths` equivalent), and auth-gated / non-200 / non-HTML routes are reported and skipped. `npm run static:serve` runs `settings/serve-static.py`, a hardened preview server that serves only `static/`, binds loopback `127.0.0.1` by default (network exposure is opt-in via `HOST=0.0.0.0`), and auto-selects a free port by walking upward from a preferred default (8000, overridable via `PORT`) so an occupied port never aborts the preview. Keep `static/`, `settings/files-list.json`, and `settings/component-map.json` treated as generated outputs. `pp.rpc()`, auth, WebSockets, streaming, and per-request server data are inert in a static export. See `node_modules/caspian-utils/dist/docs/static-export.md`.
118
121
 
119
122
  ## Task Routing
120
123
 
@@ -136,7 +139,7 @@ If the task generates or edits route, layout, or component HTML templates, check
136
139
  - RPC, data loading, streaming, uploads: read `node_modules/caspian-utils/dist/docs/fetch-data.md` and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `.venv/Lib/site-packages/casp/rpc.py`, `public/js/pp-reactive-v2.js`, and `main.py`.
137
140
  - AI/LLM/chat or any one-way streaming output: use the shipped RPC streaming path (generator `@rpc()` that `yield`s chunks, consumed by `pp.rpc(..., { onStream })`); for an LLM SDK, `async for ... yield`. Read `node_modules/caspian-utils/dist/docs/fetch-data.md` "Streaming Responses" and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `.venv/Lib/site-packages/casp/streaming.py`, `.venv/Lib/site-packages/casp/rpc.py`, `public/js/pp-reactive-v2.js`, and `main.py`. Do not reinvent with raw `fetch`/`ReadableStream`, `EventSource`, or WebSockets.
138
141
  - WebSockets and live channels: first confirm `caspian.config.json` has `websocket: true`, then read `node_modules/caspian-utils/dist/docs/websockets.md`. Verify against `main.py`, `src/lib/websocket/**` when present, the owning `src/app/**` route files, and `settings/bs-config.json`.
139
- - File uploads and managers: read `node_modules/caspian-utils/dist/docs/file-uploads.md` and `node_modules/caspian-utils/dist/docs/fetch-data.md`. Verify against `src/app/**`, `src/lib/**`, `prisma/**`, and `settings/bs-config.ts`.
142
+ - File uploads and managers: read `node_modules/caspian-utils/dist/docs/file-uploads.md` and `node_modules/caspian-utils/dist/docs/fetch-data.md`. Verify against `src/app/**`, `src/lib/**`, `prisma/**`, `settings/bs-config.ts`, `main.py`, and `.venv/Lib/site-packages/casp/runtime_security.py`; any top-level public directory that receives untrusted uploads must be configured for restricted inline media in `PublicFilesMiddleware`.
140
143
  - Server state: read `node_modules/caspian-utils/dist/docs/state.md`. Verify against `.venv/Lib/site-packages/casp/state_manager.py` and `main.py`.
141
144
  - Page caching: read `node_modules/caspian-utils/dist/docs/cache.md`. Verify against `.venv/Lib/site-packages/casp/cache_handler.py` and `main.py`.
142
145
  - Validation: read `node_modules/caspian-utils/dist/docs/validation.md`. Verify against `.venv/Lib/site-packages/casp/validate.py`.
@@ -169,3 +172,4 @@ Before merging doc or runtime changes:
169
172
  2. Update the matching packaged doc in `node_modules/caspian-utils/dist/docs/` if the running behavior changed.
170
173
  3. Update `.github/copilot-instructions.md` if the repo-wide implementation rules changed.
171
174
  4. Update this file if the decision order, task routing, workspace clarifications, or packaged-doc maintenance rules changed.
175
+ <!-- caspian:end -->
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import{execSync,spawnSync}from"child_process";import fs from"fs";import{fileURLToPath}from"url";import path from"path";import chalk from"chalk";import prompts from"prompts";import https from"https";import{randomBytes}from"crypto";const __filename=fileURLToPath(import.meta.url),__dirname=path.dirname(__filename),PACKAGE_ROOT=path.resolve(__dirname,".."),OPTIONAL_TEMPLATE_FILES=new Set([".python-version",".prettierrc"]),OPTIONAL_TEMPLATE_DIRECTORIES=new Set([".github",".vscode"]);let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.html","not-found.html","error.html"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.html","src/app/index.html"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.html","src/app/index.html","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py","pyproject.toml"]},realtime:{id:"realtime",name:"Real-time Application",description:"Application with WebSocket support and MCP",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!0,websocket:!0},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const n=e.indexOf("\\htdocs\\");if(-1===n)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,n+8).replace(/\\/g,"\\\\"),s=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let i=`http://localhost/${s}`;i=i.endsWith("/")?i.slice(0,-1):i;const o=i.replace(/(?<!:)(\/\/+)/g,"/"),c=s.replace(/\/\/+/g,"/");return{bsTarget:`${o}/`,bsPathRewrite:{"^/":`/${c.startsWith("/")?c.substring(1):c}/`}}}async function updatePackageJson(e,n){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const s=JSON.parse(fs.readFileSync(t,"utf8"));s.scripts={...s.scripts,projectName:"tsx settings/project-name.ts",check:"uv run python settings/check.py","check:fix":"uv run python settings/fix.py",logs:"uv run python settings/browser_log.py",static:"npm run build && uv run python settings/build-static.py","static:serve":"uv run python settings/serve-static.py"};let i=[];n.tailwindcss&&(s.scripts={...s.scripts,tailwind:"tsx settings/run-postcss.ts watch","tailwind:build":"tsx settings/run-postcss.ts build"},i.push("tailwind")),n.typescript&&!n.backendOnly&&(s.scripts={...s.scripts,"ts:watch":"vite build --watch","ts:watch:dev":"tsx settings/run-vite-watch.ts","ts:build":"vite build"},i.push("ts:watch:dev")),n.mcp&&(s.scripts={...s.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let o={...s.scripts};o.browserSync="tsx settings/bs-config.ts",o.dev=`npm-run-all projectName -l -p browserSync ${i.join(" ")}`;let c=["projectName"];n.tailwindcss&&c.unshift("tailwind:build"),n.typescript&&!n.backendOnly&&c.unshift("ts:build"),o.build=`npm-run-all ${c.join(" ")}`,s.scripts=o,s.type="module",fs.writeFileSync(t,JSON.stringify(s,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function buildEnvSection(e,n){return`# =============================================================================\n${e.split("\n").map(e=>e.startsWith("#")?e:`# ${e}`).join("\n")}\n# =============================================================================\n\n${n.trimEnd()}`}function buildCaspianEnvContent(e){const n=generateAuthSecret(),t=generateHexEncodedKey(8),s=generateHexEncodedKey(32),i=generateHexEncodedKey(32),o=[];return e.prisma&&o.push(buildEnvSection("1. DATABASE\n# Enforced by: prisma/schema.prisma, src/lib/prisma/db.py",'# Connection string. Prisma reads this directly from .env.\n# Format reference: https://pris.ly/d/connection-strings\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"\n\n# Connection-pool limit. Defaults: SQLite 5; MySQL and PostgreSQL 20.\n# Use 5 for local development; production does not need this unless you want to\n# limit the pool.\nDB_POOL_SIZE=5\n\n# Seconds idle before the client re-probes its connection. Default 30.\nPRISMA_CONN_PROBE_IDLE_SECONDS=30\n\n# Warn on queries that cause a full table scan. 0/false silences it. Default 1.\nPRISMA_WARN_FULL_SCAN=1')),o.push(buildEnvSection("2. APPLICATION RUNTIME\n# Enforced by: casp/runtime_security.py is_production_environment()",'# Environment selector, resolved FAIL-CLOSED: only an explicit development\n# value (dev, development, local, staging, test, testing) enables the\n# development relaxations. Unset or misspelled counts as production.\n#\n# Production turns on: HTTPS-only session cookie, Secure CSRF cookie, HSTS,\n# generic error messages, mandatory AUTH_SECRET, mandatory MCP_AUTH_TOKEN, and\n# it removes the localhost origin bypass and the WebSocket same-origin fallback.\n#\n# This single value gates most of the security posture. Set it deliberately.\nAPP_ENV="development"'),buildEnvSection("3. PUBLIC URL, CORS, AND ORIGIN VALIDATION\n# Enforced by: casp/rpc.py origin checks, main.py CORS layer",'# Canonical public origin. Leave empty when the browser URL and the app runtime\n# URL match. Set it when they differ, i.e. behind an ingress, reverse proxy,\n# load balancer, gateway, edge network, or TLS terminator.\nAPP_BASE_URL=""\n\n# Extra browser origins allowed to call protected endpoints such as RPC. Use\n# when one deployment is reachable from more than one public origin.\n# Comma-separated, no spaces.\nCORS_ALLOWED_ORIGINS=""\n\n# Trust Forwarded/X-Forwarded-* headers. Enable ONLY when every request passes\n# through infrastructure that strips client-supplied forwarded headers before\n# setting its own, because a direct client can otherwise forge them.\n#\n# Affects two things: which origin RPC accepts, and which address the rate\n# limiter buckets on. Left false, both use the direct request instead.\nTRUST_FORWARDED_HEADERS="false"\n\n# Allow cookies/Authorization on cross-origin requests. Keep true only when\n# credentialed cross-origin requests are actually required.\nCORS_ALLOW_CREDENTIALS="true"\n\n# CORS preflight response fields.\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\n\n# Preflight cache duration in seconds.\nCORS_MAX_AGE="86400"'),buildEnvSection("4. AUTHENTICATION AND SESSIONS\n# Enforced by: casp/auth.py, main.py SessionMiddleware\n# Route privacy and RBAC live in src/lib/auth/auth_config.py, not here.",`# Session signing secret. Unique and strong per app and per environment.\n# In production the app refuses to start when this is missing or left on a\n# placeholder ("change-me"/"changeme"); in development it falls back.\nAUTH_SECRET="${n}"\n\n# Session cookie name. Use a unique value when several apps share a parent\n# domain, or their sessions overwrite each other.\nAUTH_COOKIE_NAME="${t}"\n\n# Session lifetime in hours (SessionMiddleware max_age).\nSESSION_LIFETIME_HOURS="7"`),buildEnvSection("5. OAUTH PROVIDERS\n# Enforced by: casp/auth.py; routes served by main.py AuthMiddleware","# Google and GitHub sign-in are already wired: AuthMiddleware serves\n# /api/auth/signin/{google,github} and /api/auth/callback/{google,github}.\n# Link a button at those paths, do not hand-roll OAuth.\n#\n# A provider with no client id is skipped SILENTLY: the redirect returns None\n# and the button appears dead, with no error and no log. Empty means disabled.\n\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n\n# Must match the redirect URI registered in Google Cloud Console exactly.\n# Google is skipped unless BOTH the client id and this value are set.\nGOOGLE_REDIRECT_URI=\n\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET="),buildEnvSection("6. REQUEST SECURITY\n# Enforced by: main.py BodySizeLimitMiddleware, RequestDiagnosticsMiddleware",'# Max size of the whole HTTP request body in MB. Caps the entire body (file +\n# form fields + encoding overhead), so usable file size is a bit below this.\n# Middleware rejects oversized requests before the route runs. Raise if valid\n# uploads are blocked. Default 16.\nMAX_CONTENT_LENGTH_MB="16"\n\n# Seconds before a stalled route returns 504. Streaming paths (/mcp) are exempt\n# so long-lived transports are not cut mid-response. Default 20.\nCASPIAN_REQUEST_TIMEOUT_SECONDS=20'),buildEnvSection("7. SECURITY HEADERS\n# Enforced by: casp/runtime_security.py, main.py SecurityHeadersMiddleware","# Replaces the built-in Content-Security-Policy wholesale. Empty keeps the\n# default, which already permits the app's own assets.\n#\n# Any replacement MUST keep 'unsafe-eval' and 'unsafe-inline' in script-src:\n# the PulsePoint runtime compiles component templates with new Function(), so\n# removing them stops every page from rendering. Set this only to widen the\n# policy, e.g. for a CDN, analytics host, or an external frame embedder.\n#\n# Outside production, connect-src also allows http(s)/ws on localhost and\n# 127.0.0.1 on any port, because BrowserSync serves the proxied page on one port\n# while its injected live-reload client polls the BrowserSync server on another.\n# That is a separate origin, so 'self' does not cover it. Those entries are\n# omitted from a production policy. Setting an override here replaces BOTH, so\n# an override used in development must include the loopback sources itself or\n# live reload stops working.\n#\n# img-src and media-src admit remote content by scheme, so posters, avatars, CDN\n# thumbnails, and video load without per-project configuration. Plain http: is\n# development-only. Set an override here to pin them to named origins instead.\n#\n# Default: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';\n# style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;\n# media-src 'self' data: blob: https:; font-src 'self' data:;\n# connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';\n# form-action 'self'; frame-ancestors 'self'\nCONTENT_SECURITY_POLICY="),buildEnvSection("8. RATE LIMITING\n# Enforced by: main.py RateLimitMiddleware, casp/rpc.py RPCRateLimiter\n# Buckets are per client address; see TRUST_FORWARDED_HEADERS for which one.",'# Per-IP cap on page requests, applied before session decryption and rendering.\n# Static assets (/css, /js, /assets, /favicon.ico) and /health are exempt, so a\n# page load does not spend its own budget on its assets. Empty disables it.\n# Default 200/minute.\nRATE_LIMIT_PAGES=200/minute\n\n# Fallback limit for @rpc() actions that declare no limits= of their own.\nRATE_LIMIT_RPC="60 per minute"\n\n# Limit applied to @rpc(require_auth=True) actions that declare no limits=.\n# Tighten this and the per-action limits on sign-in and other credential paths.\nRATE_LIMIT_AUTH="60 per minute"\n\n# Configured for slowapi\'s Limiter. Note that slowapi\'s middleware is not in\n# the stack, so this value is inert today; page limiting is RATE_LIMIT_PAGES.\nRATE_LIMIT_DEFAULT="200 per minute"\n\n# In-memory bucket ceiling and sweep interval for the limiter store.\n# Defaults 10000 buckets, swept every 60 seconds.\nRATE_LIMIT_MAX_BUCKETS=10000\nRATE_LIMIT_CLEANUP_INTERVAL=60')),e.websocket&&o.push(buildEnvSection("9. WEBSOCKETS\n# Enforced by: src/lib/websocket/websocket_security.py, main.py channel loop\n# Only active when caspian.config.json has websocket: true.","# Browser origins allowed to open a socket (anti-CSWSH). Falls back to\n# CORS_ALLOWED_ORIGINS then APP_BASE_URL when empty.\n#\n# REQUIRED IN PRODUCTION. The convenience same-origin fallback is derived from\n# the client-supplied Host header, so it is development-only: without an\n# explicit list a spoofed Host plus matching Origin would validate itself.\n#\n# The HTTP middleware stack skips websocket scopes, so this and the socket\n# guard are the only checks a handshake passes. Comma-separated, no spaces.\nWEBSOCKET_ALLOWED_ORIGINS=\n\n# Seconds a socket may stay silent before the server closes it. Default 120.\nWEBSOCKET_IDLE_TIMEOUT_SECONDS=120\n\n# Max size of one inbound socket message in bytes. Oversized closes with 1009.\n# Default 4096.\nMAX_WEBSOCKET_MESSAGE_BYTES=4096\n\n# Simultaneous connections per pool; authenticated and guest pools are counted\n# separately. Refused connections close with 1013 during the handshake. Every\n# open socket is a live task and a broadcast target. Default 200.\nMAX_WEBSOCKET_CONNECTIONS=200\n\n# Per-connection send budget: messages allowed per rolling window. Each\n# accepted message fans out to the whole pool, so this bounds how much\n# broadcast one connection can generate. Defaults 20 per 10 seconds.\nMAX_WEBSOCKET_MESSAGES_PER_WINDOW=20\nWEBSOCKET_RATE_WINDOW_SECONDS=10")),e.mcp&&o.push(buildEnvSection("10. MCP ENDPOINT\n# Enforced by: main.py MCPAuthMiddleware; tools in src/lib/mcp/mcp_server.py\n# Only active when caspian.config.json has mcp: true.",`# Bearer token required to call /mcp. The MCP app is mounted outside the page\n# routing tree, so AuthMiddleware does NOT protect it, and its tools enumerate\n# the workspace file inventory and component map.\n#\n# generated -> every request needs "Authorization: Bearer <token>"\n#\n# REQUIRED IN PRODUCTION for the endpoint to work at all.\nMCP_AUTH_TOKEN="${i}"`)),o.push(buildEnvSection("11. CACHE\n# Enforced by: casp/cache_handler.py, main.py is_request_cacheable()",'# Master switch for serving pages from the disk cache.\n#\n# Entries are keyed on the URI alone, with no session component, so an\n# authenticated render is never cached: is_request_cacheable() gates both the\n# read and the write, and a route\'s Cache(...) cannot override it.\nCACHE_ENABLED="false"\n\n# Default cache lifetime in seconds, used when a route sets no ttl.\nCACHE_TTL="600"'),buildEnvSection("12. SERVER PROCESS\n# Enforced by: main.py __main__, settings/serve-static.py",'# Port for `python main.py`. Default 5091. The dev workflow uses BrowserSync\n# instead; its ports live in settings/bs-config.json.\n#\n# settings/serve-static.py also reads PORT, but it does not load this file, so\n# it sees only a shell value (its own default is 8000). See section 15.\nPORT=5091\n\n# Uvicorn workers are separate OS processes for the same FastAPI app. More\n# workers can increase throughput under concurrent load, but they do not make a\n# single request faster and they duplicate memory, connection pools, and any\n# in-process state. Keep at 1 unless the app is designed for multi-process\n# coordination and testing shows a real concurrency bottleneck.\nUVICORN_WORKERS="1"'),buildEnvSection("13. DEVELOPMENT TOOLING\n# Enforced by: main.py cookie scoping, casp/caspian_config.py","# Overrides the dev-only suffix appended to the session and CSRF cookie names,\n# which stops apps on different localhost ports from sharing a session.\n# Resolved from this value, then settings/bs-config.json. Ignored in production.\nCASPIAN_BROWSER_SYNC_PORT=\n\n# Overrides the detected project root used to resolve app paths. Empty uses the\n# working directory. Set only when launching from outside the project folder.\nCASPIAN_ROOT="),buildEnvSection("14. RESERVED - NOT READ BY ANY CODE\n#\n# Kept so the names stay reserved, but nothing reads them today. Changing any\n# of these has no effect. Verified against casp/**, main.py, src/**, settings/**.",`# NOT IMPLEMENTED. Setting this false does not hide anything. Error detail is\n# controlled by APP_ENV alone: client_error_message() returns the exception text\n# in development and a generic message in production, and main.py sends a\n# traceback to error.html only outside production. Change APP_ENV, not this.\nSHOW_ERRORS="true"\n\n# NOT IMPLEMENTED. RPC calls are not signed with this. Actual RPC protection is\n# the session-backed CSRF token (compared with hmac.compare_digest) plus origin\n# validation, both keyed off AUTH_SECRET. Rotating this changes nothing.\nFUNCTION_CALL_SECRET="${s}"\n\n# NOT IMPLEMENTED. No runtime file reads it; date/time helpers use the system\n# timezone. Inert unless your own code calls os.getenv("APP_TIMEZONE").\nAPP_TIMEZONE="UTC"`),buildEnvSection("15. SHELL-ONLY - NOT READ FROM THIS FILE\n#\n# Only main.py calls load_dotenv(), so .env reaches the application and Prisma.\n# The dev-toolchain scripts below read the process environment directly, so an\n# assignment here would silently do nothing. They are listed as comments on\n# purpose: an active line would be a lie. Set them per invocation instead.",'# settings/serve-static.py -- static export preview.\n# HOST bind address, default 127.0.0.1 (0.0.0.0 exposes on the network)\n# PORT preferred start port, default 8000\n# PORT_TRIES ports to try upward before giving up, default 50\n# Example: HOST=0.0.0.0 PORT=9000 npm run static:serve\n\n# settings/restart-mcp.ts -- launches the standalone MCP dev server.\n# MCP_SERVER_SPEC overrides the resolved server module spec\n# MCP_TRANSPORT transport name, default streamable-http\n# MCP_HOST bind host\n# MCP_PORT bind port\n# MCP_PATH endpoint path\n# MCP_LOG_LEVEL server log level\n# This is the separate dev server, not the /mcp endpoint mounted in main.py.\n# That one is configured by MCP_AUTH_TOKEN in section 10.\n# Example: MCP_PORT=5102 npm run mcp\n\n# NODE_ENV -- not read at runtime; vite.config.ts hardcodes "production" into\n# the browser bundle at build time.')),o.join("\n\n")}function copyRecursiveSync(e,n,t){const s=fs.existsSync(e),i=s&&fs.statSync(e);if(s&&i&&i.isDirectory()){const s=n.toLowerCase();if(!t.mcp&&s.includes("src\\lib\\mcp"))return;if(!t.websocket&&s.includes("src\\lib\\websocket"))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\ts")||s.includes("\\ts\\")))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\vite-plugins")||s.includes("\\vite-plugins\\")||s.includes("\\vite-plugins")))return;if(t.backendOnly&&s.includes("public\\js")||t.backendOnly&&s.includes("public\\css")||t.backendOnly&&s.includes("public\\assets"))return;const i=n.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),fs.readdirSync(e).forEach(s=>{copyRecursiveSync(path.join(e,s),path.join(n,s),t)})}else{if(checkExcludeFiles(n))return;const s=n.replace(/\\/g,"/").toLowerCase();if(s.endsWith("/settings/run-vite-watch.ts")&&(!t.typescript||t.backendOnly))return;if(s.endsWith("/ts/tailwind-merge.ts")&&(!t.typescript||t.backendOnly||!t.tailwindcss))return;if(!t.tailwindcss&&(n.includes("globals.css")||n.includes("styles.css")))return;if(!t.mcp&&n.includes("restart-mcp.ts"))return;if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(t.backendOnly&&nonBackendFiles.some(e=>n.includes(e)))return;if(t.backendOnly&&n.includes("layout.html"))return;if(t.tailwindcss&&n.includes("index.css"))return;if(!t.prisma&&n.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,n,0)}}async function executeCopy(e,n,t){n.forEach(({src:n,dest:s})=>{const i=normalizeTemplatePath(n),o=resolveTemplateSourcePath(n,"directory"),c=path.join(e,s);if(!o){if(OPTIONAL_TEMPLATE_DIRECTORIES.has(i))return void console.log(chalk.gray(`Optional template directory not found, skipping: ${i}`));throw new Error(`Template directory not found: ${i}. The package may be incomplete.`)}copyRecursiveSync(o,c,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.html");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),s="";n.backendOnly||(n.tailwindcss||(s='\n <link href="/css/index.css" rel="stylesheet" />'),s+='\n <script type="module" src="/js/main.js"><\/script>');let i="";n.backendOnly||(i=n.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${s}`:s),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.html:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function createNpmRcFile(e){const n=path.join(e,".npmrc");if(checkExcludeFiles(n))return;fs.writeFileSync(n,"save-exact=true\nignore-scripts=true\naudit=true\nfund=false\n\n# npm 11.10+\nmin-release-age=10080\nallow-git=none\n",{flag:"w"})}function writeTailwindMainJs(e){const n=path.join(e,"public","js","main.js");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.js";\nimport { twMerge } from "/js/tailwind-merge.mjs";\n\nconst pp = (globalThis).pp;\n\nglobalThis.twMerge = twMerge;\n\nif (document.readyState !== "loading") {\n pp?.mount?.();\n} else {\n document.addEventListener(\n "DOMContentLoaded",\n () => pp?.mount?.(),\n { once: true },\n );\n}\n',{flag:"w"}))}function copyTailwindMergeBundle(e){const n=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs"),t=path.join(e,"public","js","tailwind-merge.mjs"),s=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs.map"),i=path.join(e,"public","js","bundle-mjs.mjs.map");if(!checkExcludeFiles(t)){if(!fs.existsSync(n))throw new Error(`tailwind-merge bundle not found at ${n}`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.copyFileSync(n,t),!checkExcludeFiles(i)&&fs.existsSync(s)&&fs.copyFileSync(s,i)}}function writeTailwindTypeScriptMain(e){const n=path.join(e,"ts","main.ts");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.js";\n\n// The following global names have already been declared elsewhere in the project:\n// - pp: Used for the Reactive Core functionality.\n\n// Imports goes here --Start\nimport { createGlobalSingleton } from "./global-functions.js";\nimport { mergeTailwindClasses } from "./tailwind-merge.js";\n\ncreateGlobalSingleton("twMerge", mergeTailwindClasses);\n\n\n// Imports goes here --End\n\nconst pp = (globalThis as any).pp;\n\nif (document.readyState !== "loading") {\n\tpp?.mount?.();\n} else {\n\tdocument.addEventListener(\n\t\t"DOMContentLoaded",\n\t\t() => pp?.mount?.(),\n\t\t{ once: true },\n\t);\n}\n',{flag:"w"}))}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const n=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(n)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const t=e.replace(/\\/g,"/");return n.endsWith("/"+t)||n===t})}function normalizeTemplatePath(e){return e.replace(/^[\\/]+/,"")}function resolveTemplateSourcePath(e,n){const t=normalizeTemplatePath(e),s=[path.join(__dirname,t),path.join(PACKAGE_ROOT,t)];for(const e of s){if(!fs.existsSync(e))continue;const t=fs.statSync(e);if("file"===n&&t.isFile())return e;if("directory"===n&&t.isDirectory())return e}return null}async function createDirectoryStructure(e,n){const t=[{src:"/main.py",dest:"/main.py"},{src:"/.prettierrc",dest:"/.prettierrc"},{src:"/pyproject.toml",dest:"/pyproject.toml"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"},{src:"/AGENTS.md",dest:"/AGENTS.md"},{src:"/CLAUDE.md",dest:"/CLAUDE.md"},{src:"/.python-version",dest:"/.python-version"}];n.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),n.typescript&&!n.backendOnly&&t.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const s=[{src:"/settings",dest:"/settings"},{src:"/tests",dest:"/tests"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"},{src:"/.github",dest:"/.github"},{src:"/.vscode",dest:"/.vscode"}];n.typescript&&!n.backendOnly&&s.push({src:"/ts",dest:"/ts"}),t.forEach(({src:n,dest:t})=>{const s=normalizeTemplatePath(n),i=resolveTemplateSourcePath(n,"file"),o=path.join(e,t);if(checkExcludeFiles(o))return;if(!i){if(OPTIONAL_TEMPLATE_FILES.has(s))return void console.log(chalk.gray(`Optional template file not found, skipping: ${s}`));throw new Error(`Template file not found: ${s}. The package may be incomplete.`)}if("/pyproject.toml"===n&&updateAnswer?.isUpdate&&fs.existsSync(o))return void console.log(chalk.gray("Preserving existing pyproject.toml during update."));const c=fs.readFileSync(i,"utf8");fs.writeFileSync(o,c,{flag:"w"})}),await executeCopy(e,s,n),createNpmRcFile(e),n.tailwindcss&&!n.backendOnly&&(n.typescript?writeTailwindTypeScriptMain(e):(copyTailwindMergeBundle(e),writeTailwindMainJs(e))),await updatePackageJson(e,n),!n.tailwindcss&&n.backendOnly||modifyLayoutPHP(e,n),await createOrUpdateEnvFile(e,buildCaspianEnvContent(n))}async function getAnswer(e={},n=!1){if(n)return{projectName:e.projectName??"my-app",backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};if(e.starterKit){const n=e.starterKit;let t=null;if(STARTER_KITS[n]&&(t=STARTER_KITS[n]),t){const s={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:t.features.backendOnly??!1,tailwindcss:t.features.tailwindcss??!1,prisma:t.features.prisma??!1,mcp:t.features.mcp??!1,websocket:t.features.websocket??!1,typescript:t.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(s.backendOnly=!0),i.includes("--tailwindcss")&&(s.tailwindcss=!0),i.includes("--mcp")&&(s.mcp=!0),i.includes("--websocket")&&(s.websocket=!0),i.includes("--prisma")&&(s.prisma=!0),i.includes("--typescript")&&(s.typescript=!0),s}if(e.starterKitSource){const t={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1,typescript:!1},s=process.argv.slice(2);return s.includes("--backend-only")&&(t.backendOnly=!0),s.includes("--tailwindcss")&&(t.tailwindcss=!0),s.includes("--mcp")&&(t.mcp=!0),s.includes("--websocket")&&(t.websocket=!0),s.includes("--prisma")&&(t.prisma=!0),s.includes("--typescript")&&(t.typescript=!0),t}}const t=[];e.projectName||t.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||updateAnswer?.isUpdate||t.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const s=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(t,{onCancel:s}),o=[];i.backendOnly??e.backendOnly??!1?(e.mcp||o.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||o.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||o.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||o.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||o.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||o.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||o.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||o.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const c=await prompts(o,{onCancel:s});return{projectName:i.projectName?String(i.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:i.backendOnly??e.backendOnly??!1,tailwindcss:c.tailwindcss??e.tailwindcss??!1,typescript:c.typescript??e.typescript??!1,mcp:c.mcp??e.mcp??!1,websocket:c.websocket??e.websocket??!1,prisma:c.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,n,t=!1){console.log("Uninstalling Node dependencies:"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["uninstall",t?"--save-dev":"--save",...n]);execSync(s,{stdio:"inherit",cwd:e})}function buildManagedNpmCommand(e){return`npm ${e.join(" ")} --ignore-scripts=false --min-release-age=0 --audit=false`}function fetchPackageVersion(e){return new Promise((n,t)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let s="";e.on("data",e=>s+=e),e.on("end",()=>{try{const e=JSON.parse(s);n(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}})}).on("error",e=>t(e))})}const readJsonFile=e=>{const n=fs.readFileSync(e,"utf8");return JSON.parse(n)};function compareVersions(e,n){const t=e.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/),s=n.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);if(!t||!s)return e.localeCompare(n);const i=t.slice(1,4).map(Number),o=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>o[e])return 1;if(i[e]<o[e])return-1}const c=t[4]??null,a=s[4]??null;return c&&!a?-1:!c&&a?1:c&&a?c.localeCompare(a):0}function getInstalledPackageInfo(e){try{const n=execSync(buildManagedNpmCommand(["list","-g",e,"--depth=0"])).toString(),t=n.match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`));return t?{version:t[1],isLinked:n.includes(`${e}@`)&&n.includes("->")}:(console.error(`Package ${e} is not installed`),{version:null,isLinked:!1})}catch(e){return console.error(e instanceof Error?e.message:String(e)),{version:null,isLinked:!1}}}function isRunningFromNpxCache(e){const n=path.resolve(e).toLowerCase(),t=`${path.sep}_npx${path.sep}`.toLowerCase();return n.includes(t)}async function installNpmDependencies(e,n,t=!1){fs.existsSync(path.join(e,"package.json"))?console.log("Updating existing Node.js project..."):console.log("Initializing new Node.js project..."),fs.existsSync(path.join(e,"package.json"))||execSync(buildManagedNpmCommand(["init","-y"]),{stdio:"inherit",cwd:e}),console.log((t?"Installing development dependencies":"Installing dependencies")+":"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["install",...t?["--save-dev"]:[],...n]);execSync(s,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.3.3","@types/browser-sync":"2.29.1","@types/node":"26.1.1","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"5.6.2","chokidar-cli":"3.0.0",cssnano:"8.0.2","npm-run-all":"4.1.5",postcss:"8.5.23","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.1",typescript:"7.0.2",vite:"8.1.5","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.2.x","tailwind-merge":"3.6.0"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}function removeDirectorySafe(e){if(fs.existsSync(e))try{return void fs.rmSync(e,{recursive:!0,force:!0,maxRetries:5,retryDelay:250})}catch(n){const t=n;if("win32"===globalThis.process?.platform&&("EPERM"===t.code||"EACCES"===t.code)){try{spawnSync("cmd",["/c","attrib","-R","-H","-S","/S","/D",`${e}\\*`],{stdio:"ignore"})}catch{}return void spawnSync("cmd",["/c","rd","/s","/q",e],{stdio:"ignore"})}throw n}}async function setupStarterKit(e,n){if(!n.starterKit)return;let t=null;if(STARTER_KITS[n.starterKit]?t=STARTER_KITS[n.starterKit]:n.starterKitSource&&(t={id:n.starterKit,name:`Custom Starter Kit (${n.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:n.starterKitSource}}),t){if(console.log(chalk.green(`Setting up ${t.name}...`)),t.source)try{const s=t.source.branch?`git clone -b ${t.source.branch} --depth 1 ${t.source.url} "${e}"`:`git clone --depth 1 ${t.source.url} "${e}"`;execSync(s,{stdio:"inherit"});removeDirectorySafe(path.join(e,".git")),console.log(chalk.blue("Starter kit cloned successfully!"));const i=path.join(e,"caspian.config.json");if(fs.existsSync(i))try{const t=JSON.parse(fs.readFileSync(i,"utf8")),s=e,o=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=o.bsTarget,t.bsPathRewrite=o.bsPathRewrite;const c=await fetchPackageVersion("create-caspian-app");t.version=t.version||c,fs.writeFileSync(i,JSON.stringify(t,null,2)),console.log(chalk.green("Updated caspian.config.json with new project details"))}catch(e){console.warn(chalk.yellow("Failed to update caspian.config.json, will create new one"))}}catch(e){throw console.error(chalk.red(`Failed to setup starter kit: ${e}`)),e}t.customSetup&&await t.customSetup(e,n),console.log(chalk.green(`✓ ${t.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${n.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\n🚀 Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const n=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(n)}`)),console.log(` ${e.name}`),console.log(chalk.gray(` ${e.description}`)),e.source&&console.log(chalk.cyan(` Source: ${e.source.url}`));const t=Object.entries(e.features).filter(([,e])=>!0===e).map(([e])=>e).join(", ");t&&console.log(chalk.magenta(` Features: ${t}`)),console.log()}),console.log(chalk.yellow("Usage:")),console.log(" npx create-caspian-app my-project --starter-kit=basic"),console.log(" npx create-caspian-app my-project --starter-kit=custom --starter-kit-source=https://github.com/user/repo"),console.log()}function runCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"inherit",shell:!1,encoding:"utf8"});if(s.error)throw s.error;if(0!==s.status)throw new Error(`Command failed (${e} ${n.join(" ")}), exit=${s.status}`)}function tryRunCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"ignore",shell:!1,encoding:"utf8"});return!s.error&&0===s.status}function tryInstallUv(e){console.log(chalk.blue("uv not found. Attempting to install uv..."));const n=[{cmd:"py",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python3",args:["-m","pip","install","--upgrade","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,t.args,e))return!0;return!1}function resolveUvCommand(e){const n=[{cmd:"uv",argsPrefix:[]},{cmd:"py",argsPrefix:["-m","uv"]},{cmd:"python",argsPrefix:["-m","uv"]},{cmd:"python3",argsPrefix:["-m","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;if(tryInstallUv(e))for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;throw new Error("Could not find or install uv. Install uv and ensure `uv`, `py`, or `python` is available in PATH.")}function buildPythonDependencies(e){const n=["fastapi==0.140.0","uvicorn==0.51.0","python-dotenv==1.2.2","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.9.1","werkzeug==3.1.8","cuid2==2.0.1","nanoid==2.0.0","python-ulid==4.0.1","cuid==0.4","caspian-utils~=0.4"];return e.mcp&&n.push("fastmcp==3.4.4"),e.websocket&&n.push("websockets==16.1.1"),e.prisma&&(n.push("psycopg2-binary==2.9.12"),n.push("asyncpg==0.31.0"),n.push("aiosqlite==0.22.1"),n.push("aiomysql==0.3.2")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.15.22","pytest==9.1.1"]}function getPythonRequirementName(e){const n=e.trim().match(/^([A-Za-z0-9._-]+)/);return n?.[1]??null}function getPyProjectDependencyNames(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))return new Set;const t=fs.readFileSync(n,"utf8").replace(/\r\n/g,"\n").match(/^[ \t]*dependencies[ \t]*=[ \t]*\[([\s\S]*?)\]/m);if(!t)return new Set;const s=new Set,i=/"([^"]+)"/g;let o;for(;null!==(o=i.exec(t[1]));){const e=o[1].trim().match(/^([A-Za-z0-9._-]+)/)?.[1];e&&s.add(e.toLowerCase())}return s}function ensurePyProjectExists(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))throw new Error(`pyproject.toml not found at: ${n}`);let t=fs.readFileSync(n,"utf8");t=t.replace(/\r\n/g,"\n"),t.includes("package = false")||(t=t.includes("[tool.uv]")?t.replace("[tool.uv]","[tool.uv]\npackage = false"):`${t.trimEnd()}\n\n[tool.uv]\npackage = false\n`),fs.writeFileSync(n,t,"utf8")}async function ensurePythonVenvAndDeps(e,n,t=[]){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: syncing dependencies with uv")),console.log(chalk.green("=========================\n")),console.log(chalk.blue("Preparing pyproject.toml...")),ensurePyProjectExists(e);const s=path.join(e,"requirements.txt");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(chalk.gray("Removed legacy requirements.txt")));const i=resolveUvCommand(e),o=path.join(e,".venv");fs.existsSync(o)?console.log(chalk.blue("Existing .venv detected. Reusing it so uv sync can update dependencies without replacing the environment.")):(console.log(chalk.blue("Creating the virtual environment with uv...")),runCmd(i.cmd,[...i.argsPrefix,"venv",".venv"],e));const c=buildPythonDependencies(n),a=buildPythonDevDependencies(),r=c.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=a.map(e=>getPythonRequirementName(e)).filter(e=>null!==e);t.length>0&&(console.log(chalk.blue("Removing obsolete Python dependencies via uv remove...")),runCmd(i.cmd,[...i.argsPrefix,"remove",...t],e));const p=r.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dependencies via uv add...")),runCmd(i.cmd,[...i.argsPrefix,"add",...p,...c],e);const d=l.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dev dependencies via uv add --dev...")),runCmd(i.cmd,[...i.argsPrefix,"add","--dev",...d,...a],e),console.log(chalk.blue("Syncing dependencies...")),runCmd(i.cmd,[...i.argsPrefix,"sync"],e),console.log(chalk.green("\n✓ uv environment ready and dependencies installed.\n"))}async function main(){try{const e=process.argv.slice(2),n=e.includes("-y");let t=e[0];const s=e.find(e=>e.startsWith("--starter-kit=")),i=s?.split("=")[1],o=e.find(e=>e.startsWith("--starter-kit-source=")),c=o?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();let a=null,r=!1;if(t){const s=process.cwd(),o=path.join(s,"caspian.config.json");if(i&&c){r=!0;const s={projectName:t,starterKit:i,starterKitSource:c,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};a=await getAnswer(s,n)}else if(fs.existsSync(o)){const i=readJsonFile(o);let c=[];i.excludeFiles?.map(e=>{const n=path.join(s,e);fs.existsSync(n)&&c.push(n.replace(/\\/g,"/"))}),updateAnswer={projectName:t,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,websocket:i.websocket??!1,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:c??[],filePath:s};const r={projectName:t,backendOnly:e.includes("--backend-only")||i.backendOnly,tailwindcss:e.includes("--tailwindcss")||i.tailwindcss,typescript:e.includes("--typescript")||i.typescript,prisma:e.includes("--prisma")||i.prisma,mcp:e.includes("--mcp")||i.mcp,websocket:e.includes("--websocket")||(i.websocket??!1)};a=await getAnswer(r,n),null!==a&&(updateAnswer={projectName:t,backendOnly:a.backendOnly,tailwindcss:a.tailwindcss,mcp:a.mcp,websocket:a.websocket,prisma:a.prisma,typescript:a.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:c??[],filePath:s})}else{const s={projectName:t,starterKit:i,starterKitSource:c,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};a=await getAnswer(s,n)}if(null===a)return void console.log(chalk.red("Installation cancelled."))}else a=await getAnswer({},n);if(null===a)return void console.warn(chalk.red("Installation cancelled."));const l=await fetchPackageVersion("create-caspian-app"),p=getInstalledPackageInfo("create-caspian-app");isRunningFromNpxCache(__dirname)?console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")):p.isLinked?console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")):p.version?-1===compareVersions(p.version,l)&&(execSync(buildManagedNpmCommand(["uninstall","-g","create-caspian-app"]),{stdio:"inherit"}),execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"})):execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"});const d=process.cwd();let u;if(t)if(r){const n=path.join(d,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,await setupStarterKit(u,a),process.chdir(u);const s=path.join(u,"caspian.config.json");if(fs.existsSync(s)){const n=JSON.parse(fs.readFileSync(s,"utf8"));e.includes("--backend-only")&&(n.backendOnly=!0),e.includes("--tailwindcss")&&(n.tailwindcss=!0),e.includes("--typescript")&&(n.typescript=!0),e.includes("--mcp")&&(n.mcp=!0),e.includes("--websocket")&&(n.websocket=!0),e.includes("--prisma")&&(n.prisma=!0),a={...a,backendOnly:n.backendOnly,tailwindcss:n.tailwindcss,typescript:n.typescript,mcp:n.mcp,websocket:n.websocket??!1,prisma:n.prisma};let t=[];n.excludeFiles?.map(e=>{const n=path.join(u,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...a,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:u}}}else{const e=path.join(d,"caspian.config.json"),n=path.join(d,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?u=d:fs.existsSync(n)&&fs.existsSync(s)?(u=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,process.chdir(n))}else fs.mkdirSync(a.projectName,{recursive:!0}),u=path.join(d,a.projectName),process.chdir(a.projectName);let m=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("@lezer/common"),npmPkg("@lezer/python"),npmPkg("caspian-utils")];a.prisma&&m.push(npmPkg("prompts"),npmPkg("@types/prompts")),a.tailwindcss&&m.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),a.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),a.typescript&&!a.backendOnly&&m.push(npmPkg("vite"),npmPkg("fast-glob")),a.starterKit&&!r&&await setupStarterKit(u,a),await installNpmDependencies(u,m,!0);let h=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(u,a),a.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(u,"package.json");if(fs.existsSync(n)){const t=JSON.parse(fs.readFileSync(n,"utf8"));return!!(t.dependencies&&t.dependencies[e]||t.devDependencies&&t.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(u,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(u,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const o=path.join(u,"ts","tailwind-merge.ts");fs.existsSync(o)&&(fs.unlinkSync(o),console.log(`${o} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(a.tailwindcss){const e=path.join(u,"public","css","index.css");if(fs.existsSync(e))try{fs.unlinkSync(e),console.log(`${e} was deleted successfully.`)}catch(n){console.warn(chalk.yellow(`Failed to delete ${e}: ${n}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const n=path.join(u,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(u,"src","lib","mcp");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("MCP folder was deleted successfully.")),n.push("fastmcp")}if(!updateAnswer.websocket){const e=path.join(u,"src","lib","websocket");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("WebSocket folder was deleted successfully.")),n.push("websockets")}if(!updateAnswer.prisma){["prisma","@prisma/client","@prisma/internals","better-sqlite3","@prisma/adapter-better-sqlite3","mariadb","@prisma/adapter-mariadb","pg","@prisma/adapter-pg","@types/pg"].forEach(n=>{t(n)&&e.push(n)}),n.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts",path.join("settings","run-vite-watch.ts")].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(u,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(u,"settings","vite-plugins");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(n=>{t(n)&&e.push(n)})}const s=e=>Array.from(new Set(e)),i=s(e);i.length>0&&(console.log(`Uninstalling npm packages: ${i.join(", ")}`),await uninstallNpmDependencies(u,i,!0));const o=s(n),c=getPyProjectDependencyNames(u);h=o.filter(e=>c.has(e.toLowerCase())),h.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${h.join(", ")}`))}if(!r||!fs.existsSync(path.join(u,"caspian.config.json"))){const e=u.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:a.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:a.backendOnly,tailwindcss:a.tailwindcss,mcp:a.mcp,websocket:a.websocket,prisma:a.prisma,typescript:a.typescript,version:l,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(u,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(u,a,h),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(u.replace(/\\/g,"/"))}!`),console.log("\n=========================")}catch(e){console.error("Error while creating the project:",e),process.exit(1)}}main();
2
+ import{execSync,spawnSync}from"child_process";import fs from"fs";import{fileURLToPath}from"url";import path from"path";import chalk from"chalk";import prompts from"prompts";import https from"https";import{randomBytes}from"crypto";const __filename=fileURLToPath(import.meta.url),__dirname=path.dirname(__filename),PACKAGE_ROOT=path.resolve(__dirname,".."),OPTIONAL_TEMPLATE_FILES=new Set([".python-version",".prettierrc"]),OPTIONAL_TEMPLATE_DIRECTORIES=new Set([".github",".vscode"]),CASPIAN_SECTION_START="\x3c!-- caspian:start --\x3e",CASPIAN_SECTION_END="\x3c!-- caspian:end --\x3e";let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.html","not-found.html","error.html"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.html","src/app/index.html"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.html","src/app/index.html","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py","pyproject.toml"]},realtime:{id:"realtime",name:"Real-time Application",description:"Application with WebSocket support and MCP",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!0,websocket:!0},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const n=e.indexOf("\\htdocs\\");if(-1===n)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,n+8).replace(/\\/g,"\\\\"),s=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let i=`http://localhost/${s}`;i=i.endsWith("/")?i.slice(0,-1):i;const o=i.replace(/(?<!:)(\/\/+)/g,"/"),c=s.replace(/\/\/+/g,"/");return{bsTarget:`${o}/`,bsPathRewrite:{"^/":`/${c.startsWith("/")?c.substring(1):c}/`}}}async function updatePackageJson(e,n){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const s=JSON.parse(fs.readFileSync(t,"utf8"));s.scripts={...s.scripts,projectName:"tsx settings/project-name.ts",check:"uv run python settings/check.py","check:fix":"uv run python settings/fix.py",logs:"uv run python settings/browser_log.py",static:"npm run build && uv run python settings/build-static.py","static:serve":"uv run python settings/serve-static.py"};let i=[];n.tailwindcss&&(s.scripts={...s.scripts,tailwind:"tsx settings/run-postcss.ts watch","tailwind:build":"tsx settings/run-postcss.ts build"},i.push("tailwind")),n.typescript&&!n.backendOnly&&(s.scripts={...s.scripts,"ts:watch":"vite build --watch","ts:watch:dev":"tsx settings/run-vite-watch.ts","ts:build":"vite build"},i.push("ts:watch:dev")),n.mcp&&(s.scripts={...s.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let o={...s.scripts};o.browserSync="tsx settings/bs-config.ts",o.dev=`npm-run-all projectName -l -p browserSync ${i.join(" ")}`;let c=["projectName"];n.tailwindcss&&c.unshift("tailwind:build"),n.typescript&&!n.backendOnly&&c.unshift("ts:build"),o.build=`npm-run-all ${c.join(" ")}`,s.scripts=o,s.type="module",fs.writeFileSync(t,JSON.stringify(s,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function buildEnvSection(e,n){return`# =============================================================================\n${e.split("\n").map(e=>e.startsWith("#")?e:`# ${e}`).join("\n")}\n# =============================================================================\n\n${n.trimEnd()}`}function buildCaspianEnvContent(e){const n=generateAuthSecret(),t=generateHexEncodedKey(8),s=generateHexEncodedKey(32),i=generateHexEncodedKey(32),o=[];return e.prisma&&o.push(buildEnvSection("1. DATABASE\n# Enforced by: prisma/schema.prisma, src/lib/prisma/db.py",'# Connection string. Prisma reads this directly from .env.\n# Format reference: https://pris.ly/d/connection-strings\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"\n\n# Connection-pool limit. Defaults: SQLite 5; MySQL and PostgreSQL 20.\n# Use 5 for local development; production does not need this unless you want to\n# limit the pool.\nDB_POOL_SIZE=5\n\n# Seconds idle before the client re-probes its connection. Default 30.\nPRISMA_CONN_PROBE_IDLE_SECONDS=30\n\n# Warn on queries that cause a full table scan. 0/false silences it. Default 1.\nPRISMA_WARN_FULL_SCAN=1')),o.push(buildEnvSection("2. APPLICATION RUNTIME\n# Enforced by: casp/runtime_security.py is_production_environment()",'# Environment selector, resolved FAIL-CLOSED: only an explicit development\n# value (dev, development, local, staging, test, testing) enables the\n# development relaxations. Unset or misspelled counts as production.\n#\n# Production turns on: HTTPS-only session cookie, Secure CSRF cookie, HSTS,\n# generic error messages, mandatory AUTH_SECRET, mandatory MCP_AUTH_TOKEN, and\n# it removes the localhost origin bypass and the WebSocket same-origin fallback.\n#\n# This single value gates most of the security posture. Set it deliberately.\nAPP_ENV="development"'),buildEnvSection("3. PUBLIC URL, CORS, AND ORIGIN VALIDATION\n# Enforced by: casp/rpc.py origin checks, main.py CORS layer",'# Canonical public origin. Leave empty when the browser URL and the app runtime\n# URL match. Set it when they differ, i.e. behind an ingress, reverse proxy,\n# load balancer, gateway, edge network, or TLS terminator.\nAPP_BASE_URL=""\n\n# Extra browser origins allowed to call protected endpoints such as RPC. Use\n# when one deployment is reachable from more than one public origin.\n# Comma-separated, no spaces.\nCORS_ALLOWED_ORIGINS=""\n\n# Trust Forwarded/X-Forwarded-* headers. Enable ONLY when every request passes\n# through infrastructure that strips client-supplied forwarded headers before\n# setting its own, because a direct client can otherwise forge them.\n#\n# Affects two things: which origin RPC accepts, and which address the rate\n# limiter buckets on. Left false, both use the direct request instead.\nTRUST_FORWARDED_HEADERS="false"\n\n# Allow cookies/Authorization on cross-origin requests. Keep true only when\n# credentialed cross-origin requests are actually required.\nCORS_ALLOW_CREDENTIALS="true"\n\n# CORS preflight response fields.\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\n\n# Preflight cache duration in seconds.\nCORS_MAX_AGE="86400"'),buildEnvSection("4. AUTHENTICATION AND SESSIONS\n# Enforced by: casp/auth.py, main.py SessionMiddleware\n# Route privacy and RBAC live in src/lib/auth/auth_config.py, not here.",`# Session signing secret. Unique and strong per app and per environment.\n# In production the app refuses to start when this is missing or left on a\n# placeholder ("change-me"/"changeme"); in development it falls back.\nAUTH_SECRET="${n}"\n\n# Session cookie name. Use a unique value when several apps share a parent\n# domain, or their sessions overwrite each other.\nAUTH_COOKIE_NAME="${t}"\n\n# Session lifetime in hours (SessionMiddleware max_age).\nSESSION_LIFETIME_HOURS="7"`),buildEnvSection("5. OAUTH PROVIDERS\n# Enforced by: casp/auth.py; routes served by main.py AuthMiddleware","# Google and GitHub sign-in are already wired: AuthMiddleware serves\n# /api/auth/signin/{google,github} and /api/auth/callback/{google,github}.\n# Link a button at those paths, do not hand-roll OAuth.\n#\n# A provider with no client id is skipped SILENTLY: the redirect returns None\n# and the button appears dead, with no error and no log. Empty means disabled.\n\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n\n# Must match the redirect URI registered in Google Cloud Console exactly.\n# Google is skipped unless BOTH the client id and this value are set.\nGOOGLE_REDIRECT_URI=\n\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET="),buildEnvSection("6. REQUEST SECURITY\n# Enforced by: main.py BodySizeLimitMiddleware, RequestDiagnosticsMiddleware",'# Max size of the whole HTTP request body in MB. Caps the entire body (file +\n# form fields + encoding overhead), so usable file size is a bit below this.\n# Middleware rejects oversized requests before the route runs. Raise if valid\n# uploads are blocked. Default 16.\nMAX_CONTENT_LENGTH_MB="16"\n\n# Seconds before a stalled route returns 504. Streaming paths (/mcp) are exempt\n# so long-lived transports are not cut mid-response. Default 20.\nCASPIAN_REQUEST_TIMEOUT_SECONDS=20'),buildEnvSection("7. SECURITY HEADERS\n# Enforced by: casp/runtime_security.py, main.py SecurityHeadersMiddleware","# Replaces the built-in Content-Security-Policy wholesale. Empty keeps the\n# default, which already permits the app's own assets.\n#\n# Any replacement MUST keep 'unsafe-eval' and 'unsafe-inline' in script-src:\n# the PulsePoint runtime compiles component templates with new Function(), so\n# removing them stops every page from rendering. Set this only to widen the\n# policy, e.g. for a CDN, analytics host, or an external frame embedder.\n#\n# Outside production, connect-src also allows http(s)/ws on localhost and\n# 127.0.0.1 on any port, because BrowserSync serves the proxied page on one port\n# while its injected live-reload client polls the BrowserSync server on another.\n# That is a separate origin, so 'self' does not cover it. Those entries are\n# omitted from a production policy. Setting an override here replaces BOTH, so\n# an override used in development must include the loopback sources itself or\n# live reload stops working.\n#\n# img-src and media-src admit remote content by scheme, so posters, avatars, CDN\n# thumbnails, and video load without per-project configuration. Plain http: is\n# development-only. Set an override here to pin them to named origins instead.\n#\n# Default: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';\n# style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;\n# media-src 'self' data: blob: https:; font-src 'self' data:;\n# connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';\n# form-action 'self'; frame-ancestors 'self'\nCONTENT_SECURITY_POLICY="),buildEnvSection("8. RATE LIMITING\n# Enforced by: main.py RateLimitMiddleware, casp/rpc.py RPCRateLimiter\n# Buckets are per client address; see TRUST_FORWARDED_HEADERS for which one.",'# Per-IP cap on page requests, applied before session decryption and rendering.\n# Static assets (/css, /js, /assets, /favicon.ico) and /health are exempt, so a\n# page load does not spend its own budget on its assets. Empty disables it.\n# Default 200/minute.\nRATE_LIMIT_PAGES=200/minute\n\n# Fallback limit for @rpc() actions that declare no limits= of their own.\nRATE_LIMIT_RPC="60 per minute"\n\n# Limit applied to @rpc(require_auth=True) actions that declare no limits=.\n# Tighten this and the per-action limits on sign-in and other credential paths.\nRATE_LIMIT_AUTH="60 per minute"\n\n# Configured for slowapi\'s Limiter. Note that slowapi\'s middleware is not in\n# the stack, so this value is inert today; page limiting is RATE_LIMIT_PAGES.\nRATE_LIMIT_DEFAULT="200 per minute"\n\n# In-memory bucket ceiling and sweep interval for the limiter store.\n# Defaults 10000 buckets, swept every 60 seconds.\nRATE_LIMIT_MAX_BUCKETS=10000\nRATE_LIMIT_CLEANUP_INTERVAL=60')),e.websocket&&o.push(buildEnvSection("9. WEBSOCKETS\n# Enforced by: src/lib/websocket/websocket_security.py, main.py channel loop\n# Only active when caspian.config.json has websocket: true.","# Browser origins allowed to open a socket (anti-CSWSH). Falls back to\n# CORS_ALLOWED_ORIGINS then APP_BASE_URL when empty.\n#\n# REQUIRED IN PRODUCTION. The convenience same-origin fallback is derived from\n# the client-supplied Host header, so it is development-only: without an\n# explicit list a spoofed Host plus matching Origin would validate itself.\n#\n# The HTTP middleware stack skips websocket scopes, so this and the socket\n# guard are the only checks a handshake passes. Comma-separated, no spaces.\nWEBSOCKET_ALLOWED_ORIGINS=\n\n# Seconds a socket may stay silent before the server closes it. Default 120.\nWEBSOCKET_IDLE_TIMEOUT_SECONDS=120\n\n# Max size of one inbound socket message in bytes. Oversized closes with 1009.\n# Default 4096.\nMAX_WEBSOCKET_MESSAGE_BYTES=4096\n\n# Simultaneous connections per pool; authenticated and guest pools are counted\n# separately. Refused connections close with 1013 during the handshake. Every\n# open socket is a live task and a broadcast target. Default 200.\nMAX_WEBSOCKET_CONNECTIONS=200\n\n# Per-connection send budget: messages allowed per rolling window. Each\n# accepted message fans out to the whole pool, so this bounds how much\n# broadcast one connection can generate. Defaults 20 per 10 seconds.\nMAX_WEBSOCKET_MESSAGES_PER_WINDOW=20\nWEBSOCKET_RATE_WINDOW_SECONDS=10")),e.mcp&&o.push(buildEnvSection("10. MCP ENDPOINT\n# Enforced by: main.py MCPAuthMiddleware; tools in src/lib/mcp/mcp_server.py\n# Only active when caspian.config.json has mcp: true.",`# Bearer token required to call /mcp. The MCP app is mounted outside the page\n# routing tree, so AuthMiddleware does NOT protect it, and its tools enumerate\n# the workspace file inventory and component map.\n#\n# generated -> every request needs "Authorization: Bearer <token>"\n#\n# REQUIRED IN PRODUCTION for the endpoint to work at all.\nMCP_AUTH_TOKEN="${i}"`)),o.push(buildEnvSection("11. CACHE\n# Enforced by: casp/cache_handler.py, main.py is_request_cacheable()",'# Master switch for serving pages from the disk cache.\n#\n# Entries are keyed on the URI alone, with no session component, so an\n# authenticated render is never cached: is_request_cacheable() gates both the\n# read and the write, and a route\'s Cache(...) cannot override it.\nCACHE_ENABLED="false"\n\n# Default cache lifetime in seconds, used when a route sets no ttl.\nCACHE_TTL="600"'),buildEnvSection("12. SERVER PROCESS\n# Enforced by: main.py __main__, settings/serve-static.py",'# Port for `python main.py`. Default 5091. The dev workflow uses BrowserSync\n# instead; its ports live in settings/bs-config.json.\n#\n# settings/serve-static.py also reads PORT, but it does not load this file, so\n# it sees only a shell value (its own default is 8000). See section 15.\nPORT=5091\n\n# Uvicorn workers are separate OS processes for the same FastAPI app. More\n# workers can increase throughput under concurrent load, but they do not make a\n# single request faster and they duplicate memory, connection pools, and any\n# in-process state. Keep at 1 unless the app is designed for multi-process\n# coordination and testing shows a real concurrency bottleneck.\nUVICORN_WORKERS="1"'),buildEnvSection("13. DEVELOPMENT TOOLING\n# Enforced by: main.py cookie scoping, casp/caspian_config.py","# Overrides the dev-only suffix appended to the session and CSRF cookie names,\n# which stops apps on different localhost ports from sharing a session.\n# Resolved from this value, then settings/bs-config.json. Ignored in production.\nCASPIAN_BROWSER_SYNC_PORT=\n\n# Overrides the detected project root used to resolve app paths. Empty uses the\n# working directory. Set only when launching from outside the project folder.\nCASPIAN_ROOT="),buildEnvSection("14. RESERVED - NOT READ BY ANY CODE\n#\n# Kept so the names stay reserved, but nothing reads them today. Changing any\n# of these has no effect. Verified against casp/**, main.py, src/**, settings/**.",`# NOT IMPLEMENTED. Setting this false does not hide anything. Error detail is\n# controlled by APP_ENV alone: client_error_message() returns the exception text\n# in development and a generic message in production, and main.py sends a\n# traceback to error.html only outside production. Change APP_ENV, not this.\nSHOW_ERRORS="true"\n\n# NOT IMPLEMENTED. RPC calls are not signed with this. Actual RPC protection is\n# the session-backed CSRF token (compared with hmac.compare_digest) plus origin\n# validation, both keyed off AUTH_SECRET. Rotating this changes nothing.\nFUNCTION_CALL_SECRET="${s}"\n\n# NOT IMPLEMENTED. No runtime file reads it; date/time helpers use the system\n# timezone. Inert unless your own code calls os.getenv("APP_TIMEZONE").\nAPP_TIMEZONE="UTC"`),buildEnvSection("15. SHELL-ONLY - NOT READ FROM THIS FILE\n#\n# Only main.py calls load_dotenv(), so .env reaches the application and Prisma.\n# The dev-toolchain scripts below read the process environment directly, so an\n# assignment here would silently do nothing. They are listed as comments on\n# purpose: an active line would be a lie. Set them per invocation instead.",'# settings/serve-static.py -- static export preview.\n# HOST bind address, default 127.0.0.1 (0.0.0.0 exposes on the network)\n# PORT preferred start port, default 8000\n# PORT_TRIES ports to try upward before giving up, default 50\n# Example: HOST=0.0.0.0 PORT=9000 npm run static:serve\n\n# settings/restart-mcp.ts -- launches the standalone MCP dev server.\n# MCP_SERVER_SPEC overrides the resolved server module spec\n# MCP_TRANSPORT transport name, default streamable-http\n# MCP_HOST bind host\n# MCP_PORT bind port\n# MCP_PATH endpoint path\n# MCP_LOG_LEVEL server log level\n# This is the separate dev server, not the /mcp endpoint mounted in main.py.\n# That one is configured by MCP_AUTH_TOKEN in section 10.\n# Example: MCP_PORT=5102 npm run mcp\n\n# NODE_ENV -- not read at runtime; vite.config.ts hardcodes "production" into\n# the browser bundle at build time.')),o.join("\n\n")}function copyRecursiveSync(e,n,t){const s=fs.existsSync(e),i=s&&fs.statSync(e);if(s&&i&&i.isDirectory()){const s=n.toLowerCase();if(!t.mcp&&s.includes("src\\lib\\mcp"))return;if(!t.websocket&&s.includes("src\\lib\\websocket"))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\ts")||s.includes("\\ts\\")))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\vite-plugins")||s.includes("\\vite-plugins\\")||s.includes("\\vite-plugins")))return;if(t.backendOnly&&s.includes("public\\js")||t.backendOnly&&s.includes("public\\css")||t.backendOnly&&s.includes("public\\assets"))return;const i=n.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),fs.readdirSync(e).forEach(s=>{copyRecursiveSync(path.join(e,s),path.join(n,s),t)})}else{if(checkExcludeFiles(n))return;const s=n.replace(/\\/g,"/").toLowerCase();if(s.endsWith("/settings/run-vite-watch.ts")&&(!t.typescript||t.backendOnly))return;if(s.endsWith("/ts/tailwind-merge.ts")&&(!t.typescript||t.backendOnly||!t.tailwindcss))return;if(!t.tailwindcss&&(n.includes("globals.css")||n.includes("styles.css")))return;if(!t.mcp&&n.includes("restart-mcp.ts"))return;if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(t.backendOnly&&nonBackendFiles.some(e=>n.includes(e)))return;if(t.backendOnly&&n.includes("layout.html"))return;if(t.tailwindcss&&n.includes("index.css"))return;if(!t.prisma&&n.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,n,0)}}async function executeCopy(e,n,t){n.forEach(({src:n,dest:s})=>{const i=normalizeTemplatePath(n),o=resolveTemplateSourcePath(n,"directory"),c=path.join(e,s);if(!o){if(OPTIONAL_TEMPLATE_DIRECTORIES.has(i))return void console.log(chalk.gray(`Optional template directory not found, skipping: ${i}`));throw new Error(`Template directory not found: ${i}. The package may be incomplete.`)}copyRecursiveSync(o,c,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.html");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),s="";n.backendOnly||(n.tailwindcss||(s='\n <link href="/css/index.css" rel="stylesheet" />'),s+='\n <script type="module" src="/js/main.js"><\/script>');let i="";n.backendOnly||(i=n.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${s}`:s),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.html:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function createNpmRcFile(e){const n=path.join(e,".npmrc");if(checkExcludeFiles(n))return;fs.writeFileSync(n,"save-exact=true\nignore-scripts=true\naudit=true\nfund=false\n\n# npm 11.10+\nmin-release-age=10080\nallow-git=none\n",{flag:"w"})}function writeTailwindMainJs(e){const n=path.join(e,"public","js","main.js");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.js";\nimport { twMerge } from "/js/tailwind-merge.mjs";\n\nconst pp = (globalThis).pp;\n\nglobalThis.twMerge = twMerge;\n\nif (document.readyState !== "loading") {\n pp?.mount?.();\n} else {\n document.addEventListener(\n "DOMContentLoaded",\n () => pp?.mount?.(),\n { once: true },\n );\n}\n',{flag:"w"}))}function copyTailwindMergeBundle(e){const n=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs"),t=path.join(e,"public","js","tailwind-merge.mjs"),s=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs.map"),i=path.join(e,"public","js","bundle-mjs.mjs.map");if(!checkExcludeFiles(t)){if(!fs.existsSync(n))throw new Error(`tailwind-merge bundle not found at ${n}`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.copyFileSync(n,t),!checkExcludeFiles(i)&&fs.existsSync(s)&&fs.copyFileSync(s,i)}}function writeTailwindTypeScriptMain(e){const n=path.join(e,"ts","main.ts");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.js";\n\n// The following global names have already been declared elsewhere in the project:\n// - pp: Used for the Reactive Core functionality.\n\n// Imports goes here --Start\nimport { createGlobalSingleton } from "./global-functions.js";\nimport { mergeTailwindClasses } from "./tailwind-merge.js";\n\ncreateGlobalSingleton("twMerge", mergeTailwindClasses);\n\n\n// Imports goes here --End\n\nconst pp = (globalThis as any).pp;\n\nif (document.readyState !== "loading") {\n\tpp?.mount?.();\n} else {\n\tdocument.addEventListener(\n\t\t"DOMContentLoaded",\n\t\t() => pp?.mount?.(),\n\t\t{ once: true },\n\t);\n}\n',{flag:"w"}))}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const n=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(n)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const t=e.replace(/\\/g,"/");return n.endsWith("/"+t)||n===t})}function normalizeTemplatePath(e){return e.replace(/^[\\/]+/,"")}function resolveTemplateSourcePath(e,n){const t=normalizeTemplatePath(e),s=[path.join(__dirname,t),path.join(PACKAGE_ROOT,t)];for(const e of s){if(!fs.existsSync(e))continue;const t=fs.statSync(e);if("file"===n&&t.isFile())return e;if("directory"===n&&t.isDirectory())return e}return null}function extractCaspianSection(e){const n=e.indexOf(CASPIAN_SECTION_START);if(-1===n)return null;const t=e.indexOf(CASPIAN_SECTION_END,n);return-1===t?null:e.slice(t>n?n:0,t+20)}function mergeAgentsCaspianSection(e,n){const t=extractCaspianSection(n);if(!t)return e;const s=e.indexOf(CASPIAN_SECTION_START),i=e.indexOf(CASPIAN_SECTION_END,s);if(-1!==s&&-1!==i){return`${e.slice(0,s)}${t}${e.slice(i+20)}`}const o=e.endsWith("\n");return`${e}${o?"\n":"\n\n"}${t}\n`}async function createDirectoryStructure(e,n){const t=[{src:"/main.py",dest:"/main.py"},{src:"/.prettierrc",dest:"/.prettierrc"},{src:"/pyproject.toml",dest:"/pyproject.toml"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"},{src:"/AGENTS.md",dest:"/AGENTS.md"},{src:"/CLAUDE.md",dest:"/CLAUDE.md"},{src:"/.python-version",dest:"/.python-version"}];n.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),n.typescript&&!n.backendOnly&&t.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const s=[{src:"/settings",dest:"/settings"},{src:"/tests",dest:"/tests"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"},{src:"/.github",dest:"/.github"},{src:"/.vscode",dest:"/.vscode"}];n.typescript&&!n.backendOnly&&s.push({src:"/ts",dest:"/ts"}),t.forEach(({src:n,dest:t})=>{const s=normalizeTemplatePath(n),i=resolveTemplateSourcePath(n,"file"),o=path.join(e,t);if(checkExcludeFiles(o))return;if(!i){if(OPTIONAL_TEMPLATE_FILES.has(s))return void console.log(chalk.gray(`Optional template file not found, skipping: ${s}`));throw new Error(`Template file not found: ${s}. The package may be incomplete.`)}if("/pyproject.toml"===n&&updateAnswer?.isUpdate&&fs.existsSync(o))return void console.log(chalk.gray("Preserving existing pyproject.toml during update."));const c=fs.readFileSync(i,"utf8");if("/AGENTS.md"===n&&updateAnswer?.isUpdate&&fs.existsSync(o)){const e=mergeAgentsCaspianSection(fs.readFileSync(o,"utf8"),c);return void fs.writeFileSync(o,e,{flag:"w"})}fs.writeFileSync(o,c,{flag:"w"})}),await executeCopy(e,s,n),createNpmRcFile(e),n.tailwindcss&&!n.backendOnly&&(n.typescript?writeTailwindTypeScriptMain(e):(copyTailwindMergeBundle(e),writeTailwindMainJs(e))),await updatePackageJson(e,n),!n.tailwindcss&&n.backendOnly||modifyLayoutPHP(e,n),await createOrUpdateEnvFile(e,buildCaspianEnvContent(n))}async function getAnswer(e={},n=!1){if(n)return{projectName:e.projectName??"my-app",backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};if(e.starterKit){const n=e.starterKit;let t=null;if(STARTER_KITS[n]&&(t=STARTER_KITS[n]),t){const s={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:t.features.backendOnly??!1,tailwindcss:t.features.tailwindcss??!1,prisma:t.features.prisma??!1,mcp:t.features.mcp??!1,websocket:t.features.websocket??!1,typescript:t.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(s.backendOnly=!0),i.includes("--tailwindcss")&&(s.tailwindcss=!0),i.includes("--mcp")&&(s.mcp=!0),i.includes("--websocket")&&(s.websocket=!0),i.includes("--prisma")&&(s.prisma=!0),i.includes("--typescript")&&(s.typescript=!0),s}if(e.starterKitSource){const t={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1,typescript:!1},s=process.argv.slice(2);return s.includes("--backend-only")&&(t.backendOnly=!0),s.includes("--tailwindcss")&&(t.tailwindcss=!0),s.includes("--mcp")&&(t.mcp=!0),s.includes("--websocket")&&(t.websocket=!0),s.includes("--prisma")&&(t.prisma=!0),s.includes("--typescript")&&(t.typescript=!0),t}}const t=[];e.projectName||t.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||updateAnswer?.isUpdate||t.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const s=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(t,{onCancel:s}),o=[];i.backendOnly??e.backendOnly??!1?(e.mcp||o.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||o.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||o.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||o.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||o.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||o.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||o.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||o.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const c=await prompts(o,{onCancel:s});return{projectName:i.projectName?String(i.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:i.backendOnly??e.backendOnly??!1,tailwindcss:c.tailwindcss??e.tailwindcss??!1,typescript:c.typescript??e.typescript??!1,mcp:c.mcp??e.mcp??!1,websocket:c.websocket??e.websocket??!1,prisma:c.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,n,t=!1){console.log("Uninstalling Node dependencies:"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["uninstall",t?"--save-dev":"--save",...n]);execSync(s,{stdio:"inherit",cwd:e})}function buildManagedNpmCommand(e){return`npm ${e.join(" ")} --ignore-scripts=false --min-release-age=0 --audit=false`}function fetchPackageVersion(e){return new Promise((n,t)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let s="";e.on("data",e=>s+=e),e.on("end",()=>{try{const e=JSON.parse(s);n(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}})}).on("error",e=>t(e))})}const readJsonFile=e=>{const n=fs.readFileSync(e,"utf8");return JSON.parse(n)};function compareVersions(e,n){const t=e.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/),s=n.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);if(!t||!s)return e.localeCompare(n);const i=t.slice(1,4).map(Number),o=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>o[e])return 1;if(i[e]<o[e])return-1}const c=t[4]??null,a=s[4]??null;return c&&!a?-1:!c&&a?1:c&&a?c.localeCompare(a):0}function getInstalledPackageInfo(e){try{const n=execSync(buildManagedNpmCommand(["list","-g",e,"--depth=0"])).toString(),t=n.match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`));return t?{version:t[1],isLinked:n.includes(`${e}@`)&&n.includes("->")}:(console.error(`Package ${e} is not installed`),{version:null,isLinked:!1})}catch(e){return console.error(e instanceof Error?e.message:String(e)),{version:null,isLinked:!1}}}function isRunningFromNpxCache(e){const n=path.resolve(e).toLowerCase(),t=`${path.sep}_npx${path.sep}`.toLowerCase();return n.includes(t)}async function installNpmDependencies(e,n,t=!1){fs.existsSync(path.join(e,"package.json"))?console.log("Updating existing Node.js project..."):console.log("Initializing new Node.js project..."),fs.existsSync(path.join(e,"package.json"))||execSync(buildManagedNpmCommand(["init","-y"]),{stdio:"inherit",cwd:e}),console.log((t?"Installing development dependencies":"Installing dependencies")+":"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["install",...t?["--save-dev"]:[],...n]);execSync(s,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.3.3","@types/browser-sync":"2.29.1","@types/node":"26.1.1","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"5.6.2","chokidar-cli":"3.0.0",cssnano:"8.0.2","npm-run-all":"4.1.5",postcss:"8.5.23","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.1",typescript:"7.0.2",vite:"8.1.5","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.2.x","tailwind-merge":"3.6.0"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}function removeDirectorySafe(e){if(fs.existsSync(e))try{return void fs.rmSync(e,{recursive:!0,force:!0,maxRetries:5,retryDelay:250})}catch(n){const t=n;if("win32"===globalThis.process?.platform&&("EPERM"===t.code||"EACCES"===t.code)){try{spawnSync("cmd",["/c","attrib","-R","-H","-S","/S","/D",`${e}\\*`],{stdio:"ignore"})}catch{}return void spawnSync("cmd",["/c","rd","/s","/q",e],{stdio:"ignore"})}throw n}}async function setupStarterKit(e,n){if(!n.starterKit)return;let t=null;if(STARTER_KITS[n.starterKit]?t=STARTER_KITS[n.starterKit]:n.starterKitSource&&(t={id:n.starterKit,name:`Custom Starter Kit (${n.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:n.starterKitSource}}),t){if(console.log(chalk.green(`Setting up ${t.name}...`)),t.source)try{const s=t.source.branch?`git clone -b ${t.source.branch} --depth 1 ${t.source.url} "${e}"`:`git clone --depth 1 ${t.source.url} "${e}"`;execSync(s,{stdio:"inherit"});removeDirectorySafe(path.join(e,".git")),console.log(chalk.blue("Starter kit cloned successfully!"));const i=path.join(e,"caspian.config.json");if(fs.existsSync(i))try{const t=JSON.parse(fs.readFileSync(i,"utf8")),s=e,o=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=o.bsTarget,t.bsPathRewrite=o.bsPathRewrite;const c=await fetchPackageVersion("create-caspian-app");t.version=t.version||c,fs.writeFileSync(i,JSON.stringify(t,null,2)),console.log(chalk.green("Updated caspian.config.json with new project details"))}catch(e){console.warn(chalk.yellow("Failed to update caspian.config.json, will create new one"))}}catch(e){throw console.error(chalk.red(`Failed to setup starter kit: ${e}`)),e}t.customSetup&&await t.customSetup(e,n),console.log(chalk.green(`✓ ${t.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${n.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\n🚀 Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const n=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(n)}`)),console.log(` ${e.name}`),console.log(chalk.gray(` ${e.description}`)),e.source&&console.log(chalk.cyan(` Source: ${e.source.url}`));const t=Object.entries(e.features).filter(([,e])=>!0===e).map(([e])=>e).join(", ");t&&console.log(chalk.magenta(` Features: ${t}`)),console.log()}),console.log(chalk.yellow("Usage:")),console.log(" npx create-caspian-app my-project --starter-kit=basic"),console.log(" npx create-caspian-app my-project --starter-kit=custom --starter-kit-source=https://github.com/user/repo"),console.log()}function runCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"inherit",shell:!1,encoding:"utf8"});if(s.error)throw s.error;if(0!==s.status)throw new Error(`Command failed (${e} ${n.join(" ")}), exit=${s.status}`)}function tryRunCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"ignore",shell:!1,encoding:"utf8"});return!s.error&&0===s.status}function tryInstallUv(e){console.log(chalk.blue("uv not found. Attempting to install uv..."));const n=[{cmd:"py",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python3",args:["-m","pip","install","--upgrade","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,t.args,e))return!0;return!1}function resolveUvCommand(e){const n=[{cmd:"uv",argsPrefix:[]},{cmd:"py",argsPrefix:["-m","uv"]},{cmd:"python",argsPrefix:["-m","uv"]},{cmd:"python3",argsPrefix:["-m","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;if(tryInstallUv(e))for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;throw new Error("Could not find or install uv. Install uv and ensure `uv`, `py`, or `python` is available in PATH.")}function buildPythonDependencies(e){const n=["fastapi==0.140.0","uvicorn==0.51.0","python-dotenv==1.2.2","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.9.1","werkzeug==3.1.8","cuid2==2.0.1","nanoid==2.0.0","python-ulid==4.0.1","cuid==0.4","caspian-utils~=0.4"];return e.mcp&&n.push("fastmcp==3.4.4"),e.websocket&&n.push("websockets==16.1.1"),e.prisma&&(n.push("psycopg2-binary==2.9.12"),n.push("asyncpg==0.31.0"),n.push("aiosqlite==0.22.1"),n.push("aiomysql==0.3.2")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.15.22","pytest==9.1.1"]}function getPythonRequirementName(e){const n=e.trim().match(/^([A-Za-z0-9._-]+)/);return n?.[1]??null}function getPyProjectDependencyNames(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))return new Set;const t=fs.readFileSync(n,"utf8").replace(/\r\n/g,"\n").match(/^[ \t]*dependencies[ \t]*=[ \t]*\[([\s\S]*?)\]/m);if(!t)return new Set;const s=new Set,i=/"([^"]+)"/g;let o;for(;null!==(o=i.exec(t[1]));){const e=o[1].trim().match(/^([A-Za-z0-9._-]+)/)?.[1];e&&s.add(e.toLowerCase())}return s}function ensurePyProjectExists(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))throw new Error(`pyproject.toml not found at: ${n}`);let t=fs.readFileSync(n,"utf8");t=t.replace(/\r\n/g,"\n"),t.includes("package = false")||(t=t.includes("[tool.uv]")?t.replace("[tool.uv]","[tool.uv]\npackage = false"):`${t.trimEnd()}\n\n[tool.uv]\npackage = false\n`),fs.writeFileSync(n,t,"utf8")}async function ensurePythonVenvAndDeps(e,n,t=[]){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: syncing dependencies with uv")),console.log(chalk.green("=========================\n")),console.log(chalk.blue("Preparing pyproject.toml...")),ensurePyProjectExists(e);const s=path.join(e,"requirements.txt");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(chalk.gray("Removed legacy requirements.txt")));const i=resolveUvCommand(e),o=path.join(e,".venv");fs.existsSync(o)?console.log(chalk.blue("Existing .venv detected. Reusing it so uv sync can update dependencies without replacing the environment.")):(console.log(chalk.blue("Creating the virtual environment with uv...")),runCmd(i.cmd,[...i.argsPrefix,"venv",".venv"],e));const c=buildPythonDependencies(n),a=buildPythonDevDependencies(),r=c.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=a.map(e=>getPythonRequirementName(e)).filter(e=>null!==e);t.length>0&&(console.log(chalk.blue("Removing obsolete Python dependencies via uv remove...")),runCmd(i.cmd,[...i.argsPrefix,"remove",...t],e));const p=r.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dependencies via uv add...")),runCmd(i.cmd,[...i.argsPrefix,"add",...p,...c],e);const d=l.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dev dependencies via uv add --dev...")),runCmd(i.cmd,[...i.argsPrefix,"add","--dev",...d,...a],e),console.log(chalk.blue("Syncing dependencies...")),runCmd(i.cmd,[...i.argsPrefix,"sync"],e),console.log(chalk.green("\n✓ uv environment ready and dependencies installed.\n"))}async function main(){try{const e=process.argv.slice(2),n=e.includes("-y");let t=e[0];const s=e.find(e=>e.startsWith("--starter-kit=")),i=s?.split("=")[1],o=e.find(e=>e.startsWith("--starter-kit-source=")),c=o?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();let a=null,r=!1;if(t){const s=process.cwd(),o=path.join(s,"caspian.config.json");if(i&&c){r=!0;const s={projectName:t,starterKit:i,starterKitSource:c,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};a=await getAnswer(s,n)}else if(fs.existsSync(o)){const i=readJsonFile(o);let c=[];i.excludeFiles?.map(e=>{const n=path.join(s,e);fs.existsSync(n)&&c.push(n.replace(/\\/g,"/"))}),updateAnswer={projectName:t,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,websocket:i.websocket??!1,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:c??[],filePath:s};const r={projectName:t,backendOnly:e.includes("--backend-only")||i.backendOnly,tailwindcss:e.includes("--tailwindcss")||i.tailwindcss,typescript:e.includes("--typescript")||i.typescript,prisma:e.includes("--prisma")||i.prisma,mcp:e.includes("--mcp")||i.mcp,websocket:e.includes("--websocket")||(i.websocket??!1)};a=await getAnswer(r,n),null!==a&&(updateAnswer={projectName:t,backendOnly:a.backendOnly,tailwindcss:a.tailwindcss,mcp:a.mcp,websocket:a.websocket,prisma:a.prisma,typescript:a.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:c??[],filePath:s})}else{const s={projectName:t,starterKit:i,starterKitSource:c,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};a=await getAnswer(s,n)}if(null===a)return void console.log(chalk.red("Installation cancelled."))}else a=await getAnswer({},n);if(null===a)return void console.warn(chalk.red("Installation cancelled."));const l=await fetchPackageVersion("create-caspian-app"),p=getInstalledPackageInfo("create-caspian-app");isRunningFromNpxCache(__dirname)?console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")):p.isLinked?console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")):p.version?-1===compareVersions(p.version,l)&&(execSync(buildManagedNpmCommand(["uninstall","-g","create-caspian-app"]),{stdio:"inherit"}),execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"})):execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"});const d=process.cwd();let u;if(t)if(r){const n=path.join(d,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,await setupStarterKit(u,a),process.chdir(u);const s=path.join(u,"caspian.config.json");if(fs.existsSync(s)){const n=JSON.parse(fs.readFileSync(s,"utf8"));e.includes("--backend-only")&&(n.backendOnly=!0),e.includes("--tailwindcss")&&(n.tailwindcss=!0),e.includes("--typescript")&&(n.typescript=!0),e.includes("--mcp")&&(n.mcp=!0),e.includes("--websocket")&&(n.websocket=!0),e.includes("--prisma")&&(n.prisma=!0),a={...a,backendOnly:n.backendOnly,tailwindcss:n.tailwindcss,typescript:n.typescript,mcp:n.mcp,websocket:n.websocket??!1,prisma:n.prisma};let t=[];n.excludeFiles?.map(e=>{const n=path.join(u,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...a,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:u}}}else{const e=path.join(d,"caspian.config.json"),n=path.join(d,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?u=d:fs.existsSync(n)&&fs.existsSync(s)?(u=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,process.chdir(n))}else fs.mkdirSync(a.projectName,{recursive:!0}),u=path.join(d,a.projectName),process.chdir(a.projectName);let m=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("@lezer/common"),npmPkg("@lezer/python"),npmPkg("caspian-utils")];a.prisma&&m.push(npmPkg("prompts"),npmPkg("@types/prompts")),a.tailwindcss&&m.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),a.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),a.typescript&&!a.backendOnly&&m.push(npmPkg("vite"),npmPkg("fast-glob")),a.starterKit&&!r&&await setupStarterKit(u,a),await installNpmDependencies(u,m,!0);let h=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(u,a),a.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(u,"package.json");if(fs.existsSync(n)){const t=JSON.parse(fs.readFileSync(n,"utf8"));return!!(t.dependencies&&t.dependencies[e]||t.devDependencies&&t.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(u,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(u,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const o=path.join(u,"ts","tailwind-merge.ts");fs.existsSync(o)&&(fs.unlinkSync(o),console.log(`${o} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(a.tailwindcss){const e=path.join(u,"public","css","index.css");if(fs.existsSync(e))try{fs.unlinkSync(e),console.log(`${e} was deleted successfully.`)}catch(n){console.warn(chalk.yellow(`Failed to delete ${e}: ${n}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const n=path.join(u,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(u,"src","lib","mcp");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("MCP folder was deleted successfully.")),n.push("fastmcp")}if(!updateAnswer.websocket){const e=path.join(u,"src","lib","websocket");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("WebSocket folder was deleted successfully.")),n.push("websockets")}if(!updateAnswer.prisma){["prisma","@prisma/client","@prisma/internals","better-sqlite3","@prisma/adapter-better-sqlite3","mariadb","@prisma/adapter-mariadb","pg","@prisma/adapter-pg","@types/pg"].forEach(n=>{t(n)&&e.push(n)}),n.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts",path.join("settings","run-vite-watch.ts")].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(u,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(u,"settings","vite-plugins");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(n=>{t(n)&&e.push(n)})}const s=e=>Array.from(new Set(e)),i=s(e);i.length>0&&(console.log(`Uninstalling npm packages: ${i.join(", ")}`),await uninstallNpmDependencies(u,i,!0));const o=s(n),c=getPyProjectDependencyNames(u);h=o.filter(e=>c.has(e.toLowerCase())),h.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${h.join(", ")}`))}if(!r||!fs.existsSync(path.join(u,"caspian.config.json"))){const e=u.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:a.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:a.backendOnly,tailwindcss:a.tailwindcss,mcp:a.mcp,websocket:a.websocket,prisma:a.prisma,typescript:a.typescript,version:l,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(u,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(u,a,h),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(u.replace(/\\/g,"/"))}!`),console.log("\n=========================")}catch(e){console.error("Error while creating the project:",e),process.exit(1)}}main();
package/dist/main.py CHANGED
@@ -26,7 +26,6 @@ from fastapi import (
26
26
  )
27
27
  from fastapi.responses import (
28
28
  RedirectResponse,
29
- FileResponse,
30
29
  HTMLResponse,
31
30
  JSONResponse,
32
31
  )
@@ -68,7 +67,8 @@ from casp.runtime_security import (
68
67
  client_error_message,
69
68
  get_session_secret,
70
69
  is_production_environment,
71
- public_file_response,
70
+ PublicFilesMiddleware,
71
+ resolve_safe_public_path,
72
72
  )
73
73
  from contextlib import (
74
74
  asynccontextmanager,
@@ -347,7 +347,6 @@ REQUEST_TIMEOUT_SECONDS = max(
347
347
  STREAMING_PATH_PREFIXES = ('/mcp',)
348
348
  # Public assets: exempt from auth, request logging, and rate limiting, since one
349
349
  # page load pulls many of them.
350
- STATIC_PATH_PREFIXES = ('/css/', '/js/', '/assets/', '/favicon.ico')
351
350
  MAX_CONTENT_LENGTH_BYTES = max(1, MAX_CONTENT_LENGTH_MB) * 1024 * 1024
352
351
 
353
352
 
@@ -410,49 +409,6 @@ SESSION_COOKIE_NAME = _scoped_cookie_name(
410
409
  os.getenv('AUTH_COOKIE_NAME', 'session')
411
410
  )
412
411
 
413
- # ====
414
- # Static File Routes
415
- # ====
416
-
417
-
418
- @app.get('/css/{filename:path}')
419
- async def serve_css(filename: str):
420
- return public_file_response('public/css', filename, media_type='text/css')
421
-
422
-
423
- @app.get('/js/{filename:path}')
424
- async def serve_js(filename: str):
425
- return public_file_response(
426
- 'public/js',
427
- filename,
428
- media_type='application/javascript',
429
- )
430
-
431
-
432
- @app.get('/assets/{filename:path}')
433
- async def serve_assets(filename: str):
434
- return public_file_response('public/assets', filename)
435
-
436
-
437
- @app.get('/uploads/{filename:path}')
438
- async def serve_uploads(filename: str):
439
- # User-supplied content served from the app's own origin: only real images
440
- # render inline, everything else downloads. An uploaded .html or .svg would
441
- # otherwise be stored XSS with full access to the session cookie.
442
- return public_file_response(
443
- 'public/uploads',
444
- filename,
445
- inline_safe_media_types=INLINE_SAFE_UPLOAD_MEDIA_TYPES,
446
- )
447
-
448
-
449
- @app.get('/favicon.ico')
450
- async def favicon():
451
- file_path = Path('public/favicon.ico')
452
- if not file_path.exists():
453
- return Response(status_code=404)
454
- return FileResponse(file_path, media_type='image/x-icon')
455
-
456
412
  # ====
457
413
  # Pure ASGI Middleware Classes
458
414
  # ====
@@ -468,13 +424,6 @@ class CSRFMiddleware:
468
424
  await self.app(scope, receive, send)
469
425
  return
470
426
 
471
- # Static assets carry no session and never issue RPC calls. Attaching a
472
- # Set-Cookie to them makes every asset response uncacheable by shared
473
- # caches and CDNs for no security benefit.
474
- if scope.get("path", "").startswith(STATIC_PATH_PREFIXES):
475
- await self.app(scope, receive, send)
476
- return
477
-
478
427
  request = Request(scope, receive, send)
479
428
  csrf_token = request.session.get("csrf_token")
480
429
  if not csrf_token:
@@ -588,9 +537,6 @@ class AuthMiddleware:
588
537
  return
589
538
  request = Request(scope, receive, send)
590
539
  path = request.url.path
591
- if path.startswith(STATIC_PATH_PREFIXES):
592
- await self.app(scope, receive, send)
593
- return
594
540
  StateManager.init(request)
595
541
  Auth.set_request(request)
596
542
  auth_inst = Auth.get_instance()
@@ -699,7 +645,7 @@ class RateLimitMiddleware:
699
645
  return
700
646
 
701
647
  path = scope.get("path", "")
702
- if path.startswith(STATIC_PATH_PREFIXES) or path == '/health':
648
+ if path == '/health':
703
649
  await self.app(scope, receive, send)
704
650
  return
705
651
 
@@ -735,7 +681,14 @@ class RequestDiagnosticsMiddleware:
735
681
 
736
682
  method = scope.get("method", "GET")
737
683
  path = scope.get("path", "")
738
- should_log = not path.startswith(STATIC_PATH_PREFIXES)
684
+ is_public_file = (
685
+ method in {"GET", "HEAD"}
686
+ and resolve_safe_public_path(
687
+ 'public',
688
+ path.lstrip('/'),
689
+ ) is not None
690
+ )
691
+ should_log = not is_public_file
739
692
  started = time.perf_counter()
740
693
 
741
694
  if should_log and not IS_PRODUCTION:
@@ -1511,6 +1464,16 @@ app.add_middleware(BodySizeLimitMiddleware)
1511
1464
  # Outermost of the security layers: reject flooding before any session
1512
1465
  # decryption, template rendering, or database work is spent on the request.
1513
1466
  app.add_middleware(RateLimitMiddleware)
1467
+ # The public directory is itself the URL contract: any existing nested file is
1468
+ # served without adding a directory-specific route above. Keep upload content
1469
+ # in attachment mode unless its MIME type is explicitly safe to render inline.
1470
+ app.add_middleware(
1471
+ PublicFilesMiddleware,
1472
+ directory='public',
1473
+ inline_safe_subdirectories={
1474
+ 'uploads': INLINE_SAFE_UPLOAD_MEDIA_TYPES,
1475
+ },
1476
+ )
1514
1477
  app.add_middleware(SecurityHeadersMiddleware)
1515
1478
 
1516
1479
  if not IS_PRODUCTION:
@@ -55,10 +55,6 @@ from casp.caspian_config import get_files_index # noqa: E402
55
55
  OUT_DIR = ROOT / "static"
56
56
  PUBLIC_DIR = ROOT / "public"
57
57
 
58
- # Public asset trees to mirror into static/ (source name -> output name).
59
- ASSET_DIRS = ("css", "js", "assets", "uploads")
60
- ASSET_FILES = ("favicon.ico",)
61
-
62
58
  GREEN = "\033[32m"
63
59
  YELLOW = "\033[33m"
64
60
  RED = "\033[31m"
@@ -175,17 +171,9 @@ def _reset_out_dir(out_dir: Path, attempts: int = 5, delay: float = 0.4) -> bool
175
171
 
176
172
 
177
173
  def _copy_assets() -> None:
178
- for name in ASSET_DIRS:
179
- src = PUBLIC_DIR / name
180
- if src.is_dir():
181
- dst = OUT_DIR / name
182
- shutil.copytree(src, dst, dirs_exist_ok=True)
183
- print(f"{DIM} copied public/{name}/ -> static/{name}/{RESET}")
184
- for name in ASSET_FILES:
185
- src = PUBLIC_DIR / name
186
- if src.is_file():
187
- shutil.copy2(src, OUT_DIR / name)
188
- print(f"{DIM} copied public/{name} -> static/{name}{RESET}")
174
+ if PUBLIC_DIR.is_dir():
175
+ shutil.copytree(PUBLIC_DIR, OUT_DIR, dirs_exist_ok=True)
176
+ print(f"{DIM} copied public/** -> static/{RESET}")
189
177
 
190
178
 
191
179
  def build() -> int:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",