/** * cli:derive-seed-delta — execute.ts * * I/O layer: reads the committed state files from the working tree and from * the base git ref (read-only `git show` / `git ls-tree` — never a write git * operation), runs the pure diff, renders the scripts + PR summary, writes * the scripts unless dryRun. */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { CORE_SEED_STATE_DIR, SEED_SCRIPTS_RELATIVE_DIR, parseCoreSeedState, type CoreSeedState, } from '../scaffold-core-seed/state.js'; import { diffStates } from './diff.js'; import { renderDeltaScript, renderDeltaSummary } from './generate-sql.js'; import { planHasChanges, scriptFileName, type DeltaPlan, type DeriveSeedDeltaSpec } from './types.js'; export interface WrittenScript { app: string; /** Path relative to projectPath. */ path: string; content: string; } export interface ExecuteResult { ok: boolean; errors: string[]; warnings: string[]; plans: DeltaPlan[]; scripts: WrittenScript[]; summary: string; /** Scripts directory relative to projectPath (resolved or from the spec). */ scriptsDir: string | null; } function git(args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } { try { const stdout = execFileSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000, maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], }); return { ok: true, stdout: stdout ?? '', stderr: '' }; } catch (err) { const e = err as { stdout?: string; stderr?: string; message?: string }; return { ok: false, stdout: e.stdout ?? '', stderr: e.stderr ?? e.message ?? 'git failed' }; } } /** Locate `src/{X}.Infrastructure` — the delta scripts live under it. */ function detectScriptsDir(projectPath: string): { dir: string | null; error: string | null } { const srcPath = join(projectPath, 'src'); if (!existsSync(srcPath)) return { dir: null, error: `no src/ folder under ${projectPath}` }; const infra = readdirSync(srcPath, { withFileTypes: true }) .filter((d) => d.isDirectory() && d.name.endsWith('.Infrastructure')) .map((d) => d.name); if (infra.length === 1) return { dir: `src/${infra[0]}/${SEED_SCRIPTS_RELATIVE_DIR}`, error: null }; if (infra.length === 0) return { dir: null, error: 'no src/*.Infrastructure project found — pass scriptsDir in the spec' }; return { dir: null, error: `several Infrastructure projects found (${infra.join(', ')}) — pass scriptsDir in the spec`, }; } export function execute(spec: DeriveSeedDeltaSpec): ExecuteResult { const errors: string[] = []; const warnings: string[] = []; const projectPath = resolve(spec.projectPath); const stateDirAbs = join(projectPath, CORE_SEED_STATE_DIR); if (!existsSync(stateDirAbs)) { return fail( `no ${CORE_SEED_STATE_DIR}/ under ${projectPath} — run scaffold-core-seed first (it emits the state snapshots this CLI diffs).`, ); } // Base ref must resolve BEFORE we interpret a missing file as "baseline". const refCheck = git(['rev-parse', '--verify', '--quiet', `${spec.baseRef}^{commit}`], projectPath); if (!refCheck.ok) { return fail(`base ref '${spec.baseRef}' does not resolve in ${projectPath} — fetch it or pass another baseRef.`); } // Current apps: working-tree state files (optionally restricted by spec.apps). const presentApps = readdirSync(stateDirAbs) .filter((f) => f.endsWith('.state.json')) .map((f) => f.replace(/\.state\.json$/, '')) .sort(); const apps = spec.apps ? presentApps.filter((a) => spec.apps!.includes(a)) : presentApps; if (spec.apps) { for (const requested of spec.apps) { if (!presentApps.includes(requested)) { errors.push(`requested app '${requested}' has no ${CORE_SEED_STATE_DIR}/${requested}.state.json`); } } } if (apps.length === 0) errors.push(`no *.state.json found under ${CORE_SEED_STATE_DIR}/`); if (errors.length > 0) return fail(...errors); // Apps present at base but absent from the working tree: never reconciled. const baseList = git(['ls-tree', '--name-only', spec.baseRef, `${CORE_SEED_STATE_DIR}/`], projectPath); if (baseList.ok) { for (const line of baseList.stdout.split('\n').filter(Boolean)) { const name = line.split('/').pop() ?? ''; if (!name.endsWith('.state.json')) continue; const app = name.replace(/\.state\.json$/, ''); if (!presentApps.includes(app)) { warnings.push( `app '${app}' has a state at ${spec.baseRef} but none in the working tree — its prod data is NOT reconciled (removal of a whole app is a manual decision).`, ); } } } const scriptsDirResolved = spec.scriptsDir ?? null; const detection = scriptsDirResolved ? null : detectScriptsDir(projectPath); const scriptsDir = scriptsDirResolved ?? detection?.dir ?? null; const plans: DeltaPlan[] = []; const scripts: WrittenScript[] = []; for (const app of apps) { const currentRaw = readFileSync(join(stateDirAbs, `${app}.state.json`), 'utf-8'); const current = parseCoreSeedState(currentRaw); if (!current) { errors.push(`${CORE_SEED_STATE_DIR}/${app}.state.json is not a valid core-seed state file`); continue; } const base = readBaseState(spec.baseRef, app, projectPath); const plan = diffStates({ app, base, next: current, resolvedRenames: spec.resolvedRenames }); plans.push(plan); if (!planHasChanges(plan) || plan.baseline) continue; const content = renderDeltaScript(plan, { version: spec.version, baseRef: spec.baseRef }); if (content === null) continue; if (!scriptsDir) { errors.push(detection?.error ?? 'scriptsDir could not be resolved'); break; } scripts.push({ app, path: `${scriptsDir}/${scriptFileName(spec.version, app)}`, content }); } if (errors.length > 0) return fail(...errors); if (!spec.dryRun) { for (const script of scripts) { const abs = join(projectPath, script.path); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, script.content, 'utf-8'); } } return { ok: true, errors: [], warnings, plans, scripts, summary: renderDeltaSummary(plans, { version: spec.version, baseRef: spec.baseRef }), scriptsDir, }; function fail(...errs: string[]): ExecuteResult { return { ok: false, errors: [...errors.filter((e) => !errs.includes(e)), ...errs], warnings, plans: [], scripts: [], summary: '', scriptsDir: null, }; } } function readBaseState(baseRef: string, app: string, projectPath: string): CoreSeedState | null { const show = git(['show', `${baseRef}:${CORE_SEED_STATE_DIR}/${app}.state.json`], projectPath); if (!show.ok) return null; // ref verified upstream → missing file = baseline return parseCoreSeedState(show.stdout); }