/** * 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 { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; 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; } // ── Web root resolution ───────────────────────────────────────────────────── /** How `resolveWebRoot` settled on its answer — the evidence a caller reports. */ export type WebRootVia = 'config' | 'init-state' | 'unique' | 'smartstack-dep' | 'fallback-layout'; export interface WebRootResolution { /** `found` carries `path`; `none`/`ambiguous` carry the `candidates` that were weighed. */ status: 'found' | 'none' | 'ambiguous'; path?: string; via?: WebRootVia; /** Every `web//package.json` folder considered, absolute, sorted by name. */ candidates: string[]; } function isDirSync(p: string): boolean { try { return statSync(p).isDirectory(); } catch { return false; } } function dependsOnSmartStack(packageJsonPath: string): boolean { try { const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { dependencies?: Record; devDependencies?: Record; }; return ( pkg.dependencies?.[SMARTSTACK_WEB_PACKAGE] !== undefined || pkg.devDependencies?.[SMARTSTACK_WEB_PACKAGE] !== undefined ); } catch { return false; } } /** * Resolve THE web root of a generated SmartStack project — synchronously, so * the audit routers (whose child runner is `spawnSync`) can call it inline. * * A generated project has ONE web app for the whole PROJECT, named after the * project (`web/-web`, `src/commands/init.ts`), hosting every business * application. It is never `web/-web`: /audit-fix guessed that for a year * and every frontend/wire audit of a project whose name differs from its * application code failed on a path that does not exist (client NOTES/MEMOS, * 2026-10-03). Stray `web/*` folders exist too (`scaffold-frontend-auth` once * wrote `web/app-web/`), and `readdir` order is host-dependent — so "first * package.json wins" is not a resolution either. The order here is: * * 1. `.smartstack/config.json` → `paths.web` (the explicit override every * detector already honours), when the folder exists; * 2. `.smartstack/init-state.json` → `projectConfig.nameLower`, the name * `ss init` wrote ONCE → `web/-web/package.json`; * 3. the single `web//package.json` (sorted listing, dot-folders and * node_modules skipped); * 4. several → only those depending on `@atlashub/smartstack` (the filter * `ss upgrade` applies); exactly one left → that one; * 5. still several → `ambiguous` with the candidates (the caller says so — * never a silent pick); none → the legacy layouts (`web/package.json`, * `client|frontend|ui/`, `src/web/`), else `none`. */ export function resolveWebRoot(projectRoot: string): WebRootResolution { const root = path.resolve(projectRoot); const webDir = path.join(root, 'web'); // 1) config override try { const configPath = path.join(root, '.smartstack', 'config.json'); if (existsSync(configPath)) { const cfg = JSON.parse(readFileSync(configPath, 'utf8')) as { paths?: { web?: unknown } }; const declared = cfg.paths?.web; if (typeof declared === 'string' && declared.length > 0) { const abs = path.resolve(root, declared); if (isDirSync(abs)) return { status: 'found', path: abs, via: 'config', candidates: [abs] }; } } } catch { /* unreadable config — fall through to detection */ } // 2) init-state — the canonical project name try { const initStatePath = path.join(root, '.smartstack', 'init-state.json'); if (existsSync(initStatePath)) { const parsed = JSON.parse(readFileSync(initStatePath, 'utf8')) as { projectConfig?: { nameLower?: unknown }; }; const nameLower = parsed.projectConfig?.nameLower; if (typeof nameLower === 'string' && nameLower.length > 0) { const abs = path.join(webDir, `${nameLower}-web`); if (existsSync(path.join(abs, 'package.json'))) { return { status: 'found', path: abs, via: 'init-state', candidates: [abs] }; } } } } catch { /* unreadable init-state — fall through to detection */ } // 3) web//package.json — sorted, deterministic on every host const candidates: string[] = []; if (isDirSync(webDir)) { let entries: string[] = []; try { entries = readdirSync(webDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') .map((e) => e.name) .sort(); } catch { entries = []; } for (const name of entries) { const folder = path.join(webDir, name); if (existsSync(path.join(folder, 'package.json'))) candidates.push(folder); } } if (candidates.length === 1) return { status: 'found', path: candidates[0]!, via: 'unique', candidates }; // 4) several — keep the SmartStack client apps if (candidates.length > 1) { const clients = candidates.filter((c) => dependsOnSmartStack(path.join(c, 'package.json'))); if (clients.length === 1) return { status: 'found', path: clients[0]!, via: 'smartstack-dep', candidates }; return { status: 'ambiguous', candidates }; } // 5) legacy layouts const fallbacks = [webDir, path.join(root, 'client'), path.join(root, 'frontend'), path.join(root, 'ui'), path.join(root, 'src', 'web')]; for (const folder of fallbacks) { if (existsSync(path.join(folder, 'package.json'))) { return { status: 'found', path: folder, via: 'fallback-layout', candidates: [folder] }; } } return { status: 'none', candidates }; } /** * Search for a web/frontend project folder under the SmartStack root. * Thin wrapper over `resolveWebRoot` — kept async for the historical callers * (`findSmartStackStructure`). On an AMBIGUOUS layout it keeps its historical * lenient answer (the first candidate, now in sorted order); a caller that * must not guess reads `resolveWebRoot` directly. */ async function findWebProjectFolder(projectPath: string): Promise { const r = resolveWebRoot(projectPath); if (r.status === 'found') return r.path ?? null; return r.candidates[0] ?? 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 }); }