Improvements on SDK watcher (#17291)

# Improve cross-entity duplicate detection in manifest validation

- Refactored findDuplicates to receive the full manifest, enabling
cross-entity duplicate checks
-ObjectExtensionEntityBuilder now validates that extension field IDs
don't conflict with object field IDs
- Renamed DuplicateId type to EntityIdWithLocation for clarity
- Updated all entity builders to use the new signature
This commit is contained in:
Charles Bochet 2026-01-21 15:31:38 +01:00 committed by GitHub
parent 2ecc1ba897
commit d74d74da4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 925 additions and 913 deletions

View file

@ -1,40 +0,0 @@
const jestConfig = {
displayName: 'twenty-cli',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: false },
},
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: './coverage',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
'!src/cli/cli.ts',
],
coverageThreshold: {
global: {
statements: 1,
lines: 1,
functions: 1,
},
},
};
export default jestConfig;

View file

@ -1,49 +0,0 @@
import { type JestConfigWithTsJest } from 'ts-jest';
const jestConfig: JestConfigWithTsJest = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
displayName: 'twenty-sdk-e2e',
silent: false,
errorOnDeprecated: true,
maxConcurrency: 1,
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
testRegex: '\\.e2e-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
globalTeardown: '<rootDir>/src/cli/__tests__/e2e/teardown.ts',
setupFilesAfterEnv: ['<rootDir>/src/cli/__tests__/e2e/setupTest.ts'],
testTimeout: 30000, // 30 seconds timeout for e2e tests
maxWorkers: 1,
transform: {
'^.+\\.(t|j)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
baseUrl: '.',
paths: {
'src/*': ['./src/*'],
},
},
},
],
},
transformIgnorePatterns: [
'node_modules/(?!(chalk|inquirer|@inquirer|ansi-styles|strip-ansi|has-flag|supports-color|color-convert|color-name|wrap-ansi|string-width|is-fullwidth-code-point|emoji-regex|onetime|mimic-fn|signal-exit|yallist|lru-cache|p-limit|p-queue|p-timeout|p-finally|p-try|p-cancelable|p-locate|p-map|p-race|p-reduce|p-some|p-waterfall|p-defer|p-delay|p-retry|p-any|p-settle|p-all|p-map-series|p-map-concurrent|p-filter|p-reject|p-tap|p-log|p-debounce|p-throttle|p-forever|p-whilst|p-do-whilst|p-until|p-wait-for|p-min-delay)/)',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
export default jestConfig;

View file

@ -35,7 +35,6 @@
"archiver": "^7.0.1",
"axios": "^1.6.0",
"chalk": "^5.3.0",
"chokidar": "^4.0.0",
"commander": "^12.0.0",
"dotenv": "^16.4.0",
"fast-glob": "^3.3.0",
@ -43,7 +42,6 @@
"graphql": "^16.8.1",
"graphql-sse": "^2.5.4",
"inquirer": "^10.0.0",
"jiti": "^2.0.0",
"jsonc-parser": "^3.2.0",
"lodash.camelcase": "^4.3.0",
"lodash.capitalize": "^4.2.1",
@ -58,14 +56,12 @@
"@types/archiver": "^6.0.0",
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^29.5.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.capitalize": "^4",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^24.0.0",
"@types/react": "^19.0.2",
"jest": "^29.5.0",
"tsx": "^4.7.0",
"vite-plugin-dts": "^4.5.4",
"wait-on": "^7.2.0"

View file

@ -72,18 +72,17 @@
}
},
"test": {
"executor": "@nx/jest:jest",
"executor": "@nx/vitest:test",
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs"
"config": "{projectRoot}/vitest.config.ts"
},
"configurations": {
"ci": {
"ci": true,
"coverage": true,
"watchAll": false
"watch": false
}
}
},
@ -92,7 +91,7 @@
"options": {
"cwd": "packages/twenty-sdk",
"commands": [
"npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && NODE_ENV=test npx jest --config ./jest.e2e.config.ts"
"npx wait-on http://localhost:3000/healthz --timeout 600000 --interval 1000 --log && NODE_ENV=test npx vitest run --config ./vitest.e2e.config.ts"
]
},
"parallel": false,

View file

@ -1,12 +1,11 @@
import { ConfigService } from '@/cli/utilities/config/services/config.service';
import { testConfig } from '@/cli/__tests__/e2e/constants/testConfig';
import { vi, beforeAll, afterAll } from 'vitest';
beforeAll(() => {
jest
.spyOn(ConfigService.prototype, 'getConfig')
.mockResolvedValue(testConfig);
vi.spyOn(ConfigService.prototype, 'getConfig').mockResolvedValue(testConfig);
});
afterAll(() => {
jest.restoreAllMocks();
vi.restoreAllMocks();
});

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["../../../../src/*"]
}
},
"include": ["src/**/*"]
}

View file

@ -1,8 +1,10 @@
import { ApiService } from '@/cli/utilities/api/services/api.service';
import { type ApiResponse } from '@/cli/utilities/api/types/api-response.types';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import path from 'path';
export class AppSyncCommand {
private apiService = new ApiService();
@ -20,8 +22,16 @@ export class AppSyncCommand {
return { success: false, error: 'Build failed' };
}
const yarnLockPath = path.join(appPath, 'yarn.lock');
let yarnLock = '';
if (await fs.pathExists(yarnLockPath)) {
yarnLock = await fs.readFile(yarnLockPath, 'utf8');
}
const serverlessSyncResult = await this.apiService.syncApplication({
manifest,
yarnLock,
});
if (serverlessSyncResult.success === false) {

View file

@ -89,18 +89,22 @@ export class ApiService {
async syncApplication({
manifest,
yarnLock,
}: {
manifest: ApplicationManifest;
yarnLock: string;
}): Promise<ApiResponse> {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!) {
syncApplication(manifest: $manifest)
mutation SyncApplication($manifest: JSON!, $packageJson: JSON!, $yarnLock: String!) {
syncApplication(manifest: $manifest, packageJson: $packageJson, yarnLock: $yarnLock)
}
`;
const variables = {
manifest,
packageJson: manifest.packageJson,
yarnLock,
};
const response: AxiosResponse = await this.client.post(

View file

@ -9,7 +9,7 @@ import { printWatchingMessage } from '../common/display';
import {
type RestartableWatcher,
type RestartableWatcherOptions,
} from '../common/watcher';
} from '../common/restartable-watcher.interface';
import { FRONT_COMPONENTS_DIR } from './constants';
export const FRONT_COMPONENT_EXTERNAL_MODULES: (string | RegExp)[] = [

View file

@ -1,6 +1,3 @@
import { OUTPUT_DIR } from '../common/constants';
import { FUNCTIONS_DIR } from './constants';
import * as fs from 'fs-extra';
import path from 'path';
export const computeFunctionOutputPath = (
@ -25,65 +22,3 @@ export const computeFunctionOutputPath = (
outputDir: normalizedOutputDir,
};
};
export const buildFunctionEntries = (
appPath: string,
handlerPaths: Array<{ handlerPath: string }>,
): Record<string, string> => {
const entries: Record<string, string> = {};
for (const fn of handlerPaths) {
const { relativePath } = computeFunctionOutputPath(fn.handlerPath);
const chunkName = relativePath.replace(/\.js$/, '');
entries[chunkName] = path.join(appPath, fn.handlerPath);
}
return entries;
};
export const cleanupOldFunctions = async (
appPath: string,
currentEntryPoints: string[],
): Promise<void> => {
const functionsDir = path.join(appPath, OUTPUT_DIR, FUNCTIONS_DIR);
if (!(await fs.pathExists(functionsDir))) {
return;
}
const expectedFiles = new Set(
currentEntryPoints.map((entryPoint) => {
const { relativePath } = computeFunctionOutputPath(entryPoint);
return relativePath;
}),
);
const expectedFilesWithMaps = new Set<string>();
for (const file of expectedFiles) {
expectedFilesWithMaps.add(file);
expectedFilesWithMaps.add(`${file}.map`);
}
const removeOrphanedFiles = async (dir: string, relativeBase: string = ''): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
await removeOrphanedFiles(fullPath, relativePath);
const remaining = await fs.readdir(fullPath);
if (remaining.length === 0) {
await fs.remove(fullPath);
}
} else if (entry.isFile()) {
if (!expectedFilesWithMaps.has(relativePath)) {
await fs.remove(fullPath);
}
}
}
};
await removeOrphanedFiles(functionsDir);
};

View file

@ -9,9 +9,24 @@ import { printWatchingMessage } from '../common/display';
import {
type RestartableWatcher,
type RestartableWatcherOptions,
} from '../common/watcher';
} from '../common/restartable-watcher.interface';
import { FUNCTIONS_DIR } from './constants';
import { buildFunctionEntries } from './function-paths';
import { computeFunctionOutputPath } from './function-paths';
const buildFunctionEntries = (
appPath: string,
handlerPaths: Array<{ handlerPath: string }>,
): Record<string, string> => {
const entries: Record<string, string> = {};
for (const fn of handlerPaths) {
const { relativePath } = computeFunctionOutputPath(fn.handlerPath);
const chunkName = relativePath.replace(/\.js$/, '');
entries[chunkName] = path.join(appPath, fn.handlerPath);
}
return entries;
};
export const FUNCTION_EXTERNAL_MODULES: (string | RegExp)[] = [
'path', 'fs', 'crypto', 'stream', 'util', 'os', 'url', 'http', 'https',

View file

@ -1,11 +1,11 @@
import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role';
import {
POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
POST_CARD_EXTENSION_CATEGORY_FIELD_ID,
POST_CARD_EXTENSION_PRIORITY_FIELD_ID,
} from '@/cli/__tests__/test-app/src/app/postCard.object-extension';
import { runManifestBuild } from '@/cli/utilities/build/manifest/manifest-build';
import { type ApplicationManifest } from 'twenty-shared/application';
import { join } from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
const TEST_APP_PATH = join(__dirname, '../../../../__tests__/test-app');

View file

@ -1,9 +1,9 @@
import { validateManifest } from '@/cli/utilities/build/manifest/manifest-validate';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type Application,
type ObjectExtensionManifest,
} from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
describe('validateManifest - objectExtensions', () => {
const validApplication: Application = {

View file

@ -1,62 +1,74 @@
import chalk from 'chalk';
import path from 'path';
import { type Application, type ApplicationManifest } from 'twenty-shared/application';
import { type Application } from 'twenty-shared/application';
import { extractManifestFromFile } from '../manifest-file-extractor';
import { type ValidationError } from '../manifest.types';
import {
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from './entity.interface';
export const buildApplication = async (appPath: string): Promise<Application> => {
const applicationConfigPath = path.join(appPath, 'src', 'app', 'application.config.ts');
export class ApplicationEntityBuilder
implements ManifestEntityBuilder<Application>
{
async build(appPath: string): Promise<Application> {
const applicationConfigPath = path.join(
appPath,
'src',
'app',
'application.config.ts',
);
return extractManifestFromFile<Application>(applicationConfigPath, appPath);
};
export const validateApplication = (
application: Application | undefined,
errors: ValidationError[],
): void => {
if (!application) {
errors.push({
path: 'application',
message: 'Application config is required',
});
return;
return extractManifestFromFile<Application>(applicationConfigPath, appPath);
}
if (!application.universalIdentifier) {
errors.push({
path: 'application',
message: 'Application must have a universalIdentifier',
});
}
};
validate(application: Application, errors: ValidationError[]): void {
if (!application) {
errors.push({
path: 'application',
message: 'Application config is required',
});
return;
}
export const displayApplication = (manifest: ApplicationManifest): void => {
const appName = manifest.application.displayName ?? 'Application';
console.log(chalk.green(` ✓ Loaded "${appName}"`));
};
export const collectApplicationIds = (
application: Application | undefined,
): Array<{ id: string; location: string }> => {
const ids: Array<{ id: string; location: string }> = [];
if (application?.universalIdentifier) {
ids.push({
id: application.universalIdentifier,
location: 'application',
});
}
if (application?.applicationVariables) {
for (const [name, variable] of Object.entries(application.applicationVariables)) {
if (variable.universalIdentifier) {
ids.push({
id: variable.universalIdentifier,
location: `application.variables.${name}`,
});
}
if (!application.universalIdentifier) {
errors.push({
path: 'application',
message: 'Application must have a universalIdentifier',
});
}
}
return ids;
};
display(application: Application): void {
const appName = application.displayName ?? 'Application';
console.log(chalk.green(` ✓ Loaded "${appName}"`));
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const application = manifest.application;
if (application?.universalIdentifier) {
seen.set(application.universalIdentifier, ['application']);
}
if (application?.applicationVariables) {
for (const [name, variable] of Object.entries(
application.applicationVariables,
)) {
if (variable.universalIdentifier) {
const locations = seen.get(variable.universalIdentifier) ?? [];
locations.push(`application.variables.${name}`);
seen.set(variable.universalIdentifier, locations);
}
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const applicationEntityBuilder = new ApplicationEntityBuilder();

View file

@ -0,0 +1,19 @@
import { type ApplicationManifest } from 'twenty-shared/application';
import { type ValidationError } from '../manifest.types';
export type EntityIdWithLocation = {
id: string;
locations: string[];
};
export type ManifestWithoutSources = Omit<
ApplicationManifest,
'sources' | 'packageJson'
>;
export type ManifestEntityBuilder<EntityManifest> = {
build(appPath: string): Promise<EntityManifest>;
validate(data: EntityManifest, errors: ValidationError[]): void;
display(data: EntityManifest): void;
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[];
};

View file

@ -1,87 +1,92 @@
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
import chalk from 'chalk';
import { glob } from 'fast-glob';
import { posix, relative, sep } from 'path';
import { type FrontComponentManifest } from 'twenty-shared/application';
import { extractManifestFromFile } from '../manifest-file-extractor';
import { type ValidationError } from '../manifest.types';
import {
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from './entity.interface';
const toPosixRelative = (filepath: string, appPath: string): string => {
const rel = relative(appPath, filepath);
return rel.split(sep).join(posix.sep);
};
export class FrontComponentEntityBuilder
implements ManifestEntityBuilder<FrontComponentManifest[]>
{
async build(appPath: string): Promise<FrontComponentManifest[]> {
const componentFiles = await glob(['src/app/**/*.front-component.tsx'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
export const buildFrontComponents = async (
appPath: string,
): Promise<FrontComponentManifest[]> => {
const componentFiles = await glob(['src/app/**/*.front-component.tsx'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
const frontComponentManifests: FrontComponentManifest[] = [];
const frontComponentManifests: FrontComponentManifest[] = [];
for (const filepath of componentFiles) {
try {
frontComponentManifests.push(
await extractManifestFromFile<FrontComponentManifest>(
filepath,
appPath,
{ entryProperty: 'component', jsx: true },
),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load front component from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
for (const filepath of componentFiles) {
try {
frontComponentManifests.push(
await extractManifestFromFile<FrontComponentManifest>(
filepath,
appPath,
{ entryProperty: 'component', jsx: true },
),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load front component from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return frontComponentManifests;
}
return frontComponentManifests;
};
export const validateFrontComponents = (
components: FrontComponentManifest[],
errors: ValidationError[],
): void => {
for (const component of components) {
const componentPath = `front-components/${component.name ?? component.componentName ?? 'unknown'}`;
if (!component.universalIdentifier) {
errors.push({
path: componentPath,
message: 'Front component must have a universalIdentifier',
});
}
}
};
export const displayFrontComponents = (components: FrontComponentManifest[]): void => {
console.log(chalk.green(` ✓ Found ${components.length} front component(s)`));
if (components.length > 0) {
console.log(chalk.gray(` 📍 Front component entry points:`));
validate(
components: FrontComponentManifest[],
errors: ValidationError[],
): void {
for (const component of components) {
const name = component.name || component.universalIdentifier;
console.log(chalk.gray(` - ${name} (${component.componentPath})`));
}
}
};
const componentPath = `front-components/${component.name ?? component.componentName ?? 'unknown'}`;
export const collectFrontComponentIds = (
components: FrontComponentManifest[],
): Array<{ id: string; location: string }> => {
const ids: Array<{ id: string; location: string }> = [];
for (const component of components) {
if (component.universalIdentifier) {
ids.push({
id: component.universalIdentifier,
location: `front-components/${component.name ?? component.componentName}`,
});
if (!component.universalIdentifier) {
errors.push({
path: componentPath,
message: 'Front component must have a universalIdentifier',
});
}
}
}
return ids;
};
display(components: FrontComponentManifest[]): void {
console.log(chalk.green(` ✓ Found ${components.length} front component(s)`));
if (components.length > 0) {
console.log(chalk.gray(` 📍 Front component entry points:`));
for (const component of components) {
const name = component.name || component.universalIdentifier;
console.log(chalk.gray(` - ${name} (${component.componentPath})`));
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const components = manifest.frontComponents ?? [];
for (const component of components) {
if (component.universalIdentifier) {
const location = `front-components/${component.name ?? component.componentName}`;
const locations = seen.get(component.universalIdentifier) ?? [];
locations.push(location);
seen.set(component.universalIdentifier, locations);
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const frontComponentEntityBuilder = new FrontComponentEntityBuilder();

View file

@ -1,149 +1,154 @@
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
import chalk from 'chalk';
import { glob } from 'fast-glob';
import { posix, relative, sep } from 'path';
import { type ServerlessFunctionManifest } from 'twenty-shared/application';
import { extractManifestFromFile } from '../manifest-file-extractor';
import { type ValidationError } from '../manifest.types';
import {
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from './entity.interface';
const toPosixRelative = (filepath: string, appPath: string): string => {
const rel = relative(appPath, filepath);
return rel.split(sep).join(posix.sep);
};
export class FunctionEntityBuilder
implements ManifestEntityBuilder<ServerlessFunctionManifest[]>
{
async build(appPath: string): Promise<ServerlessFunctionManifest[]> {
const functionFiles = await glob(['src/app/**/*.function.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
export const buildFunctions = async (
appPath: string,
): Promise<ServerlessFunctionManifest[]> => {
const functionFiles = await glob(['src/app/**/*.function.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
const functionManifests: ServerlessFunctionManifest[] = [];
const functionManifests: ServerlessFunctionManifest[] = [];
for (const filepath of functionFiles) {
try {
functionManifests.push(
await extractManifestFromFile<ServerlessFunctionManifest>(
filepath,
appPath,
{ entryProperty: 'handler' },
),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
for (const filepath of functionFiles) {
try {
functionManifests.push(
await extractManifestFromFile<ServerlessFunctionManifest>(
filepath,
appPath,
{ entryProperty: 'handler' },
),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load function from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return functionManifests;
}
return functionManifests;
};
export const validateFunctions = (
functions: ServerlessFunctionManifest[],
errors: ValidationError[],
): void => {
for (const fn of functions) {
const fnPath = `functions/${fn.name ?? fn.handlerName ?? 'unknown'}`;
if (!fn.universalIdentifier) {
errors.push({
path: fnPath,
message: 'Function must have a universalIdentifier',
});
}
for (const trigger of fn.triggers ?? []) {
const triggerPath = `${fnPath}.triggers.${trigger.type ?? 'unknown'}`;
if (!trigger.universalIdentifier) {
errors.push({
path: triggerPath,
message: 'Trigger must have a universalIdentifier',
});
}
if (!trigger.type) {
errors.push({
path: triggerPath,
message: 'Trigger must have a type',
});
continue;
}
switch (trigger.type) {
case 'route':
if (!trigger.path) {
errors.push({
path: triggerPath,
message: 'Route trigger must have a path',
});
}
if (!trigger.httpMethod) {
errors.push({
path: triggerPath,
message: 'Route trigger must have an httpMethod',
});
}
break;
case 'cron':
if (!trigger.pattern) {
errors.push({
path: triggerPath,
message: 'Cron trigger must have a pattern',
});
}
break;
case 'databaseEvent':
if (!trigger.eventName) {
errors.push({
path: triggerPath,
message: 'Database event trigger must have an eventName',
});
}
break;
}
}
}
};
export const displayFunctions = (functions: ServerlessFunctionManifest[]): void => {
console.log(chalk.green(` ✓ Found ${functions.length} function(s)`));
if (functions.length > 0) {
console.log(chalk.gray(` 📍 Function entry points:`));
validate(
functions: ServerlessFunctionManifest[],
errors: ValidationError[],
): void {
for (const fn of functions) {
const name = fn.name || fn.universalIdentifier;
console.log(chalk.gray(` - ${name} (${fn.handlerPath})`));
}
}
};
const fnPath = `functions/${fn.name ?? fn.handlerName ?? 'unknown'}`;
export const collectFunctionIds = (
functions: ServerlessFunctionManifest[],
): Array<{ id: string; location: string }> => {
const ids: Array<{ id: string; location: string }> = [];
for (const fn of functions) {
if (fn.universalIdentifier) {
ids.push({
id: fn.universalIdentifier,
location: `functions/${fn.name ?? fn.handlerName}`,
});
}
for (const trigger of fn.triggers ?? []) {
if (trigger.universalIdentifier) {
ids.push({
id: trigger.universalIdentifier,
location: `functions/${fn.name ?? fn.handlerName}.triggers.${trigger.type}`,
if (!fn.universalIdentifier) {
errors.push({
path: fnPath,
message: 'Function must have a universalIdentifier',
});
}
for (const trigger of fn.triggers ?? []) {
const triggerPath = `${fnPath}.triggers.${trigger.type ?? 'unknown'}`;
if (!trigger.universalIdentifier) {
errors.push({
path: triggerPath,
message: 'Trigger must have a universalIdentifier',
});
}
if (!trigger.type) {
errors.push({
path: triggerPath,
message: 'Trigger must have a type',
});
continue;
}
switch (trigger.type) {
case 'route':
if (!trigger.path) {
errors.push({
path: triggerPath,
message: 'Route trigger must have a path',
});
}
if (!trigger.httpMethod) {
errors.push({
path: triggerPath,
message: 'Route trigger must have an httpMethod',
});
}
break;
case 'cron':
if (!trigger.pattern) {
errors.push({
path: triggerPath,
message: 'Cron trigger must have a pattern',
});
}
break;
case 'databaseEvent':
if (!trigger.eventName) {
errors.push({
path: triggerPath,
message: 'Database event trigger must have an eventName',
});
}
break;
}
}
}
}
return ids;
};
display(functions: ServerlessFunctionManifest[]): void {
console.log(chalk.green(` ✓ Found ${functions.length} function(s)`));
if (functions.length > 0) {
console.log(chalk.gray(` 📍 Function entry points:`));
for (const fn of functions) {
const name = fn.name || fn.universalIdentifier;
console.log(chalk.gray(` - ${name} (${fn.handlerPath})`));
}
}
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const functions = manifest.serverlessFunctions ?? [];
for (const fn of functions) {
if (fn.universalIdentifier) {
const location = `functions/${fn.name ?? fn.handlerName}`;
const locations = seen.get(fn.universalIdentifier) ?? [];
locations.push(location);
seen.set(fn.universalIdentifier, locations);
}
for (const trigger of fn.triggers ?? []) {
if (trigger.universalIdentifier) {
const location = `functions/${fn.name ?? fn.handlerName}.triggers.${trigger.type}`;
const locations = seen.get(trigger.universalIdentifier) ?? [];
locations.push(location);
seen.set(trigger.universalIdentifier, locations);
}
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const functionEntityBuilder = new FunctionEntityBuilder();

View file

@ -1,143 +1,178 @@
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
import { glob } from 'fast-glob';
import { posix, relative, sep } from 'path';
import { type ObjectExtensionManifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { extractManifestFromFile } from '../manifest-file-extractor';
import { type ValidationError } from '../manifest.types';
import {
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from './entity.interface';
const toPosixRelative = (filepath: string, appPath: string): string => {
const rel = relative(appPath, filepath);
return rel.split(sep).join(posix.sep);
};
export class ObjectExtensionEntityBuilder
implements ManifestEntityBuilder<ObjectExtensionManifest[]>
{
async build(appPath: string): Promise<ObjectExtensionManifest[]> {
const extensionFiles = await glob(['src/app/**/*.object-extension.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
export const buildObjectExtensions = async (
appPath: string,
): Promise<ObjectExtensionManifest[]> => {
const extensionFiles = await glob(['src/app/**/*.object-extension.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
const objectExtensionManifests: ObjectExtensionManifest[] = [];
const objectExtensionManifests: ObjectExtensionManifest[] = [];
for (const filepath of extensionFiles) {
try {
objectExtensionManifests.push(
await extractManifestFromFile<ObjectExtensionManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
for (const filepath of extensionFiles) {
try {
objectExtensionManifests.push(
await extractManifestFromFile<ObjectExtensionManifest>(
filepath,
appPath,
),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return objectExtensionManifests;
}
return objectExtensionManifests;
};
validate(
extensions: ObjectExtensionManifest[],
errors: ValidationError[],
): void {
for (const ext of extensions) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
const extPath = `object-extensions/${targetName}`;
export const validateObjectExtensions = (
extensions: ObjectExtensionManifest[],
errors: ValidationError[],
): void => {
for (const ext of extensions) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
const extPath = `object-extensions/${targetName}`;
if (!ext.targetObject) {
errors.push({
path: extPath,
message: 'Object extension must have a targetObject',
});
continue;
}
const { nameSingular, universalIdentifier } = ext.targetObject;
if (!nameSingular && !universalIdentifier) {
errors.push({
path: extPath,
message:
'Object extension targetObject must have either nameSingular or universalIdentifier',
});
}
if (nameSingular && universalIdentifier) {
errors.push({
path: extPath,
message:
'Object extension targetObject cannot have both nameSingular and universalIdentifier',
});
}
if (!ext.fields || ext.fields.length === 0) {
errors.push({
path: extPath,
message: 'Object extension must have at least one field',
});
}
for (const field of ext.fields ?? []) {
const fieldPath = `${extPath}.fields.${field.label ?? 'unknown'}`;
if (!field.universalIdentifier) {
if (!ext.targetObject) {
errors.push({
path: fieldPath,
message: 'Field must have a universalIdentifier',
path: extPath,
message: 'Object extension must have a targetObject',
});
continue;
}
const { nameSingular, universalIdentifier } = ext.targetObject;
if (!nameSingular && !universalIdentifier) {
errors.push({
path: extPath,
message:
'Object extension targetObject must have either nameSingular or universalIdentifier',
});
}
if (!field.type) {
if (nameSingular && universalIdentifier) {
errors.push({
path: fieldPath,
message: 'Field must have a type',
path: extPath,
message:
'Object extension targetObject cannot have both nameSingular and universalIdentifier',
});
}
if (!field.label) {
if (!ext.fields || ext.fields.length === 0) {
errors.push({
path: fieldPath,
message: 'Field must have a label',
path: extPath,
message: 'Object extension must have at least one field',
});
}
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!Array.isArray(field.options) || field.options.length === 0)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
};
for (const field of ext.fields ?? []) {
const fieldPath = `${extPath}.fields.${field.label ?? 'unknown'}`;
export const collectObjectExtensionIds = (
extensions: ObjectExtensionManifest[],
): Array<{ id: string; location: string }> => {
const ids: Array<{ id: string; location: string }> = [];
if (!field.universalIdentifier) {
errors.push({
path: fieldPath,
message: 'Field must have a universalIdentifier',
});
}
for (const ext of extensions) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
for (const field of ext.fields ?? []) {
if (field.universalIdentifier) {
ids.push({
id: field.universalIdentifier,
location: `object-extensions/${targetName}.fields.${field.label}`,
});
if (!field.type) {
errors.push({
path: fieldPath,
message: 'Field must have a type',
});
}
if (!field.label) {
errors.push({
path: fieldPath,
message: 'Field must have a label',
});
}
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!Array.isArray(field.options) || field.options.length === 0)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
}
return ids;
};
display(_extensions: ObjectExtensionManifest[]): void {
// Object extensions don't have a dedicated display - they're part of the manifest
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const extensions = manifest.objectExtensions ?? [];
const objects = manifest.objects ?? [];
const objectFieldIds = new Map<string, string>();
for (const obj of objects) {
for (const field of obj.fields ?? []) {
if (field.universalIdentifier) {
const location = `objects/${obj.nameSingular}.fields.${field.label}`;
objectFieldIds.set(field.universalIdentifier, location);
}
}
}
const extensionFieldLocations = new Map<string, string[]>();
for (const ext of extensions) {
const targetName =
ext.targetObject?.nameSingular ??
ext.targetObject?.universalIdentifier ??
'unknown';
for (const field of ext.fields ?? []) {
if (field.universalIdentifier) {
const location = `object-extensions/${targetName}.fields.${field.label}`;
const locations =
extensionFieldLocations.get(field.universalIdentifier) ?? [];
locations.push(location);
extensionFieldLocations.set(field.universalIdentifier, locations);
}
}
}
const duplicates: EntityIdWithLocation[] = [];
for (const [id, extLocations] of extensionFieldLocations.entries()) {
const objectLocation = objectFieldIds.get(id);
if (extLocations.length > 1 || objectLocation) {
const allLocations = objectLocation
? [objectLocation, ...extLocations]
: extLocations;
duplicates.push({ id, locations: allLocations });
}
}
return duplicates;
}
}
export const objectExtensionEntityBuilder = new ObjectExtensionEntityBuilder();

View file

@ -1,132 +1,136 @@
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
import chalk from 'chalk';
import { glob } from 'fast-glob';
import { posix, relative, sep } from 'path';
import { type ObjectManifest } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { extractManifestFromFile } from '../manifest-file-extractor';
import { type ValidationError } from '../manifest.types';
import {
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from './entity.interface';
const toPosixRelative = (filepath: string, appPath: string): string => {
const rel = relative(appPath, filepath);
return rel.split(sep).join(posix.sep);
};
export class ObjectEntityBuilder
implements ManifestEntityBuilder<ObjectManifest[]>
{
async build(appPath: string): Promise<ObjectManifest[]> {
const objectFiles = await glob(['src/app/**/*.object.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
export const buildObjects = async (appPath: string): Promise<ObjectManifest[]> => {
const objectFiles = await glob(['src/app/**/*.object.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
const objectManifests: ObjectManifest[] = [];
const objectManifests: ObjectManifest[] = [];
for (const filepath of objectFiles) {
try {
objectManifests.push(
await extractManifestFromFile<ObjectManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
for (const filepath of objectFiles) {
try {
objectManifests.push(
await extractManifestFromFile<ObjectManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load object from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return objectManifests;
}
return objectManifests;
};
validate(objects: ObjectManifest[], errors: ValidationError[]): void {
for (const obj of objects) {
const objPath = `objects/${obj.nameSingular ?? 'unknown'}`;
export const validateObjects = (
objects: ObjectManifest[],
errors: ValidationError[],
): void => {
for (const obj of objects) {
const objPath = `objects/${obj.nameSingular ?? 'unknown'}`;
if (!obj.universalIdentifier) {
errors.push({
path: objPath,
message: 'Object must have a universalIdentifier',
});
}
if (!obj.nameSingular) {
errors.push({
path: objPath,
message: 'Object must have a nameSingular',
});
}
if (!obj.namePlural) {
errors.push({
path: objPath,
message: 'Object must have a namePlural',
});
}
for (const field of obj.fields ?? []) {
const fieldPath = `${objPath}.fields.${field.label ?? 'unknown'}`;
if (!field.universalIdentifier) {
if (!obj.universalIdentifier) {
errors.push({
path: fieldPath,
message: 'Field must have a universalIdentifier',
path: objPath,
message: 'Object must have a universalIdentifier',
});
}
if (!field.type) {
if (!obj.nameSingular) {
errors.push({
path: fieldPath,
message: 'Field must have a type',
path: objPath,
message: 'Object must have a nameSingular',
});
}
if (!field.label) {
if (!obj.namePlural) {
errors.push({
path: fieldPath,
message: 'Field must have a label',
path: objPath,
message: 'Object must have a namePlural',
});
}
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!Array.isArray(field.options) || field.options.length === 0)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
};
for (const field of obj.fields ?? []) {
const fieldPath = `${objPath}.fields.${field.label ?? 'unknown'}`;
export const displayObjects = (objects: ObjectManifest[]): void => {
console.log(chalk.green(` ✓ Found ${objects.length} object(s)`));
};
if (!field.universalIdentifier) {
errors.push({
path: fieldPath,
message: 'Field must have a universalIdentifier',
});
}
export const collectObjectIds = (
objects: ObjectManifest[],
): Array<{ id: string; location: string }> => {
const ids: Array<{ id: string; location: string }> = [];
if (!field.type) {
errors.push({
path: fieldPath,
message: 'Field must have a type',
});
}
for (const obj of objects) {
if (obj.universalIdentifier) {
ids.push({
id: obj.universalIdentifier,
location: `objects/${obj.nameSingular}`,
});
}
for (const field of obj.fields ?? []) {
if (field.universalIdentifier) {
ids.push({
id: field.universalIdentifier,
location: `objects/${obj.nameSingular}.fields.${field.label}`,
});
if (!field.label) {
errors.push({
path: fieldPath,
message: 'Field must have a label',
});
}
if (
(field.type === FieldMetadataType.SELECT ||
field.type === FieldMetadataType.MULTI_SELECT) &&
(!Array.isArray(field.options) || field.options.length === 0)
) {
errors.push({
path: fieldPath,
message: 'SELECT/MULTI_SELECT field must have options',
});
}
}
}
}
return ids;
};
display(objects: ObjectManifest[]): void {
console.log(chalk.green(` ✓ Found ${objects.length} object(s)`));
}
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const objects = manifest.objects ?? [];
for (const obj of objects) {
if (obj.universalIdentifier) {
const location = `objects/${obj.nameSingular}`;
const locations = seen.get(obj.universalIdentifier) ?? [];
locations.push(location);
seen.set(obj.universalIdentifier, locations);
}
for (const field of obj.fields ?? []) {
if (field.universalIdentifier) {
const location = `objects/${obj.nameSingular}.fields.${field.label}`;
const locations = seen.get(field.universalIdentifier) ?? [];
locations.push(location);
seen.set(field.universalIdentifier, locations);
}
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const objectEntityBuilder = new ObjectEntityBuilder();

View file

@ -1,80 +1,82 @@
import { toPosixRelative } from '@/cli/utilities/file/utils/file-path';
import chalk from 'chalk';
import { glob } from 'fast-glob';
import { posix, relative, sep } from 'path';
import { type RoleManifest } from 'twenty-shared/application';
import { extractManifestFromFile } from '../manifest-file-extractor';
import { type ValidationError } from '../manifest.types';
import {
type EntityIdWithLocation,
type ManifestEntityBuilder,
type ManifestWithoutSources,
} from './entity.interface';
const toPosixRelative = (filepath: string, appPath: string): string => {
const rel = relative(appPath, filepath);
return rel.split(sep).join(posix.sep);
};
export class RoleEntityBuilder implements ManifestEntityBuilder<RoleManifest[]> {
async build(appPath: string): Promise<RoleManifest[]> {
const roleFiles = await glob(['src/app/**/*.role.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
export const buildRoles = async (appPath: string): Promise<RoleManifest[]> => {
const roleFiles = await glob(['src/app/**/*.role.ts'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**'],
});
const roleManifests: RoleManifest[] = [];
const roleManifests: RoleManifest[] = [];
for (const filepath of roleFiles) {
try {
roleManifests.push(
await extractManifestFromFile<RoleManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
for (const filepath of roleFiles) {
try {
roleManifests.push(
await extractManifestFromFile<RoleManifest>(filepath, appPath),
);
} catch (error) {
const relPath = toPosixRelative(filepath, appPath);
throw new Error(
`Failed to load role from ${relPath}: ${error instanceof Error ? error.message : String(error)}`,
);
return roleManifests;
}
validate(roles: RoleManifest[], errors: ValidationError[]): void {
for (const role of roles) {
const rolePath = `roles/${role.label ?? 'unknown'}`;
if (!role.universalIdentifier) {
errors.push({
path: rolePath,
message: 'Role must have a universalIdentifier',
});
}
if (!role.label) {
errors.push({
path: rolePath,
message: 'Role must have a label',
});
}
}
}
return roleManifests;
};
export const validateRoles = (
roles: RoleManifest[],
errors: ValidationError[],
): void => {
for (const role of roles) {
const rolePath = `roles/${role.label ?? 'unknown'}`;
if (!role.universalIdentifier) {
errors.push({
path: rolePath,
message: 'Role must have a universalIdentifier',
});
}
if (!role.label) {
errors.push({
path: rolePath,
message: 'Role must have a label',
});
}
}
};
export const displayRoles = (roles: RoleManifest[]): void => {
console.log(chalk.green(` ✓ Found ${roles?.length ?? 'no'} role(s)`));
};
export const collectRoleIds = (
roles: RoleManifest[],
): Array<{ id: string; location: string }> => {
const ids: Array<{ id: string; location: string }> = [];
for (const role of roles) {
if (role.universalIdentifier) {
ids.push({
id: role.universalIdentifier,
location: `roles/${role.label}`,
});
}
display(roles: RoleManifest[]): void {
console.log(chalk.green(` ✓ Found ${roles?.length ?? 'no'} role(s)`));
}
return ids;
};
findDuplicates(manifest: ManifestWithoutSources): EntityIdWithLocation[] {
const seen = new Map<string, string[]>();
const roles = manifest.roles ?? [];
for (const role of roles) {
if (role.universalIdentifier) {
const location = `roles/${role.label}`;
const locations = seen.get(role.universalIdentifier) ?? [];
locations.push(location);
seen.set(role.universalIdentifier, locations);
}
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
}
}
export const roleEntityBuilder = new RoleEntityBuilder();

View file

@ -7,12 +7,12 @@ import path, { relative, sep } from 'path';
import { type ApplicationManifest } from 'twenty-shared/application';
import { type Sources } from 'twenty-shared/types';
import { OUTPUT_DIR } from '../common/constants';
import { buildApplication } from './entities/application';
import { buildFrontComponents } from './entities/front-component';
import { buildFunctions } from './entities/function';
import { buildObjects } from './entities/object';
import { buildObjectExtensions } from './entities/object-extension';
import { buildRoles } from './entities/role';
import { applicationEntityBuilder } from './entities/application';
import { frontComponentEntityBuilder } from './entities/front-component';
import { functionEntityBuilder } from './entities/function';
import { objectEntityBuilder } from './entities/object';
import { objectExtensionEntityBuilder } from './entities/object-extension';
import { roleEntityBuilder } from './entities/role';
import { displayEntitySummary, displayErrors, displayWarnings } from './manifest-display';
import { validateManifest } from './manifest-validate';
import { ManifestValidationError } from './manifest.types';
@ -113,12 +113,12 @@ export const runManifestBuild = async (
roleManifests,
sources,
] = await Promise.all([
buildApplication(appPath),
buildObjects(appPath),
buildObjectExtensions(appPath),
buildFunctions(appPath),
buildFrontComponents(appPath),
buildRoles(appPath),
applicationEntityBuilder.build(appPath),
objectEntityBuilder.build(appPath),
objectExtensionEntityBuilder.build(appPath),
functionEntityBuilder.build(appPath),
frontComponentEntityBuilder.build(appPath),
roleEntityBuilder.build(appPath),
loadSources(appPath),
]);
@ -142,7 +142,6 @@ export const runManifestBuild = async (
serverlessFunctions: functionManifests,
frontComponents: frontComponentManifests,
roles: roleManifests,
packageJson,
});
if (!validation.isValid) {

View file

@ -1,18 +1,18 @@
import chalk from 'chalk';
import { type ApplicationManifest } from 'twenty-shared/application';
import { displayApplication } from './entities/application';
import { displayFrontComponents } from './entities/front-component';
import { displayFunctions } from './entities/function';
import { displayObjects } from './entities/object';
import { displayRoles } from './entities/role';
import { applicationEntityBuilder } from './entities/application';
import { frontComponentEntityBuilder } from './entities/front-component';
import { functionEntityBuilder } from './entities/function';
import { objectEntityBuilder } from './entities/object';
import { roleEntityBuilder } from './entities/role';
import { type ManifestValidationError, type ValidationWarning } from './manifest.types';
export const displayEntitySummary = (manifest: ApplicationManifest): void => {
displayApplication(manifest);
displayObjects(manifest.objects);
displayFunctions(manifest.serverlessFunctions);
displayFrontComponents(manifest.frontComponents ?? []);
displayRoles(manifest.roles ?? []);
applicationEntityBuilder.display(manifest.application);
objectEntityBuilder.display(manifest.objects);
functionEntityBuilder.display(manifest.serverlessFunctions);
frontComponentEntityBuilder.display(manifest.frontComponents ?? []);
roleEntityBuilder.display(manifest.roles ?? []);
};
export const displayErrors = (error: ManifestValidationError): void => {

View file

@ -1,74 +1,16 @@
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';
import {
closeViteServer,
findImportSource,
getViteServer,
loadModule,
} from './vite-module-loader';
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,
@ -93,57 +35,18 @@ const findConfigInModule = <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 { entryProperty } = options;
const module = (await jiti.import(filepath)) as Record<string, unknown>;
// Get or create the Vite server for this appPath
const server = await getViteServer(appPath);
// Load the module using Vite's SSR loader
const module = await loadModule(server, filepath);
const configValidator = entryProperty
? (value: unknown): boolean =>
@ -178,10 +81,10 @@ export const extractManifestFromFile = async <TManifest>(
);
}
const source = await fs.readFile(filepath, 'utf8');
const importPath = extractImportPath(source, entryName, filepath, appPath);
// Use Vite to resolve where the function was imported from
const importSource = await findImportSource(server, filepath, entryName, appPath);
const entryPath =
importPath ?? path.relative(appPath, filepath).replace(/\\/g, '/');
importSource ?? path.relative(appPath, filepath).replace(/\\/g, '/');
const { [entryProperty]: _, ...configWithoutEntry } = config;
@ -193,3 +96,6 @@ export const extractManifestFromFile = async <TManifest>(
return manifest as TManifest;
};
// Re-export for cleanup
export { closeViteServer };

View file

@ -1,60 +1,46 @@
import { type ApplicationManifest } from 'twenty-shared/application';
import { collectApplicationIds, validateApplication } from './entities/application';
import { collectFrontComponentIds, validateFrontComponents } from './entities/front-component';
import { collectFunctionIds, validateFunctions } from './entities/function';
import { collectObjectExtensionIds, validateObjectExtensions } from './entities/object-extension';
import { collectObjectIds, validateObjects } from './entities/object';
import { collectRoleIds, validateRoles } from './entities/role';
import { applicationEntityBuilder } from './entities/application';
import {
type EntityIdWithLocation,
type ManifestWithoutSources,
} from './entities/entity.interface';
import { frontComponentEntityBuilder } from './entities/front-component';
import { functionEntityBuilder } from './entities/function';
import { objectEntityBuilder } from './entities/object';
import { objectExtensionEntityBuilder } from './entities/object-extension';
import { roleEntityBuilder } from './entities/role';
import {
type ValidationError,
type ValidationResult,
type ValidationWarning,
} from './manifest.types';
const collectAllIds = (
manifest: Omit<ApplicationManifest, 'sources'>,
): Array<{ id: string; location: string }> => {
const collectAllDuplicates = (
manifest: ManifestWithoutSources,
): EntityIdWithLocation[] => {
return [
...collectApplicationIds(manifest.application),
...collectObjectIds(manifest.objects ?? []),
...collectObjectExtensionIds(manifest.objectExtensions ?? []),
...collectFunctionIds(manifest.serverlessFunctions ?? []),
...collectRoleIds(manifest.roles ?? []),
...collectFrontComponentIds(manifest.frontComponents ?? []),
...applicationEntityBuilder.findDuplicates(manifest),
...objectEntityBuilder.findDuplicates(manifest),
...objectExtensionEntityBuilder.findDuplicates(manifest),
...functionEntityBuilder.findDuplicates(manifest),
...roleEntityBuilder.findDuplicates(manifest),
...frontComponentEntityBuilder.findDuplicates(manifest),
];
};
const findDuplicates = (
ids: Array<{ id: string; location: string }>,
): Array<{ id: string; locations: string[] }> => {
const seen = new Map<string, string[]>();
for (const { id, location } of ids) {
const locations = seen.get(id) ?? [];
locations.push(location);
seen.set(id, locations);
}
return Array.from(seen.entries())
.filter(([_, locations]) => locations.length > 1)
.map(([id, locations]) => ({ id, locations }));
};
export const validateManifest = (
manifest: Omit<ApplicationManifest, 'sources'>,
manifest: ManifestWithoutSources,
): ValidationResult => {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
validateApplication(manifest.application, errors);
validateObjects(manifest.objects ?? [], errors);
validateObjectExtensions(manifest.objectExtensions ?? [], errors);
validateFunctions(manifest.serverlessFunctions ?? [], errors);
validateRoles(manifest.roles ?? [], errors);
validateFrontComponents(manifest.frontComponents ?? [], errors);
applicationEntityBuilder.validate(manifest.application, errors);
objectEntityBuilder.validate(manifest.objects ?? [], errors);
objectExtensionEntityBuilder.validate(manifest.objectExtensions ?? [], errors);
functionEntityBuilder.validate(manifest.serverlessFunctions ?? [], errors);
roleEntityBuilder.validate(manifest.roles ?? [], errors);
frontComponentEntityBuilder.validate(manifest.frontComponents ?? [], errors);
const allIds = collectAllIds(manifest);
const duplicates = findDuplicates(allIds);
const duplicates = collectAllDuplicates(manifest);
for (const dup of duplicates) {
errors.push({
path: dup.locations.join(', '),

View file

@ -6,7 +6,7 @@ import { build, type InlineConfig, type Plugin, type Rollup } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { OUTPUT_DIR } from '../common/constants';
import { printWatchingMessage } from '../common/display';
import { type RestartableWatcher } from '../common/watcher';
import { type RestartableWatcher } from '../common/restartable-watcher.interface';
import { runManifestBuild } from './manifest-build';
export type ManifestWatcherCallbacks = {

View file

@ -0,0 +1,111 @@
import path from 'path';
import { createServer, type ViteDevServer } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
// Singleton Vite dev server per appPath
const servers = new Map<string, ViteDevServer>();
export const getViteServer = async (appPath: string): Promise<ViteDevServer> => {
const existing = servers.get(appPath);
if (existing) {
return existing;
}
const server = await createServer({
root: appPath,
plugins: [tsconfigPaths({ root: appPath })],
server: { middlewareMode: true },
optimizeDeps: { disabled: true },
logLevel: 'silent',
configFile: false,
esbuild: {
jsx: 'automatic',
},
});
servers.set(appPath, server);
return server;
};
export const closeViteServer = async (appPath?: string): Promise<void> => {
if (appPath) {
const server = servers.get(appPath);
if (server) {
await server.close();
servers.delete(appPath);
}
} else {
// Close all servers
for (const [key, server] of servers) {
await server.close();
servers.delete(key);
}
}
};
// Load a module using Vite's SSR loader
export const loadModule = async (
server: ViteDevServer,
filepath: string,
): Promise<Record<string, unknown>> => {
return (await server.ssrLoadModule(filepath)) as Record<string, unknown>;
};
// Find where an identifier was imported from by parsing the source
// and using Vite's module graph to resolve the import path
export const findImportSource = async (
server: ViteDevServer,
filepath: string,
identifier: string,
appPath: string,
): Promise<string | null> => {
// Read the source and find import statements
const fs = await import('fs-extra');
const source = await fs.default.readFile(filepath, 'utf8');
// Find the import statement that imports the identifier
const importRegexes = [
// Named import: import { identifier } from 'path'
new RegExp(
`import\\s*\\{[^}]*\\b${identifier}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
// Aliased import: import { something as identifier } from 'path'
new RegExp(
`import\\s*\\{[^}]*\\w+\\s+as\\s+${identifier}[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`,
),
// Default import: import identifier from 'path'
new RegExp(`import\\s+${identifier}\\s+from\\s*['"]([^'"]+)['"]`),
];
let importSpecifier: string | null = null;
for (const regex of importRegexes) {
const match = source.match(regex);
if (match) {
importSpecifier = match[1];
break;
}
}
if (!importSpecifier) {
// Not imported, must be defined in the same file
return null;
}
// Use Vite to resolve the import path
const resolved = await server.pluginContainer.resolveId(importSpecifier, filepath);
if (resolved?.id) {
return path.relative(appPath, resolved.id).replace(/\\/g, '/');
}
// Fallback to simple relative path resolution
if (importSpecifier.startsWith('.')) {
const fileDir = path.dirname(filepath);
const absolutePath = path.resolve(fileDir, importSpecifier);
const relativePath = path.relative(appPath, absolutePath);
return (
relativePath.endsWith('.ts') ? relativePath : `${relativePath}.ts`
).replace(/\\/g, '/');
}
return null;
};

View file

@ -1,8 +1,13 @@
import { join } from 'path';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory';
import { join, posix, relative, sep } from 'path';
export const formatPath = (appPath?: string) => {
return appPath && !appPath?.startsWith('/')
? join(CURRENT_EXECUTION_DIRECTORY, appPath)
: appPath;
};
export const toPosixRelative = (filepath: string, basePath: string): string => {
const rel = relative(basePath, filepath);
return rel.split(sep).join(posix.sep);
};

View file

@ -0,0 +1,30 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
root: __dirname,
ignoreConfigErrors: true,
}),
],
test: {
name: 'twenty-sdk',
environment: 'node',
include: [
'src/**/__tests__/**/*.{test,spec}.{ts,tsx}',
'src/**/*.{test,spec}.{ts,tsx}',
],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.d.ts', 'src/cli/cli.ts'],
thresholds: {
statements: 1,
lines: 1,
functions: 1,
},
},
globals: true,
},
});

View file

@ -0,0 +1,31 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
root: __dirname,
ignoreConfigErrors: true,
}),
],
test: {
name: 'twenty-sdk-e2e',
environment: 'node',
include: ['src/**/*.e2e-spec.ts'],
globals: true,
testTimeout: 30000,
hookTimeout: 30000,
pool: 'forks',
poolOptions: {
forks: {
singleFork: true,
},
},
sequence: {
concurrent: false,
},
setupFiles: ['src/cli/__tests__/e2e/setupTest.ts'],
globalSetup: undefined,
onConsoleLog: () => true,
},
});

View file

@ -23524,16 +23524,6 @@ __metadata:
languageName: node
linkType: hard
"@types/jest@npm:^29.5.0":
version: 29.5.14
resolution: "@types/jest@npm:29.5.14"
dependencies:
expect: "npm:^29.0.0"
pretty-format: "npm:^29.0.0"
checksum: 10c0/18e0712d818890db8a8dab3d91e9ea9f7f19e3f83c2e50b312f557017dc81466207a71f3ed79cf4428e813ba939954fa26ffa0a9a7f153181ba174581b1c2aed
languageName: node
linkType: hard
"@types/jest@npm:^30.0.0":
version: 30.0.0
resolution: "@types/jest@npm:30.0.0"
@ -29697,7 +29687,7 @@ __metadata:
languageName: node
linkType: hard
"chokidar@npm:4.0.3, chokidar@npm:^4.0.0, chokidar@npm:^4.0.1, chokidar@npm:^4.0.3":
"chokidar@npm:4.0.3, chokidar@npm:^4.0.1, chokidar@npm:^4.0.3":
version: 4.0.3
resolution: "chokidar@npm:4.0.3"
dependencies:
@ -35264,7 +35254,7 @@ __metadata:
languageName: node
linkType: hard
"expect@npm:^29.0.0, expect@npm:^29.7.0":
"expect@npm:^29.7.0":
version: 29.7.0
resolution: "expect@npm:29.7.0"
dependencies:
@ -41775,7 +41765,7 @@ __metadata:
languageName: node
linkType: hard
"jest@npm:29.7.0, jest@npm:^29.5.0":
"jest@npm:29.7.0":
version: 29.7.0
resolution: "jest@npm:29.7.0"
dependencies:
@ -41849,15 +41839,6 @@ __metadata:
languageName: node
linkType: hard
"jiti@npm:^2.0.0":
version: 2.6.1
resolution: "jiti@npm:2.6.1"
bin:
jiti: lib/jiti-cli.mjs
checksum: 10c0/79b2e96a8e623f66c1b703b98ec1b8be4500e1d217e09b09e343471bbb9c105381b83edbb979d01cef18318cc45ce6e153571b6c83122170eefa531c64b6789b
languageName: node
linkType: hard
"jju@npm:~1.4.0":
version: 1.4.0
resolution: "jju@npm:1.4.0"
@ -49918,7 +49899,7 @@ __metadata:
languageName: node
linkType: hard
"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0":
"pretty-format@npm:^29.7.0":
version: 29.7.0
resolution: "pretty-format@npm:29.7.0"
dependencies:
@ -57001,7 +56982,6 @@ __metadata:
"@types/archiver": "npm:^6.0.0"
"@types/fs-extra": "npm:^11.0.0"
"@types/inquirer": "npm:^9.0.0"
"@types/jest": "npm:^29.5.0"
"@types/lodash.camelcase": "npm:^4.3.7"
"@types/lodash.capitalize": "npm:^4"
"@types/lodash.kebabcase": "npm:^4.1.7"
@ -57011,7 +56991,6 @@ __metadata:
archiver: "npm:^7.0.1"
axios: "npm:^1.6.0"
chalk: "npm:^5.3.0"
chokidar: "npm:^4.0.0"
commander: "npm:^12.0.0"
dotenv: "npm:^16.4.0"
fast-glob: "npm:^3.3.0"
@ -57019,8 +56998,6 @@ __metadata:
graphql: "npm:^16.8.1"
graphql-sse: "npm:^2.5.4"
inquirer: "npm:^10.0.0"
jest: "npm:^29.5.0"
jiti: "npm:^2.0.0"
jsonc-parser: "npm:^3.2.0"
lodash.camelcase: "npm:^4.3.0"
lodash.capitalize: "npm:^4.2.1"