import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { AppMetaSchema, type AppMetaType } from "@calcom/types/AppMetaSchema"; import chokidar from "chokidar"; // eslint-disable-next-line no-restricted-imports import { debounce } from "lodash"; import { APP_STORE_PATH } from "./constants"; import { getAppName } from "./utils/getAppName"; const isInWatchMode = process.argv[2] === "--watch"; const repoRoot = path.resolve(__dirname, "../../.."); const formatFileWithBiome = (filePath: string) => { // Normalize to forward slashes for cross-platform Biome compatibility const normalizedPath = filePath.replace(/\\/g, "/"); const { status } = spawnSync( "yarn", ["biome", "format", "--write", "--no-errors-on-unmatched", normalizedPath], { stdio: "inherit", cwd: repoRoot, shell: true, } ); if (status !== 0) { throw new Error(`Biome formatting failed for ${filePath}`); } }; const formatOutput = (source: string) => source; const getVariableName = (appName: string) => appName.replace(/[-./]/g, "_"); // INFO: Handle stripe separately as it's an old app with different dirName than slug/appId const getAppId = (app: { name: string }) => (app.name === "stripepayment" ? "stripe" : app.name); type App = Partial & { name: string; path: string; }; function generateFiles() { const browserOutput = [`import dynamic from "next/dynamic"`]; const metadataOutput = []; const bookerMetadataOutput = []; const schemasOutput = []; const appKeysSchemasOutput = []; const serverOutput = []; const crmOutput = []; const appDirs: { name: string; path: string }[] = []; fs.readdirSync(`${APP_STORE_PATH}`).forEach((dir) => { if (dir === "ee" || dir === "templates") { fs.readdirSync(path.join(APP_STORE_PATH, dir)).forEach((subDir) => { if (fs.statSync(path.join(APP_STORE_PATH, dir, subDir)).isDirectory()) { if (getAppName(subDir)) { appDirs.push({ name: subDir, path: path.join(dir, subDir), }); } } }); } else { if (fs.statSync(path.join(APP_STORE_PATH, dir)).isDirectory()) { if (!getAppName(dir)) { return; } appDirs.push({ name: dir, path: dir, }); } } }); function forEachAppDir(callback: (arg: App) => void, filter: (arg: App) => boolean = () => true) { for (let i = 0; i < appDirs.length; i++) { const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json"); const metadataPath = path.join(APP_STORE_PATH, appDirs[i].path, "_metadata.ts"); let app: Partial; if (fs.existsSync(configPath)) { try { const rawConfig = fs.readFileSync(configPath, "utf8"); const parsedConfig = JSON.parse(rawConfig); app = AppMetaSchema.parse(parsedConfig); } catch (error) { const prefix = `Config error in ${path.join(APP_STORE_PATH, appDirs[i].path, "config.json")}`; throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`); } } else if (fs.existsSync(metadataPath)) { // eslint-disable-next-line @typescript-eslint/no-var-requires app = require(metadataPath).metadata; } else { app = {}; } const finalApp = { ...app, name: appDirs[i].name, path: appDirs[i].path, }; if (filter(finalApp)) { callback(finalApp); } } } /** * Windows has paths with backslashes, so we need to replace them with forward slashes * .ts and .tsx files are imported without extensions * If a file has index.ts or index.tsx, it can be imported after removing the index.ts* part */ function getModulePath(path: string, moduleName: string) { return `./${path.replace(/\\/g, "/")}/${moduleName .replace(/\/index\.ts|\/index\.tsx/, "") .replace(/\.tsx$|\.ts$/, "")}`; } type ImportConfig = | { fileToBeImported: string; importName?: string; } | [ { fileToBeImported: string; importName?: string; }, { fileToBeImported: string; importName: string; }, ]; /** * If importConfig is an array, only 2 items are allowed. First one is the main one and second one is the fallback */ function getExportedObject( objectName: string, { lazyImport = false, importConfig, entryObjectKeyGetter = (app) => app.name, }: { lazyImport?: boolean; importConfig: ImportConfig; entryObjectKeyGetter?: (arg: App, importName?: string) => string; }, filter?: (arg: App) => boolean ) { const output: string[] = []; const getLocalImportName = ( app: { name: string }, chosenConfig: ReturnType ) => `${getVariableName(app.name)}_${getVariableName(chosenConfig.fileToBeImported)}`; const fileToBeImportedExists = ( app: { path: string }, chosenConfig: ReturnType ) => fs.existsSync(path.join(APP_STORE_PATH, app.path, chosenConfig.fileToBeImported)); addImportStatements(); createExportObject(); return output; function addImportStatements() { forEachAppDir((app) => { const chosenConfig = getChosenImportConfig(importConfig, app); if (fileToBeImportedExists(app, chosenConfig) && chosenConfig.importName) { const importName = chosenConfig.importName; if (!lazyImport) { if (importName !== "default") { // Import with local alias that will be used by createExportObject output.push( `import { ${importName} as ${getLocalImportName(app, chosenConfig)} } from "${getModulePath( app.path, chosenConfig.fileToBeImported )}"` ); } else { // Default Import output.push( `import ${getLocalImportName(app, chosenConfig)} from "${getModulePath( app.path, chosenConfig.fileToBeImported )}"` ); } } } }, filter); } function createExportObject() { output.push(`export const ${objectName} = {`); forEachAppDir((app) => { const chosenConfig = getChosenImportConfig(importConfig, app); if (fileToBeImportedExists(app, chosenConfig)) { if (!lazyImport) { const key = entryObjectKeyGetter(app); output.push(`"${key}": ${getLocalImportName(app, chosenConfig)},`); } else { const key = entryObjectKeyGetter(app); if (chosenConfig.fileToBeImported.endsWith(".tsx")) { output.push( `"${key}": dynamic(() => import("${getModulePath( app.path, chosenConfig.fileToBeImported )}")),` ); } else { output.push(`"${key}": import("${getModulePath(app.path, chosenConfig.fileToBeImported)}"),`); } } } }, filter); output.push(`};`); } function getChosenImportConfig(importConfig: ImportConfig, app: { path: string }) { let chosenConfig: ImportConfig; if (!Array.isArray(importConfig)) { chosenConfig = importConfig; } else { if (fs.existsSync(path.join(APP_STORE_PATH, app.path, importConfig[0].fileToBeImported))) { chosenConfig = importConfig[0]; } else { chosenConfig = importConfig[1]; } } return chosenConfig; } } serverOutput.push( ...getExportedObject("apiHandlers", { importConfig: { fileToBeImported: "api/index.ts", }, lazyImport: true, }) ); metadataOutput.push( ...getExportedObject("appStoreMetadata", { // Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata importConfig: [ { fileToBeImported: "config.json", importName: "default", }, { fileToBeImported: "_metadata.ts", importName: "metadata", }, ], }) ); bookerMetadataOutput.push( ...getExportedObject( "appStoreMetadata", { // Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata importConfig: [ { fileToBeImported: "config.json", importName: "default", }, { fileToBeImported: "_metadata.ts", importName: "metadata", }, ], }, isBookerApp ) ); schemasOutput.push( ...getExportedObject("appDataSchemas", { // Import path must have / even for windows and not \ importConfig: { fileToBeImported: "zod.ts", importName: "appDataSchema", }, // HACK: Key must be appId as this is used by eventType metadata and lookup is by appId // This can be removed once we rename the ids of apps like stripe to that of their app folder name entryObjectKeyGetter: (app) => getAppId(app), }) ); appKeysSchemasOutput.push( ...getExportedObject("appKeysSchemas", { importConfig: { fileToBeImported: "zod.ts", importName: "appKeysSchema", }, // HACK: Key must be appId as this is used by eventType metadata and lookup is by appId // This can be removed once we rename the ids of apps like stripe to that of their app folder name entryObjectKeyGetter: (app) => getAppId(app), }) ); browserOutput.push( ...getExportedObject("InstallAppButtonMap", { importConfig: { fileToBeImported: "components/InstallAppButton.tsx", }, lazyImport: true, }) ); // TODO: Make a component map creator that accepts ComponentName and does the rest. // TODO: dailyvideo has a slug of daily-video, so that mapping needs to be taken care of. But it is an old app, so it doesn't need AppSettings browserOutput.push( ...getExportedObject("AppSettingsComponentsMap", { importConfig: { fileToBeImported: "components/AppSettingsInterface.tsx", }, lazyImport: true, }) ); browserOutput.push( ...getExportedObject("EventTypeAddonMap", { importConfig: { fileToBeImported: "components/EventTypeAppCardInterface.tsx", }, lazyImport: true, }) ); browserOutput.push( ...getExportedObject("EventTypeSettingsMap", { importConfig: { fileToBeImported: "components/EventTypeAppSettingsInterface.tsx", }, lazyImport: true, }) ); crmOutput.push( ...getExportedObject( "CrmServiceMap", { importConfig: { fileToBeImported: "lib/CrmService.ts", importName: "default", }, lazyImport: true, }, isCrmApp ) ); const calendarOutput = []; const calendarServices = getExportedObject( "CalendarServiceMap", { importConfig: { fileToBeImported: "lib/CalendarService.ts", importName: "default", }, lazyImport: true, }, isCalendarApp ); // Find the export line and wrap it with E2E conditional const exportLineIndex = calendarServices.findIndex((line) => line.startsWith("export const CalendarServiceMap") ); if (exportLineIndex !== -1) { const exportLine = calendarServices[exportLineIndex]; const objectContent = calendarServices.slice(exportLineIndex + 1, -1); // Remove export line and closing brace calendarOutput.push( exportLine.replace( "export const CalendarServiceMap = {", "export const CalendarServiceMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {" ), ...objectContent, "};" ); } else { calendarOutput.push(...calendarServices); } const analyticsOutput = []; const analyticsServices = getExportedObject( "AnalyticsServiceMap", { importConfig: { fileToBeImported: "lib/AnalyticsService.ts", importName: "default", }, lazyImport: true, }, (app: App) => { const hasAnalyticsService = fs.existsSync( path.join(APP_STORE_PATH, app.path, "lib/AnalyticsService.ts") ); return hasAnalyticsService; } ); const analyticsExportLineIndex = analyticsServices.findIndex((line) => line.startsWith("export const AnalyticsServiceMap") ); if (analyticsExportLineIndex !== -1) { const exportLine = analyticsServices[analyticsExportLineIndex]; const objectContent = analyticsServices.slice(analyticsExportLineIndex + 1, -1); analyticsOutput.push( exportLine.replace( "export const AnalyticsServiceMap = {", "export const AnalyticsServiceMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {" ), ...objectContent, "};" ); } else { analyticsOutput.push(...analyticsServices); } const paymentOutput = []; const paymentServices = getExportedObject( "PaymentServiceMap", { importConfig: { fileToBeImported: "lib/PaymentService.ts", importName: "PaymentService", }, lazyImport: true, }, (app: App) => { const hasPaymentService = fs.existsSync(path.join(APP_STORE_PATH, app.path, "lib/PaymentService.ts")); return hasPaymentService; } ); paymentOutput.push(...paymentServices); const videoOutput = []; const videoAdapters = getExportedObject( "VideoApiAdapterMap", { importConfig: { fileToBeImported: "lib/VideoApiAdapter.ts", importName: "default", }, lazyImport: true, }, (app: App) => { return fs.existsSync(path.join(APP_STORE_PATH, app.path, "lib/VideoApiAdapter.ts")); } ); const videoExportLineIndex = videoAdapters.findIndex((line) => line.startsWith("export const VideoApiAdapterMap") ); if (videoExportLineIndex !== -1) { const exportLine = videoAdapters[videoExportLineIndex]; const objectContent = videoAdapters.slice(videoExportLineIndex + 1, -1); videoOutput.push( exportLine.replace( "export const VideoApiAdapterMap = {", "export const VideoApiAdapterMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {" ), ...objectContent, "};" ); } else { videoOutput.push(...videoAdapters); } // Generate redirect apps list (apps with externalLink in their config, excluding templates) const redirectAppsOutput: string[] = []; const redirectAppSlugs: string[] = []; forEachAppDir((app) => { // Exclude templates - they are not actual apps if (app.externalLink && !app.path.replace(/\\/g, "/").startsWith("templates/")) { redirectAppSlugs.push(app.name); } }); redirectAppsOutput.push(`export const REDIRECT_APPS = ${JSON.stringify(redirectAppSlugs)} as const;`); redirectAppsOutput.push(`export type RedirectApp = typeof REDIRECT_APPS[number];`); const banner = `/** This file is autogenerated using the command \`yarn app-store:build --watch\`. Don't modify this file manually. **/ `; const filesToGenerate: [string, string[]][] = [ ["analytics.services.generated.ts", analyticsOutput], ["apps.metadata.generated.ts", metadataOutput], ["apps.server.generated.ts", serverOutput], ["apps.browser.generated.tsx", browserOutput], ["apps.schemas.generated.ts", schemasOutput], ["apps.keys-schemas.generated.ts", appKeysSchemasOutput], ["bookerApps.metadata.generated.ts", bookerMetadataOutput], ["crm.apps.generated.ts", crmOutput], ["calendar.services.generated.ts", calendarOutput], ["payment.services.generated.ts", paymentOutput], ["video.adapters.generated.ts", videoOutput], ["redirect-apps.generated.ts", redirectAppsOutput], ]; filesToGenerate.forEach(([fileName, output]) => { const filePath = path.join(APP_STORE_PATH, fileName); fs.writeFileSync(filePath, formatOutput(`${banner}${output.join("\n")}`)); formatFileWithBiome(filePath); }); console.log(`Generated ${filesToGenerate.map(([fileName]) => fileName).join(", ")}`); } const debouncedGenerateFiles = debounce(generateFiles); if (isInWatchMode) { chokidar .watch(APP_STORE_PATH) .on("addDir", (dirPath) => { const appName = getAppName(dirPath); if (appName) { console.log(`Added ${appName}`); debouncedGenerateFiles(); } }) .on("change", (filePath) => { if (filePath.endsWith("config.json")) { console.log("Config file changed"); debouncedGenerateFiles(); } }) .on("unlinkDir", (dirPath) => { const appName = getAppName(dirPath); if (appName) { console.log(`Removed ${appName}`); debouncedGenerateFiles(); } }); } else { generateFiles(); } function isBookerApp(app: App) { // Right now there are only two types of Apps that booker needs. // Note that currently payment apps' meta don't need to be accessed on booker. We just access from DB eventType.metadata // 1. It is a location app(e.g. any Conferencing App) // 2. It is a tag manager app(e.g. Google Analytics, GTM, Fathom) return !!(app.appData?.location || app.appData?.tag); } function isCrmApp(app: App) { return !!app.categories?.includes("crm"); } function isCalendarApp(app: App) { return !!app.categories?.includes("calendar"); }