Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/core/fileOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
16 changes: 15 additions & 1 deletion src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,7 +79,6 @@ export interface State {
token: string | null;
localServer: string;
isAgilityDev: boolean;
forceNGROK: boolean;

// Push/Pull/Sync flags
isPush: boolean;
Expand Down Expand Up @@ -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)
Expand All @@ -141,7 +144,6 @@ export const state: State = {
token: null,
localServer: "",
isAgilityDev: false,
forceNGROK: false,
isPush: false,
isPull: false,
isSync: false,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -395,6 +401,8 @@ export function resetState() {

// Operation control
state.overwrite = false;
state.preflight = false;
state.preflightJson = false;

// Workflow operation control
state.operationType = undefined;
Expand Down Expand Up @@ -426,7 +434,6 @@ export function resetState() {
state.token = null;
state.localServer = "";
state.isAgilityDev = false;
state.forceNGROK = false;
}

/**
Expand Down
20 changes: 20 additions & 0 deletions src/core/system-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions src/core/tests/fileOperations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,6 +17,7 @@ afterAll(() => {

beforeEach(() => {
resetState();
preflightReport.reset();
setState({ rootPath: tmpDir });
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "warn").mockImplementation(() => {});
Expand Down Expand Up @@ -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);
});
});
Loading
Loading