import path from 'node:path'; import os from 'node:os'; import { existsSync, readFileSync } from 'node:fs'; 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 } from '../lib/migration-policy.js'; import type { ApplySpec, ApplyResult, ApplyContextResult } from './types.js'; function capitalize(s: string): string { if (!s) return s; if (s.toLowerCase() === 'sqlserver') return 'SqlServer'; return s.charAt(0).toUpperCase() + s.slice(1); } /** * Read `ConnectionStrings:DefaultConnection` from the startup project's appsettings, * preferring the dev override chain (Local > Development > base) — the same precedence * `ss dev` uses. Returns null when nothing resolves (→ classifier reports `unknown` → * the policy gates the apply as non-local, fail-closed). */ function readDefaultConnection(startupProjectPath: string | null): string | null { if (!startupProjectPath) return null; const dir = path.dirname(startupProjectPath); for (const file of ['appsettings.Local.json', 'appsettings.Development.json', 'appsettings.json']) { const p = path.join(dir, file); if (!existsSync(p)) continue; try { const json = JSON.parse(readFileSync(p, 'utf-8')) as { ConnectionStrings?: Record }; const cs = json?.ConnectionStrings?.DefaultConnection; if (typeof cs === 'string' && cs.trim()) return cs; } catch { /* malformed appsettings — try the next file */ } } return null; } /** * 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 async function execute(spec: ApplySpec): Promise { const warnings: string[] = []; const op: 'apply' | 'revert' = spec.targetMigration ? 'revert' : 'apply'; const branch = (await getCurrentBranch(spec.cwd)) ?? 'HEAD'; // Resolve the target DB + policy verdict up front (cheap, no dotnet build). const startupProject = spec.startupProject ?? (await findStartupProject(spec.cwd)); const connString = spec.connectionString ?? readDefaultConnection(startupProject); const extraLocalHosts = await readLocalHostsAllowlist(spec.cwd); const dbClass = classifyDbTarget(connString, { machineName: os.hostname(), extraLocalHosts }); const policy = decideMigrationAction({ op, branch, dbTarget: dbClass.target }); // Traceability: surface every widening lane in the envelope. A spec-provided // connection string only drives CLASSIFICATION — the real `database update` // still reads appsettings (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 appsettings resolve to.'); } const head = { cwd: spec.cwd, branch, op, dbTarget: dbClass.target, dbHost: dbClass.host, policy }; // 🔴 — autonomous refusal. A human decides (e.g. a deploy to a shared DB). if (policy.blocked) { return { success: false, ...head, blocked: true, contexts: [], warnings, error: policy.reason }; } // 🟡 — needs an explicit confirm token before we touch the DB. if (policy.requiresConfirmation && !spec.confirm) { return { success: false, ...head, needsConfirmation: true, contexts: [], warnings: [...warnings, `Re-run with "confirm": true to proceed — ${policy.reason}`], error: policy.reason, }; } // 🟢 (or confirmed 🟡) — proceed. Now we may pay for dotnet. if (!(await hasDotnetProject(spec.cwd))) { return { success: false, ...head, contexts: [], warnings, error: 'No .csproj in worktree — nothing to apply.' }; } if (!spec.dryRun) { 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[] = []; 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 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 (op === 'apply' && pending === 0 && list.success) { results.push({ contextName: ctx.name, assemblyName: asm.assemblyName, pending: 0, applied: false, appliedMigrations: [], output: 'database is up to date', success: true }); continue; } if (spec.dryRun) { results.push({ contextName: ctx.name, assemblyName: asm.assemblyName, pending, applied: false, appliedMigrations: pendingList, output: `[dry-run] dotnet ef database update ${spec.targetMigration ?? '(latest)'} --context ${ctx.name}`, success: true, }); continue; } 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, 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); return { success: allOk, ...head, cwd: detection.cwd, contexts: results, warnings, error: allOk ? undefined : 'One or more contexts failed to apply — see per-context error', }; }