create-caspian-app 1.3.26 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -326
- package/dist/.github/copilot-instructions.md +16 -10
- package/dist/AGENTS.md +28 -3
- package/dist/index.js +1 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -1
- package/dist/src/app/error.py +22 -39
- package/dist/src/app/not_found.py +3 -7
- package/dist/src/lib/auth/auth_config.py +0 -4
- package/dist/src/lib/websocket/sockets.py +15 -38
- package/package.json +1 -1
|
@@ -32,7 +32,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
32
32
|
- When `.github/instructions/**/*.instructions.md` files exist, treat them as workspace-local file instructions for specific libraries, component systems, icon sets, integrations, and implementation rules. Read the matching instruction before deciding how to implement work in that area, but do not let it override `caspian.config.json`, app code, or installed runtime behavior.
|
|
33
33
|
- Treat `node_modules/caspian-utils/dist/docs/**` as packaged Caspian docs that teach AI how Caspian features work and where to look next. Their presence does not mean the feature is enabled in the current project.
|
|
34
34
|
- Use `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` for fast PulsePoint feature lookup before editing browser-side behavior or generating advanced PulsePoint patterns.
|
|
35
|
-
- Use `node_modules/caspian-utils/dist/docs/websockets.md` before changing
|
|
35
|
+
- Use `node_modules/caspian-utils/dist/docs/websockets.md` before changing `@socket()` handlers, `pp.socket(...)` clients, the shared socket endpoint, socket origin checks, socket auth/RBAC, broadcast pools, or a fallback native `WebSocket` client.
|
|
36
36
|
- For current repo behavior, trust `main.py`, `src/lib/**`, `public/js/**`, `prisma/**`, and `src/app/**` over generic Caspian docs.
|
|
37
37
|
- For framework internals, trust `.venv/Lib/site-packages/casp/**` over generic or older upstream guidance.
|
|
38
38
|
- When packaged docs conflict with project code or installed runtime, the project code, `caspian.config.json`, and installed runtime win. Keep the packaged docs feature-oriented and point AI back to the project files that decide actual enablement and behavior.
|
|
@@ -44,7 +44,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
44
44
|
- This workspace supports static HTML export (SSG, like Next.js `output: export`) as an app-owned build convention, not a shipped Caspian feature and not gated by a `caspian.config.json` flag. `npm run static` runs `npm run build && uv run python settings/build-static.py`; keep it composed on `npm run build` (Tailwind **plus** `projectName`) so `settings/files-list.json` is regenerated before `settings/build-static.py` walks that route index — do not reduce it to `tailwind:build` only, or a newly added route/component can be exported from a stale index. `npm run static:serve` runs `settings/serve-static.py`, which serves only `static/`, binds loopback `127.0.0.1` by default, and auto-selects a free port by walking upward from a preferred default (8000, overridable via `PORT`; `HOST`/`PORT_TRIES` also apply) so an occupied port never aborts the preview. Read the port the serve command prints; do not assume 8000 or read `settings/bs-config.json` for it (that file is the dev BrowserSync source of truth, not the static preview). Pre-render a dynamic route by exporting `static_paths` from its `index.py`; auth-gated, non-200, and non-HTML routes are skipped by design. Warn users that `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`.
|
|
45
45
|
- Use `npm run build` for deployment prep or an explicit build request, not as the default validation step for routine route, feature, or documentation edits.
|
|
46
46
|
- **This workspace has an app-level quality gate, and running it is mandatory — not optional.** Any time you create, edit, or delete app-owned Python (`main.py`, `src/**`) — whether fixing a bug, adding a new file, refactoring, or implementing a feature — you must run `npm run check` (which calls `uv run python settings/check.py`) and get it fully green before treating the change as done. **A change is not complete while the gate reports anything.** It type checks with `pyright`, lints with `ruff`, and runs `pytest` in one pass, prints each problem as `path:line:col [tool:code] message`, and exits non-zero; fix every reported location and re-run until it passes clean. Do not report work as finished, hand it back, or move on to the next task on the assumption that it passes — actually run it and confirm green output first. Write app-owned Python to pass type checking (annotate parameters and returns, avoid untyped `Any` drift) and add or extend tests in `tests/` for the behavior you change. See `### tests/**/*.py and settings/check.py`.
|
|
47
|
-
- **Know the gate's boundary: `npm run check` validates Python only** (`pyright` + `ruff` + `pytest` over `main.py`, `src/**`, and `settings/*.py`). It does **not** validate
|
|
47
|
+
- **Know the gate's boundary: `npm run check` validates Python only** (`pyright` + `ruff` + `pytest` over `main.py`, `src/**`, and `settings/*.py`). It does **not** validate authored markup (the templates inside `index.py`, `layout.py`, and component `.py` files), `globals.css`/Tailwind, or `public/js/**` — a broken template, an invalid `x-*` tag, a single-root violation, or a PulsePoint error will pass the gate and only surface at render time. So a green gate means "the Python is sound," not "the page works." When you change templates, components, styles, or browser JS, verify them by actually loading the affected route in the browser (use the BrowserSync URL from `./settings/bs-config.json`) and checking it renders without console errors — do not treat a passing `npm run check` as proof that front-end work is correct.
|
|
48
48
|
- **To see browser-side errors, run `npm run logs` — never start a second `npm run dev`.** The dev terminal usually belongs to the developer, so its stdout is invisible to you. `npm run dev` starts by deleting `.casp/` and `caches/`, so launching your own copy corrupts the running server's state, takes different ports, and rewrites `settings/bs-config.json`. Instead, PulsePoint's browser errors are appended to `.casp/browser-log.jsonl` and rendered by `npm run logs` (also printed at the end of every `npm run check`, where it never affects the exit code). Read its verdicts literally: `CLEAN` means that route was opened and rendered without error; a route **absent from the listing was never opened**, which is no signal rather than a pass; and `dev server is NOT running` means the entries are leftover history, not current state. After a fix, get the route exercised again and re-run `npm run logs`; the flip to `CLEAN` is your confirmation. **What counts as exercising it depends on the status:** a mount error is cleared by a reload, but `NEEDS RECHECK` means an error thrown from an event handler, which only re-running that interaction (click/submit) can clear — a reload never will, and must not be reported as if it did. **Never diagnose from the raw `.casp/browser-log.jsonl`:** it is append-only history, so errors fixed minutes ago are still on disk and will send you hunting a bug that no longer exists — any later `load` or `resolved` event for the same route makes them historical. `UNCONFIRMED` means an error with no matching load in this log (a tab left open across a dev restart); ask for a reload before treating it as live. Details in `AGENTS.md`.
|
|
49
49
|
- Let the running dev stack own generated outputs such as `public/css/styles.css`, `settings/component-map.json`, `settings/files-list.json`, `__pycache__/`, and `.pyc` files. Treat those as generated artifacts rather than authored source.
|
|
50
50
|
- Never treat `__pycache__/` directories or `.pyc` files as files to edit, regenerate on purpose, or keep in the final diff.
|
|
@@ -74,13 +74,15 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
74
74
|
- For CRUD operations and any browser-initiated reads from the backend, use route or backend `@rpc()` actions on the server and `pp.rpc(...)` from PulsePoint code on the client unless the user explicitly asks for another integration pattern.
|
|
75
75
|
- Google and GitHub OAuth ship pre-registered in this starter: `main.py` already calls `Auth.set_providers(GithubProvider(), GoogleProvider())`, and `AuthMiddleware` already handles the `signin/{google,github}` and `callback/{google,github}` paths under `api_auth_prefix` (default `/api/auth`). To add social sign-in, point a link or button at `/api/auth/signin/google` or `/api/auth/signin/github` and set the provider credentials in `.env` (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`). Do not hand-roll an OAuth flow, manual `httpx2`/`authlib` token exchange, or custom callback routes; reuse the shipped providers and let `auth.auth_providers(...)` own redirect, callback, and sign-in.
|
|
76
76
|
- For one-way streaming output, including AI/LLM/chat token streams, use Caspian's shipped RPC streaming: write a generator `@rpc()` action that `yield`s chunks (the runtime wraps generators as SSE via `casp.streaming.SSE`) and consume it with `pp.rpc(name, args, { onStream, onStreamComplete, onStreamError })`. When bridging a Python LLM/SDK stream, `async for` over the provider's stream inside the `@rpc()` action and `yield` each token. Do not reinvent one-way streaming with raw `fetch`/`ReadableStream`, `EventSource`, or a WebSocket; reserve WebSockets for genuinely bidirectional channels per the WebSocket rules above.
|
|
77
|
-
- For live bidirectional channels, first confirm `caspian.config.json` has `websocket: true`, then use
|
|
78
|
-
- When `caspian.config.json` has `websocket: true`,
|
|
77
|
+
- For live bidirectional channels, first confirm `caspian.config.json` has `websocket: true`, then use the **named-socket layer**: one `@socket()` function in Python (route-owned in that route's `index.py`, or `src/lib/**` when shared) consumed by `pp.socket(name, args, handlers)` in the owning component script. A hand-written `@app.websocket(...)` endpoint plus a native browser `WebSocket` is the escape hatch for wires the JSON-frame contract cannot carry (binary frames, non-JSON protocols), and then the app owns the origin check, auth delegation, and every limit itself. Do not replace normal CRUD, form submits, uploads, or one-way progress streams with WebSockets.
|
|
78
|
+
- When `caspian.config.json` has `websocket: true`, every named socket shares **one** endpoint — `SOCKET_PATH` in `src/lib/websocket/sockets.py`, wired once in `main.py` — and the function is named in a query parameter, so socket names are application-wide and a route never declares its own path. `pp.socket(...)` already knows that endpoint, so do not pass `websocket_path`/`websocket_url` into templates. Keep the socket layer itself under `src/lib/websocket/**`.
|
|
79
79
|
- For route creation, every route is one `src/app/**/index.py`: `page()` returns the page markup via `html(...)`, and the same module owns metadata, `@rpc()` actions, auth checks, caching, and redirects. Shared section wrappers live in `layout.py`, whose `layout()` returns the wrapper template (optionally with a props dict). Non-visual routes (redirect-only or action-only) are `index.py` files whose `page()` returns a `Response`.
|
|
80
80
|
- Keep route-specific logic in that route's `index.py`. Move code into `src/lib/**` only when it is genuinely reusable across routes, components, integrations, or features; do not extract one-route orchestration just to make it look generic.
|
|
81
81
|
- Write every authored route, layout, and component template with one parent HTML element or one imported `x-*` component tag as its root, and keep any `<script>` inside that root rather than after it. A component **may** instead have sibling top-level nodes: that is a fragment (the `<>…</>` shape), the compiler frames it with a `<!--pp:id-->…<!--/pp-->` comment pair, and the browser materializes it into `<pp-fragment style="display: contents">` at mount — so it adds no element to the DOM and is the only shape that survives inside `<tbody>`/`<select>`. **A fragment cannot receive props**: passing any attribute (including `pp-ref`) on its `<x-*>` tag raises `FragmentPropsError`, because there is no root element for forwarding to land on. Use a single native root whenever the component takes props. A `.py` route or layout with sibling top-level nodes is instead wrapped in a layout-neutral `<div pp-component style="display: contents">` boundary host, so use plain siblings when a wrapper `<div>` would be meaningless. The same host appears around a component whose authored root is another `x-*` tag, carrying the parent's forwarded props — an extra `display: contents` div in rendered DOM is expected output. Fragment syntax is implicit: never hand-write `<pp-fragment>` or `<!--pp:…-->` markers, which are compiler output.
|
|
82
82
|
- When the user asks for a dashboard, admin area, account area, or any grouped child-route section, follow the same mental model as the Next.js App Router: create a parent folder with `layout.py` and place the child routes beneath it. Use a normal folder such as `dashboard/` when the segment should appear in the URL, and use `(group)/` only when it should not.
|
|
83
83
|
- 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.
|
|
84
|
+
- **`loading.py` is optional — but when navigation loading UI is wanted, it is the mechanism, not a hand-built spinner.** Most subtrees have no loader and navigate with a plain fade; that is correct default behavior, so do not create `loading.py` files that were not asked for. When a task _does_ ask for a loading state, skeleton, or progress indicator _while moving from one route to another_, the answer is a `loading.py` in the closest subtree folder plus `pp-loading-content="true"` on the pane it should replace in that subtree's `layout.py`. Never hand-roll it with a spinner component, a global `isLoading` store, a `pp:navigation:start`/`pp:navigation:complete` listener, a manual overlay, or a `fetch`-driven page swap: `casp/loading.py` already collects the files, `caspian_config.py` derives each one's URL scope from its folder, and the browser runtime resolves the closest ancestor scope and runs the swap and fade. Contract: `def loading():` is **synchronous and takes no parameters** (an `async def` raises `TypeError`, a missing function raises `AttributeError`); scope is folder-derived, with `(group)` segments stripped (so a loader placed directly inside `(marketing)/` becomes the app-wide `/` fallback, not the group's) and a `[id]` folder never matching a real URL (so dynamic routes put their loader on the static parent); the markup is collected once and injected as raw HTML, so Jinja `{{ }}` interpolates but `<x-*>` tags, `{ }` bindings, and `<script>` do **not** work inside it — plain elements and CSS only; and `pp-loading-transition='{"fadeIn":…,"fadeOut":…}'` inside the loader overrides the 250 ms default. Copy the shipped pair: `src/app/dashboard/loading.py` with the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`.
|
|
85
|
+
- Do not route an **in-page** wait through `loading.py`. An `@rpc()` call, a form submit, a filter refetch, or an upload is ordinary `pp.state` in the owning component, bound to `hidden`/class — `loading.py` only ever fires on route-to-route SPA navigation, never on first paint, a hard refresh, or a static export.
|
|
84
86
|
- When a single route needs to affect a wrapping layout, have `page()` return `(html(...), {"dashboard_body_class": ...})` and consume that value as `{{ layout.dashboard_body_class }}` in the wrapping layout template. Return the prop from `layout()` when the same value should apply across a whole subtree.
|
|
85
87
|
- 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.
|
|
86
88
|
- `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.
|
|
@@ -109,9 +111,9 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
109
111
|
|
|
110
112
|
- Treat `main.py` as the repo source of truth for FastAPI setup, auth bootstrap, middleware wiring, route registration, cache defaults, and error handlers.
|
|
111
113
|
- `main.py` finalizes every rendered page through `defer_component_roots(...)`. It wraps each outermost `pp-component` root in an inert `<template pp-component>` so the browser never parses raw `{...}` placeholders as live DOM. Before PulsePoint materializes those roots, it captures and empties their plain component scripts so native browser execution cannot race component-scope evaluation; later morph insertions use the same protection. 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.
|
|
112
|
-
-
|
|
114
|
+
- `main.py` owns only the wiring of the single named-socket endpoint (`@app.websocket(SOCKET_PATH)`, gated on `websocket: true`). Origin validation, the connection cap, auth delegation, idle timeout, message-size limit, per-connection message rate, the error-frame-then-close behavior, and broadcast (`SocketSender`/`SocketPool`) all live in `src/lib/websocket/sockets.py` — verify there, not in `main.py`.
|
|
113
115
|
- 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.
|
|
114
|
-
- 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`;
|
|
116
|
+
- Preserve the production effective middleware execution order unless the task explicitly changes request semantics: `SecurityHeadersMiddleware -> PublicFilesMiddleware -> RateLimitMiddleware -> MissingPublicAssetMiddleware -> BodySizeLimitMiddleware -> SessionMiddleware -> CSRFMiddleware -> AuthMiddleware -> RPCMiddleware -> route`. In development, `RequestDiagnosticsMiddleware` is outermost. Existing public-file `GET`/`HEAD` requests stop at `PublicFilesMiddleware`; a miss whose first path segment is a real `public/` directory is 404'd by `MissingPublicAssetMiddleware` (so a bad asset URL never answers with a sign-in redirect), and every other miss falls through the remaining stack.
|
|
115
117
|
- 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/**`.
|
|
116
118
|
- Document route param behavior exactly as implemented here.
|
|
117
119
|
- Do not use `main.py` alone to infer whether optional features are enabled; confirm that in `caspian.config.json` first.
|
|
@@ -186,8 +188,11 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
186
188
|
- Preserve the current public runtime contract unless the task explicitly changes Caspian frontend behavior.
|
|
187
189
|
- At runtime, component logic is discovered from a plain, untyped `<script>` inside each `pp-component` root. PulsePoint captures the source before materialization or morph insertion, prevents native execution, and evaluates it in component scope.
|
|
188
190
|
- The current SPA scroll contract is: save scroll positions per history entry, reset window scroll on push navigation, and use `pp-reset-scroll="true"` to opt specific containers into reset behavior. Use `body[pp-reset-scroll="true"]` only when a target route should reset every scrollable surface.
|
|
191
|
+
- The current SPA loading contract is: the server embeds every `src/app/**/loading.py` in a hidden registry, one `div[pp-loading-url="<scope>"]` per file; on navigation the runtime walks the destination pathname up toward `/` for the closest matching scope, then replaces the `[pp-loading-content="true"]` element (falling back to `document.body`) with that markup between a fade-out and fade-in. With no matching loader it fades the same region without swapping content, which is the expected behavior for the many routes that have none. Do not add a parallel loading mechanism in app code; if a loader is wanted, add a `loading.py`.
|
|
189
192
|
|
|
190
|
-
### `src/app/**/*.
|
|
193
|
+
### `src/app/**/*.py` (route and layout templates)
|
|
194
|
+
|
|
195
|
+
- **These rules govern the markup inside `index.py` and `layout.py`.** Authoring is Python-only: there are no `.html` route, layout, or component files in a Caspian app, so a rule scoped to authored markup applies to the triple-quoted template inside the `.py` file that owns it.
|
|
191
196
|
|
|
192
197
|
- Compose pages from components first (see "Component-First Page Composition"). Keep the page template a short assembly of `x-*` chunk components (top menu, sidebar, content sections, cards, forms, footer, and other repeated blocks) instead of a long inline HTML body. When a route would carry a long stretch of markup, move that markup into a single-file `html(...)` component and render it as an `x-*` tag here.
|
|
193
198
|
- Keep route templates and layouts server-rendered first, with PulsePoint enhancement as the default interactive layer.
|
|
@@ -199,11 +204,12 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
199
204
|
- Preserve standard Jinja template syntax such as `{{ ... }}` in layouts and `pp-*` runtime attributes in rendered HTML.
|
|
200
205
|
- Do not author `pp-component="..."` manually in route or layout templates; the Python render pipeline injects it onto the single root element.
|
|
201
206
|
- Use a plain `<script>` inside the single route or layout root when it owns PulsePoint logic; no custom script type is required.
|
|
202
|
-
- Default authored route and layout templates to one top-level parent node, the same shape used for component templates. In source, that parent may be a native HTML element or a single imported `x-*` component tag. If a script is needed, keep it inside that parent instead of as a sibling top-level node. A **component** that breaks this
|
|
207
|
+
- Default authored route and layout templates to one top-level parent node, the same shape used for component templates. In source, that parent may be a native HTML element or a single imported `x-*` component tag. If a script is needed, keep it inside that parent instead of as a sibling top-level node. A **component** that breaks this does not raise — it becomes a props-less fragment, which is rarely intended; a route or layout instead gets a `display: contents` boundary host, so reach for sibling top-level nodes only when a wrapper element would carry no meaning. `TemplateRootError` (`must have exactly one top-level HTML element so Caspian can inject pp-component`) now fires only for a component template with no root at all, or with an unresolvable `x-*` tag as its only root.
|
|
203
208
|
- For dashboard, admin, or grouped sections with multiple child routes, prefer folder-level `layout.py` wrappers in `src/app/**` instead of repeating the same shell in each child route.
|
|
204
209
|
- For grouped shells with independent sidebar and content scrolling, mark the content pane with `pp-reset-scroll="true"` when that pane should start at the top on each child-route navigation. Do not put the attribute on the whole shell when the sidebar or rail should retain its own scroll.
|
|
210
|
+
- If — and only if — a subtree is asked to show a loading state during child-route navigation, add `loading.py` beside that folder's `layout.py` and mark the swapped pane with `pp-loading-content="true"` in the layout. Do not add a spinner component, an `isLoading` state, or a navigation-event listener to the route for this. A subtree with no loader is the normal case, not an omission to fix.
|
|
205
211
|
- For upload managers and similar interactive lists, prefer `pp.state(...)` plus `pp-for` over manual DOM painting so rerenders keep the list stable.
|
|
206
|
-
- For route-owned
|
|
212
|
+
- For route-owned live channels, call `pp.socket(name, args, handlers)` inside the owning component script: open it in `pp.effect(..., [])`, keep the handle in `pp.ref(...)`, and close it in the effect cleanup. Reach for a native `new WebSocket(...)` only for a wire the named-socket contract cannot carry.
|
|
207
213
|
- Do not assume WebSocket clients live in a dashboard or any fixed route. Put the browser client in whichever route owns that live experience, pass first-render socket values from the matching `index.py`, and use route auth policy plus WebSocket endpoint auth checks intentionally for public, private, or mixed channels.
|
|
208
214
|
- Do not assume React, Vue, JSX-first component syntax, HTMX, or another frontend runtime unless the user explicitly requests one.
|
|
209
215
|
- **The React analogies in this file and in the packaged docs are scoped to two things only: the `pp.*` hook API inside `<script>`, and how you split components by responsibility. They never license JSX in markup.** A template is plain HTML compiled by PulsePoint. Concretely, never generate: `{cond && (<div/>)}` or `{cond ? <A/> : <B/>}` (use `hidden="{!cond}"`), `{list.map(item => (<tr/>))}` (use `<template pp-for="item in list">` with `key="{item.id}"`), unquoted brace attributes such as `class={...}` or `selected={x === 'y'}` (always quote: `class="{...}"`), `className`, `htmlFor`, camelCase `onClick`/`onChange`, `style={{...}}` object literals, `dangerouslySetInnerHTML`, or `<>…</>` fragments. There is no `pp-if`, `pp-show`, `pp-else`, or `pp-key`. An unquoted brace attribute is invalid HTML — the parser shreds the element, the component root never compiles, and the route renders a blank page with no console error, which is why this must be checked at authoring time. Before finishing a template, confirm it would still be valid HTML with every `{}` deleted. Full contract: `node_modules/caspian-utils/dist/docs/pulsepoint.md` "PulsePoint Is Not JSX", "Complete Directive And API Surface", and "Conditional rendering".
|
|
@@ -238,7 +244,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
238
244
|
- These files are the packaged Caspian documentation layer, not the runtime and not the source of current workspace state.
|
|
239
245
|
- Use them to help AI answer three questions: which Caspian feature applies, which project files should be inspected next, and which workflow is appropriate once the feature is confirmed as enabled.
|
|
240
246
|
- Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: navigation loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
|
|
241
|
-
- Use `node_modules/caspian-utils/dist/docs/websockets.md` when deciding how to document or implement
|
|
247
|
+
- Use `node_modules/caspian-utils/dist/docs/websockets.md` when deciding how to document or implement named sockets (`@socket()` / `pp.socket(...)`), the shared socket endpoint, origin checks, per-socket auth/RBAC, the JSON frame contract, and the choice between sockets, RPC, and RPC streaming.
|
|
242
248
|
- Verify behavior claims in this order:
|
|
243
249
|
1. `caspian.config.json`, then `main.py`, `src/lib/**`, `public/js/**`, `prisma/**`, `src/app/**`
|
|
244
250
|
2. `.venv/Lib/site-packages/casp/**`
|
package/dist/AGENTS.md
CHANGED
|
@@ -64,6 +64,29 @@ Authoring is **Python-only and single-file**. There are no `.html` sidecars in t
|
|
|
64
64
|
- Deferral is keyed on the `layout()` frame only; a component or helper called from a layout still renders eagerly.
|
|
65
65
|
- Grouped sections (dashboard/admin/account) = parent folder + `layout.py` + child routes, exactly like the App Router. Put `pp-reset-scroll="true"` on the content pane that should reset on child navigation; leave shell scrollers unmarked.
|
|
66
66
|
|
|
67
|
+
**Special files (`src/app/**`) — all optional, but each owns its behavior:\*\*
|
|
68
|
+
|
|
69
|
+
None of these is required; add one only when the app actually wants that behavior. What the table settles is _how_ — when a task does call for one, the answer is this file, never a hand-built equivalent:
|
|
70
|
+
|
|
71
|
+
| Behavior, when wanted | File | Export |
|
|
72
|
+
| ---------------------------------- | ---------------------- | ---------------------------------- |
|
|
73
|
+
| Shared shell for a subtree | `layout.py` | `layout()` |
|
|
74
|
+
| Loading UI during route navigation | `loading.py` | `loading()` |
|
|
75
|
+
| Global 404 | `src/app/not_found.py` | `page()` |
|
|
76
|
+
| Global 500 | `src/app/error.py` | `page(error_message, error_trace)` |
|
|
77
|
+
|
|
78
|
+
**`loading.py` is the one most often reinvented — but also the one least often needed.** Most subtrees ship without a loader and navigate with a plain fade, which is correct default behavior; do not add one unless the user asks for a navigation loading state or the section clearly wants one. When a task _does_ call for one, edit or add the closest `loading.py` instead of writing a spinner component, an `isLoading` store, a `pp:navigation:start` listener, or a manual overlay. Its contract:
|
|
79
|
+
|
|
80
|
+
- `def loading():` — **synchronous, no parameters**, returns `html(r"""...""")`. `async def` (or an awaitable return) raises `TypeError`; a module without the function raises `AttributeError`.
|
|
81
|
+
- Scope comes from the folder, and the browser picks the closest ancestor: `src/app/dashboard/loading.py` → `/dashboard` and everything under it; `src/app/loading.py` → the root fallback. `(group)` segments are stripped exactly as they are from the URL, so a loader placed **directly** inside `(marketing)/` collapses to the root scope `/` and becomes the app-wide fallback rather than the group's — put it in a real route folder under the group instead. **A `[id]` folder never matches** — the lookup compares scope strings to the real pathname, so put the loader on the static parent.
|
|
82
|
+
- **The markup is static HTML.** The loader is collected once into a hidden registry and injected with `innerHTML`, never mounted: Jinja `{{ }}` and `html(...)` kwargs _do_ interpolate, but `<x-*>` tags stay literal (rendering nothing), `{ }` bindings render as literal text, and `<script>`/`pp-for`/`on*` do nothing. Plain elements and CSS only.
|
|
83
|
+
- Rendered once and cached until the file's mtime changes, so the output is shared by every request — no per-user or per-request data. Every loader in the app ships inside every page, so keep them small.
|
|
84
|
+
- `pp-loading-content="true"` on a layout element marks the pane the loader replaces (fallback: `document.body`, which flashes the whole shell). `pp-loading-transition='{"fadeIn":…,"fadeOut":…}'` inside the loader markup overrides the 250 ms default each way.
|
|
85
|
+
- **Navigation only.** It never appears on first paint or a hard refresh, and it is inert in a static export. An in-page wait — an `@rpc()` call, a submit, a filter, an upload — is ordinary `pp.state` in the owning component and must not be routed through `loading.py`.
|
|
86
|
+
- This workspace already has one example of the pattern — `src/app/dashboard/loading.py` fills the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`. Copy that shape if a loader is wanted elsewhere; its existence is not a reason to add loaders to other subtrees.
|
|
87
|
+
|
|
88
|
+
Deep dive: `node_modules/caspian-utils/dist/docs/file-conventions.md` (all four files, with the full `loading.py` contract) and `pulsepoint.md` "SPA, loading, and navigation helpers".
|
|
89
|
+
|
|
67
90
|
**Components (`src/components/**/\*.py`):\*\*
|
|
68
91
|
|
|
69
92
|
- One `@component` function per responsibility, markup inline via `html(r"""...""")`, PulsePoint `<script>` inside the root. Split by responsibility exactly as you would split React components — **that analogy covers decomposition and hook API only, never markup syntax** (see the PulsePoint contract below).
|
|
@@ -207,7 +230,7 @@ The workspace-specific layers — the quality gate (`npm run check`), browser lo
|
|
|
207
230
|
- Keep the rest of this file focused on decision order, task routing, workspace-specific clarifications, and packaged-doc maintenance.
|
|
208
231
|
- Keep packaged docs under `node_modules/caspian-utils/dist/docs/` framework-oriented and use `core-runtime-map.md` when those docs need to point AI back to `main.py` or the installed `casp` runtime.
|
|
209
232
|
- **Only the built runtime under `public/js/**`exists in a generated Caspian app.** Whatever this workspace uses to produce it is a local build detail, not part of the product: never document it, reference it, or route AI to it from the packaged docs. Describe the runtime by its *behavior contract* — what the shipped runtime does — and treat `public/js/pp-reactive-v2.min.js` — the single minified PulsePoint bundle the app serves — as the artifact under discussion. Per-subsystem build output and any authoring source tree are development-only: never cite them as the runtime. The same rule applies to this workspace's own quality tooling and any performance measurement setup: they are development-only and never appear in packaged docs.
|
|
210
|
-
- `node_modules/`
|
|
233
|
+
- **The packaged docs under `node_modules/caspian-utils/dist/docs/` are git-tracked in this repo** (23 files, not gitignored), so an edit here is recoverable — but `npm install` still overwrites the files on disk, and the change does not reach any other project. Every packaged-doc edit must therefore also be ported into the `caspian-utils` package source; after a reinstall, check `git status` on that folder before assuming the edit survived.
|
|
211
234
|
|
|
212
235
|
## Decision Order
|
|
213
236
|
|
|
@@ -263,7 +286,8 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
263
286
|
- 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.
|
|
264
287
|
- 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.
|
|
265
288
|
- 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.
|
|
266
|
-
- Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: routes use `index.py`, layouts use `layout.py`, loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
|
|
289
|
+
- Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: routes use `index.py`, layouts use `layout.py`, navigation loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
|
|
290
|
+
- **If navigation loading UI is wanted, it is `loading.py` — never hand-roll it.** The file itself is optional and most subtrees do not have one; this rule governs the implementation, not whether to add the feature. A spinner component, a global `isLoading` store, a `pp:navigation:start`/`pp:navigation:complete` listener, or a manual overlay built for route-to-route navigation is a reimplementation of the shipped runtime (`casp/loading.py` collects the files, `caspian_config.py` derives their URL scopes, the browser runtime resolves the closest ancestor scope and swaps the `pp-loading-content="true"` pane). The full contract is in the "Special files" block above; the shipped example is `src/app/dashboard/loading.py` plus the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`. In-page waits (RPC, submit, filter, upload) are `pp.state` in the owning component and are not this feature.
|
|
267
291
|
- When `caspian.config.json` has `prisma: true`, database reads and writes from Python routes, layouts, RPC actions, upload flows, auth flows, and helpers must use the generated Prisma Python ORM in `src/lib/prisma/**`. Do not create a separate database fetch layer with raw drivers, hand-written SQL helpers, JSON manifests, app-specific HTTP fetches, or browser-side data fetches to replace the ORM. Use raw SQL only as a narrow Prisma ORM fallback when the generated client cannot express a query clearly.
|
|
268
292
|
- **After any `prisma/schema.prisma` change, exactly two commands are required, in order.** Step 1 — sync the database, pick one: `npx prisma migrate dev` (development default, creates and applies a migration) or `npx prisma db push` (migration-less direct sync). Step 2 — always: `npx ppy generate`, the **only** command that regenerates the Python ORM the app imports (`src/lib/prisma/__init__.py`, `db.py`, `models.py`, `settings/prisma-schema.json`). The two generators are different toolchains from the same schema: `npx prisma generate` builds the Node/TypeScript `@prisma/client` used only by `prisma/seed.ts` and writes zero Python — it is never a substitute for `npx ppy generate`. Never hand-write or patch the generated Python ORM instead of regenerating it; the generated client is ready to import from `src.lib.prisma`. See `node_modules/caspian-utils/dist/docs/database.md` "Two Generators, One Schema".
|
|
269
293
|
- Treat `npx prisma db seed` as a delicate, potentially destructive operation. In this workspace, seed scripts may clear tables before inserting fresh records. Before running that command, an AI agent must propose the exact command, warn that it can delete or overwrite database data including production data if the datasource is wrong, confirm the datasource when practical, and wait for explicit user approval.
|
|
@@ -291,7 +315,7 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
291
315
|
- The `pp` component-script API mirrors React hooks **inside the `<script>` only** — the surrounding markup is never JSX: `state`, `effect`, `layoutEffect`, `ref`, `memo`, `callback`, `reducer`, `context`, `portal`, `id`, `syncExternalStore`, `imperativeHandle`, `transition`, `deferredValue`, `optimistic`, `errorBoundary`, plus `props`. Use `pp.id()` for generated `id`/`for`/`aria-*` values, `pp.syncExternalStore(...)` for sources the component does not own, and wrap failure-prone subtrees in a parent with `pp.errorBoundary()` instead of letting a render throw reach the console. Verify against `public/js/pp-reactive-v2.min.js` and see `pulsepoint.md` "Hooks and runtime API".
|
|
292
316
|
- For grouped-subtree SPA navigation UX, the current browser runtime keeps unmarked shell scrollers stable and uses `pp-reset-scroll="true"` on the content pane that should reset. Check `pulsepoint.md`, `routing.md`, and `public/js/pp-reactive-v2.min.js` before changing that behavior.
|
|
293
317
|
- Before updating docs, verify runtime-specific claims such as middleware order, route param injection, `layout()` behavior, `StateManager` persistence, safe public-file serving, response header, or session-secret behavior against the current `main.py` and installed `casp` package, especially `.venv/Lib/site-packages/casp/runtime_security.py`, rather than copying older notes.
|
|
294
|
-
- When generating or reviewing page templates, layout templates, or component markup, single-root is the default shape: one authored top-level parent element or one imported `x-*` root, with any owned `<script>` kept inside that same root. **For a component it is no longer a hard requirement**: sibling top-level nodes make it a fragment, framed by the comment-pair boundary described below, and `TemplateRootError` is now reserved for a component template with no root at all or with an `x-*` tag as its only root. Prefer a single native root anyway — it is the only shape that can receive props. **For a `.py` page (`index.py`) or layout (`layout.py`)**: sibling top-level nodes are wrapped in a layout-neutral `<div pp-component="…" style="display: contents">` boundary host instead of raising, so a page whose sections are genuinely siblings does not need a meaningless wrapper `<div>`. The relaxation is gated on the `.py
|
|
318
|
+
- When generating or reviewing page templates, layout templates, or component markup, single-root is the default shape: one authored top-level parent element or one imported `x-*` root, with any owned `<script>` kept inside that same root. **For a component it is no longer a hard requirement**: sibling top-level nodes make it a fragment, framed by the comment-pair boundary described below, and `TemplateRootError` is now reserved for a component template with no root at all or with an `x-*` tag as its only root. Prefer a single native root anyway — it is the only shape that can receive props. **For a `.py` page (`index.py`) or layout (`layout.py`)**: sibling top-level nodes are wrapped in a layout-neutral `<div pp-component="…" style="display: contents">` boundary host instead of raising, so a page whose sections are genuinely siblings does not need a meaningless wrapper `<div>`. The relaxation is gated on the template's source path ending in `.py`, which every page and layout in a Caspian app does — there is no `.html` authoring path for the branch to exclude. The same host is emitted for a _composition component_ whose authored root is another `x-*` tag, carrying the parent's forwarded props (which is what makes them reach `pp.props`) and `pp-ref-forward` — so an extra `display: contents` div between two component roots in rendered DOM is expected output, not a bug. Keep the owned `<script>` inside the template either way: the host is the boundary, so one script still covers every root.
|
|
295
319
|
- **A multi-root component is a fragment — the `<>…</>` shape — and is now supported.** A component whose `html(...)` has sibling top-level nodes no longer raises: the compiler frames them with the comment pair `<!--pp:id-->…<!--/pp-->`, which `materializeRangeBoundaries` turns into a live `<pp-fragment style="display: contents" pp-component="id">` at mount, before the boundary scan. So a fragment component adds **no element** to the rendered tree, and it is the only shape that survives inside `<tbody>`/`<tr>`/`<select>`/`<optgroup>`, where a `display: contents` wrapper is foster-parented out by the HTML parser (the browser runtime deliberately leaves markers under those parents as comments, so the grouping renders but the fragment owns no identity there — a stateful fragment needs a context an element could also live in). The syntax is **implicit**: siblings in the template, nothing to hand-write. Still never type `<pp-fragment>` or `<!--pp:…-->` yourself — those are compiler/runtime output, and an authored marker is refused by the subtree render cache. **A fragment cannot receive props**: with no root element there is nowhere for `get_attributes(...)` forwarding to land and `pp.props` would be silently empty, so passing any attribute (including `pp-ref`) on the `<x-*>` tag of a fragment component raises `FragmentPropsError` at compile time — give the component a single native root when it needs props. Fragments are excluded from the subtree render cache; a fragment nested inside a cached subtree still has its marker id re-minted per instance.
|
|
296
320
|
- Form controls are controlled _or_ uncontrolled for an element's lifetime. `value="{state}"` / `checked="{state}"` is controlled; the lowercase HTML attributes `defaultvalue="{expr}"` / `defaultchecked="{expr}"` are the uncontrolled form and are real PulsePoint syntax (the camelCase React spellings are not). Binding `value` to state that starts `undefined` flips the mode and makes the runtime log `[PP-WARN] <input#x> changed from uncontrolled to controlled` once — fix the initial state, do not add both attributes.
|
|
297
321
|
- When generating or reviewing sign-in flows, do not ask the sign-in page to decide redirect targets by re-implementing `next` support or post-login routing. In this stack, redirect behavior is already owned by the Caspian auth runtime plus `src/lib/auth/auth_config.py`; protected-route guest redirects, auth-route redirects, and the default destination are centralized there, with `default_signin_redirect` defaulting to `/dashboard`.
|
|
@@ -334,6 +358,7 @@ If the task generates or edits route, layout, or component HTML templates, check
|
|
|
334
358
|
|
|
335
359
|
- Project layout and file placement: read `node_modules/caspian-utils/dist/docs/index.md` and `node_modules/caspian-utils/dist/docs/project-structure.md`. Verify against the current workspace tree.
|
|
336
360
|
- File conventions and special route files: read `node_modules/caspian-utils/dist/docs/file-conventions.md` and `node_modules/caspian-utils/dist/docs/routing.md`. Verify against `main.py`, `.venv/Lib/site-packages/casp/layout.py`, `.venv/Lib/site-packages/casp/loading.py`, and `.venv/Lib/site-packages/casp/caspian_config.py`.
|
|
361
|
+
- Navigation loading UI, route-change spinners, skeletons, or "show something while the next page loads" — **when the task actually asks for one**: this is `src/app/**/loading.py`, not new code. No loader at all is a valid, common configuration. Read `node_modules/caspian-utils/dist/docs/file-conventions.md` "`loading.py`" and `pulsepoint.md` "SPA, loading, and navigation helpers". Verify against `.venv/Lib/site-packages/casp/loading.py`, `.venv/Lib/site-packages/casp/caspian_config.py` (`LoadingEntry`, `_to_url_path`), `public/js/pp-reactive-v2.min.js` (`showLoadingTransition`, `findLoadingElement`, `updateContentWithTransition`), and the shipped `src/app/dashboard/loading.py` + `src/app/dashboard/layout.py` pair.
|
|
337
362
|
- Feature availability and tooling switches: read `caspian.config.json`. Verify against the current workspace tree, `main.py`, `prisma/**`, and `public/js/**`.
|
|
338
363
|
- Framework internals and core-file lookup: read `node_modules/caspian-utils/dist/docs/core-runtime-map.md`. Verify against `main.py`, `.venv/Lib/site-packages/casp/**`, and the matching feature docs.
|
|
339
364
|
- PulsePoint browser runtime lookup: read `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `public/js/pp-reactive-v2.min.js`, `main.py`, and `.venv/Lib/site-packages/casp/components_compiler.py`.
|
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"]),CASPIAN_SECTION_START="\x3c!-- caspian:start --\x3e",CASPIAN_SECTION_END="\x3c!-- caspian:end --\x3e",SELF_UPDATE_GUARD_ENV="CREATE_CASPIAN_APP_SELF_UPDATED";let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.py","not-found.py","error.py"],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.py","src/app/index.py"]},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.py","src/app/index.py","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 c=i.replace(/(?<!:)(\/\/+)/g,"/"),a=s.replace(/\/\/+/g,"/");return{bsTarget:`${c}/`,bsPathRewrite:{"^/":`/${a.startsWith("/")?a.substring(1):a}/`}}}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",format:"uv run python settings/format.py","format:check":"uv run python settings/format.py --check",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 c={...s.scripts};c.browserSync="tsx settings/bs-config.ts",c.dev=`npm-run-all projectName -l -p browserSync ${i.join(" ")}`;let a=["projectName"];n.tailwindcss&&a.unshift("tailwind:build"),n.typescript&&!n.backendOnly&&a.unshift("ts:build"),c.build=`npm-run-all ${a.join(" ")}`,s.scripts=c,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=[];return e.prisma&&i.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')),i.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"\n\n# Calendar timezone for the application, as an IANA name (e.g. "UTC",\n# "America/New_York", "America/Santo_Domingo"). Read by casp/app_time.py and\n# resolved once at boot in main.py.\n#\n# This sets which wall-clock DAY an instant belongs to: what casp.app_time.now()\n# and today() answer, how a stored timestamp reads back to a user, and the\n# boundaries a "today\'s totals" query uses. Timestamps are still STORED in UTC.\n#\n# It deliberately does NOT affect absolute time -- session expiry (casp/auth.py)\n# and cache TTLs (casp/cache_handler.py) stay on UTC, so changing this can never\n# extend a session or a cache entry.\n#\n# An unrecognized name raises InvalidAppTimezoneError at startup rather than\n# silently falling back to UTC. Empty or unset means UTC.\nAPP_TIMEZONE="UTC"'),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&&i.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&&i.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="${s}"`)),i.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",'# 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"')),i.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.py"))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),c=resolveTemplateSourcePath(n,"directory"),a=path.join(e,s);if(!c){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(c,a,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.py");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.py:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function ensureClaudeMd(e){const n=path.join(e,"CLAUDE.md");if(checkExcludeFiles(n))return;const t="@AGENTS.md";if(!fs.existsSync(n))return void fs.writeFileSync(n,`${t}\n`,{flag:"w"});const s=fs.readFileSync(n,"utf8").replace(/^\uFEFF/,"");if(s.trimStart().startsWith(t))return;const i=`${t}\n\n${s.trimStart()}`;fs.writeFileSync(n,i,{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.min.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.min.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 c=e.endsWith("\n");return`${e}${c?"\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:"/.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"),c=path.join(e,t);if(checkExcludeFiles(c))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(c))return void console.log(chalk.gray("Preserving existing pyproject.toml during update."));const a=fs.readFileSync(i,"utf8");if("/AGENTS.md"===n&&updateAnswer?.isUpdate&&fs.existsSync(c)){const e=mergeAgentsCaspianSection(fs.readFileSync(c,"utf8"),a);return void fs.writeFileSync(c,e,{flag:"w"})}fs.writeFileSync(c,a,{flag:"w"})}),await executeCopy(e,s,n),ensureClaudeMd(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(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}}if(n)return{projectName:e.projectName??"my-app",starterKit:e.starterKit,starterKitSource:e.starterKitSource,backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};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}),c=[];i.backendOnly??e.backendOnly??!1?(e.mcp||c.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||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||c.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||c.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||c.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||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const a=await prompts(c,{onCancel:s});return{projectName:i.projectName?String(i.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:i.backendOnly??e.backendOnly??!1,tailwindcss:a.tailwindcss??e.tailwindcss??!1,typescript:a.typescript??e.typescript??!1,mcp:a.mcp??e.mcp??!1,websocket:a.websocket??e.websocket??!1,prisma:a.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),c=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>c[e])return 1;if(i[e]<c[e])return-1}const a=t[4]??null,o=s[4]??null;return a&&!o?-1:!a&&o?1:a&&o?a.localeCompare(o):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)}function resolveGlobalCliEntryPoint(){const e=[__filename];try{const n=execSync("npm root -g").toString().trim();n&&e.push(path.join(n,"create-caspian-app","dist","index.js"))}catch{}for(const n of e)if(n&&fs.existsSync(n))return n;return null}async function ensureLatestCliVersion(e){const n=await fetchPackageVersion("create-caspian-app");if("1"===process.env[SELF_UPDATE_GUARD_ENV])return n;if(isRunningFromNpxCache(__dirname))return console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")),n;const t=getInstalledPackageInfo("create-caspian-app");if(t.isLinked)return console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")),n;if(!(!t.version||-1===compareVersions(t.version,n)))return n;execSync(buildManagedNpmCommand(["install","-g",`create-caspian-app@${n}`]),{stdio:"inherit"});const s=resolveGlobalCliEntryPoint();if(!s)throw new Error(`create-caspian-app was updated to ${n}, but the updated CLI could not be located. Please run the command again.`);console.log(chalk.gray(`Restarting with create-caspian-app@${n}...`));const i=spawnSync(process.execPath,[s,...e],{stdio:"inherit",env:{...process.env,[SELF_UPDATE_GUARD_ENV]:"1"}});if(i.error)throw i.error;process.exit(i.status??1)}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.2.0","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"8.0.5","npm-run-all":"4.1.5",postcss:"8.5.26","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.12",typescript:"7.0.2",vite:"8.2.0",vitest:"4.1.10","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,c=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=c.bsTarget,t.bsPathRewrite=c.bsPathRewrite;const a=await fetchPackageVersion("create-caspian-app");t.version=t.version||a,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.141.1","uvicorn==0.52.1","python-dotenv==1.2.2","tzdata==2026.3","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.10.0","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.7"),e.websocket&&n.push("websockets==17.0.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.16.2","pytest==9.1.1","djlint==1.44.2"]}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 c;for(;null!==(c=i.exec(t[1]));){const e=c[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),c=path.join(e,".venv");fs.existsSync(c)?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 a=buildPythonDependencies(n),o=buildPythonDevDependencies(),r=a.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=o.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,...a],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,...o],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],c=e.find(e=>e.startsWith("--starter-kit-source=")),a=c?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();const o=await ensureLatestCliVersion(e);let r=null,l=!1;if(t){const s=process.cwd(),c=path.join(s,"caspian.config.json");if(i&&a){l=!0;const s={projectName:t,starterKit:i,starterKitSource:a,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")};r=await getAnswer(s,n)}else if(fs.existsSync(c)){const i=readJsonFile(c);let a=[];i.excludeFiles?.map(e=>{const n=path.join(s,e);fs.existsSync(n)&&a.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:a??[],filePath:s};const o={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)};r=await getAnswer(o,n),null!==r&&(updateAnswer={projectName:t,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s})}else{const s={projectName:t,starterKit:i,starterKitSource:a,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")};r=await getAnswer(s,n)}if(null===r)return void console.log(chalk.red("Installation cancelled."))}else r=await getAnswer({},n);if(null===r)return void console.warn(chalk.red("Installation cancelled."));const p=process.cwd();let d;if(t)if(l){const n=path.join(p,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,await setupStarterKit(d,r),process.chdir(d);const s=path.join(d,"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),r={...r,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(d,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...r,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:d}}}else{const e=path.join(p,"caspian.config.json"),n=path.join(p,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?d=p:fs.existsSync(n)&&fs.existsSync(s)?(d=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,process.chdir(n))}else fs.mkdirSync(r.projectName,{recursive:!0}),d=path.join(p,r.projectName),process.chdir(r.projectName);let u=[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")];r.prisma&&u.push(npmPkg("prompts"),npmPkg("@types/prompts")),r.tailwindcss&&u.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),r.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),r.typescript&&!r.backendOnly&&u.push(npmPkg("vite"),npmPkg("fast-glob")),r.typescript&&u.push(npmPkg("vitest")),r.starterKit&&!l&&await setupStarterKit(d,r),await installNpmDependencies(d,u,!0);let m=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(d,r),r.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(d,"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(d,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(d,"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(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(d,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(d,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const c=path.join(d,"ts","tailwind-merge.ts");fs.existsSync(c)&&(fs.unlinkSync(c),console.log(`${c} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(r.tailwindcss){const e=path.join(d,"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(d,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(d,"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(d,"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(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(d,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(d,"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(d,i,!0));const c=s(n),a=getPyProjectDependencyNames(d);m=c.filter(e=>a.has(e.toLowerCase())),m.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${m.join(", ")}`))}if(!l||!fs.existsSync(path.join(d,"caspian.config.json"))){const e=d.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:r.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.typescript,version:o,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(d,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(d,r,m),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(d.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",SELF_UPDATE_GUARD_ENV="CREATE_CASPIAN_APP_SELF_UPDATED";let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.py","not-found.py","error.py"],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.py","src/app/index.py"]},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.py","src/app/index.py","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 a=i.replace(/(?<!:)(\/\/+)/g,"/"),c=s.replace(/\/\/+/g,"/");return{bsTarget:`${a}/`,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",format:"uv run python settings/format.py","format:check":"uv run python settings/format.py --check",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 a={...s.scripts};a.browserSync="tsx settings/bs-config.ts",a.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"),a.build=`npm-run-all ${c.join(" ")}`,s.scripts=a,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=[];return e.prisma&&i.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')),i.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"\n\n# Calendar timezone for the application, as an IANA name (e.g. "UTC",\n# "America/New_York", "America/Santo_Domingo"). Read by casp/app_time.py and\n# resolved once at boot in main.py.\n#\n# This sets which wall-clock DAY an instant belongs to: what casp.app_time.now()\n# and today() answer, how a stored timestamp reads back to a user, and the\n# boundaries a "today\'s totals" query uses. Timestamps are still STORED in UTC.\n#\n# It deliberately does NOT affect absolute time -- session expiry (casp/auth.py)\n# and cache TTLs (casp/cache_handler.py) stay on UTC, so changing this can never\n# extend a session or a cache entry.\n#\n# An unrecognized name raises InvalidAppTimezoneError at startup rather than\n# silently falling back to UTC. Empty or unset means UTC.\nAPP_TIMEZONE="UTC"'),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=\n\n# Callback the GitHub authorize request asks for. A GitHub App accepts up to 10\n# redirect URIs, but a request that omits this parameter always lands on the\n# FIRST one registered on the App -- which is why adding a localhost URI next to\n# the production one has no effect on its own. Register both on the App, then\n# let each environment name the one it wants here. Must match a registered URI\n# character for character. Leave empty to derive it from APP_BASE_URL as\n# "<APP_BASE_URL>/api/auth/callback/github", and empty with no APP_BASE_URL to\n# send no redirect_uri at all (GitHub\'s first-registered-URI default).\nGITHUB_REDIRECT_URI='),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&&i.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&&i.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="${s}"`)),i.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",'# 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"')),i.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.py"))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),a=resolveTemplateSourcePath(n,"directory"),c=path.join(e,s);if(!a){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(a,c,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.py");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.py:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function ensureClaudeMd(e){const n=path.join(e,"CLAUDE.md");if(checkExcludeFiles(n))return;const t="@AGENTS.md";if(!fs.existsSync(n))return void fs.writeFileSync(n,`${t}\n`,{flag:"w"});const s=fs.readFileSync(n,"utf8").replace(/^\uFEFF/,"");if(s.trimStart().startsWith(t))return;const i=`${t}\n\n${s.trimStart()}`;fs.writeFileSync(n,i,{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.min.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.min.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 a=e.endsWith("\n");return`${e}${a?"\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:"/.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"),a=path.join(e,t);if(checkExcludeFiles(a))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(a))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(a)){const e=mergeAgentsCaspianSection(fs.readFileSync(a,"utf8"),c);return void fs.writeFileSync(a,e,{flag:"w"})}fs.writeFileSync(a,c,{flag:"w"})}),await executeCopy(e,s,n),ensureClaudeMd(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(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}}if(n)return{projectName:e.projectName??"my-app",starterKit:e.starterKit,starterKitSource:e.starterKitSource,backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};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}),a=[];i.backendOnly??e.backendOnly??!1?(e.mcp||a.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||a.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||a.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||a.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||a.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||a.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||a.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||a.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const c=await prompts(a,{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),a=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>a[e])return 1;if(i[e]<a[e])return-1}const c=t[4]??null,o=s[4]??null;return c&&!o?-1:!c&&o?1:c&&o?c.localeCompare(o):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)}function resolveGlobalCliEntryPoint(){const e=[__filename];try{const n=execSync("npm root -g").toString().trim();n&&e.push(path.join(n,"create-caspian-app","dist","index.js"))}catch{}for(const n of e)if(n&&fs.existsSync(n))return n;return null}async function ensureLatestCliVersion(e){const n=await fetchPackageVersion("create-caspian-app");if("1"===process.env[SELF_UPDATE_GUARD_ENV])return n;if(isRunningFromNpxCache(__dirname))return console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")),n;const t=getInstalledPackageInfo("create-caspian-app");if(t.isLinked)return console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")),n;if(!(!t.version||-1===compareVersions(t.version,n)))return n;execSync(buildManagedNpmCommand(["install","-g",`create-caspian-app@${n}`]),{stdio:"inherit"});const s=resolveGlobalCliEntryPoint();if(!s)throw new Error(`create-caspian-app was updated to ${n}, but the updated CLI could not be located. Please run the command again.`);console.log(chalk.gray(`Restarting with create-caspian-app@${n}...`));const i=spawnSync(process.execPath,[s,...e],{stdio:"inherit",env:{...process.env,[SELF_UPDATE_GUARD_ENV]:"1"}});if(i.error)throw i.error;process.exit(i.status??1)}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.2.0","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"8.0.8","npm-run-all":"4.1.5",postcss:"8.5.26","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.12",typescript:"7.0.2",vite:"8.2.2",vitest:"4.1.11","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.2.16","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,a=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=a.bsTarget,t.bsPathRewrite=a.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.141.1","uvicorn==0.52.4","python-dotenv==1.2.3","tzdata==2026.3","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.12.0","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.7"),e.websocket&&n.push("websockets==17.0.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.16.4","pytest==9.1.1","djlint==1.44.2"]}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 a;for(;null!==(a=i.exec(t[1]));){const e=a[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),a=path.join(e,".venv");fs.existsSync(a)?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),o=buildPythonDevDependencies(),r=c.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=o.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,...o],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],a=e.find(e=>e.startsWith("--starter-kit-source=")),c=a?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();const o=await ensureLatestCliVersion(e);let r=null,l=!1;if(t){const s=process.cwd(),a=path.join(s,"caspian.config.json");if(i&&c){l=!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")};r=await getAnswer(s,n)}else if(fs.existsSync(a)){const i=readJsonFile(a);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 o={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)};r=await getAnswer(o,n),null!==r&&(updateAnswer={projectName:t,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.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")};r=await getAnswer(s,n)}if(null===r)return void console.log(chalk.red("Installation cancelled."))}else r=await getAnswer({},n);if(null===r)return void console.warn(chalk.red("Installation cancelled."));const p=process.cwd();let d;if(t)if(l){const n=path.join(p,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,await setupStarterKit(d,r),process.chdir(d);const s=path.join(d,"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),r={...r,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(d,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...r,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:d}}}else{const e=path.join(p,"caspian.config.json"),n=path.join(p,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?d=p:fs.existsSync(n)&&fs.existsSync(s)?(d=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,process.chdir(n))}else fs.mkdirSync(r.projectName,{recursive:!0}),d=path.join(p,r.projectName),process.chdir(r.projectName);let u=[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")];r.prisma&&u.push(npmPkg("prompts"),npmPkg("@types/prompts")),r.tailwindcss&&u.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),r.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),r.typescript&&!r.backendOnly&&u.push(npmPkg("vite"),npmPkg("fast-glob")),r.typescript&&u.push(npmPkg("vitest")),r.starterKit&&!l&&await setupStarterKit(d,r),await installNpmDependencies(d,u,!0);let m=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(d,r),r.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(d,"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(d,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(d,"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(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(d,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(d,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const a=path.join(d,"ts","tailwind-merge.ts");fs.existsSync(a)&&(fs.unlinkSync(a),console.log(`${a} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(r.tailwindcss){const e=path.join(d,"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(d,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(d,"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(d,"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(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(d,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(d,"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(d,i,!0));const a=s(n),c=getPyProjectDependencyNames(d);m=a.filter(e=>c.has(e.toLowerCase())),m.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${m.join(", ")}`))}if(!l||!fs.existsSync(path.join(d,"caspian.config.json"))){const e=d.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:r.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.typescript,version:o,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(d,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(d,r,m),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(d.replace(/\\/g,"/"))}!`),console.log("\n=========================")}catch(e){console.error("Error while creating the project:",e),process.exit(1)}}main();
|