Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ agility sync [options]
| --------------- | ------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--overwrite` | boolean | `false` | Conflict-scoped override. By default, when a target item has its own changes that conflict with the source, the CLI skips it to avoid data loss. With `--overwrite`, those conflicting target items are overwritten with the source version. Non-conflicting updates are applied either way. |
| `--autoPublish` | string | _(disabled)_ | Automatically publish synced items that were published in the source instance. Values: `content`, `pages`, `both`. If flag is provided without a value, defaults to `both`. Items that are only in staging (not published) in the source are skipped. |
| `--preflight` | boolean | `false` | Dry-run preview. Runs the full source-pull, target-pull, dependency analysis and change detection, then reports the creates/updates/skips/conflicts a real sync would produce β€” **without writing anything** to the target instance or mapping files. Exits non-zero when conflicts are detected, so CI can gate a real sync on a clean preflight. |

**Authentication & Environment Options:**

Expand Down Expand Up @@ -134,6 +135,9 @@ agility sync --sourceGuid="abc123" --targetGuid="def456" --elements="Assets"
# Overwrite target items that conflict with the source (forces conflicting updates through)
agility sync --sourceGuid="abc123" --targetGuid="def456" --overwrite

# Preview what a sync would do without writing anything (exits non-zero if conflicts are found)
agility sync --sourceGuid="abc123" --targetGuid="def456" --preflight

# Sync only specified models (models only, no dependencies)
agility sync --sourceGuid="abc123" --targetGuid="def456" --models="BlogPost,BlogCategory"

Expand Down
4 changes: 0 additions & 4 deletions src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ 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 @@ -119,7 +118,6 @@ export const state: State = {
// Operation control
overwrite: false,
preflight: false,
preflightJson: false,
autoPublish: "", // Empty string = disabled

// Explicit ID overrides (bypass mappings lookup)
Expand Down Expand Up @@ -219,7 +217,6 @@ export function setState(argv: any) {

// 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;
Expand Down Expand Up @@ -402,7 +399,6 @@ export function resetState() {
// Operation control
state.overwrite = false;
state.preflight = false;
state.preflightJson = false;

// Workflow operation control
state.operationType = undefined;
Expand Down
9 changes: 0 additions & 9 deletions src/core/system-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,6 @@ export const systemArgs = {
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: {
Expand Down Expand Up @@ -196,7 +188,6 @@ export interface SystemArgs {
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
25 changes: 3 additions & 22 deletions src/lib/preflight/preflight-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 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`).
* human-readable table.
*
* This is a process-level singleton because the push pipeline threads `state`
* globally rather than passing context objects down to each pusher.
Expand Down Expand Up @@ -108,21 +108,6 @@ class PreflightReport {
return totals;
}

/** Machine-readable representation (for --json). */
toJSON(): {
preflight: true;
totals: Record<PreflightAction, number>;
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[] = [];
Expand Down Expand Up @@ -176,13 +161,9 @@ class PreflightReport {
return lines.join("\n");
}

/** Print the report to stdout in the configured format. */
/** Print the report to stdout. */
print(): void {
if (state.preflightJson) {
console.log(JSON.stringify(this.toJSON(), null, 2));
} else {
console.log(this.renderTable());
}
console.log(this.renderTable());
}
}

Expand Down
103 changes: 3 additions & 100 deletions src/lib/preflight/tests/preflight-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,55 +199,6 @@ describe("getPhaseSummaries", () => {
});
});

// ─── 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", () => {
Expand Down Expand Up @@ -311,62 +262,14 @@ describe("renderTable", () => {
// ─── 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 });
it("outputs the rendered table", () => {
setState({ preflight: true });
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);
expect(allOutput).toContain("TOTAL");
});
});
Loading