/** * configure-login/validate.ts — Zod parse + filesystem resolution. * * Beyond schema validation this resolves the project layout (where appsettings * lives, where the web app lives) via the shared detector, and enforces the * secrets-safety rule: in 'local-file' mode, if .gitignore does NOT exclude * appsettings.Local.json, downgrade to 'placeholders' (never risk committing a * secret) and warn loudly. */ 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 { ConfigureLoginInputSchema, type ConfigureLoginInput, type ProjectLayout } from './types.js'; export interface ValidationResult { valid: boolean; spec: ConfigureLoginInput | null; layout: ProjectLayout | null; errors: string[]; warnings: string[]; } function gitignoreExcludesLocal(projectRoot: string): boolean { const gi = path.join(projectRoot, '.gitignore'); if (!existsSync(gi)) return false; try { const text = readFileSync(gi, 'utf-8'); // Matches `appsettings.Local.json`, `appsettings.*.json`, `**/appsettings.Local.json`, etc. return /appsettings\.(\*|local)/i.test(text); } catch { return false; } } export async function validate(raw: unknown): Promise { const parsed = ConfigureLoginInputSchema.safeParse(raw); if (!parsed.success) { return { valid: false, spec: null, layout: null, errors: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), warnings: [], }; } const spec = parsed.data; const errors: string[] = []; const warnings: string[] = []; const root = path.resolve(spec.projectPath); if (!existsSync(root)) { return { valid: false, spec, layout: null, errors: [`projectPath does not exist: ${root}`], warnings }; } if (!statSync(root).isDirectory()) { return { valid: false, spec, layout: null, errors: [`projectPath is not a directory: ${root}`], warnings }; } // Locate the .NET API folder (holds appsettings.json). const structure = await findSmartStackStructure(root); let apiAbs = structure.api ?? null; if (!apiAbs) { const hits = await findFiles('**/appsettings.json', { cwd: root }); if (hits.length > 0) apiAbs = path.dirname(hits[0]); } if (!apiAbs) { errors.push( 'Could not locate the .NET API folder (no appsettings.json found). Run against a generated SmartStack project.', ); return { valid: false, spec, layout: null, errors, warnings }; } // Locate the web app folder. let webAbs = structure.web ?? null; if (!webAbs) { webAbs = path.join(root, 'web', `${spec.appCode}-web`); warnings.push(`Web folder not auto-detected; assuming web/${spec.appCode}-web.`); } // Secrets safety: never risk committing a secret. if (spec.secretsMode === 'local-file' && !gitignoreExcludesLocal(root)) { spec.secretsMode = 'placeholders'; warnings.push( '.gitignore does not exclude appsettings.Local.json — downgraded to placeholders mode (no secret written). ' + 'Add `appsettings.Local.json` (or `appsettings.*.json`) to .gitignore, then re-run with secretsMode:"local-file".', ); } const toRel = (abs: string): string => path.relative(root, abs).replace(/\\/g, '/'); const layout: ProjectLayout = { apiDir: toRel(apiAbs), webDir: toRel(webAbs) }; return { valid: errors.length === 0, spec, layout, errors, warnings }; }