diff --git a/src/index.ts b/src/index.ts index 1d19e851..ae5c425a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { Auth, state, setState, resetState, primeFromEnv, systemArgs, normalizeP import { Pull } from "./core/pull"; import { Push } from "./core/push"; import { WorkflowOperation } from "./lib/workflows"; +import { MappingsHealth } from "./lib/mappers/mappings-health"; import { initializeLogger, getLogger, finalizeLogger, finalizeAllGuidLoggers } from "./core/state"; @@ -42,6 +43,7 @@ yargs.command({ console.log(colors.white(" pull - Pull your Agility instance locally")); console.log(colors.white(" push - Push your instance to a target instance")); console.log(colors.white(" sync - Sync your instance (alias for push with updates enabled)")); + console.log(colors.white(" mappings-health - Check your mappings to make sure they are valid.")); console.log(colors.white(" workflowOperation - Perform workflow operations (publish, unpublish, approve, decline)")); console.log(colors.white("\nFor more information, use: --help")); console.log(""); @@ -308,6 +310,87 @@ yargs.command({ } }) +// Mappings health check command - analyzes the health of mappings between two instances +yargs.command({ + command: "mappings-health", + describe: "Analyze the health of content item mappings between two instances.", + builder: { + sourceGuid: { + describe: "Source instance GUID.", + demandOption: true, + type: "string", + }, + targetGuid: { + describe: "Target instance GUID.", + demandOption: true, + type: "string", + }, + ...systemArgs + }, + handler: async function (argv) { + resetState(); + argv = normalizeArgv(argv); + + const envPriming = primeFromEnv(); + if (envPriming.hasEnvFile && envPriming.primedValues.length > 0) { + console.log(colors.cyan(`šŸ“„ Found .env file, primed: ${envPriming.primedValues.join(', ')}`)); + } + + setState(argv); + state.update = true; + // Skip assets and galleries — not needed for mappings health analysis + state.elements = 'Models,Containers,Content,Templates,Sitemaps'; + + auth = new Auth(); + const isAuthorized = await auth.init(); + if (!isAuthorized) return; + + const isValidCommand = await auth.validateCommand('push'); + if (!isValidCommand) return; + + initializeLogger("pull"); + + const sourceGuid = state.sourceGuid[0]; + const targetGuid = state.targetGuid[0]; + + console.log(colors.cyan(`\nšŸ“„ Pulling latest data for ${sourceGuid} and ${targetGuid} (skipping assets)...`)); + const pull = new Pull(); + // fromPush=true pulls both sourceGuid and targetGuid, and does not call process.exit() + const pullResult = await pull.pullInstances(true); + + if (!pullResult.success) { + console.log(colors.yellow("\nāš ļø Pull completed with some errors. Proceeding with health check using available data...")); + } + + console.log(colors.cyan(`\nšŸ” Analyzing mappings health for ${sourceGuid} → ${targetGuid}...`)); + + const healthChecker = new MappingsHealth(sourceGuid, targetGuid); + const result = await healthChecker.analyze(); + + // Summary + console.log(colors.cyan(`\n${'─'.repeat(60)}`)); + console.log(colors.cyan(`šŸ“Š Mappings Health Summary`)); + console.log(colors.cyan(`${'─'.repeat(60)}`)); + console.log(` Source GUID : ${result.sourceGuid}`); + console.log(` Target GUID : ${result.targetGuid}`); + console.log(` Locales checked : ${result.localesChecked.join(', ') || 'none'}`); + console.log(` Item mappings found : ${result.totalItemMappingsChecked}`); + if (result.skippedNotOnDisk > 0) { + console.log(colors.yellow(` Skipped (not on disk): ${result.skippedNotOnDisk} (draft or deleted content — not checked)`)); + } + console.log(` Issues detected : ${result.issues.length}`); + + if (result.isHealthy) { + console.log(colors.green(`\nāœ… All mappings are healthy!`)); + } else { + console.log(colors.red(`\nāŒ ${result.issues.length} issue(s) detected in mappings.`)); + process.exitCode = 1; + } + + finalizeLogger(); + } +}); + // Normalize process.argv to handle rich text editor character conversions // (e.g., em dashes, curly quotes from Word/Notepad) normalizeProcessArgs(); diff --git a/src/lib/mappers/mappings-health.ts b/src/lib/mappers/mappings-health.ts new file mode 100644 index 00000000..bdac5302 --- /dev/null +++ b/src/lib/mappers/mappings-health.ts @@ -0,0 +1,208 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import colors from 'ansi-colors'; +import * as mgmtApi from '@agility/management-sdk'; +import { state, getApiClient } from '../../core/state'; +import { fileOperations } from '../../core'; +import { ContainerMapper } from './container-mapper'; + +export interface MappingHealthIssue { + type: 'containers_not_mapped' | 'duplicate_container_mapping' | 'missing_container_mapping'; + locale: string; + sourceContentID: number; + targetContentID: number; + sourceReferenceName: string; + targetReferenceName: string; + message: string; + sourceItem?: any; + targetItem?: any; +} + +export interface MappingsHealthResult { + sourceGuid: string; + targetGuid: string; + localesChecked: string[]; + totalItemMappingsChecked: number; + skippedNotOnDisk: number; + issues: MappingHealthIssue[]; + isHealthy: boolean; +} + +export class MappingsHealth { + private sourceGuid: string; + private targetGuid: string; + private apiFetchCount = 0; + + constructor(sourceGuid: string, targetGuid: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + } + + async analyze(): Promise { + const result: MappingsHealthResult = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + localesChecked: [], + totalItemMappingsChecked: 0, + skippedNotOnDisk: 0, + issues: [], + isHealthy: true + }; + + const locales = this.findMappingLocales(); + + if (locales.length === 0) { + const mappingsDir = path.join(state.rootPath, 'mappings', `${this.sourceGuid}-${this.targetGuid}`); + console.log(colors.yellow(`\nāš ļø No locale mapping directories found for ${this.sourceGuid} → ${this.targetGuid}`)); + console.log(colors.yellow(` Expected path: ${mappingsDir}`)); + return result; + } + + const containerMapper = new ContainerMapper(this.sourceGuid, this.targetGuid); + + // Load raw container mappings to check for duplicates + const containerFileOps = new fileOperations(this.targetGuid); + const rawContainerMappings = containerFileOps.getMappingFile('containers', this.sourceGuid, this.targetGuid); + + for (const locale of locales) { + result.localesChecked.push(locale); + + const localeFileOps = new fileOperations(this.targetGuid, locale); + const itemMappings = localeFileOps.getMappingFile('item', this.sourceGuid, this.targetGuid, locale); + + this.apiFetchCount = 0; + console.log(colors.cyan(`\nšŸ“‹ Checking ${itemMappings.length} item mapping(s) for locale: ${locale}`)); + + for (let i = 0; i < itemMappings.length; i++) { + const mapping = itemMappings[i]; + result.totalItemMappingsChecked++; + + process.stdout.write( + colors.gray(` [${i + 1}/${itemMappings.length}] checking ${mapping.sourceContentID} → ${mapping.targetContentID} ... \r`) + ); + + const sourceItem = await this.getContentItem(mapping.sourceContentID, this.sourceGuid, locale); + const targetItem = await this.getContentItem(mapping.targetContentID, this.targetGuid, locale); + + if (!sourceItem || !targetItem) { + result.skippedNotOnDisk++; + continue; + } + + const sourceRefName: string = sourceItem.properties?.referenceName; + const targetRefName: string = targetItem.properties?.referenceName; + + if (!sourceRefName || !targetRefName) { + console.log(colors.yellow(` āš ļø Missing referenceName for mapping ${mapping.sourceContentID} → ${mapping.targetContentID}`)); + continue; + } + + const sourceRefNameLower = sourceRefName.toLowerCase(); + const targetRefNameLower = targetRefName.toLowerCase(); + + // Check for duplicate container mappings + const sourceContainerCount = rawContainerMappings.filter( + (m: any) => m.sourceReferenceName?.toLowerCase() === sourceRefNameLower + ).length; + + const targetContainerCount = rawContainerMappings.filter( + (m: any) => m.targetReferenceName?.toLowerCase() === targetRefNameLower + ).length; + + if (sourceContainerCount > 1 || targetContainerCount > 1) { + const issue: MappingHealthIssue = { + type: 'duplicate_container_mapping', + locale, + sourceContentID: mapping.sourceContentID, + targetContentID: mapping.targetContentID, + sourceReferenceName: sourceRefName, + targetReferenceName: targetRefName, + message: `Duplicate container mappings detected. Source "${sourceRefName}" has ${sourceContainerCount} mapping(s), target "${targetRefName}" has ${targetContainerCount} mapping(s).`, + sourceItem, + targetItem + }; + result.issues.push(issue); + result.isHealthy = false; + this.logBrokenMapping(issue); + continue; + } + + // Check if containers are mapped to each other + const sourceMappingEntry = containerMapper.getContainerMappingByReferenceName(sourceRefName, 'source'); + const targetMappingEntry = containerMapper.getContainerMappingByReferenceName(targetRefName, 'target'); + + if (!sourceMappingEntry || !targetMappingEntry || sourceMappingEntry !== targetMappingEntry) { + const referenceNamesMatch = sourceRefNameLower === targetRefNameLower; + const issue: MappingHealthIssue = { + type: referenceNamesMatch ? 'missing_container_mapping' : 'containers_not_mapped', + locale, + sourceContentID: mapping.sourceContentID, + targetContentID: mapping.targetContentID, + sourceReferenceName: sourceRefName, + targetReferenceName: targetRefName, + message: referenceNamesMatch + ? `Item mapping looks correct (same container "${sourceRefName}"), but no container mapping entry exists for this referenceName.` + : `Containers are not mapped to each other. Source container "${sourceRefName}" and target container "${targetRefName}" do not share a mapping entry.`, + sourceItem, + targetItem + }; + result.issues.push(issue); + result.isHealthy = false; + this.logBrokenMapping(issue); + } + } + + // Clear the in-place progress line and print locale summary + process.stdout.write('\r' + ' '.repeat(80) + '\r'); + const apiNote = this.apiFetchCount > 0 ? colors.gray(` (${this.apiFetchCount} fetched from API)`) : ''; + console.log(colors.green(` āœ“ ${locale} done`) + apiNote); + } + + return result; + } + + private logBrokenMapping(issue: MappingHealthIssue): void { + // Clear the in-place progress line before printing multi-line output + process.stdout.write('\r' + ' '.repeat(80) + '\r'); + + const icon = + issue.type === 'duplicate_container_mapping' ? 'šŸ”„' : + issue.type === 'missing_container_mapping' ? 'āš ļø ' : 'āŒ'; + const typeLabel = + issue.type === 'duplicate_container_mapping' ? 'DUPLICATE CONTAINER MAPPING' : + issue.type === 'missing_container_mapping' ? 'MISSING CONTAINER MAPPING' : 'BROKEN MAPPING'; + + console.log(colors.red(`\n ${icon} [${issue.locale}] ${typeLabel}`)); + console.log(colors.red(` Source Content ID : ${issue.sourceContentID} (container: ${issue.sourceReferenceName})`)); + console.log(colors.red(` Target Content ID : ${issue.targetContentID} (container: ${issue.targetReferenceName})`)); + console.log(colors.red(` Issue : ${issue.message}`)); + } + + private async getContentItem(contentID: number, guid: string, locale: string): Promise { + // Try disk first (fast path — published items) + const fileOps = new fileOperations(guid, locale); + const onDisk = fileOps.readJsonFile(`item/${contentID}.json`); + if (onDisk) return onDisk as mgmtApi.ContentItem; + + // Fall back to Management API (covers draft/staging items) + this.apiFetchCount++; + process.stdout.write(colors.gray(` [api #${this.apiFetchCount}] fetching ${contentID} from ${guid} ... \r`)); + try { + const apiClient = getApiClient(); + const item = await apiClient.contentMethods.getContentItem(contentID, guid, locale); + return item ?? null; + } catch { + return null; + } + } + + private findMappingLocales(): string[] { + const mappingsDir = path.join(state.rootPath, 'mappings', `${this.sourceGuid}-${this.targetGuid}`); + + if (!fs.existsSync(mappingsDir)) { + return []; + } + + return fs.readdirSync(mappingsDir).filter(item => /^[a-z]{2}-[a-z]{2}$/i.test(item)); + } +}