create-caspian-app 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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);let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.html","not-found.html","error.html"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.html","src/app/index.html"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.html","src/app/index.html","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1},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},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const s=e.indexOf("\\htdocs\\");if(-1===s)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const t=e.substring(0,s+8).replace(/\\/g,"\\\\"),n=e.replace(new RegExp(`^${t}`),"").replace(/\\/g,"/");let i=`http://localhost/${n}`;i=i.endsWith("/")?i.slice(0,-1):i;const c=i.replace(/(?<!:)(\/\/+)/g,"/"),a=n.replace(/\/\/+/g,"/");return{bsTarget:`${c}/`,bsPathRewrite:{"^/":`/${a.startsWith("/")?a.substring(1):a}/`}}}async function updatePackageJson(e,s){const t=path.join(e,"package.json");if(checkExcludeFiles(t))return;const n=JSON.parse(fs.readFileSync(t,"utf8"));n.scripts={...n.scripts,projectName:"tsx settings/project-name.ts"};let i=[];s.tailwindcss&&(n.scripts={...n.scripts,tailwind:"postcss src/app/globals.css -o public/css/styles.css --watch","tailwind:build":"postcss src/app/globals.css -o public/css/styles.css"},i.push("tailwind")),s.typescript&&!s.backendOnly&&(n.scripts={...n.scripts,"ts:watch":"vite build --watch","ts:build":"vite build"},i.push("ts:watch")),s.mcp&&(n.scripts={...n.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let c={...n.scripts};c.browserSync="tsx settings/bs-config.ts",c["browserSync:build"]="tsx settings/build.ts",c.dev=`npm-run-all projectName -p browserSync ${i.join(" ")}`;let a=["browserSync:build"];s.tailwindcss&&a.unshift("tailwind:build"),s.typescript&&!s.backendOnly&&a.unshift("ts:build"),c.build=`npm-run-all ${a.join(" ")}`,n.scripts=c,n.type="module",fs.writeFileSync(t,JSON.stringify(n,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function copyRecursiveSync(e,s,t){const n=fs.existsSync(e),i=n&&fs.statSync(e);if(n&&i&&i.isDirectory()){const n=s.toLowerCase();if(!t.mcp&&n.includes("src\\lib\\mcp"))return;if((!t.typescript||t.backendOnly)&&(n.endsWith("\\ts")||n.includes("\\ts\\")))return;if((!t.typescript||t.backendOnly)&&(n.endsWith("\\vite-plugins")||n.includes("\\vite-plugins\\")||n.includes("\\vite-plugins")))return;if(t.backendOnly&&n.includes("public\\js")||t.backendOnly&&n.includes("public\\css")||t.backendOnly&&n.includes("public\\assets"))return;const i=s.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),fs.readdirSync(e).forEach(n=>{copyRecursiveSync(path.join(e,n),path.join(s,n),t)})}else{if(checkExcludeFiles(s))return;if(!t.tailwindcss&&(s.includes("globals.css")||s.includes("styles.css")))return;if(!t.mcp&&s.includes("restart-mcp.ts"))return;if(t.backendOnly&&nonBackendFiles.some(e=>s.includes(e)))return;if(t.backendOnly&&s.includes("layout.html"))return;if(t.tailwindcss&&s.includes("index.css"))return;if(!t.prisma&&s.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,s,0)}}async function executeCopy(e,s,t){s.forEach(({src:s,dest:n})=>{copyRecursiveSync(path.join(__dirname,s),path.join(e,n),t)})}function modifyPostcssConfig(e){const s=path.join(e,"postcss.config.js");if(checkExcludeFiles(s))return;fs.writeFileSync(s,'export default {\n plugins: {\n "@tailwindcss/postcss": {},\n cssnano: {},\n },\n};',{flag:"w"})}function modifyLayoutPHP(e,s){const t=path.join(e,"src","app","layout.html");if(!checkExcludeFiles(t))try{let e=fs.readFileSync(t,"utf8"),n="";s.backendOnly||(s.tailwindcss||(n='\n <link href="/css/index.css" rel="stylesheet" />'),n+='\n <script type="module" src="/js/main.js"><\/script>');let i="";s.backendOnly||(i=s.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${n}`:n),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(t,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.html:"),e)}}async function createOrUpdateEnvFile(e,s){const t=path.join(e,".env");checkExcludeFiles(t)||fs.writeFileSync(t,s,{flag:"w"})}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const s=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(s)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const t=e.replace(/\\/g,"/");return s.endsWith("/"+t)||s===t})}async function createDirectoryStructure(e,s){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"}];s.tailwindcss&&t.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),s.typescript&&!s.backendOnly&&t.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const n=[{src:"/settings",dest:"/settings"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"}];s.typescript&&!s.backendOnly&&n.push({src:"/ts",dest:"/ts"}),t.forEach(({src:s,dest:t})=>{const n=path.join(__dirname,s),i=path.join(e,t);if(checkExcludeFiles(i))return;const c=fs.readFileSync(n,"utf8");fs.writeFileSync(i,c,{flag:"w"})}),await executeCopy(e,n,s),await updatePackageJson(e,s),s.tailwindcss&&modifyPostcssConfig(e),!s.tailwindcss&&s.backendOnly||modifyLayoutPHP(e,s);const i=`# Authentication secret key for JWT or session encryption.\nAUTH_SECRET="${generateAuthSecret()}"\n# Name of the authentication cookie.\nAUTH_COOKIE_NAME="${generateHexEncodedKey(8)}"\nAUTH_TOKEN_VALIDITY="1h"\nAUTH_TOKEN_AUTO_REFRESH="true"\n\nAUTH_ALL_ROUTES_PRIVATE="true"\nAUTH_PUBLIC_ROUTES="/"\nAUTH_ROUTES="/signin,/signup"\nAUTH_PRIVATE_ROUTES=""\n\nAUTH_ROLE_BASED="false"\nAUTH_ROLE_IDENTIFIER="role"\n# Role-based route allowlist (JSON)\n# Format: {"roleName": ["/route1", "/route2"]}\n# Example:\n# AUTH_ROLE_BASED_ROUTES_JSON="{"admin": ["/admin", "/reports"], "user": ["/dashboard"]}"\nAUTH_ROLE_BASED_ROUTES_JSON="{}"\n\nAUTH_DEFAULT_SIGNIN_REDIRECT="/dashboard"\nAUTH_DEFAULT_SIGNOUT_REDIRECT="/signin"\nAUTH_API_PREFIX="/api/auth"\n\n# Show errors in the browser (development only). Set to false in production.\nSHOW_ERRORS="true"\n\n# Application timezone (default: UTC)\nAPP_TIMEZONE="UTC"\n\n# Application environment (development or production)\nAPP_ENV="development"\n\n# Enable or disable application cache (default: false)\nCACHE_ENABLED="false"\n# Cache time-to-live in seconds (default: 600)\nCACHE_TTL="600"\n\n# Secret key for encrypting function calls.\nFUNCTION_CALL_SECRET="${generateHexEncodedKey(32)}"\n\n# Single or multiple origins allowed for CORS (comma-separated)\nCORS_ALLOWED_ORIGINS=""\n\n# If you need cookies/Authorization across origins, keep this true\nCORS_ALLOW_CREDENTIALS="true"\n\n# Optional tuning\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\nCORS_MAX_AGE="86400"\n\n# Session & Security\nSESSION_LIFETIME_HOURS="7"\nMAX_CONTENT_LENGTH_MB="16"\n\n# Rate Limiting\nRATE_LIMIT_DEFAULT="200 per minute"\nRATE_LIMIT_RPC="60 per minute"\nRATE_LIMIT_AUTH="60 per minute"`;if(s.prisma){const s=`${'# Environment variables declared in this file are automatically made available to Prisma.\n# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema\n\n# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.\n# See the documentation for all the connection string options: https://pris.ly/d/connection-strings\n\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"'}\n\n${i}`;await createOrUpdateEnvFile(e,s)}else await createOrUpdateEnvFile(e,i)}async function getAnswer(e={},s=!1){if(s)return{projectName:e.projectName??"my-app",backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,prisma:e.prisma??!1};if(e.starterKit){const s=e.starterKit;let t=null;if(STARTER_KITS[s]&&(t=STARTER_KITS[s]),t){const n={projectName:e.projectName??"my-app",starterKit:s,starterKitSource:e.starterKitSource,backendOnly:t.features.backendOnly??!1,tailwindcss:t.features.tailwindcss??!1,prisma:t.features.prisma??!1,mcp:t.features.mcp??!1,typescript:t.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(n.backendOnly=!0),i.includes("--tailwindcss")&&(n.tailwindcss=!0),i.includes("--mcp")&&(n.mcp=!0),i.includes("--prisma")&&(n.prisma=!0),i.includes("--typescript")&&(n.typescript=!0),n}if(e.starterKitSource){const t={projectName:e.projectName??"my-app",starterKit:s,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,typescript:!1},n=process.argv.slice(2);return n.includes("--backend-only")&&(t.backendOnly=!0),n.includes("--tailwindcss")&&(t.tailwindcss=!0),n.includes("--mcp")&&(t.mcp=!0),n.includes("--prisma")&&(t.prisma=!0),n.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 n=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(t,{onCancel:n}),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.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"}));const a=await prompts(c,{onCancel:n});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,prisma:a.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,s,t=!1){console.log("Uninstalling Node dependencies:"),s.forEach(e=>console.log(`- ${chalk.blue(e)}`));const n=`npm uninstall ${t?"--save-dev":"--save"} ${s.join(" ")}`;execSync(n,{stdio:"inherit",cwd:e})}function fetchPackageVersion(e){return new Promise((s,t)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let n="";e.on("data",e=>n+=e),e.on("end",()=>{try{const e=JSON.parse(n);s(e["dist-tags"].latest)}catch(e){t(new Error("Failed to parse JSON response"))}})}).on("error",e=>t(e))})}const readJsonFile=e=>{const s=fs.readFileSync(e,"utf8");return JSON.parse(s)};function compareVersions(e,s){const t=e.split(".").map(Number),n=s.split(".").map(Number);for(let e=0;e<t.length;e++){if(t[e]>n[e])return 1;if(t[e]<n[e])return-1}return 0}function getInstalledPackageVersion(e){try{const s=execSync(`npm list -g ${e} --depth=0`).toString().match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+)`));return s?s[1]:(console.error(`Package ${e} is not installed`),null)}catch(e){return console.error(e instanceof Error?e.message:String(e)),null}}async function installNpmDependencies(e,s,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("npm init -y",{stdio:"inherit",cwd:e}),console.log((t?"Installing development dependencies":"Installing dependencies")+":"),s.forEach(e=>console.log(`- ${chalk.blue(e)}`));const n=`npm install ${t?"--save-dev":""} ${s.join(" ")}`;execSync(n,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.1.18","@types/browser-sync":"2.29.1","@types/node":"25.0.3","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"5.6.2","chokidar-cli":"3.0.0",cssnano:"7.1.2","http-proxy-middleware":"3.0.5","npm-run-all":"4.1.5",postcss:"8.5.6","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.1.18",tsx:"4.21.0",typescript:"5.9.3",vite:"7.3.0","fast-glob":"3.3.3","tree-sitter":"^0.25.0","tree-sitter-python":"^0.25.0"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}async function setupStarterKit(e,s){if(!s.starterKit)return;let t=null;if(STARTER_KITS[s.starterKit]?t=STARTER_KITS[s.starterKit]:s.starterKitSource&&(t={id:s.starterKit,name:`Custom Starter Kit (${s.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:s.starterKitSource}}),t){if(console.log(chalk.green(`Setting up ${t.name}...`)),t.source)try{const n=t.source.branch?`git clone -b ${t.source.branch} --depth 1 ${t.source.url} ${e}`:`git clone --depth 1 ${t.source.url} ${e}`;execSync(n,{stdio:"inherit"});const i=path.join(e,".git");fs.existsSync(i)&&fs.rmSync(i,{recursive:!0,force:!0}),console.log(chalk.blue("Starter kit cloned successfully!"));const c=path.join(e,"caspian.config.json");if(fs.existsSync(c))try{const t=JSON.parse(fs.readFileSync(c,"utf8")),n=e.replace(/\\/g,"\\"),i=bsConfigUrls(n);t.projectName=s.projectName,t.projectRootPath=n,t.bsTarget=i.bsTarget,t.bsPathRewrite=i.bsPathRewrite;const a=await fetchPackageVersion("create-caspian-app");t.version=t.version||a,fs.writeFileSync(c,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,s),console.log(chalk.green(`āœ“ ${t.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${s.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\nšŸš€ Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const s=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(s)}`)),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,s,t){const n=spawnSync(e,s,{cwd:t,stdio:"inherit",shell:!1,encoding:"utf8"});if(n.error)throw n.error;if(0!==n.status)throw new Error(`Command failed (${e} ${s.join(" ")}), exit=${n.status}`)}function tryRunCmd(e,s,t){const n=spawnSync(e,s,{cwd:t,stdio:"ignore",shell:!1,encoding:"utf8"});return!n.error&&0===n.status}function getVenvPythonPath(e){const s=path.join(e,".venv","Scripts","python.exe");if(fs.existsSync(s))return s;return path.join(e,".venv","bin","python")}async function uninstallPythonDependencies(e,s){const t=getVenvPythonPath(e);fs.existsSync(t)?(console.log("Uninstalling Python dependencies:"),s.forEach(e=>console.log(`- ${chalk.blue(e)}`)),runCmd(t,["-m","pip","uninstall","-y",...s],e)):console.warn(chalk.yellow("Virtual environment not found. Skipping Python dependency uninstallation."))}function fetchPyPiPackageVersion(e){return new Promise((s,t)=>{https.get(`https://pypi.org/pypi/${e}/json`,e=>{let n="";e.on("data",e=>n+=e),e.on("end",()=>{if(200===e.statusCode)try{const e=JSON.parse(n);s(e.info.version)}catch(e){t(new Error("Failed to parse PyPI JSON response"))}else t(new Error(`Request failed with status code ${e.statusCode}`))})}).on("error",e=>t(e))})}async function ensurePythonVenvAndDeps(e,s){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: creating venv + installing dependencies")),console.log(chalk.green("=========================\n"));const t=path.join(e,".venv");if(fs.existsSync(t)&&(fs.existsSync(path.join(e,".venv","Scripts","python.exe"))||fs.existsSync(path.join(e,".venv","bin","python"))))console.log(chalk.gray("Venv already exists: .venv"));else if(console.log(chalk.blue("Creating virtual environment: .venv")),tryRunCmd("py",["-m","venv",".venv"],e));else if(tryRunCmd("python",["-m","venv",".venv"],e));else if(!tryRunCmd("python3",["-m","venv",".venv"],e))throw new Error("Could not create venv. Install Python and ensure `py` or `python` is in PATH.");const n=getVenvPythonPath(e);if(!fs.existsSync(n))throw new Error(`Venv python not found at: ${n}`);console.log(chalk.blue("Upgrading pip...")),runCmd(n,["-m","pip","install","--upgrade","pip"],e);let i="caspian-utils";try{const e=await fetchPyPiPackageVersion("caspian-utils");i=`caspian-utils==${e}`,console.log(chalk.blue(`Pinned caspian-utils to version ${e}`))}catch(e){console.warn(chalk.yellow("Could not fetch latest caspian-utils version. Using unpinned dependency."))}const c=["fastapi>=0.110,<0.128","uvicorn>=0.27,<0.40","python-dotenv>=1.0,<2.0","jinja2>=3.1,<4.0","beautifulsoup4>=4.12,<5.0","slowapi>=0.1,<0.2","python-multipart>=0.0.9,<0.1","starsessions>=1.3,<2.2","httpx>=0.27,<0.29","werkzeug>=3.0,<4.0","cuid2>=2.0,<3.0","nanoid>=2.0,<3.0","python-ulid>=2.7,<3.1","cuid>=0.4.0,<0.5.0",i];s.tailwindcss&&c.push("tailwind-merge>=0.3.3,<0.4.0"),s.mcp&&c.push("fastmcp<3"),s.prisma&&(c.push("psycopg2-binary>=2.9,<3.0"),c.push("asyncpg>=0.31.0,<1.0"),c.push("aiosqlite>=0.22.1,<1.0"),c.push("aiomysql>=0.3.2,<1.0")),console.log(chalk.blue("Generating requirements.txt..."));const a=path.join(e,"requirements.txt"),r=c.join("\n")+"\n";fs.writeFileSync(a,r,"utf8"),console.log(chalk.gray(`Created requirements.txt with ${c.length} packages.`)),console.log(chalk.blue("Installing dependencies from requirements.txt...")),c.forEach(e=>console.log(`- ${chalk.gray(e)}`));runCmd(n,["-m","pip","install",...updateAnswer?.isUpdate?["--upgrade","--upgrade-strategy","only-if-needed"]:[],...updateAnswer?.isUpdate?["--no-cache-dir"]:[],"-r","requirements.txt"],e),console.log(chalk.green("\nāœ“ Python venv ready and FastAPI dependencies installed.\n"))}function setupVsCodeSettings(e){const s=path.join(e,".vscode");fs.existsSync(s)||fs.mkdirSync(s);const t={"python.defaultInterpreterPath":"win32"===process.platform?"${workspaceFolder}/.venv/Scripts/python.exe":"${workspaceFolder}/.venv/bin/python","python.terminal.activateEnvironment":!0,"python.analysis.typeCheckingMode":"basic","python.analysis.autoImportCompletions":!0};fs.writeFileSync(path.join(s,"settings.json"),JSON.stringify(t,null,2)),console.log(chalk.gray("Created .vscode/settings.json for auto-activation."))}async function main(){try{const e=process.argv.slice(2),s=e.includes("-y");let t=e[0];const n=e.find(e=>e.startsWith("--starter-kit=")),i=n?.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 r=null,o=!1;if(t){const n=process.cwd(),c=path.join(n,"caspian.config.json");if(i&&a){o=!0;const n={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),prisma:e.includes("--prisma")};r=await getAnswer(n,s)}else if(fs.existsSync(c)){const i=readJsonFile(c);let a=[];i.excludeFiles?.map(e=>{const s=path.join(n,e);fs.existsSync(s)&&a.push(s.replace(/\\/g,"/"))}),updateAnswer={projectName:t,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:n};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};r=await getAnswer(o,s),null!==r&&(updateAnswer={projectName:t,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,prisma:r.prisma,typescript:r.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:n})}else{const n={projectName:t,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),prisma:e.includes("--prisma")};r=await getAnswer(n,s)}if(null===r)return void console.log(chalk.red("Installation cancelled."))}else r=await getAnswer({},s);if(null===r)return void console.warn(chalk.red("Installation cancelled."));const l=await fetchPackageVersion("create-caspian-app"),p=getInstalledPackageVersion("create-caspian-app");p?-1===compareVersions(p,l)&&(execSync("npm uninstall -g create-caspian-app",{stdio:"inherit"}),execSync("npm install -g create-caspian-app",{stdio:"inherit"})):execSync("npm install -g create-caspian-app",{stdio:"inherit"});const d=process.cwd();let u;if(t)if(o){const s=path.join(d,t);fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),u=s,await setupStarterKit(u,r),process.chdir(u);const n=path.join(u,"caspian.config.json");if(fs.existsSync(n)){const s=JSON.parse(fs.readFileSync(n,"utf8"));e.includes("--backend-only")&&(s.backendOnly=!0),e.includes("--tailwindcss")&&(s.tailwindcss=!0),e.includes("--typescript")&&(s.typescript=!0),e.includes("--mcp")&&(s.mcp=!0),e.includes("--prisma")&&(s.prisma=!0),r={...r,backendOnly:s.backendOnly,tailwindcss:s.tailwindcss,typescript:s.typescript,mcp:s.mcp,prisma:s.prisma};let t=[];s.excludeFiles?.map(e=>{const s=path.join(u,e);fs.existsSync(s)&&t.push(s.replace(/\\/g,"/"))}),updateAnswer={...r,isUpdate:!0,componentScanDirs:s.componentScanDirs??[],excludeFiles:s.excludeFiles??[],excludeFilePath:t??[],filePath:u}}}else{const e=path.join(d,"caspian.config.json"),s=path.join(d,t),n=path.join(s,"caspian.config.json");fs.existsSync(e)?u=d:fs.existsSync(s)&&fs.existsSync(n)?(u=s,process.chdir(s)):(fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),u=s,process.chdir(s))}else fs.mkdirSync(r.projectName,{recursive:!0}),u=path.join(d,r.projectName),process.chdir(r.projectName);let m=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("http-proxy-middleware"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("tree-sitter"),npmPkg("tree-sitter-python")];if(r.prisma&&m.push(npmPkg("prompts"),npmPkg("@types/prompts")),r.tailwindcss&&m.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano")),r.prisma&&execSync("npm install -g prisma-client-python@latest",{stdio:"inherit"}),r.typescript&&!r.backendOnly&&m.push(npmPkg("vite"),npmPkg("fast-glob")),r.starterKit&&!o&&await setupStarterKit(u,r),await installNpmDependencies(u,m,!0),t||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(u,r),r.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],s=[],t=e=>{try{const s=path.join(u,"package.json");if(fs.existsSync(s)){const t=JSON.parse(fs.readFileSync(s,"utf8"));return!!(t.dependencies&&t.dependencies[e]||t.devDependencies&&t.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const s=path.join(u,"src","app",e);fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const s=path.join(u,"src","app",e);fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const s=path.join(u,e);fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${e} was deleted successfully.`))});["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano"].forEach(s=>{t(s)&&e.push(s)}),s.push("tailwind-merge")}if(r.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(s){console.warn(chalk.yellow(`Failed to delete ${e}: ${s}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const s=path.join(u,"settings",e);fs.existsSync(s)&&(fs.unlinkSync(s),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.")),s.push("mcp")}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(s=>{t(s)&&e.push(s)}),s.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts"].forEach(e=>{const s=path.join(u,e);fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${e} was deleted successfully.`))});const s=path.join(u,"ts");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const n=path.join(u,"settings","vite-plugins");fs.existsSync(n)&&(fs.rmSync(n,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(s=>{t(s)&&e.push(s)})}const n=(e=>Array.from(new Set(e)))(e);n.length>0&&(console.log(`Uninstalling npm packages: ${n.join(", ")}`),await uninstallNpmDependencies(u,n,!0)),s.length>0&&await uninstallPythonDependencies(u,s)}if(!o||!fs.existsSync(path.join(u,"caspian.config.json"))){const e=u.replace(/\\/g,"\\"),s=bsConfigUrls(e),t={projectName:r.projectName,projectRootPath:e,bsTarget:s.bsTarget,bsPathRewrite:s.bsPathRewrite,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,prisma:r.prisma,typescript:r.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,r),setupVsCodeSettings(u),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);let updateAnswer=null;const nonBackendFiles=["favicon.ico","\\src\\app\\index.html","not-found.html","error.html"],STARTER_KITS={basic:{id:"basic",name:"Basic PHP Application",description:"Simple PHP backend with minimal dependencies",features:{backendOnly:!0,tailwindcss:!1,prisma:!1,mcp:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","src/app/layout.html","src/app/index.html"]},fullstack:{id:"fullstack",name:"Full-Stack Application",description:"Complete web application with frontend and backend",features:{backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/app/layout.html","src/app/index.html","public/js/main.js","src/app/globals.css"]},api:{id:"api",name:"REST API",description:"Backend API with database and documentation",features:{backendOnly:!0,tailwindcss:!1,prisma:!0,mcp:!1},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},requiredFiles:["main.py",".prettierrc","pyproject.toml","postcss.config.js","src/lib/mcp"]}};function bsConfigUrls(e){const s=e.indexOf("\\htdocs\\");if(-1===s)return console.error("Invalid PROJECT_ROOT_PATH. The path does not contain \\htdocs\\"),{bsTarget:"",bsPathRewrite:{}};const n=e.substring(0,s+8).replace(/\\/g,"\\\\"),t=e.replace(new RegExp(`^${n}`),"").replace(/\\/g,"/");let i=`http://localhost/${t}`;i=i.endsWith("/")?i.slice(0,-1):i;const c=i.replace(/(?<!:)(\/\/+)/g,"/"),a=t.replace(/\/\/+/g,"/");return{bsTarget:`${c}/`,bsPathRewrite:{"^/":`/${a.startsWith("/")?a.substring(1):a}/`}}}async function updatePackageJson(e,s){const n=path.join(e,"package.json");if(checkExcludeFiles(n))return;const t=JSON.parse(fs.readFileSync(n,"utf8"));t.scripts={...t.scripts,projectName:"tsx settings/project-name.ts"};let i=[];s.tailwindcss&&(t.scripts={...t.scripts,tailwind:"postcss src/app/globals.css -o public/css/styles.css --watch","tailwind:build":"postcss src/app/globals.css -o public/css/styles.css"},i.push("tailwind")),s.typescript&&!s.backendOnly&&(t.scripts={...t.scripts,"ts:watch":"vite build --watch","ts:build":"vite build"},i.push("ts:watch")),s.mcp&&(t.scripts={...t.scripts,mcp:"tsx settings/restart-mcp.ts"},i.push("mcp"));let c={...t.scripts};c.browserSync="tsx settings/bs-config.ts",c["browserSync:build"]="tsx settings/build.ts",c.dev=`npm-run-all projectName -p browserSync ${i.join(" ")}`;let a=["browserSync:build"];s.tailwindcss&&a.unshift("tailwind:build"),s.typescript&&!s.backendOnly&&a.unshift("ts:build"),c.build=`npm-run-all ${a.join(" ")}`,t.scripts=c,t.type="module",fs.writeFileSync(n,JSON.stringify(t,null,2))}function generateAuthSecret(){return randomBytes(33).toString("base64")}function generateHexEncodedKey(e=16){return randomBytes(e).toString("hex")}function copyRecursiveSync(e,s,n){const t=fs.existsSync(e),i=t&&fs.statSync(e);if(t&&i&&i.isDirectory()){const t=s.toLowerCase();if(!n.mcp&&t.includes("src\\lib\\mcp"))return;if((!n.typescript||n.backendOnly)&&(t.endsWith("\\ts")||t.includes("\\ts\\")))return;if((!n.typescript||n.backendOnly)&&(t.endsWith("\\vite-plugins")||t.includes("\\vite-plugins\\")||t.includes("\\vite-plugins")))return;if(n.backendOnly&&t.includes("public\\js")||n.backendOnly&&t.includes("public\\css")||n.backendOnly&&t.includes("public\\assets"))return;const i=s.replace(/\\/g,"/");if(updateAnswer?.excludeFilePath?.includes(i))return;fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),fs.readdirSync(e).forEach(t=>{copyRecursiveSync(path.join(e,t),path.join(s,t),n)})}else{if(checkExcludeFiles(s))return;if(!n.tailwindcss&&(s.includes("globals.css")||s.includes("styles.css")))return;if(!n.mcp&&s.includes("restart-mcp.ts"))return;if(n.backendOnly&&nonBackendFiles.some(e=>s.includes(e)))return;if(n.backendOnly&&s.includes("layout.html"))return;if(n.tailwindcss&&s.includes("index.css"))return;if(!n.prisma&&s.includes("prisma-schema-config.json"))return;fs.copyFileSync(e,s,0)}}async function executeCopy(e,s,n){s.forEach(({src:s,dest:t})=>{copyRecursiveSync(path.join(__dirname,s),path.join(e,t),n)})}function modifyPostcssConfig(e){const s=path.join(e,"postcss.config.js");if(checkExcludeFiles(s))return;fs.writeFileSync(s,'export default {\n plugins: {\n "@tailwindcss/postcss": {},\n cssnano: {},\n },\n};',{flag:"w"})}function modifyLayoutPHP(e,s){const n=path.join(e,"src","app","layout.html");if(!checkExcludeFiles(n))try{let e=fs.readFileSync(n,"utf8"),t="";s.backendOnly||(s.tailwindcss||(t='\n <link href="/css/index.css" rel="stylesheet" />'),t+='\n <script type="module" src="/js/main.js"><\/script>');let i="";s.backendOnly||(i=s.tailwindcss?` <link href="/css/styles.css" rel="stylesheet" />${t}`:t),e=e.replace("</head>",`${i}\n</head>`),fs.writeFileSync(n,e,{flag:"w"})}catch(e){console.error(chalk.red("Error modifying layout.html:"),e)}}async function createOrUpdateEnvFile(e,s){const n=path.join(e,".env");checkExcludeFiles(n)||fs.writeFileSync(n,s,{flag:"w"})}function checkExcludeFiles(e){if(!updateAnswer?.isUpdate)return!1;const s=e.replace(/\\/g,"/");return!!updateAnswer?.excludeFilePath?.includes(s)||!!updateAnswer?.excludeFiles&&updateAnswer.excludeFiles.some(e=>{const n=e.replace(/\\/g,"/");return s.endsWith("/"+n)||s===n})}async function createDirectoryStructure(e,s){const n=[{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"}];s.tailwindcss&&n.push({src:"/postcss.config.js",dest:"/postcss.config.js"}),s.typescript&&!s.backendOnly&&n.push({src:"/vite.config.ts",dest:"/vite.config.ts"});const t=[{src:"/settings",dest:"/settings"},{src:"/src",dest:"/src"},{src:"/public",dest:"/public"}];s.typescript&&!s.backendOnly&&t.push({src:"/ts",dest:"/ts"}),n.forEach(({src:s,dest:n})=>{const t=path.join(__dirname,s),i=path.join(e,n);if(checkExcludeFiles(i))return;const c=fs.readFileSync(t,"utf8");fs.writeFileSync(i,c,{flag:"w"})}),await executeCopy(e,t,s),await updatePackageJson(e,s),s.tailwindcss&&modifyPostcssConfig(e),!s.tailwindcss&&s.backendOnly||modifyLayoutPHP(e,s);const i=`# Authentication secret key for JWT or session encryption.\nAUTH_SECRET="${generateAuthSecret()}"\n# Name of the authentication cookie.\nAUTH_COOKIE_NAME="${generateHexEncodedKey(8)}"\nAUTH_TOKEN_VALIDITY="1h"\nAUTH_TOKEN_AUTO_REFRESH="true"\n\nAUTH_ALL_ROUTES_PRIVATE="true"\nAUTH_PUBLIC_ROUTES="/"\nAUTH_ROUTES="/signin,/signup"\nAUTH_PRIVATE_ROUTES=""\n\nAUTH_ROLE_BASED="false"\nAUTH_ROLE_IDENTIFIER="role"\n# Role-based route allowlist (JSON)\n# Format: {"roleName": ["/route1", "/route2"]}\n# Example:\n# AUTH_ROLE_BASED_ROUTES_JSON="{"admin": ["/admin", "/reports"], "user": ["/dashboard"]}"\nAUTH_ROLE_BASED_ROUTES_JSON="{}"\n\nAUTH_DEFAULT_SIGNIN_REDIRECT="/dashboard"\nAUTH_DEFAULT_SIGNOUT_REDIRECT="/signin"\nAUTH_API_PREFIX="/api/auth"\n\n# Show errors in the browser (development only). Set to false in production.\nSHOW_ERRORS="true"\n\n# Application timezone (default: UTC)\nAPP_TIMEZONE="UTC"\n\n# Application environment (development or production)\nAPP_ENV="development"\n\n# Enable or disable application cache (default: false)\nCACHE_ENABLED="false"\n# Cache time-to-live in seconds (default: 600)\nCACHE_TTL="600"\n\n# Secret key for encrypting function calls.\nFUNCTION_CALL_SECRET="${generateHexEncodedKey(32)}"\n\n# Single or multiple origins allowed for CORS (comma-separated)\nCORS_ALLOWED_ORIGINS=""\n\n# If you need cookies/Authorization across origins, keep this true\nCORS_ALLOW_CREDENTIALS="true"\n\n# Optional tuning\nCORS_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"\nCORS_ALLOWED_HEADERS="Content-Type,Authorization,X-Requested-With"\nCORS_EXPOSE_HEADERS=""\nCORS_MAX_AGE="86400"\n\n# Session & Security\nSESSION_LIFETIME_HOURS="7"\nMAX_CONTENT_LENGTH_MB="16"\n\n# Rate Limiting\nRATE_LIMIT_DEFAULT="200 per minute"\nRATE_LIMIT_RPC="60 per minute"\nRATE_LIMIT_AUTH="60 per minute"`;if(s.prisma){const s=`${'# Environment variables declared in this file are automatically made available to Prisma.\n# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema\n\n# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.\n# See the documentation for all the connection string options: https://pris.ly/d/connection-strings\n\nDATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"'}\n\n${i}`;await createOrUpdateEnvFile(e,s)}else await createOrUpdateEnvFile(e,i)}async function getAnswer(e={},s=!1){if(s)return{projectName:e.projectName??"my-app",backendOnly:e.backendOnly??!1,tailwindcss:e.tailwindcss??!1,typescript:e.typescript??!1,mcp:e.mcp??!1,prisma:e.prisma??!1};if(e.starterKit){const s=e.starterKit;let n=null;if(STARTER_KITS[s]&&(n=STARTER_KITS[s]),n){const t={projectName:e.projectName??"my-app",starterKit:s,starterKitSource:e.starterKitSource,backendOnly:n.features.backendOnly??!1,tailwindcss:n.features.tailwindcss??!1,prisma:n.features.prisma??!1,mcp:n.features.mcp??!1,typescript:n.features.typescript??!1},i=process.argv.slice(2);return i.includes("--backend-only")&&(t.backendOnly=!0),i.includes("--tailwindcss")&&(t.tailwindcss=!0),i.includes("--mcp")&&(t.mcp=!0),i.includes("--prisma")&&(t.prisma=!0),i.includes("--typescript")&&(t.typescript=!0),t}if(e.starterKitSource){const n={projectName:e.projectName??"my-app",starterKit:s,starterKitSource:e.starterKitSource,backendOnly:!1,tailwindcss:!0,prisma:!0,mcp:!1,typescript:!1},t=process.argv.slice(2);return t.includes("--backend-only")&&(n.backendOnly=!0),t.includes("--tailwindcss")&&(n.tailwindcss=!0),t.includes("--mcp")&&(n.mcp=!0),t.includes("--prisma")&&(n.prisma=!0),t.includes("--typescript")&&(n.typescript=!0),n}}const n=[];e.projectName||n.push({type:"text",name:"projectName",message:"What is your project named?",initial:"my-app"}),e.backendOnly||updateAnswer?.isUpdate||n.push({type:"toggle",name:"backendOnly",message:`Would you like to create a ${chalk.blue("backend-only project")}?`,initial:!1,active:"Yes",inactive:"No"});const t=()=>{console.warn(chalk.red("Operation cancelled by the user.")),process.exit(0)},i=await prompts(n,{onCancel:t}),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.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"}));const a=await prompts(c,{onCancel:t});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,prisma:a.prisma??e.prisma??!1}}async function uninstallNpmDependencies(e,s,n=!1){console.log("Uninstalling Node dependencies:"),s.forEach(e=>console.log(`- ${chalk.blue(e)}`));const t=`npm uninstall ${n?"--save-dev":"--save"} ${s.join(" ")}`;execSync(t,{stdio:"inherit",cwd:e})}function fetchPackageVersion(e){return new Promise((s,n)=>{https.get(`https://registry.npmjs.org/${e}`,e=>{let t="";e.on("data",e=>t+=e),e.on("end",()=>{try{const e=JSON.parse(t);s(e["dist-tags"].latest)}catch(e){n(new Error("Failed to parse JSON response"))}})}).on("error",e=>n(e))})}const readJsonFile=e=>{const s=fs.readFileSync(e,"utf8");return JSON.parse(s)};function compareVersions(e,s){const n=e.split(".").map(Number),t=s.split(".").map(Number);for(let e=0;e<n.length;e++){if(n[e]>t[e])return 1;if(n[e]<t[e])return-1}return 0}function getInstalledPackageVersion(e){try{const s=execSync(`npm list -g ${e} --depth=0`).toString().match(new RegExp(`${e}@(\\d+\\.\\d+\\.\\d+)`));return s?s[1]:(console.error(`Package ${e} is not installed`),null)}catch(e){return console.error(e instanceof Error?e.message:String(e)),null}}async function installNpmDependencies(e,s,n=!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("npm init -y",{stdio:"inherit",cwd:e}),console.log((n?"Installing development dependencies":"Installing dependencies")+":"),s.forEach(e=>console.log(`- ${chalk.blue(e)}`));const t=`npm install ${n?"--save-dev":""} ${s.join(" ")}`;execSync(t,{stdio:"inherit",cwd:e})}const npmPinnedVersions={"@tailwindcss/postcss":"4.1.18","@types/browser-sync":"2.29.1","@types/node":"25.0.3","@types/prompts":"2.4.9","browser-sync":"3.0.4",chalk:"5.6.2","chokidar-cli":"3.0.0",cssnano:"7.1.2","http-proxy-middleware":"3.0.5","npm-run-all":"4.1.5",postcss:"8.5.6","postcss-cli":"11.0.1",prompts:"2.4.2",tailwindcss:"4.1.18",tsx:"4.21.0",typescript:"5.9.3",vite:"7.3.0","fast-glob":"3.3.3","@lezer/common":"^1.5.1","@lezer/python":"^1.1.18"};function npmPkg(e){return npmPinnedVersions[e]?`${e}@${npmPinnedVersions[e]}`:e}async function setupStarterKit(e,s){if(!s.starterKit)return;let n=null;if(STARTER_KITS[s.starterKit]?n=STARTER_KITS[s.starterKit]:s.starterKitSource&&(n={id:s.starterKit,name:`Custom Starter Kit (${s.starterKit})`,description:"Custom starter kit from external source",features:{},requiredFiles:[],source:{type:"git",url:s.starterKitSource}}),n){if(console.log(chalk.green(`Setting up ${n.name}...`)),n.source)try{const t=n.source.branch?`git clone -b ${n.source.branch} --depth 1 ${n.source.url} ${e}`:`git clone --depth 1 ${n.source.url} ${e}`;execSync(t,{stdio:"inherit"});const i=path.join(e,".git");fs.existsSync(i)&&fs.rmSync(i,{recursive:!0,force:!0}),console.log(chalk.blue("Starter kit cloned successfully!"));const c=path.join(e,"caspian.config.json");if(fs.existsSync(c))try{const n=JSON.parse(fs.readFileSync(c,"utf8")),t=e.replace(/\\/g,"\\"),i=bsConfigUrls(t);n.projectName=s.projectName,n.projectRootPath=t,n.bsTarget=i.bsTarget,n.bsPathRewrite=i.bsPathRewrite;const a=await fetchPackageVersion("create-caspian-app");n.version=n.version||a,fs.writeFileSync(c,JSON.stringify(n,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}n.customSetup&&await n.customSetup(e,s),console.log(chalk.green(`āœ“ ${n.name} setup complete!`))}else console.warn(chalk.yellow(`Starter kit '${s.starterKit}' not found. Skipping...`))}function showStarterKits(){console.log(chalk.blue("\nšŸš€ Available Starter Kits:\n")),Object.values(STARTER_KITS).forEach(e=>{const s=e.source?" (Custom)":" (Built-in)";console.log(chalk.green(` ${e.id}${chalk.gray(s)}`)),console.log(` ${e.name}`),console.log(chalk.gray(` ${e.description}`)),e.source&&console.log(chalk.cyan(` Source: ${e.source.url}`));const n=Object.entries(e.features).filter(([,e])=>!0===e).map(([e])=>e).join(", ");n&&console.log(chalk.magenta(` Features: ${n}`)),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,s,n){const t=spawnSync(e,s,{cwd:n,stdio:"inherit",shell:!1,encoding:"utf8"});if(t.error)throw t.error;if(0!==t.status)throw new Error(`Command failed (${e} ${s.join(" ")}), exit=${t.status}`)}function tryRunCmd(e,s,n){const t=spawnSync(e,s,{cwd:n,stdio:"ignore",shell:!1,encoding:"utf8"});return!t.error&&0===t.status}function getVenvPythonPath(e){const s=path.join(e,".venv","Scripts","python.exe");if(fs.existsSync(s))return s;return path.join(e,".venv","bin","python")}async function uninstallPythonDependencies(e,s){const n=getVenvPythonPath(e);fs.existsSync(n)?(console.log("Uninstalling Python dependencies:"),s.forEach(e=>console.log(`- ${chalk.blue(e)}`)),runCmd(n,["-m","pip","uninstall","-y",...s],e)):console.warn(chalk.yellow("Virtual environment not found. Skipping Python dependency uninstallation."))}function fetchPyPiPackageVersion(e){return new Promise((s,n)=>{https.get(`https://pypi.org/pypi/${e}/json`,e=>{let t="";e.on("data",e=>t+=e),e.on("end",()=>{if(200===e.statusCode)try{const e=JSON.parse(t);s(e.info.version)}catch(e){n(new Error("Failed to parse PyPI JSON response"))}else n(new Error(`Request failed with status code ${e.statusCode}`))})}).on("error",e=>n(e))})}async function ensurePythonVenvAndDeps(e,s){console.log(chalk.green("\n=========================")),console.log(chalk.green("Python setup: creating venv + installing dependencies")),console.log(chalk.green("=========================\n"));const n=path.join(e,".venv");if(fs.existsSync(n)&&(fs.existsSync(path.join(e,".venv","Scripts","python.exe"))||fs.existsSync(path.join(e,".venv","bin","python"))))console.log(chalk.gray("Venv already exists: .venv"));else if(console.log(chalk.blue("Creating virtual environment: .venv")),tryRunCmd("py",["-m","venv",".venv"],e));else if(tryRunCmd("python",["-m","venv",".venv"],e));else if(!tryRunCmd("python3",["-m","venv",".venv"],e))throw new Error("Could not create venv. Install Python and ensure `py` or `python` is in PATH.");const t=getVenvPythonPath(e);if(!fs.existsSync(t))throw new Error(`Venv python not found at: ${t}`);console.log(chalk.blue("Upgrading pip...")),runCmd(t,["-m","pip","install","--upgrade","pip"],e);let i="caspian-utils";try{const e=await fetchPyPiPackageVersion("caspian-utils");i=`caspian-utils==${e}`,console.log(chalk.blue(`Pinned caspian-utils to version ${e}`))}catch(e){console.warn(chalk.yellow("Could not fetch latest caspian-utils version. Using unpinned dependency."))}const c=["fastapi>=0.110,<0.128","uvicorn>=0.27,<0.40","python-dotenv>=1.0,<2.0","jinja2>=3.1,<4.0","beautifulsoup4>=4.12,<5.0","slowapi>=0.1,<0.2","python-multipart>=0.0.9,<0.1","starsessions>=1.3,<2.2","httpx>=0.27,<0.29","werkzeug>=3.0,<4.0","cuid2>=2.0,<3.0","nanoid>=2.0,<3.0","python-ulid>=2.7,<3.1","cuid>=0.4.0,<0.5.0",i];s.tailwindcss&&c.push("tailwind-merge>=0.3.3,<0.4.0"),s.mcp&&c.push("fastmcp<3"),s.prisma&&(c.push("psycopg2-binary>=2.9,<3.0"),c.push("asyncpg>=0.31.0,<1.0"),c.push("aiosqlite>=0.22.1,<1.0"),c.push("aiomysql>=0.3.2,<1.0")),console.log(chalk.blue("Generating requirements.txt..."));const a=path.join(e,"requirements.txt"),r=c.join("\n")+"\n";fs.writeFileSync(a,r,"utf8"),console.log(chalk.gray(`Created requirements.txt with ${c.length} packages.`)),console.log(chalk.blue("Installing dependencies from requirements.txt...")),c.forEach(e=>console.log(`- ${chalk.gray(e)}`));runCmd(t,["-m","pip","install",...updateAnswer?.isUpdate?["--upgrade","--upgrade-strategy","only-if-needed"]:[],...updateAnswer?.isUpdate?["--no-cache-dir"]:[],"-r","requirements.txt"],e),console.log(chalk.green("\nāœ“ Python venv ready and FastAPI dependencies installed.\n"))}function setupVsCodeSettings(e){const s=path.join(e,".vscode");fs.existsSync(s)||fs.mkdirSync(s);const n={"python.defaultInterpreterPath":"win32"===process.platform?"${workspaceFolder}/.venv/Scripts/python.exe":"${workspaceFolder}/.venv/bin/python","python.terminal.activateEnvironment":!0,"python.analysis.typeCheckingMode":"basic","python.analysis.autoImportCompletions":!0};fs.writeFileSync(path.join(s,"settings.json"),JSON.stringify(n,null,2)),console.log(chalk.gray("Created .vscode/settings.json for auto-activation."))}async function main(){try{const e=process.argv.slice(2),s=e.includes("-y");let n=e[0];const t=e.find(e=>e.startsWith("--starter-kit=")),i=t?.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 r=null,o=!1;if(n){const t=process.cwd(),c=path.join(t,"caspian.config.json");if(i&&a){o=!0;const t={projectName:n,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),prisma:e.includes("--prisma")};r=await getAnswer(t,s)}else if(fs.existsSync(c)){const i=readJsonFile(c);let a=[];i.excludeFiles?.map(e=>{const s=path.join(t,e);fs.existsSync(s)&&a.push(s.replace(/\\/g,"/"))}),updateAnswer={projectName:n,backendOnly:i.backendOnly,tailwindcss:i.tailwindcss,mcp:i.mcp,prisma:i.prisma,typescript:i.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:t};const o={projectName:n,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};r=await getAnswer(o,s),null!==r&&(updateAnswer={projectName:n,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,prisma:r.prisma,typescript:r.typescript,isUpdate:!0,componentScanDirs:i.componentScanDirs??[],excludeFiles:i.excludeFiles??[],excludeFilePath:a??[],filePath:t})}else{const t={projectName:n,starterKit:i,starterKitSource:a,backendOnly:e.includes("--backend-only"),tailwindcss:e.includes("--tailwindcss"),typescript:e.includes("--typescript"),mcp:e.includes("--mcp"),prisma:e.includes("--prisma")};r=await getAnswer(t,s)}if(null===r)return void console.log(chalk.red("Installation cancelled."))}else r=await getAnswer({},s);if(null===r)return void console.warn(chalk.red("Installation cancelled."));const l=await fetchPackageVersion("create-caspian-app"),p=getInstalledPackageVersion("create-caspian-app");p?-1===compareVersions(p,l)&&(execSync("npm uninstall -g create-caspian-app",{stdio:"inherit"}),execSync("npm install -g create-caspian-app",{stdio:"inherit"})):execSync("npm install -g create-caspian-app",{stdio:"inherit"});const d=process.cwd();let u;if(n)if(o){const s=path.join(d,n);fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),u=s,await setupStarterKit(u,r),process.chdir(u);const t=path.join(u,"caspian.config.json");if(fs.existsSync(t)){const s=JSON.parse(fs.readFileSync(t,"utf8"));e.includes("--backend-only")&&(s.backendOnly=!0),e.includes("--tailwindcss")&&(s.tailwindcss=!0),e.includes("--typescript")&&(s.typescript=!0),e.includes("--mcp")&&(s.mcp=!0),e.includes("--prisma")&&(s.prisma=!0),r={...r,backendOnly:s.backendOnly,tailwindcss:s.tailwindcss,typescript:s.typescript,mcp:s.mcp,prisma:s.prisma};let n=[];s.excludeFiles?.map(e=>{const s=path.join(u,e);fs.existsSync(s)&&n.push(s.replace(/\\/g,"/"))}),updateAnswer={...r,isUpdate:!0,componentScanDirs:s.componentScanDirs??[],excludeFiles:s.excludeFiles??[],excludeFilePath:n??[],filePath:u}}}else{const e=path.join(d,"caspian.config.json"),s=path.join(d,n),t=path.join(s,"caspian.config.json");fs.existsSync(e)?u=d:fs.existsSync(s)&&fs.existsSync(t)?(u=s,process.chdir(s)):(fs.existsSync(s)||fs.mkdirSync(s,{recursive:!0}),u=s,process.chdir(s))}else fs.mkdirSync(r.projectName,{recursive:!0}),u=path.join(d,r.projectName),process.chdir(r.projectName);let m=[npmPkg("typescript"),npmPkg("@types/node"),npmPkg("tsx"),npmPkg("http-proxy-middleware"),npmPkg("chalk"),npmPkg("npm-run-all"),npmPkg("browser-sync"),npmPkg("@types/browser-sync"),npmPkg("@lezer/common"),npmPkg("@lezer/python")];if(r.prisma&&m.push(npmPkg("prompts"),npmPkg("@types/prompts")),r.tailwindcss&&m.push(npmPkg("tailwindcss"),npmPkg("postcss"),npmPkg("postcss-cli"),npmPkg("@tailwindcss/postcss"),npmPkg("cssnano")),r.prisma&&execSync("npm install -g prisma-client-python@latest",{stdio:"inherit"}),r.typescript&&!r.backendOnly&&m.push(npmPkg("vite"),npmPkg("fast-glob")),r.starterKit&&!o&&await setupStarterKit(u,r),await installNpmDependencies(u,m,!0),n||execSync("npx tsc --init",{stdio:"inherit"}),await createDirectoryStructure(u,r),r.prisma&&execSync("npx ppy init --caspian",{stdio:"inherit"}),updateAnswer?.isUpdate){const e=[],s=[],n=e=>{try{const s=path.join(u,"package.json");if(fs.existsSync(s)){const n=JSON.parse(fs.readFileSync(s,"utf8"));return!!(n.dependencies&&n.dependencies[e]||n.devDependencies&&n.devDependencies[e])}return!1}catch{return!1}};if(updateAnswer.backendOnly){nonBackendFiles.forEach(e=>{const s=path.join(u,"src","app",e);fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${e} was deleted successfully.`))});["js","css"].forEach(e=>{const s=path.join(u,"src","app",e);fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log(`${e} was deleted successfully.`))})}if(!updateAnswer.tailwindcss){["postcss.config.js"].forEach(e=>{const s=path.join(u,e);fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${e} was deleted successfully.`))});["tailwindcss","postcss","postcss-cli","@tailwindcss/postcss","cssnano"].forEach(s=>{n(s)&&e.push(s)}),s.push("tailwind-merge")}if(r.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(s){console.warn(chalk.yellow(`Failed to delete ${e}: ${s}`))}}if(!updateAnswer.mcp){["restart-mcp.ts"].forEach(e=>{const s=path.join(u,"settings",e);fs.existsSync(s)&&(fs.unlinkSync(s),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.")),s.push("mcp")}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(s=>{n(s)&&e.push(s)}),s.push("psycopg2-binary","asyncpg","aiosqlite","aiomysql")}if(!updateAnswer.typescript||updateAnswer.backendOnly){["vite.config.ts"].forEach(e=>{const s=path.join(u,e);fs.existsSync(s)&&(fs.unlinkSync(s),console.log(`${e} was deleted successfully.`))});const s=path.join(u,"ts");fs.existsSync(s)&&(fs.rmSync(s,{recursive:!0,force:!0}),console.log("ts folder was deleted successfully."));const t=path.join(u,"settings","vite-plugins");fs.existsSync(t)&&(fs.rmSync(t,{recursive:!0,force:!0}),console.log("settings/vite-plugins folder was deleted successfully."));["vite","fast-glob"].forEach(s=>{n(s)&&e.push(s)})}const t=(e=>Array.from(new Set(e)))(e);t.length>0&&(console.log(`Uninstalling npm packages: ${t.join(", ")}`),await uninstallNpmDependencies(u,t,!0)),s.length>0&&await uninstallPythonDependencies(u,s)}if(!o||!fs.existsSync(path.join(u,"caspian.config.json"))){const e=u.replace(/\\/g,"\\"),s=bsConfigUrls(e),n={projectName:r.projectName,projectRootPath:e,bsTarget:s.bsTarget,bsPathRewrite:s.bsPathRewrite,backendOnly:r.backendOnly,tailwindcss:r.tailwindcss,mcp:r.mcp,prisma:r.prisma,typescript:r.typescript,version:l,componentScanDirs:updateAnswer?.componentScanDirs??["src"],excludeFiles:updateAnswer?.excludeFiles??[]};fs.writeFileSync(path.join(u,"caspian.config.json"),JSON.stringify(n,null,2),{flag:"w"})}await ensurePythonVenvAndDeps(u,r),setupVsCodeSettings(u),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();
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import Parser from "tree-sitter";
4
- import Python from "tree-sitter-python";
3
+ import { parser as pythonParser } from "@lezer/python";
4
+ import type { Tree, TreeCursor } from "@lezer/common";
5
5
  import { getFileMeta } from "./utils";
6
6
 
7
7
  const { __dirname } = getFileMeta();
@@ -42,291 +42,920 @@ const CONFIG_PATH = path.join(PROJECT_ROOT, CONFIG_FILENAME);
42
42
 
43
43
  /**
44
44
  * ---------------------------------------------------------
45
- * AST Helpers
45
+ * AST Helpers (Lezer)
46
46
  * ---------------------------------------------------------
47
47
  */
48
48
 
49
- const parser = new Parser();
50
- parser.setLanguage(Python as any);
49
+ type NodeSpan = {
50
+ name: string;
51
+ from: number;
52
+ to: number;
53
+ children: NodeSpan[];
54
+ };
55
+
56
+ const PY_KEYWORDS = new Set([
57
+ "def",
58
+ "async",
59
+ "class",
60
+ "return",
61
+ "pass",
62
+ "if",
63
+ "elif",
64
+ "else",
65
+ "for",
66
+ "while",
67
+ "try",
68
+ "except",
69
+ "finally",
70
+ "with",
71
+ "lambda",
72
+ "yield",
73
+ "from",
74
+ "import",
75
+ "as",
76
+ "global",
77
+ "nonlocal",
78
+ "assert",
79
+ "raise",
80
+ "del",
81
+ "and",
82
+ "or",
83
+ "not",
84
+ "in",
85
+ "is",
86
+ "True",
87
+ "False",
88
+ "None",
89
+ ]);
90
+
91
+ function slice(source: string, node: NodeSpan): string {
92
+ return source.slice(node.from, node.to);
93
+ }
94
+
95
+ function stripQuotes(value: string): string {
96
+ return value.replace(/^['"]|['"]$/g, "");
97
+ }
98
+
99
+ function lower(s: string): string {
100
+ return s.toLowerCase();
101
+ }
102
+
103
+ function isProbablyIdentifierText(text: string): boolean {
104
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(text) && !PY_KEYWORDS.has(text);
105
+ }
51
106
 
52
107
  /**
53
- * Extracts string values from a Dictionary node
108
+ * Convert Lezer cursor subtree into a plain recursive span tree.
109
+ * This makes traversals simpler and stable for custom heuristics.
54
110
  */
55
- function extractDictKeysFromNode(node: Parser.SyntaxNode): string[] {
56
- const keys: string[] = [];
111
+ function cursorToSpanTree(cursor: TreeCursor): NodeSpan {
112
+ const node: NodeSpan = {
113
+ name: cursor.name,
114
+ from: cursor.from,
115
+ to: cursor.to,
116
+ children: [],
117
+ };
118
+
119
+ if (cursor.firstChild()) {
120
+ do {
121
+ node.children.push(cursorToSpanTree(cursor));
122
+ } while (cursor.nextSibling());
123
+ cursor.parent();
124
+ }
125
+
126
+ return node;
127
+ }
128
+
129
+ function parsePythonToSpanTree(source: string): { tree: Tree; root: NodeSpan } {
130
+ const tree = pythonParser.parse(source);
131
+ const cursor = tree.cursor();
132
+ const root = cursorToSpanTree(cursor);
133
+ return { tree, root };
134
+ }
135
+
136
+ function walk(
137
+ node: NodeSpan,
138
+ cb: (node: NodeSpan, parent: NodeSpan | null) => void,
139
+ parent: NodeSpan | null = null,
140
+ ) {
141
+ cb(node, parent);
142
+ for (const child of node.children) walk(child, cb, node);
143
+ }
144
+
145
+ function findFirstDesc(
146
+ node: NodeSpan,
147
+ predicate: (n: NodeSpan) => boolean,
148
+ ): NodeSpan | null {
57
149
  for (const child of node.children) {
58
- if (child.type === "dictionary_splat") continue;
150
+ if (predicate(child)) return child;
151
+ const deep = findFirstDesc(child, predicate);
152
+ if (deep) return deep;
153
+ }
154
+ return null;
155
+ }
59
156
 
60
- if (child.type === "pair") {
61
- const keyNode = child.childForFieldName("key");
62
- if (
63
- keyNode &&
64
- (keyNode.type === "string" || keyNode.type === "identifier")
65
- ) {
66
- keys.push(keyNode.text.replace(/^['"]|['"]$/g, ""));
67
- }
157
+ function isFunctionLikeNode(n: NodeSpan, source: string): boolean {
158
+ const name = lower(n.name);
159
+ if (
160
+ !(
161
+ name.includes("function") ||
162
+ name.includes("definition") ||
163
+ name.includes("def")
164
+ )
165
+ ) {
166
+ return false;
167
+ }
168
+ const text = slice(source, n).trimStart();
169
+ return text.startsWith("def ") || text.startsWith("async def ");
170
+ }
171
+
172
+ function isDecoratedLikeNode(n: NodeSpan, source: string): boolean {
173
+ const name = lower(n.name);
174
+ if (name.includes("decorated")) return true;
175
+
176
+ // fallback heuristic: node text starts with @ and contains a function definition
177
+ const text = slice(source, n).trimStart();
178
+ return (
179
+ text.startsWith("@") &&
180
+ (text.includes("\ndef ") ||
181
+ text.includes("\nasync def ") ||
182
+ text.includes("def "))
183
+ );
184
+ }
185
+
186
+ function splitTopLevelComma(input: string): string[] {
187
+ const parts: string[] = [];
188
+ let current = "";
189
+ let depthParen = 0;
190
+ let depthBracket = 0;
191
+ let depthBrace = 0;
192
+ let inSingle = false;
193
+ let inDouble = false;
194
+ let escape = false;
195
+
196
+ for (let i = 0; i < input.length; i++) {
197
+ const ch = input[i];
198
+
199
+ if (escape) {
200
+ current += ch;
201
+ escape = false;
202
+ continue;
68
203
  }
204
+ if (ch === "\\") {
205
+ current += ch;
206
+ escape = true;
207
+ continue;
208
+ }
209
+
210
+ if (!inDouble && ch === "'" && !inSingle) {
211
+ inSingle = true;
212
+ current += ch;
213
+ continue;
214
+ } else if (inSingle && ch === "'") {
215
+ inSingle = false;
216
+ current += ch;
217
+ continue;
218
+ }
219
+
220
+ if (!inSingle && ch === '"' && !inDouble) {
221
+ inDouble = true;
222
+ current += ch;
223
+ continue;
224
+ } else if (inDouble && ch === '"') {
225
+ inDouble = false;
226
+ current += ch;
227
+ continue;
228
+ }
229
+
230
+ if (inSingle || inDouble) {
231
+ current += ch;
232
+ continue;
233
+ }
234
+
235
+ if (ch === "(") depthParen++;
236
+ else if (ch === ")") depthParen--;
237
+ else if (ch === "[") depthBracket++;
238
+ else if (ch === "]") depthBracket--;
239
+ else if (ch === "{") depthBrace++;
240
+ else if (ch === "}") depthBrace--;
241
+
242
+ if (
243
+ ch === "," &&
244
+ depthParen === 0 &&
245
+ depthBracket === 0 &&
246
+ depthBrace === 0
247
+ ) {
248
+ if (current.trim()) parts.push(current.trim());
249
+ current = "";
250
+ continue;
251
+ }
252
+
253
+ current += ch;
69
254
  }
70
- return keys;
255
+
256
+ if (current.trim()) parts.push(current.trim());
257
+ return parts;
71
258
  }
72
259
 
73
260
  /**
74
- * Extracts values from a Literal[...] or types from Union[...] node
261
+ * Extract values from Literal[...] / Union[...] strings.
262
+ * (Leaf text parsing is okay here)
75
263
  */
76
- function extractLiteralValues(node: Parser.SyntaxNode): string[] {
264
+ function extractLiteralValuesFromTypeString(typeExpr: string): string[] {
265
+ const expr = typeExpr.trim();
77
266
  const values: string[] = [];
78
267
 
79
- if (node.type !== "subscript" && node.type !== "generic_type") return values;
80
-
81
- let typeName: string | undefined;
82
- if (node.type === "subscript") {
83
- typeName = node.childForFieldName("value")?.text;
84
- } else if (node.type === "generic_type") {
85
- typeName = node.children.find((c) => c.type === "identifier")?.text;
268
+ // Literal[...]
269
+ if (expr.startsWith("Literal[") && expr.endsWith("]")) {
270
+ const inner = expr.slice(expr.indexOf("[") + 1, -1);
271
+ for (const part of splitTopLevelComma(inner)) {
272
+ const p = part.trim();
273
+ if (!p) continue;
274
+ if (/^['"].*['"]$/s.test(p)) values.push(stripQuotes(p));
275
+ else values.push(p);
276
+ }
277
+ return values;
86
278
  }
87
279
 
88
- // --- Handle Literal[...] ---
89
- if (typeName === "Literal") {
90
- const findValues = (n: Parser.SyntaxNode) => {
91
- if (n.type === "string") {
92
- // Strip quotes from strings
93
- values.push(n.text.replace(/^['"]|['"]$/g, ""));
94
- } else if (
95
- ["integer", "float", "true", "false", "none"].includes(n.type)
96
- ) {
97
- // Capture raw values for primitives (e.g. Literal[1, True, None])
98
- values.push(n.text);
280
+ // Union[...]
281
+ if (expr.startsWith("Union[") && expr.endsWith("]")) {
282
+ const inner = expr.slice(expr.indexOf("[") + 1, -1);
283
+ for (const part of splitTopLevelComma(inner)) {
284
+ const p = part.trim();
285
+ if (!p) continue;
286
+ if (p.startsWith("Literal[") && p.endsWith("]")) {
287
+ values.push(...extractLiteralValuesFromTypeString(p));
288
+ } else {
289
+ values.push(p);
99
290
  }
100
- for (const child of n.children) {
101
- findValues(child);
291
+ }
292
+ return values;
293
+ }
294
+
295
+ return values;
296
+ }
297
+
298
+ /**
299
+ * ---------------------------------------------------------
300
+ * AST-driven Python structure extraction
301
+ * ---------------------------------------------------------
302
+ */
303
+
304
+ type DecoratedFunctionAst = {
305
+ decoratedNode: NodeSpan;
306
+ functionNode: NodeSpan;
307
+ decoratorNodes: NodeSpan[];
308
+ };
309
+
310
+ function getTopLevelStatements(root: NodeSpan): NodeSpan[] {
311
+ // Lezer root usually has direct statement nodes; keep generic
312
+ return root.children;
313
+ }
314
+
315
+ /**
316
+ * Find decorated functions by AST traversal (no regex structure scan)
317
+ */
318
+ function findDecoratedFunctionsAst(
319
+ root: NodeSpan,
320
+ source: string,
321
+ ): DecoratedFunctionAst[] {
322
+ const results: DecoratedFunctionAst[] = [];
323
+
324
+ walk(root, (node) => {
325
+ if (!isDecoratedLikeNode(node, source)) return;
326
+
327
+ const functionNode =
328
+ node.children.find((c) => isFunctionLikeNode(c, source)) ||
329
+ findFirstDesc(node, (c) => isFunctionLikeNode(c, source));
330
+
331
+ if (!functionNode) return;
332
+
333
+ // Decorators are usually sibling children before function node
334
+ const decoratorNodes = node.children.filter((c) => {
335
+ if (c === functionNode) return false;
336
+ const txt = slice(source, c).trimStart();
337
+ return txt.startsWith("@");
338
+ });
339
+
340
+ results.push({
341
+ decoratedNode: node,
342
+ functionNode,
343
+ decoratorNodes,
344
+ });
345
+ });
346
+
347
+ return results;
348
+ }
349
+
350
+ function decoratorMatchesComponent(node: NodeSpan, source: string): boolean {
351
+ const txt = slice(source, node).trim();
352
+ // Supports @component and @component(...)
353
+ return txt === "@component" || txt.startsWith("@component(");
354
+ }
355
+
356
+ function extractFunctionNameAst(
357
+ functionNode: NodeSpan,
358
+ source: string,
359
+ ): string {
360
+ const txt = slice(source, functionNode);
361
+
362
+ // Header-based extraction (robust fix for "def" bug)
363
+ // Supports:
364
+ // def Profile(...)
365
+ // async def Profile(...)
366
+ const parenIndex = txt.indexOf("(");
367
+ if (parenIndex > 0) {
368
+ const header = txt.slice(0, parenIndex);
369
+ const defIndex = header.indexOf("def");
370
+
371
+ if (defIndex >= 0) {
372
+ const afterDef = header.slice(defIndex + 3).trim();
373
+
374
+ let i = 0;
375
+ while (i < afterDef.length && /\s/.test(afterDef[i])) i++;
376
+
377
+ let j = i;
378
+ while (j < afterDef.length && /[A-Za-z0-9_]/.test(afterDef[j])) j++;
379
+
380
+ const candidate = afterDef.slice(i, j).trim();
381
+ if (candidate && isProbablyIdentifierText(candidate)) {
382
+ return candidate;
102
383
  }
103
- };
104
- findValues(node);
384
+ }
385
+ }
386
+
387
+ // AST fallback: direct children identifiers excluding keywords
388
+ for (const child of functionNode.children) {
389
+ const t = slice(source, child).trim();
390
+ if (isProbablyIdentifierText(t)) return t;
391
+ }
392
+
393
+ // Deep fallback
394
+ const nameNode = findFirstDesc(functionNode, (n) => {
395
+ const t = slice(source, n).trim();
396
+ return isProbablyIdentifierText(t);
397
+ });
398
+ if (nameNode) return slice(source, nameNode).trim();
399
+
400
+ return "Unknown";
401
+ }
402
+
403
+ function findParamListNode(
404
+ functionNode: NodeSpan,
405
+ source: string,
406
+ ): NodeSpan | null {
407
+ // Prefer child whose text starts with "(" and ends with ")"
408
+ for (const child of functionNode.children) {
409
+ const txt = slice(source, child).trim();
410
+ if (txt.startsWith("(") && txt.endsWith(")")) return child;
105
411
  }
106
412
 
107
- // --- Handle Union[...] ---
108
- else if (typeName === "Union") {
109
- for (const child of node.children) {
110
- // Skip the "Union" keyword itself and punctuation
413
+ // fallback deep search
414
+ return findFirstDesc(functionNode, (n) => {
415
+ const txt = slice(source, n).trim();
416
+ return txt.startsWith("(") && txt.endsWith(")");
417
+ });
418
+ }
419
+
420
+ function extractParamListTextAst(
421
+ functionNode: NodeSpan,
422
+ source: string,
423
+ ): string {
424
+ const paramsNode = findParamListNode(functionNode, source);
425
+ if (!paramsNode) return "";
426
+
427
+ const txt = slice(source, paramsNode).trim();
428
+ if (txt.startsWith("(") && txt.endsWith(")")) {
429
+ return txt.slice(1, -1);
430
+ }
431
+ return txt;
432
+ }
433
+
434
+ function findFunctionBodyNode(
435
+ functionNode: NodeSpan,
436
+ source: string,
437
+ ): NodeSpan | null {
438
+ // Heuristic: child block after ":" with multiline content often is the body
439
+ const candidates = functionNode.children.filter((c) => {
440
+ const txt = slice(source, c);
441
+ return (
442
+ txt.includes("\n") ||
443
+ txt.trimStart().startsWith("return") ||
444
+ txt.trimStart().startsWith("pass")
445
+ );
446
+ });
447
+
448
+ if (candidates.length > 0) {
449
+ // last candidate tends to be body
450
+ return candidates[candidates.length - 1];
451
+ }
452
+
453
+ // fallback deep search for a block/suite-like name
454
+ return findFirstDesc(functionNode, (n) => {
455
+ const name = lower(n.name);
456
+ return (
457
+ name.includes("body") || name.includes("suite") || name.includes("block")
458
+ );
459
+ });
460
+ }
461
+
462
+ function extractFunctionBodyTextAst(
463
+ functionNode: NodeSpan,
464
+ source: string,
465
+ ): string {
466
+ const bodyNode = findFunctionBodyNode(functionNode, source);
467
+ if (!bodyNode) {
468
+ // fallback: slice after first colon in function text
469
+ const txt = slice(source, functionNode);
470
+ const idx = txt.indexOf(":");
471
+ return idx >= 0 ? txt.slice(idx + 1) : "";
472
+ }
473
+ return slice(source, bodyNode);
474
+ }
475
+
476
+ /**
477
+ * Parse a single parameter chunk (still text-level, but only for leaf content)
478
+ */
479
+ function parseParameterChunk(raw: string): {
480
+ name: string;
481
+ type?: string;
482
+ defaultValue?: string;
483
+ arbitraryDict?: boolean;
484
+ listSplat?: boolean;
485
+ } | null {
486
+ let s = raw.trim();
487
+ if (!s) return null;
488
+
489
+ // Positional-only / keyword-only separators
490
+ if (s === "/" || s === "*") return null;
491
+
492
+ // **kwargs
493
+ if (s.startsWith("**")) {
494
+ return { name: s.slice(2).trim(), arbitraryDict: true };
495
+ }
496
+
497
+ // *args
498
+ if (s.startsWith("*")) {
499
+ return { name: s.slice(1).trim(), listSplat: true };
500
+ }
501
+
502
+ // Split default at top-level "="
503
+ let left = s;
504
+ let defaultValue: string | undefined;
505
+
506
+ {
507
+ let depthParen = 0,
508
+ depthBracket = 0,
509
+ depthBrace = 0;
510
+ let inSingle = false,
511
+ inDouble = false,
512
+ escape = false;
513
+ let eqIndex = -1;
514
+
515
+ for (let i = 0; i < s.length; i++) {
516
+ const ch = s[i];
517
+
518
+ if (escape) {
519
+ escape = false;
520
+ continue;
521
+ }
522
+ if (ch === "\\") {
523
+ escape = true;
524
+ continue;
525
+ }
526
+
527
+ if (!inDouble && ch === "'" && !inSingle) {
528
+ inSingle = true;
529
+ continue;
530
+ } else if (inSingle && ch === "'") {
531
+ inSingle = false;
532
+ continue;
533
+ }
534
+
535
+ if (!inSingle && ch === '"' && !inDouble) {
536
+ inDouble = true;
537
+ continue;
538
+ } else if (inDouble && ch === '"') {
539
+ inDouble = false;
540
+ continue;
541
+ }
542
+
543
+ if (inSingle || inDouble) continue;
544
+
545
+ if (ch === "(") depthParen++;
546
+ else if (ch === ")") depthParen--;
547
+ else if (ch === "[") depthBracket++;
548
+ else if (ch === "]") depthBracket--;
549
+ else if (ch === "{") depthBrace++;
550
+ else if (ch === "}") depthBrace--;
551
+
111
552
  if (
112
- (child.type === "identifier" && child.text === "Union") ||
113
- ["[", "]", ","].includes(child.type)
553
+ ch === "=" &&
554
+ depthParen === 0 &&
555
+ depthBracket === 0 &&
556
+ depthBrace === 0
114
557
  ) {
558
+ eqIndex = i;
559
+ break;
560
+ }
561
+ }
562
+
563
+ if (eqIndex >= 0) {
564
+ left = s.slice(0, eqIndex).trim();
565
+ defaultValue = s.slice(eqIndex + 1).trim();
566
+ }
567
+ }
568
+
569
+ // Split type annotation at top-level ":"
570
+ let name = left;
571
+ let type: string | undefined;
572
+
573
+ {
574
+ let depthParen = 0,
575
+ depthBracket = 0,
576
+ depthBrace = 0;
577
+ let inSingle = false,
578
+ inDouble = false,
579
+ escape = false;
580
+ let colonIndex = -1;
581
+
582
+ for (let i = 0; i < left.length; i++) {
583
+ const ch = left[i];
584
+
585
+ if (escape) {
586
+ escape = false;
587
+ continue;
588
+ }
589
+ if (ch === "\\") {
590
+ escape = true;
115
591
  continue;
116
592
  }
117
593
 
118
- // Capture Types (bool, str, etc.) and None
594
+ if (!inDouble && ch === "'" && !inSingle) {
595
+ inSingle = true;
596
+ continue;
597
+ } else if (inSingle && ch === "'") {
598
+ inSingle = false;
599
+ continue;
600
+ }
601
+
602
+ if (!inSingle && ch === '"' && !inDouble) {
603
+ inDouble = true;
604
+ continue;
605
+ } else if (inDouble && ch === '"') {
606
+ inDouble = false;
607
+ continue;
608
+ }
609
+
610
+ if (inSingle || inDouble) continue;
611
+
612
+ if (ch === "(") depthParen++;
613
+ else if (ch === ")") depthParen--;
614
+ else if (ch === "[") depthBracket++;
615
+ else if (ch === "]") depthBracket--;
616
+ else if (ch === "{") depthBrace++;
617
+ else if (ch === "}") depthBrace--;
618
+
119
619
  if (
120
- child.type === "identifier" ||
121
- child.type === "none" ||
122
- child.type === "type" ||
123
- child.type === "primitive_type"
620
+ ch === ":" &&
621
+ depthParen === 0 &&
622
+ depthBracket === 0 &&
623
+ depthBrace === 0
124
624
  ) {
125
- values.push(child.text);
126
- }
127
- // Recursively handle nested structures (e.g. Union[str, Literal["a"]])
128
- else if (child.type === "subscript" || child.type === "generic_type") {
129
- values.push(...extractLiteralValues(child));
625
+ colonIndex = i;
626
+ break;
130
627
  }
131
628
  }
629
+
630
+ if (colonIndex >= 0) {
631
+ name = left.slice(0, colonIndex).trim();
632
+ type = left.slice(colonIndex + 1).trim();
633
+ }
132
634
  }
133
635
 
134
- return values;
636
+ if (!name) return null;
637
+ return { name, type, defaultValue };
135
638
  }
136
639
 
137
640
  /**
138
- * Collects module-level type aliases
641
+ * AST-based top-level alias collection:
642
+ * Size = Literal["sm","md"]
643
+ * Foo = Union[str, Literal["x"]]
139
644
  */
140
- function collectTypeAliases(
141
- rootNode: Parser.SyntaxNode
645
+ function collectTypeAliasesAst(
646
+ root: NodeSpan,
647
+ source: string,
142
648
  ): Map<string, string[]> {
143
649
  const aliases = new Map<string, string[]>();
144
650
 
145
- for (const child of rootNode.children) {
146
- if (child.type === "expression_statement") {
147
- const assignment = child.firstChild;
148
- if (assignment?.type === "assignment") {
149
- const left = assignment.childForFieldName("left");
150
- const right = assignment.childForFieldName("right");
151
-
152
- if (left?.type === "identifier" && right) {
153
- const values = extractLiteralValues(right);
154
- if (values.length > 0) {
155
- aliases.set(left.text, values);
156
- }
651
+ for (const stmt of getTopLevelStatements(root)) {
652
+ const stmtText = slice(source, stmt).trim();
653
+ if (!stmtText || stmtText.startsWith("@")) continue;
654
+
655
+ // structure is AST (top-level statement); leaf extraction uses text
656
+ const eqIndex = stmtText.indexOf("=");
657
+ if (eqIndex < 0) continue;
658
+
659
+ const left = stmtText.slice(0, eqIndex).trim();
660
+ const right = stmtText.slice(eqIndex + 1).trim();
661
+
662
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(left)) continue;
663
+
664
+ const values = extractLiteralValuesFromTypeString(right);
665
+ if (values.length > 0) {
666
+ aliases.set(left, values);
667
+ }
668
+ }
669
+
670
+ return aliases;
671
+ }
672
+
673
+ /**
674
+ * Dictionary key extraction from dict literal text (token-aware, no regex)
675
+ */
676
+ function extractDictKeysFromText(dictText: string): string[] {
677
+ const keys: string[] = [];
678
+
679
+ const inner = dictText.trim();
680
+ if (!(inner.startsWith("{") && inner.endsWith("}"))) return keys;
681
+
682
+ const body = inner.slice(1, -1);
683
+ const pairs = splitTopLevelComma(body);
684
+
685
+ for (const part of pairs) {
686
+ const p = part.trim();
687
+ if (!p || p.startsWith("**")) continue;
688
+
689
+ // split top-level colon
690
+ let colonIndex = -1;
691
+ let depthParen = 0,
692
+ depthBracket = 0,
693
+ depthBrace = 0;
694
+ let inSingle = false,
695
+ inDouble = false,
696
+ escape = false;
697
+
698
+ for (let i = 0; i < p.length; i++) {
699
+ const ch = p[i];
700
+ if (escape) {
701
+ escape = false;
702
+ continue;
703
+ }
704
+ if (ch === "\\") {
705
+ escape = true;
706
+ continue;
707
+ }
708
+
709
+ if (!inDouble && ch === "'" && !inSingle) {
710
+ inSingle = true;
711
+ continue;
712
+ } else if (inSingle && ch === "'") {
713
+ inSingle = false;
714
+ continue;
715
+ }
716
+
717
+ if (!inSingle && ch === '"' && !inDouble) {
718
+ inDouble = true;
719
+ continue;
720
+ } else if (inDouble && ch === '"') {
721
+ inDouble = false;
722
+ continue;
723
+ }
724
+
725
+ if (inSingle || inDouble) continue;
726
+
727
+ if (ch === "(") depthParen++;
728
+ else if (ch === ")") depthParen--;
729
+ else if (ch === "[") depthBracket++;
730
+ else if (ch === "]") depthBracket--;
731
+ else if (ch === "{") depthBrace++;
732
+ else if (ch === "}") depthBrace--;
733
+
734
+ if (
735
+ ch === ":" &&
736
+ depthParen === 0 &&
737
+ depthBracket === 0 &&
738
+ depthBrace === 0
739
+ ) {
740
+ colonIndex = i;
741
+ break;
742
+ }
743
+ }
744
+
745
+ if (colonIndex < 0) continue;
746
+ const keyExpr = p.slice(0, colonIndex).trim();
747
+
748
+ if (/^['"].*['"]$/s.test(keyExpr)) {
749
+ keys.push(stripQuotes(keyExpr));
750
+ } else if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyExpr)) {
751
+ keys.push(keyExpr);
752
+ }
753
+ }
754
+
755
+ return [...new Set(keys)];
756
+ }
757
+
758
+ /**
759
+ * AST-ish body dictionary assignments:
760
+ * scoped body parsing (less fragile than whole-file regex)
761
+ */
762
+ function extractBodyDictAssignmentsAst(
763
+ bodyText: string,
764
+ ): Map<string, string[]> {
765
+ const out = new Map<string, string[]>();
766
+ const lines = bodyText.split(/\r?\n/);
767
+
768
+ let i = 0;
769
+ while (i < lines.length) {
770
+ const line = lines[i];
771
+ const trimmed = line.trim();
772
+
773
+ if (!trimmed || trimmed.startsWith("#")) {
774
+ i++;
775
+ continue;
776
+ }
777
+
778
+ // Look for `name = {`
779
+ const eq = line.indexOf("=");
780
+ if (eq < 0) {
781
+ i++;
782
+ continue;
783
+ }
784
+
785
+ const left = line.slice(0, eq).trim();
786
+ const rightStart = line.slice(eq + 1).trimStart();
787
+
788
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(left) || !rightStart.startsWith("{")) {
789
+ i++;
790
+ continue;
791
+ }
792
+
793
+ // collect balanced dict literal across lines
794
+ let dictText = line.slice(line.indexOf("{"));
795
+ let depth = 0;
796
+ let started = false;
797
+ let done = false;
798
+
799
+ const countBraces = (s: string) => {
800
+ let inSingle = false;
801
+ let inDouble = false;
802
+ let escape = false;
803
+
804
+ for (const ch of s) {
805
+ if (escape) {
806
+ escape = false;
807
+ continue;
808
+ }
809
+ if (ch === "\\") {
810
+ escape = true;
811
+ continue;
812
+ }
813
+
814
+ if (!inDouble && ch === "'" && !inSingle) {
815
+ inSingle = true;
816
+ continue;
817
+ } else if (inSingle && ch === "'") {
818
+ inSingle = false;
819
+ continue;
820
+ }
821
+
822
+ if (!inSingle && ch === '"' && !inDouble) {
823
+ inDouble = true;
824
+ continue;
825
+ } else if (inDouble && ch === '"') {
826
+ inDouble = false;
827
+ continue;
828
+ }
829
+
830
+ if (inSingle || inDouble) continue;
831
+
832
+ if (ch === "{") {
833
+ depth++;
834
+ started = true;
835
+ } else if (ch === "}") {
836
+ depth--;
837
+ if (started && depth === 0) done = true;
157
838
  }
158
839
  }
840
+ };
841
+
842
+ countBraces(dictText);
843
+
844
+ let j = i + 1;
845
+ while (!done && j < lines.length) {
846
+ dictText += "\n" + lines[j];
847
+ countBraces(lines[j]);
848
+ j++;
849
+ }
850
+
851
+ if (done) {
852
+ out.set(left, extractDictKeysFromText(dictText));
853
+ i = j;
854
+ continue;
159
855
  }
856
+
857
+ i++;
160
858
  }
161
- return aliases;
859
+
860
+ return out;
162
861
  }
163
862
 
164
863
  /**
165
864
  * ---------------------------------------------------------
166
- * Core Analysis Logic (AST Based)
865
+ * Core Analysis Logic (AST-first)
167
866
  * ---------------------------------------------------------
168
867
  */
169
868
 
170
869
  function analyzeFile(filePath: string, rootDir: string): ComponentMetadata[] {
171
870
  const fileContent = fs.readFileSync(filePath, "utf-8");
172
- const tree = parser.parse(fileContent);
871
+ const { root } = parsePythonToSpanTree(fileContent);
872
+
173
873
  const components: ComponentMetadata[] = [];
174
- const typeAliases = collectTypeAliases(tree.rootNode);
175
-
176
- const query = new Parser.Query(
177
- Python as any,
178
- `(decorated_definition
179
- (decorator) @dec
180
- (function_definition) @func
181
- )`
182
- );
874
+ const typeAliases = collectTypeAliasesAst(root, fileContent);
875
+ const decoratedFns = findDecoratedFunctionsAst(root, fileContent);
183
876
 
184
- const matches = query.matches(tree.rootNode);
877
+ for (const item of decoratedFns) {
878
+ const hasComponentDecorator = item.decoratorNodes.some((d) =>
879
+ decoratorMatchesComponent(d, fileContent),
880
+ );
185
881
 
186
- for (const match of matches) {
187
- const decoratorNode = match.captures.find((c) => c.name === "dec")?.node;
188
- if (decoratorNode?.text.trim() !== "@component") continue;
882
+ // fallback for grammars that don't expose decorator children cleanly
883
+ const decoratedText = slice(fileContent, item.decoratedNode);
884
+ const fallbackHasComponent =
885
+ decoratedText.trimStart().startsWith("@component") ||
886
+ decoratedText.includes("\n@component");
189
887
 
190
- const funcNode = match.captures.find((c) => c.name === "func")?.node;
191
- if (!funcNode) continue;
888
+ if (!hasComponentDecorator && !fallbackHasComponent) continue;
192
889
 
193
- const nameNode = funcNode.childForFieldName("name");
194
- const componentName = nameNode?.text || "Unknown";
195
- const paramsNode = funcNode.childForFieldName("parameters");
196
- const bodyNode = funcNode.childForFieldName("body");
890
+ const componentName = extractFunctionNameAst(
891
+ item.functionNode,
892
+ fileContent,
893
+ );
894
+ const paramsText = extractParamListTextAst(item.functionNode, fileContent);
895
+ const bodyText = extractFunctionBodyTextAst(item.functionNode, fileContent);
197
896
 
198
- // 1. Parse Props (Parameters)
897
+ // 1) Parse Props (parameters)
199
898
  const props: ComponentProp[] = [];
200
899
  const propMap = new Map<string, ComponentProp>();
201
900
  let acceptsArbitraryProps = false;
202
901
 
203
- if (paramsNode) {
204
- for (const param of paramsNode.children) {
205
- // --- Detect **props (dictionary_splat_pattern) ---
206
- if (param.type === "dictionary_splat_pattern") {
207
- acceptsArbitraryProps = true;
208
- continue;
209
- }
902
+ for (const rawParam of splitTopLevelComma(paramsText)) {
903
+ const parsed = parseParameterChunk(rawParam);
904
+ if (!parsed) continue;
210
905
 
211
- // Skip punctuation and list splats (*args)
212
- if (["(", ")", ",", "list_splat_pattern"].includes(param.type))
213
- continue;
906
+ if (parsed.arbitraryDict) {
907
+ acceptsArbitraryProps = true;
908
+ continue;
909
+ }
910
+ if (parsed.listSplat) {
911
+ continue; // skip *args
912
+ }
214
913
 
215
- let name = "";
216
- let defaultValue: string | undefined = undefined;
217
- let typeNode: Parser.SyntaxNode | null = null;
218
-
219
- if (param.type === "identifier") {
220
- name = param.text;
221
- } else if (param.type === "default_parameter") {
222
- name = param.childForFieldName("name")?.text || "";
223
- defaultValue = param.childForFieldName("value")?.text;
224
- } else if (param.type === "typed_parameter") {
225
- const nameNode =
226
- param.childForFieldName("name") ??
227
- param.children.find((c) => c.type === "identifier") ??
228
- null;
229
-
230
- name = nameNode?.text || "";
231
- typeNode =
232
- param.childForFieldName("type") ??
233
- param.children.find(
234
- (c) => c.type !== "identifier" && c.type !== ":"
235
- ) ??
236
- null;
237
- } else if (param.type === "typed_default_parameter") {
238
- name = param.childForFieldName("name")?.text || "";
239
- defaultValue = param.childForFieldName("value")?.text;
240
- typeNode = param.childForFieldName("type") ?? null;
241
- }
914
+ let { name, type, defaultValue } = parsed;
242
915
 
243
- // Clean up quotes from default value
244
- if (defaultValue)
245
- defaultValue = defaultValue.replace(/^['"]|['"]$/g, "");
916
+ if (name === "self" || name === "cls") continue;
246
917
 
247
- // Exclude standard python args
248
- if (name === "self" || name === "cls") continue;
918
+ if (defaultValue && /^['"].*['"]$/s.test(defaultValue)) {
919
+ defaultValue = stripQuotes(defaultValue);
920
+ }
249
921
 
250
- // --- Extract Type String ---
251
- let propType = "Any";
252
- if (typeNode) {
253
- propType = typeNode.text;
254
- }
922
+ const propType = type?.trim() || "Any";
255
923
 
256
- // --- Extract Options ---
257
- let options: string[] = [];
258
- if (typeNode) {
259
- let actualType: Parser.SyntaxNode | null = typeNode;
260
- if (typeNode.type === "type") {
261
- actualType = typeNode.firstChild;
262
- }
263
-
264
- if (actualType?.type === "identifier") {
265
- options = typeAliases.get(actualType.text) || [];
266
- } else if (
267
- actualType?.type === "subscript" ||
268
- actualType?.type === "generic_type"
269
- ) {
270
- options = extractLiteralValues(actualType);
271
- } else if (actualType) {
272
- const findSubscript = (
273
- n: Parser.SyntaxNode
274
- ): Parser.SyntaxNode | null => {
275
- if (n.type === "subscript") return n;
276
- for (const c of n.children) {
277
- const found = findSubscript(c);
278
- if (found) return found;
279
- }
280
- return null;
281
- };
282
- const subscript = findSubscript(actualType);
283
- if (subscript) {
284
- options = extractLiteralValues(subscript);
285
- }
286
- }
924
+ let options: string[] = [];
925
+ if (type) {
926
+ const t = type.trim();
927
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t)) {
928
+ options = typeAliases.get(t) || [];
929
+ } else {
930
+ options = extractLiteralValuesFromTypeString(t);
287
931
  }
932
+ }
288
933
 
289
- const propObj: ComponentProp = {
290
- name,
291
- type: propType,
292
- hasDefault: defaultValue !== undefined,
293
- defaultValue,
294
- options: options.length > 0 ? options : undefined,
295
- };
934
+ const propObj: ComponentProp = {
935
+ name,
936
+ type: propType,
937
+ hasDefault: defaultValue !== undefined,
938
+ defaultValue,
939
+ options: options.length > 0 ? options : undefined,
940
+ };
296
941
 
297
- props.push(propObj);
298
- propMap.set(name, propObj);
299
- }
942
+ props.push(propObj);
943
+ propMap.set(name, propObj);
300
944
  }
301
945
 
302
- // 2. Parse Body for Dictionaries
303
- if (bodyNode) {
304
- for (const statement of bodyNode.children) {
305
- if (statement.type === "expression_statement") {
306
- const assignment = statement.firstChild;
307
- if (assignment?.type === "assignment") {
308
- const left = assignment.childForFieldName("left");
309
- const right = assignment.childForFieldName("right");
310
-
311
- if (left?.type === "identifier" && right?.type === "dictionary") {
312
- const varName = left.text;
313
- if (varName.endsWith("s")) {
314
- const propName = varName.slice(0, -1);
315
- const relatedProp = propMap.get(propName);
316
-
317
- if (relatedProp) {
318
- if (!relatedProp.options) {
319
- relatedProp.options = extractDictKeysFromNode(right);
320
- }
321
- }
322
- }
323
- }
324
- }
325
- }
946
+ // 2) Infer options from body dict assignments
947
+ const dictAssignments = extractBodyDictAssignmentsAst(bodyText);
948
+
949
+ for (const [varName, keys] of dictAssignments.entries()) {
950
+ if (!varName.endsWith("s")) continue;
951
+ const propName = varName.slice(0, -1);
952
+ const relatedProp = propMap.get(propName);
953
+ if (relatedProp && !relatedProp.options && keys.length > 0) {
954
+ relatedProp.options = keys;
326
955
  }
327
956
  }
328
957
 
329
- // Generate Metadata Paths
958
+ // 3) Paths
330
959
  const relativePath = path.relative(rootDir, filePath).replace(/\\/g, "/");
331
960
  const pathNoExt = relativePath.replace(/\.py$/, "");
332
961
  const importRoute = pathNoExt.replace(/\//g, ".");
@@ -349,6 +978,7 @@ function analyzeFile(filePath: string, rootDir: string): ComponentMetadata[] {
349
978
  * File System Helpers
350
979
  * ---------------------------------------------------------
351
980
  */
981
+
352
982
  function loadConfig(): CaspianConfig {
353
983
  if (!fs.existsSync(CONFIG_PATH)) {
354
984
  console.error(`āŒ Configuration file not found at: ${CONFIG_PATH}`);
@@ -360,18 +990,20 @@ function loadConfig(): CaspianConfig {
360
990
  function walkDirectory(
361
991
  dir: string,
362
992
  fileList: string[] = [],
363
- excludeList: string[] = []
993
+ excludeList: string[] = [],
364
994
  ): string[] {
365
995
  if (!fs.existsSync(dir)) return fileList;
366
996
  const files = fs.readdirSync(dir);
997
+
367
998
  files.forEach((file) => {
368
999
  const fullPath = path.join(dir, file);
369
1000
  const stat = fs.statSync(fullPath);
370
1001
  const relativeCheck = path
371
1002
  .relative(PROJECT_ROOT, fullPath)
372
1003
  .replace(/\\/g, "/");
1004
+
373
1005
  const isExcluded = excludeList.some((ex) =>
374
- relativeCheck.includes(ex.replace(/^\.\//, ""))
1006
+ relativeCheck.includes(ex.replace(/^\.\//, "")),
375
1007
  );
376
1008
  if (isExcluded) return;
377
1009
 
@@ -381,6 +1013,7 @@ function walkDirectory(
381
1013
  fileList.push(fullPath);
382
1014
  }
383
1015
  });
1016
+
384
1017
  return fileList;
385
1018
  }
386
1019
 
@@ -389,8 +1022,9 @@ function walkDirectory(
389
1022
  * Execution Entry Point
390
1023
  * ---------------------------------------------------------
391
1024
  */
1025
+
392
1026
  export async function componentMap() {
393
- console.log(`šŸ” Starting Component Analysis (AST Powered)...`);
1027
+ console.log(`šŸ” Starting Component Analysis (Lezer AST-first)...`);
394
1028
  const config = loadConfig();
395
1029
  let allFiles: string[] = [];
396
1030
 
@@ -398,7 +1032,7 @@ export async function componentMap() {
398
1032
  allFiles = walkDirectory(
399
1033
  path.join(PROJECT_ROOT, scanDir),
400
1034
  allFiles,
401
- config.excludeFiles || []
1035
+ config.excludeFiles || [],
402
1036
  );
403
1037
  });
404
1038
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",