diff --git a/src/core/fileOperations.ts b/src/core/fileOperations.ts index 8b8fe323..122a2d9d 100644 --- a/src/core/fileOperations.ts +++ b/src/core/fileOperations.ts @@ -383,6 +383,13 @@ export class fileOperations { const mappingRootPath = this.getMappingFilePath(sourceGuid, targetGuid, locale); const centralMappingsPath = path.join(mappingRootPath, type); + // Preflight (PROD-2203): never persist mapping changes. Pushers already + // short-circuit before reaching here, but this is a hard safety net so no + // mapping file can be written while previewing a sync. + if (state.preflight) { + return; + } + const mappingFilePath = path.join(centralMappingsPath, `mappings.json`); if (!fs.existsSync(centralMappingsPath)) { diff --git a/src/core/push.ts b/src/core/push.ts index 6085dac0..2f0eea4c 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -12,6 +12,7 @@ import { markPushStart } from "../lib/incremental"; import { Pushers, PushResults } from "../lib/pushers/orchestrate-pushers"; import { Pull } from "./pull"; +import { preflightReport } from "../lib/preflight/preflight-report"; export class Push { private pushers: Pushers; @@ -27,6 +28,9 @@ export class Push { // Clear failed content registry from any previous sync clearFailedContentRegistry(); + // Preflight (PROD-2203): start each run with a clean report of planned actions + preflightReport.reset(); + // Initialize logger for push operation // Determine if this is a sync operation by checking if both source and target GUIDs exist initializeLogger(isSync ? "sync" : "push"); @@ -142,7 +146,8 @@ export class Push { let autoPublishErrors: Array<{ locale: string; type: string; error: string }> = []; // Auto-publish if enabled (failures are expected and shouldn't block publish) - if (isSync && autoPublish) { + // Preflight (PROD-2203): never auto-publish — nothing was written to the target. + if (isSync && autoPublish && !state.preflight) { autoPublishErrors = await this.executeAutoPublish(results, autoPublish); } @@ -209,6 +214,15 @@ export class Push { }); } + // Preflight (PROD-2203): print the report of actions that WOULD have been + // taken, and signal conflicts via a non-zero exit code so automation can gate on it. + if (state.preflight) { + preflightReport.print(); + if (preflightReport.hasConflicts()) { + process.exitCode = 1; + } + } + // Only exit if not called from another operation return { diff --git a/src/core/state.ts b/src/core/state.ts index 2e9e57e7..1fe90385 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -35,6 +35,8 @@ export interface State { // Operation control overwrite: boolean; + preflight: boolean; // Preflight mode - report planned sync actions without writing to target/mappings + preflightJson: boolean; // Emit the preflight report as machine-readable JSON // Workflow operation control operationType?: string; // Workflow operation: publish, unpublish, approve, decline, requestApproval @@ -77,7 +79,6 @@ export interface State { token: string | null; localServer: string; isAgilityDev: boolean; - forceNGROK: boolean; // Push/Pull/Sync flags isPush: boolean; @@ -117,6 +118,8 @@ export const state: State = { // Operation control overwrite: false, + preflight: false, + preflightJson: false, autoPublish: "", // Empty string = disabled // Explicit ID overrides (bypass mappings lookup) @@ -141,7 +144,6 @@ export const state: State = { token: null, localServer: "", isAgilityDev: false, - forceNGROK: false, isPush: false, isPull: false, isSync: false, @@ -215,6 +217,10 @@ export function setState(argv: any) { // Operation control if (argv.overwrite !== undefined) state.overwrite = argv.overwrite; + // Operation control (preflight) + if (argv.preflight !== undefined) state.preflight = argv.preflight; + if (argv.preflightJson !== undefined) state.preflightJson = argv.preflightJson; + // Workflow operation control if (argv.operationType !== undefined) state.operationType = argv.operationType; if (argv.autoPublish !== undefined) state.autoPublish = argv.autoPublish; @@ -395,6 +401,8 @@ export function resetState() { // Operation control state.overwrite = false; + state.preflight = false; + state.preflightJson = false; // Workflow operation control state.operationType = undefined; @@ -426,7 +434,6 @@ export function resetState() { state.token = null; state.localServer = ""; state.isAgilityDev = false; - state.forceNGROK = false; } /** diff --git a/src/core/system-args.ts b/src/core/system-args.ts index 1a366b42..16159807 100644 --- a/src/core/system-args.ts +++ b/src/core/system-args.ts @@ -78,6 +78,24 @@ export const systemArgs = { default: "", }, + // Preflight (dry-run preview) args + preflight: { + describe: + "Preflight mode (sync/push only): run the full source-pull, target-pull, dependency analysis and change detection, then report the creates/updates/skips/conflicts that a real sync would produce — WITHOUT writing anything to the target instance or mapping files. Exits non-zero if conflicts are detected.", + demandOption: false, + type: "boolean" as const, + alias: ["pre-flight", "Preflight", "PREFLIGHT", "PreFlight"], + default: false, + }, + preflightJson: { + describe: + "Emit the --preflight report as machine-readable JSON instead of the human-readable summary.", + demandOption: false, + type: "boolean" as const, + alias: ["preflight-json", "preflightjson", "PreflightJson", "PREFLIGHT_JSON"], + default: false, + }, + // **Explicit ID Override for Workflow Operations** contentIDs: { describe: @@ -177,6 +195,8 @@ export interface SystemArgs { clean?: boolean; generate?: boolean; operationType?: string; // Workflow operation: publish, unpublish, approve, decline, requestApproval + preflight?: boolean; // Preflight mode - report planned sync actions without writing to target/mappings + preflightJson?: boolean; // Emit the preflight report as machine-readable JSON verbose?: boolean; overwrite?: boolean; elements?: string; diff --git a/src/core/tests/fileOperations.test.ts b/src/core/tests/fileOperations.test.ts index 44b8e8f9..34e86377 100644 --- a/src/core/tests/fileOperations.test.ts +++ b/src/core/tests/fileOperations.test.ts @@ -3,6 +3,7 @@ import * as os from "os"; import * as path from "path"; import { fileOperations } from "../fileOperations"; import { resetState, setState } from "../state"; +import { preflightReport } from "../../lib/preflight/preflight-report"; let tmpDir: string; @@ -16,6 +17,7 @@ afterAll(() => { beforeEach(() => { resetState(); + preflightReport.reset(); setState({ rootPath: tmpDir }); jest.spyOn(console, "log").mockImplementation(() => {}); jest.spyOn(console, "warn").mockImplementation(() => {}); @@ -300,3 +302,69 @@ describe("cliFolderExists", () => { expect(ops.cliFolderExists()).toBe(false); }); }); + +// ─── saveMappingFile — preflight guard (PROD-2203) ──────────────────────────── + +describe("saveMappingFile — preflight guard", () => { + it("writes the mapping file to disk when preflight is disabled", () => { + const ops = new fileOperations("pf-s-guid", "en-us"); + const mappingData = [{ sourceID: 10, targetID: 200 }]; + + ops.saveMappingFile(mappingData, "content", "pf-s-guid", "pf-t-guid", "en-us"); + + const expectedDir = path.join(tmpDir, "mappings", "pf-s-guid-pf-t-guid", "en-us", "content"); + const expectedFile = path.join(expectedDir, "mappings.json"); + expect(fs.existsSync(expectedFile)).toBe(true); + const written = JSON.parse(fs.readFileSync(expectedFile, "utf8")); + expect(written).toEqual(mappingData); + }); + + it("does NOT write any file to disk when preflight is enabled", () => { + setState({ preflight: true }); + const ops = new fileOperations("pf2-s-guid", "en-us"); + const mappingData = [{ sourceID: 11, targetID: 201 }]; + + ops.saveMappingFile(mappingData, "content", "pf2-s-guid", "pf2-t-guid", "en-us"); + + const expectedDir = path.join(tmpDir, "mappings", "pf2-s-guid-pf2-t-guid", "en-us", "content"); + const expectedFile = path.join(expectedDir, "mappings.json"); + expect(fs.existsSync(expectedFile)).toBe(false); + }); + + it("does NOT create the mapping directory when preflight is enabled", () => { + setState({ preflight: true }); + const ops = new fileOperations("pf3-s-guid", "en-us"); + + ops.saveMappingFile([{ sourceID: 1, targetID: 2 }], "models", "pf3-s-guid", "pf3-t-guid", "en-us"); + + const expectedDir = path.join(tmpDir, "mappings", "pf3-s-guid-pf3-t-guid", "en-us", "models"); + expect(fs.existsSync(expectedDir)).toBe(false); + }); + + it("does not overwrite an existing mapping file when preflight is enabled", () => { + // First write normally to establish an existing file + const ops = new fileOperations("pf4-s-guid", "en-us"); + const originalData = [{ sourceID: 50, targetID: 500 }]; + ops.saveMappingFile(originalData, "content", "pf4-s-guid", "pf4-t-guid", "en-us"); + + const expectedFile = path.join( + tmpDir, + "mappings", + "pf4-s-guid-pf4-t-guid", + "en-us", + "content", + "mappings.json" + ); + expect(fs.existsSync(expectedFile)).toBe(true); + + // Now enable preflight and attempt to overwrite with different data + setState({ preflight: true }); + const newOps = new fileOperations("pf4-s-guid", "en-us"); + const newData = [{ sourceID: 99, targetID: 999 }]; + newOps.saveMappingFile(newData, "content", "pf4-s-guid", "pf4-t-guid", "en-us"); + + // File should still contain the original data + const written = JSON.parse(fs.readFileSync(expectedFile, "utf8")); + expect(written).toEqual(originalData); + }); +}); diff --git a/src/lib/preflight/preflight-report.ts b/src/lib/preflight/preflight-report.ts new file mode 100644 index 00000000..a06fc86c --- /dev/null +++ b/src/lib/preflight/preflight-report.ts @@ -0,0 +1,190 @@ +/** + * Preflight report collector (PROD-2203) + * + * Central, in-memory collector for the actions a sync/push WOULD take when run + * in `--preflight` mode. Each pusher records the outcome of its change-detection + * (create / update / skip / conflict) here instead of writing to the target + * instance or mapping files. At the end of the run the report is rendered as a + * human-readable table (default) or JSON (`--preflight-json`). + * + * This is a process-level singleton because the push pipeline threads `state` + * globally rather than passing context objects down to each pusher. + */ +import ansiColors from "ansi-colors"; +import { state } from "../../core/state"; + +export type PreflightAction = "create" | "update" | "skip" | "conflict"; + +export interface PreflightEntry { + /** Phase / element type, e.g. "Models", "Containers", "Content". */ + phase: string; + /** What the real sync would do for this item. */ + action: PreflightAction; + /** Human-friendly identifier for the item (reference name, title, path, etc.). */ + name: string; + /** Locale, for locale-scoped phases (content, pages). */ + locale?: string; + /** Optional extra context, e.g. the reason for a skip or the nature of a conflict. */ + detail?: string; +} + +export interface PreflightPhaseSummary { + phase: string; + create: number; + update: number; + skip: number; + conflict: number; + entries: PreflightEntry[]; +} + +const ACTION_ORDER: PreflightAction[] = ["create", "update", "skip", "conflict"]; + +const ACTION_COLOR: Record string> = { + create: ansiColors.green, + update: ansiColors.cyan, + skip: ansiColors.gray, + conflict: ansiColors.red, +}; + +const ACTION_GLYPH: Record = { + create: "+", + update: "~", + skip: "·", + conflict: "✗", +}; + +class PreflightReport { + private entries: PreflightEntry[] = []; + + /** Whether preflight mode is active for the current run. */ + isEnabled(): boolean { + return state.preflight === true; + } + + /** Clear all recorded actions (called per-command via resetState). */ + reset(): void { + this.entries = []; + } + + /** + * Record a planned action. Safe to call unconditionally — it is a no-op when + * preflight mode is off, so pushers can record without branching first. + */ + record(entry: PreflightEntry): void { + if (!this.isEnabled()) return; + this.entries.push(entry); + } + + /** True if any item would conflict (used to set a non-zero exit code). */ + hasConflicts(): boolean { + return this.entries.some((e) => e.action === "conflict"); + } + + getEntries(): PreflightEntry[] { + return this.entries; + } + + /** Per-phase counts, preserving first-seen phase order. */ + getPhaseSummaries(): PreflightPhaseSummary[] { + const byPhase = new Map(); + for (const entry of this.entries) { + let summary = byPhase.get(entry.phase); + if (!summary) { + summary = { phase: entry.phase, create: 0, update: 0, skip: 0, conflict: 0, entries: [] }; + byPhase.set(entry.phase, summary); + } + summary[entry.action]++; + summary.entries.push(entry); + } + return Array.from(byPhase.values()); + } + + /** Aggregate totals across all phases. */ + getTotals(): Record { + const totals: Record = { create: 0, update: 0, skip: 0, conflict: 0 }; + for (const entry of this.entries) { + totals[entry.action]++; + } + return totals; + } + + /** Machine-readable representation (for --json). */ + toJSON(): { + preflight: true; + totals: Record; + hasConflicts: boolean; + phases: PreflightPhaseSummary[]; + } { + return { + preflight: true, + totals: this.getTotals(), + hasConflicts: this.hasConflicts(), + phases: this.getPhaseSummaries(), + }; + } + + /** Human-readable, colorized per-phase summary. */ + renderTable(): string { + const lines: string[] = []; + const bar = "═".repeat(60); + + lines.push(ansiColors.cyan(bar)); + lines.push(ansiColors.cyan("🔎 PREFLIGHT — no changes were written to the target or mappings")); + lines.push(ansiColors.cyan(bar)); + + const phases = this.getPhaseSummaries(); + if (phases.length === 0) { + lines.push(ansiColors.gray("\nNothing to do — no creates, updates, skips, or conflicts detected.\n")); + return lines.join("\n"); + } + + for (const phase of phases) { + const header = + `\n${ansiColors.bold(phase.phase)}: ` + + ACTION_COLOR.create(`${phase.create} create, `) + + ACTION_COLOR.update(`${phase.update} update, `) + + ACTION_COLOR.skip(`${phase.skip} skip, `) + + ACTION_COLOR.conflict(`${phase.conflict} conflict`); + lines.push(header); + + // List entries grouped by action so creates/updates/conflicts are easy to scan. + for (const action of ACTION_ORDER) { + const items = phase.entries.filter((e) => e.action === action); + for (const item of items) { + const color = ACTION_COLOR[action]; + const localePart = item.locale ? ansiColors.gray(`[${item.locale}] `) : ""; + const detailPart = item.detail ? ansiColors.gray(` — ${item.detail}`) : ""; + lines.push(` ${color(ACTION_GLYPH[action])} ${localePart}${item.name}${detailPart}`); + } + } + } + + const totals = this.getTotals(); + lines.push(ansiColors.cyan("\n" + bar)); + lines.push( + ansiColors.bold("TOTAL: ") + + ACTION_COLOR.create(`${totals.create} create, `) + + ACTION_COLOR.update(`${totals.update} update, `) + + ACTION_COLOR.skip(`${totals.skip} skip, `) + + ACTION_COLOR.conflict(`${totals.conflict} conflict`) + ); + if (this.hasConflicts()) { + lines.push(ansiColors.red("\n⚠️ Conflicts detected — a real sync would require --overwrite (exit code 1).")); + } + lines.push(ansiColors.cyan(bar)); + + return lines.join("\n"); + } + + /** Print the report to stdout in the configured format. */ + print(): void { + if (state.preflightJson) { + console.log(JSON.stringify(this.toJSON(), null, 2)); + } else { + console.log(this.renderTable()); + } + } +} + +/** Process-level singleton shared by all pushers. */ +export const preflightReport = new PreflightReport(); diff --git a/src/lib/preflight/tests/preflight-report.test.ts b/src/lib/preflight/tests/preflight-report.test.ts new file mode 100644 index 00000000..7cc2352c --- /dev/null +++ b/src/lib/preflight/tests/preflight-report.test.ts @@ -0,0 +1,372 @@ +import { preflightReport, PreflightEntry } from "../preflight-report"; +import { resetState, setState } from "core/state"; + +beforeEach(() => { + resetState(); + preflightReport.reset(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ─── isEnabled ──────────────────────────────────────────────────────────────── + +describe("isEnabled", () => { + it("returns false when state.preflight is false (default)", () => { + expect(preflightReport.isEnabled()).toBe(false); + }); + + it("returns true when state.preflight is true", () => { + setState({ preflight: true }); + expect(preflightReport.isEnabled()).toBe(true); + }); +}); + +// ─── record / getEntries ────────────────────────────────────────────────────── + +describe("record", () => { + it("is a no-op when preflight is disabled", () => { + preflightReport.record({ phase: "Models", action: "create", name: "my-model" }); + expect(preflightReport.getEntries()).toHaveLength(0); + }); + + it("records an entry when preflight is enabled", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "my-model" }); + expect(preflightReport.getEntries()).toHaveLength(1); + }); + + it("records multiple entries in order", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "model-1" }); + preflightReport.record({ phase: "Models", action: "update", name: "model-2" }); + preflightReport.record({ phase: "Content", action: "skip", name: "content-1", locale: "en-us" }); + + const entries = preflightReport.getEntries(); + expect(entries).toHaveLength(3); + expect(entries[0].name).toBe("model-1"); + expect(entries[1].name).toBe("model-2"); + expect(entries[2].name).toBe("content-1"); + }); + + it("stores all optional fields on the entry", () => { + setState({ preflight: true }); + const entry: PreflightEntry = { + phase: "Content", + action: "conflict", + name: "my-item", + locale: "fr-ca", + detail: "target is newer", + }; + preflightReport.record(entry); + const recorded = preflightReport.getEntries()[0]; + expect(recorded).toEqual(entry); + }); + + it("remains no-op across multiple calls when preflight is disabled", () => { + for (let i = 0; i < 5; i++) { + preflightReport.record({ phase: "Models", action: "create", name: `item-${i}` }); + } + expect(preflightReport.getEntries()).toHaveLength(0); + }); +}); + +// ─── reset ──────────────────────────────────────────────────────────────────── + +describe("reset", () => { + it("clears all recorded entries", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Models", action: "update", name: "m2" }); + + preflightReport.reset(); + expect(preflightReport.getEntries()).toHaveLength(0); + }); + + it("can be called on an empty report without error", () => { + expect(() => preflightReport.reset()).not.toThrow(); + }); +}); + +// ─── hasConflicts ───────────────────────────────────────────────────────────── + +describe("hasConflicts", () => { + it("returns false when there are no entries", () => { + setState({ preflight: true }); + expect(preflightReport.hasConflicts()).toBe(false); + }); + + it("returns false when there are entries but none are conflicts", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Models", action: "update", name: "m2" }); + preflightReport.record({ phase: "Content", action: "skip", name: "c1" }); + expect(preflightReport.hasConflicts()).toBe(false); + }); + + it("returns true when at least one entry has action=conflict", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Content", action: "conflict", name: "bad-item" }); + expect(preflightReport.hasConflicts()).toBe(true); + }); +}); + +// ─── getTotals ──────────────────────────────────────────────────────────────── + +describe("getTotals", () => { + it("returns all-zeros totals when there are no entries", () => { + setState({ preflight: true }); + expect(preflightReport.getTotals()).toEqual({ create: 0, update: 0, skip: 0, conflict: 0 }); + }); + + it("correctly counts each action type", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Models", action: "create", name: "m2" }); + preflightReport.record({ phase: "Content", action: "update", name: "c1" }); + preflightReport.record({ phase: "Content", action: "skip", name: "c2" }); + preflightReport.record({ phase: "Content", action: "skip", name: "c3" }); + preflightReport.record({ phase: "Pages", action: "conflict", name: "p1" }); + + expect(preflightReport.getTotals()).toEqual({ create: 2, update: 1, skip: 2, conflict: 1 }); + }); + + it("counts only the actions that appear", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "update", name: "m1" }); + + expect(preflightReport.getTotals()).toEqual({ create: 0, update: 1, skip: 0, conflict: 0 }); + }); +}); + +// ─── getPhaseSummaries ──────────────────────────────────────────────────────── + +describe("getPhaseSummaries", () => { + it("returns empty array when there are no entries", () => { + setState({ preflight: true }); + expect(preflightReport.getPhaseSummaries()).toEqual([]); + }); + + it("groups entries by phase with correct per-action counts", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Models", action: "update", name: "m2" }); + preflightReport.record({ phase: "Models", action: "create", name: "m3" }); + preflightReport.record({ phase: "Content", action: "skip", name: "c1" }); + + const summaries = preflightReport.getPhaseSummaries(); + expect(summaries).toHaveLength(2); + + const modelsSummary = summaries.find((s) => s.phase === "Models"); + expect(modelsSummary).toBeDefined(); + expect(modelsSummary!.create).toBe(2); + expect(modelsSummary!.update).toBe(1); + expect(modelsSummary!.skip).toBe(0); + expect(modelsSummary!.conflict).toBe(0); + expect(modelsSummary!.entries).toHaveLength(3); + + const contentSummary = summaries.find((s) => s.phase === "Content"); + expect(contentSummary).toBeDefined(); + expect(contentSummary!.skip).toBe(1); + }); + + it("preserves first-seen phase insertion order", () => { + setState({ preflight: true }); + // Record in a deliberate, non-alphabetical order + preflightReport.record({ phase: "Pages", action: "create", name: "p1" }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Containers", action: "update", name: "ct1" }); + + const summaries = preflightReport.getPhaseSummaries(); + expect(summaries.map((s) => s.phase)).toEqual(["Pages", "Models", "Containers"]); + }); + + it("includes each entry in the phase entries array", () => { + setState({ preflight: true }); + const e1: PreflightEntry = { phase: "Assets", action: "skip", name: "logo.png" }; + const e2: PreflightEntry = { phase: "Assets", action: "create", name: "hero.jpg" }; + preflightReport.record(e1); + preflightReport.record(e2); + + const summary = preflightReport.getPhaseSummaries()[0]; + expect(summary.entries).toContainEqual(e1); + expect(summary.entries).toContainEqual(e2); + }); +}); + +// ─── toJSON ─────────────────────────────────────────────────────────────────── + +describe("toJSON", () => { + it("returns the correct shape with preflight: true", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + + const result = preflightReport.toJSON(); + expect(result.preflight).toBe(true); + expect(result).toHaveProperty("totals"); + expect(result).toHaveProperty("hasConflicts"); + expect(result).toHaveProperty("phases"); + }); + + it("totals and hasConflicts are consistent with entries", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Content", action: "conflict", name: "c1" }); + preflightReport.record({ phase: "Content", action: "create", name: "c2" }); + + const result = preflightReport.toJSON(); + expect(result.totals).toEqual({ create: 1, update: 0, skip: 0, conflict: 1 }); + expect(result.hasConflicts).toBe(true); + }); + + it("hasConflicts is false when no conflicts exist", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + + const result = preflightReport.toJSON(); + expect(result.hasConflicts).toBe(false); + }); + + it("phases array matches getPhaseSummaries()", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Templates", action: "skip", name: "t1" }); + + const result = preflightReport.toJSON(); + expect(result.phases).toEqual(preflightReport.getPhaseSummaries()); + }); + + it("returns zero-count totals and empty phases when no entries recorded", () => { + setState({ preflight: true }); + const result = preflightReport.toJSON(); + expect(result.totals).toEqual({ create: 0, update: 0, skip: 0, conflict: 0 }); + expect(result.phases).toEqual([]); + expect(result.hasConflicts).toBe(false); + }); +}); + +// ─── renderTable ────────────────────────────────────────────────────────────── + +describe("renderTable", () => { + it("always contains PREFLIGHT in the header", () => { + setState({ preflight: true }); + const output = preflightReport.renderTable(); + expect(output).toContain("PREFLIGHT"); + }); + + it("includes phase names when entries have been recorded", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Content", action: "update", name: "c1" }); + + const output = preflightReport.renderTable(); + expect(output).toContain("Models"); + expect(output).toContain("Content"); + }); + + it("includes item names in the output", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "unique-model-name" }); + + const output = preflightReport.renderTable(); + expect(output).toContain("unique-model-name"); + }); + + it("includes a conflict warning when conflicts exist", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Content", action: "conflict", name: "conflicted-item" }); + + const output = preflightReport.renderTable(); + expect(output).toContain("Conflicts detected"); + }); + + it("does NOT include conflict warning when there are no conflicts", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Content", action: "skip", name: "c1" }); + + const output = preflightReport.renderTable(); + expect(output).not.toContain("Conflicts detected"); + }); + + it("indicates nothing to do when there are no entries", () => { + setState({ preflight: true }); + const output = preflightReport.renderTable(); + expect(output).toContain("Nothing to do"); + }); + + it("includes count totals in the footer", () => { + setState({ preflight: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + preflightReport.record({ phase: "Models", action: "update", name: "m2" }); + + const output = preflightReport.renderTable(); + expect(output).toContain("TOTAL"); + }); +}); + +// ─── print ──────────────────────────────────────────────────────────────────── + +describe("print", () => { + it("outputs JSON when state.preflightJson is true", () => { + setState({ preflight: true, preflightJson: true }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + + preflightReport.print(); + + const calls = (console.log as jest.Mock).mock.calls; + expect(calls.length).toBeGreaterThanOrEqual(1); + + // Find the call that contains the JSON preflight output + const jsonCall = calls.find((args: any[]) => { + try { + const parsed = JSON.parse(args[0]); + return parsed.preflight === true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + + const parsed = JSON.parse(jsonCall[0]); + expect(parsed.preflight).toBe(true); + expect(parsed).toHaveProperty("totals"); + expect(parsed).toHaveProperty("hasConflicts"); + expect(parsed).toHaveProperty("phases"); + }); + + it("outputs the rendered table when state.preflightJson is false", () => { + setState({ preflight: true, preflightJson: false }); + preflightReport.record({ phase: "Models", action: "create", name: "m1" }); + + preflightReport.print(); + + const allOutput = (console.log as jest.Mock).mock.calls.flat().join("\n"); + expect(allOutput).toContain("PREFLIGHT"); + }); + + it("JSON output is valid JSON that can be parsed", () => { + setState({ preflight: true, preflightJson: true }); + preflightReport.record({ phase: "Pages", action: "conflict", name: "home-page" }); + + preflightReport.print(); + + const calls = (console.log as jest.Mock).mock.calls; + const jsonCall = calls.find((args: any[]) => { + try { + const parsed = JSON.parse(args[0]); + return parsed.preflight === true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + + const parsed = JSON.parse(jsonCall[0]); + expect(parsed.hasConflicts).toBe(true); + expect(parsed.totals.conflict).toBe(1); + }); +}); diff --git a/src/lib/pushers/asset-pusher.ts b/src/lib/pushers/asset-pusher.ts index 574ce211..5b49b926 100644 --- a/src/lib/pushers/asset-pusher.ts +++ b/src/lib/pushers/asset-pusher.ts @@ -8,6 +8,7 @@ const FormData = require("form-data"); import { fileOperations } from "../../core/fileOperations"; import path from "path"; import { GalleryMapper } from "lib/mappers/gallery-mapper"; +import { preflightReport } from "../preflight/preflight-report"; /** * Extract meaningful error message from API errors @@ -109,6 +110,12 @@ export async function pushAssets( if (!existingMapping && targetAssetByOriginKey) { referenceMapper.addMapping(media, targetAssetByOriginKey); logger.asset.skipped(media, "already exists in target by path", targetGuid[0]); + preflightReport.record({ + phase: "Assets", + action: "skip", + name: media.fileName, + detail: "already exists in target by path", + }); skipped++; continue; } @@ -134,36 +141,54 @@ export async function pushAssets( if (shouldCreate) { // Asset needs to be created (doesn't exist in target) - const createdAsset = await createAsset( - media, - absoluteLocalFilePath, - folderPath, - apiClient, - sourceGuid[0], - targetGuid[0], - referenceMapper, - logger - ); - referenceMapper.addMapping(media, createdAsset); + if (state.preflight) { + preflightReport.record({ phase: "Assets", action: "create", name: media.fileName }); + } else { + const createdAsset = await createAsset( + media, + absoluteLocalFilePath, + folderPath, + apiClient, + sourceGuid[0], + targetGuid[0], + referenceMapper, + logger + ); + referenceMapper.addMapping(media, createdAsset); + } successful++; } else if (shouldUpdate) { // Asset exists but needs updating - const updatedAsset = await updateAsset( - media, - absoluteLocalFilePath, - folderPath, - apiClient, - sourceGuid[0], - targetGuid[0], - referenceMapper, - logger - ); - referenceMapper.addMapping(media, updatedAsset); + if (state.preflight) { + preflightReport.record({ phase: "Assets", action: "update", name: media.fileName }); + } else { + const updatedAsset = await updateAsset( + media, + absoluteLocalFilePath, + folderPath, + apiClient, + sourceGuid[0], + targetGuid[0], + referenceMapper, + logger + ); + referenceMapper.addMapping(media, updatedAsset); + } successful++; } else if (shouldSkip) { // Asset exists and is up to date - skip logger.asset.skipped(media, "up to date, skipping", targetGuid[0]); + preflightReport.record({ phase: "Assets", action: "skip", name: media.fileName, detail: "up to date" }); skipped++; + } else if (isConflict) { + // Conflict: both source and target changed and --overwrite was not set. + // The real sync silently does nothing here; surface it in preflight. + preflightReport.record({ + phase: "Assets", + action: "conflict", + name: media.fileName, + detail: "target changed; use --overwrite to force", + }); } } catch (error: any) { const errorMsg = extractErrorMessage(error); diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 6c692527..41ed514c 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -5,6 +5,7 @@ import { ContainerMapper } from "lib/mappers/container-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; import { FailureDetail, PusherResult } from "types/sourceData"; +import { preflightReport } from "../preflight/preflight-report"; /** * Container pusher with enhanced version-based comparison @@ -92,6 +93,12 @@ export async function pushContainers( `target container: ${existingMapping.targetReferenceName} was deleted, skipping!`, targetGuid[0] ); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: sourceContainer.referenceName, + detail: "target container was deleted", + }); skipped++; continue; } @@ -108,15 +115,37 @@ export async function pushContainers( if (targetModelID < 1) { logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: sourceContainer.referenceName, + detail: "target model mapping not found", + }); skipped++; } else if (shouldSkip) { // Container exists and is up to date - skip logger.container.skipped(sourceContainer, "up to date, skipping", targetGuid[0]); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: sourceContainer.referenceName, + detail: "up to date", + }); skipped++; } else if (hasTargetChanges && !overwrite) { // Container exists and is up to date - skip logger.container.error(sourceContainer, "Conflict detected, use --overwrite to force changes", targetGuid[0]); + preflightReport.record({ + phase: "Containers", + action: "conflict", + name: sourceContainer.referenceName, + detail: "target changed; use --overwrite to force", + }); skipped++; + } else if (shouldUpdate && state.preflight) { + // Preflight: would update, but skip the write. + preflightReport.record({ phase: "Containers", action: "update", name: sourceContainer.referenceName }); + successful++; } else if (shouldUpdate) { // Container exists but needs updating const updateResult = await updateExistingContainer( @@ -159,7 +188,17 @@ export async function pushContainers( // Container doesn't exist - create new one if (targetModelID < 1) { logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: sourceContainer.referenceName, + detail: "target model mapping not found", + }); skipped++; + } else if (state.preflight) { + // Preflight: would create, but skip the write. + preflightReport.record({ phase: "Containers", action: "create", name: sourceContainer.referenceName }); + successful++; } else { // Container doesn't exist - create new one const createResult = await createNewContainer( diff --git a/src/lib/pushers/content-pusher/content-pusher.ts b/src/lib/pushers/content-pusher/content-pusher.ts index b6db3d55..adc91ffd 100644 --- a/src/lib/pushers/content-pusher/content-pusher.ts +++ b/src/lib/pushers/content-pusher/content-pusher.ts @@ -84,6 +84,13 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content logger, }); + // Preflight (PROD-2203): the filter has already recorded create/update/skip/conflict. + // Skip the batch write entirely so nothing is sent to the target, but keep + // processing the remaining (normal) content so its actions are reported too. + if (state.preflight) { + totalSuccessful += filteredLinkedContentItems.itemsToProcess.length; + totalSkipped += filteredLinkedContentItems.skippedCount; + } else { const linkedBatchProcessor = new ContentBatchProcessor(linkedBatchConfig); const linkedResult = await linkedBatchProcessor.processBatches( filteredLinkedContentItems.itemsToProcess.reverse(), @@ -115,6 +122,7 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content } }); } + } } // Process normal content items first (no dependencies) @@ -139,6 +147,11 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content targetData, logger, }); + // Preflight (PROD-2203): filter already recorded the actions; skip the write. + if (state.preflight) { + totalSuccessful += filteredNormalContentItems.itemsToProcess.length; + totalSkipped += filteredNormalContentItems.skippedCount; + } else { const normalBatchProcessor = new ContentBatchProcessor(normalBatchConfig); const normalResult = await normalBatchProcessor.processBatches( filteredNormalContentItems.itemsToProcess as ContentItem[], @@ -170,6 +183,7 @@ export async function pushContent(sourceData: ContentItem[], targetData: Content } }); } + } } // Convert batch result to expected PusherResult format diff --git a/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts b/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts index d7eee011..9102eddd 100644 --- a/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts +++ b/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts @@ -4,6 +4,7 @@ import { findContentInTargetInstance } from "./find-content-in-target-instance"; import { ApiClient, ContentItem } from "@agility/management-sdk"; import { Logs } from "core/logs"; import { state } from "core"; +import { preflightReport } from "../../../preflight/preflight-report"; /** * Filter content items for processing @@ -63,20 +64,30 @@ export async function filterContentItemsForProcessing({ } itemsToSkip.push(contentItem); conflictCount++; + preflightReport.record({ + phase: "Content", + action: "conflict", + name: itemName, + locale, + detail: reason || "changes detected in both source and target", + }); continue; } else if (shouldCreate) { // Content doesn't exist - include it for creation itemsToProcess.push(contentItem); createCount++; + preflightReport.record({ phase: "Content", action: "create", name: itemName, locale }); } else if (shouldUpdate) { // Content exists but needs updating itemsToProcess.push(contentItem); updateCount++; + preflightReport.record({ phase: "Content", action: "update", name: itemName, locale }); } else if (shouldSkip) { // Content exists and is up to date - skip logger.content.skipped(contentItem, "up to date, skipping", locale, targetGuid); itemsToSkip.push(contentItem); skipCount++; + preflightReport.record({ phase: "Content", action: "skip", name: itemName, locale, detail: "up to date" }); } } catch (error: any) { // If we can't check, err on the side of processing it diff --git a/src/lib/pushers/gallery-pusher.ts b/src/lib/pushers/gallery-pusher.ts index 34705e2f..512a4c4f 100644 --- a/src/lib/pushers/gallery-pusher.ts +++ b/src/lib/pushers/gallery-pusher.ts @@ -3,6 +3,7 @@ import ansiColors from "ansi-colors"; import { Logs } from "core/logs"; import { state, getState, getApiClient, getLoggerForGuid } from "core/state"; import { GalleryMapper } from "lib/mappers/gallery-mapper"; +import { preflightReport } from "../preflight/preflight-report"; /** * Extract meaningful error message from API errors @@ -76,6 +77,12 @@ export async function pushGalleries( // Gallery exists in target by name but no mapping - add mapping and skip referenceMapper.addMapping(sourceGallery, targetGalleryByName); logger.gallery.skipped(sourceGallery, "already exists in target by name", targetGuid[0]); + preflightReport.record({ + phase: "Galleries", + action: "skip", + name: sourceGallery.name, + detail: "already exists in target by name", + }); skipped++; continue; } @@ -84,7 +91,11 @@ export async function pushGalleries( if (shouldCreate) { // Gallery needs to be created (doesn't exist in target) - await createGallery(sourceGallery, apiClient, targetGuid[0], referenceMapper, logger); + if (state.preflight) { + preflightReport.record({ phase: "Galleries", action: "create", name: sourceGallery.name }); + } else { + await createGallery(sourceGallery, apiClient, targetGuid[0], referenceMapper, logger); + } successful++; } else if (existingMapping) { const targetGallery = targetGalleryById || targetGalleryByName; @@ -100,19 +111,39 @@ export async function pushGalleries( if (shouldUpdate) { // Gallery exists but needs updating - await updateGallery( - sourceGallery, - existingMapping.targetMediaGroupingID, - apiClient, - targetGuid[0], - referenceMapper, - logger - ); + if (state.preflight) { + preflightReport.record({ phase: "Galleries", action: "update", name: sourceGallery.name }); + } else { + await updateGallery( + sourceGallery, + existingMapping.targetMediaGroupingID, + apiClient, + targetGuid[0], + referenceMapper, + logger + ); + } successful++; } else if (shouldSkip) { // Gallery exists and is up to date - skip logger.gallery.skipped(sourceGallery, "up to date, skipping", targetGuid[0]); + preflightReport.record({ + phase: "Galleries", + action: "skip", + name: sourceGallery.name, + detail: "up to date", + }); skipped++; + } else { + // Neither update nor skip: target changed while source also changed and + // --overwrite was not set. The real sync silently does nothing here; in + // preflight we surface it as a conflict so it isn't missed. + preflightReport.record({ + phase: "Galleries", + action: "conflict", + name: sourceGallery.name, + detail: "target changed; use --overwrite to force", + }); } } } catch (error: any) { diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index d0ee97a3..0ac38169 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -3,6 +3,7 @@ import { getApiClient, state, getLoggerForGuid } from "../../core/state"; import { PusherResult, FailureDetail } from "../../types/sourceData"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; +import { preflightReport } from "../preflight/preflight-report"; /** * Two Agility models can share a `referenceName` while differing in `contentDefinitionTypeID` @@ -202,6 +203,34 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp continue; } } + // Preflight (PROD-2203): report the planned model actions and skip all writes. + // A "target has changed" skip is surfaced as a conflict since a real sync would + // need --overwrite to proceed. + if (state.preflight) { + for (const model of shouldCreateStub) { + preflightReport.record({ phase: "Models", action: "create", name: model.referenceName }); + } + for (const model of shouldUpdateFields) { + preflightReport.record({ phase: "Models", action: "update", name: model.referenceName }); + } + for (const { model, reason } of shouldSkip) { + const isConflict = /target model has changed/i.test(reason); + preflightReport.record({ + phase: "Models", + action: isConflict ? "conflict" : "skip", + name: model.referenceName, + detail: reason, + }); + } + return { + status: "success", + successful: shouldCreateStub.length + shouldUpdateFields.length, + failed: 0, + skipped: shouldSkip.length, + failureDetails: [], + }; + } + for (const model of shouldCreateStub) { const result = await createNewModel(model, referenceMapper, apiClient, targetGuid[0], logger); if (result === "created") { diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index efc20988..05f82225 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -8,6 +8,7 @@ import { findPageInOtherLocale, OtherLocaleMapping } from "./find-page-in-other- import { Logs } from "core/logs"; import { state, getFailedContent, contentExistsInSourceData, contentExistsInOtherLocale } from "core/state"; import { PageModuleExtended } from "types/sourceData"; +import { preflightReport } from "../../preflight/preflight-report"; interface Props { channel: string; @@ -59,6 +60,13 @@ export async function processPage({ channel, targetGuid ); + preflightReport.record({ + phase: "Pages", + action: "skip", + name: page.name, + locale, + detail: `missing page template ${page.templateName}`, + }); return { status: "skip" }; } targetTemplate = templateMapper.getMappedEntity(templateRef, "target") as mgmtApi.PageModel; @@ -131,6 +139,7 @@ export async function processPage({ console.warn(` - Target: ${targetUrl}`); if (!overwrite) { + preflightReport.record({ phase: "Pages", action: "conflict", name: page.name, locale, detail: reason }); return { status: "skip" }; // Prevent conflicting pages from being processed and auto-published } // overwrite mode: warn but continue processing @@ -142,9 +151,22 @@ export async function processPage({ } logger.page.skipped(page, "up to date, skipping", locale, channel, targetGuid); + preflightReport.record({ phase: "Pages", action: "skip", name: page.name, locale, detail: "up to date" }); return { status: "skip" }; // Skip processing - page already exists } + // Preflight (PROD-2203): we've decided this page would be created or updated. + // Record the planned action and skip the actual write to the target. + if (state.preflight) { + preflightReport.record({ + phase: "Pages", + action: createRequired ? "create" : "update", + name: page.name, + locale, + }); + return { status: "success" }; + } + // Map Content IDs in Zones // Handle folder pages which may not have zones let sourceZones = page.zones ? { ...page.zones } : {}; // Clone zones or use empty object diff --git a/src/lib/pushers/template-pusher.ts b/src/lib/pushers/template-pusher.ts index 9e5f9143..d5d9b984 100644 --- a/src/lib/pushers/template-pusher.ts +++ b/src/lib/pushers/template-pusher.ts @@ -4,6 +4,7 @@ import { TemplateMapper } from "lib/mappers/template-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { ContainerMapper } from "lib/mappers/container-mapper"; import { FailureDetail, PusherResult } from "types/sourceData"; +import { preflightReport } from "../preflight/preflight-report"; export async function pushTemplates( @@ -70,8 +71,23 @@ export async function pushTemplates( if (shouldSkip) { logger.template.skipped(sourceTemplate, "Up to date, skipping", targetGuid[0]); + preflightReport.record({ + phase: "Templates", + action: "skip", + name: sourceTemplate.pageTemplateName, + detail: "up to date", + }); skipped++; } + else if (state.preflight) { + // Preflight: report the planned create/update and skip the write. + preflightReport.record({ + phase: "Templates", + action: shouldUpdate ? "update" : "create", + name: sourceTemplate.pageTemplateName, + }); + successful++; + } else { let targetId = shouldUpdate ? targetTemplate?.pageTemplateID : -1;