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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ The `--autoPublish` flag lets you automatically publish synced content and/or pa
4. Publishing is done per-locale to match the workflow API requirements
5. After publishing, reference mappings are updated to reflect the new published versions

A page's components are shared by all of its locales, so a save that adds, removes or reorders components in one locale also puts the page in staging in its other locales. When the page was published in another locale of the run before the save, and is published in the source, `pages` and `both` republish it in that locale too. With `content` or no `--autoPublish`, the sync lists those pages under "Pages Left in Staging by Another Locale's Save" (and in `--jsonSummary` under `autoPublish.warnings`, type `staging`). Locales not included in the run are not checked.

#### Examples

```bash
Expand Down Expand Up @@ -517,6 +519,48 @@ agility reverse-sync --sourceGuid A --targetGuid B --locales en-us --preflight
agility reverse-sync --sourceGuid A --targetGuid B --locales en-us
```

### Verify

Compare a synced target instance against its source and report every difference, independent of the sync's own logs and change tracking.

```bash
agility verify --sourceGuid <source> --targetGuid <target> [--locales en-us,fr-ca] [--channel website] [--models ...] [--pages ...] [--containers ...] [--jsonSummary <path>]
```

Use the same `--sourceGuid` / `--targetGuid` as the forward `sync`. Verify never writes to either instance or to the mapping files.

#### What it does

1. Waits until neither instance has a content update in progress (preview and published), up to 10 minutes.
2. Runs a full pull of both instances, then checks again. If an update ran during the pull, it pulls once more.
3. Compares the two instances through the mapping files:
- mapping integrity (unmapped source items, mappings to missing targets, two sources mapped to one target)
- models, containers and templates
- content counts per model and container, list order, and field values (linked content, assets and companion fields resolved through the mappings)
- component order within each page zone, and which zone each component is in
- sitemap parent and sibling order per locale and channel

#### Results

- **Failures**: field mismatches, missing items, components in the wrong order or zone, sitemap order errors.
- **Warnings**: content that exists only on the target, publish-state differences. These don't fail the run.
- **Source defects**: differences explained by the source data, such as an item missing a required field or an item in a deleted container. These are listed but don't fail the run.

The console prints a summary table per locale and the first 20 findings per category. The full report is written to `--jsonSummary`, or to `agility-files/logs/verify-{source}-{target}-{timestamp}.json`.

| Exit code | Meaning |
| --------- | ----------------------------------------------------------------------------- |
| `0` | Target matches source |
| `1` | Differences found |
| `2` | Could not verify (auth, status check timeout, pull failure, missing mappings) |

```bash
# Blue/green: sync into the new instance, then prove it before switching traffic
agility sync --sourceGuid A --targetGuid B --locales en-us --preflight
agility sync --sourceGuid A --targetGuid B --locales en-us
agility verify --sourceGuid A --targetGuid B --locales en-us
```

## File Structure

The CLI organizes downloaded content in a structured format:
Expand Down
21 changes: 20 additions & 1 deletion src/core/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { serverUser } from "../types/serverUser";
import { state } from "./state";
import * as mgmtApi from "@agility/management-sdk";
const open = require("open");

Check warning on line 4 in src/core/auth.ts

View workflow job for this annotation

GitHub Actions / ESLint Check

A `require()` style import is forbidden
const FormData = require("form-data");

Check warning on line 5 in src/core/auth.ts

View workflow job for this annotation

GitHub Actions / ESLint Check

A `require()` style import is forbidden
import fs from "fs";
import path from "path";

Expand All @@ -10,7 +10,7 @@
function getKeytar() {
if (_keytar === undefined) {
try {
_keytar = require("keytar");

Check warning on line 13 in src/core/auth.ts

View workflow job for this annotation

GitHub Actions / ESLint Check

A `require()` style import is forbidden
} catch {
_keytar = null;
}
Expand Down Expand Up @@ -225,7 +225,7 @@
return "https://mgmt.aglty.io";
}

getBaseUrl(guid: string, userBaseUrl: string = null): string {

Check warning on line 228 in src/core/auth.ts

View workflow job for this annotation

GitHub Actions / ESLint Check

'userBaseUrl' is assigned a value but never used
let baseUrl = this.determineBaseUrl(guid);
return `${baseUrl}/oauth`;
}
Expand Down Expand Up @@ -993,7 +993,7 @@
* Validate command-specific requirements and set up instance access
* This should be called by each command after auth.init()
*/
async validateCommand(commandType: "pull" | "sync" | "clean" | "interactive" | "push"): Promise<boolean> {
async validateCommand(commandType: "pull" | "sync" | "clean" | "interactive" | "push" | "verify"): Promise<boolean> {
const missingFields: string[] = [];

// Check command-specific requirements
Expand Down Expand Up @@ -1030,6 +1030,20 @@
if (!state.channel) missingFields.push("channel (use --channel or AGILITY_WEBSITE in .env)");
break;

case "verify":
// Verify (PROD-2552) reads both instances and writes to neither
if (!state.sourceGuid)
missingFields.push("sourceGuid (use --sourceGuid or AGILITY_GUID in .env)");
if (!state.targetGuid)
missingFields.push("targetGuid (use --targetGuid or AGILITY_TARGET_GUID in .env)");

const hasVerifyUserLocales = state.locale && state.locale.length > 0;
const hasVerifyAutoDetectedLocales = state.guidLocaleMap && state.guidLocaleMap.size > 0;
if (!hasVerifyUserLocales && !hasVerifyAutoDetectedLocales) {
missingFields.push("locale (use --locales or AGILITY_LOCALES in .env, or locales will be auto-detected)");
}
break;

case "clean":
// Clean needs minimal validation since it prompts for instance selection
break;
Expand Down Expand Up @@ -1078,6 +1092,11 @@
);
return false;
}
} else if (commandType === "verify") {
// Verify only needs read access to both instances; the pull uses the API keys auth.init()
// already resolved for each guid.
await this.validateInstanceAccess(state.sourceGuid, "source");
await this.validateInstanceAccess(state.targetGuid, "target");
} else if (commandType === "pull" && state.sourceGuid) {
// Pull operation - validate source access and get API keys
await this.validateInstanceAccess(state.sourceGuid, "instance");
Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Core authentication and state management
export { Auth } from "./auth";
export { state, setState, resetState, primeFromEnv, getState, getUIMode, enableReverseSync } from "./state";
export { systemArgs, workflowArgs, type SystemArgsType } from "./system-args";
export { systemArgs, workflowArgs, verifyArgs, type SystemArgsType } from "./system-args";
export { normalizeProcessArgs, normalizeArgv } from "./arg-normalizer";

// Main operation services
Expand Down
27 changes: 23 additions & 4 deletions src/core/json-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ export interface JsonSummaryDetail {
model?: string;
/** PROD-2542: the required fields it was missing, as "name (Type)". */
fields?: string[];
/**
* PROD-2773 (additive): set on warnings that are not item failures, e.g. "foreignCdnReference"
* — one entry per asset URL into a CDN container owned by neither instance, with the URL, its
* container and every item/field/locale that references it.
*/
code?: string;
url?: string;
container?: string;
references?: Array<{
contentID: number;
referenceName: string;
locale: string;
field: string;
kind: "attachment" | "text";
}>;
}

/**
Expand Down Expand Up @@ -286,9 +301,12 @@ export function buildJsonSummary(input: BuildJsonSummaryInput): JsonSummary {
);

// Same split the console summary uses (PROD-2311): "publish"/"fatal" block the run,
// "mapping"/"refresh" are post-publish bookkeeping and deliberately do not.
// "mapping"/"refresh" are post-publish bookkeeping and deliberately do not, nor does "staging"
// (PROD-2782: a page another locale's save left in staging, not republished).
const autoPublishErrors = input.autoPublishErrors.filter((e) => e.type === "publish" || e.type === "fatal");
const autoPublishWarnings = input.autoPublishErrors.filter((e) => e.type === "mapping" || e.type === "refresh");
const autoPublishWarnings = input.autoPublishErrors.filter(
(e) => e.type === "mapping" || e.type === "refresh" || e.type === "staging"
);

return {
schemaVersion: JSON_SUMMARY_SCHEMA_VERSION,
Expand Down Expand Up @@ -316,13 +334,14 @@ export function buildJsonSummary(input: BuildJsonSummaryInput): JsonSummary {
}

/**
* Write the summary to disk, creating parent directories as needed.
* Write the summary to disk, creating parent directories as needed. Also used for the
* `agility verify` report (PROD-2552), which has its own schema but the same write contract.
*
* Never throws. A reporting artifact must not be able to fail a sync that otherwise
* succeeded — an unwritable path is worth a warning, not a non-zero exit. Returns whether
* the write landed so the caller can say so.
*/
export function writeJsonSummary(filePath: string, summary: JsonSummary): { written: boolean; error?: string } {
export function writeJsonSummary(filePath: string, summary: JsonSummary | object): { written: boolean; error?: string } {
try {
const resolved = path.resolve(filePath);
const dir = path.dirname(resolved);
Expand Down
9 changes: 7 additions & 2 deletions src/core/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function safeStringify(obj: any, indent?: number): string {
}
}

export type OperationType = "pull" | "push" | "sync" | "reverse-sync";
export type OperationType = "pull" | "push" | "sync" | "reverse-sync" | "verify";

export type EntityType =
| "model"
Expand Down Expand Up @@ -524,7 +524,12 @@ export class Logs {
}

// Build filename with GUID
if (this.operationType === "push" || this.operationType === "sync" || this.operationType === "reverse-sync") {
if (
this.operationType === "push" ||
this.operationType === "sync" ||
this.operationType === "reverse-sync" ||
this.operationType === "verify"
) {
const sourceGuid = state.sourceGuid || "unknown";
const targetGuid = state.targetGuid || "unknown";
filename = `${sourceGuid}-${targetGuid}-${this.operationType}-${timestamp}.txt`;
Expand Down
95 changes: 93 additions & 2 deletions src/core/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
clearFailedContentRegistry,
clearHeldBackTemplateRegistry,
clearEffectiveTargetModels,
clearOtherLocaleStagingRegistry,
getOtherLocaleStaging,
getPageCmsLink,
getContentCmsLink,
} from "./state";
Expand All @@ -19,6 +21,50 @@ import { Pull } from "./pull";
import { preflightReport } from "../lib/preflight/preflight-report";
import { buildJsonSummary, writeJsonSummary, JsonSummaryDetail, JsonSummaryPhase } from "./json-summary";
import type { FailureDetail } from "../types/sourceData";
import { foreignCdnReport } from "../lib/content/foreign-cdn-report";
import { resetForeignCdnDetectorCache } from "../lib/pushers/content-pusher/util/report-foreign-cdn-references";
import type { OtherLocaleStagingEntry } from "./state";

/**
* PROD-2782: whether an --autoPublish mode publishes pages ("pages" or "both").
*/
export function autoPublishIncludesPages(autoPublishMode: string | undefined): boolean {
return autoPublishMode === "pages" || autoPublishMode === "both";
}

/**
* PROD-2782: add the pages a save in one locale put in staging in another locale to that locale's
* page publish list. Duplicates are left in; executeAutoPublish de-duplicates per locale.
*/
export function mergeOtherLocaleStaging(
pageIdsByLocale: Map<string, number[]>,
stagingByLocale: Map<string, OtherLocaleStagingEntry[]>
): void {
stagingByLocale.forEach((entries, locale) => {
const existing = pageIdsByLocale.get(locale) || [];
pageIdsByLocale.set(locale, [...existing, ...entries.map((e) => e.targetPageID)]);
});
}

/**
* PROD-2782: non-blocking "staging" notices for pages a save in one locale put in staging in
* another locale, used when auto-publish does not publish pages and so cannot republish them.
*/
export function otherLocaleStagingWarnings(
stagingByLocale: Map<string, OtherLocaleStagingEntry[]>
): Array<{ locale: string; type: string; error: string }> {
const warnings: Array<{ locale: string; type: string; error: string }> = [];
stagingByLocale.forEach((entries, locale) => {
for (const { targetPageID, pageName, savedLocale } of entries) {
warnings.push({
locale,
type: "staging",
error: `page "${pageName}" (target page ${targetPageID}) was put in staging by the ${savedLocale} save and was not republished (auto-publish does not include pages)`,
});
}
});
return warnings;
}

/**
* PROD-2310: Decide whether a set of auto-publish errors should fail the sync exit code.
Expand Down Expand Up @@ -59,9 +105,13 @@ export class Push {
clearHeldBackTemplateRegistry();
// PROD-2542: models saved by a previous run must not stand in for this run's target models
clearEffectiveTargetModels();
clearOtherLocaleStagingRegistry();

// Preflight (PROD-2203): start each run with a clean report of planned actions
preflightReport.reset();
// PROD-2773: foreign CDN references are reported once per URL per run
foreignCdnReport.reset();
resetForeignCdnDetectorCache();

// Initialize logger for push operation
// Determine if this is a sync operation by checking if both source and target GUIDs exist
Expand Down Expand Up @@ -205,6 +255,12 @@ export class Push {
autoPublishErrors = await this.executeAutoPublish(results, autoPublish);
}

// PROD-2782: pages a save in one locale put in staging in another locale are republished by
// auto-publish when it covers pages. Otherwise, say which ones were left in staging.
if (!autoPublishIncludesPages(autoPublish) || !isSync || state.preflight) {
autoPublishErrors.push(...otherLocaleStagingWarnings(getOtherLocaleStaging()));
}

// Split auto-publish outcomes: genuine publish failures (blocking) vs
// post-publish mapping/refresh bookkeeping notices (non-blocking). PROD-2311
const genuinePublishErrors = autoPublishErrors.filter(
Expand All @@ -213,6 +269,7 @@ export class Push {
const mappingWarnings = autoPublishErrors.filter(
({ type }) => type === "mapping" || type === "refresh"
);
const stagingWarnings = autoPublishErrors.filter(({ type }) => type === "staging");

// PROD-2310: Fold auto-publish failures into the returned success signal so the
// entry point can exit non-zero. "mapping"/"refresh" entries are post-publish
Expand Down Expand Up @@ -299,6 +356,14 @@ export class Push {
});
}

// PROD-2773: asset URLs into a CDN container owned by neither instance — passed through
// unchanged, so non-blocking, but an image/file field holding one may arrive empty.
if (foreignCdnReport.hasEntries()) {
const [headline, ...rest] = foreignCdnReport.renderSummaryLines();
console.log(ansiColors.yellow(`\n ${headline}`));
rest.forEach((line) => console.log(ansiColors.yellow(` ${line}`)));
}

// PROD-2311: post-publish mapping/version bookkeeping notices are NOT publish
// failures — show them under a non-blocking header so they don't read as broken.
if (mappingWarnings.length > 0) {
Expand All @@ -309,6 +374,16 @@ export class Push {
});
}

// PROD-2782: pages left in staging in a locale by another locale's save (no auto-publish of
// pages). Non-blocking: nothing was lost, the page needs publishing in that locale.
if (stagingWarnings.length > 0) {
console.log(ansiColors.yellow(`\n Pages Left in Staging by Another Locale's Save (non-blocking):`));
stagingWarnings.forEach(({ locale, error }) => {
const localeDisplay = locale ? `[${locale}]` : "";
console.log(ansiColors.yellow(` • ${localeDisplay} ${error}`));
});
}

// Show log file paths at the very end
if (logFilePaths.length > 0) {
console.log(ansiColors.cyan("\n📄 Log Files:"));
Expand All @@ -320,6 +395,9 @@ 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) {
if (foreignCdnReport.hasEntries()) {
preflightReport.recordWarning(`${foreignCdnReport.headline()} — see the list above`);
}
preflightReport.print();
if (preflightReport.hasConflicts()) {
process.exitCode = 1;
Expand All @@ -334,7 +412,8 @@ export class Push {
exitCode: typeof process.exitCode === "number" ? process.exitCode : success ? 0 : 1,
phases: results.flatMap((r) => r.phases ?? []),
failures: syncFailureDetails,
warnings: syncWarningDetails,
// PROD-2773: one additive entry per foreign CDN URL (code "foreignCdnReference")
warnings: [...syncWarningDetails, ...foreignCdnReport.toWarningDetails(sourceGuid)],
operationErrors: syncErrors,
autoPublishErrors,
startedAt,
Expand Down Expand Up @@ -477,7 +556,14 @@ export class Push {

// Determine what to publish based on mode
const publishContent = autoPublishMode === "content" || autoPublishMode === "both";
const publishPages = autoPublishMode === "pages" || autoPublishMode === "both";
const publishPages = autoPublishIncludesPages(autoPublishMode);

// PROD-2782: a page saved in one locale may have been put in staging in another locale that
// this run did not save it in. Republish it there too.
const otherLocaleStaging = getOtherLocaleStaging();
if (publishPages && otherLocaleStaging.size > 0) {
mergeOtherLocaleStaging(pageIdsByLocale, otherLocaleStaging);
}

// Get all locales that have items to publish
const allLocales = new Set<string>();
Expand Down Expand Up @@ -510,6 +596,11 @@ export class Push {
console.log(ansiColors.gray(`Locales to publish: ${Array.from(allLocales).join(", ")}`));
console.log(ansiColors.gray(`Total content items: ${totalContent}`));
console.log(ansiColors.gray(`Total pages: ${totalPages}`));
if (publishPages && otherLocaleStaging.size > 0) {
let count = 0;
otherLocaleStaging.forEach((entries) => (count += entries.length));
console.log(ansiColors.gray(` incl. ${count} page(s) put in staging by a save in another locale`));
}

// Import workflow dependencies
const { processBatches } = await import("../lib/workflows/process-batches");
Expand Down
Loading
Loading