import chalk from 'chalk'; import path from 'node:path'; import { CollectionModuleConfig, isCollectionModuleConfig, } from '../domain/collection-modules/collection-module-config'; import { collectionModuleDefinitionToNetwork, collectionModuleDefinitionToNetworkCodeBased, } from '../domain/collection-modules/collection-module-definition'; import { productModuleDefinitionFromNetwork, productModuleDefinitionToNetwork, } from '../domain/product-module-definition'; import { RootConfigWrite } from '../domain/root-config'; import { checkValidProductModuleDirectory } from '../helpers/check-valid-product-module-directory'; import { createLambdaPackage } from '../helpers/collection-modules/create-lambda-package'; import { createSourcePackage } from '../helpers/collection-modules/create-source-package'; import { getLambdaPackageSignedUrl, getSourcePackageSignedUrl, uploadLambdaPackageToS3, uploadSourcePackageToS3, } from '../helpers/collection-modules/lambda-package-helpers'; import { readCollectionModuleDefinition } from '../helpers/collection-modules/read-collection-module-definition'; import { readCollectionModuleStaticFiles, writeCollectionModuleStaticFiles, } from '../helpers/collection-modules/static-file-helpers'; import { updateCollectionModuleDefinition } from '../helpers/collection-modules/update-collection-module-definition'; import { fetchProductModuleDefinition } from '../helpers/fetch-product-module-definition'; import { handleProductModuleNameChange } from '../helpers/handle-product-module-name-change'; import { checkDiff, displayDiffWarning } from '../helpers/high-level-diff'; import { readJsConfigFiles } from '../helpers/jsconfig-helpers'; import { postProductModuleDefinition } from '../helpers/post-product-module-definition'; import { readAuthAndConfig } from '../helpers/read-auth-and-config'; import { readProductModuleDefinition } from '../helpers/read-product-module-definition'; import { ask } from '../helpers/readline-helper'; import { createSpinner, runWithSpinner } from '../helpers/spinner'; import { symbols } from '../helpers/symbols'; import { toProductModuleStack } from '../helpers/types-helpers'; import { updateStaticFiles } from '../helpers/update-static-files'; import { validateAlterationHookKeys } from '../helpers/validate-alteration-hook-keys'; import { validateDocuments } from '../helpers/validate-documents'; import { validateProductModuleDefinition } from '../helpers/validate-product-module-definition'; import { writeProductModuleDefinition } from '../helpers/write-product-module-definition'; import { tsBuild } from './ts-build'; interface PushOptions { force?: boolean; sort?: boolean; target?: string; } export const pushCollectionModule = async (params: { apiKey: string; collectionModuleConfig: CollectionModuleConfig; }) => { const { apiKey, collectionModuleConfig } = params; const { host, collectionModuleKey } = collectionModuleConfig; const directory = path.join('./'); // Read collection module definition and static files const [collectionModuleDefinition, existingStaticFiles] = await Promise.all([ readCollectionModuleDefinition({ collectionModuleConfig, directory, }), readCollectionModuleStaticFiles(directory), ]); // Use code-based payload (code + paths, no S3) when host is localhost/127.0.0.1, unless FORCE_S3 is set const isLocalHost = host.includes('localhost') || host.includes('127.0.0.1'); const useCodeBasedPayload = isLocalHost && !process.env.FORCE_S3; if (useCodeBasedPayload) { const networkDefinition = collectionModuleDefinitionToNetworkCodeBased(collectionModuleDefinition); await runWithSpinner('Updating collection module on Root...', () => updateCollectionModuleDefinition({ apiKey, collectionModuleDefinition: networkDefinition, collectionModuleKey, host, }), ); } else { // Production: upload Lambda and source to S3, then PATCH with lambda_package_s3_key. const lambdaPackage = await createLambdaPackage({ directory, collectionModuleConfig, }); const uploadSpinner = createSpinner('Uploading deployment package...'); uploadSpinner.start(); const { signedUrl, codeId } = await getLambdaPackageSignedUrl({ collectionModuleKey, host, apiKey, }); await uploadLambdaPackageToS3({ signedUrl, lambdaPackage, }); uploadSpinner.succeed('Deployment package uploaded successfully'); const sourcePackage = await runWithSpinner('Creating source package...', () => createSourcePackage({ codeFiles: collectionModuleDefinition.codeFiles }), ); const sourceSpinner = createSpinner('Uploading source files...'); sourceSpinner.start(); const sourceSignedUrl = await getSourcePackageSignedUrl({ collectionModuleKey, host, apiKey, }); await uploadSourcePackageToS3({ signedUrl: sourceSignedUrl, sourcePackage, }); sourceSpinner.succeed('Source files uploaded successfully'); const lambdaPackageS3Key = `collection-module-code/${codeId}/lambda-package.zip`; const networkDefinition = collectionModuleDefinitionToNetwork(collectionModuleDefinition, lambdaPackageS3Key); await runWithSpinner('Updating collection module on Root...', () => updateCollectionModuleDefinition({ apiKey, collectionModuleDefinition: networkDefinition, collectionModuleKey, host, }), ); } // Write static files back to ensure they're up to date await writeCollectionModuleStaticFiles({ directory, existingStaticFiles }); console.log('\n' + chalk.green(`Collection module '${collectionModuleKey}' successfully pushed to Root.`)); }; export const pushProductModule = async (params: { apiKey: string; productModuleConfig: RootConfigWrite; options: PushOptions; }) => { const { apiKey, options, productModuleConfig } = params; const forcePush = !!options.force; // Default to not force pushing const sortToMatchLocal = !!options.sort; // Default to sorting object keys checkValidProductModuleDirectory({ productModuleConfig }); const productModuleKey = options.target || productModuleConfig.productModuleKey; if ( options.target && !forcePush && !(await ask.yesNo( chalk.yellow( `You are about to push the contents of ${chalk.blue(productModuleConfig.productModuleKey)} to the ${chalk.blue( options?.target, )} module. Are you sure you would like to continue (y/n)? `, ), )) ) { console.log('\nAborting push. When you are ready, run the "rp push" command again.'); return; } const { host } = productModuleConfig; if (productModuleConfig.settings.typescriptProductModuleCode) { await tsBuild(); } // Read the product module contents const productModuleDirectory = path.join('./'); const localDefinition = await readProductModuleDefinition(productModuleDirectory); validateAlterationHookKeys(localDefinition); const networkDefinition = productModuleDefinitionToNetwork(localDefinition); if (forcePush) { // Force push: validate only, no diff comparison needed await runWithSpinner('Validating product module...', () => validateProductModuleDefinition({ productModuleKey, apiKey, productModuleDefinition: networkDefinition, host: productModuleConfig.host, }), ); } else { // Non-force push: run validation and fetch in parallel for better performance. // Wire an AbortController so the fetch is cancelled as soon as validation rejects — // otherwise the (now irrelevant) GET keeps running in the background, consuming // the user's bandwidth and server resources after they've already seen a failure. const spinner = createSpinner('Validating and fetching remote product module...'); spinner.start(); const abortController = new AbortController(); let remoteDefinitionNetwork; try { const validatePromise = validateProductModuleDefinition({ productModuleKey, apiKey, productModuleDefinition: networkDefinition, host: productModuleConfig.host, }).catch((error) => { // Tear down the parallel fetch the instant validation fails. abortController.abort(); throw error; }); const fetchPromise = fetchProductModuleDefinition({ apiKey, productModuleKey, pullLive: false, host: productModuleConfig.host, signal: abortController.signal, }); [, remoteDefinitionNetwork] = await Promise.all([validatePromise, fetchPromise]); spinner.succeed('Validation complete'); } catch (error) { spinner.fail(); throw error; } const remoteDefinition = productModuleDefinitionFromNetwork(remoteDefinitionNetwork); // Check the diffs between the local and remote version and warn the user const diff = checkDiff(localDefinition, remoteDefinition); if (localDefinition.productModuleName !== remoteDefinition.productModuleName && !options.target) { await handleProductModuleNameChange({ newName: localDefinition.productModuleName, oldName: remoteDefinition.productModuleName, apiKey, host, productModuleKey, }); } if (!diff.hasChanged) { console.log(`${symbols.check} No changes to push, local version is in sync with Root.`); return; } const answer = await displayDiffWarning(diff.changes); if (!answer) { console.log('Aborting push. When you are ready, run the "rp push" command again.'); return; } } await validateDocuments({ apiKey, productModuleConfig }); // Post the product module to Root const updatedRemoteDefinitionNetwork = await runWithSpinner('Pushing product module to Root...', () => postProductModuleDefinition({ productModuleKey, apiKey, productModuleDefinition: networkDefinition, host: productModuleConfig.host, }), ); const updatedRemoteDefinition = productModuleDefinitionFromNetwork(updatedRemoteDefinitionNetwork); const stack = toProductModuleStack(updatedRemoteDefinition.rootConfig?.stack); console.log( '\n' + chalk.green( `Product module '${localDefinition.key}' successfully pushed to Root to create draft version ${ updatedRemoteDefinition.version || '' }.`, ), ); if (!options.target) { // Read the existing jsconfig.json files, before the directory is overwritten const currentJsConfigs = await readJsConfigFiles(productModuleDirectory); // Now instantiate the product module into the folder the same way clone works await runWithSpinner('Writing product module to directory...', () => writeProductModuleDefinition({ productModuleDefinition: updatedRemoteDefinition, directory: productModuleDirectory, host, targetOrderDefinition: sortToMatchLocal ? localDefinition : undefined, }), ); // Update static files await runWithSpinner('Updating static files...', () => updateStaticFiles({ productModuleDirectory, apiKey, host, currentJsConfigs, stack, }), ); console.log( chalk.green( `Version ${String(updatedRemoteDefinition.version)} of product module '${ updatedRemoteDefinition.key }' successfully written to current directory.`, ), ); } }; export const push = async (options: PushOptions) => { const { apiKey, config } = readAuthAndConfig(); await (isCollectionModuleConfig(config) ? pushCollectionModule({ apiKey, collectionModuleConfig: config }) : pushProductModule({ apiKey, options, productModuleConfig: config })); };