create-caspian-app 1.6.0 → 1.6.1-alpha
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/dist/.github/copilot-instructions.md +1 -1
- package/dist/AGENTS.md +2 -13
- package/dist/index.js +1 -1
- package/dist/settings/browser_log.py +505 -536
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/bs-config.ts +0 -32
- package/dist/settings/utils.ts +0 -28
- package/dist/tests/test_health_route.py +42 -85
- package/package.json +1 -1
- package/dist/settings/dev-hold.ts +0 -223
|
@@ -49,7 +49,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
49
49
|
- **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 test` (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`.
|
|
50
50
|
- **Know the gate's boundary: `npm run test` 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 test` as proof that front-end work is correct.
|
|
51
51
|
- **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 test`, 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, reload and exercise the affected route, then re-run `npm run logs`; verify the behavior and absence of fresh errors or relevant warnings. **What counts as exercising it depends on the status:** a mount error is cleared by a reload, but `NEEDS RECHECK` can mean an interaction error or an error carried across a source change. Repeat the affected interaction (click/submit). The reporter does not observe successful clicks, so historical recheck entries can remain; report the retest evidence and remaining history separately instead of claiming the digest is `CLEAN`. **Prefer the digest over raw `.casp/browser-log.jsonl`:** the file includes history and is compacted on source changes. A later matching load can retire earlier mount errors, but interaction and carried entries can still need rechecking. If inspecting raw events to correlate a retest, use session, route, page id, and timestamp; a later event does not unconditionally clear every earlier error. Never delete logs or restart the server just to make the status look clean. `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`.
|
|
52
|
-
- `src/app/globals.css` is the only stylesheet you edit. It compiles to `public/css/styles.css` in every project, with or without Tailwind (`caspian.config.json` decides whether `postcss.config.js` loads the Tailwind plugin)
|
|
52
|
+
- `src/app/globals.css` is the only stylesheet you edit. It compiles to `public/css/styles.css` in every project, with or without Tailwind (`caspian.config.json` decides whether `postcss.config.js` loads the Tailwind plugin). Never edit `public/css/styles.css`.
|
|
53
53
|
- 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.
|
|
54
54
|
- Never treat `__pycache__/` directories or `.pyc` files as files to edit, regenerate on purpose, or keep in the final diff.
|
|
55
55
|
- Treat `settings/component-map.json` and `settings/files-list.json` as generated outputs owned by `settings/component-map.ts` and `settings/files-list.ts`; inspect them when needed, but do not hand-edit them.
|
package/dist/AGENTS.md
CHANGED
|
@@ -12,7 +12,7 @@ When you work here, use `caspian.config.json` and the code that actually runs as
|
|
|
12
12
|
|
|
13
13
|
Do not treat the existence of a packaged doc as proof that the feature is enabled in this project.
|
|
14
14
|
|
|
15
|
-
**Frontend verification is required for every UI change or browser-error fix.** Run `npm run logs` before editing to read the frontend reports from `.casp/browser-log.jsonl`, then
|
|
15
|
+
**Frontend verification is required for every UI change or browser-error fix.** Run `npm run logs` before editing to read the frontend reports from `.casp/browser-log.jsonl`, then reload the affected route, exercise the changed interactions, and run it again before reporting completion. A passing `npm run test` does not prove browser health: its browser-log section is informational and does not affect the exit code. Never skip this because Python tests passed or the first paint looks correct. See the browser-log rules under Workspace Clarifications for historical entries and unavailable browser access.
|
|
16
16
|
|
|
17
17
|
## Caspian Core Contracts (Read Before Any Analysis)
|
|
18
18
|
|
|
@@ -350,22 +350,12 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
350
350
|
- Its boundary: it does not validate Tailwind/`globals.css`, `x-*` tag resolution, single-root violations, or `public/js/**`. Those still surface only at render time — verify front-end changes by loading the affected route in the browser (BrowserSync URL from `./settings/bs-config.json`).
|
|
351
351
|
- **Browser console errors reach the `npm run dev` terminal and `.casp/browser-log.jsonl`.** `settings/dev-log-bridge.ts` adds a BrowserSync middleware serving `/__pp-devlog.js` and receiving `POST /__pp-devlog`; `_inject_dev_console_bridge(...)` in `main.py` adds the `<script>` tag, gated on **both** `IS_PRODUCTION` and `CASPIAN_BROWSER_SYNC_PORT`. That variable is normally set only by `settings/python-server.ts` when the dev stack spawns the server, but that is a convention about who sets it, not an enforcement — so the production check is what actually keeps the tag out of production and a static export, mirroring `_dev_cookie_scope`. The variable is deliberately absent from `.env`: `load_dotenv()` defaults to `override=False`, so the process env var always wins, and the dev stack picks a free port by walking upward from 5090 (`settings/bs-config.ts` `getAvailablePort`), making any hand-typed value either ignored or stale. The hook forwards only `[PP-ERROR]` / `[PP-WARN]` console output plus uncaught errors and unhandled rejections — ordinary `console.log` stays in the browser — and prints them with the route, message, and top stack frames, deduplicated within a 1s window. Do not switch this to the BrowserSync socket: mount-time errors fire before that socket connects, and BrowserSync's own `snippetOptions` injection does not fire against this app's proxied responses at all, which is why the tag comes from the render pipeline. `npm run test` only reports — auto-fix with `npm run test:fix` (runs `settings/fix.py`: safe ruff fixes, then the gate). Unused-import (`F401`) removal is guarded because component imports look unused to ruff: Caspian single-file components import children and use them only as `<x-*>` tags (`from .Dialog import DialogContent` → `<x-dialog-content>`), which ruff can't see, and casp resolves the tag from module globals at render time. So `F401` is `unfixable` in `pyproject.toml` (a raw `ruff check --fix` never deletes any import), and `settings/fix.py` removes dead imports only from files with no `<x-*>`-tag import (component files are skipped whole); `settings/check.py` suppresses the matching `F401` reports so the gate fails only on genuinely dead imports. Shared detection lives in `settings/_component_imports.py`. See `tests/README.md`. Tests live in `tests/`; tooling and config live in `pyproject.toml` (`[dependency-groups] dev`, `[tool.pyright]`, `[tool.ruff]`, `[tool.pytest.ini_options]`); install them with `uv sync --group dev`. The type checker is **pyright** (the same engine Pylance uses in the editor), so IDE squiggles and `npm run test` agree instead of disagreeing like the previous `pyrefly` setup. `[tool.pyright]` is `include = ["main.py", "src", "settings/*.py"]` with `exclude = [".venv", "node_modules", "**/__pycache__"]`, so it checks `main.py`, everything under `src` — including the generated `src/lib/prisma/**` ORM, which is analyzed, not excluded — and the `settings/*.py` orchestrator scripts (`check.py`, `fix.py`, `_component_imports.py`). It uses `typeCheckingMode = "basic"` (Pylance's default), and mirrors the old pyrefly suppressions by setting `reportReturnType = "none"` and `reportAssignmentType = "none"`; re-enable those per-rule when tightening. Because this is project-specific, keep it documented here and in `.github/copilot-instructions.md`, not in the packaged docs.
|
|
352
352
|
|
|
353
|
-
- **`src/app/globals.css` is the only stylesheet in this workspace, and `public/css/styles.css` is its compiled output.** The root layout links the generated file (`<link href="/css/styles.css" rel="stylesheet" />` in `src/app/layout.py`); never edit it, never add a second hand-maintained stylesheet, and never inline a `<style>` block to work around the pipeline. `postcss.config.js` reads `tailwindcss` out of `caspian.config.json` and uses it only to decide whether `@tailwindcss/postcss` is in the plugin list — the authored path, the generated path, and the `<link>` are the same either way, so "does this project use Tailwind?" never changes where CSS lives. `cssnano` is added for one-shot builds only, so watch output stays readable in devtools (`settings/run-postcss.ts` sets `PP_POSTCSS_MODE`). The scripts are `npm run css` (watch, inside `npm run dev`) and `npm run css:build` (inside `npm run build`); you rarely run either directly. Because Tailwind generates utilities from the class names it scans, the watcher rebuilds on `.css`, `.html`, `.js`, **and `.py`** changes under `src/` when Tailwind is on (class names live inside `html(r"""...""")`), and on `.css` alone when it is off. `globals.css` declares its scan roots explicitly — `@import "tailwindcss" source(none);` plus `@source "../";` (`src/**`) and `@source "../../ts";` — so a utility that never appears in the output is usually an `@source` gap, not a pipeline failure. Treat `public/css/styles.css` as a generated artifact owned by the running dev stack.
|
|
354
|
-
|
|
355
353
|
- **The markup formatter proves each block before writing it, and a skip is a result rather than a failure.** `settings/format.py` runs djLint over every `html(r"""...""")` template, then checks the output against `settings/_markup_equivalence.py` — a tokenizer that decides whether the reformatted markup is _guaranteed_ to render identically. Only proven blocks are written back; the rest are reported with a reason and left alone. The gap this closes is specific: djLint is a general HTML formatter, so it inserts a newline between a block tag and an adjacent inline or `<x-*>` tag, and that newline renders as a visible space because a custom element's `display` comes from CSS the formatter cannot see. Four rules make the oracle correct rather than merely cautious — whitespace inside a tag never renders; a whitespace run in text collapses to one space but presence-vs-absence between inline elements is significant; a text node's edge whitespace collapses only when its parent is block-level; and `<pre>`/`<textarea>` render verbatim while `<script>`/`<style>` are indentation-insensitive code. Those last two are additionally **masked out before djLint sees them**, so code is preserved by construction, not by proof — djLint otherwise reads `/>` inside a JS regex as a tag delimiter and rewrites `.replace(/>/g, …)` into `.replace( />/g, …)`. Do not resolve a skip by relaxing the oracle: a false positive there silently changes rendering across hundreds of templates at once, which is exactly the failure a bulk reformat cannot afford. Coverage is in `tests/test_format.py`, which pins both directions. Two tag families are registered deliberately: `<x-*>` component tags are given to djLint via `--custom-html` so a component tree nests instead of sitting flat, while the oracle still treats them as **inline** — so indenting tags already on separate lines is accepted and separating two touching tags is still refused; and SVG elements count as block-level in the oracle because an SVG fragment lays out no text, so indenting the children of an inline `<svg>` in a component template cannot change what is drawn (`<text>`, `<tspan>`, `<textPath>` and `<foreignObject>` are excluded, since they do render their content).
|
|
356
354
|
- **A BOM makes a Python file invisible to the `templates` gate.** `check_templates.py` reads files with `path.read_text(encoding="utf-8")` and `ast.parse`, and a UTF-8 BOM makes that parse raise, which the scanner swallows as "skip this file". One file (`src/components/dashboard/ProductsPage.py`) carried a BOM and was silently exempt from every template rule, including `html-form` — it had been using the non-raw `html("""...""")` shape the whole time. `ruff format` strips the BOM, so the violation surfaced the moment formatting ran. If a template rule ever seems not to apply to a file, check for a BOM before assuming the rule is wrong.
|
|
357
|
-
- **Dev hold: an agent's whole editing run costs one restart and one reload, not one per edit.** The change coordinator in `settings/bs-config.ts` batches filesystem events on a 1500 ms quiet period, which is tuned for a human's burst-save. An agent's gap between two edits is a tool round-trip and is always wider than that window, so without a hold every single edit triggers a full Python restart plus a reload of **every** open tab — and each reload re-runs the route's Prisma queries against a pool the restart just discarded. Against a remote database (Railway, Render) that is a real connection storm, not just noise.
|
|
358
|
-
- **The signal is explicit, because the watcher cannot infer it.** chokidar sees an inode change, not a writer, so an agent's `Edit` and a human's save are indistinguishable. `settings/dev-hold.ts` owns a hold file at `.casp/dev-hold.json`; while it is active, `SettledBatchWorker` keeps queueing changes and skips both the restart and the reload, then drains the whole run as one batch with one settle. It lives in `.casp/` on purpose — `npm run dev` deletes that directory at startup, so a fresh stack cannot inherit a stale hold.
|
|
359
|
-
- **It is set by hooks, not by agent discipline.** `.claude/settings.json` acquires and refreshes the hold on `PreToolUse` for `Edit|Write|NotebookEdit` (~74 ms, dependency-free, run directly by Node) and releases it on `Stop` and `SessionEnd`. There is deliberately no `SubagentStop` release: a subagent finishing does not mean the main agent has stopped editing. Do not rely on remembering to acquire it — but **do** run `npm run dev:resume` before browser verification, because `Stop` fires only after your turn ends.
|
|
360
|
-
- **Manual controls:** `npm run dev:resume` (release and apply now — the one an agent needs), `npm run dev:hold` (acquire), `npm run dev:hold:status` (inspect).
|
|
361
|
-
- **It fails open, never frozen.** A hold that stops being refreshed for 120 s is stale and ignored, so a crashed agent or killed session degrades to normal reloading rather than a dead stack. A hold that keeps being refreshed is capped at 10 minutes — and once capped it **stays** capped until an explicit release, because letting the next edit start a fresh window would let a steadily-editing agent reset the cap forever and it would never actually force a drain. A missing, corrupt, or structurally wrong hold file all read as no hold. The dev terminal prints the hold state on entry and exit, so silence never looks like a broken watcher.
|
|
362
|
-
- **A human editing while an agent holds is deferred too.** This is intended: the tree is mid-edit and a reload would render a half-finished state. `npm run dev:resume` is the escape hatch. With no hold present, human editing behaves exactly as it always did.
|
|
363
|
-
- Coverage is in `settings/dev-hold.test.ts` (lifecycle, both expiry valves, corrupt input, and the real coordinator wiring) and `settings/utils.test.ts` (`SettledBatchWorker` deferral). The Python half of the warning is in `tests/test_browser_log.py::TestDevHoldWarning`.
|
|
364
355
|
- **`npm run logs` is how an agent checks front-end health, because the dev terminal usually belongs to someone else.** The developer typically runs `npm run dev` in their own shell, so its stdout is invisible to an agent session — and starting a second dev stack is the wrong fix: `npm run dev` begins with `projectName`, which **deletes `.casp/` and `caches/`** out from under the running server, and then binds different ports and rewrites `settings/bs-config.json`, orphaning the browser tab the developer is actually looking at. **Never start a second `npm run dev` to get a log.** Instead `settings/dev-log-bridge.ts` appends every event to `.casp/browser-log.jsonl` (JSONL, one event per line, gitignored, truncated per dev session because `.casp/` is recreated at startup), and `settings/browser_log.py` renders it via `npm run logs`. `npm run test` prints the same digest at the end of its run but **never lets it affect the exit code** — whether a route has been exercised depends on someone clicking around, and a gate that flaky gets ignored; `--fail-on-error` opts in for scripts that want it, `--no-browser` skips the section.
|
|
365
356
|
- **The log records successful page loads, not just errors.** This is the property that makes it safe to act on, and it must not be removed as redundant. A clean reload writes nothing on its own, so without `load` events a fixed error would sit in the file forever and an agent would "fix" a bug that no longer exists. A route's status is therefore whatever happened during its **most recent load**: one clean reload retires every earlier error for that route (reported as `N earlier error(s) resolved`). Errors are tied to their load by a client-generated `page` id, never by arrival order, because two `fetch` POSTs can land out of sequence.
|
|
366
357
|
- **`NEEDS RECHECK` can represent interaction errors or errors carried across source changes — a reload does not re-test everything.** A reload re-runs mount, so it is real evidence against a mount-phase error. It never clicks a button, so it proves nothing about an error thrown from an event handler. Errors are classified by how long after their page load they arrived (`phase: "mount"` within 2s, `"interaction"` after), and an interaction error is **not** cleared by a later load — it is carried as `NEEDS RECHECK` with its timing shown. Treating that as `CLEAN` is exactly how a live bug gets signed off; this was a real defect in an earlier version of this tool, on a route whose `onclick` threw 17s after load. Repeat the interaction (click/submit) and re-run `npm run logs`. The reporter does not record successful clicks, so an old recheck entry may remain even after a successful retest. Confirm there are no fresh errors or relevant warnings and report the interaction evidence separately; do not claim the status became `CLEAN` if it did not.
|
|
367
358
|
- **The log is compacted on every source change, not appended forever.** A dev session left running for hours would otherwise grow an unbounded file (~331 KB / ~85k tokens in a measured 6-hour session), and `.casp/` is only wiped by a full `npm run dev`, which nobody does mid-session. So when `src/**` changes, `compactBrowserLog(...)` rewrites the file down to the session header, a `{"type":"restart"}` marker, and the errors still open — resolved history is dropped. Survivors are marked `"carried": true` and dropped at the _next_ compaction, so a stale interaction error cannot haunt the log forever. Reading the digest costs ~855 tokens regardless of session length, because it is bounded by route count, not time.
|
|
368
|
-
- **A `DEV HOLD ACTIVE` banner means the digest is stale.** While a hold is active nothing has restarted or reloaded since your edits landed, so every line below the banner was produced by the previous code. Run `npm run dev:resume`, reload the route, and re-run before reading any of it as current state.
|
|
369
359
|
- **Do not diagnose from raw JSONL alone.** It is history, not state: it can hold errors resolved minutes ago, and errors carried across a source change. Always prefer `npm run logs`, which derives current status. If you do read the file, the `session` line's `readme` states the rule — **a later matching load can retire earlier mount errors, but interaction errors and carried entries can remain pending recheck.** When the digest retains history after a retest, correlate raw details by session, route, page id, and timestamp; never treat every line as a new failure.
|
|
370
360
|
- **`UNCONFIRMED` means an error with no matching `load` in this log** — almost always a browser tab left open across a dev restart, reporting against a page that was rendered under the previous session. The error was real when it fired but may already be fixed. Ask for a reload of that route and re-run `npm run logs` before treating it as a live bug; do not start editing code on an `UNCONFIRMED` line alone.
|
|
371
361
|
- **Read the three not-an-error states literally.** `NONE` = no dev session ever wrote — nothing has been observed. `WARN … dev server is NOT running` = the log is left over from an exited session; treat every line as history. `CLEAN` on a route means _that route was opened and rendered without error_. A route absent from the listing was never opened, which is **no signal, not a pass** — say so rather than reporting the front end healthy.
|
|
@@ -412,8 +402,7 @@ If the task generates or edits route, layout, or component HTML templates, check
|
|
|
412
402
|
- Dates, times, "today", or date-range queries: read `node_modules/caspian-utils/dist/docs/core-runtime-map.md` "Application time (`casp.app_time`)". Verify against `.venv/Lib/site-packages/casp/app_time.py` and the `APP_TIMEZONE` resolution in `main.py`. **Never write a bare `datetime.now()` in `src/**`or`main.py`** — it returns the server's local wall clock, which silently disagrees with the UTC timestamps `src/lib/prisma/models.py`writes. Use`app_time.now()`/`today()`for the current moment,`to_app_time(...)`to display a stored value, and`day_bounds_utc(...)`with`gte`/`lt` to query a calendar day. Session expiry and cache TTLs stay on UTC and must not be routed through this module.
|
|
413
403
|
- Database and seed flow: read `node_modules/caspian-utils/dist/docs/database.md` — start at "Two Generators, One Schema" for the required command order after schema changes (`npx prisma migrate dev` or `npx prisma db push`, then always `npx ppy generate`; `npx prisma generate` is Node-client-only and never a substitute). Verify against `prisma/schema.prisma`, `prisma/seed.ts`, and `src/lib/prisma/**`.
|
|
414
404
|
- Static export (SSG) or previewing a static build: read `node_modules/caspian-utils/dist/docs/static-export.md`. Verify against `package.json` (`static`, `static:serve`), `settings/build-static.py`, `settings/serve-static.py`, and `settings/project-name.ts`. This is an app-owned convention, not a shipped Caspian feature and not gated by a `caspian.config.json` flag. `npm run static` = `npm run build && uv run python settings/build-static.py`, so it regenerates `settings/files-list.json` (via `projectName`) before the exporter walks that route index; do not reduce it back to `css:build` only. `npm run static:serve` runs `settings/serve-static.py`, which auto-selects a free port from a preferred default (8000) and binds loopback `127.0.0.1` — read the port it prints, not `settings/bs-config.json` (that is the dev BrowserSync source of truth, not the static preview).
|
|
415
|
-
- Testing, type checking, linting, or the quality gate: read `tests/README.md` and `settings/check.py
|
|
416
|
-
- Styling, a stylesheet, CSS variables, a theme, or dark mode: edit `src/app/globals.css` and nothing else. Read `node_modules/caspian-utils/dist/docs/project-structure.md` "`src/app/globals.css` And `public/css/styles.css`" for the pipeline and `commands.md` "Compile the stylesheet" for the scripts. Verify against `postcss.config.js`, `settings/run-postcss.ts`, `caspian.config.json`, and the `<link href="/css/styles.css">` in `src/app/layout.py`.
|
|
405
|
+
- Testing, type checking, linting, or the quality gate: read `tests/README.md` and `settings/check.py`. This is a workspace-adopted convention, not a shipped Caspian feature, so it is documented in the workspace files (this section plus `.github/copilot-instructions.md`), not in the packaged docs. Verify against `pyproject.toml` (`[dependency-groups]`, `[tool.pyright]`, `[tool.ruff]`, `[tool.pytest.ini_options]`) and the `package.json` `test` script. The single command is `npm run test`.
|
|
417
406
|
- Formatting code or markup: read `tests/README.md` "Formatting" and `settings/format.py`. App-owned tooling, not a shipped Caspian feature. The single command is `npm run format` (`npm run format:check` to report only); `npm run test:fix` runs it first, before the ruff fixes and the gate. It formats markup with **djLint** and Python with **`ruff format`**, in that order — reformatting a template changes how many lines its literal spans, which changes how ruff wraps the enclosing `html(...)` call, so ruff must run last for a single pass to converge. The house style is `html(r"""` on one line with the markup starting on the next; `ruff format` explodes that shape whenever the call has arguments besides the template, so `format.py` rejoins the opening afterwards and iterates the pair to a fixed point. A bare `ruff format` or an IDE format-on-save will re-split them — rerun `npm run format` rather than editing call sites by hand. Prettier is not usable on this markup: it has no Jinja awareness and de-indents `{% for %}` blocks to column 0. Verify against `settings/format.py`, `settings/_markup_equivalence.py`, and `tests/test_format.py`.
|
|
418
407
|
|
|
419
408
|
## Docs Maintenance Rules
|
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 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",test:"uv run python settings/check.py","test: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","dev:hold":"node settings/dev-hold.ts acquire","dev:resume":"node settings/dev-hold.ts release","dev:hold:status":"node settings/dev-hold.ts status"};let i=[];n.backendOnly||(s.scripts={...s.scripts,css:"tsx settings/run-postcss.ts watch","css:build":"tsx settings/run-postcss.ts build"},i.push("css")),delete s.scripts.tailwind,delete s.scripts["tailwind:build"],delete s.scripts.check,delete s.scripts["check:fix"],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.backendOnly||c.unshift("css: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.backendOnly&&n.includes("globals."))return;if(t.tailwindcss&&n.endsWith("globals.plain.css"))return;if(!t.tailwindcss&&n.endsWith("globals.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.prisma&&n.includes("prisma-schema-config.json"))return;if(n.endsWith("globals.plain.css")&&(n=n.replace("globals.plain.css","globals.css")),n.endsWith("globals.css")&&fs.existsSync(n))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||(s=' <link href="/css/styles.css" rel="stylesheet" />\n <script type="module" src="/js/main.js"><\/script>'),e=e.replace("</head>",`${s}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.py:"),e)}}const TAILWIND_STYLESHEET_HEADER='@import "tailwindcss" source(none);\n@source "../";\n@source "../../ts";\n';function syncGlobalsStylesheetForTailwind(e,n){const t=path.join(e,"src","app","globals.css");if(checkExcludeFiles(t))return;if(!fs.existsSync(t))return;const s=fs.readFileSync(t,"utf8"),i=/^\s*@import\s+["']tailwindcss["']/m.test(s);if(n){if(i)return;return fs.writeFileSync(t,`${TAILWIND_STYLESHEET_HEADER}\n${s}`,{flag:"w"}),void console.log(chalk.green("Added the Tailwind directives to src/app/globals.css. Your own rules were kept."))}if(!i)return;const a=s.split(/\r?\n/).filter(e=>!/^\s*@import\s+["']tailwindcss["']/.test(e)&&!/^\s*@source\s/.test(e)).join("\n").replace(/^\n+/,"");fs.writeFileSync(t,a,{flag:"w"}),console.log(chalk.green("Removed the Tailwind directives from src/app/globals.css. Your own rules were kept.")),/@apply\s|@theme\s|@variant\s|@custom-variant\s/.test(a)&&console.log(chalk.yellow("Warning: src/app/globals.css still uses Tailwind-only at-rules (@apply/@theme/@variant).\n Without Tailwind these do nothing -- rewrite them as plain CSS.")),console.log(chalk.yellow("Note: Tailwind utility classes in your markup no longer resolve.\n Define the classes you still use in src/app/globals.css."))}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.backendOnly||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.4.1","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"9.0.2","npm-run-all":"4.1.5",postcss:"8.5.28","postcss-cli":"12.0.0",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.13",typescript:"7.0.2",vite:"8.2.2",vitest:"5.0.0","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.3.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,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==4.0.3"),e.websocket&&n.push("websockets==17.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.push("cryptography==50.0.1")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.16.6","pytest==9.1.1","djlint==1.45.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.backendOnly||u.push(npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("cssnano")),r.tailwindcss&&u.push(npmPkg("tailwindcss"),npmPkg("@tailwindcss/postcss"),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),updateAnswer?.isUpdate&&!r.backendOnly&&syncGlobalsStylesheetForTailwind(d,r.tailwindcss),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){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","@tailwindcss/postcss","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();
|
|
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",test:"uv run python settings/check.py","test: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.backendOnly||(s.scripts={...s.scripts,css:"tsx settings/run-postcss.ts watch","css:build":"tsx settings/run-postcss.ts build"},i.push("css")),delete s.scripts.tailwind,delete s.scripts["tailwind:build"],delete s.scripts.check,delete s.scripts["check:fix"],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.backendOnly||c.unshift("css: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.backendOnly&&n.includes("globals."))return;if(t.tailwindcss&&n.endsWith("globals.plain.css"))return;if(!t.tailwindcss&&n.endsWith("globals.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.prisma&&n.includes("prisma-schema-config.json"))return;if(n.endsWith("globals.plain.css")&&(n=n.replace("globals.plain.css","globals.css")),n.endsWith("globals.css")&&fs.existsSync(n))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||(s=' <link href="/css/styles.css" rel="stylesheet" />\n <script type="module" src="/js/main.js"><\/script>'),e=e.replace("</head>",`${s}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.py:"),e)}}const TAILWIND_STYLESHEET_HEADER='@import "tailwindcss" source(none);\n@source "../";\n@source "../../ts";\n';function syncGlobalsStylesheetForTailwind(e,n){const t=path.join(e,"src","app","globals.css");if(checkExcludeFiles(t))return;if(!fs.existsSync(t))return;const s=fs.readFileSync(t,"utf8"),i=/^\s*@import\s+["']tailwindcss["']/m.test(s);if(n){if(i)return;return fs.writeFileSync(t,`${TAILWIND_STYLESHEET_HEADER}\n${s}`,{flag:"w"}),void console.log(chalk.green("Added the Tailwind directives to src/app/globals.css. Your own rules were kept."))}if(!i)return;const a=s.split(/\r?\n/).filter(e=>!/^\s*@import\s+["']tailwindcss["']/.test(e)&&!/^\s*@source\s/.test(e)).join("\n").replace(/^\n+/,"");fs.writeFileSync(t,a,{flag:"w"}),console.log(chalk.green("Removed the Tailwind directives from src/app/globals.css. Your own rules were kept.")),/@apply\s|@theme\s|@variant\s|@custom-variant\s/.test(a)&&console.log(chalk.yellow("Warning: src/app/globals.css still uses Tailwind-only at-rules (@apply/@theme/@variant).\n Without Tailwind these do nothing -- rewrite them as plain CSS.")),console.log(chalk.yellow("Note: Tailwind utility classes in your markup no longer resolve.\n Define the classes you still use in src/app/globals.css."))}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.backendOnly||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.4.1","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"9.0.2","npm-run-all":"4.1.5",postcss:"8.5.28","postcss-cli":"12.0.0",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.13",typescript:"7.0.2",vite:"8.2.2",vitest:"5.0.0","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,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==4.0.3"),e.websocket&&n.push("websockets==17.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.push("cryptography==50.0.1")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.16.6","pytest==9.1.1","djlint==1.45.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.backendOnly||u.push(npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("cssnano")),r.tailwindcss&&u.push(npmPkg("tailwindcss"),npmPkg("@tailwindcss/postcss"),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),updateAnswer?.isUpdate&&!r.backendOnly&&syncGlobalsStylesheetForTailwind(d,r.tailwindcss),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){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","@tailwindcss/postcss","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();
|