259 lines
10 KiB
JavaScript
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
|