Files
2026-07-31 13:12:54 -04:00

259 lines
10 KiB
JavaScript

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