init project
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { detectProject, summarizeDetection } from '../detect.js';
|
||||
|
||||
function createTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-test-'));
|
||||
}
|
||||
|
||||
describe('detectProject', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('detects a Laravel project', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'composer.json'), JSON.stringify({ require: { 'laravel/framework': '^10.0' } }));
|
||||
fs.writeFileSync(path.join(tmpDir, 'artisan'), '#!/usr/bin/env php');
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.backend?.kind).toBe('laravel');
|
||||
expect(detected.primary.kind).toBe('laravel');
|
||||
});
|
||||
|
||||
it('detects a CodeIgniter project', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'composer.json'), JSON.stringify({ require: { 'codeigniter4/framework': '^4.0' } }));
|
||||
fs.writeFileSync(path.join(tmpDir, 'spark'), '#!/usr/bin/env php');
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.backend?.kind).toBe('codeigniter');
|
||||
expect(detected.primary.kind).toBe('codeigniter');
|
||||
});
|
||||
|
||||
it('detects a React project', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { react: '^18.0' } }));
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.frontend?.kind).toBe('react');
|
||||
expect(detected.primary.kind).toBe('react');
|
||||
});
|
||||
|
||||
it('detects a Vue project', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { vue: '^3.0' } }));
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.frontend?.kind).toBe('vue');
|
||||
expect(detected.primary.kind).toBe('vue');
|
||||
});
|
||||
|
||||
it('detects Nuxt over Vue when both are present', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'package.json'),
|
||||
JSON.stringify({ dependencies: { vue: '^3.0', nuxt: '^3.0' } }),
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, 'nuxt.config.ts'), 'export default {}\n');
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.frontend?.kind).toBe('nuxt');
|
||||
});
|
||||
|
||||
it('detects Vue via .vue SFC + Vite when vue is not in package.json', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
|
||||
fs.writeFileSync(path.join(tmpDir, 'vite.config.ts'), 'export default {}\n');
|
||||
const src = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, 'App.vue'), '<template><div /></template>\n');
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.frontend?.kind).toBe('vue');
|
||||
});
|
||||
|
||||
it('detects a Symfony project', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'composer.json'),
|
||||
JSON.stringify({ require: { 'symfony/framework-bundle': '^7.0' } }),
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, 'bin'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'bin', 'console'), '#!/usr/bin/env php\n');
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.backend?.kind).toBe('symfony');
|
||||
});
|
||||
|
||||
it('detects a Django project', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'manage.py'), '#!/usr/bin/env python\n');
|
||||
fs.writeFileSync(path.join(tmpDir, 'requirements.txt'), 'Django>=5.0\n');
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.backend?.kind).toBe('django');
|
||||
expect(detected.primary.kind).toBe('django');
|
||||
});
|
||||
|
||||
it('detects a Rails project', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'Gemfile'), "gem 'rails', '~> 7.0'\n");
|
||||
fs.mkdirSync(path.join(tmpDir, 'bin'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'bin', 'rails'), '#!/usr/bin/env ruby\n');
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.backend?.kind).toBe('rails');
|
||||
});
|
||||
|
||||
it('detects a Laravel backend with a separate React frontend', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'composer.json'), JSON.stringify({ require: { 'laravel/framework': '^10.0' } }));
|
||||
fs.writeFileSync(path.join(tmpDir, 'artisan'), '#!/usr/bin/env php');
|
||||
const frontendDir = path.join(tmpDir, 'frontend');
|
||||
fs.mkdirSync(frontendDir);
|
||||
fs.writeFileSync(path.join(frontendDir, 'package.json'), JSON.stringify({ dependencies: { react: '^18.0' } }));
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.backend?.kind).toBe('laravel');
|
||||
expect(detected.frontend?.kind).toBe('react');
|
||||
expect(detected.primary.kind).toBe('laravel');
|
||||
});
|
||||
|
||||
it('falls back to generic for an empty directory', () => {
|
||||
const detected = detectProject(tmpDir);
|
||||
expect(detected.backend).toBeNull();
|
||||
expect(detected.frontend).toBeNull();
|
||||
expect(detected.primary.kind).toBe('generic');
|
||||
});
|
||||
|
||||
it('summarizeDetection returns serializable metadata', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { react: '^18.0' } }));
|
||||
const summary = summarizeDetection(detectProject(tmpDir));
|
||||
expect(summary.primary).toBe('react');
|
||||
expect(summary.combined).toBe(false);
|
||||
expect(summary.frontend).toHaveProperty('kind', 'react');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { LaravelAdapter } from '../laravel.js';
|
||||
|
||||
describe('LaravelAdapter Sail-aware devCommand', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-sail-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses php artisan serve when Sail is not present', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'composer.json'),
|
||||
JSON.stringify({ require: { 'laravel/framework': '^11.0' } }),
|
||||
);
|
||||
const adapter = new LaravelAdapter(tmpDir);
|
||||
const cmd = adapter.devCommand();
|
||||
expect(cmd.command).toBe('php');
|
||||
expect(cmd.args).toContain('artisan');
|
||||
expect(adapter.describe().usesSail).toBe(false);
|
||||
});
|
||||
|
||||
it('uses sail up when laravel/sail + compose file exist', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'composer.json'),
|
||||
JSON.stringify({
|
||||
require: { 'laravel/framework': '^11.0' },
|
||||
'require-dev': { 'laravel/sail': '^1.0' },
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, 'docker-compose.yml'), 'services: {}\n');
|
||||
fs.mkdirSync(path.join(tmpDir, 'vendor', 'bin'), { recursive: true });
|
||||
const sail = path.join(tmpDir, 'vendor', 'bin', 'sail');
|
||||
fs.writeFileSync(sail, '#!/usr/bin/env bash\n');
|
||||
|
||||
const adapter = new LaravelAdapter(tmpDir);
|
||||
const cmd = adapter.devCommand();
|
||||
expect(cmd.command).toBe('bash');
|
||||
expect(cmd.args[0]?.replace(/\\/g, '/')).toContain('vendor/bin/sail');
|
||||
expect(cmd.args).toContain('up');
|
||||
expect(adapter.describe().usesSail).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to docker compose up when Sail package is present but sail script is missing', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'composer.json'),
|
||||
JSON.stringify({
|
||||
require: { 'laravel/framework': '^11.0' },
|
||||
'require-dev': { 'laravel/sail': '^1.0' },
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, 'compose.yaml'), 'services: {}\n');
|
||||
|
||||
const adapter = new LaravelAdapter(tmpDir);
|
||||
const cmd = adapter.devCommand();
|
||||
expect(cmd.command).toBe('docker');
|
||||
expect(cmd.args).toEqual(['compose', 'up']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ReactAdapter } from '../react.js';
|
||||
|
||||
describe('ReactAdapter Next.js metadata', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-next-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports app router and rsc for Next App Router projects', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'package.json'),
|
||||
JSON.stringify({ dependencies: { react: '^18.0', next: '^14.0' } }),
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, 'next.config.mjs'), 'export default {}');
|
||||
fs.mkdirSync(path.join(tmpDir, 'app'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'app', 'layout.tsx'), 'export default function Root({ children }) { return children; }');
|
||||
|
||||
const described = new ReactAdapter(tmpDir).describe();
|
||||
expect(described.bundler).toBe('next');
|
||||
expect(described.router).toBe('app');
|
||||
expect(described.rsc).toBe(true);
|
||||
});
|
||||
|
||||
it('reports pages router without rsc', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'package.json'),
|
||||
JSON.stringify({ dependencies: { react: '^18.0', next: '^13.0' } }),
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, 'pages'));
|
||||
fs.writeFileSync(path.join(tmpDir, 'pages', 'index.tsx'), 'export default function Home() { return null; }');
|
||||
|
||||
const described = new ReactAdapter(tmpDir).describe();
|
||||
expect(described.bundler).toBe('next');
|
||||
expect(described.router).toBe('pages');
|
||||
expect(described.rsc).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { detectWorkspace, resolvePackage, summarizeWorkspace } from '../detect.js';
|
||||
|
||||
describe('detectWorkspace', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-dev-mcp-ws-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('includes the root and apps/* packages', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'root', private: true }));
|
||||
fs.mkdirSync(path.join(tmpDir, 'apps', 'web'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'apps', 'web', 'package.json'),
|
||||
JSON.stringify({ name: 'web', dependencies: { react: '^18.0' } }),
|
||||
);
|
||||
|
||||
const workspace = detectWorkspace(tmpDir);
|
||||
const summary = summarizeWorkspace(workspace);
|
||||
expect(summary.packageCount).toBeGreaterThanOrEqual(2);
|
||||
const names = workspace.packages.map((p) => p.name);
|
||||
expect(names).toContain('web');
|
||||
});
|
||||
|
||||
it('resolvePackage finds by name and relative path', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'root' }));
|
||||
fs.mkdirSync(path.join(tmpDir, 'packages', 'api'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'packages', 'api', 'package.json'), JSON.stringify({ name: 'api' }));
|
||||
|
||||
const byName = resolvePackage(tmpDir, 'api');
|
||||
expect(byName.pkg.relativePath.replace(/\\/g, '/')).toBe('packages/api');
|
||||
|
||||
const byPath = resolvePackage(tmpDir, 'packages/api');
|
||||
expect(byPath.pkg.name).toBe('api');
|
||||
});
|
||||
|
||||
it('resolvePackage throws for unknown packages', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'root' }));
|
||||
expect(() => resolvePackage(tmpDir, 'missing')).toThrow(/Unknown package/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AdapterCommand } from './types.js';
|
||||
import { PhpFrameworkAdapter } from './phpBase.js';
|
||||
|
||||
export class CodeIgniterAdapter extends PhpFrameworkAdapter {
|
||||
readonly kind = 'codeigniter' as const;
|
||||
readonly label = 'CodeIgniter 4';
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
return { command: 'php', args: ['spark', 'serve'], cwd: this.root };
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
// CodeIgniter has no dedicated production-build step beyond a lean install;
|
||||
// callers that need config/route caching should extend this via `spark`.
|
||||
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
// `php spark serve` prints: "CodeIgniter development server started on http://localhost:8080"
|
||||
return /CodeIgniter development server started on (https?:\/\/\S+)/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
codeigniterVersion: this.composerJson?.require?.['codeigniter4/framework'] ?? null,
|
||||
devUrl: 'http://localhost:8080',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ComposerJson {
|
||||
name?: string;
|
||||
require?: Record<string, string>;
|
||||
'require-dev'?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function readComposerJson(root: string): ComposerJson | null {
|
||||
const file = path.join(root, 'composer.json');
|
||||
if (!fs.existsSync(file)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8')) as ComposerJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasComposerDependency(pkg: ComposerJson | null, name: string): boolean {
|
||||
if (!pkg) return false;
|
||||
return Boolean(pkg.require?.[name] || pkg['require-dev']?.[name]);
|
||||
}
|
||||
|
||||
export function vendorBinExists(root: string, bin: string): boolean {
|
||||
const suffix = process.platform === 'win32' ? '.bat' : '';
|
||||
return fs.existsSync(path.join(root, 'vendor', 'bin', `${bin}${suffix}`)) || fs.existsSync(path.join(root, 'vendor', 'bin', bin));
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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';
|
||||
import type { DetectedPackage, DetectedProject, DetectedWorkspace, FrameworkAdapter } from './types.js';
|
||||
|
||||
const FRONTEND_SUBDIRS = ['frontend', 'client', 'web', 'resources/js-app'];
|
||||
const MONOREPO_GLOBS = ['apps', 'packages', 'services', 'libs'];
|
||||
|
||||
function fileExists(root: string, names: string[]): boolean {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
|
||||
function hasVueSfc(root: string): boolean {
|
||||
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: string): FrameworkAdapter | null {
|
||||
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: string): FrameworkAdapter | null {
|
||||
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: string): FrameworkAdapter | null {
|
||||
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: string): FrameworkAdapter | null {
|
||||
return detectPhpOrSymfonyBackend(root) ?? detectDjangoAt(root) ?? detectRailsAt(root);
|
||||
}
|
||||
|
||||
function detectFrontendAt(root: string): FrameworkAdapter | null {
|
||||
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: string): DetectedProject {
|
||||
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: DetectedProject): Record<string, unknown> {
|
||||
return {
|
||||
backend: detected.backend?.describe() ?? null,
|
||||
frontend: detected.frontend?.describe() ?? null,
|
||||
primary: detected.primary.kind,
|
||||
combined: Boolean(detected.backend && detected.frontend),
|
||||
};
|
||||
}
|
||||
|
||||
function readWorkspaceGlobs(root: string): string[] {
|
||||
const globs = new Set<string>([...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 as { packages?: string[] }).packages)) {
|
||||
for (const entry of (workspaces as { packages: string[] }).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: string): string[] {
|
||||
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: string): boolean {
|
||||
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: string): DetectedWorkspace {
|
||||
const absoluteRoot = path.resolve(workspaceRoot);
|
||||
const packages: DetectedPackage[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const addPackage = (name: string, absolutePath: string) => {
|
||||
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: DetectedWorkspace): Record<string, unknown> {
|
||||
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: string,
|
||||
packageNameOrPath: string | undefined,
|
||||
): { workspace: DetectedWorkspace; pkg: DetectedPackage } {
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { AdapterCommand, FrameworkAdapter } from './types.js';
|
||||
|
||||
function fileExists(root: string, names: string[]): boolean {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
|
||||
function readRequirements(root: string): string {
|
||||
for (const name of ['requirements.txt', 'requirements-dev.txt', 'pyproject.toml']) {
|
||||
const file = path.join(root, name);
|
||||
if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8').toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Django detection stub: install/dev/test via manage.py / common Python tooling.
|
||||
* Deeper generators land in a later phase.
|
||||
*/
|
||||
export class DjangoAdapter implements FrameworkAdapter {
|
||||
readonly kind = 'django' as const;
|
||||
readonly label = 'Django';
|
||||
|
||||
constructor(readonly root: string) {}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
if (fileExists(this.root, ['pyproject.toml']) && fileExists(this.root, ['uv.lock'])) {
|
||||
return { command: 'uv', args: ['sync'], cwd: this.root };
|
||||
}
|
||||
if (fileExists(this.root, ['poetry.lock', 'pyproject.toml'])) {
|
||||
return { command: 'poetry', args: ['install'], cwd: this.root };
|
||||
}
|
||||
if (fileExists(this.root, ['requirements.txt'])) {
|
||||
return { command: 'pip', args: ['install', '-r', 'requirements.txt'], cwd: this.root };
|
||||
}
|
||||
throw new Error(
|
||||
`No Django dependency file found at "${this.root}" (expected requirements.txt, poetry.lock, or uv.lock).`,
|
||||
);
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
if (!fileExists(this.root, ['manage.py'])) {
|
||||
throw new Error(`No manage.py found at "${this.root}".`);
|
||||
}
|
||||
return { command: 'python', args: ['manage.py', 'runserver', '127.0.0.1:8000'], cwd: this.root };
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
if (!fileExists(this.root, ['manage.py'])) return null;
|
||||
return { command: 'python', args: ['manage.py', 'collectstatic', '--noinput'], cwd: this.root };
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
if (!fileExists(this.root, ['manage.py'])) return null;
|
||||
return { command: 'python', args: ['manage.py', 'test', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
|
||||
lintCommand(): AdapterCommand | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
return /Starting development server at (https?:\/\/\S+)/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
const req = readRequirements(this.root);
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
hasManagePy: fileExists(this.root, ['manage.py']),
|
||||
notes: 'install/dev/test + generate_*_resource stubs available.',
|
||||
hintsDjango: /django/.test(req),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rails detection stub: bundle install + bin/rails server / test.
|
||||
*/
|
||||
export class RailsAdapter implements FrameworkAdapter {
|
||||
readonly kind = 'rails' as const;
|
||||
readonly label = 'Rails';
|
||||
|
||||
constructor(readonly root: string) {}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
return { command: 'bundle', args: ['install'], cwd: this.root };
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
const binRails = path.join(this.root, 'bin', 'rails');
|
||||
if (fs.existsSync(binRails)) {
|
||||
return { command: 'ruby', args: [binRails, 'server', '-b', '127.0.0.1', '-p', '3000'], cwd: this.root };
|
||||
}
|
||||
return { command: 'rails', args: ['server', '-b', '127.0.0.1', '-p', '3000'], cwd: this.root };
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
const binRails = path.join(this.root, 'bin', 'rails');
|
||||
if (fs.existsSync(binRails)) {
|
||||
return { command: 'ruby', args: [binRails, 'assets:precompile'], cwd: this.root };
|
||||
}
|
||||
return { command: 'rails', args: ['assets:precompile'], cwd: this.root };
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
const binRails = path.join(this.root, 'bin', 'rails');
|
||||
if (fs.existsSync(binRails)) {
|
||||
return { command: 'ruby', args: [binRails, 'test', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
return { command: 'rails', args: ['test', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
|
||||
lintCommand(): AdapterCommand | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
return /Listening on (https?:\/\/\S+)|Puma starting/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
hasBinRails: fs.existsSync(path.join(this.root, 'bin', 'rails')),
|
||||
notes: 'install/dev/test + generate_*_resource stubs available.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Symfony detection stub: composer + symfony CLI / php -S patterns via composer scripts when present.
|
||||
*/
|
||||
export class SymfonyAdapter implements FrameworkAdapter {
|
||||
readonly kind = 'symfony' as const;
|
||||
readonly label = 'Symfony';
|
||||
|
||||
constructor(readonly root: string) {}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
return { command: 'composer', args: ['install'], cwd: this.root };
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
// Prefer composer script when present; otherwise PHP built-in server on public/
|
||||
const composerPath = path.join(this.root, 'composer.json');
|
||||
if (fs.existsSync(composerPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(composerPath, 'utf8')) as { scripts?: Record<string, string> };
|
||||
if (pkg.scripts?.['serve'] || pkg.scripts?.dev) {
|
||||
const script = pkg.scripts.dev ? 'dev' : 'serve';
|
||||
return { command: 'composer', args: ['run', script], cwd: this.root };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return {
|
||||
command: 'php',
|
||||
args: ['-S', '127.0.0.1:8000', '-t', 'public'],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
const phpunit = path.join(this.root, 'bin', 'phpunit');
|
||||
if (fs.existsSync(phpunit) || fs.existsSync(`${phpunit}.bat`)) {
|
||||
return { command: 'php', args: ['bin/phpunit', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
const vendor = path.join(this.root, 'vendor', 'bin', 'phpunit');
|
||||
if (fs.existsSync(vendor) || fs.existsSync(`${vendor}.bat`)) {
|
||||
return { command: 'php', args: [path.join('vendor', 'bin', 'phpunit'), ...extraArgs], cwd: this.root };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
lintCommand(): AdapterCommand | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
return /(?:Development Server|started|listening).*(https?:\/\/\S+)/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
notes: 'install/dev/test + generate_*_resource stubs available.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AdapterCommand, FrameworkAdapter } from './types.js';
|
||||
import {
|
||||
detectJsPackageManager,
|
||||
installCommandFor,
|
||||
readPackageJson,
|
||||
runScriptCommandFor,
|
||||
type JsPackageManager,
|
||||
type PackageJson,
|
||||
} from './packageManager.js';
|
||||
|
||||
/**
|
||||
* Fallback adapter for any project that isn't recognized as Laravel,
|
||||
* CodeIgniter, or React. Drives everything off `package.json` scripts when
|
||||
* one exists; otherwise tool calls fail with a clear, actionable message
|
||||
* instead of guessing.
|
||||
*/
|
||||
export class GenericAdapter implements FrameworkAdapter {
|
||||
readonly kind = 'generic' as const;
|
||||
readonly label: string;
|
||||
readonly packageManager?: JsPackageManager;
|
||||
private readonly pkg: PackageJson | null;
|
||||
|
||||
constructor(readonly root: string) {
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = this.pkg ? detectJsPackageManager(root) : undefined;
|
||||
this.label = this.pkg ? `Generic Node project (${this.packageManager})` : 'Unrecognized project';
|
||||
}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
if (!this.packageManager) {
|
||||
throw new Error(
|
||||
`No package.json found at "${this.root}" and no recognized framework detected. ` +
|
||||
`Nothing for run_install to do here.`,
|
||||
);
|
||||
}
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
return this.scriptCommand(['dev', 'start', 'serve']);
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
return this.scriptCommandOrNull(['build']);
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
return this.scriptCommandOrNull(['test'], extraArgs);
|
||||
}
|
||||
|
||||
lintCommand(fix = false): AdapterCommand | null {
|
||||
return this.scriptCommandOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
return /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[:\d]*\S*)|ready|listening/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager ?? null,
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private scriptCommand(candidates: string[]): AdapterCommand {
|
||||
const result = this.scriptCommandOrNull(candidates);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
`No package.json script found for any of [${candidates.join(', ')}] in "${this.root}". ` +
|
||||
`Add one of these scripts, or use run_script with an explicit script name.`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private scriptCommandOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
|
||||
if (!this.pkg?.scripts || !this.packageManager) return null;
|
||||
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
if (!script) return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { AdapterCommand } from './types.js';
|
||||
import { hasComposerDependency, readComposerJson } from './composer.js';
|
||||
import { PhpFrameworkAdapter } from './phpBase.js';
|
||||
|
||||
const DEV_HOST = '127.0.0.1';
|
||||
const DEV_PORT = '8000';
|
||||
|
||||
function composeFileExists(root: string): boolean {
|
||||
return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'].some((name) =>
|
||||
fs.existsSync(path.join(root, name)),
|
||||
);
|
||||
}
|
||||
|
||||
function sailScriptPath(root: string): string | null {
|
||||
const candidates = [
|
||||
path.join(root, 'vendor', 'bin', 'sail'),
|
||||
path.join(root, 'vendor', 'bin', 'sail.bat'),
|
||||
];
|
||||
return candidates.find((file) => fs.existsSync(file)) ?? null;
|
||||
}
|
||||
|
||||
/** True when laravel/sail is required and a compose file is present. */
|
||||
export function laravelUsesSail(root: string): boolean {
|
||||
return Boolean(hasComposerDependency(readComposerJson(root), 'laravel/sail') && composeFileExists(root));
|
||||
}
|
||||
|
||||
export class LaravelAdapter extends PhpFrameworkAdapter {
|
||||
readonly kind = 'laravel' as const;
|
||||
readonly label = 'Laravel';
|
||||
|
||||
private get usesSail(): boolean {
|
||||
return hasComposerDependency(this.composerJson, 'laravel/sail') && composeFileExists(this.root);
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
if (this.usesSail) {
|
||||
const sail = sailScriptPath(this.root);
|
||||
if (sail) {
|
||||
// Sail is a bash script; invoke via bash so Windows+Git Bash/WSL and Unix share one path.
|
||||
return {
|
||||
command: 'bash',
|
||||
args: [sail.replace(/\\/g, '/'), 'up'],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'docker',
|
||||
args: ['compose', 'up'],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'php',
|
||||
args: ['artisan', 'serve', `--host=${DEV_HOST}`, `--port=${DEV_PORT}`],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
if (this.usesSail) {
|
||||
return /(?:Container|Service).+(?:Started|Healthy|Running)|Listening on (https?:\/\/\S+)|APP_URL[=:].*(https?:\/\/\S+)|Local:\s+(https?:\/\/\S+)|Server running on \[?(https?:\/\/[^\s\]]+)/i;
|
||||
}
|
||||
// `php artisan serve` prints: "INFO Server running on [http://127.0.0.1:8000]."
|
||||
return /Server running on \[?(https?:\/\/[^\s\]]+)/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
const sail = this.usesSail;
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
laravelVersion: this.composerJson?.require?.['laravel/framework'] ?? null,
|
||||
usesPest: Boolean(this.composerJson?.['require-dev']?.['pestphp/pest']),
|
||||
usesSail: sail,
|
||||
sailScript: sail ? sailScriptPath(this.root) : null,
|
||||
devUrl: sail ? 'http://localhost' : `http://${DEV_HOST}:${DEV_PORT}`,
|
||||
notes: sail
|
||||
? 'Sail detected: start_dev_server uses `vendor/bin/sail up` (or docker compose up).'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type JsPackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
||||
|
||||
export function detectJsPackageManager(root: string): JsPackageManager {
|
||||
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
|
||||
if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn';
|
||||
if (fs.existsSync(path.join(root, 'bun.lockb')) || fs.existsSync(path.join(root, 'bun.lock'))) return 'bun';
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
export function installCommandFor(pm: JsPackageManager): { command: string; args: string[] } {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['install'] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: ['install'] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['install'] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['install'] };
|
||||
}
|
||||
}
|
||||
|
||||
export function runScriptCommandFor(pm: JsPackageManager, script: string, extraArgs: string[] = []): { command: string; args: string[] } {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['run', script, ...extraArgs] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: [script, ...extraArgs] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['run', script, ...extraArgs] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['run', script, ...(extraArgs.length ? ['--', ...extraArgs] : [])] };
|
||||
}
|
||||
}
|
||||
|
||||
export function addDependencyCommandFor(
|
||||
pm: JsPackageManager,
|
||||
packages: string[],
|
||||
options: { dev?: boolean; remove?: boolean },
|
||||
): { command: string; args: string[] } {
|
||||
if (options.remove) {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['remove', ...packages] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: ['remove', ...packages] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['remove', ...packages] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['uninstall', ...packages] };
|
||||
}
|
||||
}
|
||||
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['add', ...(options.dev ? ['-D'] : []), ...packages] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: ['add', ...(options.dev ? ['-D'] : []), ...packages] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['add', ...(options.dev ? ['-d'] : []), ...packages] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['install', ...(options.dev ? ['-D'] : []), ...packages] };
|
||||
}
|
||||
}
|
||||
|
||||
export interface PackageJson {
|
||||
name?: string;
|
||||
scripts?: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
workspaces?: string[] | { packages?: string[] };
|
||||
}
|
||||
|
||||
export function readPackageJson(root: string): PackageJson | null {
|
||||
const file = path.join(root, 'package.json');
|
||||
if (!fs.existsSync(file)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8')) as PackageJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasDependency(pkg: PackageJson | null, name: string): boolean {
|
||||
if (!pkg) return false;
|
||||
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import path from 'node:path';
|
||||
import type { AdapterCommand, FrameworkAdapter, FrameworkKind } from './types.js';
|
||||
import { readComposerJson, vendorBinExists, type ComposerJson } from './composer.js';
|
||||
|
||||
/**
|
||||
* Shared behaviour for Composer-based PHP frameworks (Laravel, CodeIgniter).
|
||||
* Subclasses only need to override the bits that actually differ: the CLI
|
||||
* used for scaffolding/migrations (artisan vs spark), the dev-server "ready"
|
||||
* line, and which test runner is used.
|
||||
*/
|
||||
export abstract class PhpFrameworkAdapter implements FrameworkAdapter {
|
||||
abstract readonly kind: FrameworkKind;
|
||||
abstract readonly label: string;
|
||||
readonly packageManager = 'composer';
|
||||
protected readonly composerJson: ComposerJson | null;
|
||||
|
||||
constructor(readonly root: string) {
|
||||
this.composerJson = readComposerJson(root);
|
||||
}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
return { command: 'composer', args: ['install'], cwd: this.root };
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
if (vendorBinExists(this.root, 'pest')) {
|
||||
return { command: this.vendorBin('pest'), args: extraArgs, cwd: this.root };
|
||||
}
|
||||
if (vendorBinExists(this.root, 'phpunit')) {
|
||||
return { command: this.vendorBin('phpunit'), args: extraArgs, cwd: this.root };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
lintCommand(fix = false): AdapterCommand | null {
|
||||
if (vendorBinExists(this.root, 'pint')) {
|
||||
return { command: this.vendorBin('pint'), args: fix ? [] : ['--test'], cwd: this.root };
|
||||
}
|
||||
if (vendorBinExists(this.root, 'phpcs')) {
|
||||
return { command: this.vendorBin(fix ? 'phpcbf' : 'phpcs'), args: [], cwd: this.root };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected vendorBin(bin: string): string {
|
||||
const suffix = process.platform === 'win32' ? '.bat' : '';
|
||||
return path.join(this.root, 'vendor', 'bin', `${bin}${suffix}`);
|
||||
}
|
||||
|
||||
abstract devCommand(): AdapterCommand;
|
||||
abstract readyPattern(): RegExp;
|
||||
abstract describe(): Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { AdapterCommand, FrameworkAdapter } from './types.js';
|
||||
import {
|
||||
detectJsPackageManager,
|
||||
hasDependency,
|
||||
installCommandFor,
|
||||
readPackageJson,
|
||||
runScriptCommandFor,
|
||||
type JsPackageManager,
|
||||
type PackageJson,
|
||||
} from './packageManager.js';
|
||||
|
||||
type Bundler = 'vite' | 'next' | 'create-react-app' | 'unknown';
|
||||
export type NextRouter = 'app' | 'pages' | 'unknown';
|
||||
|
||||
function detectBundler(root: string, pkg: PackageJson | null): Bundler {
|
||||
if (hasDependency(pkg, 'next') || fileExists(root, ['next.config.js', 'next.config.mjs', 'next.config.ts'])) {
|
||||
return 'next';
|
||||
}
|
||||
if (fileExists(root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'])) return 'vite';
|
||||
if (hasDependency(pkg, 'react-scripts')) return 'create-react-app';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function detectNextRouter(root: string): NextRouter {
|
||||
const appLayouts = [
|
||||
path.join(root, 'app', 'layout.tsx'),
|
||||
path.join(root, 'app', 'layout.jsx'),
|
||||
path.join(root, 'src', 'app', 'layout.tsx'),
|
||||
path.join(root, 'src', 'app', 'layout.jsx'),
|
||||
];
|
||||
if (appLayouts.some((p) => fs.existsSync(p))) return 'app';
|
||||
|
||||
const pagesIndexes = [
|
||||
path.join(root, 'pages', 'index.tsx'),
|
||||
path.join(root, 'pages', 'index.jsx'),
|
||||
path.join(root, 'pages', 'index.js'),
|
||||
path.join(root, 'src', 'pages', 'index.tsx'),
|
||||
path.join(root, 'src', 'pages', 'index.jsx'),
|
||||
];
|
||||
if (pagesIndexes.some((p) => fs.existsSync(p))) return 'pages';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function fileExists(root: string, names: string[]): boolean {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
|
||||
export class ReactAdapter implements FrameworkAdapter {
|
||||
readonly kind = 'react' as const;
|
||||
readonly label: string;
|
||||
readonly packageManager: JsPackageManager;
|
||||
private readonly pkg: PackageJson | null;
|
||||
private readonly bundler: Bundler;
|
||||
private readonly nextRouter: NextRouter;
|
||||
|
||||
constructor(readonly root: string) {
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = detectJsPackageManager(root);
|
||||
this.bundler = detectBundler(root, this.pkg);
|
||||
this.nextRouter = this.bundler === 'next' ? detectNextRouter(root) : 'unknown';
|
||||
this.label = `React (${this.bundler})`;
|
||||
}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
const script = this.findScript(['dev', 'start']);
|
||||
if (!script) {
|
||||
throw new Error(`No "dev" or "start" script found in package.json at "${this.root}".`);
|
||||
}
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
return this.scriptCommandOrNull(['build']);
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
return this.scriptCommandOrNull(['test'], extraArgs);
|
||||
}
|
||||
|
||||
lintCommand(fix = false): AdapterCommand | null {
|
||||
return this.scriptCommandOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
switch (this.bundler) {
|
||||
case 'vite':
|
||||
return /Local:\s+(https?:\/\/\S+)/i;
|
||||
case 'next':
|
||||
return /(?:Local|ready)[^:]*:\s*(https?:\/\/\S+)|started server on[^,]*,\s*url:\s*(https?:\/\/\S+)/i;
|
||||
case 'create-react-app':
|
||||
return /Local:\s+(https?:\/\/\S+)|Compiled successfully/i;
|
||||
default:
|
||||
return /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[:\d]*\S*)/i;
|
||||
}
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
const isNext = this.bundler === 'next';
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
bundler: this.bundler,
|
||||
router: isNext ? this.nextRouter : null,
|
||||
rsc: isNext && this.nextRouter === 'app',
|
||||
reactVersion: this.pkg?.dependencies?.react ?? this.pkg?.devDependencies?.react ?? null,
|
||||
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private findScript(candidates: string[]): string | undefined {
|
||||
return candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
}
|
||||
|
||||
private scriptCommandOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
|
||||
const script = this.findScript(candidates);
|
||||
if (!script) return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AdapterTarget, DetectedProject, FrameworkAdapter } from './types.js';
|
||||
|
||||
/**
|
||||
* Picks which adapter a workflow tool call should act on.
|
||||
* - If the caller passed an explicit `target`, honor it (error if that side wasn't detected).
|
||||
* - If only one side was detected, use it regardless of `target`.
|
||||
* - If both sides were detected and no `target` was given, default to backend
|
||||
* (matches the common "start the API, then the frontend separately" flow)
|
||||
* but callers that care about the frontend by default (e.g. dev server) can
|
||||
* pass `defaultTarget: 'frontend'`.
|
||||
*/
|
||||
export function resolveTarget(
|
||||
detected: DetectedProject,
|
||||
target: AdapterTarget | undefined,
|
||||
defaultTarget: AdapterTarget = 'backend',
|
||||
): { adapter: FrameworkAdapter; target: AdapterTarget | 'primary' } {
|
||||
if (target === 'backend') {
|
||||
if (!detected.backend) {
|
||||
throw new Error(
|
||||
'target "backend" was requested, but no backend (Laravel/CodeIgniter/Symfony/Django/Rails) project was detected.',
|
||||
);
|
||||
}
|
||||
return { adapter: detected.backend, target: 'backend' };
|
||||
}
|
||||
if (target === 'frontend') {
|
||||
if (!detected.frontend) {
|
||||
throw new Error(
|
||||
'target "frontend" was requested, but no frontend (React/Vue/Nuxt) project was detected.',
|
||||
);
|
||||
}
|
||||
return { adapter: detected.frontend, target: 'frontend' };
|
||||
}
|
||||
|
||||
if (detected.backend && detected.frontend) {
|
||||
const adapter = defaultTarget === 'frontend' ? detected.frontend : detected.backend;
|
||||
return { adapter, target: defaultTarget };
|
||||
}
|
||||
|
||||
return { adapter: detected.primary, target: 'primary' };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export type FrameworkKind =
|
||||
| 'laravel'
|
||||
| 'codeigniter'
|
||||
| 'react'
|
||||
| 'vue'
|
||||
| 'nuxt'
|
||||
| 'django'
|
||||
| 'rails'
|
||||
| 'symfony'
|
||||
| 'generic';
|
||||
|
||||
export interface AdapterCommand {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A FrameworkAdapter knows how to install, run, build, test, and lint a
|
||||
* specific kind of project. Generic workflow tools resolve one (or two, for
|
||||
* combined backend+frontend projects) via detect.ts and dispatch through
|
||||
* this interface instead of hardcoding any framework's CLI.
|
||||
*/
|
||||
export interface FrameworkAdapter {
|
||||
readonly kind: FrameworkKind;
|
||||
readonly label: string;
|
||||
readonly root: string;
|
||||
readonly packageManager?: string;
|
||||
|
||||
installCommand(): AdapterCommand;
|
||||
devCommand(): AdapterCommand;
|
||||
buildCommand(): AdapterCommand | null;
|
||||
testCommand(extraArgs?: string[]): AdapterCommand | null;
|
||||
lintCommand(fix?: boolean): AdapterCommand | null;
|
||||
|
||||
/** Matched against dev-server output to detect "ready" + extract the URL. */
|
||||
readyPattern(): RegExp;
|
||||
|
||||
/** Metadata surfaced via the detect_framework tool and project.json resource. */
|
||||
describe(): Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type AdapterTarget = 'backend' | 'frontend';
|
||||
|
||||
export interface DetectedProject {
|
||||
backend: FrameworkAdapter | null;
|
||||
frontend: FrameworkAdapter | null;
|
||||
/** Convenience accessor: backend if present, else frontend, else the generic fallback. */
|
||||
primary: FrameworkAdapter;
|
||||
}
|
||||
|
||||
/** One package/app inside a monorepo (or the single root project). */
|
||||
export interface DetectedPackage {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
detected: DetectedProject;
|
||||
}
|
||||
|
||||
/** Workspace-level view: root plus zero or more packages. */
|
||||
export interface DetectedWorkspace {
|
||||
root: string;
|
||||
packages: DetectedPackage[];
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { AdapterCommand, FrameworkAdapter } from './types.js';
|
||||
import {
|
||||
detectJsPackageManager,
|
||||
hasDependency,
|
||||
installCommandFor,
|
||||
readPackageJson,
|
||||
runScriptCommandFor,
|
||||
type JsPackageManager,
|
||||
type PackageJson,
|
||||
} from './packageManager.js';
|
||||
|
||||
function fileExists(root: string, names: string[]): boolean {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite + Vue SFC projects (non-Nuxt).
|
||||
*/
|
||||
export class VueAdapter implements FrameworkAdapter {
|
||||
readonly kind = 'vue' as const;
|
||||
readonly label: string;
|
||||
readonly packageManager: JsPackageManager;
|
||||
private readonly pkg: PackageJson | null;
|
||||
|
||||
constructor(readonly root: string) {
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = detectJsPackageManager(root);
|
||||
this.label = 'Vue';
|
||||
}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
return this.requireScript(['dev', 'start']);
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
return this.scriptOrNull(['build']);
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
return this.scriptOrNull(['test'], extraArgs);
|
||||
}
|
||||
|
||||
lintCommand(fix = false): AdapterCommand | null {
|
||||
return this.scriptOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
return /Local:\s+(https?:\/\/\S+)/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
vueVersion: this.pkg?.dependencies?.vue ?? this.pkg?.devDependencies?.vue ?? null,
|
||||
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
|
||||
vite: fileExists(this.root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']),
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private requireScript(candidates: string[]): AdapterCommand {
|
||||
const cmd = this.scriptOrNull(candidates);
|
||||
if (!cmd) {
|
||||
throw new Error(`No "${candidates.join('"/"')}" script found in package.json at "${this.root}".`);
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private scriptOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
|
||||
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
if (!script) return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt (Vue meta-framework) projects.
|
||||
*/
|
||||
export class NuxtAdapter implements FrameworkAdapter {
|
||||
readonly kind = 'nuxt' as const;
|
||||
readonly label = 'Nuxt';
|
||||
readonly packageManager: JsPackageManager;
|
||||
private readonly pkg: PackageJson | null;
|
||||
|
||||
constructor(readonly root: string) {
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = detectJsPackageManager(root);
|
||||
}
|
||||
|
||||
installCommand(): AdapterCommand {
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
|
||||
devCommand(): AdapterCommand {
|
||||
return this.requireScript(['dev', 'start']);
|
||||
}
|
||||
|
||||
buildCommand(): AdapterCommand | null {
|
||||
return this.scriptOrNull(['build', 'generate']);
|
||||
}
|
||||
|
||||
testCommand(extraArgs: string[] = []): AdapterCommand | null {
|
||||
return this.scriptOrNull(['test'], extraArgs);
|
||||
}
|
||||
|
||||
lintCommand(fix = false): AdapterCommand | null {
|
||||
return this.scriptOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
|
||||
readyPattern(): RegExp {
|
||||
return /Local:\s+(https?:\/\/\S+)|Nuxt\s+.+ready/i;
|
||||
}
|
||||
|
||||
describe(): Record<string, unknown> {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
nuxtVersion: this.pkg?.dependencies?.nuxt ?? this.pkg?.devDependencies?.nuxt ?? null,
|
||||
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
private requireScript(candidates: string[]): AdapterCommand {
|
||||
const cmd = this.scriptOrNull(candidates);
|
||||
if (!cmd) {
|
||||
throw new Error(`No "${candidates.join('"/"')}" script found in package.json at "${this.root}".`);
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private scriptOrNull(candidates: string[], extraArgs: string[] = []): AdapterCommand | null {
|
||||
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
if (!script) return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user