#!/usr/bin/env node import { parseArgsOrFail as parseArgs } from '../../../../../lib/cli-args.js' 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' import { customisedWarnings, writeGeneratedFiles } from '../../../../../lib/generated-writes.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 || v.data === undefined) { printEnvelope(failGenerate(COMMAND, v.errors)); process.exit(1) } // The PARSED spec (Zod defaults applied) — never the raw JSON: on the raw // object `tenantMode`/`schemaTarget` were absent, so the entity lost // ITenantEntity and the named tenant filter was never mounted (the // DEV-API-032 cross-tenant class) while `required` defaulted to nothing. const spec = v.data 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 }) // Guarded write (lib/generated-writes): an `@customised` output is skipped + warned, never overwritten. const writes = writeGeneratedFiles(files, { outdir }) const written = writes.written const tenantPatch = applyTenantFilterPatch(outdir, spec) printEnvelope(generateEnvelope(COMMAND, { data: { entity: spec.name, fileCount: written.length, filesRemoved: legacySweep.removed, skipped: writes.skipped, 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.`), ...customisedWarnings(writes)], 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()