/** * scaffold-vitrine/validate.ts — Zod parse + filesystem resolution + guards. * * Resolves the web app folder (where src/main.tsx lives), enforces the SDK's * reserved-path list for public routes, and performs a NON-BLOCKING version * guard: the public-route seam (PublicRouteRegistry / PAGE_KEYS.HOME) is present * in @atlashub/smartstack >= 3.55.0; below that we warn (never refuse) — the * generated TS build will surface a clear "not exported" error if the installed * package is too old. */ import path from 'node:path'; import { existsSync, statSync, readFileSync } from 'node:fs'; import { findSmartStackStructure } from '../../../lib/detector.js'; import { findFiles } from '../../../lib/fs.js'; import { ScaffoldVitrineInputSchema, isReservedPath, type ScaffoldVitrineInput, type ProjectLayout, } from './types.js'; /** Floor where the public-route seam is verified present (@atlashub/smartstack). */ export const MIN_SMARTSTACK_VERSION = '3.55.0'; export interface ValidationResult { valid: boolean; spec: ScaffoldVitrineInput | null; layout: ProjectLayout | null; smartStackVersion: string | null; versionWarning: string | null; errors: string[]; warnings: string[]; } function parseSemver(v: string): [number, number, number] { const core = v.split('-')[0].split('+')[0]; const parts = core.split('.').map((n) => parseInt(n, 10) || 0); return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; } /** -1 if ab (major.minor.patch). */ export function cmpSemver(a: string, b: string): number { const pa = parseSemver(a); const pb = parseSemver(b); for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1; } return 0; } function fail(extra: Partial): ValidationResult { return { valid: false, spec: null, layout: null, smartStackVersion: null, versionWarning: null, errors: [], warnings: [], ...extra, }; } export async function validate(raw: unknown): Promise { const parsed = ScaffoldVitrineInputSchema.safeParse(raw); if (!parsed.success) { return fail({ errors: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) }); } const spec = parsed.data; const errors: string[] = []; const warnings: string[] = []; const root = path.resolve(spec.projectPath); if (!existsSync(root)) return fail({ spec, errors: [`projectPath does not exist: ${root}`] }); if (!statSync(root).isDirectory()) return fail({ spec, errors: [`projectPath is not a directory: ${root}`] }); // Reserved-path enforcement (mirrors PublicRouteRegistry). for (const page of spec.pages ?? []) { if (isReservedPath(page.path)) { errors.push(`pages: path "${page.path}" is reserved by the SmartStack SDK and cannot be registered.`); } } if (errors.length > 0) return fail({ spec, errors }); // Locate the web app folder (holds src/main.tsx). const structure = await findSmartStackStructure(root); let webAbs = structure.web ?? null; if (!webAbs) { const guess = path.join(root, 'web', `${spec.appCode}-web`); if (existsSync(guess)) { webAbs = guess; warnings.push(`Web folder not auto-detected; using web/${spec.appCode}-web.`); } } if (!webAbs) { const hits = await findFiles('**/src/main.tsx', { cwd: root }); if (hits.length > 0) webAbs = path.dirname(path.dirname(hits[0])); } if (!webAbs) { return fail({ spec, errors: ['Could not locate the web app (no src/main.tsx found). Run against a generated SmartStack project.'], }); } // Non-blocking version guard. let smartStackVersion: string | null = null; let versionWarning: string | null = null; const pkgPath = path.join(webAbs, 'node_modules', '@atlashub', 'smartstack', 'package.json'); if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown }; if (typeof pkg.version === 'string') { smartStackVersion = pkg.version; if (cmpSemver(pkg.version, MIN_SMARTSTACK_VERSION) < 0) { versionWarning = `Installed @atlashub/smartstack ${pkg.version} is below ${MIN_SMARTSTACK_VERSION}; the public-route seam ` + `(PublicRouteRegistry / PAGE_KEYS) may be absent — upgrade the package or the generated imports will fail the TS build.`; } } } catch { /* malformed package.json — leave version unknown */ } } const toRel = (abs: string): string => path.relative(root, abs).replace(/\\/g, '/'); const layout: ProjectLayout = { webDir: toRel(webAbs) }; return { valid: true, spec, layout, smartStackVersion, versionWarning, errors, warnings }; }