import path from 'node:path'; import os from 'node:os'; import { detectDbContexts, findStartupProject, hasDotnetProject } from '../lib/detect-dbcontexts.js'; import { runDotnet, listMigrationsJson, dotnetAvailable, dotnetEfAvailable } from '../lib/ef-runner.js'; import { getCurrentBranch } from '../../../lib/git.js'; import { readConfig, resolveConfigPath } from '../../../gitflow/cli/lib/config.js'; import { classifyDbTarget, decideMigrationAction, type PolicyVerdict } from '../lib/migration-policy.js'; import { determineBaseBranch } from '../lib/squash-base.js'; import { resolveConnectionProvenance, type ConfigSource } from '../lib/connection-provenance.js'; import { buildApplyRemediation } from '../lib/apply-remediation.js'; import { appendHistory, buildApplyHistoryRecord, shouldJournalApply } from '../lib/efcore-history.js'; import type { ApplySpec, ApplyResult, ApplyContextResult } from './types.js'; const AZURE_SUFFIX = '.database.windows.net'; function capitalize(s: string): string { if (!s) return s; if (s.toLowerCase() === 'sqlserver') return 'SqlServer'; return s.charAt(0).toUpperCase() + s.slice(1); } /** * User-ratified extra local hosts from `/.gitflow/config.json` → * `efcore.localHosts`. Resolved EXPLICITLY from spec.cwd — never a bare * `readConfig()` fallthrough, which would re-resolve from process.cwd() and * could import ANOTHER project's allowlist. Best-effort: no config → []. */ async function readLocalHostsAllowlist(cwd: string): Promise { try { const configPath = await resolveConfigPath(cwd); if (!configPath) return []; const cfg = await readConfig(configPath); return cfg.efcore?.localHosts ?? []; } catch { return []; } } export type ExecutionPlan = 'dry-run' | 'refuse' | 'needs-confirmation' | 'run'; /** * What the verdict lets this spec do. PURE, and the one place that orders the * gates: a dry-run is READ-ONLY, so it runs on EVERY tier — measuring what a * refused run would have applied is precisely the information a refusal needs. */ export function planExecution(policy: PolicyVerdict, spec: { dryRun: boolean; confirm: boolean }): ExecutionPlan { if (spec.dryRun) return 'dry-run'; if (policy.blocked) return 'refuse'; if (policy.requiresConfirmation && !spec.confirm) return 'needs-confirmation'; return 'run'; } export interface MigrationsListOutcome { success: boolean; migrations: Array<{ name: string; applied: boolean }>; raw: string; error?: string; } /** * Turn a `migrations list` outcome into the row that ends this context — a * FAILURE row when the list itself failed (an unreachable database or a broken * build is a measurement error, never `pending: 0`), the no-op row when nothing * is pending, the `[dry-run]` row — or `null` when a real `database update` must * run. PURE. */ export function preUpdateRow( ctx: { contextName: string; assemblyName: string }, list: MigrationsListOutcome, opts: { op: 'apply' | 'revert'; dryRun: boolean; targetMigration?: string }, ): { row: ApplyContextResult | null; pendingList: string[] } { const base = { contextName: ctx.contextName, assemblyName: ctx.assemblyName, applied: false, appliedMigrations: [] as string[] }; if (!list.success) { return { row: { ...base, pending: 0, output: list.raw, success: false, error: list.error ?? 'dotnet ef migrations list failed' }, pendingList: [], }; } const pendingList = list.migrations.filter((m) => !m.applied).map((m) => m.name); const pending = pendingList.length; // Apply-to-latest with nothing pending → no-op (skip the build cost of update). if (opts.op === 'apply' && pending === 0) { return { row: { ...base, pending: 0, output: `${opts.dryRun ? '[dry-run] ' : ''}database is up to date`, success: true }, pendingList }; } if (opts.dryRun) { return { row: { ...base, pending, appliedMigrations: pendingList, output: `[dry-run] dotnet ef database update ${opts.targetMigration ?? '(latest)'} --context ${ctx.contextName}`, success: true, }, pendingList, }; } return { row: null, pendingList }; } export async function execute(spec: ApplySpec): Promise { const startedAtIso = new Date().toISOString(); const warnings: string[] = []; const op: 'apply' | 'revert' = spec.targetMigration ? 'revert' : 'apply'; const branch = (await getCurrentBranch(spec.cwd)) ?? 'HEAD'; const branchType = determineBaseBranch(branch).branchType; // Resolve the target DB — WHICH layer named it, then its host — and the verdict // up front (cheap, no dotnet build). const startupProject = spec.startupProject ?? (await findStartupProject(spec.cwd)); const resolved = resolveConnectionProvenance(startupProject ? path.dirname(startupProject) : null); warnings.push(...resolved.warnings); const connString = spec.connectionString ?? resolved.value; const configSource: ConfigSource = spec.connectionString ? { ...resolved.source, kind: 'spec', file: null } : resolved.source; const extraLocalHosts = await readLocalHostsAllowlist(spec.cwd); const dbClass = classifyDbTarget(connString, { machineName: os.hostname(), extraLocalHosts }); const policy = decideMigrationAction({ op, branch, dbTarget: dbClass.target, dbProvenance: configSource.kind, dbHost: dbClass.host }); // Traceability: surface every widening lane in the envelope. A spec-provided // connection string only drives CLASSIFICATION — the real `database update` // still reads what the app resolves (no --connection is passed) — so it must // never be used to green-wash an apply; make it visible whenever present. if (dbClass.target === 'local' && dbClass.reason.includes('efcore.localHosts')) { warnings.push(`DB host "${dbClass.host}" classified local via the user-ratified allowlist (.gitflow/config.json → efcore.localHosts).`); } if (spec.connectionString) { warnings.push('Classification used the spec-provided connectionString — the actual `dotnet ef database update` still targets what the app resolves (environment, then appsettings).'); if (resolved.source.kind === 'environment') { warnings.push('ConnectionStrings__DefaultConnection is ALSO set in this shell — that is the database dotnet ef will target, not the spec-provided string.'); } } if (configSource.kind === 'local-override' && dbClass.target !== 'local') { warnings.push(`DB host "${dbClass.host}" is not loopback-local — the grant is appsettings.Local.json (your working database, ${branchType} branch); a real run is journaled under .claude/Efcore-history/.`); if (dbClass.host?.endsWith(AZURE_SUFFIX)) { warnings.push(`"${dbClass.host}" is an Azure SQL database named by appsettings.Local.json — make sure it is a DEVELOPMENT instance; nothing here can tell it from production.`); } } const remediation = buildApplyRemediation({ policy, dbClass, source: configSource, branchType }); const head = { cwd: spec.cwd, branch, branchType, op, dbTarget: dbClass.target, dbHost: dbClass.host, configSource, policy, ...(remediation ? { remediation } : {}), }; const plan = planExecution(policy, spec); // 🔴 — autonomous refusal, no token. `remediation` names the structural exit. if (plan === 'refuse') { return { success: false, ...head, blocked: true, contexts: [], warnings, error: policy.reason }; } // 🟡 — needs an explicit confirm token before we touch the DB. if (plan === 'needs-confirmation') { return { success: false, ...head, needsConfirmation: true, contexts: [], warnings: [...warnings, `Re-run with "confirm": true to proceed — ${policy.reason}`], error: policy.reason, }; } // 🟢 / confirmed 🟡 / dry-run on any tier — now we may pay for dotnet. The // probes run on a dry-run too: a missing tool is a measurement error, never // `pending: 0`. if (!(await hasDotnetProject(spec.cwd))) { return { success: false, ...head, contexts: [], warnings, error: 'No .csproj in worktree — nothing to apply.' }; } if (!(await dotnetAvailable())) { return { success: false, ...head, contexts: [], warnings, error: 'dotnet CLI is not available on PATH' }; } if (!(await dotnetEfAvailable())) { return { success: false, ...head, contexts: [], warnings, error: 'dotnet-ef is not installed — run: dotnet tool install --global dotnet-ef' }; } const detection = await detectDbContexts(spec.cwd); const contexts = spec.contextName ? detection.contexts.filter((c) => c.name === spec.contextName) : detection.contexts; if (contexts.length === 0) { return { success: false, ...head, contexts: [], warnings, error: spec.contextName ? `Context "${spec.contextName}" not found. Detected: ${detection.contexts.map((c) => c.name).join(', ') || 'none'}` : 'No DbContext detected in worktree.', }; } const results: ApplyContextResult[] = []; let attemptedUpdate = false; for (const ctx of contexts) { const asm = ctx.assemblies[0]; // generated app: one assembly per context (runtime provider) if (!asm) continue; const extraEnv = asm.provider ? { STUDIO_DESIGN_PROVIDER: capitalize(asm.provider) } : undefined; const list = await listMigrationsJson(spec.cwd, { context: ctx.name, projectPath: asm.csprojPath, startupProjectPath: startupProject ?? undefined, extraEnv, }); const { row, pendingList } = preUpdateRow( { contextName: ctx.name, assemblyName: asm.assemblyName }, list, { op, dryRun: spec.dryRun, targetMigration: spec.targetMigration }, ); if (row) { results.push(row); continue; } attemptedUpdate = true; const args = ['ef', 'database', 'update']; if (spec.targetMigration) args.push(spec.targetMigration); args.push('--context', ctx.name, '--project', asm.csprojPath, '--no-color'); if (startupProject) args.push('--startup-project', startupProject); const r = await runDotnet(args, spec.cwd, { timeoutMs: 5 * 60_000, extraEnv }); const success = r.exitCode === 0; results.push({ contextName: ctx.name, assemblyName: asm.assemblyName, pending: pendingList.length, applied: success, appliedMigrations: success ? pendingList : [], output: `${r.stdout}${r.stderr ? `\n${r.stderr}` : ''}`, success, error: success ? undefined : r.stderr || r.stdout || 'dotnet ef database update failed', }); } const allOk = results.every((r) => r.success); if (plan === 'dry-run') { const gate = policy.blocked ? { blocked: true } : policy.requiresConfirmation && !spec.confirm ? { needsConfirmation: true } : {}; return { success: allOk, ...head, ...gate, wouldApply: !policy.blocked && (!policy.requiresConfirmation || spec.confirm), cwd: detection.cwd, contexts: results, warnings, error: allOk ? undefined : 'One or more contexts could not be measured — see per-context error', }; } // Audit trail: every revert, and every apply that is NOT on a loopback-local // database (the provenance lane — a working DB on a shared host). Never blocks. let historyLogPath: string | undefined; if (attemptedUpdate && shouldJournalApply(op, dbClass.target)) { const record = buildApplyHistoryRecord({ timestamp: startedAtIso, targetMigration: spec.targetMigration ?? null, cwd: detection.cwd, currentBranch: branch, branchType, dbHost: dbClass.host, dbTarget: dbClass.target, configSource: { kind: configSource.kind, file: configSource.file }, policy: { tier: policy.tier, axis: policy.axis }, confirmed: spec.confirm, contexts: results.map((r) => ({ contextName: r.contextName, assemblyName: r.assemblyName, pending: r.pending, applied: r.applied, appliedMigrations: r.appliedMigrations, success: r.success, })), }); const logged = await appendHistory(spec.cwd, record); if (logged.path) historyLogPath = logged.path; else warnings.push(`Could not write the audit record to .claude/Efcore-history (${logged.error}).`); } return { success: allOk, ...head, cwd: detection.cwd, ...(historyLogPath ? { historyLogPath } : {}), contexts: results, warnings, error: allOk ? undefined : 'One or more contexts failed to apply — see per-context error', }; }