/** * ef-runner.ts — Spawn wrapper for `dotnet ef` commands. * * `dotnet ef` must be on the PATH (`dotnet tool install --global dotnet-ef`). * On Windows the global tools cache is at `%USERPROFILE%\.dotnet\tools`; under * Git Bash `$HOME/.dotnet/tools`. Both are prepended to PATH so the wrapper * works from either shell. */ import { execFile } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; export interface EfRunResult { stdout: string; stderr: string; exitCode: number; } function buildPathEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; const home = os.homedir(); const candidates = [ path.join(home, '.dotnet', 'tools'), process.env.USERPROFILE ? path.join(process.env.USERPROFILE, '.dotnet', 'tools') : null, ].filter((p): p is string => Boolean(p)); const sep = process.platform === 'win32' ? ';' : ':'; const existing = env.PATH ?? env.Path ?? ''; const merged = [...candidates, existing].filter(Boolean).join(sep); env.PATH = merged; env.Path = merged; return env; } export function runDotnet( args: string[], cwd: string, opts: { timeoutMs?: number; extraEnv?: NodeJS.ProcessEnv } = {}, ): Promise { const timeout = opts.timeoutMs ?? 180_000; return new Promise((resolve) => { execFile( 'dotnet', args, { cwd, encoding: 'utf-8', timeout, maxBuffer: 20 * 1024 * 1024, env: { ...buildPathEnv(), ...(opts.extraEnv ?? {}) }, }, (error, stdout, stderr) => { resolve({ stdout: (stdout || '').trim(), stderr: (stderr || '').trim(), exitCode: error ? ((error as NodeJS.ErrnoException).code as number | undefined ?? 1) : 0, }); }, ); }); } /** * Compute the EF Core `--output-dir` value for `dotnet ef migrations add`. * * EF writes a new migration (and the model snapshot) to "Migrations/" by * default. SmartStack repos keep them under "Persistence/Migrations", so the * generated files must be told where the EXISTING migrations live — otherwise * they land in the wrong folder with the wrong namespace and the build breaks. * The path EF expects is relative to the PROJECT directory (the `--project` * .csproj folder), NOT the cwd, which is usually the solution root. * * Returns a forward-slash path ("Persistence/Migrations", or "Migrations" for * the default layout) so the value is identical on Windows and POSIX. */ export function migrationsOutputDir(csprojPath: string, migrationsDir: string): string { const rel = path.relative(path.dirname(csprojPath), migrationsDir); return rel.split(/[\\/]/).filter(Boolean).join('/') || 'Migrations'; } /** * Build the full `dotnet ef migrations add` argument vector. Shared by the * create and squash CLIs so the command can never drift again: the missing * `--output-dir` regression came from three hand-maintained copies of this * array, one of which silently lacked the flag. */ export function buildMigrationsAddArgs(opts: { migrationName: string; csprojPath: string; contextName: string; migrationsDir: string; startupProject?: string; }): string[] { const args = [ 'ef', 'migrations', 'add', opts.migrationName, '--project', opts.csprojPath, '--context', opts.contextName, '--output-dir', migrationsOutputDir(opts.csprojPath, opts.migrationsDir), '--no-color', ]; if (opts.startupProject) args.push('--startup-project', opts.startupProject); return args; } /** * Fetch the list of migrations as parsed JSON. Uses `--json` which EF Core * 10 supports on `migrations list`. Fallback to text parsing if `--json` is * unavailable on the target SDK. */ export async function listMigrationsJson( cwd: string, opts: { context?: string; projectPath?: string; startupProjectPath?: string; extraEnv?: NodeJS.ProcessEnv } = {}, ): Promise<{ success: boolean; migrations: Array<{ id: string; name: string; applied: boolean }>; raw: string; error?: string; }> { const args = ['ef', 'migrations', 'list', '--json', '--no-color']; if (opts.context) args.push('--context', opts.context); if (opts.projectPath) args.push('--project', opts.projectPath); if (opts.startupProjectPath) args.push('--startup-project', opts.startupProjectPath); const result = await runDotnet(args, cwd, { extraEnv: opts.extraEnv }); if (result.exitCode !== 0) { return { success: false, migrations: [], raw: result.stdout + (result.stderr ? `\n${result.stderr}` : ''), error: result.stderr || result.stdout || 'dotnet ef migrations list failed', }; } const parsed = extractJsonBlock(result.stdout); if (!parsed) { // Fallback: plain text parse (one migration per line, applied ones have no marker) const lines = result.stdout .split('\n') .map((l) => l.trim()) .filter((l) => l && !l.startsWith('Build') && !l.startsWith('info:') && !l.includes('Done.')); return { success: true, raw: result.stdout, migrations: lines.map((line) => ({ id: line.replace(/\s+\(Pending\)$/, ''), name: line.replace(/\s+\(Pending\)$/, ''), applied: !/\(Pending\)$/.test(line), })), }; } const migrations = (Array.isArray(parsed) ? parsed : []).map((m) => ({ id: String(m.id ?? m.name ?? ''), name: String(m.name ?? m.id ?? ''), applied: Boolean(m.applied), })); return { success: true, migrations, raw: result.stdout }; } function extractJsonBlock(text: string): unknown | null { // `dotnet ef --json` emits a marker line around the JSON payload on newer SDKs. const lines = text.split('\n'); const startIdx = lines.findIndex((l) => l.trim().startsWith('[')); if (startIdx === -1) return null; // Find matching end bracket (greedy — the JSON is always the last array). for (let end = lines.length - 1; end >= startIdx; end--) { const block = lines.slice(startIdx, end + 1).join('\n').trim(); if (!block.endsWith(']')) continue; try { return JSON.parse(block); } catch { continue; } } return null; } export async function dotnetAvailable(): Promise { const res = await runDotnet(['--version'], process.cwd(), { timeoutMs: 5_000 }); return res.exitCode === 0; } export async function dotnetEfAvailable(): Promise { const res = await runDotnet(['ef', '--version'], process.cwd(), { timeoutMs: 10_000 }); return res.exitCode === 0; }