import { promises as fsp } from 'node:fs'; import path from 'node:path'; import { CollectionModuleTopDirectory, CollectionModuleFileName, CollectionModuleWorkflowsDirectory, } from '../../domain/collection-modules/collection-module-file-names'; import { CollectionModuleConfig } from '../../domain/collection-modules/collection-module-config'; import { writeFile } from '../write-file'; import { TopLevelFileName } from '../../domain/file-names'; import { existsAsync, rmAsync } from '../fs-helpers'; import { CollectionModuleDefinitionResponse } from '../../domain/collection-modules/collection-module-definition'; import { downloadAndExtractSourcePackage } from './download-source-package'; import { createCodeFilesSignedUrls, S3Operation, addContentToCodeFile } from './code-file-helpers'; /** * Files whose first path segment is in this set are written at the clone root (e.g. * `/.claude/...`) instead of inside the `code/` subdirectory. These are dev-tooling * and CI folders that ship with the template repo but have no place in the Lambda source * tree — keeping them outside `code/` means `readCollectionModuleDefinition` (which walks * only `code/`) will never pick them up on a subsequent `cli push`. */ const ROOT_PLACED_TOP_SEGMENTS: ReadonlySet = new Set(['.claude', '.cursor', '.github']); const isRootPlacedPath = (pathSegments: readonly string[]): boolean => pathSegments.length > 0 && ROOT_PLACED_TOP_SEGMENTS.has(pathSegments[0]); export const writeCollectionModuleDefinition = async (params: { collectionModuleDefinition: CollectionModuleDefinitionResponse; directory: string; host: string; apiKey: string; liveVersion: boolean; }) => { const { collectionModuleDefinition, directory, host, apiKey, liveVersion } = params; const { codeFiles, collectionModuleKey, collectionModuleName, organizationId, code, rootConfig, workflows } = collectionModuleDefinition; const { manualTransactionWorkflows } = workflows; // Clear out all existing folders to ensure no mixing of old and new const paths = Object.values(CollectionModuleTopDirectory).map((name) => path.join(directory, name)); const removeAndRecreate = paths.map(async (p) => { await rmAsync(p, { recursive: true, force: true }); if (p === path.join(directory, CollectionModuleTopDirectory.Dist)) { return; } await fsp.mkdir(p); }); await Promise.all(removeAndRecreate); await fsp.mkdir( path.join(directory, CollectionModuleTopDirectory.Workflows, CollectionModuleWorkflowsDirectory.ManualTransactions), ); // Write config file const config: CollectionModuleConfig = { collectionModuleKey, collectionModuleName, organizationId, host, settings: rootConfig.settings, manualTransactions: rootConfig.manualTransactions.map((t) => { return { key: t.key, functionName: t.functionName, displayData: t.displayData }; }), }; const configPath = path.join(directory, TopLevelFileName.RootConfig); const writeConfig = writeFile(configPath, JSON.stringify(config, null, 2)); // Detect format based on response structure: // - Old format: Backend returns 'code' (non-empty string) and 'paths' (array with items) // - New format: Backend returns neither or empty values const isOldFormat = code && code.length > 0 && codeFiles && codeFiles.length > 0; let updatedCodeFilesWithContent: { pathSegments: string[]; content: string }[]; if (isOldFormat) { // Old flow: Download individual files from S3 based on paths const updatedCodeFilesWithGetUrls = await createCodeFilesSignedUrls({ codeFiles, collectionModuleKey, host, apiKey, operation: S3Operation.GetObject, liveVersion, }); updatedCodeFilesWithContent = await Promise.all(updatedCodeFilesWithGetUrls.map(addContentToCodeFile)); } else { // New flow: Download and extract source.zip updatedCodeFilesWithContent = await downloadAndExtractSourcePackage({ collectionModuleKey, host, apiKey, liveVersion, }); } const codeDir = path.join(directory, CollectionModuleTopDirectory.Code); const writeIndividualCodeFiles = updatedCodeFilesWithContent.length > 0 ? updatedCodeFilesWithContent.map(async (codeFile) => { const { content, pathSegments } = codeFile; // Dev-tooling folders (.claude, .cursor, .github) land at the clone root alongside // .root-config.json / .gitignore; everything else stays under `code/`. const baseDir = isRootPlacedPath(pathSegments) ? directory : codeDir; const filePath = path.join(baseDir, ...pathSegments); const dirPath = path.dirname(filePath); if (!(await existsAsync(dirPath))) { await fsp.mkdir(dirPath, { recursive: true }); } await writeFile(filePath, content); }) : // Backwards compatibility - if no code files are present, write the code to the main file [writeFile(path.join(codeDir, CollectionModuleFileName.MainJs), code)]; const writeManualTransactionWorkflowSchemaFiles = manualTransactionWorkflows.map(async (t) => { const { key, schema } = t; const filePath = path.join( directory, CollectionModuleTopDirectory.Workflows, CollectionModuleWorkflowsDirectory.ManualTransactions, `${key}.json`, ); await writeFile(filePath, JSON.stringify(schema, null, 2)); }); await Promise.all([writeConfig, ...writeIndividualCodeFiles, ...writeManualTransactionWorkflowSchemaFiles]); };