462 lines
13 KiB
Python
462 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Framework Detection Utility for Eureka Deployments.
|
|
|
|
Detects project framework from package.json, requirements.txt, go.mod, or Cargo.toml
|
|
and returns appropriate Dockerfile template information.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List
|
|
|
|
|
|
# Framework definitions with Dockerfile requirements
|
|
FRAMEWORKS: Dict[str, Dict[str, Any]] = {
|
|
# Full-stack frameworks
|
|
'nextjs-prisma': {
|
|
'name': 'Next.js + Prisma',
|
|
'base_image': 'node:20-alpine3.18',
|
|
'port': 3000,
|
|
'requirements': ['output: standalone in next.config.js', 'openssl1.1-compat'],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node server.js',
|
|
},
|
|
'nextjs': {
|
|
'name': 'Next.js',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': ['output: standalone in next.config.js'],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node server.js',
|
|
},
|
|
'nuxt': {
|
|
'name': 'Nuxt.js 3',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': [],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node .output/server/index.mjs',
|
|
},
|
|
'remix': {
|
|
'name': 'Remix',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': [],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'npm start',
|
|
},
|
|
'sveltekit': {
|
|
'name': 'SvelteKit',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': [],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node build',
|
|
},
|
|
|
|
# Frontend frameworks (SPA)
|
|
'react-vite': {
|
|
'name': 'React (Vite)',
|
|
'base_image': 'nginx:alpine',
|
|
'port': 80,
|
|
'requirements': ['nginx.conf for SPA routing'],
|
|
'build_cmd': 'npm run build',
|
|
'build_output': 'dist',
|
|
'spa': True,
|
|
},
|
|
'react-cra': {
|
|
'name': 'React (Create React App)',
|
|
'base_image': 'nginx:alpine',
|
|
'port': 80,
|
|
'requirements': ['nginx.conf for SPA routing'],
|
|
'build_cmd': 'npm run build',
|
|
'build_output': 'build',
|
|
'spa': True,
|
|
},
|
|
'vue-vite': {
|
|
'name': 'Vue.js (Vite)',
|
|
'base_image': 'nginx:alpine',
|
|
'port': 80,
|
|
'requirements': ['nginx.conf for SPA routing'],
|
|
'build_cmd': 'npm run build',
|
|
'build_output': 'dist',
|
|
'spa': True,
|
|
},
|
|
'angular': {
|
|
'name': 'Angular',
|
|
'base_image': 'nginx:alpine',
|
|
'port': 80,
|
|
'requirements': ['nginx.conf for SPA routing'],
|
|
'build_cmd': 'npm run build --prod',
|
|
'build_output': 'dist/*/browser',
|
|
'spa': True,
|
|
},
|
|
|
|
# Backend frameworks
|
|
'nestjs-prisma': {
|
|
'name': 'NestJS + Prisma',
|
|
'base_image': 'node:20-alpine3.18',
|
|
'port': 3000,
|
|
'requirements': ['openssl1.1-compat'],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node dist/main.js',
|
|
},
|
|
'nestjs': {
|
|
'name': 'NestJS',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': [],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node dist/main.js',
|
|
},
|
|
'fastify-prisma': {
|
|
'name': 'Fastify + Prisma',
|
|
'base_image': 'node:20-alpine3.18',
|
|
'port': 3000,
|
|
'requirements': ['openssl1.1-compat'],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node dist/index.js',
|
|
},
|
|
'fastify': {
|
|
'name': 'Fastify',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': [],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node dist/index.js',
|
|
},
|
|
'express-prisma': {
|
|
'name': 'Express + Prisma',
|
|
'base_image': 'node:20-alpine3.18',
|
|
'port': 3000,
|
|
'requirements': ['openssl1.1-compat'],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node dist/index.js',
|
|
},
|
|
'express': {
|
|
'name': 'Express.js',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': [],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node dist/index.js',
|
|
},
|
|
'hono': {
|
|
'name': 'Hono',
|
|
'base_image': 'node:20-alpine',
|
|
'port': 3000,
|
|
'requirements': [],
|
|
'build_cmd': 'npm run build',
|
|
'start_cmd': 'node dist/index.js',
|
|
},
|
|
|
|
# Python frameworks
|
|
'fastapi': {
|
|
'name': 'FastAPI',
|
|
'base_image': 'python:3.12-slim',
|
|
'port': 8000,
|
|
'requirements': ['uvicorn'],
|
|
'start_cmd': 'uvicorn main:app --host 0.0.0.0 --port 8000',
|
|
},
|
|
'flask': {
|
|
'name': 'Flask',
|
|
'base_image': 'python:3.12-slim',
|
|
'port': 5000,
|
|
'requirements': ['gunicorn'],
|
|
'start_cmd': 'gunicorn --bind 0.0.0.0:5000 app:app',
|
|
},
|
|
'django': {
|
|
'name': 'Django',
|
|
'base_image': 'python:3.12-slim',
|
|
'port': 8000,
|
|
'requirements': ['gunicorn', 'collectstatic'],
|
|
'start_cmd': 'gunicorn --bind 0.0.0.0:8000 project.wsgi:application',
|
|
},
|
|
|
|
# Other languages
|
|
'go': {
|
|
'name': 'Go',
|
|
'base_image': 'golang:1.22-alpine',
|
|
'runtime_image': 'alpine:latest',
|
|
'port': 8080,
|
|
'requirements': ['CGO_ENABLED=0 for static binary'],
|
|
'build_cmd': 'go build -o main .',
|
|
'start_cmd': './main',
|
|
},
|
|
'rust': {
|
|
'name': 'Rust',
|
|
'base_image': 'rust:1.75-alpine',
|
|
'runtime_image': 'alpine:latest',
|
|
'port': 8080,
|
|
'requirements': ['musl-dev for static linking'],
|
|
'build_cmd': 'cargo build --release',
|
|
'start_cmd': './app',
|
|
},
|
|
}
|
|
|
|
|
|
def has_prisma(project_path: Path, deps: Dict[str, str]) -> bool:
|
|
"""Check if project uses Prisma ORM."""
|
|
# Check for prisma in dependencies
|
|
if 'prisma' in deps or '@prisma/client' in deps:
|
|
return True
|
|
# Check for prisma directory
|
|
if (project_path / 'prisma').exists():
|
|
return True
|
|
return False
|
|
|
|
|
|
def detect_node_framework(project_path: Path) -> Optional[str]:
|
|
"""Detect Node.js framework from package.json."""
|
|
package_json_path = project_path / 'package.json'
|
|
if not package_json_path.exists():
|
|
return None
|
|
|
|
try:
|
|
with open(package_json_path) as f:
|
|
pkg = json.load(f)
|
|
except (json.JSONDecodeError, IOError):
|
|
return None
|
|
|
|
deps = {**pkg.get('dependencies', {}), **pkg.get('devDependencies', {})}
|
|
uses_prisma = has_prisma(project_path, deps)
|
|
|
|
# Full-stack frameworks (order matters - check specific ones first)
|
|
if 'next' in deps:
|
|
return 'nextjs-prisma' if uses_prisma else 'nextjs'
|
|
if 'nuxt' in deps:
|
|
return 'nuxt'
|
|
if '@remix-run/node' in deps or '@remix-run/react' in deps:
|
|
return 'remix'
|
|
if '@sveltejs/kit' in deps:
|
|
return 'sveltekit'
|
|
|
|
# Frontend frameworks (SPA)
|
|
if 'vite' in deps and 'react' in deps:
|
|
return 'react-vite'
|
|
if 'react-scripts' in deps:
|
|
return 'react-cra'
|
|
if 'vite' in deps and 'vue' in deps:
|
|
return 'vue-vite'
|
|
if '@angular/core' in deps:
|
|
return 'angular'
|
|
|
|
# Backend frameworks
|
|
if '@nestjs/core' in deps:
|
|
return 'nestjs-prisma' if uses_prisma else 'nestjs'
|
|
if 'fastify' in deps:
|
|
return 'fastify-prisma' if uses_prisma else 'fastify'
|
|
if 'express' in deps:
|
|
return 'express-prisma' if uses_prisma else 'express'
|
|
if 'hono' in deps:
|
|
return 'hono'
|
|
|
|
return None
|
|
|
|
|
|
def detect_python_framework(project_path: Path) -> Optional[str]:
|
|
"""Detect Python framework from requirements.txt or pyproject.toml."""
|
|
requirements_path = project_path / 'requirements.txt'
|
|
pyproject_path = project_path / 'pyproject.toml'
|
|
|
|
requirements_content = ''
|
|
|
|
if requirements_path.exists():
|
|
try:
|
|
requirements_content = requirements_path.read_text().lower()
|
|
except IOError:
|
|
pass
|
|
|
|
if pyproject_path.exists():
|
|
try:
|
|
requirements_content += pyproject_path.read_text().lower()
|
|
except IOError:
|
|
pass
|
|
|
|
if not requirements_content:
|
|
return None
|
|
|
|
if 'fastapi' in requirements_content:
|
|
return 'fastapi'
|
|
if 'flask' in requirements_content:
|
|
return 'flask'
|
|
if 'django' in requirements_content:
|
|
return 'django'
|
|
|
|
return None
|
|
|
|
|
|
def detect_go_project(project_path: Path) -> Optional[str]:
|
|
"""Detect Go project from go.mod."""
|
|
if (project_path / 'go.mod').exists():
|
|
return 'go'
|
|
return None
|
|
|
|
|
|
def detect_rust_project(project_path: Path) -> Optional[str]:
|
|
"""Detect Rust project from Cargo.toml."""
|
|
if (project_path / 'Cargo.toml').exists():
|
|
return 'rust'
|
|
return None
|
|
|
|
|
|
def detect_framework(project_path: Path) -> Dict[str, Any]:
|
|
"""
|
|
Detect project framework and return Dockerfile configuration.
|
|
|
|
Args:
|
|
project_path: Path to the project root
|
|
|
|
Returns:
|
|
Dictionary with framework info and Dockerfile requirements
|
|
"""
|
|
project_path = Path(project_path).resolve()
|
|
|
|
# Try detection in order
|
|
framework_id = (
|
|
detect_node_framework(project_path) or
|
|
detect_python_framework(project_path) or
|
|
detect_go_project(project_path) or
|
|
detect_rust_project(project_path)
|
|
)
|
|
|
|
if not framework_id:
|
|
return {
|
|
'detected': False,
|
|
'framework_id': 'unknown',
|
|
'name': 'Unknown',
|
|
'error': 'Could not detect framework. Check package.json, requirements.txt, go.mod, or Cargo.toml',
|
|
}
|
|
|
|
framework = FRAMEWORKS[framework_id].copy()
|
|
framework['detected'] = True
|
|
framework['framework_id'] = framework_id
|
|
|
|
# Add validation checks
|
|
checks: List[Dict[str, Any]] = []
|
|
|
|
# Check Next.js standalone config
|
|
if framework_id in ['nextjs', 'nextjs-prisma']:
|
|
next_config = project_path / 'next.config.js'
|
|
next_config_mjs = project_path / 'next.config.mjs'
|
|
next_config_ts = project_path / 'next.config.ts'
|
|
|
|
config_found = False
|
|
standalone_configured = False
|
|
|
|
for config_path in [next_config, next_config_mjs, next_config_ts]:
|
|
if config_path.exists():
|
|
config_found = True
|
|
try:
|
|
content = config_path.read_text()
|
|
if 'standalone' in content:
|
|
standalone_configured = True
|
|
except IOError:
|
|
pass
|
|
|
|
checks.append({
|
|
'name': 'Next.js standalone output',
|
|
'passed': standalone_configured,
|
|
'message': 'Add output: "standalone" to next.config.js' if not standalone_configured else 'OK',
|
|
'critical': True,
|
|
})
|
|
|
|
# Check nginx.conf for SPAs
|
|
if framework.get('spa'):
|
|
nginx_conf = project_path / 'nginx.conf'
|
|
checks.append({
|
|
'name': 'nginx.conf exists',
|
|
'passed': nginx_conf.exists(),
|
|
'message': 'Create nginx.conf for SPA routing' if not nginx_conf.exists() else 'OK',
|
|
'critical': False, # Can be generated
|
|
})
|
|
|
|
# Check Dockerfile exists
|
|
dockerfile = project_path / 'Dockerfile'
|
|
checks.append({
|
|
'name': 'Dockerfile exists',
|
|
'passed': dockerfile.exists(),
|
|
'message': 'Dockerfile will be generated' if not dockerfile.exists() else 'OK',
|
|
'critical': False,
|
|
})
|
|
|
|
framework['checks'] = checks
|
|
framework['all_checks_passed'] = all(c['passed'] or not c['critical'] for c in checks)
|
|
|
|
return framework
|
|
|
|
|
|
def format_output(result: Dict[str, Any], format_type: str = 'json') -> str:
|
|
"""Format the detection result for output."""
|
|
if format_type == 'json':
|
|
return json.dumps(result, indent=2)
|
|
|
|
if format_type == 'summary':
|
|
lines = []
|
|
if result['detected']:
|
|
lines.append(f"Framework: {result['name']}")
|
|
lines.append(f"Base Image: {result['base_image']}")
|
|
lines.append(f"Port: {result['port']}")
|
|
|
|
if result.get('requirements'):
|
|
lines.append(f"Requirements: {', '.join(result['requirements'])}")
|
|
|
|
if result.get('checks'):
|
|
lines.append("\nValidation Checks:")
|
|
for check in result['checks']:
|
|
status = "✅" if check['passed'] else "❌"
|
|
lines.append(f" {status} {check['name']}: {check['message']}")
|
|
else:
|
|
lines.append(f"❌ {result['error']}")
|
|
|
|
return '\n'.join(lines)
|
|
|
|
return str(result)
|
|
|
|
|
|
def main():
|
|
"""CLI entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description='Detect project framework for Eureka deployment'
|
|
)
|
|
parser.add_argument(
|
|
'path',
|
|
nargs='?',
|
|
default='.',
|
|
help='Project path (default: current directory)'
|
|
)
|
|
parser.add_argument(
|
|
'--format',
|
|
choices=['json', 'summary'],
|
|
default='json',
|
|
help='Output format (default: json)'
|
|
)
|
|
parser.add_argument(
|
|
'--check',
|
|
action='store_true',
|
|
help='Exit with error code if critical checks fail'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
project_path = Path(args.path)
|
|
if not project_path.exists():
|
|
print(f"Error: Path not found: {project_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
result = detect_framework(project_path)
|
|
print(format_output(result, args.format))
|
|
|
|
if args.check and result.get('detected') and not result.get('all_checks_passed'):
|
|
sys.exit(1)
|
|
elif not result.get('detected'):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|