mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
2ecc1ba897
commit
d74d74da4c
31 changed files with 925 additions and 913 deletions
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
16
packages/twenty-sdk/src/cli/__tests__/test-app/tsconfig.json
Normal file
16
packages/twenty-sdk/src/cli/__tests__/test-app/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)[] = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(', '),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
30
packages/twenty-sdk/vitest.config.ts
Normal file
30
packages/twenty-sdk/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
31
packages/twenty-sdk/vitest.e2e.config.ts
Normal file
31
packages/twenty-sdk/vitest.e2e.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
31
yarn.lock
31
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue