@tryloop/oats
Version:
š¾ OATS - OpenAPI TypeScript Sync. The missing link between your OpenAPI specs and TypeScript applications. Automatically watch, generate, and sync TypeScript clients from your API definitions.
578 lines ⢠19.4 kB
JavaScript
/**
* OATS Detect Command
*
* Auto-detects project structure and generates configuration
*
* @module @oatsjs/cli/detect
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, relative } from 'path';
import chalk from 'chalk';
import { glob } from 'glob';
import ora from 'ora';
import { validateConfig } from '../config/schema.js';
/**
* Detect project structure and create configuration
*/
export async function detect(options) {
console.log(chalk.yellow('\nš Auto-detecting project structure...\n'));
const outputPath = join(process.cwd(), options.output);
// Check if config already exists
if (existsSync(outputPath) && !options.force) {
console.error(chalk.red(`Configuration already exists at ${outputPath}`), chalk.dim('\nUse --force to overwrite'));
process.exit(1);
}
const spinner = ora('Scanning directories...').start();
try {
const structure = await detectProjectStructure(process.cwd());
spinner.succeed('Project structure detected!');
// Display detected structure
displayDetectedStructure(structure);
// Generate configuration
const config = generateConfigFromStructure(structure);
// Validate configuration
const validationSpinner = ora('Validating configuration...').start();
const validation = validateConfig(config);
if (!validation.valid) {
validationSpinner.fail('Generated configuration is invalid');
console.error(chalk.red('\nValidation errors:'));
validation.errors.forEach((error) => {
console.error(chalk.red(` - ${error.path}: ${error.message}`));
});
process.exit(1);
}
validationSpinner.succeed('Configuration validated');
// Write configuration
const writeSpinner = ora('Writing configuration...').start();
const configContent = JSON.stringify(config, null, 2);
writeFileSync(outputPath, configContent);
writeSpinner.succeed(`Configuration saved to ${outputPath}`);
console.log(); // Empty line
ora().succeed('Detection complete!');
console.log(`\n${chalk.bold('Next steps:')}`);
console.log(chalk.cyan(' 1. Review the configuration:'));
console.log(chalk.dim(` cat ${outputPath}`));
console.log(chalk.cyan(' 2. Start watching:'));
console.log(chalk.dim(' oats start'));
}
catch (error) {
spinner.fail('Detection failed');
console.error(chalk.red(error instanceof Error ? error.message : String(error)));
process.exit(1);
}
}
/**
* Detect project structure
*/
export async function detectProjectStructure(rootPath) {
const structure = {};
// Check if it's a monorepo
structure.monorepo = await isMonorepo(rootPath);
structure.rootPackageManager = await detectPackageManager(rootPath);
// Search patterns
const searchPaths = structure.monorepo
? ['packages/*', 'apps/*', 'services/*', '.']
: ['../*', '.'];
// Find backend
for (const pattern of searchPaths) {
const backend = await findBackend(rootPath, pattern);
if (backend) {
structure.backend = backend;
break;
}
}
// Find client
for (const pattern of searchPaths) {
const client = await findClient(rootPath, pattern);
if (client) {
structure.client = client;
break;
}
}
// Find frontend
for (const pattern of searchPaths) {
const frontend = await findFrontend(rootPath, pattern);
if (frontend) {
structure.frontend = frontend;
break;
}
}
return structure;
}
/**
* Check if directory is a monorepo
*/
async function isMonorepo(path) {
// Check for common monorepo files
const monorepoFiles = [
'lerna.json',
'nx.json',
'rush.json',
'pnpm-workspace.yaml',
'yarn.lock', // with workspaces in package.json
];
for (const file of monorepoFiles) {
if (existsSync(join(path, file))) {
return true;
}
}
// Check for workspaces in package.json
const packageJsonPath = join(path, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
if (packageJson.workspaces) {
return true;
}
}
catch {
// Ignore parse errors
}
}
return false;
}
/**
* Detect package manager
*/
async function detectPackageManager(path) {
if (existsSync(join(path, 'yarn.lock'))) {
return 'yarn';
}
if (existsSync(join(path, 'pnpm-lock.yaml'))) {
return 'pnpm';
}
return 'npm';
}
/**
* Find backend service
*/
async function findBackend(rootPath, pattern) {
const dirs = await glob(pattern, { cwd: rootPath, absolute: true });
for (const dir of dirs) {
// Check for Python backend first
const pythonBackend = await findPythonBackend(dir, rootPath);
if (pythonBackend) {
return pythonBackend;
}
// Check for Node.js backend
if (!existsSync(join(dir, 'package.json')))
continue;
const packageJson = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
// Check for backend frameworks
const frameworks = {
express: 'Express',
fastify: 'Fastify',
'@nestjs/core': 'NestJS',
koa: 'Koa',
'@hapi/hapi': 'Hapi',
restify: 'Restify',
};
for (const [dep, framework] of Object.entries(frameworks)) {
if (deps[dep]) {
// Found backend
const apiSpec = await findApiSpec(dir);
const packageManager = await detectPackageManager(dir);
return {
path: relative(rootPath, dir),
type: 'backend',
framework,
runtime: 'node',
packageManager,
apiSpec,
port: detectPort(packageJson),
};
}
}
}
return null;
}
/**
* Find TypeScript client
*/
async function findClient(rootPath, pattern) {
const dirs = await glob(pattern, { cwd: rootPath, absolute: true });
for (const dir of dirs) {
if (!existsSync(join(dir, 'package.json')))
continue;
const packageJson = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
// Check for client indicators
const clientIndicators = [
packageJson.name?.includes('client'),
packageJson.name?.includes('api'),
packageJson.name?.includes('sdk'),
existsSync(join(dir, 'openapi-ts.config.ts')),
existsSync(join(dir, 'swagger.json')),
existsSync(join(dir, 'openapi.json')),
packageJson.scripts?.generate,
packageJson.scripts?.codegen,
];
if (clientIndicators.filter(Boolean).length >= 2) {
const packageManager = await detectPackageManager(dir);
return {
path: relative(rootPath, dir),
type: 'client',
packageManager,
packageName: packageJson.name,
};
}
}
return null;
}
/**
* Find frontend service
*/
async function findFrontend(rootPath, pattern) {
const dirs = await glob(pattern, { cwd: rootPath, absolute: true });
for (const dir of dirs) {
if (!existsSync(join(dir, 'package.json')))
continue;
const packageJson = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
// Check for frontend frameworks
const frameworks = {
react: 'React',
vue: 'Vue',
'@angular/core': 'Angular',
svelte: 'Svelte',
next: 'Next.js',
nuxt: 'Nuxt',
'@remix-run/react': 'Remix',
};
for (const [dep, framework] of Object.entries(frameworks)) {
if (deps[dep]) {
const packageManager = await detectPackageManager(dir);
return {
path: relative(rootPath, dir),
type: 'frontend',
framework,
packageManager,
port: detectPort(packageJson),
};
}
}
}
return null;
}
/**
* Find API specification file
*/
async function findApiSpec(dir) {
const patterns = [
'swagger.json',
'openapi.json',
'swagger.yaml',
'openapi.yaml',
'src/swagger.json',
'src/openapi.json',
'dist/swagger.json',
'dist/openapi.json',
'docs/swagger.json',
'docs/openapi.json',
'src/public/tsoa/swagger.json',
];
for (const pattern of patterns) {
const files = await glob(pattern, { cwd: dir });
if (files.length > 0) {
return files[0];
}
}
return undefined;
}
/**
* Detect port from package.json scripts
*/
function detectPort(packageJson) {
const scripts = packageJson.scripts || {};
const scriptValues = Object.values(scripts).join(' ');
// Look for port patterns
const portMatch = scriptValues.match(/(?:--port|PORT=|-p\s+)(\d{4,5})/);
if (portMatch && portMatch[1]) {
return parseInt(portMatch[1], 10);
}
return undefined;
}
/**
* Display detected structure
*/
function displayDetectedStructure(structure) {
console.log(`\n${chalk.bold('Detected structure:')}`);
if (structure.monorepo) {
console.log(chalk.cyan(' š¦ Monorepo') +
chalk.dim(` (${structure.rootPackageManager})`));
}
if (structure.backend) {
console.log(chalk.cyan(' š„ļø Backend:'), chalk.green(structure.backend.path), chalk.dim(`(${structure.backend.framework}${structure.backend.runtime === 'python' ? ' - Python' : ''})`));
if (structure.backend.apiSpec) {
console.log(chalk.dim(` API Spec: ${structure.backend.apiSpec}`));
}
if (structure.backend.runtime === 'python' &&
structure.backend.virtualEnv) {
console.log(chalk.dim(` Virtual Env: ${structure.backend.virtualEnv}`));
}
}
if (structure.client) {
console.log(chalk.cyan(' š” Client:'), chalk.green(structure.client.path), chalk.dim(`(${structure.client.packageName})`));
}
if (structure.frontend) {
console.log(chalk.cyan(' šØ Frontend:'), chalk.green(structure.frontend.path), chalk.dim(`(${structure.frontend.framework})`));
}
if (!structure.backend && !structure.client && !structure.frontend) {
console.log(chalk.yellow(' ā ļø No services detected'));
}
}
/**
* Find Python backend
*/
async function findPythonBackend(dir, rootPath) {
// Check for Python project indicators
const pythonFiles = [
'requirements.txt',
'pyproject.toml',
'Pipfile',
'setup.py',
'manage.py', // Django
];
const hasPythonProject = pythonFiles.some((file) => existsSync(join(dir, file)));
if (!hasPythonProject)
return null;
// Detect Python framework
const framework = await detectPythonFramework(dir);
if (!framework)
return null;
// Detect Python package manager
const pythonPackageManager = await detectPythonPackageManager(dir);
// Detect virtual environment
const virtualEnv = await detectVirtualEnv(dir);
// Find API spec
const apiSpec = await findPythonApiSpec(dir);
// Detect port
const port = await detectPythonPort(dir, framework);
return {
path: relative(rootPath, dir),
type: 'backend',
framework,
runtime: 'python',
pythonPackageManager,
virtualEnv,
apiSpec,
port,
};
}
/**
* Detect Python framework
*/
async function detectPythonFramework(dir) {
// Check requirements.txt
if (existsSync(join(dir, 'requirements.txt'))) {
const requirements = readFileSync(join(dir, 'requirements.txt'), 'utf-8').toLowerCase();
if (requirements.includes('fastapi'))
return 'FastAPI';
if (requirements.includes('flask'))
return 'Flask';
if (requirements.includes('django'))
return 'Django';
}
// Check pyproject.toml
if (existsSync(join(dir, 'pyproject.toml'))) {
const pyproject = readFileSync(join(dir, 'pyproject.toml'), 'utf-8');
if (pyproject.includes('fastapi'))
return 'FastAPI';
if (pyproject.includes('flask'))
return 'Flask';
if (pyproject.includes('django'))
return 'Django';
}
// Check Pipfile
if (existsSync(join(dir, 'Pipfile'))) {
const pipfile = readFileSync(join(dir, 'Pipfile'), 'utf-8');
if (pipfile.includes('fastapi'))
return 'FastAPI';
if (pipfile.includes('flask'))
return 'Flask';
if (pipfile.includes('django'))
return 'Django';
}
// Check for framework-specific files
if (existsSync(join(dir, 'manage.py')))
return 'Django';
if (existsSync(join(dir, 'main.py')) || existsSync(join(dir, 'app.py'))) {
// Could be FastAPI or Flask, default to FastAPI for modern apps
return 'FastAPI';
}
return null;
}
/**
* Detect Python package manager
*/
async function detectPythonPackageManager(dir) {
if (existsSync(join(dir, 'poetry.lock')) ||
existsSync(join(dir, 'pyproject.toml'))) {
const hasPoetry = existsSync(join(dir, 'pyproject.toml')) &&
readFileSync(join(dir, 'pyproject.toml'), 'utf-8').includes('[tool.poetry]');
if (hasPoetry)
return 'poetry';
}
if (existsSync(join(dir, 'Pipfile.lock')))
return 'pipenv';
return 'pip';
}
/**
* Detect virtual environment
*/
async function detectVirtualEnv(dir) {
const venvDirs = ['venv', '.venv', 'env', '.env', 'virtualenv'];
for (const venvDir of venvDirs) {
if (existsSync(join(dir, venvDir))) {
return venvDir;
}
}
return undefined;
}
/**
* Find Python API spec
*/
async function findPythonApiSpec(dir) {
const patterns = [
'openapi.json',
'swagger.json',
'docs/openapi.json',
'docs/swagger.json',
'static/openapi.json',
'static/swagger.json',
'app/openapi.json',
'api/openapi.json',
];
for (const pattern of patterns) {
if (existsSync(join(dir, pattern))) {
return pattern;
}
}
// For FastAPI, the spec is usually generated at runtime
// We'll use a special marker
if ((await detectPythonFramework(dir)) === 'FastAPI') {
return 'runtime:/docs/openapi.json';
}
return undefined;
}
/**
* Detect Python backend port
*/
async function detectPythonPort(dir, framework) {
// Check common files for port configuration
const filesToCheck = [
'main.py',
'app.py',
'run.py',
'server.py',
'.env',
'config.py',
'settings.py',
];
for (const file of filesToCheck) {
if (existsSync(join(dir, file))) {
const content = readFileSync(join(dir, file), 'utf-8');
// Look for port patterns
const portPatterns = [
/port\s*=\s*(\d{4,5})/i,
/PORT\s*=\s*(\d{4,5})/,
/\.run\([^)]*port\s*=\s*(\d{4,5})/,
/uvicorn\.run\([^)]*port\s*=\s*(\d{4,5})/,
];
for (const pattern of portPatterns) {
const match = content.match(pattern);
if (match && match[1]) {
return parseInt(match[1], 10);
}
}
}
}
// Default ports by framework
switch (framework) {
case 'FastAPI':
return 8000;
case 'Flask':
return 5000;
case 'Django':
return 8000;
default:
return 8000;
}
}
/**
* Generate configuration from detected structure
*/
function generateConfigFromStructure(structure) {
if (!structure.backend || !structure.client) {
throw new Error('Could not detect required services. Please ensure you have both a backend and client project.');
}
const config = {
services: {
backend: {
path: structure.backend.path,
port: structure.backend.port ||
(structure.backend.runtime === 'python' ? 8000 : 4000),
startCommand: getStartCommand(structure.backend),
apiSpec: {
path: structure.backend.apiSpec ||
(structure.backend.runtime === 'python'
? 'docs/openapi.json'
: 'src/swagger.json'),
},
},
client: {
path: structure.client.path,
packageName: structure.client.packageName || '@myorg/api-client',
generator: 'custom',
generateCommand: structure.client.packageManager === 'yarn'
? 'yarn generate'
: 'npm run generate',
buildCommand: structure.client.packageManager === 'yarn'
? 'yarn build'
: 'npm run build',
linkCommand: structure.client.packageManager === 'yarn' ? 'yarn link' : 'npm link',
},
},
};
// Add frontend if detected
if (structure.frontend) {
config.services.frontend = {
path: structure.frontend.path,
port: structure.frontend.port || 3000,
startCommand: structure.frontend.packageManager === 'yarn'
? 'yarn dev'
: 'npm run dev',
packageLinkCommand: structure.frontend.packageManager === 'yarn' ? 'yarn link' : 'npm link',
};
}
return config;
}
/**
* Get start command based on service details
*/
function getStartCommand(service) {
if (service.runtime === 'python') {
const activateCmd = service.virtualEnv
? `source ${service.virtualEnv}/bin/activate && `
: '';
switch (service.framework) {
case 'FastAPI':
return `${activateCmd}uvicorn main:app --reload --port ${service.port || 8000}`;
case 'Flask':
return `${activateCmd}flask run --port ${service.port || 5000}`;
case 'Django':
return `${activateCmd}python manage.py runserver ${service.port || 8000}`;
default:
return `${activateCmd}python app.py`;
}
}
// Node.js backends
return service.packageManager === 'yarn' ? 'yarn dev' : 'npm run dev';
}
//# sourceMappingURL=detect.js.map