import path from 'node:path'; import diffDisplay from 'cli-diff'; import chalk from 'chalk'; import { cloneDeep } from 'lodash'; import { checkValidProductModuleDirectory } from '../helpers/check-valid-product-module-directory'; import { readProductModuleDefinition } from '../helpers/read-product-module-definition'; import { checkDiff, DiffResults } from '../helpers/high-level-diff'; import { CLIProductModuleDefinition, productModuleDefinitionFromNetwork, CLIProductModuleDefinitionOutput, CLIWorkflows, } from '../domain/product-module-definition'; import { fetchProductModuleDefinition } from '../helpers/fetch-product-module-definition'; import { EmbedFileName, RequiredWorkflowFileName, CodeDirectoryName, DocsFileName, TopLevelDirectoryName, TopLevelFileName, isPolicyDocumentFileName, mapFileNameToDocumentKey, } from '../domain/file-names'; import { isProductModuleConfig } from '../domain/root-config'; import { readAuthAndConfig } from '../helpers/read-auth-and-config'; import { runWithSpinner } from '../helpers/spinner'; import { symbols } from '../helpers/symbols'; /** * Diff message about missing newlines is unwanted, as newlines are added automatically * by the CLI tool when writing content to local files. */ const removeNewlineMessage = (message: string) => { const lines = message.split('\n'); const outputLines = []; for (const line of lines) { if (!line.includes(`No newline at end of file`)) { outputLines.push(line); } } return outputLines.join('\n'); }; const getEmbedContent = (params: { fileName: EmbedFileName; workflows: CLIWorkflows }): string => { const { workflows, fileName } = params; switch (fileName) { case EmbedFileName.PrePaymentCompliance: { return workflows.productModuleEmbedConfig?.prePaymentCompliance?.wording?.content; } case EmbedFileName.PrePersonalDetailsCompliance: { return workflows.productModuleEmbedConfig?.prePersonalDetailsCompliance?.wording?.content; } case EmbedFileName.EmbedConfig: { const deepCopy = cloneDeep(workflows.productModuleEmbedConfig); delete deepCopy?.prePaymentCompliance?.wording?.content; delete deepCopy?.prePersonalDetailsCompliance?.wording?.content; return JSON.stringify(deepCopy, null, 2); } default: { throw new Error(`Unknown embed file name: ${fileName}`); } } }; const getRequiredWorkflowContent = (params: { fileName: RequiredWorkflowFileName; workflows: CLIWorkflows; }): string => { const { workflows, fileName } = params; switch (fileName) { case RequiredWorkflowFileName.QuoteSchema: { return JSON.stringify(workflows.quoteSchema, null, 2); } case RequiredWorkflowFileName.ApplicationSchema: { return JSON.stringify(workflows.applicationSchema, null, 2); } case RequiredWorkflowFileName.ClaimsBlocks: { return JSON.stringify(workflows.claimsBlocks, null, 2); } default: { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unknown required workflow file name: ${fileName}`); } } }; const getAlterationHookWorkflowContent = (params: { fileName: string; workflows: CLIWorkflows }): string => { const { workflows, fileName } = params; const alterationHookKey = fileName.replace('.json', ''); const alterationHook = workflows.alterationHooks.find((hook) => hook.key === alterationHookKey); return JSON.stringify(alterationHook, null, 2); }; const diffWorkflow = (params: { localContent: any; remoteContent: any; fileName: string }) => { const { localContent, remoteContent, fileName } = params; if (localContent && !remoteContent) { console.log(symbols.success + chalk.green(`"${fileName}" has been added.`)); } else if (!localContent && remoteContent) { console.log(symbols.error + chalk.red(`"${fileName}" has been removed.`)); } else { const displayMessage = diffDisplay( { name: fileName, content: remoteContent }, { name: fileName, content: localContent }, ); console.log(displayMessage); } }; const diffWorkflows = (params: { highLevelDiff: DiffResults; localDefinition: CLIProductModuleDefinition; remoteDefinition: CLIProductModuleDefinitionOutput; }) => { const { highLevelDiff, localDefinition, remoteDefinition } = params; const { requiredWorkflows, alterationHookSchemas, embed } = highLevelDiff.changes; if (requiredWorkflows.length <= 0 && alterationHookSchemas.length <= 0 && embed.length <= 0) { console.log(`${symbols.info}No changes to display for workflows`); return; } const localPmWorkflows = localDefinition.workflows; const remotePmWorkflows = remoteDefinition.workflows; // Embed const embedChanged = embed.filter((workflow) => workflow.includes('embed')); for (const embedFile of embedChanged) { const fileName = embedFile.slice(Math.max(0, embedFile.indexOf('> ') + 1)) as EmbedFileName; const localContent = getEmbedContent({ workflows: localPmWorkflows, fileName }); const remoteContent = getEmbedContent({ workflows: remotePmWorkflows, fileName }); diffWorkflow({ localContent, remoteContent, fileName }); } // Required workflow files const requiredChanged = requiredWorkflows.filter((workflow) => Object.values(RequiredWorkflowFileName).includes(workflow), ); for (const fileName of requiredChanged) { const localContent = getRequiredWorkflowContent({ workflows: localPmWorkflows, fileName }); const remoteContent = getRequiredWorkflowContent({ workflows: remotePmWorkflows, fileName }); diffWorkflow({ localContent, remoteContent, fileName }); } // Alteration hook schemas for (const alterationHookFile of alterationHookSchemas) { const fileName = alterationHookFile.slice(Math.max(0, alterationHookFile.indexOf('> ') + 1)); const displayedFileName = `${alterationHookFile}.json`; const localContent = getAlterationHookWorkflowContent({ workflows: localPmWorkflows, fileName }); const remoteContent = getAlterationHookWorkflowContent({ workflows: remotePmWorkflows, fileName }); diffWorkflow({ localContent, remoteContent, fileName: displayedFileName }); } }; const diffReadMe = (params: { highLevelDiff: DiffResults; localDefinition: CLIProductModuleDefinition; remoteDefinition: CLIProductModuleDefinitionOutput; }) => { const { highLevelDiff, localDefinition, remoteDefinition } = params; if (highLevelDiff.changes.miscellaneous.includes(TopLevelFileName.Readme)) { const fileName = TopLevelFileName.Readme; const displayMessage = diffDisplay( { name: fileName, content: localDefinition.readmeMarkdown }, { name: fileName, content: remoteDefinition.readmeMarkdown }, ); console.log(displayMessage); } else { console.log(`${symbols.info}No changes to display for ${TopLevelFileName.Readme}`); } }; const diffAPIDocs = (params: { highLevelDiff: DiffResults; localDefinition: CLIProductModuleDefinition; remoteDefinition: CLIProductModuleDefinitionOutput; }) => { const { highLevelDiff, localDefinition, remoteDefinition } = params; if (highLevelDiff.changes.miscellaneous.includes(DocsFileName.ApiDocs)) { const fileName = DocsFileName.ApiDocs; const localContent = JSON.stringify(localDefinition.apiDocsSchema, null, 2); const remoteContent = JSON.stringify(remoteDefinition.apiDocsSchema, null, 2); const displayMessage = diffDisplay( { name: fileName, content: localContent }, { name: fileName, content: remoteContent }, ); console.log(displayMessage); } else { console.log(`${symbols.info}No changes to display for '${TopLevelDirectoryName.Docs} > ${DocsFileName.ApiDocs}'`); } }; const diffPolicyDocs = (params: { highLevelDiff: DiffResults; localDefinition: CLIProductModuleDefinition; remoteDefinition: CLIProductModuleDefinitionOutput; }) => { const { highLevelDiff, localDefinition, remoteDefinition } = params; if (highLevelDiff.changes.documents.length > 0) { const localPmDocuments = localDefinition.documents; const remotePmDocuments = remoteDefinition.documents; for (const document of highLevelDiff.changes.documents) { if (isPolicyDocumentFileName(document)) { const key = mapFileNameToDocumentKey(document); // The terms PDF is a binary file, so we can't diff it we can only check if the pdf is a new one const displayMessage = key.includes('termsPdf') ? `file @/ + ${document} has been updated` : diffDisplay( { name: document, content: remotePmDocuments[key] ?? '' }, { name: document, content: localPmDocuments[key] ?? '' }, ); console.log(displayMessage); } else { console.log(`file @/ + ${document} has been updated`); } } } else { console.log(`${symbols.info}No changes to display for the policy document templates`); } }; const diffUnitTests = (params: { highLevelDiff: DiffResults; localDefinition: CLIProductModuleDefinition; remoteDefinition: CLIProductModuleDefinitionOutput; }) => { const { highLevelDiff, localDefinition, remoteDefinition } = params; console.log(chalk.bold(`\nUnit test code files in ./${TopLevelDirectoryName.Code}/${CodeDirectoryName.UnitTests}\n`)); if (highLevelDiff.changes.unitTestCodeFiles.length > 0) { const remoteUnitTestFiles = remoteDefinition.unitTestCodeFiles; const localUnitTestFiles = localDefinition.unitTestCodeFiles; for (const fileName of highLevelDiff.changes.unitTestCodeFiles) { const remoteFile = remoteUnitTestFiles.find((file) => file.fileName === fileName); const localFile = localUnitTestFiles.find((file) => file.fileName === fileName); const result = diffDisplay( { name: fileName, content: remoteFile?.fileContent || '' }, { name: fileName, content: localFile?.fileContent || '' }, ); console.log(removeNewlineMessage(result)); } } else { console.log(`${symbols.info}No changes.`); } }; const diffCodeFiles = (params: { highLevelDiff: DiffResults; localDefinition: CLIProductModuleDefinition; remoteDefinition: CLIProductModuleDefinitionOutput; }) => { const { highLevelDiff, localDefinition, remoteDefinition } = params; console.log(chalk.bold(`\nCode files in ./${TopLevelDirectoryName.Code}\n`)); if (highLevelDiff.changes.codeFiles.length > 0) { const remotePmCodeFiles = remoteDefinition.codeFiles; const localPmCodeFiles = localDefinition.codeFiles; for (const fileName of highLevelDiff.changes.codeFiles) { const remoteFile = remotePmCodeFiles.find((file) => file.fileName === fileName); const localFile = localPmCodeFiles.find((file) => file.fileName === fileName); const result = diffDisplay( { name: fileName, content: remoteFile?.fileContent || '' }, { name: fileName, content: localFile?.fileContent || '' }, ); console.log(removeNewlineMessage(result)); } } else { console.log(`${symbols.info}No changes.`); } }; const diffSourceCodeFiles = (params: { highLevelDiff: DiffResults; localDefinition: CLIProductModuleDefinition; remoteDefinition: CLIProductModuleDefinitionOutput; }) => { const { highLevelDiff, localDefinition, remoteDefinition } = params; console.log(chalk.bold(`\nSource files in ./${TopLevelDirectoryName.Code}/${CodeDirectoryName.Src}\n`)); if (highLevelDiff.changes.sourceCodeFiles.length > 0) { const remoteSourceCodeFiles = remoteDefinition.sourceCodeFiles || []; const localSourceCodeFiles = localDefinition.sourceCodeFiles || []; for (const fileName of highLevelDiff.changes.sourceCodeFiles) { const remoteFile = remoteSourceCodeFiles.find((file) => file.fileName === fileName); const localFile = localSourceCodeFiles.find((file) => file.fileName === fileName); const result = diffDisplay( { name: fileName, content: remoteFile?.fileContent || '' }, { name: fileName, content: localFile?.fileContent || '' }, ); console.log(removeNewlineMessage(result)); } } else { console.log(`${symbols.info}No changes.`); } }; export const diff = async (options: { code?: boolean; unitTests?: boolean; documents?: boolean; apiDocs?: boolean; readMe?: boolean; workflows?: boolean; }) => { const noOptionsPassed = Object.values(options).every((value) => !value); // We can remove the below once all options are supported if (noOptionsPassed) { throw new Error( `rp diff must be called with one or more options. Use 'rp diff --help' to see a list of supported options.`, ); } const dir = path.join('./'); const { apiKey, config: productModuleConfig } = readAuthAndConfig(); if (!isProductModuleConfig(productModuleConfig)) { throw new Error('.root-config.json is not a valid product module config file.'); } checkValidProductModuleDirectory({ productModuleConfig }); const localDefinition = await readProductModuleDefinition(dir); const remoteDefinitionNetwork = await runWithSpinner('Fetching product module from Root...', () => fetchProductModuleDefinition({ apiKey, productModuleKey: productModuleConfig.productModuleKey, pullLive: false, host: productModuleConfig.host, }), ); const remoteDefinition = productModuleDefinitionFromNetwork(remoteDefinitionNetwork); const highLevelDiff = checkDiff(localDefinition, remoteDefinition); if (!highLevelDiff.hasChanged) { console.log(`${symbols.check}No changes in local product module definition.`); return; } // Product module code files if (noOptionsPassed || options.code) { if (productModuleConfig.settings.typescriptProductModuleCode) { diffSourceCodeFiles({ highLevelDiff, localDefinition, remoteDefinition }); } else { diffCodeFiles({ highLevelDiff, localDefinition, remoteDefinition }); } } // Unit test code files if (noOptionsPassed || options.unitTests) { diffUnitTests({ highLevelDiff, localDefinition, remoteDefinition }); } // Policy document templates if (noOptionsPassed || options.documents) { diffPolicyDocs({ highLevelDiff, localDefinition, remoteDefinition }); } // API docs if (noOptionsPassed || options.apiDocs) { diffAPIDocs({ highLevelDiff, localDefinition, remoteDefinition }); } // Readme if (noOptionsPassed || options.readMe) { diffReadMe({ highLevelDiff, localDefinition, remoteDefinition }); } // Workflows if (noOptionsPassed || options.workflows) { diffWorkflows({ highLevelDiff, localDefinition, remoteDefinition }); } };