create-caspian-app 1.3.21 → 1.3.23

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/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";let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.py","not-found.py","error.py"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.py","src/app/index.py"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.py","src/app/index.py","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py","pyproject.toml"]},realtime:{id:"realtime",name:"Real-time Application",description:"Application with WebSocket support and MCP",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!0,websocket:!0},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const n=e.indexOf("\\htdocs\\");if(-1===n)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,n+8).replace(/\\/g,"\\\\"),s=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let i=`http://localhost/${s}`;i=i.endsWith("/")?i.slice(0,-1):i;const c=i.replace(/(?<!:)(\/\/+)/g,"/"),a=s.replace(/\/\/+/g,"/");return{bsTarget:`${c}/`,bsPathRewrite:{"^/":`/${a.startsWith("/")?a.substring(1):a}/`}}}async function updatePackageJson(e,n){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const s=JSON.parse(fs.readFileSync(t,"utf8"));s.scripts={...s.scripts,projectName:"tsx settings/project-name.ts",format:"uv run python settings/format.py","format:check":"uv run python settings/format.py --check",check:"uv run python settings/check.py","check:fix":"uv run python settings/fix.py",logs:"uv run python settings/browser_log.py",static:"npm run build && uv run python settings/build-static.py","static:serve":"uv run python settings/serve-static.py"};let i=[];n.tailwindcss&&(s.scripts={...s.scripts,tailwind:"tsx settings/run-postcss.ts watch","tailwind:build":"tsx settings/run-postcss.ts build"},i.push("tailwind")),n.typescript&&!n.backendOnly&&(s.scripts={...s.scripts,"ts:watch":"vite build --watch","ts:watch:dev":"tsx settings/run-vite-watch.ts","ts:build":"vite build"},i.push("ts:watch:dev")),n.mcp&&(s.scripts={...s.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let c={...s.scripts};c.browserSync="tsx settings/bs-config.ts",c.dev=`npm-run-all projectName -l -p browserSync ${i.join(" ")}`;let a=["projectName"];n.tailwindcss&&a.unshift("tailwind:build"),n.typescript&&!n.backendOnly&&a.unshift("ts:build"),c.build=`npm-run-all ${a.join(" ")}`,s.scripts=c,s.type="module",fs.writeFileSync(t,JSON.stringify(s,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function buildEnvSection(e,n){return`# =============================================================================\n${e.split("\n").map(e=>e.startsWith("#")?e:`# ${e}`).join("\n")}\n# =============================================================================\n\n${n.trimEnd()}`}function buildCaspianEnvContent(e){const n=generateAuthSecret(),t=generateHexEncodedKey(8),s=generateHexEncodedKey(32),i=[];return e.prisma&&i.push(buildEnvSection("1. DATABASE\n# Enforced by: prisma/schema.prisma, src/lib/prisma/db.py",'# Connection string. Prisma reads this directly from .env.\n# Format reference: https://pris.ly/d/connection-strings\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"\n\n# Connection-pool limit. Defaults: SQLite 5; MySQL and PostgreSQL 20.\n# Use 5 for local development; production does not need this unless you want to\n# limit the pool.\nDB_POOL_SIZE=5\n\n# Seconds idle before the client re-probes its connection. Default 30.\nPRISMA_CONN_PROBE_IDLE_SECONDS=30\n\n# Warn on queries that cause a full table scan. 0/false silences it. Default 1.\nPRISMA_WARN_FULL_SCAN=1')),i.push(buildEnvSection("2. APPLICATION RUNTIME\n# Enforced by: casp/runtime_security.py is_production_environment()",'# Environment selector, resolved FAIL-CLOSED: only an explicit development\n# value (dev, development, local, staging, test, testing) enables the\n# development relaxations. Unset or misspelled counts as production.\n#\n# Production turns on: HTTPS-only session cookie, Secure CSRF cookie, HSTS,\n# generic error messages, mandatory AUTH_SECRET, mandatory MCP_AUTH_TOKEN, and\n# it removes the localhost origin bypass and the WebSocket same-origin fallback.\n#\n# This single value gates most of the security posture. Set it deliberately.\nAPP_ENV="development"\n\n# Calendar timezone for the application, as an IANA name (e.g. "UTC",\n# "America/New_York", "America/Santo_Domingo"). Read by casp/app_time.py and\n# resolved once at boot in main.py.\n#\n# This sets which wall-clock DAY an instant belongs to: what casp.app_time.now()\n# and today() answer, how a stored timestamp reads back to a user, and the\n# boundaries a "today\'s totals" query uses. Timestamps are still STORED in UTC.\n#\n# It deliberately does NOT affect absolute time -- session expiry (casp/auth.py)\n# and cache TTLs (casp/cache_handler.py) stay on UTC, so changing this can never\n# extend a session or a cache entry.\n#\n# An unrecognized name raises InvalidAppTimezoneError at startup rather than\n# silently falling back to UTC. Empty or unset means UTC.\nAPP_TIMEZONE="UTC"'),buildEnvSection("3. PUBLIC URL, CORS, AND ORIGIN VALIDATION\n# Enforced by: casp/rpc.py origin checks, main.py CORS layer",'# Canonical public origin. Leave empty when the browser URL and the app runtime\n# URL match. Set it when they differ, i.e. behind an ingress, reverse proxy,\n# load balancer, gateway, edge network, or TLS terminator.\nAPP_BASE_URL=""\n\n# Extra browser origins allowed to call protected endpoints such as RPC. Use\n# when one deployment is reachable from more than one public origin.\n# Comma-separated, no spaces.\nCORS_ALLOWED_ORIGINS=""\n\n# Trust Forwarded/X-Forwarded-* headers. Enable ONLY when every request passes\n# through infrastructure that strips client-supplied forwarded headers before\n# setting its own, because a direct client can otherwise forge them.\n#\n# Affects two things: which origin RPC accepts, and which address the rate\n# limiter buckets on. Left false, both use the direct request instead.\nTRUST_FORWARDED_HEADERS="false"\n\n# Allow cookies/Authorization on cross-origin requests. Keep true only when\n# credentialed cross-origin requests are actually required.\nCORS_ALLOW_CREDENTIALS="true"\n\n# CORS preflight response fields.\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\n\n# Preflight cache duration in seconds.\nCORS_MAX_AGE="86400"'),buildEnvSection("4. AUTHENTICATION AND SESSIONS\n# Enforced by: casp/auth.py, main.py SessionMiddleware\n# Route privacy and RBAC live in src/lib/auth/auth_config.py, not here.",`# Session signing secret. Unique and strong per app and per environment.\n# In production the app refuses to start when this is missing or left on a\n# placeholder ("change-me"/"changeme"); in development it falls back.\nAUTH_SECRET="${n}"\n\n# Session cookie name. Use a unique value when several apps share a parent\n# domain, or their sessions overwrite each other.\nAUTH_COOKIE_NAME="${t}"\n\n# Session lifetime in hours (SessionMiddleware max_age).\nSESSION_LIFETIME_HOURS="7"`),buildEnvSection("5. OAUTH PROVIDERS\n# Enforced by: casp/auth.py; routes served by main.py AuthMiddleware","# Google and GitHub sign-in are already wired: AuthMiddleware serves\n# /api/auth/signin/{google,github} and /api/auth/callback/{google,github}.\n# Link a button at those paths, do not hand-roll OAuth.\n#\n# A provider with no client id is skipped SILENTLY: the redirect returns None\n# and the button appears dead, with no error and no log. Empty means disabled.\n\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n\n# Must match the redirect URI registered in Google Cloud Console exactly.\n# Google is skipped unless BOTH the client id and this value are set.\nGOOGLE_REDIRECT_URI=\n\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET="),buildEnvSection("6. REQUEST SECURITY\n# Enforced by: main.py BodySizeLimitMiddleware, RequestDiagnosticsMiddleware",'# Max size of the whole HTTP request body in MB. Caps the entire body (file +\n# form fields + encoding overhead), so usable file size is a bit below this.\n# Middleware rejects oversized requests before the route runs. Raise if valid\n# uploads are blocked. Default 16.\nMAX_CONTENT_LENGTH_MB="16"\n\n# Seconds before a stalled route returns 504. Streaming paths (/mcp) are exempt\n# so long-lived transports are not cut mid-response. Default 20.\nCASPIAN_REQUEST_TIMEOUT_SECONDS=20'),buildEnvSection("7. SECURITY HEADERS\n# Enforced by: casp/runtime_security.py, main.py SecurityHeadersMiddleware","# Replaces the built-in Content-Security-Policy wholesale. Empty keeps the\n# default, which already permits the app's own assets.\n#\n# Any replacement MUST keep 'unsafe-eval' and 'unsafe-inline' in script-src:\n# the PulsePoint runtime compiles component templates with new Function(), so\n# removing them stops every page from rendering. Set this only to widen the\n# policy, e.g. for a CDN, analytics host, or an external frame embedder.\n#\n# Outside production, connect-src also allows http(s)/ws on localhost and\n# 127.0.0.1 on any port, because BrowserSync serves the proxied page on one port\n# while its injected live-reload client polls the BrowserSync server on another.\n# That is a separate origin, so 'self' does not cover it. Those entries are\n# omitted from a production policy. Setting an override here replaces BOTH, so\n# an override used in development must include the loopback sources itself or\n# live reload stops working.\n#\n# img-src and media-src admit remote content by scheme, so posters, avatars, CDN\n# thumbnails, and video load without per-project configuration. Plain http: is\n# development-only. Set an override here to pin them to named origins instead.\n#\n# Default: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';\n# style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;\n# media-src 'self' data: blob: https:; font-src 'self' data:;\n# connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';\n# form-action 'self'; frame-ancestors 'self'\nCONTENT_SECURITY_POLICY="),buildEnvSection("8. RATE LIMITING\n# Enforced by: main.py RateLimitMiddleware, casp/rpc.py RPCRateLimiter\n# Buckets are per client address; see TRUST_FORWARDED_HEADERS for which one.",'# Per-IP cap on page requests, applied before session decryption and rendering.\n# Static assets (/css, /js, /assets, /favicon.ico) and /health are exempt, so a\n# page load does not spend its own budget on its assets. Empty disables it.\n# Default 200/minute.\nRATE_LIMIT_PAGES=200/minute\n\n# Fallback limit for @rpc() actions that declare no limits= of their own.\nRATE_LIMIT_RPC="60 per minute"\n\n# Limit applied to @rpc(require_auth=True) actions that declare no limits=.\n# Tighten this and the per-action limits on sign-in and other credential paths.\nRATE_LIMIT_AUTH="60 per minute"\n\n# Configured for slowapi\'s Limiter. Note that slowapi\'s middleware is not in\n# the stack, so this value is inert today; page limiting is RATE_LIMIT_PAGES.\nRATE_LIMIT_DEFAULT="200 per minute"\n\n# In-memory bucket ceiling and sweep interval for the limiter store.\n# Defaults 10000 buckets, swept every 60 seconds.\nRATE_LIMIT_MAX_BUCKETS=10000\nRATE_LIMIT_CLEANUP_INTERVAL=60')),e.websocket&&i.push(buildEnvSection("9. WEBSOCKETS\n# Enforced by: src/lib/websocket/websocket_security.py, main.py channel loop\n# Only active when caspian.config.json has websocket: true.","# Browser origins allowed to open a socket (anti-CSWSH). Falls back to\n# CORS_ALLOWED_ORIGINS then APP_BASE_URL when empty.\n#\n# REQUIRED IN PRODUCTION. The convenience same-origin fallback is derived from\n# the client-supplied Host header, so it is development-only: without an\n# explicit list a spoofed Host plus matching Origin would validate itself.\n#\n# The HTTP middleware stack skips websocket scopes, so this and the socket\n# guard are the only checks a handshake passes. Comma-separated, no spaces.\nWEBSOCKET_ALLOWED_ORIGINS=\n\n# Seconds a socket may stay silent before the server closes it. Default 120.\nWEBSOCKET_IDLE_TIMEOUT_SECONDS=120\n\n# Max size of one inbound socket message in bytes. Oversized closes with 1009.\n# Default 4096.\nMAX_WEBSOCKET_MESSAGE_BYTES=4096\n\n# Simultaneous connections per pool; authenticated and guest pools are counted\n# separately. Refused connections close with 1013 during the handshake. Every\n# open socket is a live task and a broadcast target. Default 200.\nMAX_WEBSOCKET_CONNECTIONS=200\n\n# Per-connection send budget: messages allowed per rolling window. Each\n# accepted message fans out to the whole pool, so this bounds how much\n# broadcast one connection can generate. Defaults 20 per 10 seconds.\nMAX_WEBSOCKET_MESSAGES_PER_WINDOW=20\nWEBSOCKET_RATE_WINDOW_SECONDS=10")),e.mcp&&i.push(buildEnvSection("10. MCP ENDPOINT\n# Enforced by: main.py MCPAuthMiddleware; tools in src/lib/mcp/mcp_server.py\n# Only active when caspian.config.json has mcp: true.",`# Bearer token required to call /mcp. The MCP app is mounted outside the page\n# routing tree, so AuthMiddleware does NOT protect it, and its tools enumerate\n# the workspace file inventory and component map.\n#\n# generated -> every request needs "Authorization: Bearer <token>"\n#\n# REQUIRED IN PRODUCTION for the endpoint to work at all.\nMCP_AUTH_TOKEN="${s}"`)),i.push(buildEnvSection("11. CACHE\n# Enforced by: casp/cache_handler.py, main.py is_request_cacheable()",'# Master switch for serving pages from the disk cache.\n#\n# Entries are keyed on the URI alone, with no session component, so an\n# authenticated render is never cached: is_request_cacheable() gates both the\n# read and the write, and a route\'s Cache(...) cannot override it.\nCACHE_ENABLED="false"\n\n# Default cache lifetime in seconds, used when a route sets no ttl.\nCACHE_TTL="600"'),buildEnvSection("12. SERVER PROCESS\n# Enforced by: main.py __main__, settings/serve-static.py",'# Uvicorn workers are separate OS processes for the same FastAPI app. More\n# workers can increase throughput under concurrent load, but they do not make a\n# single request faster and they duplicate memory, connection pools, and any\n# in-process state. Keep at 1 unless the app is designed for multi-process\n# coordination and testing shows a real concurrency bottleneck.\nUVICORN_WORKERS="1"')),i.join("\n\n")}function copyRecursiveSync(e,n,t){const s=fs.existsSync(e),i=s&&fs.statSync(e);if(s&&i&&i.isDirectory()){const s=n.toLowerCase();if(!t.mcp&&s.includes("src\\lib\\mcp"))return;if(!t.websocket&&s.includes("src\\lib\\websocket"))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\ts")||s.includes("\\ts\\")))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\vite-plugins")||s.includes("\\vite-plugins\\")||s.includes("\\vite-plugins")))return;if(t.backendOnly&&s.includes("public\\js")||t.backendOnly&&s.includes("public\\css")||t.backendOnly&&s.includes("public\\assets"))return;const i=n.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),fs.readdirSync(e).forEach(s=>{copyRecursiveSync(path.join(e,s),path.join(n,s),t)})}else{if(checkExcludeFiles(n))return;const s=n.replace(/\\/g,"/").toLowerCase();if(s.endsWith("/settings/run-vite-watch.ts")&&(!t.typescript||t.backendOnly))return;if(s.endsWith("/ts/tailwind-merge.ts")&&(!t.typescript||t.backendOnly||!t.tailwindcss))return;if(!t.tailwindcss&&(n.includes("globals.css")||n.includes("styles.css")))return;if(!t.mcp&&n.includes("restart-mcp.ts"))return;if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(t.backendOnly&&nonBackendFiles.some(e=>n.includes(e)))return;if(t.backendOnly&&n.includes("layout.py"))return;if(t.tailwindcss&&n.includes("index.css"))return;if(!t.prisma&&n.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,n,0)}}async function executeCopy(e,n,t){n.forEach(({src:n,dest:s})=>{const i=normalizeTemplatePath(n),c=resolveTemplateSourcePath(n,"directory"),a=path.join(e,s);if(!c){if(OPTIONAL_TEMPLATE_DIRECTORIES.has(i))return void console.log(chalk.gray(`Optional template directory not found, skipping: ${i}`));throw new Error(`Template directory not found: ${i}. The package may be incomplete.`)}copyRecursiveSync(c,a,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.py");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),s="";n.backendOnly||(n.tailwindcss||(s='\n <link href="/css/index.css" rel="stylesheet" />'),s+='\n <script type="module" src="/js/main.js"><\/script>');let i="";n.backendOnly||(i=n.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${s}`:s),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.py:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function ensureClaudeMd(e){const n=path.join(e,"CLAUDE.md");if(checkExcludeFiles(n))return;const t="@AGENTS.md";if(!fs.existsSync(n))return void fs.writeFileSync(n,`${t}\n`,{flag:"w"});const s=fs.readFileSync(n,"utf8").replace(/^\uFEFF/,"");if(s.trimStart().startsWith(t))return;const i=`${t}\n\n${s.trimStart()}`;fs.writeFileSync(n,i,{flag:"w"})}function writeTailwindMainJs(e){const n=path.join(e,"public","js","main.js");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\nimport { twMerge } from "/js/tailwind-merge.mjs";\n\nconst pp = (globalThis).pp;\n\nglobalThis.twMerge = twMerge;\n\nif (document.readyState !== "loading") {\n pp?.mount?.();\n} else {\n document.addEventListener(\n "DOMContentLoaded",\n () => pp?.mount?.(),\n { once: true },\n );\n}\n',{flag:"w"}))}function copyTailwindMergeBundle(e){const n=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs"),t=path.join(e,"public","js","tailwind-merge.mjs"),s=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs.map"),i=path.join(e,"public","js","bundle-mjs.mjs.map");if(!checkExcludeFiles(t)){if(!fs.existsSync(n))throw new Error(`tailwind-merge bundle not found at ${n}`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.copyFileSync(n,t),!checkExcludeFiles(i)&&fs.existsSync(s)&&fs.copyFileSync(s,i)}}function writeTailwindTypeScriptMain(e){const n=path.join(e,"ts","main.ts");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\n\n// The following global names have already been declared elsewhere in the project:\n// - pp: Used for the Reactive Core functionality.\n\n// Imports goes here --Start\nimport { createGlobalSingleton } from "./global-functions.js";\nimport { mergeTailwindClasses } from "./tailwind-merge.js";\n\ncreateGlobalSingleton("twMerge", mergeTailwindClasses);\n\n\n// Imports goes here --End\n\nconst pp = (globalThis as any).pp;\n\nif (document.readyState !== "loading") {\n\tpp?.mount?.();\n} else {\n\tdocument.addEventListener(\n\t\t"DOMContentLoaded",\n\t\t() => pp?.mount?.(),\n\t\t{ once: true },\n\t);\n}\n',{flag:"w"}))}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const n=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(n)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const t=e.replace(/\\/g,"/");return n.endsWith("/"+t)||n===t})}function normalizeTemplatePath(e){return e.replace(/^[\\/]+/,"")}function resolveTemplateSourcePath(e,n){const t=normalizeTemplatePath(e),s=[path.join(__dirname,t),path.join(PACKAGE_ROOT,t)];for(const e of s){if(!fs.existsSync(e))continue;const t=fs.statSync(e);if("file"===n&&t.isFile())return e;if("directory"===n&&t.isDirectory())return e}return null}function extractCaspianSection(e){const n=e.indexOf(CASPIAN_SECTION_START);if(-1===n)return null;const t=e.indexOf(CASPIAN_SECTION_END,n);return-1===t?null:e.slice(t>n?n:0,t+20)}function mergeAgentsCaspianSection(e,n){const t=extractCaspianSection(n);if(!t)return e;const s=e.indexOf(CASPIAN_SECTION_START),i=e.indexOf(CASPIAN_SECTION_END,s);if(-1!==s&&-1!==i){return`${e.slice(0,s)}${t}${e.slice(i+20)}`}const c=e.endsWith("\n");return`${e}${c?"\n":"\n\n"}${t}\n`}async function createDirectoryStructure(e,n){const t=[{src:"/main.py",dest:"/main.py"},{src:"/.prettierrc",dest:"/.prettierrc"},{src:"/pyproject.toml",dest:"/pyproject.toml"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"},{src:"/AGENTS.md",dest:"/AGENTS.md"},{src:"/.python-version",dest:"/.python-version"}];n.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),n.typescript&&!n.backendOnly&&t.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const s=[{src:"/settings",dest:"/settings"},{src:"/tests",dest:"/tests"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"},{src:"/.github",dest:"/.github"},{src:"/.vscode",dest:"/.vscode"}];n.typescript&&!n.backendOnly&&s.push({src:"/ts",dest:"/ts"}),t.forEach(({src:n,dest:t})=>{const s=normalizeTemplatePath(n),i=resolveTemplateSourcePath(n,"file"),c=path.join(e,t);if(checkExcludeFiles(c))return;if(!i){if(OPTIONAL_TEMPLATE_FILES.has(s))return void console.log(chalk.gray(`Optional template file not found, skipping: ${s}`));throw new Error(`Template file not found: ${s}. The package may be incomplete.`)}if("/pyproject.toml"===n&&updateAnswer?.isUpdate&&fs.existsSync(c))return void console.log(chalk.gray("Preserving existing pyproject.toml during update."));const a=fs.readFileSync(i,"utf8");if("/AGENTS.md"===n&&updateAnswer?.isUpdate&&fs.existsSync(c)){const e=mergeAgentsCaspianSection(fs.readFileSync(c,"utf8"),a);return void fs.writeFileSync(c,e,{flag:"w"})}fs.writeFileSync(c,a,{flag:"w"})}),await executeCopy(e,s,n),ensureClaudeMd(e),n.tailwindcss&&!n.backendOnly&&(n.typescript?writeTailwindTypeScriptMain(e):(copyTailwindMergeBundle(e),writeTailwindMainJs(e))),await updatePackageJson(e,n),!n.tailwindcss&&n.backendOnly||modifyLayoutPHP(e,n),await createOrUpdateEnvFile(e,buildCaspianEnvContent(n))}async function getAnswer(e={},n=!1){if(n)return{projectName:e.projectName??"my-app",backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};if(e.starterKit){const n=e.starterKit;let t=null;if(STARTER_KITS[n]&&(t=STARTER_KITS[n]),t){const s={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:t.features.backendOnly??!1,tailwindcss:t.features.tailwindcss??!1,prisma:t.features.prisma??!1,mcp:t.features.mcp??!1,websocket:t.features.websocket??!1,typescript:t.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(s.backendOnly=!0),i.includes("--tailwindcss")&&(s.tailwindcss=!0),i.includes("--mcp")&&(s.mcp=!0),i.includes("--websocket")&&(s.websocket=!0),i.includes("--prisma")&&(s.prisma=!0),i.includes("--typescript")&&(s.typescript=!0),s}if(e.starterKitSource){const t={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1,typescript:!1},s=process.argv.slice(2);return s.includes("--backend-only")&&(t.backendOnly=!0),s.includes("--tailwindcss")&&(t.tailwindcss=!0),s.includes("--mcp")&&(t.mcp=!0),s.includes("--websocket")&&(t.websocket=!0),s.includes("--prisma")&&(t.prisma=!0),s.includes("--typescript")&&(t.typescript=!0),t}}const t=[];e.projectName||t.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||updateAnswer?.isUpdate||t.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const s=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(t,{onCancel:s}),c=[];i.backendOnly??e.backendOnly??!1?(e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||c.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||c.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const a=await prompts(c,{onCancel:s});return{projectName:i.projectName?String(i.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:i.backendOnly??e.backendOnly??!1,tailwindcss:a.tailwindcss??e.tailwindcss??!1,typescript:a.typescript??e.typescript??!1,mcp:a.mcp??e.mcp??!1,websocket:a.websocket??e.websocket??!1,prisma:a.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,n,t=!1){console.log("Uninstalling Node dependencies:"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["uninstall",t?"--save-dev":"--save",...n]);execSync(s,{stdio:"inherit",cwd:e})}function buildManagedNpmCommand(e){return`npm ${e.join(" ")} --ignore-scripts=false --min-release-age=0 --audit=false`}function fetchPackageVersion(e){return new Promise((n,t)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let s="";e.on("data",e=>s+=e),e.on("end",()=>{try{const e=JSON.parse(s);n(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}})}).on("error",e=>t(e))})}const readJsonFile=e=>{const n=fs.readFileSync(e,"utf8");return JSON.parse(n)};function compareVersions(e,n){const t=e.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/),s=n.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);if(!t||!s)return e.localeCompare(n);const i=t.slice(1,4).map(Number),c=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>c[e])return 1;if(i[e]<c[e])return-1}const a=t[4]??null,o=s[4]??null;return a&&!o?-1:!a&&o?1:a&&o?a.localeCompare(o):0}function getInstalledPackageInfo(e){try{const n=execSync(buildManagedNpmCommand(["list","-g",e,"--depth=0"])).toString(),t=n.match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`));return t?{version:t[1],isLinked:n.includes(`${e}@`)&&n.includes("->")}:(console.error(`Package ${e} is not installed`),{version:null,isLinked:!1})}catch(e){return console.error(e instanceof Error?e.message:String(e)),{version:null,isLinked:!1}}}function isRunningFromNpxCache(e){const n=path.resolve(e).toLowerCase(),t=`${path.sep}_npx${path.sep}`.toLowerCase();return n.includes(t)}async function installNpmDependencies(e,n,t=!1){fs.existsSync(path.join(e,"package.json"))?console.log("Updating existing Node.js project..."):console.log("Initializing new Node.js project..."),fs.existsSync(path.join(e,"package.json"))||execSync(buildManagedNpmCommand(["init","-y"]),{stdio:"inherit",cwd:e}),console.log((t?"Installing development dependencies":"Installing dependencies")+":"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["install",...t?["--save-dev"]:[],...n]);execSync(s,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.3.3","@types/browser-sync":"2.29.1","@types/node":"26.2.0","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"8.0.5","npm-run-all":"4.1.5",postcss:"8.5.26","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.12",typescript:"7.0.2",vite:"8.2.0",vitest:"4.1.10","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.2.x","tailwind-merge":"3.6.0"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}function removeDirectorySafe(e){if(fs.existsSync(e))try{return void fs.rmSync(e,{recursive:!0,force:!0,maxRetries:5,retryDelay:250})}catch(n){const t=n;if("win32"===globalThis.process?.platform&&("EPERM"===t.code||"EACCES"===t.code)){try{spawnSync("cmd",["/c","attrib","-R","-H","-S","/S","/D",`${e}\\*`],{stdio:"ignore"})}catch{}return void spawnSync("cmd",["/c","rd","/s","/q",e],{stdio:"ignore"})}throw n}}async function setupStarterKit(e,n){if(!n.starterKit)return;let t=null;if(STARTER_KITS[n.starterKit]?t=STARTER_KITS[n.starterKit]:n.starterKitSource&&(t={id:n.starterKit,name:`Custom Starter Kit (${n.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:n.starterKitSource}}),t){if(console.log(chalk.green(`Setting up ${t.name}...`)),t.source)try{const s=t.source.branch?`git clone -b ${t.source.branch} --depth 1 ${t.source.url} "${e}"`:`git clone --depth 1 ${t.source.url} "${e}"`;execSync(s,{stdio:"inherit"});removeDirectorySafe(path.join(e,".git")),console.log(chalk.blue("Starter kit cloned successfully!"));const i=path.join(e,"caspian.config.json");if(fs.existsSync(i))try{const t=JSON.parse(fs.readFileSync(i,"utf8")),s=e,c=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=c.bsTarget,t.bsPathRewrite=c.bsPathRewrite;const a=await fetchPackageVersion("create-caspian-app");t.version=t.version||a,fs.writeFileSync(i,JSON.stringify(t,null,2)),console.log(chalk.green("Updated caspian.config.json with new project details"))}catch(e){console.warn(chalk.yellow("Failed to update caspian.config.json, will create new one"))}}catch(e){throw console.error(chalk.red(`Failed to setup starter kit: ${e}`)),e}t.customSetup&&await t.customSetup(e,n),console.log(chalk.green(`āœ“ ${t.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${n.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\nšŸš€ Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const n=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(n)}`)),console.log(` ${e.name}`),console.log(chalk.gray(` ${e.description}`)),e.source&&console.log(chalk.cyan(` Source: ${e.source.url}`));const t=Object.entries(e.features).filter(([,e])=>!0===e).map(([e])=>e).join(", ");t&&console.log(chalk.magenta(` Features: ${t}`)),console.log()}),console.log(chalk.yellow("Usage:")),console.log(" npx create-caspian-app my-project --starter-kit=basic"),console.log(" npx create-caspian-app my-project --starter-kit=custom --starter-kit-source=https://github.com/user/repo"),console.log()}function runCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"inherit",shell:!1,encoding:"utf8"});if(s.error)throw s.error;if(0!==s.status)throw new Error(`Command failed (${e} ${n.join(" ")}), exit=${s.status}`)}function tryRunCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"ignore",shell:!1,encoding:"utf8"});return!s.error&&0===s.status}function tryInstallUv(e){console.log(chalk.blue("uv not found. Attempting to install uv..."));const n=[{cmd:"py",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python3",args:["-m","pip","install","--upgrade","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,t.args,e))return!0;return!1}function resolveUvCommand(e){const n=[{cmd:"uv",argsPrefix:[]},{cmd:"py",argsPrefix:["-m","uv"]},{cmd:"python",argsPrefix:["-m","uv"]},{cmd:"python3",argsPrefix:["-m","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;if(tryInstallUv(e))for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;throw new Error("Could not find or install uv. Install uv and ensure `uv`, `py`, or `python` is available in PATH.")}function buildPythonDependencies(e){const n=["fastapi==0.141.1","uvicorn==0.52.1","python-dotenv==1.2.2","tzdata==2026.3","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.10.0","werkzeug==3.1.8","cuid2==2.0.1","nanoid==2.0.0","python-ulid==4.0.1","cuid==0.4","caspian-utils~=0.4"];return e.mcp&&n.push("fastmcp==3.4.7"),e.websocket&&n.push("websockets==17.0.1"),e.prisma&&(n.push("psycopg2-binary==2.9.12"),n.push("asyncpg==0.31.0"),n.push("aiosqlite==0.22.1"),n.push("aiomysql==0.3.2")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.16.2","pytest==9.1.1","djlint==1.44.2"]}function getPythonRequirementName(e){const n=e.trim().match(/^([A-Za-z0-9._-]+)/);return n?.[1]??null}function getPyProjectDependencyNames(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))return new Set;const t=fs.readFileSync(n,"utf8").replace(/\r\n/g,"\n").match(/^[ \t]*dependencies[ \t]*=[ \t]*\[([\s\S]*?)\]/m);if(!t)return new Set;const s=new Set,i=/"([^"]+)"/g;let c;for(;null!==(c=i.exec(t[1]));){const e=c[1].trim().match(/^([A-Za-z0-9._-]+)/)?.[1];e&&s.add(e.toLowerCase())}return s}function ensurePyProjectExists(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))throw new Error(`pyproject.toml not found at: ${n}`);let t=fs.readFileSync(n,"utf8");t=t.replace(/\r\n/g,"\n"),t.includes("package = false")||(t=t.includes("[tool.uv]")?t.replace("[tool.uv]","[tool.uv]\npackage = false"):`${t.trimEnd()}\n\n[tool.uv]\npackage = false\n`),fs.writeFileSync(n,t,"utf8")}async function ensurePythonVenvAndDeps(e,n,t=[]){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: syncing dependencies with uv")),console.log(chalk.green("=========================\n")),console.log(chalk.blue("Preparing pyproject.toml...")),ensurePyProjectExists(e);const s=path.join(e,"requirements.txt");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(chalk.gray("Removed legacy requirements.txt")));const i=resolveUvCommand(e),c=path.join(e,".venv");fs.existsSync(c)?console.log(chalk.blue("Existing .venv detected. Reusing it so uv sync can update dependencies without replacing the environment.")):(console.log(chalk.blue("Creating the virtual environment with uv...")),runCmd(i.cmd,[...i.argsPrefix,"venv",".venv"],e));const a=buildPythonDependencies(n),o=buildPythonDevDependencies(),r=a.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=o.map(e=>getPythonRequirementName(e)).filter(e=>null!==e);t.length>0&&(console.log(chalk.blue("Removing obsolete Python dependencies via uv remove...")),runCmd(i.cmd,[...i.argsPrefix,"remove",...t],e));const p=r.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dependencies via uv add...")),runCmd(i.cmd,[...i.argsPrefix,"add",...p,...a],e);const d=l.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dev dependencies via uv add --dev...")),runCmd(i.cmd,[...i.argsPrefix,"add","--dev",...d,...o],e),console.log(chalk.blue("Syncing dependencies...")),runCmd(i.cmd,[...i.argsPrefix,"sync"],e),console.log(chalk.green("\nāœ“ uv environment ready and dependencies installed.\n"))}async function main(){try{const e=process.argv.slice(2),n=e.includes("-y");let t=e[0];const s=e.find(e=>e.startsWith("--starter-kit=")),i=s?.split("=")[1],c=e.find(e=>e.startsWith("--starter-kit-source=")),a=c?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();let o=null,r=!1;if(t){const s=process.cwd(),c=path.join(s,"caspian.config.json");if(i&&a){r=!0;const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};o=await getAnswer(s,n)}else if(fs.existsSync(c)){const i=readJsonFile(c);let a=[];i.excludeFiles?.map(e=>{const n=path.join(s,e);fs.existsSync(n)&&a.push(n.replace(/\\/g,"/"))}),updateAnswer={projectName:t,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,websocket:i.websocket??!1,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s};const r={projectName:t,backendOnly:e.includes("--backend-only")||i.backendOnly,tailwindcss:e.includes("--tailwindcss")||i.tailwindcss,typescript:e.includes("--typescript")||i.typescript,prisma:e.includes("--prisma")||i.prisma,mcp:e.includes("--mcp")||i.mcp,websocket:e.includes("--websocket")||(i.websocket??!1)};o=await getAnswer(r,n),null!==o&&(updateAnswer={projectName:t,backendOnly:o.backendOnly,tailwindcss:o.tailwindcss,mcp:o.mcp,websocket:o.websocket,prisma:o.prisma,typescript:o.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s})}else{const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};o=await getAnswer(s,n)}if(null===o)return void console.log(chalk.red("Installation cancelled."))}else o=await getAnswer({},n);if(null===o)return void console.warn(chalk.red("Installation cancelled."));const l=await fetchPackageVersion("create-caspian-app"),p=getInstalledPackageInfo("create-caspian-app");isRunningFromNpxCache(__dirname)?console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")):p.isLinked?console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")):p.version?-1===compareVersions(p.version,l)&&(execSync(buildManagedNpmCommand(["uninstall","-g","create-caspian-app"]),{stdio:"inherit"}),execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"})):execSync(buildManagedNpmCommand(["install","-g","create-caspian-app"]),{stdio:"inherit"});const d=process.cwd();let u;if(t)if(r){const n=path.join(d,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,await setupStarterKit(u,o),process.chdir(u);const s=path.join(u,"caspian.config.json");if(fs.existsSync(s)){const n=JSON.parse(fs.readFileSync(s,"utf8"));e.includes("--backend-only")&&(n.backendOnly=!0),e.includes("--tailwindcss")&&(n.tailwindcss=!0),e.includes("--typescript")&&(n.typescript=!0),e.includes("--mcp")&&(n.mcp=!0),e.includes("--websocket")&&(n.websocket=!0),e.includes("--prisma")&&(n.prisma=!0),o={...o,backendOnly:n.backendOnly,tailwindcss:n.tailwindcss,typescript:n.typescript,mcp:n.mcp,websocket:n.websocket??!1,prisma:n.prisma};let t=[];n.excludeFiles?.map(e=>{const n=path.join(u,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...o,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:u}}}else{const e=path.join(d,"caspian.config.json"),n=path.join(d,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?u=d:fs.existsSync(n)&&fs.existsSync(s)?(u=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),u=n,process.chdir(n))}else fs.mkdirSync(o.projectName,{recursive:!0}),u=path.join(d,o.projectName),process.chdir(o.projectName);let m=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("@lezer/common"),npmPkg("@lezer/python"),npmPkg("caspian-utils")];o.prisma&&m.push(npmPkg("prompts"),npmPkg("@types/prompts")),o.tailwindcss&&m.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),o.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),o.typescript&&!o.backendOnly&&m.push(npmPkg("vite"),npmPkg("fast-glob")),o.typescript&&m.push(npmPkg("vitest")),o.starterKit&&!r&&await setupStarterKit(u,o),await installNpmDependencies(u,m,!0);let h=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(u,o),o.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(u,"package.json");if(fs.existsSync(n)){const t=JSON.parse(fs.readFileSync(n,"utf8"));return!!(t.dependencies&&t.dependencies[e]||t.devDependencies&&t.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(u,"src","app",e);fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(u,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(u,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const c=path.join(u,"ts","tailwind-merge.ts");fs.existsSync(c)&&(fs.unlinkSync(c),console.log(`${c} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(o.tailwindcss){const e=path.join(u,"public","css","index.css");if(fs.existsSync(e))try{fs.unlinkSync(e),console.log(`${e} was deleted successfully.`)}catch(n){console.warn(chalk.yellow(`Failed to delete ${e}: ${n}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const n=path.join(u,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(u,"src","lib","mcp");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("MCP folder was deleted successfully.")),n.push("fastmcp")}if(!updateAnswer.websocket){const e=path.join(u,"src","lib","websocket");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("WebSocket folder was deleted successfully.")),n.push("websockets")}if(!updateAnswer.prisma){["prisma","@prisma/client","@prisma/internals","better-sqlite3","@prisma/adapter-better-sqlite3","mariadb","@prisma/adapter-mariadb","pg","@prisma/adapter-pg","@types/pg"].forEach(n=>{t(n)&&e.push(n)}),n.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts",path.join("settings","run-vite-watch.ts")].forEach(e=>{const n=path.join(u,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(u,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(u,"settings","vite-plugins");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(n=>{t(n)&&e.push(n)})}const s=e=>Array.from(new Set(e)),i=s(e);i.length>0&&(console.log(`Uninstalling npm packages: ${i.join(", ")}`),await uninstallNpmDependencies(u,i,!0));const c=s(n),a=getPyProjectDependencyNames(u);h=c.filter(e=>a.has(e.toLowerCase())),h.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${h.join(", ")}`))}if(!r||!fs.existsSync(path.join(u,"caspian.config.json"))){const e=u.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:o.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:o.backendOnly,tailwindcss:o.tailwindcss,mcp:o.mcp,websocket:o.websocket,prisma:o.prisma,typescript:o.typescript,version:l,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(u,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(u,o,h),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(u.replace(/\\/g,"/"))}!`),console.log("\n=========================")}catch(e){console.error("Error while creating the project:",e),process.exit(1)}}main();
2
+ import{execSync,spawnSync}from"child_process";import fs from"fs";import{fileURLToPath}from"url";import path from"path";import chalk from"chalk";import prompts from"prompts";import https from"https";import{randomBytes}from"crypto";const __filename=fileURLToPath(import.meta.url),__dirname=path.dirname(__filename),PACKAGE_ROOT=path.resolve(__dirname,".."),OPTIONAL_TEMPLATE_FILES=new Set([".python-version",".prettierrc"]),OPTIONAL_TEMPLATE_DIRECTORIES=new Set([".github",".vscode"]),CASPIAN_SECTION_START="\x3c!-- caspian:start --\x3e",CASPIAN_SECTION_END="\x3c!-- caspian:end --\x3e",SELF_UPDATE_GUARD_ENV="CREATE_CASPIAN_APP_SELF_UPDATED";let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.py","not-found.py","error.py"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.py","src/app/index.py"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.py","src/app/index.py","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1,websocket:!1},requiredFiles:["main.py","pyproject.toml"]},realtime:{id:"realtime",name:"Real-time Application",description:"Application with WebSocket support and MCP",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!0,websocket:!0},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const n=e.indexOf("\\htdocs\\");if(-1===n)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,n+8).replace(/\\/g,"\\\\"),s=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let i=`http://localhost/${s}`;i=i.endsWith("/")?i.slice(0,-1):i;const c=i.replace(/(?<!:)(\/\/+)/g,"/"),a=s.replace(/\/\/+/g,"/");return{bsTarget:`${c}/`,bsPathRewrite:{"^/":`/${a.startsWith("/")?a.substring(1):a}/`}}}async function updatePackageJson(e,n){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const s=JSON.parse(fs.readFileSync(t,"utf8"));s.scripts={...s.scripts,projectName:"tsx settings/project-name.ts",format:"uv run python settings/format.py","format:check":"uv run python settings/format.py --check",check:"uv run python settings/check.py","check:fix":"uv run python settings/fix.py",logs:"uv run python settings/browser_log.py",static:"npm run build && uv run python settings/build-static.py","static:serve":"uv run python settings/serve-static.py"};let i=[];n.tailwindcss&&(s.scripts={...s.scripts,tailwind:"tsx settings/run-postcss.ts watch","tailwind:build":"tsx settings/run-postcss.ts build"},i.push("tailwind")),n.typescript&&!n.backendOnly&&(s.scripts={...s.scripts,"ts:watch":"vite build --watch","ts:watch:dev":"tsx settings/run-vite-watch.ts","ts:build":"vite build"},i.push("ts:watch:dev")),n.mcp&&(s.scripts={...s.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let c={...s.scripts};c.browserSync="tsx settings/bs-config.ts",c.dev=`npm-run-all projectName -l -p browserSync ${i.join(" ")}`;let a=["projectName"];n.tailwindcss&&a.unshift("tailwind:build"),n.typescript&&!n.backendOnly&&a.unshift("ts:build"),c.build=`npm-run-all ${a.join(" ")}`,s.scripts=c,s.type="module",fs.writeFileSync(t,JSON.stringify(s,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function buildEnvSection(e,n){return`# =============================================================================\n${e.split("\n").map(e=>e.startsWith("#")?e:`# ${e}`).join("\n")}\n# =============================================================================\n\n${n.trimEnd()}`}function buildCaspianEnvContent(e){const n=generateAuthSecret(),t=generateHexEncodedKey(8),s=generateHexEncodedKey(32),i=[];return e.prisma&&i.push(buildEnvSection("1. DATABASE\n# Enforced by: prisma/schema.prisma, src/lib/prisma/db.py",'# Connection string. Prisma reads this directly from .env.\n# Format reference: https://pris.ly/d/connection-strings\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"\n\n# Connection-pool limit. Defaults: SQLite 5; MySQL and PostgreSQL 20.\n# Use 5 for local development; production does not need this unless you want to\n# limit the pool.\nDB_POOL_SIZE=5\n\n# Seconds idle before the client re-probes its connection. Default 30.\nPRISMA_CONN_PROBE_IDLE_SECONDS=30\n\n# Warn on queries that cause a full table scan. 0/false silences it. Default 1.\nPRISMA_WARN_FULL_SCAN=1')),i.push(buildEnvSection("2. APPLICATION RUNTIME\n# Enforced by: casp/runtime_security.py is_production_environment()",'# Environment selector, resolved FAIL-CLOSED: only an explicit development\n# value (dev, development, local, staging, test, testing) enables the\n# development relaxations. Unset or misspelled counts as production.\n#\n# Production turns on: HTTPS-only session cookie, Secure CSRF cookie, HSTS,\n# generic error messages, mandatory AUTH_SECRET, mandatory MCP_AUTH_TOKEN, and\n# it removes the localhost origin bypass and the WebSocket same-origin fallback.\n#\n# This single value gates most of the security posture. Set it deliberately.\nAPP_ENV="development"\n\n# Calendar timezone for the application, as an IANA name (e.g. "UTC",\n# "America/New_York", "America/Santo_Domingo"). Read by casp/app_time.py and\n# resolved once at boot in main.py.\n#\n# This sets which wall-clock DAY an instant belongs to: what casp.app_time.now()\n# and today() answer, how a stored timestamp reads back to a user, and the\n# boundaries a "today\'s totals" query uses. Timestamps are still STORED in UTC.\n#\n# It deliberately does NOT affect absolute time -- session expiry (casp/auth.py)\n# and cache TTLs (casp/cache_handler.py) stay on UTC, so changing this can never\n# extend a session or a cache entry.\n#\n# An unrecognized name raises InvalidAppTimezoneError at startup rather than\n# silently falling back to UTC. Empty or unset means UTC.\nAPP_TIMEZONE="UTC"'),buildEnvSection("3. PUBLIC URL, CORS, AND ORIGIN VALIDATION\n# Enforced by: casp/rpc.py origin checks, main.py CORS layer",'# Canonical public origin. Leave empty when the browser URL and the app runtime\n# URL match. Set it when they differ, i.e. behind an ingress, reverse proxy,\n# load balancer, gateway, edge network, or TLS terminator.\nAPP_BASE_URL=""\n\n# Extra browser origins allowed to call protected endpoints such as RPC. Use\n# when one deployment is reachable from more than one public origin.\n# Comma-separated, no spaces.\nCORS_ALLOWED_ORIGINS=""\n\n# Trust Forwarded/X-Forwarded-* headers. Enable ONLY when every request passes\n# through infrastructure that strips client-supplied forwarded headers before\n# setting its own, because a direct client can otherwise forge them.\n#\n# Affects two things: which origin RPC accepts, and which address the rate\n# limiter buckets on. Left false, both use the direct request instead.\nTRUST_FORWARDED_HEADERS="false"\n\n# Allow cookies/Authorization on cross-origin requests. Keep true only when\n# credentialed cross-origin requests are actually required.\nCORS_ALLOW_CREDENTIALS="true"\n\n# CORS preflight response fields.\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\n\n# Preflight cache duration in seconds.\nCORS_MAX_AGE="86400"'),buildEnvSection("4. AUTHENTICATION AND SESSIONS\n# Enforced by: casp/auth.py, main.py SessionMiddleware\n# Route privacy and RBAC live in src/lib/auth/auth_config.py, not here.",`# Session signing secret. Unique and strong per app and per environment.\n# In production the app refuses to start when this is missing or left on a\n# placeholder ("change-me"/"changeme"); in development it falls back.\nAUTH_SECRET="${n}"\n\n# Session cookie name. Use a unique value when several apps share a parent\n# domain, or their sessions overwrite each other.\nAUTH_COOKIE_NAME="${t}"\n\n# Session lifetime in hours (SessionMiddleware max_age).\nSESSION_LIFETIME_HOURS="7"`),buildEnvSection("5. OAUTH PROVIDERS\n# Enforced by: casp/auth.py; routes served by main.py AuthMiddleware","# Google and GitHub sign-in are already wired: AuthMiddleware serves\n# /api/auth/signin/{google,github} and /api/auth/callback/{google,github}.\n# Link a button at those paths, do not hand-roll OAuth.\n#\n# A provider with no client id is skipped SILENTLY: the redirect returns None\n# and the button appears dead, with no error and no log. Empty means disabled.\n\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n\n# Must match the redirect URI registered in Google Cloud Console exactly.\n# Google is skipped unless BOTH the client id and this value are set.\nGOOGLE_REDIRECT_URI=\n\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET="),buildEnvSection("6. REQUEST SECURITY\n# Enforced by: main.py BodySizeLimitMiddleware, RequestDiagnosticsMiddleware",'# Max size of the whole HTTP request body in MB. Caps the entire body (file +\n# form fields + encoding overhead), so usable file size is a bit below this.\n# Middleware rejects oversized requests before the route runs. Raise if valid\n# uploads are blocked. Default 16.\nMAX_CONTENT_LENGTH_MB="16"\n\n# Seconds before a stalled route returns 504. Streaming paths (/mcp) are exempt\n# so long-lived transports are not cut mid-response. Default 20.\nCASPIAN_REQUEST_TIMEOUT_SECONDS=20'),buildEnvSection("7. SECURITY HEADERS\n# Enforced by: casp/runtime_security.py, main.py SecurityHeadersMiddleware","# Replaces the built-in Content-Security-Policy wholesale. Empty keeps the\n# default, which already permits the app's own assets.\n#\n# Any replacement MUST keep 'unsafe-eval' and 'unsafe-inline' in script-src:\n# the PulsePoint runtime compiles component templates with new Function(), so\n# removing them stops every page from rendering. Set this only to widen the\n# policy, e.g. for a CDN, analytics host, or an external frame embedder.\n#\n# Outside production, connect-src also allows http(s)/ws on localhost and\n# 127.0.0.1 on any port, because BrowserSync serves the proxied page on one port\n# while its injected live-reload client polls the BrowserSync server on another.\n# That is a separate origin, so 'self' does not cover it. Those entries are\n# omitted from a production policy. Setting an override here replaces BOTH, so\n# an override used in development must include the loopback sources itself or\n# live reload stops working.\n#\n# img-src and media-src admit remote content by scheme, so posters, avatars, CDN\n# thumbnails, and video load without per-project configuration. Plain http: is\n# development-only. Set an override here to pin them to named origins instead.\n#\n# Default: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';\n# style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:;\n# media-src 'self' data: blob: https:; font-src 'self' data:;\n# connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self';\n# form-action 'self'; frame-ancestors 'self'\nCONTENT_SECURITY_POLICY="),buildEnvSection("8. RATE LIMITING\n# Enforced by: main.py RateLimitMiddleware, casp/rpc.py RPCRateLimiter\n# Buckets are per client address; see TRUST_FORWARDED_HEADERS for which one.",'# Per-IP cap on page requests, applied before session decryption and rendering.\n# Static assets (/css, /js, /assets, /favicon.ico) and /health are exempt, so a\n# page load does not spend its own budget on its assets. Empty disables it.\n# Default 200/minute.\nRATE_LIMIT_PAGES=200/minute\n\n# Fallback limit for @rpc() actions that declare no limits= of their own.\nRATE_LIMIT_RPC="60 per minute"\n\n# Limit applied to @rpc(require_auth=True) actions that declare no limits=.\n# Tighten this and the per-action limits on sign-in and other credential paths.\nRATE_LIMIT_AUTH="60 per minute"\n\n# Configured for slowapi\'s Limiter. Note that slowapi\'s middleware is not in\n# the stack, so this value is inert today; page limiting is RATE_LIMIT_PAGES.\nRATE_LIMIT_DEFAULT="200 per minute"\n\n# In-memory bucket ceiling and sweep interval for the limiter store.\n# Defaults 10000 buckets, swept every 60 seconds.\nRATE_LIMIT_MAX_BUCKETS=10000\nRATE_LIMIT_CLEANUP_INTERVAL=60')),e.websocket&&i.push(buildEnvSection("9. WEBSOCKETS\n# Enforced by: src/lib/websocket/websocket_security.py, main.py channel loop\n# Only active when caspian.config.json has websocket: true.","# Browser origins allowed to open a socket (anti-CSWSH). Falls back to\n# CORS_ALLOWED_ORIGINS then APP_BASE_URL when empty.\n#\n# REQUIRED IN PRODUCTION. The convenience same-origin fallback is derived from\n# the client-supplied Host header, so it is development-only: without an\n# explicit list a spoofed Host plus matching Origin would validate itself.\n#\n# The HTTP middleware stack skips websocket scopes, so this and the socket\n# guard are the only checks a handshake passes. Comma-separated, no spaces.\nWEBSOCKET_ALLOWED_ORIGINS=\n\n# Seconds a socket may stay silent before the server closes it. Default 120.\nWEBSOCKET_IDLE_TIMEOUT_SECONDS=120\n\n# Max size of one inbound socket message in bytes. Oversized closes with 1009.\n# Default 4096.\nMAX_WEBSOCKET_MESSAGE_BYTES=4096\n\n# Simultaneous connections per pool; authenticated and guest pools are counted\n# separately. Refused connections close with 1013 during the handshake. Every\n# open socket is a live task and a broadcast target. Default 200.\nMAX_WEBSOCKET_CONNECTIONS=200\n\n# Per-connection send budget: messages allowed per rolling window. Each\n# accepted message fans out to the whole pool, so this bounds how much\n# broadcast one connection can generate. Defaults 20 per 10 seconds.\nMAX_WEBSOCKET_MESSAGES_PER_WINDOW=20\nWEBSOCKET_RATE_WINDOW_SECONDS=10")),e.mcp&&i.push(buildEnvSection("10. MCP ENDPOINT\n# Enforced by: main.py MCPAuthMiddleware; tools in src/lib/mcp/mcp_server.py\n# Only active when caspian.config.json has mcp: true.",`# Bearer token required to call /mcp. The MCP app is mounted outside the page\n# routing tree, so AuthMiddleware does NOT protect it, and its tools enumerate\n# the workspace file inventory and component map.\n#\n# generated -> every request needs "Authorization: Bearer <token>"\n#\n# REQUIRED IN PRODUCTION for the endpoint to work at all.\nMCP_AUTH_TOKEN="${s}"`)),i.push(buildEnvSection("11. CACHE\n# Enforced by: casp/cache_handler.py, main.py is_request_cacheable()",'# Master switch for serving pages from the disk cache.\n#\n# Entries are keyed on the URI alone, with no session component, so an\n# authenticated render is never cached: is_request_cacheable() gates both the\n# read and the write, and a route\'s Cache(...) cannot override it.\nCACHE_ENABLED="false"\n\n# Default cache lifetime in seconds, used when a route sets no ttl.\nCACHE_TTL="600"'),buildEnvSection("12. SERVER PROCESS\n# Enforced by: main.py __main__, settings/serve-static.py",'# Uvicorn workers are separate OS processes for the same FastAPI app. More\n# workers can increase throughput under concurrent load, but they do not make a\n# single request faster and they duplicate memory, connection pools, and any\n# in-process state. Keep at 1 unless the app is designed for multi-process\n# coordination and testing shows a real concurrency bottleneck.\nUVICORN_WORKERS="1"')),i.join("\n\n")}function copyRecursiveSync(e,n,t){const s=fs.existsSync(e),i=s&&fs.statSync(e);if(s&&i&&i.isDirectory()){const s=n.toLowerCase();if(!t.mcp&&s.includes("src\\lib\\mcp"))return;if(!t.websocket&&s.includes("src\\lib\\websocket"))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\ts")||s.includes("\\ts\\")))return;if((!t.typescript||t.backendOnly)&&(s.endsWith("\\vite-plugins")||s.includes("\\vite-plugins\\")||s.includes("\\vite-plugins")))return;if(t.backendOnly&&s.includes("public\\js")||t.backendOnly&&s.includes("public\\css")||t.backendOnly&&s.includes("public\\assets"))return;const i=n.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),fs.readdirSync(e).forEach(s=>{copyRecursiveSync(path.join(e,s),path.join(n,s),t)})}else{if(checkExcludeFiles(n))return;const s=n.replace(/\\/g,"/").toLowerCase();if(s.endsWith("/settings/run-vite-watch.ts")&&(!t.typescript||t.backendOnly))return;if(s.endsWith("/ts/tailwind-merge.ts")&&(!t.typescript||t.backendOnly||!t.tailwindcss))return;if(!t.tailwindcss&&(n.includes("globals.css")||n.includes("styles.css")))return;if(!t.mcp&&n.includes("restart-mcp.ts"))return;if(!t.websocket&&n.includes("src\\lib\\websocket"))return;if(t.backendOnly&&nonBackendFiles.some(e=>n.includes(e)))return;if(t.backendOnly&&n.includes("layout.py"))return;if(t.tailwindcss&&n.includes("index.css"))return;if(!t.prisma&&n.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,n,0)}}async function executeCopy(e,n,t){n.forEach(({src:n,dest:s})=>{const i=normalizeTemplatePath(n),c=resolveTemplateSourcePath(n,"directory"),a=path.join(e,s);if(!c){if(OPTIONAL_TEMPLATE_DIRECTORIES.has(i))return void console.log(chalk.gray(`Optional template directory not found, skipping: ${i}`));throw new Error(`Template directory not found: ${i}. The package may be incomplete.`)}copyRecursiveSync(c,a,t)})}function modifyLayoutPHP(e,n){const t=path.join(e,"src","app","layout.py");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),s="";n.backendOnly||(n.tailwindcss||(s='\n <link href="/css/index.css" rel="stylesheet" />'),s+='\n <script type="module" src="/js/main.js"><\/script>');let i="";n.backendOnly||(i=n.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${s}`:s),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.py:"),e)}}async function createOrUpdateEnvFile(e,n){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,n,{flag:"w"})}function ensureClaudeMd(e){const n=path.join(e,"CLAUDE.md");if(checkExcludeFiles(n))return;const t="@AGENTS.md";if(!fs.existsSync(n))return void fs.writeFileSync(n,`${t}\n`,{flag:"w"});const s=fs.readFileSync(n,"utf8").replace(/^\uFEFF/,"");if(s.trimStart().startsWith(t))return;const i=`${t}\n\n${s.trimStart()}`;fs.writeFileSync(n,i,{flag:"w"})}function writeTailwindMainJs(e){const n=path.join(e,"public","js","main.js");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\nimport { twMerge } from "/js/tailwind-merge.mjs";\n\nconst pp = (globalThis).pp;\n\nglobalThis.twMerge = twMerge;\n\nif (document.readyState !== "loading") {\n pp?.mount?.();\n} else {\n document.addEventListener(\n "DOMContentLoaded",\n () => pp?.mount?.(),\n { once: true },\n );\n}\n',{flag:"w"}))}function copyTailwindMergeBundle(e){const n=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs"),t=path.join(e,"public","js","tailwind-merge.mjs"),s=path.join(e,"node_modules","tailwind-merge","dist","bundle-mjs.mjs.map"),i=path.join(e,"public","js","bundle-mjs.mjs.map");if(!checkExcludeFiles(t)){if(!fs.existsSync(n))throw new Error(`tailwind-merge bundle not found at ${n}`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.copyFileSync(n,t),!checkExcludeFiles(i)&&fs.existsSync(s)&&fs.copyFileSync(s,i)}}function writeTailwindTypeScriptMain(e){const n=path.join(e,"ts","main.ts");checkExcludeFiles(n)||(fs.mkdirSync(path.dirname(n),{recursive:!0}),fs.writeFileSync(n,'import "/js/pp-reactive-v2.min.js";\n\n// The following global names have already been declared elsewhere in the project:\n// - pp: Used for the Reactive Core functionality.\n\n// Imports goes here --Start\nimport { createGlobalSingleton } from "./global-functions.js";\nimport { mergeTailwindClasses } from "./tailwind-merge.js";\n\ncreateGlobalSingleton("twMerge", mergeTailwindClasses);\n\n\n// Imports goes here --End\n\nconst pp = (globalThis as any).pp;\n\nif (document.readyState !== "loading") {\n\tpp?.mount?.();\n} else {\n\tdocument.addEventListener(\n\t\t"DOMContentLoaded",\n\t\t() => pp?.mount?.(),\n\t\t{ once: true },\n\t);\n}\n',{flag:"w"}))}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const n=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(n)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const t=e.replace(/\\/g,"/");return n.endsWith("/"+t)||n===t})}function normalizeTemplatePath(e){return e.replace(/^[\\/]+/,"")}function resolveTemplateSourcePath(e,n){const t=normalizeTemplatePath(e),s=[path.join(__dirname,t),path.join(PACKAGE_ROOT,t)];for(const e of s){if(!fs.existsSync(e))continue;const t=fs.statSync(e);if("file"===n&&t.isFile())return e;if("directory"===n&&t.isDirectory())return e}return null}function extractCaspianSection(e){const n=e.indexOf(CASPIAN_SECTION_START);if(-1===n)return null;const t=e.indexOf(CASPIAN_SECTION_END,n);return-1===t?null:e.slice(t>n?n:0,t+20)}function mergeAgentsCaspianSection(e,n){const t=extractCaspianSection(n);if(!t)return e;const s=e.indexOf(CASPIAN_SECTION_START),i=e.indexOf(CASPIAN_SECTION_END,s);if(-1!==s&&-1!==i){return`${e.slice(0,s)}${t}${e.slice(i+20)}`}const c=e.endsWith("\n");return`${e}${c?"\n":"\n\n"}${t}\n`}async function createDirectoryStructure(e,n){const t=[{src:"/main.py",dest:"/main.py"},{src:"/.prettierrc",dest:"/.prettierrc"},{src:"/pyproject.toml",dest:"/pyproject.toml"},{src:"/tsconfig.json",dest:"/tsconfig.json"},{src:"/app-gitignore",dest:"/.gitignore"},{src:"/AGENTS.md",dest:"/AGENTS.md"},{src:"/.python-version",dest:"/.python-version"}];n.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),n.typescript&&!n.backendOnly&&t.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const s=[{src:"/settings",dest:"/settings"},{src:"/tests",dest:"/tests"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"},{src:"/.github",dest:"/.github"},{src:"/.vscode",dest:"/.vscode"}];n.typescript&&!n.backendOnly&&s.push({src:"/ts",dest:"/ts"}),t.forEach(({src:n,dest:t})=>{const s=normalizeTemplatePath(n),i=resolveTemplateSourcePath(n,"file"),c=path.join(e,t);if(checkExcludeFiles(c))return;if(!i){if(OPTIONAL_TEMPLATE_FILES.has(s))return void console.log(chalk.gray(`Optional template file not found, skipping: ${s}`));throw new Error(`Template file not found: ${s}. The package may be incomplete.`)}if("/pyproject.toml"===n&&updateAnswer?.isUpdate&&fs.existsSync(c))return void console.log(chalk.gray("Preserving existing pyproject.toml during update."));const a=fs.readFileSync(i,"utf8");if("/AGENTS.md"===n&&updateAnswer?.isUpdate&&fs.existsSync(c)){const e=mergeAgentsCaspianSection(fs.readFileSync(c,"utf8"),a);return void fs.writeFileSync(c,e,{flag:"w"})}fs.writeFileSync(c,a,{flag:"w"})}),await executeCopy(e,s,n),ensureClaudeMd(e),n.tailwindcss&&!n.backendOnly&&(n.typescript?writeTailwindTypeScriptMain(e):(copyTailwindMergeBundle(e),writeTailwindMainJs(e))),await updatePackageJson(e,n),!n.tailwindcss&&n.backendOnly||modifyLayoutPHP(e,n),await createOrUpdateEnvFile(e,buildCaspianEnvContent(n))}async function getAnswer(e={},n=!1){if(e.starterKit){const n=e.starterKit;let t=null;if(STARTER_KITS[n]&&(t=STARTER_KITS[n]),t){const s={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:t.features.backendOnly??!1,tailwindcss:t.features.tailwindcss??!1,prisma:t.features.prisma??!1,mcp:t.features.mcp??!1,websocket:t.features.websocket??!1,typescript:t.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(s.backendOnly=!0),i.includes("--tailwindcss")&&(s.tailwindcss=!0),i.includes("--mcp")&&(s.mcp=!0),i.includes("--websocket")&&(s.websocket=!0),i.includes("--prisma")&&(s.prisma=!0),i.includes("--typescript")&&(s.typescript=!0),s}if(e.starterKitSource){const t={projectName:e.projectName??"my-app",starterKit:n,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,websocket:!1,typescript:!1},s=process.argv.slice(2);return s.includes("--backend-only")&&(t.backendOnly=!0),s.includes("--tailwindcss")&&(t.tailwindcss=!0),s.includes("--mcp")&&(t.mcp=!0),s.includes("--websocket")&&(t.websocket=!0),s.includes("--prisma")&&(t.prisma=!0),s.includes("--typescript")&&(t.typescript=!0),t}}if(n)return{projectName:e.projectName??"my-app",starterKit:e.starterKit,starterKitSource:e.starterKitSource,backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,websocket:e.websocket??!1,prisma:e.prisma??!1};const t=[];e.projectName||t.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||updateAnswer?.isUpdate||t.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const s=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(t,{onCancel:s}),c=[];i.backendOnly??e.backendOnly??!1?(e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"})):(e.tailwindcss||c.push({type:"toggle",name:"tailwindcss",message:`Would you like to use ${chalk.blue("Tailwind CSS")}?`,initial:!1,active:"Yes",inactive:"No"}),e.typescript||c.push({type:"toggle",name:"typescript",message:`Would you like to use ${chalk.blue("TypeScript")}?`,initial:!1,active:"Yes",inactive:"No"}),e.mcp||c.push({type:"toggle",name:"mcp",message:`Would you like to use ${chalk.blue("MCP (Model Context Protocol)")}?`,initial:!1,active:"Yes",inactive:"No"}),e.prisma||c.push({type:"toggle",name:"prisma",message:`Would you like to use ${chalk.blue("Prisma ORM")}?`,initial:!1,active:"Yes",inactive:"No"}),e.websocket||c.push({type:"toggle",name:"websocket",message:`Would you like to use ${chalk.blue("WebSocket")}?`,initial:!1,active:"Yes",inactive:"No"}));const a=await prompts(c,{onCancel:s});return{projectName:i.projectName?String(i.projectName).trim().replace(/ /g,"-"):e.projectName??"my-app",backendOnly:i.backendOnly??e.backendOnly??!1,tailwindcss:a.tailwindcss??e.tailwindcss??!1,typescript:a.typescript??e.typescript??!1,mcp:a.mcp??e.mcp??!1,websocket:a.websocket??e.websocket??!1,prisma:a.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,n,t=!1){console.log("Uninstalling Node dependencies:"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["uninstall",t?"--save-dev":"--save",...n]);execSync(s,{stdio:"inherit",cwd:e})}function buildManagedNpmCommand(e){return`npm ${e.join(" ")} --ignore-scripts=false --min-release-age=0 --audit=false`}function fetchPackageVersion(e){return new Promise((n,t)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let s="";e.on("data",e=>s+=e),e.on("end",()=>{try{const e=JSON.parse(s);n(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}})}).on("error",e=>t(e))})}const readJsonFile=e=>{const n=fs.readFileSync(e,"utf8");return JSON.parse(n)};function compareVersions(e,n){const t=e.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/),s=n.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);if(!t||!s)return e.localeCompare(n);const i=t.slice(1,4).map(Number),c=s.slice(1,4).map(Number);for(let e=0;e<i.length;e++){if(i[e]>c[e])return 1;if(i[e]<c[e])return-1}const a=t[4]??null,o=s[4]??null;return a&&!o?-1:!a&&o?1:a&&o?a.localeCompare(o):0}function getInstalledPackageInfo(e){try{const n=execSync(buildManagedNpmCommand(["list","-g",e,"--depth=0"])).toString(),t=n.match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`));return t?{version:t[1],isLinked:n.includes(`${e}@`)&&n.includes("->")}:(console.error(`Package ${e} is not installed`),{version:null,isLinked:!1})}catch(e){return console.error(e instanceof Error?e.message:String(e)),{version:null,isLinked:!1}}}function isRunningFromNpxCache(e){const n=path.resolve(e).toLowerCase(),t=`${path.sep}_npx${path.sep}`.toLowerCase();return n.includes(t)}function resolveGlobalCliEntryPoint(){const e=[__filename];try{const n=execSync("npm root -g").toString().trim();n&&e.push(path.join(n,"create-caspian-app","dist","index.js"))}catch{}for(const n of e)if(n&&fs.existsSync(n))return n;return null}async function ensureLatestCliVersion(e){const n=await fetchPackageVersion("create-caspian-app");if("1"===process.env[SELF_UPDATE_GUARD_ENV])return n;if(isRunningFromNpxCache(__dirname))return console.log(chalk.gray("Skipping global create-caspian-app update because this command is running from an npx cache package.")),n;const t=getInstalledPackageInfo("create-caspian-app");if(t.isLinked)return console.log(chalk.gray("Skipping global create-caspian-app update because the global install is linked.")),n;if(!(!t.version||-1===compareVersions(t.version,n)))return n;execSync(buildManagedNpmCommand(["install","-g",`create-caspian-app@${n}`]),{stdio:"inherit"});const s=resolveGlobalCliEntryPoint();if(!s)throw new Error(`create-caspian-app was updated to ${n}, but the updated CLI could not be located. Please run the command again.`);console.log(chalk.gray(`Restarting with create-caspian-app@${n}...`));const i=spawnSync(process.execPath,[s,...e],{stdio:"inherit",env:{...process.env,[SELF_UPDATE_GUARD_ENV]:"1"}});if(i.error)throw i.error;process.exit(i.status??1)}async function installNpmDependencies(e,n,t=!1){fs.existsSync(path.join(e,"package.json"))?console.log("Updating existing Node.js project..."):console.log("Initializing new Node.js project..."),fs.existsSync(path.join(e,"package.json"))||execSync(buildManagedNpmCommand(["init","-y"]),{stdio:"inherit",cwd:e}),console.log((t?"Installing development dependencies":"Installing dependencies")+":"),n.forEach(e=>console.log(`- ${chalk.blue(e)}`));const s=buildManagedNpmCommand(["install",...t?["--save-dev"]:[],...n]);execSync(s,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.3.3","@types/browser-sync":"2.29.1","@types/node":"26.2.0","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"6.0.0","chokidar-cli":"3.0.0",cssnano:"8.0.5","npm-run-all":"4.1.5",postcss:"8.5.26","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.3.3",tsx:"4.23.12",typescript:"7.0.2",vite:"8.2.0",vitest:"4.1.10","fast-glob":"3.3.3","@lezer/common":"1.5.2","@lezer/python":"1.1.19","caspian-utils":"0.2.x","tailwind-merge":"3.6.0"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}function removeDirectorySafe(e){if(fs.existsSync(e))try{return void fs.rmSync(e,{recursive:!0,force:!0,maxRetries:5,retryDelay:250})}catch(n){const t=n;if("win32"===globalThis.process?.platform&&("EPERM"===t.code||"EACCES"===t.code)){try{spawnSync("cmd",["/c","attrib","-R","-H","-S","/S","/D",`${e}\\*`],{stdio:"ignore"})}catch{}return void spawnSync("cmd",["/c","rd","/s","/q",e],{stdio:"ignore"})}throw n}}async function setupStarterKit(e,n){if(!n.starterKit)return;let t=null;if(STARTER_KITS[n.starterKit]?t=STARTER_KITS[n.starterKit]:n.starterKitSource&&(t={id:n.starterKit,name:`Custom Starter Kit (${n.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:n.starterKitSource}}),t){if(console.log(chalk.green(`Setting up ${t.name}...`)),t.source)try{const s=t.source.branch?`git clone -b ${t.source.branch} --depth 1 ${t.source.url} "${e}"`:`git clone --depth 1 ${t.source.url} "${e}"`;execSync(s,{stdio:"inherit"});removeDirectorySafe(path.join(e,".git")),console.log(chalk.blue("Starter kit cloned successfully!"));const i=path.join(e,"caspian.config.json");if(fs.existsSync(i))try{const t=JSON.parse(fs.readFileSync(i,"utf8")),s=e,c=bsConfigUrls(s);t.projectName=n.projectName,t.projectRootPath=s,t.bsTarget=c.bsTarget,t.bsPathRewrite=c.bsPathRewrite;const a=await fetchPackageVersion("create-caspian-app");t.version=t.version||a,fs.writeFileSync(i,JSON.stringify(t,null,2)),console.log(chalk.green("Updated caspian.config.json with new project details"))}catch(e){console.warn(chalk.yellow("Failed to update caspian.config.json, will create new one"))}}catch(e){throw console.error(chalk.red(`Failed to setup starter kit: ${e}`)),e}t.customSetup&&await t.customSetup(e,n),console.log(chalk.green(`āœ“ ${t.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${n.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\nšŸš€ Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const n=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(n)}`)),console.log(` ${e.name}`),console.log(chalk.gray(` ${e.description}`)),e.source&&console.log(chalk.cyan(` Source: ${e.source.url}`));const t=Object.entries(e.features).filter(([,e])=>!0===e).map(([e])=>e).join(", ");t&&console.log(chalk.magenta(` Features: ${t}`)),console.log()}),console.log(chalk.yellow("Usage:")),console.log(" npx create-caspian-app my-project --starter-kit=basic"),console.log(" npx create-caspian-app my-project --starter-kit=custom --starter-kit-source=https://github.com/user/repo"),console.log()}function runCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"inherit",shell:!1,encoding:"utf8"});if(s.error)throw s.error;if(0!==s.status)throw new Error(`Command failed (${e} ${n.join(" ")}), exit=${s.status}`)}function tryRunCmd(e,n,t){const s=spawnSync(e,n,{cwd:t,stdio:"ignore",shell:!1,encoding:"utf8"});return!s.error&&0===s.status}function tryInstallUv(e){console.log(chalk.blue("uv not found. Attempting to install uv..."));const n=[{cmd:"py",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python",args:["-m","pip","install","--upgrade","uv"]},{cmd:"python3",args:["-m","pip","install","--upgrade","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,t.args,e))return!0;return!1}function resolveUvCommand(e){const n=[{cmd:"uv",argsPrefix:[]},{cmd:"py",argsPrefix:["-m","uv"]},{cmd:"python",argsPrefix:["-m","uv"]},{cmd:"python3",argsPrefix:["-m","uv"]}];for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;if(tryInstallUv(e))for(const t of n)if(tryRunCmd(t.cmd,[...t.argsPrefix,"--version"],e))return t;throw new Error("Could not find or install uv. Install uv and ensure `uv`, `py`, or `python` is available in PATH.")}function buildPythonDependencies(e){const n=["fastapi==0.141.1","uvicorn==0.52.1","python-dotenv==1.2.2","tzdata==2026.3","jinja2==3.1.6","beautifulsoup4==4.15.0","slowapi==0.1.10","python-multipart==0.0.32","starsessions==2.2.1","httpx2==2.10.0","werkzeug==3.1.8","cuid2==2.0.1","nanoid==2.0.0","python-ulid==4.0.1","cuid==0.4","caspian-utils~=0.4"];return e.mcp&&n.push("fastmcp==3.4.7"),e.websocket&&n.push("websockets==17.0.1"),e.prisma&&(n.push("psycopg2-binary==2.9.12"),n.push("asyncpg==0.31.0"),n.push("aiosqlite==0.22.1"),n.push("aiomysql==0.3.2")),n}function buildPythonDevDependencies(){return["pyright==1.1.411","ruff==0.16.2","pytest==9.1.1","djlint==1.44.2"]}function getPythonRequirementName(e){const n=e.trim().match(/^([A-Za-z0-9._-]+)/);return n?.[1]??null}function getPyProjectDependencyNames(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))return new Set;const t=fs.readFileSync(n,"utf8").replace(/\r\n/g,"\n").match(/^[ \t]*dependencies[ \t]*=[ \t]*\[([\s\S]*?)\]/m);if(!t)return new Set;const s=new Set,i=/"([^"]+)"/g;let c;for(;null!==(c=i.exec(t[1]));){const e=c[1].trim().match(/^([A-Za-z0-9._-]+)/)?.[1];e&&s.add(e.toLowerCase())}return s}function ensurePyProjectExists(e){const n=path.join(e,"pyproject.toml");if(!fs.existsSync(n))throw new Error(`pyproject.toml not found at: ${n}`);let t=fs.readFileSync(n,"utf8");t=t.replace(/\r\n/g,"\n"),t.includes("package = false")||(t=t.includes("[tool.uv]")?t.replace("[tool.uv]","[tool.uv]\npackage = false"):`${t.trimEnd()}\n\n[tool.uv]\npackage = false\n`),fs.writeFileSync(n,t,"utf8")}async function ensurePythonVenvAndDeps(e,n,t=[]){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: syncing dependencies with uv")),console.log(chalk.green("=========================\n")),console.log(chalk.blue("Preparing pyproject.toml...")),ensurePyProjectExists(e);const s=path.join(e,"requirements.txt");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(chalk.gray("Removed legacy requirements.txt")));const i=resolveUvCommand(e),c=path.join(e,".venv");fs.existsSync(c)?console.log(chalk.blue("Existing .venv detected. Reusing it so uv sync can update dependencies without replacing the environment.")):(console.log(chalk.blue("Creating the virtual environment with uv...")),runCmd(i.cmd,[...i.argsPrefix,"venv",".venv"],e));const a=buildPythonDependencies(n),o=buildPythonDevDependencies(),r=a.map(e=>getPythonRequirementName(e)).filter(e=>null!==e),l=o.map(e=>getPythonRequirementName(e)).filter(e=>null!==e);t.length>0&&(console.log(chalk.blue("Removing obsolete Python dependencies via uv remove...")),runCmd(i.cmd,[...i.argsPrefix,"remove",...t],e));const p=r.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dependencies via uv add...")),runCmd(i.cmd,[...i.argsPrefix,"add",...p,...a],e);const d=l.flatMap(e=>["--upgrade-package",e]);console.log(chalk.blue("Adding Python dev dependencies via uv add --dev...")),runCmd(i.cmd,[...i.argsPrefix,"add","--dev",...d,...o],e),console.log(chalk.blue("Syncing dependencies...")),runCmd(i.cmd,[...i.argsPrefix,"sync"],e),console.log(chalk.green("\nāœ“ uv environment ready and dependencies installed.\n"))}async function main(){try{const e=process.argv.slice(2),n=e.includes("-y");let t=e[0];const s=e.find(e=>e.startsWith("--starter-kit=")),i=s?.split("=")[1],c=e.find(e=>e.startsWith("--starter-kit-source=")),a=c?.split("=")[1];if(e.includes("--list-starter-kits"))return void showStarterKits();const o=await ensureLatestCliVersion(e);let r=null,l=!1;if(t){const s=process.cwd(),c=path.join(s,"caspian.config.json");if(i&&a){l=!0;const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};r=await getAnswer(s,n)}else if(fs.existsSync(c)){const i=readJsonFile(c);let a=[];i.excludeFiles?.map(e=>{const n=path.join(s,e);fs.existsSync(n)&&a.push(n.replace(/\\/g,"/"))}),updateAnswer={projectName:t,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,websocket:i.websocket??!1,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s};const o={projectName:t,backendOnly:e.includes("--backend-only")||i.backendOnly,tailwindcss:e.includes("--tailwindcss")||i.tailwindcss,typescript:e.includes("--typescript")||i.typescript,prisma:e.includes("--prisma")||i.prisma,mcp:e.includes("--mcp")||i.mcp,websocket:e.includes("--websocket")||(i.websocket??!1)};r=await getAnswer(o,n),null!==r&&(updateAnswer={projectName:t,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:s})}else{const s={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),websocket:e.includes("--websocket"),prisma:e.includes("--prisma")};r=await getAnswer(s,n)}if(null===r)return void console.log(chalk.red("Installation cancelled."))}else r=await getAnswer({},n);if(null===r)return void console.warn(chalk.red("Installation cancelled."));const p=process.cwd();let d;if(t)if(l){const n=path.join(p,t);fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,await setupStarterKit(d,r),process.chdir(d);const s=path.join(d,"caspian.config.json");if(fs.existsSync(s)){const n=JSON.parse(fs.readFileSync(s,"utf8"));e.includes("--backend-only")&&(n.backendOnly=!0),e.includes("--tailwindcss")&&(n.tailwindcss=!0),e.includes("--typescript")&&(n.typescript=!0),e.includes("--mcp")&&(n.mcp=!0),e.includes("--websocket")&&(n.websocket=!0),e.includes("--prisma")&&(n.prisma=!0),r={...r,backendOnly:n.backendOnly,tailwindcss:n.tailwindcss,typescript:n.typescript,mcp:n.mcp,websocket:n.websocket??!1,prisma:n.prisma};let t=[];n.excludeFiles?.map(e=>{const n=path.join(d,e);fs.existsSync(n)&&t.push(n.replace(/\\/g,"/"))}),updateAnswer={...r,isUpdate:!0,componentScanDirs:n.componentScanDirs??[],excludeFiles:n.excludeFiles??[],excludeFilePath:t??[],filePath:d}}}else{const e=path.join(p,"caspian.config.json"),n=path.join(p,t),s=path.join(n,"caspian.config.json");fs.existsSync(e)?d=p:fs.existsSync(n)&&fs.existsSync(s)?(d=n,process.chdir(n)):(fs.existsSync(n)||fs.mkdirSync(n,{recursive:!0}),d=n,process.chdir(n))}else fs.mkdirSync(r.projectName,{recursive:!0}),d=path.join(p,r.projectName),process.chdir(r.projectName);let u=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("@lezer/common"),npmPkg("@lezer/python"),npmPkg("caspian-utils")];r.prisma&&u.push(npmPkg("prompts"),npmPkg("@types/prompts")),r.tailwindcss&&u.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano"),npmPkg("tailwind-merge")),r.prisma&&execSync(buildManagedNpmCommand(["install","-g","prisma-client-python@latest"]),{stdio:"inherit"}),r.typescript&&!r.backendOnly&&u.push(npmPkg("vite"),npmPkg("fast-glob")),r.typescript&&u.push(npmPkg("vitest")),r.starterKit&&!l&&await setupStarterKit(d,r),await installNpmDependencies(d,u,!0);let m=[];if(t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(d,r),r.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],n=[],t=e=>{try{const n=path.join(d,"package.json");if(fs.existsSync(n)){const t=JSON.parse(fs.readFileSync(n,"utf8"));return!!(t.dependencies&&t.dependencies[e]||t.devDependencies&&t.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const n=path.join(d,"src","app",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const n=path.join(d,"src","app",e);fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const n=path.join(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const s=path.join(d,"public","js","tailwind-merge.mjs");fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${s} was deleted successfully.`));const i=path.join(d,"public","js","bundle-mjs.mjs.map");fs.existsSync(i)&&(fs.unlinkSync(i),console.log(`${i} was deleted successfully.`));const c=path.join(d,"ts","tailwind-merge.ts");fs.existsSync(c)&&(fs.unlinkSync(c),console.log(`${c} was deleted successfully.`));["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano","tailwind-merge"].forEach(n=>{t(n)&&e.push(n)}),n.push("tailwind-merge")}if(r.tailwindcss){const e=path.join(d,"public","css","index.css");if(fs.existsSync(e))try{fs.unlinkSync(e),console.log(`${e} was deleted successfully.`)}catch(n){console.warn(chalk.yellow(`Failed to delete ${e}: ${n}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const n=path.join(d,"settings",e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const e=path.join(d,"src","lib","mcp");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("MCP folder was deleted successfully.")),n.push("fastmcp")}if(!updateAnswer.websocket){const e=path.join(d,"src","lib","websocket");fs.existsSync(e)&&(fs.rmSync(e,{recursive:!0,force:!0}),console.log("WebSocket folder was deleted successfully.")),n.push("websockets")}if(!updateAnswer.prisma){["prisma","@prisma/client","@prisma/internals","better-sqlite3","@prisma/adapter-better-sqlite3","mariadb","@prisma/adapter-mariadb","pg","@prisma/adapter-pg","@types/pg"].forEach(n=>{t(n)&&e.push(n)}),n.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts",path.join("settings","run-vite-watch.ts")].forEach(e=>{const n=path.join(d,e);fs.existsSync(n)&&(fs.unlinkSync(n),console.log(`${e} was deleted successfully.`))});const n=path.join(d,"ts");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const s=path.join(d,"settings","vite-plugins");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(n=>{t(n)&&e.push(n)})}const s=e=>Array.from(new Set(e)),i=s(e);i.length>0&&(console.log(`Uninstalling npm packages: ${i.join(", ")}`),await uninstallNpmDependencies(d,i,!0));const c=s(n),a=getPyProjectDependencyNames(d);m=c.filter(e=>a.has(e.toLowerCase())),m.length>0&&console.log(chalk.gray(`Python dependencies will be removed via uv remove: ${m.join(", ")}`))}if(!l||!fs.existsSync(path.join(d,"caspian.config.json"))){const e=d.replace(/\\/g,"\\"),n=bsConfigUrls(e),t={projectName:r.projectName,projectRootPath:e,bsTarget:n.bsTarget,bsPathRewrite:n.bsPathRewrite,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,websocket:r.websocket,prisma:r.prisma,typescript:r.typescript,version:o,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(d,"caspian.config.json"),JSON.stringify(t,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(d,r,m),console.log("\n=========================\n"),console.log(`${chalk.green("Success!")} Caspian project successfully created in ${chalk.green(d.replace(/\\/g,"/"))}!`),console.log("\n=========================")}catch(e){console.error("Error while creating the project:",e),process.exit(1)}}main();
@@ -24,6 +24,10 @@ Each rule below is a real rendering rule, not a heuristic:
24
24
  * whitespace touching a block-level boundary collapses and never renders
25
25
  * a text node's leading/trailing whitespace collapses when its parent is
26
26
  block-level, but not when the parent is inline or an unknown `<x-*>` tag
27
+ * in a flex or grid container, an anonymous item holding only white space is
28
+ not rendered at all (CSS Flexbox 4, CSS Grid 6), so whitespace between that
29
+ container's children collapses -- including when the container is an `<x-*>`
30
+ tag whose rendered root is known (see `component_display.py`)
27
31
  * `<pre>` / `<textarea>` text renders verbatim
28
32
  * `<script>` / `<style>` bodies are code: indentation is irrelevant
29
33
  * attribute ORDER never affects rendering; attribute VALUES always do
@@ -39,35 +43,119 @@ from __future__ import annotations
39
43
  import re
40
44
  from html.parser import HTMLParser
41
45
 
46
+
42
47
  LITERAL_TAGS = {"pre", "textarea"}
43
48
  CODE_TAGS = {"script", "style"}
44
49
 
45
- # Whitespace touching one of these collapses away. A custom `<x-*>` tag is
46
- # deliberately absent: its display is set by CSS the formatter cannot see, so it
47
- # is treated as inline and its surrounding whitespace is significant.
48
50
  # fmt: off
51
+ # Block-level boxes. Whitespace touching one of these collapses: it sits either
52
+ # at the edge of the box's own content or on a line that the block already
53
+ # broke, and in both places it is removed.
49
54
  BLOCK_TAGS = {
50
55
  "html", "head", "body", "div", "p", "section", "article", "header",
51
56
  "footer", "nav", "aside", "main", "form", "fieldset", "legend", "figure",
52
57
  "figcaption", "blockquote", "hr", "ul", "ol", "li", "dl", "dt", "dd",
53
58
  "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "colgroup",
54
59
  "col", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "details", "summary",
55
- "dialog", "script", "style", "template", "option", "optgroup", "select",
56
- "textarea", "video", "audio", "source", "track", "canvas", "iframe",
57
- "meta", "link", "title", "address", "hgroup", "menu", "search", "noscript",
58
- "br",
59
- # SVG. Inside an SVG fragment, whitespace between elements is never laid out
60
- # as text, so indenting the children of an inline <svg> cannot change what is
61
- # drawn. `<text>`, `<tspan>` and `<textPath>` are deliberately excluded --
62
- # they do render their content -- as is `<foreignObject>`, whose children are
63
- # HTML again and follow HTML rules.
60
+ "dialog", "script", "style", "template", "address", "hgroup", "menu",
61
+ "search", "noscript", "br", "meta", "link", "title",
62
+ }
63
+
64
+ # Inline-level elements that still lay their *content* out as a block. A
65
+ # `<button>` is `inline-block`: the whitespace at the edges of its content
66
+ # collapses exactly as it would in a div, even though whitespace next to the
67
+ # button itself is significant. Keeping these out of BLOCK_TAGS is the point --
68
+ # two adjacent inline-blocks separated by a newline really do render a space.
69
+ CONTENT_BOX_TAGS = {
70
+ "button", "select", "textarea", "option", "optgroup",
71
+ "video", "audio", "canvas", "iframe", "object", "progress", "meter",
72
+ }
73
+
74
+ # Containers where a child made only of whitespace cannot render at all.
75
+ # * SVG lays out no text, so indenting an svg fragment cannot change what is
76
+ # drawn. `<text>`, `<tspan>`, `<textPath>` and `<foreignObject>` are excluded
77
+ # because they do render their content.
78
+ # * A table or select drops stray text between its structural children.
79
+ # Flex and grid containers join this set at runtime, from their class list.
80
+ WS_DROPPING_TAGS = {
64
81
  "svg", "g", "defs", "symbol", "use", "path", "circle", "ellipse", "line",
65
82
  "polyline", "polygon", "rect", "clippath", "lineargradient",
66
83
  "radialgradient", "stop", "mask", "pattern", "filter", "marker", "desc",
67
84
  "animate", "animatetransform", "animatemotion", "switch", "image",
85
+ "table", "thead", "tbody", "tfoot", "tr", "colgroup", "select", "optgroup",
86
+ "html", "head",
68
87
  }
69
88
  # fmt: on
70
89
 
90
+ # Void elements cannot have children at all. A component written as
91
+ # `<x-input class="..."></x-input>` renders an `<input>`, so anything the author
92
+ # puts between those tags -- including a newline djLint adds -- is discarded.
93
+ VOID_TAGS = {
94
+ "area",
95
+ "base",
96
+ "br",
97
+ "col",
98
+ "embed",
99
+ "hr",
100
+ "img",
101
+ "input",
102
+ "link",
103
+ "meta",
104
+ "param",
105
+ "source",
106
+ "track",
107
+ "wbr",
108
+ }
109
+
110
+ # Tailwind's display utilities that create a flex or grid formatting context.
111
+ # Membership is tested against whitespace-split class tokens, so only the
112
+ # unprefixed form counts: `sm:flex` means the element is *not* flex at every
113
+ # breakpoint, so its whitespace can still render on a narrow screen.
114
+ FLEX_DISPLAY_CLASSES = {"flex", "inline-flex", "grid", "inline-grid"}
115
+ # Display utilities that make an element a block-level box, so whitespace next
116
+ # to it collapses. The `inline-*` forms are deliberately absent: two adjacent
117
+ # inline-blocks separated by a newline really do render a space.
118
+ BLOCK_LEVEL_CLASSES = {"block", "flow-root", "list-item", "table", "flex", "grid"}
119
+ # `display: none` removes the box entirely, so it is not a block-level box that
120
+ # whitespace beside it can collapse against. It says nothing about how its own
121
+ # content is laid out when some state does display it, so it only suppresses
122
+ # `block_level`.
123
+ HIDDEN_CLASS = "hidden"
124
+ # `display: inline` makes an element lay its content out inline, so edge
125
+ # whitespace inside it becomes significant. `display: contents` removes the box
126
+ # and hoists the children into the parent, exactly like `as-child`.
127
+ INLINE_CLASS = "inline"
128
+ CONTENTS_CLASS = "contents"
129
+
130
+ # Display utilities that make an element lay its content out as a block, so the
131
+ # whitespace at the edges of that content collapses. `inline-block` counts: only
132
+ # whitespace *next to* it stays significant, not whitespace inside it.
133
+ BLOCK_CONTAINER_CLASSES = {
134
+ "block",
135
+ "inline-block",
136
+ "flow-root",
137
+ "list-item",
138
+ "table",
139
+ "inline-table",
140
+ "table-cell",
141
+ "table-row",
142
+ "table-caption",
143
+ }
144
+ # Utilities that switch whitespace collapsing off. Every rule here assumes
145
+ # collapsible text, so an element carrying one of these knows nothing.
146
+ PRE_WHITESPACE_CLASSES = {
147
+ "whitespace-pre",
148
+ "whitespace-pre-wrap",
149
+ "whitespace-pre-line",
150
+ "whitespace-break-spaces",
151
+ }
152
+
153
+ # Attributes that make a component transparent: `as-child` renders the child in
154
+ # the component's place, so the child's whitespace lands in the *parent's*
155
+ # formatting context, not in any box the component owns.
156
+ AS_CHILD_ATTRS = ("as-child", "aschild", "as_child")
157
+ _TRUTHY_AS_CHILD = {"", "true", "1", "yes"}
158
+
71
159
  JINJA = re.compile(r"\{\{\s*(.*?)\s*\}\}|\{%\s*(.*?)\s*%\}", re.S)
72
160
 
73
161
 
@@ -88,19 +176,436 @@ def _canon_attr_value(value: str | None) -> str | None:
88
176
  return re.sub(r"\s+", " ", _canon_jinja(value)).strip()
89
177
 
90
178
 
179
+ # ---------------------------------------------------------------------------
180
+ # What an `<x-*>` tag really is
181
+ #
182
+ # A component tag is an ordinary HTML tag with an `x-` prefix: `<x-search />`
183
+ # *is* an `<svg>`, `<x-dialog-close>` *is* a `<button>`. Nothing above needs a
184
+ # custom rule for them -- they only need resolving to the element they render,
185
+ # and then every rule already written for HTML applies unchanged.
186
+ #
187
+ # The mapping is not declared anywhere because it does not have to be: a
188
+ # component is a function returning markup, so rendering it with no props shows
189
+ # its root. That is done once, lazily, the first time a custom tag is seen.
190
+ # ---------------------------------------------------------------------------
191
+
192
+ _ROOT_TAG_RE = re.compile(r"\s*<([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>")
193
+ _CLASS_ATTR_RE = re.compile(r'(?:^|\s)class="([^"]*)"')
194
+ # `merge_classes` renders a live `{twMerge("base classes", ...)}` expression, so
195
+ # a component's own classes are the first quoted literal inside it.
196
+ _TWMERGE_LITERAL_RE = re.compile(r'twMerge\(\s*(?:"|&quot;)(.*?)(?:"|&quot;)')
197
+
198
+ _component_roots: dict[str, dict] | None = None
199
+
200
+
201
+ def _describe_component(component) -> dict | None:
202
+ """Render one component with no props and name its root element."""
203
+ import asyncio
204
+ import html as html_module
205
+
206
+ try:
207
+ markup = str(asyncio.run(component.acall()))
208
+ except Exception:
209
+ # Needs props, or does not render standalone. Unknown is the safe answer.
210
+ return None
211
+ match = _ROOT_TAG_RE.match(markup)
212
+ if match is None:
213
+ return None
214
+ root, attrs_text = match.group(1), match.group(2)
215
+ if root.startswith("x-"):
216
+ # A composition component whose root is another component: the compiler
217
+ # inserts a `display: contents` host, so resolving the chain would mean
218
+ # reasoning about a box that lays out as if it were not there.
219
+ return None
220
+ classes = ""
221
+ class_match = _CLASS_ATTR_RE.search(attrs_text)
222
+ if class_match is not None:
223
+ literal = _TWMERGE_LITERAL_RE.search(class_match.group(1))
224
+ raw = html_module.unescape(class_match.group(1))
225
+ classes = html_module.unescape(literal.group(1)) if literal else ("" if "{" in raw else raw)
226
+ return {"root": root, "classes": classes}
227
+
228
+
229
+ def _load_component_roots() -> dict[str, dict]:
230
+ """Map every `x-*` tag to the element it renders. `{}` if unavailable."""
231
+ import importlib
232
+ import json
233
+ import sys
234
+ from pathlib import Path
235
+
236
+ settings_dir = Path(__file__).resolve().parent
237
+ project_root = settings_dir.parent
238
+ if str(project_root) not in sys.path:
239
+ sys.path.insert(0, str(project_root))
240
+ try:
241
+ entries = json.loads((settings_dir / "component-map.json").read_text(encoding="utf-8"))
242
+ except OSError, ValueError:
243
+ return {}
244
+
245
+ roots: dict[str, dict] = {}
246
+ for entry in entries:
247
+ name = str(entry.get("componentName") or "")
248
+ route = str(entry.get("importRoute") or "")
249
+ if not name or not route:
250
+ continue
251
+ try:
252
+ component = getattr(importlib.import_module(route), name, None)
253
+ except Exception:
254
+ continue
255
+ if component is None or not hasattr(component, "acall"):
256
+ continue
257
+ info = _describe_component(component)
258
+ if info is None:
259
+ continue
260
+ tag = "x-" + re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
261
+ # Two components claiming one tag resolve by import order; unknown is safer.
262
+ if tag in roots and roots[tag] != info:
263
+ roots[tag] = {}
264
+ continue
265
+ roots[tag] = info
266
+ return roots
267
+
268
+
269
+ def component_root(tag: str) -> dict:
270
+ """The rendered root of an `x-*` tag: `{"root": ..., "classes": ...}`."""
271
+ global _component_roots
272
+ if _component_roots is None:
273
+ try:
274
+ _component_roots = _load_component_roots()
275
+ except Exception:
276
+ _component_roots = {}
277
+ return _component_roots.get(tag) or {}
278
+
279
+
280
+ def resolve_tag(tag: str) -> str:
281
+ """The HTML tag an element behaves as. Non-components are themselves."""
282
+ if not tag.startswith("x-"):
283
+ return tag
284
+ return str(component_root(tag).get("root") or "")
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Seeing through `{{ attributes }}`
289
+ #
290
+ # Caspian's props contract forwards a root's attributes as one Jinja value:
291
+ # `<div {{ attributes }}>`. An HTML parser sees an attribute named
292
+ # `{{attributes}}` and no class at all, so the element's display is invisible --
293
+ # which is what froze the last few blocks.
294
+ #
295
+ # The value is built in the same Python file, by the two documented helpers, so
296
+ # an AST walk can recover it. Only a *fully literal* class is accepted:
297
+ # `merge_classes("a b c")`. The moment a props-derived value is merged in
298
+ # (`merge_classes(base, incoming_class)`) the answer is refused, because the
299
+ # call site's class wins at runtime through `twMerge` and the call site is in
300
+ # another file.
301
+ # ---------------------------------------------------------------------------
302
+
303
+ _MAX_RESOLVE_DEPTH = 6
304
+
305
+
306
+ def _call_name(node) -> str:
307
+ func = node.func
308
+ return getattr(func, "id", None) or getattr(func, "attr", None) or ""
309
+
310
+
311
+ def _literal_classes(node, assigned: dict, depth: int = 0) -> str | None:
312
+ """The class list a node evaluates to, or None when it is not fully literal."""
313
+ import ast
314
+
315
+ if depth > _MAX_RESOLVE_DEPTH:
316
+ return None
317
+ if isinstance(node, ast.Constant):
318
+ return node.value if isinstance(node.value, str) else None
319
+ if isinstance(node, ast.Name):
320
+ values = assigned.get(node.id)
321
+ # Reassigned under a branch: which value reaches the template is a
322
+ # runtime decision.
323
+ if not values or len(values) != 1:
324
+ return None
325
+ return _literal_classes(values[0], assigned, depth + 1)
326
+ if isinstance(node, ast.Call):
327
+ name = _call_name(node)
328
+ if name == "merge_classes":
329
+ if node.keywords:
330
+ return None
331
+ parts = []
332
+ for arg in node.args:
333
+ value = _literal_classes(arg, assigned, depth + 1)
334
+ if value is None:
335
+ return None
336
+ parts.append(value)
337
+ return " ".join(parts)
338
+ if name == "get_attributes":
339
+ if not node.args or not isinstance(node.args[0], ast.Dict):
340
+ return None
341
+ for key, value in zip(node.args[0].keys, node.args[0].values):
342
+ if isinstance(key, ast.Constant) and key.value == "class":
343
+ return _literal_classes(value, assigned, depth + 1)
344
+ return "" # a forwarded attribute set with no class of its own
345
+ return None
346
+
347
+
348
+ # A component script may also hold a class list behind a helper:
349
+ # `class="{getIndicatorIconClass()}"`. Only the simplest possible shape is
350
+ # accepted -- `const name = () => "literal";` -- because it takes no arguments,
351
+ # closes over nothing and has a single string body, so the attribute's value is
352
+ # that literal on every render. A helper with parameters or a block body is a
353
+ # runtime decision and stays unknown.
354
+ _SCRIPT_CLASS_FN = re.compile(
355
+ r"""(?<![\w$])const\s+([A-Za-z_$][\w$]*)\s*=\s*\(\s*\)\s*=>\s*(['"])(.*?)\2""",
356
+ re.S,
357
+ )
358
+ # Any other binding of that name means the arrow is not the whole story.
359
+ _SCRIPT_ANY_BINDING = r"(?<![\w$])(?:const|let|var|function)\s+{name}(?![\w$])"
360
+
361
+
362
+ def script_class_literals(source: str) -> dict[str, str]:
363
+ """Map `{name()}` class expressions to the literal they always return."""
364
+ found: dict[str, str] = {}
365
+ for name, _, literal in _SCRIPT_CLASS_FN.findall(source):
366
+ bindings = re.findall(_SCRIPT_ANY_BINDING.format(name=re.escape(name)), source)
367
+ if len(bindings) != 1:
368
+ found.pop(name, None)
369
+ continue
370
+ found[name] = literal
371
+ return {"{" + name + "()}": literal for name, literal in found.items()}
372
+
373
+
374
+ def class_hints(python_source: str) -> dict[str, str]:
375
+ """Every class list this file hides behind an expression.
376
+
377
+ Keyed by the attribute text the markup actually carries: `{{attributes}}`
378
+ for a forwarded Jinja value, `{helper()}` for a script helper.
379
+ """
380
+ hints = jinja_attr_classes(python_source)
381
+ hints.update(script_class_literals(python_source))
382
+ return hints
383
+
384
+
385
+ def jinja_attr_classes(python_source: str) -> dict[str, str]:
386
+ """Map each `{{ name }}` attribute variable to the class list it carries."""
387
+ import ast
388
+
389
+ try:
390
+ tree = ast.parse(python_source)
391
+ except SyntaxError:
392
+ return {}
393
+
394
+ assigned: dict[str, list] = {}
395
+ for node in ast.walk(tree):
396
+ if isinstance(node, ast.Assign):
397
+ for target in node.targets:
398
+ if isinstance(target, ast.Name):
399
+ assigned.setdefault(target.id, []).append(node.value)
400
+
401
+ resolved: dict[str, str] = {}
402
+ for node in ast.walk(tree):
403
+ if not isinstance(node, ast.Call) or _call_name(node) != "html":
404
+ continue
405
+ for keyword in node.keywords:
406
+ if not keyword.arg:
407
+ continue
408
+ classes = _literal_classes(keyword.value, assigned)
409
+ if classes is not None:
410
+ resolved["{{" + keyword.arg.lower() + "}}"] = classes
411
+ return resolved
412
+
413
+
414
+ # Every token that can set an element's display. A component's base classes are
415
+ # merged with whatever the call site passes, and `twMerge` lets the call site
416
+ # win -- so `<x-button class="inline">` really is inline despite the component's
417
+ # `inline-flex` base.
418
+ _DISPLAY_TOKENS = (
419
+ FLEX_DISPLAY_CLASSES
420
+ | BLOCK_LEVEL_CLASSES
421
+ | BLOCK_CONTAINER_CLASSES
422
+ | {HIDDEN_CLASS, INLINE_CLASS, CONTENTS_CLASS}
423
+ )
424
+
425
+
426
+ # Class-like words in an attribute value, including the ones inside a
427
+ # PulsePoint expression such as `{compact ? 'flex' : 'block'}`. Quotes and
428
+ # punctuation are not part of a word, so `'flex'` yields `flex` while the
429
+ # flex-grow utility `flex-1` stays `flex-1`.
430
+ _CLASS_WORD = re.compile(r"[A-Za-z][A-Za-z0-9_/-]*(?::[A-Za-z0-9_/-]+)*")
431
+
432
+
433
+ def _may_override_display(usage_class: str | None) -> bool:
434
+ """Whether a call site's class attribute could change the rendered display.
435
+
436
+ The value may be a PulsePoint expression rather than a plain class list, so
437
+ every class-like word in it is checked, variant prefix stripped: `sm:flex`
438
+ could override at that breakpoint, `flex-1` is flex-grow and cannot. If no
439
+ word can set a display, the call site provably cannot override the
440
+ component's own; if one can, the answer is unknown.
441
+ """
442
+ if not usage_class:
443
+ return False
444
+ for word in _CLASS_WORD.findall(usage_class):
445
+ if word.rsplit(":", 1)[-1] in _DISPLAY_TOKENS:
446
+ return True
447
+ return False
448
+
449
+
450
+ def _preserves_whitespace(class_value: str | None) -> bool:
451
+ """Whether a class list turns off whitespace collapsing.
452
+
453
+ Every rule here assumes collapsible text. Under `white-space: pre` a run of
454
+ spaces renders literally, and even a whitespace-only flex item is drawn, so
455
+ an element carrying one of these utilities has to fall back to knowing
456
+ nothing about its children.
457
+ """
458
+ if not class_value:
459
+ return False
460
+ return any(token in PRE_WHITESPACE_CLASSES for token in class_value.split())
461
+
462
+
463
+ def _as_child_state(attr_map: dict[str, str | None]) -> str:
464
+ """`"yes"` / `"no"` / `"unknown"` for a tag's as-child attribute."""
465
+ for name in AS_CHILD_ATTRS:
466
+ if name not in attr_map:
467
+ continue
468
+ value = (attr_map[name] or "").strip().lower()
469
+ if "{" in value:
470
+ # A runtime expression: the component may or may not be transparent,
471
+ # and guessing either way could drop whitespace that renders.
472
+ return "unknown"
473
+ return "yes" if value in _TRUTHY_AS_CHILD else "no"
474
+ return "no"
475
+
476
+
477
+ class _Frame:
478
+ """One open element, reduced to how it treats its children's whitespace.
479
+
480
+ ``collapses`` -- a text child's edge whitespace cannot render.
481
+ ``drops_ws`` -- a child made only of whitespace cannot render at all.
482
+ ``transparent`` -- the element owns no box (`as-child`); defer to the
483
+ nearest ancestor that does.
484
+ ``opaque`` -- nothing is known; every rule must stay conservative.
485
+ """
486
+
487
+ __slots__ = (
488
+ "tag",
489
+ "collapses",
490
+ "drops_ws",
491
+ "block_level",
492
+ "flex_container",
493
+ "transparent",
494
+ "opaque",
495
+ )
496
+
497
+ def __init__(
498
+ self,
499
+ tag: str,
500
+ attrs: tuple,
501
+ in_flex_parent: bool = False,
502
+ jinja_classes: dict | None = None,
503
+ ) -> None:
504
+ self.tag = tag
505
+ attr_map = dict(attrs)
506
+ state = _as_child_state(attr_map)
507
+ self.transparent = state == "yes"
508
+ self.opaque = state == "unknown"
509
+ self.block_level = False
510
+ self.flex_container = False
511
+ if self.transparent or self.opaque:
512
+ self.collapses = self.drops_ws = False
513
+ return
514
+
515
+ # A component behaves as the element it renders, plus that element's
516
+ # own classes; an ordinary tag is simply itself.
517
+ html_tag = tag
518
+ classes = attr_map.get("class") or ""
519
+ if classes and jinja_classes:
520
+ # `class="{helper()}"` -- a helper with a single literal body.
521
+ classes = jinja_classes.get(classes, classes)
522
+ if not classes and jinja_classes:
523
+ # `<div {{ attributes }}>` -- the class is behind a forwarded value.
524
+ for name in attr_map:
525
+ if name in jinja_classes:
526
+ classes = jinja_classes[name]
527
+ break
528
+ if tag.startswith("x-"):
529
+ html_tag = resolve_tag(tag)
530
+ if _may_override_display(classes):
531
+ # The call site could win the display over the component's base
532
+ # classes, and which one wins is a runtime `twMerge` decision.
533
+ self.collapses = self.drops_ws = False
534
+ return
535
+ classes = str(component_root(tag).get("classes") or "")
536
+
537
+ class_tokens = set(classes.split())
538
+ if CONTENTS_CLASS in class_tokens:
539
+ # No box of its own: the children belong to the parent's context.
540
+ self.transparent = True
541
+ self.collapses = self.drops_ws = False
542
+ return
543
+ if not html_tag or _preserves_whitespace(classes) or INLINE_CLASS in class_tokens:
544
+ self.collapses = self.drops_ws = False
545
+ return
546
+
547
+ flex = bool(class_tokens & FLEX_DISPLAY_CLASSES)
548
+ self.flex_container = flex
549
+ self.drops_ws = flex or html_tag in WS_DROPPING_TAGS or html_tag in VOID_TAGS
550
+ self.collapses = (
551
+ flex
552
+ or self.drops_ws
553
+ or html_tag in BLOCK_TAGS
554
+ or html_tag in CONTENT_BOX_TAGS
555
+ or bool(class_tokens & BLOCK_CONTAINER_CLASSES)
556
+ )
557
+ # An element's own display utility outranks its tag's default: a
558
+ # `<label class="block">` is a block-level box, a `<div class="inline">`
559
+ # is not one any more.
560
+ if HIDDEN_CLASS in class_tokens:
561
+ self.block_level = False
562
+ return
563
+ if in_flex_parent:
564
+ # A flex or grid item is blockified: its computed display becomes
565
+ # the block-level equivalent whatever the element's default was
566
+ # (CSS Display 2.7). So an inline `<label>` inside a grid really is
567
+ # a block box, and the whitespace at its edges collapses.
568
+ self.block_level = True
569
+ self.collapses = True
570
+ return
571
+ self.block_level = bool(class_tokens & BLOCK_LEVEL_CLASSES) or (
572
+ html_tag in BLOCK_TAGS
573
+ and not (class_tokens & BLOCK_CONTAINER_CLASSES - BLOCK_LEVEL_CLASSES)
574
+ )
575
+
576
+
91
577
  class _Tokens(HTMLParser):
92
578
  """Reduce markup to a token stream where equality implies equal rendering."""
93
579
 
94
- def __init__(self) -> None:
580
+ def __init__(self, jinja_classes: dict | None = None) -> None:
95
581
  super().__init__(convert_charrefs=False)
582
+ self.jinja_classes = jinja_classes or {}
96
583
  self.out: list[tuple] = []
97
- self._open: list[str] = []
584
+ self._open: list[_Frame] = []
98
585
  self._raw_stack: list[str] = []
99
586
  self._raw_buf: list[str] = []
100
587
 
101
588
  def _in_raw(self) -> str | None:
102
589
  return self._raw_stack[-1] if self._raw_stack else None
103
590
 
591
+ def _parent_is_flex(self) -> bool:
592
+ """Whether the element about to open becomes a flex or grid item."""
593
+ frame = self._context()
594
+ return frame is not None and frame.flex_container
595
+
596
+ def _context(self) -> "_Frame | None":
597
+ """The nearest open element that actually owns a box.
598
+
599
+ A transparent element (`as-child`) renders its child in its own place,
600
+ so whitespace written inside it belongs to whichever ancestor lays the
601
+ child out. An opaque one stops the walk with nothing known.
602
+ """
603
+ for frame in reversed(self._open):
604
+ if frame.transparent:
605
+ continue
606
+ return frame
607
+ return None
608
+
104
609
  def _attrs(self, attrs) -> tuple:
105
610
  return tuple(sorted((k, _canon_attr_value(v)) for k, v in attrs))
106
611
 
@@ -111,15 +616,19 @@ class _Tokens(HTMLParser):
111
616
  if tag in LITERAL_TAGS | CODE_TAGS:
112
617
  self._raw_stack.append(tag)
113
618
  self._raw_buf = []
114
- self.out.append(("start", tag, self._attrs(attrs)))
115
- self._open.append(tag)
619
+ attrs_tuple = self._attrs(attrs)
620
+ frame = _Frame(tag, attrs_tuple, self._parent_is_flex(), self.jinja_classes)
621
+ self.out.append(("start", tag, attrs_tuple, frame.block_level))
622
+ self._open.append(frame)
116
623
 
117
624
  def handle_startendtag(self, tag, attrs):
118
625
  if self._in_raw():
119
626
  self._raw_buf.append(self.get_starttag_text() or "")
120
627
  return
121
- self.out.append(("start", tag, self._attrs(attrs)))
122
- self.out.append(("end", tag))
628
+ attrs_tuple = self._attrs(attrs)
629
+ frame = _Frame(tag, attrs_tuple, self._parent_is_flex(), self.jinja_classes)
630
+ self.out.append(("start", tag, attrs_tuple, frame.block_level))
631
+ self.out.append(("end", tag, frame.block_level))
123
632
 
124
633
  def handle_endtag(self, tag):
125
634
  raw = self._in_raw()
@@ -134,9 +643,10 @@ class _Tokens(HTMLParser):
134
643
  self.out.append(("raw", raw, body))
135
644
  self._raw_stack.pop()
136
645
  self._raw_buf = []
137
- self.out.append(("end", tag))
138
- if tag in self._open:
139
- while self._open and self._open.pop() != tag:
646
+ closing = next((f for f in reversed(self._open) if f.tag == tag), None)
647
+ self.out.append(("end", tag, closing.block_level if closing else False))
648
+ if any(frame.tag == tag for frame in self._open):
649
+ while self._open and self._open.pop().tag != tag:
140
650
  pass
141
651
 
142
652
  def handle_data(self, data):
@@ -146,18 +656,24 @@ class _Tokens(HTMLParser):
146
656
  collapsed = re.sub(r"\s+", " ", _canon_jinja(data))
147
657
  if collapsed == "":
148
658
  return
149
- parent = self._open[-1] if self._open else ""
659
+ frame = self._context()
660
+ parent = frame.tag if frame is not None else ""
150
661
  if collapsed.strip() == "":
151
- # A pure-whitespace node: its existence can separate two inline
152
- # elements, so it is kept as a token, but its length is irrelevant.
153
- self.out.append(("ws",))
662
+ # A pure-whitespace node. Inside a flex or grid container it becomes
663
+ # an anonymous item holding only white space, which is never
664
+ # rendered; anywhere else its presence can separate two inline
665
+ # elements, so it is kept as a token whose length is irrelevant.
666
+ if frame is not None and frame.drops_ws:
667
+ return
668
+ self.out.append(("ws", frame is not None and frame.collapses))
154
669
  return
670
+ collapses = frame is not None and frame.collapses
155
671
  self.out.append(
156
672
  (
157
673
  "text",
158
674
  collapsed.strip(),
159
- collapsed[0].isspace(),
160
- collapsed[-1].isspace(),
675
+ collapsed[0].isspace() and not collapses,
676
+ collapsed[-1].isspace() and not collapses,
161
677
  parent,
162
678
  )
163
679
  )
@@ -185,10 +701,16 @@ class _Tokens(HTMLParser):
185
701
 
186
702
 
187
703
  def _is_block_boundary(token: tuple | None) -> bool:
704
+ """Whether whitespace touching this token collapses.
705
+
706
+ Only a genuinely block-level box qualifies. An inline-block (`<button>`) or
707
+ an inline replaced element (`<svg>`) does not: two of them separated by a
708
+ newline really do render a space between them.
709
+ """
188
710
  if token is None:
189
711
  return True # the fragment's own edge
190
712
  if token[0] in ("start", "end"):
191
- return token[1] in BLOCK_TAGS
713
+ return bool(token[-1])
192
714
  return False
193
715
 
194
716
 
@@ -196,32 +718,52 @@ def _drop_insignificant_ws(tokens: list[tuple]) -> list[tuple]:
196
718
  """Remove whitespace nodes that provably cannot render."""
197
719
  out: list[tuple] = []
198
720
  for i, tok in enumerate(tokens):
199
- if tok != ("ws",):
721
+ if tok[0] != "ws":
200
722
  out.append(tok)
201
723
  continue
202
724
  prev = out[-1] if out else None
203
- nxt = next((t for t in tokens[i + 1 :] if t != ("ws",)), None)
725
+ nxt = next((t for t in tokens[i + 1 :] if t[0] != "ws"), None)
204
726
  if _is_block_boundary(prev) and _is_block_boundary(nxt):
205
727
  continue
206
- out.append(tok)
728
+ # Whitespace at the first or last position inside a container that lays
729
+ # its content out as a block: a leading space is removed at the start of
730
+ # the first line box, a trailing one at the end of the last.
731
+ at_open = prev is not None and prev[0] == "start"
732
+ at_close = nxt is not None and nxt[0] == "end"
733
+ if tok[1] and (at_open or at_close):
734
+ continue
735
+ out.append(("ws",))
207
736
  return out
208
737
 
209
738
 
210
739
  def _canon_text_edges(tokens: list[tuple]) -> list[tuple]:
211
- """Drop edge-whitespace flags for text inside a block-level parent.
740
+ """Drop edge-whitespace flags that provably cannot render.
741
+
742
+ Three separate reasons a text node's edge whitespace collapses:
212
743
 
213
- `<h1>\\n Title\\n</h1>` -> `<h1>Title</h1>` cannot change rendering, because
214
- whitespace at the edges of a block container always collapses. The same trim
215
- inside a `<span>` or an `<x-*>` tag CAN change rendering (it closes a gap
216
- against an adjacent inline sibling), so those keep their flags and will be
217
- reported as a difference.
744
+ * its parent lays its content out as a block -- `<h1>\n Title\n</h1>`
745
+ is `<h1>Title</h1>`, because whitespace at the edge of a block container
746
+ always collapses;
747
+ * the neighbour on that side is a block-level box -- whitespace next to one
748
+ is removed, even inside an inline parent;
749
+ * both, which is the common case.
750
+
751
+ Inside a `<span>` between two inline siblings none of that applies, and the
752
+ flags survive to be reported as a difference.
218
753
  """
219
754
  out: list[tuple] = []
220
- for tok in tokens:
221
- if tok[0] == "text" and tok[4] in BLOCK_TAGS:
222
- out.append(("text", tok[1], False, False, tok[4]))
223
- else:
755
+ for i, tok in enumerate(tokens):
756
+ if tok[0] != "text":
224
757
  out.append(tok)
758
+ continue
759
+ _, body, lead, trail, parent = tok
760
+ if parent in BLOCK_TAGS:
761
+ lead = trail = False
762
+ if lead and _is_block_boundary(out[-1] if out else None):
763
+ lead = False
764
+ if trail and _is_block_boundary(tokens[i + 1] if i + 1 < len(tokens) else None):
765
+ trail = False
766
+ out.append(("text", body, lead, trail, parent))
225
767
  return out
226
768
 
227
769
 
@@ -243,8 +785,8 @@ def _squeeze_jinja(markup: str) -> str:
243
785
  return JINJA.sub(sub, markup)
244
786
 
245
787
 
246
- def tokenize(markup: str) -> list[tuple] | None:
247
- parser = _Tokens()
788
+ def tokenize(markup: str, jinja_classes: dict | None = None) -> list[tuple] | None:
789
+ parser = _Tokens(jinja_classes)
248
790
  try:
249
791
  parser.feed(_squeeze_jinja(markup))
250
792
  parser.close()
@@ -253,13 +795,17 @@ def tokenize(markup: str) -> list[tuple] | None:
253
795
  return _canon_text_edges(_drop_insignificant_ws(parser.out))
254
796
 
255
797
 
256
- def equivalent(before: str, after: str) -> tuple[bool, str]:
798
+ def equivalent(before: str, after: str, jinja_classes: dict | None = None) -> tuple[bool, str]:
257
799
  """True when `after` is guaranteed to render exactly like `before`.
258
800
 
801
+ `jinja_classes` maps `{{name}}` attribute variables to the class list they
802
+ forward, from `jinja_attr_classes` on the owning Python file. Without it a
803
+ `<div {{ attributes }}>` simply has an unknown display, as before.
804
+
259
805
  The second element is a short explanation of the first difference, for the
260
806
  skip report.
261
807
  """
262
- ta, tb = tokenize(before), tokenize(after)
808
+ ta, tb = tokenize(before, jinja_classes), tokenize(after, jinja_classes)
263
809
  if ta is None or tb is None:
264
810
  return False, "markup could not be parsed"
265
811
  if ta == tb:
@@ -53,6 +53,7 @@ import subprocess
53
53
  import sys
54
54
  import tempfile
55
55
  from dataclasses import dataclass, field
56
+ from functools import lru_cache
56
57
  from pathlib import Path
57
58
 
58
59
  import _markup_equivalence as eq
@@ -345,6 +346,20 @@ def render_literal(block: Block, markup: str) -> str | None:
345
346
  return f"{block.prefix}{body}{block.quote}"
346
347
 
347
348
 
349
+ @lru_cache(maxsize=None)
350
+ def jinja_classes(path: Path) -> dict:
351
+ """Class lists this file hides behind an expression.
352
+
353
+ Recovered from the file's own source so the oracle can see the display of a
354
+ root written as `<div {{ attributes }}>` or `class="{helper()}"`. Cached
355
+ because every block in a file shares one answer.
356
+ """
357
+ try:
358
+ return eq.class_hints(path.read_text(encoding="utf-8"))
359
+ except OSError:
360
+ return {}
361
+
362
+
348
363
  @dataclass
349
364
  class Skip:
350
365
  path: str
@@ -402,7 +417,7 @@ def format_markup_blocks(*, write: bool) -> MarkupReport:
402
417
  if formatted.strip("\n") == block.source.strip("\n"):
403
418
  report.already += 1
404
419
  continue
405
- same, why = eq.equivalent(block.source, formatted)
420
+ same, why = eq.equivalent(block.source, formatted, jinja_classes(path))
406
421
  if not same:
407
422
  report.skips.append(Skip(rel, block.lineno, why))
408
423
  continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.3.21",
3
+ "version": "1.3.23",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",