import { promises as fsp } from 'node:fs'; import path from 'node:path'; import chalk from 'chalk'; import { CLICodeFile, CLIProductModuleDefinition, SupplementaryTermsFile } from '../domain/product-module-definition'; import { JsonObject } from '../domain/json-types'; import { CLIScheduledFunction, RootConfigWrite } from '../domain/root-config'; import { PDFHelper } from './pdf-helper'; import { getErrorMessage } from './get-error-message'; import { camelCaseKeys } from './change-case'; import { existsAsync } from './fs-helpers'; import { CLIError, ExitCodes } from '../errors/platform-error'; import { DocsFileName, CodeDirectoryName, DocumentsDirectoryName, EmbedFileName, PolicyDocumentFileName, RequiredWorkflowFileName, TopLevelDirectoryName, TopLevelFileName, WorkflowsDirectoryName, } from '../domain/file-names'; export enum Command { Pull = 'pull', Push = 'push', } /** * Parse local API docs in JSON format with user-friendly error handling * @param directory The local product module parent directory * @returns The parsed API Docs schema */ const readApiDocs = async (directory: string): Promise => { const apiDocsPath = path.join(directory, TopLevelDirectoryName.Docs, DocsFileName.ApiDocs); let apiDocsSchema: unknown; try { apiDocsSchema = JSON.parse(await fsp.readFile(apiDocsPath, { encoding: 'utf8' })); } catch (error) { // File missing is fine (fall back to empty). JSON parse errors are not — warn loudly // so the user doesn't silently overwrite valid remote docs with a local typo. const nodeError = error as NodeJS.ErrnoException; if (nodeError && nodeError.code !== 'ENOENT') { console.log( chalk.yellow( [ `Warning: could not parse ${chalk.yellow(DocsFileName.ApiDocs)}`, `File: ${chalk.yellow(apiDocsPath)}`, '', 'Underlying error:', ...getErrorMessage(error) .split('\n') .map((line) => ` ${line}`), '', `Tip: API docs were skipped — push will continue with empty docs. Fix the JSON to include them on the next push.`, ].join('\n'), ), ); } return {}; } if (typeof apiDocsSchema !== 'object' || apiDocsSchema === null || Array.isArray(apiDocsSchema)) { console.log( chalk.yellow( [ `Warning: ${chalk.yellow(DocsFileName.ApiDocs)} is not a JSON object (legacy array format?)`, `File: ${chalk.yellow(apiDocsPath)}`, '', `Tip: API docs were skipped — push will continue with empty docs. Fix the JSON to include them on the next push.`, ].join('\n'), ), ); return {}; } return apiDocsSchema as JsonObject; }; /** * Recursively reads all files with the given extensions from a directory, returning * each file's content with a path relative to the base directory (e.g. "utils/policies.ts"). */ const readFilesRecursively = async ( basePath: string, currentPath: string, fileExtensions: string[], ): Promise => { const entries = await fsp.readdir(currentPath, { withFileTypes: true }); const results = await Promise.all( entries.map(async (entry) => { const fullPath = path.join(currentPath, entry.name); if (entry.isDirectory()) { return readFilesRecursively(basePath, fullPath, fileExtensions); } if (fileExtensions.some((ext) => entry.name.endsWith(ext))) { const fileName = path.relative(basePath, fullPath); const fileContent = await fsp.readFile(fullPath, { encoding: 'utf8' }); return [{ fileName, fileContent }]; } return []; }), ); return results.flat(); }; /** * Reads unit test code files in local directory. If no code files are found, * creates boilerplate code file. * @param directory The local product module parent directory * @returns {CLICodeFile[]} */ const readUnitTestCodeFiles = async (directory: string): Promise => { let unitTestCodeFiles: CLICodeFile[] = []; const unitTestsPath = path.join(directory, TopLevelDirectoryName.Code, CodeDirectoryName.UnitTests); // If an empty array is sent to platform, the unit test code is not overwritten in the database. if (!(await existsAsync(unitTestsPath))) { return []; } const fileNames = (await fsp.readdir(unitTestsPath)).filter( (fileName) => fileName.endsWith('.js') || fileName.endsWith('.ts'), ); if (fileNames.length === 0) { return []; } try { unitTestCodeFiles = await Promise.all( fileNames.map(async (fn) => { return { fileName: fn, fileContent: await fsp.readFile(path.join(unitTestsPath, fn), { encoding: 'utf8' }), }; }), ); } catch (error) { const message = getErrorMessage(error); const unitTestsDir = `${TopLevelDirectoryName.Code}/${CodeDirectoryName.UnitTests}/`; const yellowDir = chalk.yellow(unitTestsDir); throw new Error( [ `Error reading unit test code files`, `Source: ${yellowDir}`, '', 'Underlying error:', ...message.split('\n').map((line) => ` ${line}`), '', `Tip: confirm every file under ${yellowDir} is readable and has a .js or .ts extension.`, ].join('\n'), ); } return unitTestCodeFiles; }; export const readConfig = async (directory: string): Promise => { const localConfig = JSON.parse( await fsp.readFile(path.join(directory, TopLevelFileName.RootConfig), { encoding: 'utf8' }), ); // Backwards compatibility - PM settings used to have typos: `individualsIDAllowed` and `individualsCustomIDAllowed` const { policyholder } = localConfig.settings; const adjustedPolicyholderSettings = { ...policyholder, individualsIdAllowed: policyholder.individualsIdAllowed === undefined ? policyholder.individualsIDAllowed : policyholder.individualsIdAllowed, individualsCustomIdAllowed: policyholder.individualsCustomIdAllowed === undefined ? policyholder.individualsCustomIDAllowed : policyholder.individualsCustomIdAllowed, }; delete adjustedPolicyholderSettings.individualsIDAllowed; delete adjustedPolicyholderSettings.individualsCustomIDAllowed; localConfig.settings.policyholder = adjustedPolicyholderSettings; // Backwards compatibility - scheduled function frequency keys used to be in snake_case // We also renamed `key` to `functionName` localConfig.scheduledFunctions = localConfig.scheduledFunctions.map( (scheduledFunction: any): CLIScheduledFunction => { const correctedScheduledFunction = { ...scheduledFunction, functionName: scheduledFunction.functionName || scheduledFunction.key, frequency: camelCaseKeys(scheduledFunction.frequency), }; delete correctedScheduledFunction.key; return correctedScheduledFunction; }, ); // Backwards compatibility - these settings were removed and default to an empty array delete localConfig.settings.gracePeriod.sendNotification; delete localConfig.settings.waitingPeriod.sendNotification; delete localConfig.$schema; return localConfig; }; const readTermsFile = async (params: { directory: string }): Promise => { const { directory } = params; const termsFilePath = path.join(directory, TopLevelDirectoryName.Documents, PolicyDocumentFileName.Terms); if (!(await existsAsync(termsFilePath))) { // Exit non-zero: this used to process.exit(0), which let CI pipelines carry on // (e.g. publishing a stale draft) after a push that uploaded nothing. throw new CLIError( [ `Required document is missing: ${chalk.yellow(PolicyDocumentFileName.Terms)}`, `File: ${chalk.yellow(termsFilePath)}`, '', `Tip: add this file or run ${chalk.yellow('rp pull -f --no-sort')} to retrieve the latest product module definition.`, ].join('\n'), ExitCodes.GENERAL_ERROR, ); } return PDFHelper.buildDataURI(await fsp.readFile(termsFilePath, { encoding: 'base64' })); }; const readSupplementaryTermsFiles = async (params: { directory: string }): Promise => { const { directory } = params; const supplementaryTermsDirectory = path.join( directory, TopLevelDirectoryName.Documents, DocumentsDirectoryName.SupplementaryTerms, ); if (!(await existsAsync(supplementaryTermsDirectory))) { return []; } const fileNames = (await fsp.readdir(supplementaryTermsDirectory)).filter( (fileName) => fileName.slice(-4) === '.pdf', ); const readFiles = fileNames.map(async (fileName) => { const filePath = path.join(supplementaryTermsDirectory, fileName); const fileContent = await fsp.readFile(filePath, { encoding: 'base64' }); return { type: fileName.slice(0, -4), pdfBase64: PDFHelper.buildDataURI(fileContent), }; }); return Promise.all(readFiles); }; const getEmbedPaths = async (params: { directory: string }) => { const { directory } = params; const documentsDirPath = path.join(directory, TopLevelDirectoryName.Documents); const workflowsDirPath = path.join(directory, TopLevelDirectoryName.Workflows); const embedDirPath = path.join(workflowsDirPath, WorkflowsDirectoryName.Embed); // embed-config.json const newConfigPath = path.join(embedDirPath, EmbedFileName.EmbedConfig); const oldConfigPath = path.join(workflowsDirPath, EmbedFileName.EmbedConfig); const embedConfig = (await existsAsync(newConfigPath)) ? newConfigPath : (await existsAsync(oldConfigPath)) ? oldConfigPath : undefined; // pre personal details compliance const oldPrePersonalDetailsCompliancePath = path.join(documentsDirPath, 'pre_personal_details_compliance.md'); const newPrePersonalDetailsCompliancePath = path.join(embedDirPath, EmbedFileName.PrePersonalDetailsCompliance); const prePersonalDetailsCompliance = (await existsAsync(newPrePersonalDetailsCompliancePath)) ? newPrePersonalDetailsCompliancePath : (await existsAsync(oldPrePersonalDetailsCompliancePath)) ? oldPrePersonalDetailsCompliancePath : undefined; // pre payment compliance const oldPrePaymentCompliancePath = path.join(documentsDirPath, 'pre_payment_compliance.md'); const newPrePaymentCompliancePath = path.join(embedDirPath, EmbedFileName.PrePaymentCompliance); const prePaymentCompliance = (await existsAsync(newPrePaymentCompliancePath)) ? newPrePaymentCompliancePath : (await existsAsync(oldPrePaymentCompliancePath)) ? oldPrePaymentCompliancePath : undefined; // inputFields const embedInputFieldsPath = path.join(embedDirPath, EmbedFileName.InputFields); const inputFieldsPath = (await existsAsync(embedInputFieldsPath)) ? embedInputFieldsPath : undefined; return { embedConfig, prePersonalDetailsCompliance, prePaymentCompliance, inputFieldsPath, }; }; const readEmbedConfig = async (params: { directory: string }): Promise | undefined> => { const { directory } = params; const embedPaths = await getEmbedPaths({ directory }); let productModuleEmbedConfig; if (embedPaths.embedConfig) { // Read the embed config productModuleEmbedConfig = JSON.parse(await fsp.readFile(embedPaths.embedConfig, { encoding: 'utf8' })); // Read the pre personal details compliance wording content if (embedPaths.prePersonalDetailsCompliance) { const content = await fsp.readFile(embedPaths.prePersonalDetailsCompliance, { encoding: 'utf8', }); productModuleEmbedConfig.prePersonalDetailsCompliance = { ...productModuleEmbedConfig.prePersonalDetailsCompliance, wording: { ...productModuleEmbedConfig.prePersonalDetailsCompliance?.wording, content, }, }; } // Read the pre payment compliance wording content if (embedPaths.prePaymentCompliance) { const content = await fsp.readFile(embedPaths.prePaymentCompliance, { encoding: 'utf8', }); productModuleEmbedConfig.prePaymentCompliance = { ...productModuleEmbedConfig.prePaymentCompliance, wording: { ...productModuleEmbedConfig.prePaymentCompliance?.wording, content, }, }; } if (embedPaths.inputFieldsPath) { const content = await fsp.readFile(embedPaths.inputFieldsPath, { encoding: 'utf8', }); productModuleEmbedConfig.inputFields = JSON.parse(content); } } return productModuleEmbedConfig; }; /** * Read all the relevant files and create the full body payload. * @param directory The directory from which to read the product module. */ export const readProductModuleDefinition = async (directory: string): Promise => { // Read config file const localConfig = await readConfig(directory); const { configVersion, productModuleName, productModuleKey: key, organizationId, host, alterationHooks: configAlterationHooks, applicationAlterationHooks: configApplicationAlterationHooks, memberAlterationHooks: configMemberAlterationHooks, codeFileOrder, ...rootConfig } = localConfig; // Read the README.md file const readmeMarkdownPromise = fsp.readFile(path.join(directory, TopLevelFileName.Readme), { encoding: 'utf8' }); // Read documents const termsPdfBase64Promise = readTermsFile({ directory }); const supplementaryTermsPromise = readSupplementaryTermsFiles({ directory }); const [ welcomeLetterHtmlPromise, policyScheduleHtmlPromise, anniversaryLetterHtmlPromise, quoteSummaryHtmlPromise, certificateHtmlPromise, memberCertificateHtmlPromise, invoiceTemplateHtmlPromise, ] = [ PolicyDocumentFileName.WelcomeLetter, PolicyDocumentFileName.PolicySchedule, PolicyDocumentFileName.AnniversaryLetter, PolicyDocumentFileName.QuoteSummary, PolicyDocumentFileName.Certificate, PolicyDocumentFileName.MemberCertificate, PolicyDocumentFileName.Invoice, ].map(async (documentFileName) => { const documentFilePath = path.join(directory, TopLevelDirectoryName.Documents, documentFileName); const document = (await existsAsync(documentFilePath)) ? await fsp.readFile(documentFilePath, { encoding: 'utf-8', }) : undefined; return document; }); // Read workflows const workflowsDirPath = path.join(directory, TopLevelDirectoryName.Workflows); const [quoteSchemaPromise, applicationSchemaPromise, claimsBlocksPromise] = [ RequiredWorkflowFileName.QuoteSchema, RequiredWorkflowFileName.ApplicationSchema, RequiredWorkflowFileName.ClaimsBlocks, ].map(async (workflowFileName) => { const workflowFilePath = path.join(workflowsDirPath, workflowFileName); const fileContent = await fsp.readFile(workflowFilePath, { encoding: 'utf-8' }); return JSON.parse(fileContent) as Record[]; }); const alterationHooksPromise = Promise.all( configAlterationHooks.map(async (alterationHook) => { const alterationHookFilePath = path.join( workflowsDirPath, WorkflowsDirectoryName.AlterationHooks, `${alterationHook.key}.json`, ); const content = await fsp.readFile(alterationHookFilePath, { encoding: 'utf-8' }); return { key: alterationHook.key, name: alterationHook.name, schema: JSON.parse(content) as Record[], }; }), ); const applicationAlterationHooksPromise = Promise.all( (configApplicationAlterationHooks || []).map(async (alterationHook) => { const alterationHookFilePath = path.join( workflowsDirPath, WorkflowsDirectoryName.ApplicationAlterationHooks, `${alterationHook.key}.json`, ); const content = await fsp.readFile(alterationHookFilePath, { encoding: 'utf-8' }); return { key: alterationHook.key, name: alterationHook.name, schema: JSON.parse(content) as Record[], }; }), ); const memberAlterationHooksPromise = Promise.all( (configMemberAlterationHooks || []).map(async (alterationHook) => { const alterationHookFilePath = path.join( workflowsDirPath, WorkflowsDirectoryName.MemberAlterationHooks, `${alterationHook.key}.json`, ); const content = await fsp.readFile(alterationHookFilePath, { encoding: 'utf-8' }); return { key: alterationHook.key, name: alterationHook.name, schema: JSON.parse(content) as Record[], }; }), ); // Read embed files const productModuleEmbedConfigPromise = readEmbedConfig({ directory }); // Read the API docs const apiDocsSchemaPromise = readApiDocs(directory); const isTypeScript = !!rootConfig.settings.typescriptProductModuleCode; // Read code files const codeFilesPromise = isTypeScript ? (async () => { const buildDirPath = path.join(directory, TopLevelDirectoryName.Code, CodeDirectoryName.Build); if (!(await existsAsync(buildDirPath))) { const buildDir = `${TopLevelDirectoryName.Code}/${CodeDirectoryName.Build}/`; throw new Error( [ `TypeScript build output not found`, `File: ${chalk.yellow(buildDir)}`, '', `Tip: run ${chalk.yellow('rp ts-build')} to compile your TypeScript source files, then push again.`, ].join('\n'), ); } const fileNames = (await fsp.readdir(buildDirPath)).filter((fn) => fn.endsWith('.js')); return Promise.all( fileNames.map(async (fn) => ({ fileName: fn, fileContent: await fsp.readFile(path.join(buildDirPath, fn), { encoding: 'utf8' }), })), ); })() : Promise.all( codeFileOrder.map(async (fn) => ({ fileName: fn, fileContent: await fsp.readFile(path.join(directory, TopLevelDirectoryName.Code, fn), { encoding: 'utf8' }), })), ); const sourceCodeFilesPromise = isTypeScript ? (async (): Promise => { const srcPath = path.join(directory, TopLevelDirectoryName.Code, CodeDirectoryName.Src); if (!(await existsAsync(srcPath))) { return undefined; } const files = await readFilesRecursively(srcPath, srcPath, ['.ts', '.js']); return files.length === 0 ? undefined : files; })() : Promise.resolve(undefined); // Read unit-test files const unitTestCodeFilesPromise = readUnitTestCodeFiles(directory); const [ readmeMarkdown, termsPdfBase64, supplementaryTerms, policyScheduleHtml, welcomeLetterHtml, anniversaryLetterHtml, quoteSummaryHtml, memberCertificateHtml, certificateHtml, invoiceTemplateHtml, quoteSchema, applicationSchema, claimsBlocks, alterationHooks, applicationAlterationHooks, memberAlterationHooks, productModuleEmbedConfig, apiDocsSchema, codeFiles, sourceCodeFiles, unitTestCodeFiles, ] = await Promise.all([ readmeMarkdownPromise, termsPdfBase64Promise, supplementaryTermsPromise, policyScheduleHtmlPromise, welcomeLetterHtmlPromise, anniversaryLetterHtmlPromise, quoteSummaryHtmlPromise, memberCertificateHtmlPromise, certificateHtmlPromise, invoiceTemplateHtmlPromise, quoteSchemaPromise, applicationSchemaPromise, claimsBlocksPromise, alterationHooksPromise, applicationAlterationHooksPromise, memberAlterationHooksPromise, productModuleEmbedConfigPromise, apiDocsSchemaPromise, codeFilesPromise, sourceCodeFilesPromise, unitTestCodeFilesPromise, ]); // Compile the product module definition return { key, rootConfig, readmeMarkdown, productModuleName, documents: { termsPdfBase64, supplementaryTerms, policyScheduleHtml, welcomeLetterHtml, anniversaryLetterHtml, quoteSummaryHtml, memberCertificateHtml, certificateHtml, invoiceTemplateHtml, }, workflows: { quoteSchema, applicationSchema, claimsBlocks, alterationHooks, applicationAlterationHooks, memberAlterationHooks, productModuleEmbedConfig, }, apiDocsSchema, codeFiles, sourceCodeFiles, unitTestCodeFiles, }; };