/** * lib/detector.ts — SmartStack project structure detection. * Ported from SmartStack.cli/src/mcp/lib/detector.ts — MCP logger removed, * findSmartStackStructure kept as the primary API for dev CLIs. * * Given an absolute path to a SmartStack project, this module locates: * - The Clean Architecture layers (Domain, Application, Infrastructure, Api) * - The web/frontend folder (searches common conventions) * - The EF Core migrations folder * * Configuration overrides can be declared in `.smartstack/config.json` under a * `paths` key. Auto-detection fills the gaps. */ import path from 'node:path'; import { readdir } from 'node:fs/promises'; import { directoryExists, fileExists, readText } from './fs.js'; import { findCsprojFiles, hasEfCore, findDbContextName, getTargetFramework } from './dotnet.js'; import { isGitRepo, getCurrentBranch } from './git.js'; export interface SmartStackStructure { root: string; domain?: string; application?: string; infrastructure?: string; /** Primary API (prefers *.Api.Core over *.Api) */ api?: string; /** *.Api.Core project (core controllers) if present */ apiCore?: string; /** *.Api project without Core (extension controllers) if present */ apiExtensions?: string; web?: string; migrations?: string; } export interface ProjectInfo { name: string; version: string; isGitRepo: boolean; currentBranch?: string; hasDotNet: boolean; hasEfCore: boolean; hasReact: boolean; csprojFiles: string[]; dbContextName?: string; targetFramework?: string; } /** * Search for a web/frontend project folder under the SmartStack root. * Priority order matches common conventions (never hardcoded to "smartstack-web"). */ async function findWebProjectFolder(projectPath: string): Promise { // 1) web//package.json (any subfolder of web/) const webDir = path.join(projectPath, 'web'); if (await directoryExists(webDir)) { try { const entries = await readdir(webDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const folder = path.join(webDir, entry.name); if (await fileExists(path.join(folder, 'package.json'))) return folder; } } } catch { // Unreadable — fall through } } // 2) web/package.json (monorepo style) if (await fileExists(path.join(webDir, 'package.json'))) return webDir; // 3) client/ or frontend/ or ui/ for (const folderName of ['client', 'frontend', 'ui']) { const altPath = path.join(projectPath, folderName); if (await fileExists(path.join(altPath, 'package.json'))) return altPath; } // 4) src/web/ const srcWeb = path.join(projectPath, 'src', 'web'); if (await fileExists(path.join(srcWeb, 'package.json'))) return srcWeb; return null; } /** * Detect basic metadata about a SmartStack project (git, .NET, React, version). */ export async function detectProject(projectPath: string): Promise { const info: ProjectInfo = { name: path.basename(projectPath), version: '0.0.0', isGitRepo: false, hasDotNet: false, hasEfCore: false, hasReact: false, csprojFiles: [], }; if (!(await directoryExists(projectPath))) return info; info.isGitRepo = await isGitRepo(projectPath); if (info.isGitRepo) { info.currentBranch = (await getCurrentBranch(projectPath)) || undefined; } info.csprojFiles = await findCsprojFiles(projectPath); info.hasDotNet = info.csprojFiles.length > 0; if (info.hasDotNet) { for (const csproj of info.csprojFiles) { if (await hasEfCore(csproj)) { info.hasEfCore = true; info.dbContextName = (await findDbContextName(path.dirname(csproj))) || undefined; break; } } } const webFolder = await findWebProjectFolder(projectPath); if (webFolder) { const packageJsonPath = path.join(webFolder, 'package.json'); if (await fileExists(packageJsonPath)) { try { const pkg = JSON.parse(await readText(packageJsonPath)) as { dependencies?: Record; version?: string; }; info.hasReact = !!pkg.dependencies?.react; info.version = pkg.version || info.version; } catch { // parse error — ignore } } } if (info.csprojFiles.length > 0) { const mainCsproj = info.csprojFiles.find((f) => /\.Api\.csproj$/i.test(f)) || info.csprojFiles.find((f) => /Api\.csproj$/i.test(f)) || info.csprojFiles[0]; const tf = await getTargetFramework(mainCsproj); if (tf) info.targetFramework = tf; } return info; } /** * Find the physical folders that make up a SmartStack project. * Reads optional overrides from `.smartstack/config.json` → `paths`, then * auto-detects from .csproj files to fill the gaps. */ export async function findSmartStackStructure(projectPath: string): Promise { const structure: SmartStackStructure = { root: projectPath }; // Config override const configPath = path.join(projectPath, '.smartstack', 'config.json'); if (await fileExists(configPath)) { try { const cfg = JSON.parse(await readText(configPath)) as { paths?: Partial>; }; const paths = cfg.paths; if (paths) { if (paths.api) structure.api = path.resolve(projectPath, paths.api); if (paths.domain) structure.domain = path.resolve(projectPath, paths.domain); if (paths.application) structure.application = path.resolve(projectPath, paths.application); if (paths.infrastructure) structure.infrastructure = path.resolve(projectPath, paths.infrastructure); if (paths.web) structure.web = path.resolve(projectPath, paths.web); } } catch { // parse error — fall through to auto-detect } } // Auto-detect from .csproj files const csprojFiles = await findCsprojFiles(projectPath); for (const csproj of csprojFiles) { const projectName = path.basename(csproj, '.csproj').toLowerCase(); const projectDir = path.dirname(csproj); if (!structure.domain && projectName.includes('domain')) { structure.domain = projectDir; } else if (!structure.application && projectName.includes('application')) { structure.application = projectDir; } else if (!structure.infrastructure && projectName.includes('infrastructure')) { structure.infrastructure = projectDir; } else if (projectName.includes('api.core')) { if (!structure.apiCore) structure.apiCore = projectDir; if (!structure.api) structure.api = projectDir; } else if (projectName.includes('api') && !projectName.includes('api.core')) { if (!structure.apiExtensions) structure.apiExtensions = projectDir; if (!structure.api) structure.api = projectDir; } } // Migrations folder if (structure.infrastructure) { const migrationsPath = path.join(structure.infrastructure, 'Persistence', 'Migrations'); if (await directoryExists(migrationsPath)) structure.migrations = migrationsPath; } // Web folder if (!structure.web) { const webFolder = await findWebProjectFolder(projectPath); if (webFolder) structure.web = webFolder; } return structure; } // ── Frontend deployment mode (source monorepo vs client package) ──────────── export type FrontendMode = 'source' | 'client' | 'unknown'; export interface FrontendModeInfo { /** How the SmartStack frontend is consumed in this web project. */ mode: FrontendMode; /** Version of the @atlashub/smartstack dependency (client mode only), else null. */ packageVersion: string | null; /** Human-readable signals that led to the verdict (for error messages). */ evidence: string[]; } export const SMARTSTACK_WEB_PACKAGE = '@atlashub/smartstack'; /** * Decide whether a web root is the SmartStack.app SOURCE monorepo (docs * subsystem files live in the tree: DocRoutes.tsx, i18n/config.ts, …) or a * generated CLIENT project consuming the platform as the npm package * (@atlashub/smartstack): DB-driven routing via DynamicRouter + PageRegistry, * i18n via the generated moduleResources channel. * * Consumers that write different artifacts per mode (e.g. the /documentation * scaffold-doc CLI) MUST refuse to proceed on 'unknown' rather than guess. */ export async function detectFrontendMode(webRoot: string): Promise { const evidence: string[] = []; let packageVersion: string | null = null; // 1) package.json — the authoritative signal. const pkgPath = path.join(webRoot, 'package.json'); if (await fileExists(pkgPath)) { try { const pkg = JSON.parse(await readText(pkgPath)) as { name?: string; dependencies?: Record; devDependencies?: Record; }; if (pkg.name === SMARTSTACK_WEB_PACKAGE) { evidence.push(`package.json name is ${SMARTSTACK_WEB_PACKAGE} (the source monorepo itself)`); return { mode: 'source', packageVersion: null, evidence }; } const dep = pkg.dependencies?.[SMARTSTACK_WEB_PACKAGE] ?? pkg.devDependencies?.[SMARTSTACK_WEB_PACKAGE]; if (dep) { packageVersion = dep; evidence.push(`package.json depends on ${SMARTSTACK_WEB_PACKAGE}@${dep} (client project)`); return { mode: 'client', packageVersion, evidence }; } evidence.push(`package.json is neither named ${SMARTSTACK_WEB_PACKAGE} nor depending on it`); } catch { evidence.push('package.json unreadable (JSON parse error)'); } } else { evidence.push('no package.json at the web root'); } // 2) File markers — corroboration when package.json is inconclusive. const sourceMarkers = [ path.join(webRoot, 'src', 'i18n', 'config.ts'), path.join(webRoot, 'src', 'components', 'routing', 'DocRoutes.tsx'), ]; let sourceHits = 0; for (const marker of sourceMarkers) { if (await fileExists(marker)) sourceHits++; } if (sourceHits === sourceMarkers.length) { evidence.push('source markers present (src/i18n/config.ts + src/components/routing/DocRoutes.tsx)'); return { mode: 'source', packageVersion: null, evidence }; } if (await fileExists(path.join(webRoot, 'src', 'extensions', 'componentRegistry.generated.ts'))) { evidence.push('client marker present (src/extensions/componentRegistry.generated.ts)'); return { mode: 'client', packageVersion, evidence }; } evidence.push('no conclusive marker — pass the mode explicitly'); return { mode: 'unknown', packageVersion, evidence }; } /** * Find all entity files in the domain layer (classes/records with an Id property). */ export async function findEntityFiles(domainPath: string): Promise { const { findFiles } = await import('./fs.js'); const entityFiles = await findFiles('**/*.cs', { cwd: domainPath }); const entities: string[] = []; for (const file of entityFiles) { const content = await readText(file); if ( content.match(/public\s+(?:class|record)\s+\w+/) && content.match(/public\s+(?:int|long|Guid|string)\s+Id\s*\{/) ) { entities.push(file); } } return entities; } /** Find all service interfaces in the application layer. */ export async function findServiceInterfaces(applicationPath: string): Promise { const { findFiles } = await import('./fs.js'); return findFiles('**/I*Service.cs', { cwd: applicationPath }); } /** Find all controller files in the API layer. */ export async function findControllerFiles(apiPath: string): Promise { const { findFiles } = await import('./fs.js'); return findFiles('**/*Controller.cs', { cwd: apiPath }); }