#!/usr/bin/env node import { parseArgs } from 'node:util' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve, join } from 'node:path' import { validate } from './validate.js' import { generate, legacyPaths } from './generate.js' import { patchTenantFilter } from './tenant-filter.js' import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js' import { guardedRm } from '../../../../../lib/guarded-rm.js' const COMMAND = 'scaffold-entity' /** Locate the client DbContext hosting OnExtensionModelCreating (mirror of scaffold-data-scope). */ function resolveContextHost(outdir: string, appCode: string): string | null { const candidates = [ `src/${appCode}.Infrastructure/Persistence/ExtensionsDbContext.cs`, `src/${appCode}.Infrastructure/Persistence/Contexts/ExtensionsDbContext.cs`, `src/${appCode}.Infrastructure/ExtensionsDbContext.cs`, ] for (const candidate of candidates) { const fullPath = resolve(join(outdir, candidate)) if (existsSync(fullPath)) return fullPath } return null } /** * Mount/refresh the entity's named "Tenant" query filter in the * ExtensionsDbContext (TENANT-FILTERS markers). Extension entities carry NO * automatic tenant filter — without this line every generated read is * cross-tenant. Returns the patched path (when a write happened) + warnings. */ function applyTenantFilterPatch(outdir: string, spec: any): { patched: string | null; warnings: string[] } { const warnings: string[] = [] if (spec.schemaTarget !== 'extensions') return { patched: null, warnings } // Core entities: filtered by CoreDbContext const hostPath = resolveContextHost(outdir, spec.appCode) if (hostPath === null) { if (spec.tenantMode !== 'none') { warnings.push(`No ExtensionsDbContext.cs found under src/${spec.appCode}.Infrastructure/ — the "${spec.name}" tenant filter was NOT mounted: its reads are CROSS-TENANT until ApplyNamed${spec.tenantMode === 'strict' ? 'Strict' : 'Optional'}TenantFilter<${spec.name}> is added manually (DEV-API-032).`) } return { patched: null, warnings } } const source = readFileSync(hostPath, 'utf-8') const next = patchTenantFilter(source, spec) if (next !== null) { writeFileSync(hostPath, next, 'utf-8') } else if (spec.tenantMode !== 'none' && !/OnExtensionModelCreating\s*\(\s*ModelBuilder/.test(source)) { warnings.push(`ExtensionsDbContext does not override OnExtensionModelCreating(ModelBuilder) — the "${spec.name}" tenant filter was NOT mounted; add it manually (DEV-API-032).`) } if (spec.tenantMode !== 'none' && !/ICurrentTenantService/.test(source)) { warnings.push(`ExtensionsDbContext does not forward ICurrentTenantService to the SmartStackExtensionDbContext base — the tenant filter stays INERT (system context, cross-tenant reads). Upgrade the constructor to forward every optional base dependency.`) } return { patched: next !== null ? hostPath : null, warnings } } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, outdir: { type: 'string' }, dry_run: { type: 'boolean', default: false } }, strict: true }) if (!values.spec) { printEnvelope(failGenerate(COMMAND, ['--spec is required'])); process.exit(1) } let raw: unknown try { raw = JSON.parse(values.spec) } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON'])); process.exit(1) } const v = validate(raw) if (!v.valid) { printEnvelope(failGenerate(COMMAND, v.errors)); process.exit(1) } const spec = raw as any const files = generate(spec) if (values.dry_run) { printEnvelope(generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map(f => f.path) }, warnings: v.warnings })) process.exit(0) } const outdir = values.outdir ?? spec.projectPath // Idempotent relocate: delete the pre-classification copies before writing the // new /-classified files, so re-running /ba-develop MOVES the // entity rather than orphaning a duplicate EF config in the assembly. // Guarded sweep (lib/guarded-rm): @customised files preserved, deletions traced. const legacySweep = guardedRm(legacyPaths(spec), { outdir }) const written: string[] = [] for (const file of files) { const p = resolve(join(outdir, file.path)); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, file.content, 'utf-8'); written.push(p) } const tenantPatch = applyTenantFilterPatch(outdir, spec) printEnvelope(generateEnvelope(COMMAND, { data: { entity: spec.name, fileCount: written.length, filesRemoved: legacySweep.removed, tenantFilterPatched: tenantPatch.patched !== null }, filesCreated: written, filesModified: tenantPatch.patched !== null ? [tenantPatch.patched] : [], warnings: [...v.warnings, ...tenantPatch.warnings, ...legacySweep.preserved.map(p => `legacy path ${p} kept: marked @customised — delete manually if truly superseded.`)], nextSteps: [ 'Run dotnet build', // Rowversion column = schema change. SIGNAL ONLY — the sanctioned path is // the governed /efcore skill; this CLI never runs dotnet ef itself. ...(spec.versioned ? [`RowVersion column added to ${spec.name} — create/apply the extension migration via /efcore (governed; never auto-run).`] : []), ], })) } main()