/** * lib/dotnet.ts — .NET / EF Core helpers for dev CLIs. * Ported from SmartStack.cli/src/mcp/utils/dotnet.ts. * * Used by detector.ts and any CLI that needs to reason about .csproj files, * target frameworks, EF Core migrations, or package references. */ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import path from 'node:path'; import { findFiles, readText } from './fs.js'; const execAsync = promisify(exec); export async function dotnet(command: string, cwd?: string): Promise { const options = cwd ? { cwd } : {}; const { stdout } = await execAsync(`dotnet ${command}`, options); return stdout.trim(); } export async function isDotnetAvailable(): Promise { try { await execAsync('dotnet --version'); return true; } catch { return false; } } export async function getDotnetVersion(): Promise { try { return await dotnet('--version'); } catch { return null; } } export async function findCsprojFiles(cwd?: string): Promise { return findFiles('**/*.csproj', { cwd: cwd || process.cwd() }); } export async function hasEfCore(csprojPath: string): Promise { try { const content = await readText(csprojPath); return content.includes('Microsoft.EntityFrameworkCore'); } catch { return false; } } export async function findDbContextName(projectPath: string): Promise { try { const csFiles = await findFiles('**/*.cs', { cwd: projectPath }); for (const file of csFiles) { const content = await readText(file); const match = content.match(/class\s+(\w+)\s*:\s*(?:\w+,\s*)*DbContext/); if (match) return match[1]; } return null; } catch { return null; } } export async function listMigrations( projectPath: string, context?: string, ): Promise { try { const contextArg = context ? `--context ${context}` : ''; const result = await dotnet(`ef migrations list ${contextArg}`, projectPath); return result .split('\n') .filter((line) => line && !line.startsWith('Build') && !line.includes('...')) .map((line) => line.trim()); } catch { return []; } } export async function getPendingMigrations( projectPath: string, context?: string, ): Promise { try { const contextArg = context ? `--context ${context}` : ''; const result = await dotnet(`ef migrations list ${contextArg} --pending`, projectPath); return result .split('\n') .filter((line) => line && !line.startsWith('Build') && !line.includes('...')) .map((line) => line.trim()); } catch { return []; } } export async function buildProject( projectPath: string, ): Promise<{ success: boolean; output: string }> { try { const output = await dotnet('build --no-restore', projectPath); return { success: true, output }; } catch (error) { const err = error as { stdout?: string; stderr?: string }; return { success: false, output: err.stdout || err.stderr || 'Build failed', }; } } export async function getPackageReferences( csprojPath: string, ): Promise> { try { const content = await readText(csprojPath); const packages: Array<{ name: string; version: string }> = []; const regex = / { try { const content = await readText(csprojPath); const match = content.match(/([^<]+)<\/TargetFramework>/); return match ? match[1] : null; } catch { return null; } } export function getProjectName(csprojPath: string): string { return path.basename(csprojPath, '.csproj'); } export interface NamespaceConfig { baseNamespace: string; domain: string; application: string; infrastructure: string; api: string; } /** * Detect the base namespace + per-layer namespaces from a list of .csproj files. * Recognizes the common layer suffixes (Domain, Application, Infrastructure, Api). * ["MyProject.Domain.csproj", "MyProject.Api.csproj"] → { baseNamespace: "MyProject", ... } * ["Acme.Corp.Domain.csproj", "Acme.Corp.Api.csproj"] → { baseNamespace: "Acme.Corp", ... } */ export async function detectNamespaces(csprojFiles: string[]): Promise { if (csprojFiles.length === 0) return null; const projectNames = csprojFiles.map((f) => path.basename(f, '.csproj')); const layerSuffixes = [ 'Domain', 'Application', 'Infrastructure', 'Api', 'Web', 'Core', 'Tests', ]; const baseNamespaces: string[] = []; for (const name of projectNames) { const parts = name.split('.'); for (let i = parts.length - 1; i >= 0; i--) { const part = parts[i]; if (layerSuffixes.some((s) => s.toLowerCase() === part.toLowerCase())) { const baseParts = parts.slice(0, i); if (baseParts.length > 0) baseNamespaces.push(baseParts.join('.')); break; } } } if (baseNamespaces.length === 0) { const commonPrefix = findCommonPrefix(projectNames); if (commonPrefix && commonPrefix.length > 0) { const base = commonPrefix.replace(/\.$/, ''); if (base) baseNamespaces.push(base); } } if (baseNamespaces.length === 0) return null; const baseNamespace = getMostCommon(baseNamespaces) || baseNamespaces[0]; return { baseNamespace, domain: `${baseNamespace}.Domain`, application: `${baseNamespace}.Application`, infrastructure: `${baseNamespace}.Infrastructure`, api: `${baseNamespace}.Api`, }; } function findCommonPrefix(strings: string[]): string { if (strings.length === 0) return ''; if (strings.length === 1) { const parts = strings[0].split('.'); if (parts.length > 1) return parts.slice(0, -1).join('.') + '.'; return strings[0] + '.'; } let prefix = strings[0]; for (let i = 1; i < strings.length; i++) { while (strings[i].indexOf(prefix) !== 0 && prefix.length > 0) { prefix = prefix.slice(0, -1); } } const lastDot = prefix.lastIndexOf('.'); if (lastDot > 0 && !prefix.endsWith('.')) { prefix = prefix.slice(0, lastDot + 1); } return prefix; } function getMostCommon(arr: string[]): string | null { if (arr.length === 0) return null; const counts = new Map(); for (const item of arr) counts.set(item, (counts.get(item) || 0) + 1); let maxCount = 0; let mostCommon = arr[0]; for (const [item, count] of counts) { if (count > maxCount) { maxCount = count; mostCommon = item; } } return mostCommon; }