import fs from 'node:fs'; import path from 'node:path'; import { readComposerJson, hasComposerDependency } from './composer.js'; import { hasDependency, readPackageJson } from './packageManager.js'; import { LaravelAdapter } from './laravel.js'; import { CodeIgniterAdapter } from './codeigniter.js'; import { ReactAdapter } from './react.js'; import { VueAdapter, NuxtAdapter } from './vue.js'; import { DjangoAdapter, RailsAdapter, SymfonyAdapter } from './djangoRailsSymfony.js'; import { GenericAdapter } from './generic.js'; const FRONTEND_SUBDIRS = ['frontend', 'client', 'web', 'resources/js-app']; const MONOREPO_GLOBS = ['apps', 'packages', 'services', 'libs']; function fileExists(root, names) { return names.some((name) => fs.existsSync(path.join(root, name))); } function hasVueSfc(root) { const src = path.join(root, 'src'); if (!fs.existsSync(src)) return false; try { const stack = [src]; let checked = 0; while (stack.length > 0 && checked < 200) { const dir = stack.pop(); for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { checked += 1; if (entry.isDirectory() && entry.name !== 'node_modules') { stack.push(path.join(dir, entry.name)); } else if (entry.isFile() && entry.name.endsWith('.vue')) { return true; } } } } catch { return false; } return false; } function detectPhpOrSymfonyBackend(root) { const composerJson = readComposerJson(root); if (!composerJson) return null; const hasArtisan = fs.existsSync(path.join(root, 'artisan')); const hasSpark = fs.existsSync(path.join(root, 'spark')); if (hasComposerDependency(composerJson, 'laravel/framework') || hasArtisan) { return new LaravelAdapter(root); } if (hasComposerDependency(composerJson, 'codeigniter4/framework') || hasSpark) { return new CodeIgniterAdapter(root); } if (hasComposerDependency(composerJson, 'symfony/framework-bundle') || hasComposerDependency(composerJson, 'symfony/symfony') || fileExists(root, ['bin/console', 'symfony.lock'])) { return new SymfonyAdapter(root); } return null; } function detectDjangoAt(root) { if (fileExists(root, ['manage.py'])) return new DjangoAdapter(root); for (const name of ['requirements.txt', 'requirements-dev.txt', 'pyproject.toml']) { const file = path.join(root, name); if (!fs.existsSync(file)) continue; const text = fs.readFileSync(file, 'utf8').toLowerCase(); if (/\bdjango\b/.test(text)) return new DjangoAdapter(root); } return null; } function detectRailsAt(root) { const gemfile = path.join(root, 'Gemfile'); if (fs.existsSync(gemfile)) { const text = fs.readFileSync(gemfile, 'utf8').toLowerCase(); if (/\brails\b/.test(text) || fileExists(root, ['bin/rails', 'config/application.rb'])) { return new RailsAdapter(root); } } if (fileExists(root, ['bin/rails', 'config/application.rb'])) { return new RailsAdapter(root); } return null; } function detectBackend(root) { return detectPhpOrSymfonyBackend(root) ?? detectDjangoAt(root) ?? detectRailsAt(root); } function detectFrontendAt(root) { const pkg = readPackageJson(root); if (hasDependency(pkg, 'nuxt') || fileExists(root, ['nuxt.config.js', 'nuxt.config.ts', 'nuxt.config.mjs'])) { return new NuxtAdapter(root); } if (hasDependency(pkg, 'vue') || (hasVueSfc(root) && fileExists(root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']))) { return new VueAdapter(root); } if (hasDependency(pkg, 'react')) return new ReactAdapter(root); return null; } /** * Inspects `workspaceRoot` and returns the detected backend/frontend * adapter(s). Supports PHP (Laravel/CI/Symfony), Django, Rails backends and * React/Vue/Nuxt frontends, including sibling frontend folders. */ export function detectProject(workspaceRoot) { const backend = detectBackend(workspaceRoot); let frontend = detectFrontendAt(workspaceRoot); if (backend && !frontend) { for (const subdir of FRONTEND_SUBDIRS) { const candidateRoot = path.join(workspaceRoot, subdir); if (!fs.existsSync(candidateRoot)) continue; const candidate = detectFrontendAt(candidateRoot); if (candidate) { frontend = candidate; break; } } } // Pure frontend-only Vue/Nuxt/React at root without backend. if (!backend && !frontend) { frontend = detectFrontendAt(workspaceRoot); } if (backend) { return { backend, frontend, primary: backend }; } if (frontend) { return { backend: null, frontend, primary: frontend }; } const generic = new GenericAdapter(workspaceRoot); return { backend: null, frontend: null, primary: generic }; } export function summarizeDetection(detected) { return { backend: detected.backend?.describe() ?? null, frontend: detected.frontend?.describe() ?? null, primary: detected.primary.kind, combined: Boolean(detected.backend && detected.frontend), }; } function readWorkspaceGlobs(root) { const globs = new Set([...MONOREPO_GLOBS, ...FRONTEND_SUBDIRS.map((d) => d.split('/')[0]).filter(Boolean)]); const pkg = readPackageJson(root); const workspaces = pkg?.workspaces; if (Array.isArray(workspaces)) { for (const entry of workspaces) { if (typeof entry === 'string' && entry.includes('/*')) { globs.add(entry.replace(/\/\*$/, '')); } } } else if (workspaces && typeof workspaces === 'object' && Array.isArray(workspaces.packages)) { for (const entry of workspaces.packages) { if (entry.includes('/*')) globs.add(entry.replace(/\/\*$/, '')); } } const pnpm = path.join(root, 'pnpm-workspace.yaml'); if (fs.existsSync(pnpm)) { const text = fs.readFileSync(pnpm, 'utf8'); for (const match of text.matchAll(/['"]?([A-Za-z0-9_-]+)\/\*['"]?/g)) { if (match[1]) globs.add(match[1]); } } return [...globs]; } function listImmediateSubdirs(dir) { if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; return fs .readdirSync(dir, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'vendor') .map((entry) => entry.name); } function packageLooksLikeProject(dir) { return (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, 'composer.json')) || fs.existsSync(path.join(dir, 'artisan')) || fs.existsSync(path.join(dir, 'spark')) || fs.existsSync(path.join(dir, 'manage.py')) || fs.existsSync(path.join(dir, 'Gemfile')) || fs.existsSync(path.join(dir, 'bin', 'console'))); } /** * Scan the workspace for monorepo packages under apps/*, packages/*, services/*, * frontend/client/web, plus the workspace root itself. */ export function detectWorkspace(workspaceRoot) { const absoluteRoot = path.resolve(workspaceRoot); const packages = []; const seen = new Set(); const addPackage = (name, absolutePath) => { const normalized = path.resolve(absolutePath); if (seen.has(normalized.toLowerCase())) return; if (!packageLooksLikeProject(normalized) && normalized !== absoluteRoot) return; seen.add(normalized.toLowerCase()); const relativePath = path.relative(absoluteRoot, normalized) || '.'; packages.push({ name, relativePath: relativePath === '' ? '.' : relativePath.replace(/\\/g, '/'), absolutePath: normalized, detected: detectProject(normalized), }); }; addPackage(path.basename(absoluteRoot) || 'root', absoluteRoot); for (const globRoot of readWorkspaceGlobs(absoluteRoot)) { const base = path.join(absoluteRoot, globRoot); for (const child of listImmediateSubdirs(base)) { addPackage(child, path.join(base, child)); } if (FRONTEND_SUBDIRS.some((d) => d === globRoot || d.startsWith(`${globRoot}/`)) && packageLooksLikeProject(base)) { addPackage(globRoot, base); } } return { root: absoluteRoot, packages }; } export function summarizeWorkspace(workspace) { return { root: workspace.root, packageCount: workspace.packages.length, packages: workspace.packages.map((pkg) => ({ name: pkg.name, path: pkg.relativePath, primary: pkg.detected.primary.kind, combined: Boolean(pkg.detected.backend && pkg.detected.frontend), backend: pkg.detected.backend?.kind ?? null, frontend: pkg.detected.frontend?.kind ?? null, })), }; } /** * Resolve a package name or relative path to a DetectedPackage inside the workspace. */ export function resolvePackage(workspaceRoot, packageNameOrPath) { const workspace = detectWorkspace(workspaceRoot); if (!packageNameOrPath) { const rootPkg = workspace.packages.find((p) => p.relativePath === '.') ?? workspace.packages[0]; if (!rootPkg) throw new Error('No packages detected in the workspace.'); return { workspace, pkg: rootPkg }; } const needle = packageNameOrPath.replace(/\\/g, '/').replace(/^\.\//, ''); const match = workspace.packages.find((p) => p.name === packageNameOrPath || p.relativePath === needle || p.relativePath === packageNameOrPath || p.absolutePath.replace(/\\/g, '/').endsWith(`/${needle}`)); if (!match) { const available = workspace.packages.map((p) => `${p.name} (${p.relativePath})`).join(', '); throw new Error(`Unknown package "${packageNameOrPath}". Available: ${available || '(none)'}.`); } return { workspace, pkg: match }; } //# sourceMappingURL=detect.js.map