#!/usr/bin/env node /** * cli:derive-seed-delta * * Diffs the committed core-seed desired-state snapshots * (`.smartstack/core-seed/{app}.state.json`) between a base git ref (default * `origin/main` — the previously released state) and the working tree, and * generates ONE reviewable, idempotent SQL delta script per changed app under * `src/{Project}.Infrastructure/Persistence/Seeding/Scripts/{version}_{app}.sql`. * * Run on the RELEASE (or hotfix) branch before opening the PR to main — the * gitflow `pr` gate blocks the PR when the state changed without a matching * committed script. The generated CoreSeedScriptRunner applies pending scripts * once per database at boot. * * Usage: * npx --prefer-offline tsx skills/development/backend/core-seed/cli/derive-seed-delta/index.ts \ * --spec '{"version":"5.10.0","projectPath":"D:/path/to/project"}' [--json] * * # or with a spec file (large resolvedRenames payloads): * ... --spec-file /path/to/spec.json * * Read-only towards git (rev-parse/show/ls-tree) — it never stages, commits * or pushes; committing the generated script goes through the normal gitflow * commit flow. */ import { parseArgs } from 'node:util'; import { readFileSync } from 'node:fs'; import { validate } from './validate.js'; import { execute } from './execute.js'; import { DeriveSeedDeltaSpecSchema } from './types.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js'; const COMMAND = 'derive-seed-delta'; function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, json: { type: 'boolean', default: false }, }, strict: true, }); const rawJson = loadSpecJson(values); if (rawJson === null) { printEnvelope(failGenerate(COMMAND, ['Either --spec or --spec-file is required'])); process.exit(1); } let raw: unknown; try { raw = JSON.parse(rawJson); } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in spec'])); process.exit(1); } const validation = validate(raw); if (!validation.valid) { printEnvelope(failGenerate(COMMAND, validation.errors)); process.exit(1); } const spec = DeriveSeedDeltaSpecSchema.parse(raw); const result = execute(spec); if (!result.ok) { printEnvelope(failGenerate(COMMAND, result.errors)); process.exit(1); } const ambiguous = result.plans.flatMap((p) => p.ambiguous.map((a) => ({ app: p.app, ...a })), ); printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: spec.dryRun, baseRef: spec.baseRef, version: spec.version, scriptsDir: result.scriptsDir, apps: result.plans.map((p) => ({ app: p.app, baseline: p.baseline, changed: p.baseHash !== p.newHash, baseHash: p.baseHash, newHash: p.newHash, renames: p.navRenames.length + p.roleRenames.length + p.permissionRenames.length, updates: p.navUpdates.length + p.roleUpdates.length + p.permissionUpdates.length, additions: p.navAdditions.length + p.roleAdditions.length + p.permissionAdditions.length + p.rolePermissionAdditions.length, revocations: p.rolePermissionRevocations.length, permissionDeletions: p.permissionDeletions.length, navDeactivations: p.navDeactivations.length, reportedOnly: p.removedApplications.length + p.removedRoles.length, ambiguous: p.ambiguous.length, })), ambiguous, summary: result.summary, }, filesCreated: spec.dryRun ? [] : result.scripts.map((s) => s.path), warnings: [...validation.warnings, ...result.warnings], nextSteps: buildNextSteps(result, spec.dryRun, ambiguous.length), }), ); } function buildNextSteps( result: ReturnType, dryRun: boolean, ambiguousCount: number, ): string[] { const steps: string[] = []; if (ambiguousCount > 0) { steps.push( `${ambiguousCount} ambiguous rename candidate(s): if any IS a rename, re-run with resolvedRenames (or declare previousCodes/previousPaths upstream) — otherwise the removals stand.`, ); } if (result.scripts.length === 0) { steps.push('No delta script needed (baseline or unchanged state).'); } else if (dryRun) { steps.push('Dry-run: re-run without dryRun to write the script(s), then review + commit them on the release branch.'); } else { steps.push('REVIEW each generated script, then commit it on the release branch (gitflow commit).'); steps.push('Use the summary (data.summary) as the PR description section for the seed delta.'); } return steps; } function loadSpecJson(values: { spec?: string; 'spec-file'?: string }): string | null { if (values['spec-file']) { try { return readFileSync(values['spec-file'], 'utf-8'); } catch (err) { printEnvelope( failGenerate(COMMAND, [ `Failed to read --spec-file ${values['spec-file']}: ${err instanceof Error ? err.message : String(err)}`, ]), ); process.exit(1); } } return values.spec ?? null; } main();