create-caspian-app 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.claude/settings.json +36 -0
- package/dist/.codex/hooks.json +29 -0
- package/dist/.github/copilot-instructions.md +10 -0
- package/dist/.github/hooks/dev-hold.json +27 -0
- package/dist/AGENTS.md +18 -4
- package/dist/index.js +1 -1
- package/dist/settings/check.py +60 -4
- package/dist/settings/dev-hold-hook.ts +211 -0
- package/dist/tests/README.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Edit|Write|NotebookEdit|MultiEdit|Bash",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node settings/dev-hold-hook.ts"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"Stop": [
|
|
16
|
+
{
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node settings/dev-hold.ts release --quiet"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"SessionEnd": [
|
|
26
|
+
{
|
|
27
|
+
"hooks": [
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "node settings/dev-hold.ts release --quiet"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Edit|Write|NotebookEdit|MultiEdit|Bash|apply_patch|shell|exec_command|write_file",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node settings/dev-hold-hook.ts",
|
|
10
|
+
"statusMessage": "Holding dev reloads",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"Stop": [
|
|
17
|
+
{
|
|
18
|
+
"hooks": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "node settings/dev-hold.ts release --quiet",
|
|
22
|
+
"statusMessage": "Applying queued dev reloads",
|
|
23
|
+
"timeout": 10
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -173,6 +173,16 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
173
173
|
- The digest is informational inside `npm run test` and must stay that way — it cannot become part of the exit code, because whether a route has been exercised depends on someone opening a browser. `--fail-on-error` exists for callers that opt in.
|
|
174
174
|
- The log lives in `.casp/` so that `settings/project-name.ts` (which deletes that directory at the start of every `npm run dev`) truncates it per session for free. Do not relocate it somewhere that survives a restart without adding explicit truncation.
|
|
175
175
|
|
|
176
|
+
### `settings/dev-hold.ts`, `settings/dev-hold-hook.ts`, and the agent hook configs
|
|
177
|
+
|
|
178
|
+
- **An agent's whole editing run must cost one Python restart and one browser reload, not one per edit.** The change coordinator in `settings/bs-config.ts` batches on a 1500 ms quiet period tuned for a human's burst-save; an agent's gap between two edits is a tool round-trip and is always wider, so without a hold every edit restarts the server and reloads every open tab, re-running each route's Prisma queries against a pool the restart just discarded.
|
|
179
|
+
- **The signal is explicit, because chokidar sees an inode change and not a writer.** `settings/dev-hold.ts` owns `.casp/dev-hold.json`; while it is active `SettledBatchWorker` keeps queueing and skips both the restart and the reload, then drains the run as one batch. Both expiry valves fail open (120 s without a refresh, 600 s absolute), so a crashed agent degrades to normal reloading rather than a frozen stack.
|
|
180
|
+
- **Every agent host that reads this repo is wired, not just Claude Code.** `settings/dev-hold-hook.ts` runs on `PreToolUse` and `node settings/dev-hold.ts release --quiet` runs when the turn ends, configured in `.claude/settings.json`, `.github/hooks/dev-hold.json` (Copilot CLI and VS Code), and `.codex/hooks.json`. One script serves all three because they deliver the same PascalCase stdin payload (`tool_name`, `tool_input.command`); only the config file differs. Codex has no `SessionEnd`, so it releases on `Stop` alone and relies on the stale valve for a killed session.
|
|
181
|
+
- **`Bash` is in the matcher on purpose.** An agent told to prefer the shell for file changes edits with `cat > file <<'EOF'`, `sed -i`, or a throwaway Python script, none of which an `Edit|Write` matcher can see — that gap once cost a single feature branch eight restarts and five reloads. A blanket Bash match would over-correct, so `commandCanWrite` holds only for commands that can write, and never for the hold's own controls or the read-only quality gate: holding around `npm run logs` would print `DEV HOLD ACTIVE` over a perfectly current digest.
|
|
182
|
+
- **The hook must never exit non-zero.** `PreToolUse` is fail-closed in GitHub Copilot, so a non-zero exit denies the agent's tool call outright. `dev-hold.ts` is therefore imported lazily inside the `try`/`catch`, the `.ts` extension in that specifier is load-bearing (this hook runs under plain `node`, which cannot resolve an extensionless relative import — unlike `bs-config.ts`, which runs under tsx), and nothing is written to stdout.
|
|
183
|
+
- Tool names are normalised before matching, because the hosts spell the same tool `Edit`, `apply_patch`, and `insert_edit_into_file`. Adding a host means extending those sets plus that host's `matcher`, never forking the script.
|
|
184
|
+
- Manual controls, and the fallback for a host without hooks: `npm run dev:hold`, `npm run dev:resume`, `npm run dev:hold:status`. **If `dev:resume` reports `No hold was active` during an editing run, the hook layer is not wired** — every edit so far has cost its own restart. Coverage is in `settings/dev-hold.test.ts` (including a test that the three config files exist and point at the hook) and `settings/utils.test.ts`, both run by the gate's `node` leg.
|
|
185
|
+
|
|
176
186
|
### `settings/build-static.py` and `settings/serve-static.py`
|
|
177
187
|
|
|
178
188
|
- Treat these as the app-owned static-export tooling (not shipped `casp` runtime). `settings/build-static.py` is the SSG exporter; `settings/serve-static.py` is the preview server. Document behavior against these files, `package.json`, and `settings/project-name.ts`, not against a `casp` module.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"matcher": "Edit|Write|NotebookEdit|MultiEdit|Bash|edit|create|str_replace|bash|shell|run_in_terminal",
|
|
8
|
+
"command": "node settings/dev-hold-hook.ts",
|
|
9
|
+
"timeoutSec": 10
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"Stop": [
|
|
13
|
+
{
|
|
14
|
+
"type": "command",
|
|
15
|
+
"command": "node settings/dev-hold.ts release --quiet",
|
|
16
|
+
"timeoutSec": 10
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"SessionEnd": [
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"command": "node settings/dev-hold.ts release --quiet",
|
|
23
|
+
"timeoutSec": 10
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
package/dist/AGENTS.md
CHANGED
|
@@ -344,7 +344,7 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
344
344
|
- Form controls are controlled _or_ uncontrolled for an element's lifetime. `value="{state}"` / `checked="{state}"` is controlled; the lowercase HTML attributes `defaultvalue="{expr}"` / `defaultchecked="{expr}"` are the uncontrolled form and are real PulsePoint syntax (the camelCase React spellings are not). Binding `value` to state that starts `undefined` flips the mode and makes the runtime log `[PP-WARN] <input#x> changed from uncontrolled to controlled` once — fix the initial state, do not add both attributes.
|
|
345
345
|
- When generating or reviewing sign-in flows, do not ask the sign-in page to decide redirect targets by re-implementing `next` support or post-login routing. In this stack, redirect behavior is already owned by the Caspian auth runtime plus `src/lib/auth/auth_config.py`; protected-route guest redirects, auth-route redirects, and the default destination are centralized there, with `default_signin_redirect` defaulting to `/dashboard`.
|
|
346
346
|
- Component markup is server-deferred in an inert `<template>`. `main.py` finalizes every page through `defer_component_roots(...)`, which wraps each outermost `pp-component` root in `<template pp-component="…">`. The browser never parses/validates/fetches `<template>` contents, so raw `{...}` placeholders never reach live DOM at first paint. During `mount()`, PulsePoint captures and empties each plain component `<script>` before materializing `template[pp-component]` into live DOM, then evaluates that captured source in component scope; the same guard applies to scripts introduced by later morphs. Because of this, `{...}` is safe in ANY attribute or position — SVG geometry (`d`, `viewBox`, `points`, `transform`), URL attributes (`src`, `srcset`, `href`, `poster`), form `value`/date/number/color, and text placed directly inside `<table>`/`<select>`. Do NOT add per-tag workarounds to dodge browser first-paint validation: no static-path `hidden` toggles just to avoid binding `d`, no `data-*` URL holders, no gating `<img src>` behind `hidden`, and no SSR-resolving an initial value only to prevent a validation flash. Two compiler transforms still apply for different reasons and stay: `pp-style` (so `.html` source-file HTML/CSS tooling does not choke on `style="{...}"`) and the `<input>`/`<select>`/`checked`/`defaultvalue`/`<textarea>` value rewrites (attribute-vs-property correctness for controlled form fields), not first-paint validation.
|
|
347
|
-
- This workspace has an app-level quality gate for its own Python (`main.py`, `src/**`, `settings/*.py`), added on top of Caspian — the framework itself ships no test runner. One command, `npm run test` (which calls `uv run python settings/check.py`), runs `pyright` (types), `ruff` (lint), and `pytest` (tests) in a single pass and prints each problem as `path:line:col [tool:code] message`, exiting non-zero on failure. Running it is mandatory: after you create, edit, or delete app-owned Python — bug fix, new file, refactor, or feature — run it and get it fully green before treating the change as done, and do not report work as finished on the assumption that it passes. Fix every reported location and re-run until clean. The gate runs
|
|
347
|
+
- This workspace has an app-level quality gate for its own Python (`main.py`, `src/**`, `settings/*.py`), added on top of Caspian — the framework itself ships no test runner. One command, `npm run test` (which calls `uv run python settings/check.py`), runs `pyright` (types), `ruff` (lint), `templates` (markup), `node` (the TypeScript dev tooling) and `pytest` (tests) in a single pass and prints each problem as `path:line:col [tool:code] message`, exiting non-zero on failure. Running it is mandatory: after you create, edit, or delete app-owned Python — bug fix, new file, refactor, or feature — run it and get it fully green before treating the change as done, and do not report work as finished on the assumption that it passes. Fix every reported location and re-run until clean. The gate runs five tools: `pyright`, `ruff`, `templates`, `node`, and `pytest`.
|
|
348
348
|
- **`templates`** (`settings/check_templates.py`) lints authored markup — `src/**/*.html` plus the triple-quoted markup inside single-file Python components — for JSX and non-existent directives, and **fails the gate** on a hit. It exists because JSX kept reaching routes: `{users.map(user => (<tr/>))}` renders one literal row, and an unquoted `class={...}` is invalid HTML that blanks the entire page with no console error. Rules: `jsx-map`, `jsx-logical`, `jsx-ternary-element`, `unquoted-brace-attr`, `react-attribute`, `camelcase-event`, `jsx-fragment`, `style-object`, `unknown-directive` (`pp-if`/`pp-show`/`pp-else`/`pp-key`/…), `pp-for-placement` (`pp-for` outside `<template>`), `html-form`. `<script>`, `<pre>`/`<code>`, and HTML comments are excluded, so real component JavaScript and docs samples never trip it.
|
|
349
349
|
- **`html-form` enforces the single markup form: every `html(...)` call takes a raw triple-quoted literal, `html(r"""...""")`.** Nothing else is accepted — not a plain `"""..."""`, not a single-line string, not an f-string, not a variable holding markup assembled elsewhere. A non-raw literal silently rewrites backslashes, so a JS regex (`split(/\s+/)`) or a `'\n'` in a component script means one thing in the source and another at render, and the two forms have to be written differently (`\\s` vs `\s`) to produce the same output. An f-string additionally inverts the brace dialects and emits interpolated data unescaped while `Component.acall` still marks it trusted. One form also keeps the markup surface greppable. This drifted once already — 437 calls used the raw form and 133 did not — so the rule exists to hold it. Markup that must be built dynamically stays in the template: a tag name is a server value like any other (`html(r"""<{{ tag }} …>""", tag=tag)`), because Jinja renders before the component compiler sees the markup. Coverage is in `tests/test_check_templates.py`, including a repo-wide clean assertion. Run it alone with `uv run python settings/check_templates.py`.
|
|
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`).
|
|
@@ -356,11 +356,25 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
|
|
|
356
356
|
- **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
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
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.**
|
|
359
|
+
- **It is set by hooks, not by agent discipline, and not only for Claude.** All three agent hosts that read this repo run `settings/dev-hold-hook.ts` on `PreToolUse` (~80 ms, dependency-free, run directly by Node) and `node settings/dev-hold.ts release --quiet` when the turn ends. One script serves all three, because they deliver the same PascalCase payload on stdin (`tool_name`, `tool_input.command`) — only the config file differs, and each host ignores the others':
|
|
360
|
+
|
|
361
|
+
| Host | Config file | Release event |
|
|
362
|
+
| ------------------------------ | ----------------------------- | --------------------------------------------------------------------------------- |
|
|
363
|
+
| Claude Code | `.claude/settings.json` | `Stop`, `SessionEnd` |
|
|
364
|
+
| GitHub Copilot (CLI + VS Code) | `.github/hooks/dev-hold.json` | `Stop`, `SessionEnd` |
|
|
365
|
+
| Codex CLI | `.codex/hooks.json` | `Stop` (Codex has no `SessionEnd`; the 120 s stale valve covers a killed session) |
|
|
366
|
+
|
|
367
|
+
VS Code Copilot also reads `.claude/settings.json` as a fallback hook source, so it is covered twice. There is deliberately no `SubagentStop` release anywhere: a subagent finishing does not mean the main agent has stopped editing. Do not rely on remembering to acquire the hold — but **do** run `npm run dev:resume` before browser verification, because the release event fires only after your turn ends. Any host without hook support still has the manual path: `npm run dev:hold` at the start of an editing run, `npm run dev:resume` at the end.
|
|
368
|
+
|
|
369
|
+
- **Tool names are normalised, because the hosts spell them differently.** `Edit` (Claude), `apply_patch` (Codex) and `insert_edit_into_file` (Copilot) all mean the same thing, so `normalizeToolName` lower-cases and drops non-letters before matching. Adding a host means extending those sets and the `matcher` in that host's config — not forking the script.
|
|
370
|
+
- **The hook must never exit non-zero.** `PreToolUse` is fail-_closed_ in GitHub Copilot: a non-zero exit denies the agent's tool call outright, so a broken hook would stop the session rather than merely lose a reload optimisation. That is why `dev-hold.ts` is imported lazily inside the `try`/`catch` rather than at the top of the file, why the `.ts` extension in that specifier is load-bearing (plain `node` cannot resolve an extensionless relative import, and this hook runs under plain `node`, unlike `bs-config.ts` which runs under tsx), and why nothing is printed to stdout. `dev-hold.test.ts` spawns the file for real to pin all three.
|
|
371
|
+
- **`Bash` is in that matcher because the write tools are not the only writers.** An agent running under bypass-permissions mode is instructed to prefer the Bash tool for file changes, so it edits with `cat > file <<'EOF'`, `sed -i`, or a throwaway Python script — none of which an `Edit|Write|NotebookEdit` matcher can see. That gap is what let one feature branch cost eight full Python restarts and five reloads instead of one of each. A blanket `Bash` matcher would over-correct, though: holding on every `git status` and `npm run test` would make `npm run logs` print its `DEV HOLD ACTIVE` banner over a perfectly current digest, training the reader to ignore the one warning that matters. So `dev-hold-hook.ts` holds for a Bash command only when it can plausibly write (`commandCanWrite`), and never for the hold's own controls (`dev:resume`, `dev:hold*`, `npm run logs`) — re-acquiring there would undo the drain in the same breath that released it. The hook is fail-open by construction: unparseable input, an unknown tool, or a thrown error all exit 0 without a hold, because a missing hold costs reloads while a hook that blocks tool calls breaks the session.
|
|
372
|
+
- **If `npm run dev:resume` reports `No hold was active`, the hook layer is not wired** — check that `.claude/settings.json` exists and still carries the `PreToolUse` entry. That message during an editing run means every edit so far has been costing its own restart; acquire manually with `npm run dev:hold` for the rest of the run and fix the wiring.
|
|
360
373
|
- **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
374
|
- **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
375
|
- **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
|
|
376
|
+
- Coverage is in `settings/dev-hold.test.ts` (lifecycle, both expiry valves, corrupt input, and the `PreToolUse` hold decision) and `settings/utils.test.ts` (`SettledBatchWorker` deferral) — both run by the gate's `node` leg. The Python half of the warning is in `tests/test_browser_log.py::TestDevHoldWarning`.
|
|
377
|
+
|
|
364
378
|
- **`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
379
|
- **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
380
|
- **`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.
|
|
@@ -412,7 +426,7 @@ If the task generates or edits route, layout, or component HTML templates, check
|
|
|
412
426
|
- 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
427
|
- 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
428
|
- 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` for this workspace's gate, and `node_modules/caspian-utils/dist/docs/testing.md` for the general Caspian convention it implements. This is a workspace-adopted convention, not a shipped Caspian feature, so the project-specific details (the
|
|
429
|
+
- Testing, type checking, linting, or the quality gate: read `tests/README.md` and `settings/check.py` for this workspace's gate, and `node_modules/caspian-utils/dist/docs/testing.md` for the general Caspian convention it implements. This is a workspace-adopted convention, not a shipped Caspian feature, so the project-specific details (the five tools including `templates` and `node`, the `F401` component-import guard as configured here, the browser-log digest) live in the workspace files; the packaged doc holds only the reusable shape. 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`** (auto-fix: `npm run test:fix`) — there is no `npm run check` script, and a bare `pytest`/`ruff`/`pyright` run is a subset of the gate, not the gate.
|
|
416
430
|
- 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`.
|
|
417
431
|
- 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
432
|
|
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",".claude",".codex"]),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:"/.claude",dest:"/.claude"},{src:"/.codex",dest:"/.codex"},{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();
|
package/dist/settings/check.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""App-level quality gate: type check + lint + template lint + tests in one command.
|
|
2
2
|
|
|
3
|
-
Runs the
|
|
3
|
+
Runs the five app-owned checks against `main.py`, `src/**`, authored markup, and the
|
|
4
|
+
TypeScript dev tooling in `settings/`,
|
|
4
5
|
then prints a single, AI-friendly list of problems as `path:line:col` with the
|
|
5
6
|
message, so an agent (or a human) is told exactly which file and location to fix.
|
|
6
7
|
|
|
@@ -23,6 +24,7 @@ import argparse
|
|
|
23
24
|
import itertools
|
|
24
25
|
import json
|
|
25
26
|
import os
|
|
27
|
+
import re
|
|
26
28
|
import subprocess
|
|
27
29
|
import sys
|
|
28
30
|
import threading
|
|
@@ -266,6 +268,58 @@ def run_templates() -> Result:
|
|
|
266
268
|
return Result("templates", ok=not issues, issues=issues)
|
|
267
269
|
|
|
268
270
|
|
|
271
|
+
def run_node_tests() -> Result:
|
|
272
|
+
"""Run the TypeScript tests for the dev-stack tooling in `settings/`.
|
|
273
|
+
|
|
274
|
+
pyright/ruff/pytest cover Python, which left the dev tooling that is written
|
|
275
|
+
in TypeScript with no coverage at all -- including the reload hold, whose
|
|
276
|
+
whole job is to keep an agent's editing run from restarting the Python server
|
|
277
|
+
once per edit. A silent regression there is invisible from a green gate and
|
|
278
|
+
costs a restart storm on the next feature branch.
|
|
279
|
+
|
|
280
|
+
Invoked as `node --import tsx`, not `npx tsx`: `npx` resolves to a `.cmd`
|
|
281
|
+
shim on Windows that `subprocess` cannot exec from a list argv, while `node`
|
|
282
|
+
is a real executable on every platform the gate runs on.
|
|
283
|
+
"""
|
|
284
|
+
tests = sorted(str(p.relative_to(PROJECT_ROOT)) for p in PROJECT_ROOT.glob("settings/*.test.ts"))
|
|
285
|
+
if not tests:
|
|
286
|
+
return Result("node", ok=True, note="no TypeScript tests found")
|
|
287
|
+
|
|
288
|
+
proc = _run_streamed(["node", "--import", "tsx", "--test", *tests])
|
|
289
|
+
ok = proc.returncode == 0
|
|
290
|
+
|
|
291
|
+
issues: list[Issue] = []
|
|
292
|
+
if not ok:
|
|
293
|
+
# The spec reporter ends with a `failing tests:` block that pairs a
|
|
294
|
+
# `test at path:line:col` line with the failing test's name.
|
|
295
|
+
pending: tuple[str, int, int] | None = None
|
|
296
|
+
for raw in (proc.stdout + proc.stderr).splitlines():
|
|
297
|
+
line = raw.strip()
|
|
298
|
+
location = re.match(r"^test at (.+):(\d+):(\d+)$", line)
|
|
299
|
+
if location:
|
|
300
|
+
pending = (location.group(1), int(location.group(2)), int(location.group(3)))
|
|
301
|
+
continue
|
|
302
|
+
if pending and line.startswith("✖"):
|
|
303
|
+
name = re.sub(r"\s*\([\d.]+ms\)$", "", line[1:].strip())
|
|
304
|
+
path, line_no, column = pending
|
|
305
|
+
issues.append(
|
|
306
|
+
Issue(
|
|
307
|
+
path=path.replace("\\", "/"),
|
|
308
|
+
line=line_no,
|
|
309
|
+
column=column,
|
|
310
|
+
tool="node",
|
|
311
|
+
code="test",
|
|
312
|
+
message=name or "test failed",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
pending = None
|
|
316
|
+
|
|
317
|
+
note = ""
|
|
318
|
+
if not ok and not issues:
|
|
319
|
+
note = "node --test failed"
|
|
320
|
+
return Result("node", ok=ok, issues=issues, note=note)
|
|
321
|
+
|
|
322
|
+
|
|
269
323
|
def run_pytest() -> Result:
|
|
270
324
|
# `-o addopts=` drops the ini `-q` so `-v` can print one live line per test
|
|
271
325
|
# (the "which test is running" progress); `-rfE` keeps the `FAILED nodeid -
|
|
@@ -375,7 +429,7 @@ def main() -> int:
|
|
|
375
429
|
parser.add_argument(
|
|
376
430
|
"--only",
|
|
377
431
|
action="append",
|
|
378
|
-
choices=["pyright", "ruff", "templates", "pytest"],
|
|
432
|
+
choices=["pyright", "ruff", "templates", "node", "pytest"],
|
|
379
433
|
help="Run only the named tool(s). Repeatable. Default: all.",
|
|
380
434
|
)
|
|
381
435
|
parser.add_argument(
|
|
@@ -385,7 +439,7 @@ def main() -> int:
|
|
|
385
439
|
)
|
|
386
440
|
args = parser.parse_args()
|
|
387
441
|
|
|
388
|
-
selected = args.only or ["pyright", "ruff", "templates", "pytest"]
|
|
442
|
+
selected = args.only or ["pyright", "ruff", "templates", "node", "pytest"]
|
|
389
443
|
|
|
390
444
|
print()
|
|
391
445
|
print(bold("Caspian app checks") + " (live progress)")
|
|
@@ -398,12 +452,14 @@ def main() -> int:
|
|
|
398
452
|
results.append(_execute("ruff", run_ruff, streamed=False))
|
|
399
453
|
if "templates" in selected:
|
|
400
454
|
results.append(_execute("templates", run_templates, streamed=False))
|
|
455
|
+
if "node" in selected:
|
|
456
|
+
results.append(_execute("node", run_node_tests, streamed=True))
|
|
401
457
|
if "pytest" in selected:
|
|
402
458
|
results.append(_execute("pytest", run_pytest, streamed=True))
|
|
403
459
|
|
|
404
460
|
ok = print_report(results)
|
|
405
461
|
|
|
406
|
-
# Browser status is reported, never enforced. The
|
|
462
|
+
# Browser status is reported, never enforced. The five tools above are
|
|
407
463
|
# deterministic; whether a route has been exercised in a browser depends on
|
|
408
464
|
# someone clicking around, so folding it into the exit code would make the
|
|
409
465
|
# gate flaky and people would learn to ignore it. Printing it here is enough:
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PreToolUse` bridge between Claude Code and the dev-stack reload hold.
|
|
3
|
+
*
|
|
4
|
+
* `dev-hold.ts` explains why the hold exists. This file exists because of how it
|
|
5
|
+
* is *triggered*: the matcher in `.claude/settings.json` keys on the tool name,
|
|
6
|
+
* and `Edit|Write|NotebookEdit` is not the whole set of ways an agent writes a
|
|
7
|
+
* file. An agent running under bypass-permissions mode is told to prefer the
|
|
8
|
+
* Bash tool for file changes, so it edits with `cat > file <<'EOF'`, `sed -i`,
|
|
9
|
+
* or a throwaway Python script -- none of which that matcher can ever see. That
|
|
10
|
+
* gap is not hypothetical: it is what let one feature branch cost eight full
|
|
11
|
+
* Python restarts and five browser reloads instead of one of each.
|
|
12
|
+
*
|
|
13
|
+
* Adding a blanket `Bash` matcher would over-correct. Every `git status`, every
|
|
14
|
+
* `npm run test`, every `grep` would take the hold, and `npm run logs` would
|
|
15
|
+
* then print its `DEV HOLD ACTIVE -- this digest is stale` banner over a digest
|
|
16
|
+
* that is perfectly current -- training the reader to ignore the one warning
|
|
17
|
+
* that matters. So a Bash command takes the hold only when it can plausibly
|
|
18
|
+
* write into the tree (see `commandCanWrite`), and never when it is one of the
|
|
19
|
+
* hold's own controls -- which would otherwise re-freeze the stack in the same
|
|
20
|
+
* breath that released it -- or the read-only quality gate.
|
|
21
|
+
*
|
|
22
|
+
* The hook is fail-open by construction: unparseable input, an unknown tool, or
|
|
23
|
+
* a thrown error all exit 0 without a hold. A missing hold degrades to the old
|
|
24
|
+
* reload-per-edit behaviour; a hook that blocks tool calls would break the
|
|
25
|
+
* session outright, so that trade is deliberate and one-directional. It matters
|
|
26
|
+
* more than it looks: `PreToolUse` is fail-*closed* in GitHub Copilot, where a
|
|
27
|
+
* non-zero exit denies the tool call, so this file must never exit non-zero for
|
|
28
|
+
* any reason. Nothing here prints to stdout either -- an exit 0 with no output
|
|
29
|
+
* is "continue normally" in all three hosts.
|
|
30
|
+
*
|
|
31
|
+
* One script serves Claude Code, GitHub Copilot and Codex CLI: all three deliver
|
|
32
|
+
* the same PascalCase payload on stdin (`tool_name`, `tool_input.command`), so
|
|
33
|
+
* only the config file that points at it differs. See the "Dev hold" section of
|
|
34
|
+
* AGENTS.md for the three locations.
|
|
35
|
+
*
|
|
36
|
+
* Erasable-only TypeScript and dependency-free, like `dev-hold.ts`, so Node can
|
|
37
|
+
* run it directly in front of every tool call without a bundler in the path.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The hold module is loaded lazily, inside the try/catch in `main()`, and never
|
|
42
|
+
* imported at the top of this file.
|
|
43
|
+
*
|
|
44
|
+
* This is not a style choice. `PreToolUse` is **fail-closed** in GitHub Copilot:
|
|
45
|
+
* a non-zero exit denies the tool call outright. A static import that fails to
|
|
46
|
+
* resolve throws before any of this file's own code runs, so the process exits
|
|
47
|
+
* 1 and the agent is left unable to use a single tool -- and the failure mode
|
|
48
|
+
* is real, not theoretical: an extensionless `./dev-hold` specifier (valid under
|
|
49
|
+
* tsx, invalid under plain `node`, which is what runs this hook) did exactly
|
|
50
|
+
* that. Deferring the import keeps every failure inside a `catch` that exits 0.
|
|
51
|
+
*
|
|
52
|
+
* The `.ts` extension is load-bearing for the same reason and must not be
|
|
53
|
+
* "tidied" away. Both are pinned by `dev-hold.test.ts` -> "the hook binary".
|
|
54
|
+
*/
|
|
55
|
+
const HOLD_MODULE = "./dev-hold.ts";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tool names are normalised before matching, because the three hosts spell the
|
|
59
|
+
* same tool differently: Claude Code's `Edit`, Codex CLI's `apply_patch`, and
|
|
60
|
+
* VS Code Copilot's `insert_edit_into_file` all mean "an agent is writing a
|
|
61
|
+
* file". Lower-casing and dropping non-letters collapses those spellings so one
|
|
62
|
+
* set covers every host, and a name nobody predicted simply falls through to
|
|
63
|
+
* "do not hold" rather than to a crash.
|
|
64
|
+
*/
|
|
65
|
+
function normalizeToolName(name: string): string {
|
|
66
|
+
return name.toLowerCase().replace(/[^a-z]/g, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Tools whose whole purpose is writing a file. Always take the hold. */
|
|
70
|
+
const ALWAYS_HOLD_TOOLS = new Set(
|
|
71
|
+
[
|
|
72
|
+
// Claude Code
|
|
73
|
+
"Edit",
|
|
74
|
+
"Write",
|
|
75
|
+
"MultiEdit",
|
|
76
|
+
"NotebookEdit",
|
|
77
|
+
// Codex CLI
|
|
78
|
+
"apply_patch",
|
|
79
|
+
"write_file",
|
|
80
|
+
// Copilot (CLI + VS Code)
|
|
81
|
+
"edit",
|
|
82
|
+
"create",
|
|
83
|
+
"str_replace",
|
|
84
|
+
"str_replace_editor",
|
|
85
|
+
"create_file",
|
|
86
|
+
"insert_edit_into_file",
|
|
87
|
+
"replace_string_in_file",
|
|
88
|
+
"apply_diff",
|
|
89
|
+
].map(normalizeToolName),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Tools that run a shell command. These hold only when the command itself looks
|
|
94
|
+
* like a writer -- see `commandCanWrite`.
|
|
95
|
+
*/
|
|
96
|
+
const SHELL_TOOLS = new Set(
|
|
97
|
+
["Bash", "BashOutput", "bash", "shell", "exec_command", "run_in_terminal", "terminal"].map(
|
|
98
|
+
normalizeToolName,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Commands that must never take the hold, whatever else they look like.
|
|
104
|
+
*
|
|
105
|
+
* Two groups, for two different reasons. The hold's own controls come first:
|
|
106
|
+
* `npm run dev:resume` releases and drains, so re-acquiring on the very next
|
|
107
|
+
* Bash call would undo the drain the agent just asked for, and `npm run logs`
|
|
108
|
+
* reads the result of that drain, so holding around it would stamp a fresh
|
|
109
|
+
* digest stale.
|
|
110
|
+
*
|
|
111
|
+
* The quality gate is the second group. It runs through `uv run python`, which
|
|
112
|
+
* the write-intent list below treats as a writer -- correctly, since a throwaway
|
|
113
|
+
* Python script is a common way for an agent to edit a file. But `check.py` only
|
|
114
|
+
* ever reports, so letting it hold would raise the stale-digest banner over a
|
|
115
|
+
* digest nothing had invalidated.
|
|
116
|
+
*/
|
|
117
|
+
const NEVER_HOLD_PATTERN = new RegExp(
|
|
118
|
+
[
|
|
119
|
+
"dev-hold",
|
|
120
|
+
"dev:hold",
|
|
121
|
+
"dev:resume",
|
|
122
|
+
"browser_log\\.py",
|
|
123
|
+
"npm\\s+run\\s+logs",
|
|
124
|
+
// The gate, by either path separator: `check.py` reports and never writes.
|
|
125
|
+
"settings[\\\\/]check\\.py",
|
|
126
|
+
"\\bpyright\\b",
|
|
127
|
+
"\\bruff\\s+check\\b",
|
|
128
|
+
"\\bpytest\\b",
|
|
129
|
+
].join("|"),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Shapes of Bash command that can put bytes on disk.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately generous: a false positive costs one deferred reload that the
|
|
136
|
+
* `Stop` hook drains anyway, while a false negative costs a full server restart
|
|
137
|
+
* mid-run. When in doubt, hold.
|
|
138
|
+
*/
|
|
139
|
+
const WRITE_INTENT_PATTERNS: RegExp[] = [
|
|
140
|
+
/>>?\s*\S/, // shell redirection, including `cat > file <<'EOF'`
|
|
141
|
+
/\btee\b/,
|
|
142
|
+
/\bsed\b[^|]*-i/, // in-place sed
|
|
143
|
+
/\b(cp|mv|rm|mkdir|touch|ln)\b/,
|
|
144
|
+
/\bgit\s+(checkout|restore|apply|stash|revert|merge|rebase|pull|clean|mv|rm)\b/,
|
|
145
|
+
/\bnpm\s+run\s+(format|test:fix|build|css|static)\b/,
|
|
146
|
+
/\bnpx\s+(ppy|prisma|maddex|ppicons)\b/,
|
|
147
|
+
/\bpython\b/, // a throwaway script is still a writer
|
|
148
|
+
/\buv\s+run\b/,
|
|
149
|
+
/\bruff\s+format\b/,
|
|
150
|
+
/\bdjlint\b/,
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
export function commandCanWrite(command: string): boolean {
|
|
154
|
+
if (!command) return false;
|
|
155
|
+
if (NEVER_HOLD_PATTERN.test(command)) return false;
|
|
156
|
+
return WRITE_INTENT_PATTERNS.some((pattern) => pattern.test(command));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function shouldHold(toolName: string, command: string): boolean {
|
|
160
|
+
const normalized = normalizeToolName(toolName);
|
|
161
|
+
if (ALWAYS_HOLD_TOOLS.has(normalized)) return true;
|
|
162
|
+
if (SHELL_TOOLS.has(normalized)) return commandCanWrite(command);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readStdin(): Promise<string> {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
let raw = "";
|
|
169
|
+
// No stdin (a manual invocation, or a host that does not pipe the payload)
|
|
170
|
+
// must not hang the tool call, so resolve on `end` and on nothing at all.
|
|
171
|
+
if (process.stdin.isTTY) {
|
|
172
|
+
resolve("");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
process.stdin.setEncoding("utf8");
|
|
176
|
+
process.stdin.on("data", (chunk) => {
|
|
177
|
+
raw += chunk;
|
|
178
|
+
});
|
|
179
|
+
process.stdin.on("end", () => resolve(raw));
|
|
180
|
+
process.stdin.on("error", () => resolve(""));
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function main(): Promise<void> {
|
|
185
|
+
let toolName = "";
|
|
186
|
+
let command = "";
|
|
187
|
+
try {
|
|
188
|
+
const payload = JSON.parse((await readStdin()).trim() || "{}");
|
|
189
|
+
// PascalCase events carry snake_case fields in all three hosts; Copilot's
|
|
190
|
+
// camelCase event names carry camelCase ones. Accept either rather than
|
|
191
|
+
// depending on which spelling a host's config happened to select.
|
|
192
|
+
toolName = String(payload?.tool_name ?? payload?.toolName ?? "");
|
|
193
|
+
const input = payload?.tool_input ?? payload?.toolArgs ?? payload?.toolInput ?? {};
|
|
194
|
+
command = String(input?.command ?? input?.commandLine ?? input?.script ?? "");
|
|
195
|
+
} catch {
|
|
196
|
+
// Malformed payload: fail open rather than guessing at a hold.
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!shouldHold(toolName, command)) return;
|
|
201
|
+
const { acquireDevHold } = await import(HOLD_MODULE);
|
|
202
|
+
acquireDevHold(process.env.CASPIAN_DEV_HOLD_OWNER || "agent");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (process.argv[1] && import.meta.filename === process.argv[1]) {
|
|
206
|
+
main()
|
|
207
|
+
.catch(() => {
|
|
208
|
+
// Fail open: never block a tool call over a reload optimisation.
|
|
209
|
+
})
|
|
210
|
+
.finally(() => process.exit(0));
|
|
211
|
+
}
|
package/dist/tests/README.md
CHANGED
|
@@ -19,7 +19,7 @@ location to fix.
|
|
|
19
19
|
While debugging you can narrow to one tool:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
uv run python settings/check.py --only pyright # or ruff / templates / pytest
|
|
22
|
+
uv run python settings/check.py --only pyright # or ruff / templates / node / pytest
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
## The `templates` check
|