import chalk from 'chalk'; import { cloneDeep, isEqual } from 'lodash'; import { diff } from 'deep-diff'; import { CLIAlterationHook, CLICodeFile, CLIDocumentKey, CLIDocumentsWithSupplementaryTerms, CLIProductModuleDefinition, CLIProductModuleDefinitionOutput, CLIWorkflows, isCLIDocumentKey, isValidApiDocsSchema, } from '../domain/product-module-definition'; import { ask } from './readline-helper'; import { CLIScheduledFunction, CLIFulfillmentType } from '../domain/root-config'; import { DocsFileName, EmbedFileName, isPolicyDocumentFileName, mapDocumentKeyToFileName, mapRequiredWorkflowKeyToFileName, PolicyDocumentFileName, RequiredWorkflowFileName, CodeDirectoryName, TopLevelDirectoryName, TopLevelFileName, } from '../domain/file-names'; import { UnionString } from '../domain/utils'; export interface Changes { settings: string[]; scheduledFunctions: string[]; fulfillmentTypes: string[]; alterationHooks: string[]; applicationAlterationHooks: string[]; memberAlterationHooks: string[]; codeFiles: string[]; sourceCodeFiles: string[]; unitTestCodeFiles: string[]; documents: (PolicyDocumentFileName | UnionString)[]; requiredWorkflows: RequiredWorkflowFileName[]; embed: EmbedFileName[]; alterationHookSchemas: string[]; applicationAlterationHookSchemas: string[]; memberAlterationHookSchemas: string[]; miscellaneous: (DocsFileName | TopLevelFileName.Readme)[]; stack: string[]; } export interface DiffResults { hasChanged: boolean; changes: Changes; } const checkDiffDocuments = ( local: CLIDocumentsWithSupplementaryTerms, remote: CLIDocumentsWithSupplementaryTerms, ): (CLIDocumentKey | UnionString)[] => { let tracker: (CLIDocumentKey | UnionString)[] = []; const keys = Object.keys({ ...local, ...remote }) as (keyof CLIDocumentsWithSupplementaryTerms)[]; for (const key of keys) { switch (key) { case 'supplementaryTerms': { const remoteFiles = cloneDeep(remote.supplementaryTerms || []); for (const localFile of local.supplementaryTerms || []) { const { type } = localFile; const remoteIndex = remoteFiles.findIndex((file) => file.type === type); const remoteFile = remoteFiles[remoteIndex]; if (remoteFile?.pdfBase64 !== localFile.pdfBase64.split(';base64,').pop()) { tracker.push(type); } remoteFiles.splice(remoteIndex, 1); } for (const remoteFile of remoteFiles) { const { type } = remoteFile; const localFile = (local.supplementaryTerms || []).find((file) => file.type === type); if (remoteFile.pdfBase64 !== localFile?.pdfBase64.split(';base64,').pop()) { tracker.push(type); } } break; } case CLIDocumentKey.TermsPdfBase64: { const localVal = local[key].split(';base64,').pop(); const remoteVal = remote[key]; if (!isEqual(localVal, remoteVal)) { tracker.push(key); } break; } default: { const localVal = (local[key] || '').trim(); const remoteVal = (remote[key] || '').trim(); if (!isEqual(localVal, remoteVal)) { tracker.push(key); } } } } tracker = tracker.filter((item, pos) => { return tracker.indexOf(item) === pos; }); return tracker; }; const checkDiffFiles = (local: CLICodeFile[], remote: CLICodeFile[]) => { let tracker: string[] = []; for (const codeFile of local) { const remoteCode = remote.find((file: CLICodeFile) => file.fileName === codeFile.fileName); if (!remoteCode || codeFile.fileContent.trim() !== remoteCode.fileContent.trim()) { tracker.push(codeFile.fileName); } } for (const codeFile of remote) { const localCode = local.find((file: CLICodeFile) => file.fileName === codeFile.fileName); if (!localCode || codeFile.fileContent.trim() !== localCode.fileContent.trim()) { tracker.push(codeFile.fileName); } } tracker = tracker.filter((item, pos) => { return tracker.indexOf(item) === pos; }); return tracker; }; /** * Checks for differences in the alteration hook config in .root-confi.json */ const checkDiffAlterationHooks = (params: { local: CLIAlterationHook[]; remote: CLIAlterationHook[] }) => { const { local, remote } = params; let tracker: string[] = []; for (const localHook of local) { const remoteHook = remote.find((hook) => hook.key === localHook.key); if (!remoteHook || !isEqual(localHook.name, remoteHook.name)) { tracker.push(localHook.key); } } for (const remoteHook of remote) { const localHook = local.find((hook) => hook.key === remoteHook.key); if (!localHook || !isEqual(remoteHook.name, localHook.name)) { tracker.push(remoteHook.key); } } tracker = tracker.filter((item, pos) => { return tracker.indexOf(item) === pos; }); return tracker; }; /** * Checks for differences in the alteration hook schemas in workflow/alteration-hooks */ const checkDiffAlterationHookSchemas = (local: CLIAlterationHook[], remote: CLIAlterationHook[]) => { let tracker: string[] = []; for (const localHook of local) { const remoteHook = remote.find((hook) => hook.key === localHook.key); if (!isEqual(localHook.schema, remoteHook?.schema)) { tracker.push(localHook.key); } } for (const remoteHook of remote) { const localHook = local.find((hook) => hook.key === remoteHook.key); if (!isEqual(remoteHook.schema, localHook?.schema)) { tracker.push(remoteHook.key); } } tracker = tracker.filter((item, pos) => { return tracker.indexOf(item) === pos; }); return tracker; }; const checkDiffEmbedConfig = (params: { local: CLIWorkflows; remote: CLIWorkflows }): EmbedFileName[] => { const embedTracker: EmbedFileName[] = []; const { local, remote } = params; // Clone the embed configs so we can check the diff after splitting out the individual files const localEmbedConfig = local.productModuleEmbedConfig ? JSON.parse(JSON.stringify(local.productModuleEmbedConfig)) : {}; const remoteEmbedConfig = remote.productModuleEmbedConfig ? JSON.parse(JSON.stringify(remote.productModuleEmbedConfig)) : {}; const localPrePersonalDetailsCompliance = local.productModuleEmbedConfig?.prePersonalDetailsCompliance?.wording?.content; const localPrePaymentCompliance = local.productModuleEmbedConfig?.prePaymentCompliance?.wording?.content; const remotePrePersonalDetailsCompliance = remote.productModuleEmbedConfig?.prePersonalDetailsCompliance?.wording?.content; const remotePrePaymentCompliance = remote.productModuleEmbedConfig?.prePaymentCompliance?.wording?.content; delete localEmbedConfig?.prePaymentCompliance?.wording?.content; delete remoteEmbedConfig?.prePaymentCompliance?.wording?.content; delete localEmbedConfig?.prePersonalDetailsCompliance?.wording?.content; delete remoteEmbedConfig?.prePersonalDetailsCompliance?.wording?.content; if ((localPrePersonalDetailsCompliance || '').trim() !== (remotePrePersonalDetailsCompliance || '').trim()) { embedTracker.push(EmbedFileName.PrePersonalDetailsCompliance); } if ((localPrePaymentCompliance || '').trim() !== (remotePrePaymentCompliance || '').trim()) { embedTracker.push(EmbedFileName.PrePaymentCompliance); } if ( (local.productModuleEmbedConfig || remote.productModuleEmbedConfig) && !isEqual(localEmbedConfig, remoteEmbedConfig) ) { embedTracker.push(EmbedFileName.EmbedConfig); } return embedTracker; }; const checkDiffRequiredWorkflows = (params: { local: CLIWorkflows; remote: CLIWorkflows }) => { const { local, remote } = params; const tracker: RequiredWorkflowFileName[] = []; const keys = Object.keys({ ...local, ...remote }) as (keyof CLIWorkflows)[]; for (const key of keys) { if ( key !== 'alterationHooks' && key !== 'applicationAlterationHooks' && key !== 'memberAlterationHooks' && key !== 'productModuleEmbedConfig' && !isEqual(local[key], remote[key]) ) { tracker.push(mapRequiredWorkflowKeyToFileName(key)); } } return tracker; }; const checkDiffScheduledFunctions = (local: CLIScheduledFunction[], remote: CLIScheduledFunction[]) => { const tracker: string[] = []; const uniqueFunctionNames: string[] = []; for (const scheduledFunction of [...local, ...remote]) { if (!uniqueFunctionNames.includes(scheduledFunction.functionName)) { uniqueFunctionNames.push(scheduledFunction.functionName); } } for (const functionName of uniqueFunctionNames) { if ( !isEqual( local.find((scheduledFunction) => scheduledFunction.functionName === functionName), remote.find((scheduledFunction) => scheduledFunction.functionName === functionName), ) ) { tracker.push(functionName); } } return tracker; }; const checkDiffFulfillmentTypes = (local: Array, remote: Array) => { const tracker: string[] = []; const keys: string[] = []; for (const fulfillmentType of [...local, ...remote]) { if (!keys.includes(fulfillmentType.key)) { keys.push(fulfillmentType.key); } } for (const key of keys) { if ( !isEqual( local.find((fulfillmentType) => fulfillmentType.key === key), remote.find((fulfillmentType) => fulfillmentType.key === key), ) ) { tracker.push(`${key}`); } } return tracker; }; export const checkDiffMisc = ( local: CLIProductModuleDefinition, remote: CLIProductModuleDefinition, ): (DocsFileName | TopLevelFileName.Readme)[] => { const tracker: (DocsFileName | TopLevelFileName.Readme)[] = []; const { apiDocsSchema: localApiDocs, readmeMarkdown: localReadMe } = local; const { apiDocsSchema: remoteApiDocs, readmeMarkdown: remoteReadMe } = remote; if ( isValidApiDocsSchema(localApiDocs) && isValidApiDocsSchema(remoteApiDocs) && !isEqual(localApiDocs, remoteApiDocs) ) { tracker.push(DocsFileName.ApiDocs); } if ((localReadMe || '').trim() !== (remoteReadMe || '').trim()) { tracker.push(TopLevelFileName.Readme); } return tracker; }; const checkSettings = (local: Record, remote: Record) => { const differences = diff(local, remote); if (differences) { return differences.map((difference) => { return difference.path?.join('.') || ''; }); } return []; }; export const checkDiff = ( localDefinition: CLIProductModuleDefinition, remoteDefinition: CLIProductModuleDefinitionOutput, ): DiffResults => { const changes: Changes = { settings: [], scheduledFunctions: [], fulfillmentTypes: [], alterationHooks: [], applicationAlterationHooks: [], memberAlterationHooks: [], codeFiles: [], sourceCodeFiles: [], unitTestCodeFiles: [], documents: [], alterationHookSchemas: [], applicationAlterationHookSchemas: [], memberAlterationHookSchemas: [], embed: [], requiredWorkflows: [], miscellaneous: [], stack: [], }; const { rootConfig: localRootConfig, documents: localPmDocs, codeFiles: localPmCodeFiles, sourceCodeFiles: localSourceCodeFiles, unitTestCodeFiles: localUnitTestCodeFiles, workflows: localPmWorkflows, } = localDefinition; const { rootConfig: remoteRootConfig, documents: remotePmDocs, codeFiles: remotePmCodeFiles, sourceCodeFiles: remoteSourceCodeFiles, unitTestCodeFiles: remoteUnitTestCodeFiles, workflows: remotePmWorkflows, } = remoteDefinition; changes.settings = checkSettings(localRootConfig.settings, remoteRootConfig.settings); changes.scheduledFunctions = checkDiffScheduledFunctions( localRootConfig.scheduledFunctions, remoteRootConfig.scheduledFunctions, ); changes.fulfillmentTypes = checkDiffFulfillmentTypes( localRootConfig.fulfillmentTypes || [], remoteRootConfig.fulfillmentTypes, ); changes.alterationHooks = checkDiffAlterationHooks({ local: localPmWorkflows.alterationHooks, remote: remotePmWorkflows.alterationHooks, }); changes.applicationAlterationHooks = checkDiffAlterationHooks({ local: localPmWorkflows.applicationAlterationHooks || [], remote: remotePmWorkflows.applicationAlterationHooks || [], }); changes.memberAlterationHooks = checkDiffAlterationHooks({ local: localPmWorkflows.memberAlterationHooks || [], remote: remotePmWorkflows.memberAlterationHooks || [], }); changes.documents = checkDiffDocuments(localPmDocs, remotePmDocs).map((docName) => { if (isCLIDocumentKey(docName)) { return mapDocumentKeyToFileName(docName); } return `${docName}.pdf`; }); changes.requiredWorkflows = checkDiffRequiredWorkflows({ local: localPmWorkflows, remote: remotePmWorkflows, }); changes.embed = checkDiffEmbedConfig({ local: localPmWorkflows, remote: remotePmWorkflows, }); changes.alterationHookSchemas = checkDiffAlterationHookSchemas( localPmWorkflows.alterationHooks, remotePmWorkflows.alterationHooks, ); changes.applicationAlterationHookSchemas = checkDiffAlterationHookSchemas( localPmWorkflows.applicationAlterationHooks || [], remotePmWorkflows.applicationAlterationHooks || [], ); changes.memberAlterationHookSchemas = checkDiffAlterationHookSchemas( localPmWorkflows.memberAlterationHooks || [], remotePmWorkflows.memberAlterationHooks || [], ); changes.codeFiles = checkDiffFiles(localPmCodeFiles, remotePmCodeFiles); changes.sourceCodeFiles = checkDiffFiles(localSourceCodeFiles || [], remoteSourceCodeFiles || []); changes.unitTestCodeFiles = checkDiffFiles(localUnitTestCodeFiles, remoteUnitTestCodeFiles); changes.miscellaneous = checkDiffMisc(localDefinition, remoteDefinition); // Check for stack changes if (localRootConfig.stack !== remoteRootConfig.stack) { changes.stack.push(localRootConfig.stack); } // Set the value of the has changed flag based on whether any changes were detected const hasChanged = Object.values(changes).some((value) => value.length > 0); return { hasChanged, changes, }; }; /** Gets the type of elements inside the array corresponding to T */ type Item = Changes[T][number]; const getPath = (params: { key: keyof Changes; item: unknown }) => { const { key, item } = params; switch (key) { case 'settings': case 'scheduledFunctions': case 'fulfillmentTypes': case 'alterationHooks': case 'applicationAlterationHooks': case 'memberAlterationHooks': { return `${TopLevelFileName.RootConfig} > ${key} > ${item as Item}`; } case 'stack': { return `${TopLevelFileName.RootConfig} > ${key}`; } case 'codeFiles': { return `code > ${item as Item}`; } case 'sourceCodeFiles': { return `${TopLevelDirectoryName.Code}/${CodeDirectoryName.Src} > ${item as Item}`; } case 'unitTestCodeFiles': { return `code > unit-tests > ${item as Item}`; } case 'embed': { return `workflows > embed > ${item as Item}`; } case 'requiredWorkflows': { return `workflows > ${item as Item}`; } case 'alterationHookSchemas': { return `workflows > alteration-hooks > ${item as Item}.json`; } case 'applicationAlterationHookSchemas': { return `workflows > application-alteration-hooks > ${item as Item}.json`; } case 'memberAlterationHookSchemas': { return `workflows > member-alteration-hooks > ${item as Item}.json`; } case 'miscellaneous': { if ((item as Item) === DocsFileName.ApiDocs) { return `docs > ${item as Item}`; } // Readme return `${item as Item}`; } // Documents case 'documents': { if (isPolicyDocumentFileName(item as Item)) { return `${key} > ${item as Item}`; } return `${key} > supplementary-terms > ${item as Item}`; } default: { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unknown high level diff key: ${key satisfies never}`); } } }; export const displayDiffWarning = async (changes: Changes) => { console.log('The following items have changed.'); console.log(); // Insert a gap const keys = Object.keys(changes) as (keyof Changes)[]; // For TypeScript modules, `codeFiles` is the compiled bundle derived from `sourceCodeFiles`. // Hide the bundle from the warning so the user sees the meaningful source-level changes. const hideBundledCodeFiles = changes.sourceCodeFiles.length > 0; for (const key of keys) { if (hideBundledCodeFiles && key === 'codeFiles') { continue; } const value = changes[key]; if (value.length > 0) { for (const item of value) { const path = getPath({ key, item }); console.log(chalk.magenta(` ${path}`)); } } } console.log(); // Insert a gap return ask.yesNo('Do you want to continue (y/n)? '); };