diff --git a/packages/twenty-sdk/src/cli/utilities/file/utils/file-config-loader.ts b/packages/twenty-sdk/src/cli/utilities/file/utils/file-config-loader.ts deleted file mode 100644 index d5c731d7fb..0000000000 --- a/packages/twenty-sdk/src/cli/utilities/file/utils/file-config-loader.ts +++ /dev/null @@ -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> => { - 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 - | undefined; - const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.'; - - if (!paths) { - return {}; - } - - const aliases: Record = {}; - - 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 = ( - mod: Record, - 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 ( - filepath: string, - appPath?: string, -): Promise => { - const jiti = await createConfigLoader(appPath); - - try { - const mod = (await jiti.import(filepath)) as Record; - const config = findConfigExport(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 >( - 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; - - const hasProperty = (value: unknown): boolean => { - return ( - typeof value === 'object' && - value !== null && - propertyName in value && - typeof (value as Record)[propertyName] === 'function' - ); - }; - - const config = findConfigExport>( - 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 => { - const { config, entryName, entryPath } = - await extractConfigFromFile(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 => { - const { config, entryName, entryPath } = - await extractConfigFromFile(filepath, appPath, { - propertyName: 'component', - entityType: 'Front component', - jsx: true, - }); - - return { - universalIdentifier: config.universalIdentifier, - name: config.name, - description: config.description, - componentName: entryName, - componentPath: entryPath, - }; -}; diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts index 95cb45415e..add65ff75d 100644 --- a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-build.ts @@ -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 => { const appFolder = path.join(appPath, 'src', 'app'); @@ -45,9 +40,6 @@ const validateFolderStructure = async (appPath: string): Promise => { } }; -/** - * 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 => { +const loadObjectManifests = async ( + appPath: string, +): Promise => { 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(filepath, appPath); - - objects.push(manifest); + objectManifests.push( + await extractManifestFromFile(filepath, appPath), + ); } catch (error) { const relPath = toPosixRelative(filepath, appPath); throw new Error( @@ -85,13 +76,10 @@ const loadObjects = async (appPath: string): Promise => { } } - 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 => { 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(filepath, appPath); - - extensions.push(manifest); + objectExtensionManifests.push( + await extractManifestFromFile(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 => { 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( + 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 => { +const loadRoleManifests = async (appPath: string): Promise => { 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(filepath, appPath); - roles.push(config); + roleManifests.push( + await extractManifestFromFile(filepath, appPath), + ); } catch (error) { const relPath = toPosixRelative(filepath, appPath); throw new Error( @@ -158,10 +150,10 @@ const loadRoles = async (appPath: string): Promise => { } } - return roles; + return roleManifests; }; -const loadFrontComponents = async ( +const loadFrontComponentManifests = async ( appPath: string, ): Promise => { 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( + 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 => { 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 => { 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 => { 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 => { 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 => { - // 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(applicationConfigPath, appPath); + const application = await extractManifestFromFile( + 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) { diff --git a/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-file-extractor.ts b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-file-extractor.ts new file mode 100644 index 0000000000..f44cbe80e6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/manifest/utils/manifest-file-extractor.ts @@ -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> => { + 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 + | undefined; + const baseUrl = (tsconfig?.compilerOptions?.baseUrl as string) || '.'; + + if (!paths) { + return {}; + } + + const aliases: Record = {}; + + 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 = ( + module: Record, + 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 ( + filepath: string, + appPath: string, + options: ExtractManifestOptions = {}, +): Promise => { + const { jsx, entryProperty } = options; + const jiti = await createModuleLoader(appPath, { jsx }); + + const module = (await jiti.import(filepath)) as Record; + + const configValidator = entryProperty + ? (value: unknown): boolean => + typeof value === 'object' && + value !== null && + entryProperty in value && + typeof (value as Record)[entryProperty] === 'function' + : undefined; + + const config = findConfigInModule>( + 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; +};