572 lines
17 KiB
TypeScript
572 lines
17 KiB
TypeScript
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<AppMetaType> & {
|
|
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<AppMetaType>;
|
|
|
|
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<typeof getChosenImportConfig>
|
|
) => `${getVariableName(app.name)}_${getVariableName(chosenConfig.fileToBeImported)}`;
|
|
|
|
const fileToBeImportedExists = (
|
|
app: { path: string },
|
|
chosenConfig: ReturnType<typeof getChosenImportConfig>
|
|
) => 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");
|
|
}
|