Simplify config file script in sdk (#17267)

Some simplifications about loading the manifest as there is no
difference between all entities
This commit is contained in:
Charles Bochet
2026-01-20 12:03:36 +01:00
committed by GitHub
parent 472fe4eec3
commit c388b29f7c
3 changed files with 265 additions and 358 deletions
@@ -1,269 +0,0 @@
import type { FrontComponentConfig, FunctionConfig } from '@/application';
import * as fs from 'fs-extra';
import { createJiti } from 'jiti';
import { type JitiOptions } from 'jiti/lib/types';
import path from 'path';
import {
type FrontComponentManifest,
type ServerlessFunctionManifest,
} from 'twenty-shared/application';
import { fileURLToPath } from 'url';
import { parseJsoncFile } from './file-jsonc';
type ExtractOptions = {
propertyName: string;
entityType: string;
jsx?: boolean;
};
const getTsconfigAliases = async (
appPath: string,
): Promise<Record<string, string>> => {
const tsconfigPath = path.join(appPath, 'tsconfig.json');
if (!(await fs.pathExists(tsconfigPath))) {
return {};
}
try {
const tsconfig = await parseJsoncFile(tsconfigPath);
const paths = tsconfig?.compilerOptions?.paths as
| Record<string, string[]>
| undefined;
const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.';
if (!paths) {
return {};
}
const aliases: Record<string, string> = {};
for (const [pattern, targets] of Object.entries(paths)) {
if (targets.length === 0) continue;
const aliasKey = pattern.replace(/\/\*$/, '');
const targetPath = targets[0].replace(/\/\*$/, '');
const resolvedTarget = path.resolve(appPath, baseUrl, targetPath);
aliases[aliasKey] = resolvedTarget;
}
return aliases;
} catch {
return {};
}
};
const createConfigLoader = async (
appPath?: string,
options: { jsx?: boolean } = {},
) => {
const basePath = appPath ?? fileURLToPath(import.meta.url);
const jitiOptions: JitiOptions = {
moduleCache: false,
fsCache: false,
interopDefault: true,
};
if (options.jsx) {
jitiOptions.jsx = { runtime: 'automatic' };
}
if (appPath) {
const aliases = await getTsconfigAliases(appPath);
if (Object.keys(aliases).length > 0) {
jitiOptions.alias = aliases;
}
}
return createJiti(basePath, jitiOptions);
};
const findConfigExport = <T>(
mod: Record<string, unknown>,
validator?: (value: unknown) => boolean,
): T | undefined => {
if (mod.default !== undefined) {
if (!validator || validator(mod.default)) {
return mod.default as T;
}
}
for (const [key, value] of Object.entries(mod)) {
if (key === 'default') continue;
if (value === undefined || value === null) continue;
if (typeof value !== 'object') continue;
if (Array.isArray(value)) continue;
if (!validator || validator(value)) {
return value as T;
}
}
return undefined;
};
export const loadConfig = async <T>(
filepath: string,
appPath?: string,
): Promise<T> => {
const jiti = await createConfigLoader(appPath);
try {
const mod = (await jiti.import(filepath)) as Record<string, unknown>;
const config = findConfigExport<T>(mod);
if (!config) {
throw new Error(
`Config file ${filepath} must export a config object (default export or any named object export)`,
);
}
return config;
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Failed to load config from ${filepath}: ${error.message}`,
);
}
throw error;
}
};
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const extractImportPath = (
source: string,
identifier: string,
filepath: string,
appPath: string,
): string | null => {
const escapedIdentifier = escapeRegExp(identifier);
const patterns = [
new RegExp(
`import\\s*\\{[^}]*\\b${escapedIdentifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
new RegExp(
`import\\s*\\{[^}]*\\w+\\s+as\\s+${escapedIdentifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
new RegExp(`import\\s+${escapedIdentifier}\\s+from\\s*['"]([^'"]+)['"]`),
];
for (const pattern of patterns) {
const match = source.match(pattern);
if (match) {
const importPath = match[1];
const fileDir = path.dirname(filepath);
const absolutePath = path.resolve(fileDir, importPath);
const relativePath = path.relative(appPath, absolutePath);
const resultPath = relativePath.endsWith('.ts')
? relativePath
: `${relativePath}.ts`;
return resultPath.replace(/\\/g, '/');
}
}
return null;
};
const extractConfigFromFile = async <T extends Record<string, unknown>>(
filepath: string,
appPath: string,
options: ExtractOptions,
): Promise<{ config: T; entryName: string; entryPath: string }> => {
const { propertyName, entityType, jsx } = options;
const jiti = await createConfigLoader(appPath, { jsx });
try {
const mod = (await jiti.import(filepath)) as Record<string, unknown>;
const hasProperty = (value: unknown): boolean => {
return (
typeof value === 'object' &&
value !== null &&
propertyName in value &&
typeof (value as Record<string, unknown>)[propertyName] === 'function'
);
};
const config = findConfigExport<T & Record<string, Function>>(
mod,
hasProperty,
);
if (!config) {
throw new Error(
`${entityType} file ${filepath} must export a config object with a "${propertyName}" property`,
);
}
const entryName = config[propertyName].name;
if (!entryName) {
throw new Error(
`${propertyName} function in ${filepath} must be a named function`,
);
}
const source = await fs.readFile(filepath, 'utf8');
const importPath = extractImportPath(source, entryName, filepath, appPath);
const entryPath =
importPath ?? path.relative(appPath, filepath).replace(/\\/g, '/');
return { config, entryName, entryPath };
} catch (error) {
if (error instanceof Error) {
throw new Error(
`Failed to load ${entityType.toLowerCase()} module from ${filepath}: ${error.message}`,
);
}
throw error;
}
};
export const extractFunctionConfig = async (
filepath: string,
appPath: string,
): Promise<ServerlessFunctionManifest> => {
const { config, entryName, entryPath } =
await extractConfigFromFile<FunctionConfig>(filepath, appPath, {
propertyName: 'handler',
entityType: 'Function',
});
return {
universalIdentifier: config.universalIdentifier,
name: config.name,
description: config.description,
timeoutSeconds: config.timeoutSeconds,
triggers: config.triggers ?? [],
handlerName: entryName,
handlerPath: entryPath,
};
};
export const extractFrontComponentConfig = async (
filepath: string,
appPath: string,
): Promise<FrontComponentManifest> => {
const { config, entryName, entryPath } =
await extractConfigFromFile<FrontComponentConfig>(filepath, appPath, {
propertyName: 'component',
entityType: 'Front component',
jsx: true,
});
return {
universalIdentifier: config.universalIdentifier,
name: config.name,
description: config.description,
componentName: entryName,
componentPath: entryPath,
};
};
@@ -1,11 +1,8 @@
import { type RoleConfig } from '@/application/role-config';
import {
extractFrontComponentConfig,
extractFunctionConfig,
loadConfig,
} from '@/cli/utilities/file/utils/file-config-loader';
import { findPathFile } from '@/cli/utilities/file/utils/file-find';
import { parseJsoncFile, parseTextFile } from '@/cli/utilities/file/utils/file-jsonc';
import {
parseJsoncFile,
parseTextFile,
} from '@/cli/utilities/file/utils/file-jsonc';
import { glob } from 'fast-glob';
import * as fs from 'fs-extra';
import path, { posix, relative, sep } from 'path';
@@ -24,11 +21,9 @@ import {
ManifestValidationError,
type ValidationWarning,
} from '../types/manifest.types';
import { extractManifestFromFile } from './manifest-file-extractor';
import { validateManifest } from './manifest-validate';
/**
* Validate that the required folder structure exists.
*/
const validateFolderStructure = async (appPath: string): Promise<void> => {
const appFolder = path.join(appPath, 'src', 'app');
@@ -45,9 +40,6 @@ const validateFolderStructure = async (appPath: string): Promise<void> => {
}
};
/**
* Convert a file path to posix format relative to appPath.
*/
const toPosixRelative = (filepath: string, appPath: string): string => {
const rel = relative(appPath, filepath);
return rel.split(sep).join(posix.sep);
@@ -64,19 +56,18 @@ const loadFiles = async (
});
};
/**
* Load all object definitions from src/app/ (any *.object.ts file).
*/
const loadObjects = async (appPath: string): Promise<ObjectManifest[]> => {
const loadObjectManifests = async (
appPath: string,
): Promise<ObjectManifest[]> => {
const objectFiles = await loadFiles(['src/app/**/*.object.ts'], appPath);
const objects: ObjectManifest[] = [];
const objectManifests: ObjectManifest[] = [];
for (const filepath of objectFiles) {
try {
const manifest = await loadConfig<ObjectManifest>(filepath, appPath);
objects.push(manifest);
objectManifests.push(
await extractManifestFromFile<ObjectManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
@@ -85,13 +76,10 @@ const loadObjects = async (appPath: string): Promise<ObjectManifest[]> => {
}
}
return objects;
return objectManifests;
};
/**
* Load all object extension definitions from src/app/ (any *.object-extension.ts file).
*/
const loadObjectExtensions = async (
const loadObjectExtensionManifests = async (
appPath: string,
): Promise<ObjectExtensionManifest[]> => {
const extensionFiles = await loadFiles(
@@ -99,13 +87,13 @@ const loadObjectExtensions = async (
appPath,
);
const extensions: ObjectExtensionManifest[] = [];
const objectExtensionManifests: ObjectExtensionManifest[] = [];
for (const filepath of extensionFiles) {
try {
const manifest = await loadConfig<ObjectExtensionManifest>(filepath, appPath);
extensions.push(manifest);
objectExtensionManifests.push(
await extractManifestFromFile<ObjectExtensionManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
@@ -114,19 +102,25 @@ const loadObjectExtensions = async (
}
}
return extensions;
return objectExtensionManifests;
};
const loadFunctions = async (
const loadFunctionManifests = async (
appPath: string,
): Promise<ServerlessFunctionManifest[]> => {
const functionFiles = await loadFiles(['src/app/**/*.function.ts'], appPath);
const functions: ServerlessFunctionManifest[] = [];
const functionManifests: ServerlessFunctionManifest[] = [];
for (const filepath of functionFiles) {
try {
functions.push(await extractFunctionConfig(filepath, appPath));
functionManifests.push(
await extractManifestFromFile<ServerlessFunctionManifest>(
filepath,
appPath,
{ entryProperty: 'handler' },
),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
@@ -135,21 +129,19 @@ const loadFunctions = async (
}
}
return functions;
return functionManifests;
};
/**
* Load all role definitions from src/app/ (any *.role.ts file).
*/
const loadRoles = async (appPath: string): Promise<RoleManifest[]> => {
const loadRoleManifests = async (appPath: string): Promise<RoleManifest[]> => {
const roleFiles = await loadFiles(['src/app/**/*.role.ts'], appPath);
const roles: RoleManifest[] = [];
const roleManifests: RoleManifest[] = [];
for (const filepath of roleFiles) {
try {
const config = await loadConfig<RoleConfig>(filepath, appPath);
roles.push(config);
roleManifests.push(
await extractManifestFromFile<RoleManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
@@ -158,10 +150,10 @@ const loadRoles = async (appPath: string): Promise<RoleManifest[]> => {
}
}
return roles;
return roleManifests;
};
const loadFrontComponents = async (
const loadFrontComponentManifests = async (
appPath: string,
): Promise<FrontComponentManifest[]> => {
const componentFiles = await loadFiles(
@@ -169,11 +161,17 @@ const loadFrontComponents = async (
appPath,
);
const components: FrontComponentManifest[] = [];
const frontComponentManifests: FrontComponentManifest[] = [];
for (const filepath of componentFiles) {
try {
components.push(await extractFrontComponentConfig(filepath, appPath));
frontComponentManifests.push(
await extractManifestFromFile<FrontComponentManifest>(
filepath,
appPath,
{ entryProperty: 'component', jsx: true },
),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
@@ -182,16 +180,12 @@ const loadFrontComponents = async (
}
}
return components;
return frontComponentManifests;
};
/**
* Build a nested object structure from all TypeScript source files.
*/
const loadSources = async (appPath: string): Promise<Sources> => {
const sources: Sources = {};
// Get all TypeScript files in src/ folder
const tsFiles = await loadFiles(
['src/**/*.ts', 'generated/**/*.ts'],
appPath,
@@ -202,7 +196,6 @@ const loadSources = async (appPath: string): Promise<Sources> => {
const parts = relPath.split(sep);
const content = await fs.readFile(filepath, 'utf8');
// Build nested structure
let current: Sources = sources;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
@@ -218,19 +211,12 @@ const loadSources = async (appPath: string): Promise<Sources> => {
return sources;
};
/**
* Check if the app imports from the generated folder.
* Detects ESM imports: `import ... from '...generated'` or `import ... from '...generated/...'`
* Detects CommonJS requires: `require('...generated')` or `require('...generated/...')`
*/
const checkShouldGenerate = async (appPath: string): Promise<boolean> => {
const tsFiles = await loadFiles(['src/**/*.ts'], appPath);
// Matches ESM: import ... from 'generated' or from '.../generated' or from '.../generated/...'
const esmImportPattern =
/from\s+['"][^'"]*\/generated(?:\/[^'"]*)?['"]|from\s+['"]generated['"]/;
// Matches CommonJS: require('generated') or require('.../generated') or require('.../generated/...')
const commonJsRequirePattern =
/require\s*\(\s*['"][^'"]*\/generated(?:\/[^'"]*)?['"]\s*\)|require\s*\(\s*['"]generated['"]\s*\)/;
@@ -253,16 +239,11 @@ export type BuildManifestResult = {
warnings: ValidationWarning[];
};
/**
* Build an application manifest using the folder structure with jiti runtime evaluation.
*/
export const buildManifest = async (
appPath: string,
): Promise<BuildManifestResult> => {
// Validate folder structure
await validateFolderStructure(appPath);
// Load package.json and yarn.lock
const packageJson = await parseJsoncFile(
await findPathFile(appPath, 'package.json'),
);
@@ -271,54 +252,54 @@ export const buildManifest = async (
await findPathFile(appPath, 'yarn.lock'),
);
// Load application config
const applicationConfigPath = path.join(
appPath,
'src',
'app',
'application.config.ts',
);
const application = await loadConfig<Application>(applicationConfigPath, appPath);
const application = await extractManifestFromFile<Application>(
applicationConfigPath,
appPath,
);
// Load all entities in parallel
const [
objects,
objectExtensions,
serverlessFunctions,
frontComponents,
roles,
objectManifests,
objectExtensionManifests,
functionManifests,
frontComponentManifests,
roleManifests,
sources,
shouldGenerate,
] = await Promise.all([
loadObjects(appPath),
loadObjectExtensions(appPath),
loadFunctions(appPath),
loadFrontComponents(appPath),
loadRoles(appPath),
loadObjectManifests(appPath),
loadObjectExtensionManifests(appPath),
loadFunctionManifests(appPath),
loadFrontComponentManifests(appPath),
loadRoleManifests(appPath),
loadSources(appPath),
checkShouldGenerate(appPath),
]);
// Build manifest
const manifest: ApplicationManifest = {
application,
objects,
objects: objectManifests,
objectExtensions:
objectExtensions.length > 0 ? objectExtensions : undefined,
serverlessFunctions,
frontComponents: frontComponents.length > 0 ? frontComponents : undefined,
roles,
objectExtensionManifests.length > 0 ? objectExtensionManifests : undefined,
serverlessFunctions: functionManifests,
frontComponents:
frontComponentManifests.length > 0 ? frontComponentManifests : undefined,
roles: roleManifests,
sources,
};
// Validate manifest
const validation = validateManifest({
application,
objects,
objectExtensions,
serverlessFunctions,
frontComponents,
roles,
objects: objectManifests,
objectExtensions: objectExtensionManifests,
serverlessFunctions: functionManifests,
frontComponents: frontComponentManifests,
roles: roleManifests,
});
if (!validation.isValid) {
@@ -0,0 +1,195 @@
import * as fs from 'fs-extra';
import { createJiti } from 'jiti';
import { type JitiOptions } from 'jiti/lib/types';
import path from 'path';
import { parseJsoncFile } from '../../file/utils/file-jsonc';
export type ExtractManifestOptions = {
jsx?: boolean;
entryProperty?: string;
};
const getTsconfigAliases = async (
appPath: string,
): Promise<Record<string, string>> => {
const tsconfigPath = path.join(appPath, 'tsconfig.json');
if (!(await fs.pathExists(tsconfigPath))) {
return {};
}
try {
const tsconfig = await parseJsoncFile(tsconfigPath);
const paths = tsconfig?.compilerOptions?.paths as
| Record<string, string[]>
| undefined;
const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.';
if (!paths) {
return {};
}
const aliases: Record<string, string> = {};
for (const [pattern, targets] of Object.entries(paths)) {
if (targets.length === 0) continue;
const aliasKey = pattern.replace(/\/\*$/, '');
const targetPath = targets[0].replace(/\/\*$/, '');
const resolvedTarget = path.resolve(appPath, baseUrl, targetPath);
aliases[aliasKey] = resolvedTarget;
}
return aliases;
} catch {
return {};
}
};
const createModuleLoader = async (
appPath: string,
options: { jsx?: boolean } = {},
) => {
const jitiOptions: JitiOptions = {
moduleCache: false,
fsCache: false,
interopDefault: true,
};
if (options.jsx) {
jitiOptions.jsx = { runtime: 'automatic' };
}
const aliases = await getTsconfigAliases(appPath);
if (Object.keys(aliases).length > 0) {
jitiOptions.alias = aliases;
}
return createJiti(appPath, jitiOptions);
};
const findConfigInModule = <T>(
module: Record<string, unknown>,
validator?: (value: unknown) => boolean,
): T | undefined => {
if (module.default !== undefined) {
if (!validator || validator(module.default)) {
return module.default as T;
}
}
for (const [key, value] of Object.entries(module)) {
if (key === 'default') continue;
if (value === undefined || value === null) continue;
if (typeof value !== 'object') continue;
if (Array.isArray(value)) continue;
if (!validator || validator(value)) {
return value as T;
}
}
return undefined;
};
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
const extractImportPath = (
source: string,
identifier: string,
filepath: string,
appPath: string,
): string | null => {
const escapedIdentifier = escapeRegExp(identifier);
const patterns = [
new RegExp(
`import\\s*\\{[^}]*\\b${escapedIdentifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
new RegExp(
`import\\s*\\{[^}]*\\w+\\s+as\\s+${escapedIdentifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
new RegExp(`import\\s+${escapedIdentifier}\\s+from\\s*['"]([^'"]+)['"]`),
];
for (const pattern of patterns) {
const match = source.match(pattern);
if (match) {
const importPath = match[1];
const fileDir = path.dirname(filepath);
const absolutePath = path.resolve(fileDir, importPath);
const relativePath = path.relative(appPath, absolutePath);
const resultPath = relativePath.endsWith('.ts')
? relativePath
: `${relativePath}.ts`;
return resultPath.replace(/\\/g, '/');
}
}
return null;
};
export const extractManifestFromFile = async <TManifest>(
filepath: string,
appPath: string,
options: ExtractManifestOptions = {},
): Promise<TManifest> => {
const { jsx, entryProperty } = options;
const jiti = await createModuleLoader(appPath, { jsx });
const module = (await jiti.import(filepath)) as Record<string, unknown>;
const configValidator = entryProperty
? (value: unknown): boolean =>
typeof value === 'object' &&
value !== null &&
entryProperty in value &&
typeof (value as Record<string, unknown>)[entryProperty] === 'function'
: undefined;
const config = findConfigInModule<Record<string, unknown>>(
module,
configValidator,
);
if (!config) {
const expectedExport = entryProperty
? `a config object with a "${entryProperty}" property`
: 'a config object (default export or any named object export)';
throw new Error(`Config file ${filepath} must export ${expectedExport}`);
}
if (!entryProperty) {
return config as TManifest;
}
const entryFunction = config[entryProperty] as Function;
const entryName = entryFunction.name;
if (!entryName) {
throw new Error(
`${entryProperty} function in ${filepath} must be a named function`,
);
}
const source = await fs.readFile(filepath, 'utf8');
const importPath = extractImportPath(source, entryName, filepath, appPath);
const entryPath =
importPath ?? path.relative(appPath, filepath).replace(/\\/g, '/');
const { [entryProperty]: _, ...configWithoutEntry } = config;
const manifest = {
...configWithoutEntry,
[`${entryProperty}Name`]: entryName,
[`${entryProperty}Path`]: entryPath,
};
return manifest as TManifest;
};