1750 extensibility twenty sdk v2 use twenty sdk to define an object (#15230)

We maintain jsonc object definition but will deprecate them pretty soon

## Before
<img width="1512" height="575" alt="image"
src="https://github.com/user-attachments/assets/d2fa6ca4-c456-4aa9-a1e3-845b61839718"
/>

## After
<img width="1260" height="555" alt="image"
src="https://github.com/user-attachments/assets/ba72f4cf-d443-4967-913c-029bc71f3f48"
/>
This commit is contained in:
martmull
2025-10-22 13:18:23 +00:00
committed by GitHub
parent 32558673c6
commit 033c28a3d5
25 changed files with 440 additions and 25 deletions
@@ -3,11 +3,13 @@ import { randomUUID } from 'crypto';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import path from 'path';
import camelcase from 'lodash.camelcase';
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
import { HTTPMethod } from '../types/config.types';
import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser';
import { getSchemaUrls } from '../utils/schema-validator';
import { BASE_SCHEMAS_PATH } from '../constants/constants-path';
import { getDecoratedClass } from '../utils/get-decorated-class';
export enum SyncableEntity {
AGENT = 'agent',
@@ -56,6 +58,22 @@ export class AppAddCommand {
const entityData = await this.getEntityToCreateData(entity, entityName);
if (entity === SyncableEntity.OBJECT) {
delete entityData['standardId'];
delete entityData['$schema'];
const objectFileName = `${camelcase(entityName)}.ts`;
const decoratedObject = getDecoratedClass({
data: entityData,
name: entityName,
});
await fs.writeFile(path.join(appPath, objectFileName), decoratedObject);
return;
}
const folderName = getFolderName(entity);
const entitiesDir = path.join(appPath, folderName, entityName);
@@ -0,0 +1,5 @@
# Duplicated with ./gitignore because npm publish does not include .gitignore
# https://github.com/npm/npm/issues/3763
.yarn/install-state.gz
.env
@@ -7,6 +7,9 @@
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"dependencies": {
"twenty-sdk": "^0.0.2"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
@@ -0,0 +1,22 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true
},
"exclude": ["node_modules", "dist"],
"include": ["**/*.ts"]
}
@@ -0,0 +1,38 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"@types/node@npm:^24.7.2":
version: 24.9.1
resolution: "@types/node@npm:24.9.1"
dependencies:
undici-types: "npm:~7.16.0"
checksum: 10c0/c52f8168080ef9a7c3dc23d8ac6061fab5371aad89231a0f6f4c075869bc3de7e89b075b1f3e3171d9e5143d0dda1807c3dab8e32eac6d68f02e7480e7e78576
languageName: node
linkType: hard
"root-workspace-0b6124@workspace:.":
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
"@types/node": "npm:^24.7.2"
twenty-sdk: "npm:^0.0.2"
languageName: unknown
linkType: soft
"twenty-sdk@npm:^0.0.2":
version: 0.0.2
resolution: "twenty-sdk@npm:0.0.2"
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
languageName: node
linkType: hard
"undici-types@npm:~7.16.0":
version: 7.16.0
resolution: "undici-types@npm:7.16.0"
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node
linkType: hard
@@ -78,6 +78,7 @@ export type ServerlessFunctionCodeManifest = {
export type ObjectManifest = {
$schema?: string;
standardId: string;
universalIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
@@ -89,6 +90,7 @@ export type ObjectManifest = {
export type AgentManifest = {
$schema?: string;
standardId: string;
universalIdentifier: string;
name: string;
label: string;
description?: string;
@@ -0,0 +1,21 @@
import { getDecoratedClass } from '../../utils/get-decorated-class';
describe('getDecoratedClass', () => {
it('should return properly formatted class', () => {
const result = getDecoratedClass({
data: { nameSingular: 'Name', namePlural: 'Names' },
name: 'MyNewObject',
});
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk';
@ObjectMetadata({
nameSingular: 'Name',
namePlural: 'Names',
})
export class MyNewObject {}
`;
expect(result).toEqual(expectedResult);
});
});
@@ -5,10 +5,12 @@ import * as path from 'path';
import {
AppManifest,
CoreEntityManifest,
ObjectManifest,
PackageJson,
} from '../types/config.types';
import { validateSchema } from '../utils/schema-validator';
import { parseJsoncFile } from './jsonc-parser';
import { loadManifestFromDecorators } from '../utils/load-manifest-from-decorators';
type Sources = { [key: string]: string | Sources };
@@ -156,7 +158,7 @@ export const loadManifest = async (
(manifest, path) => validateSchema('agent', manifest, path),
);
const objects = await loadCoreEntity(
const objectFromManifests = await loadCoreEntity(
path.join(appPath, 'objects'),
(manifest, path) => validateSchema('object', manifest, path),
);
@@ -166,6 +168,15 @@ export const loadManifest = async (
(manifest, path) => validateSchema('serverlessFunction', manifest, path),
);
const { objects: objectsFromDecorators } = loadManifestFromDecorators();
const objects = (
[...objectFromManifests, ...objectsFromDecorators] as ObjectManifest[]
).map((object) => {
object.standardId = object.universalIdentifier;
return object;
});
return {
packageJson,
yarnLock: rawYarnLock,
@@ -0,0 +1,25 @@
import camelcase from 'lodash.camelcase';
export const getDecoratedClass = ({
data,
name,
}: {
data: object;
name: string;
}) => {
const decoratorOptions = Object.entries(data)
.map(([key, value]) => ` ${key}: '${value}',`)
.join('\n');
const camelCaseName = camelcase(name);
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
return `import { ObjectMetadata } from 'twenty-sdk';
@ObjectMetadata({
${decoratorOptions}
})
export class ${className} {}
`;
};
@@ -0,0 +1,175 @@
import {
sys,
getDecorators,
readConfigFile,
parseJsonConfigFileContent,
formatDiagnosticsWithColorAndContext,
createProgram,
Decorator,
isPropertyAccessExpression,
isNumericLiteral,
SyntaxKind,
isArrayLiteralExpression,
Expression,
isPropertyAssignment,
isComputedPropertyName,
isStringLiteralLike,
isShorthandPropertyAssignment,
isIdentifier,
Program,
Node,
isClassDeclaration,
isCallExpression,
isObjectLiteralExpression,
forEachChild,
} from 'typescript';
import { AppManifest, ObjectManifest } from '../types/config.types';
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [k: string]: JSONValue };
const getProgramFromTsconfig = (tsconfigPath = 'tsconfig.json') => {
const basePath = process.cwd();
const configFile = readConfigFile(tsconfigPath, sys.readFile);
if (configFile.error)
throw new Error(
formatDiagnosticsWithColorAndContext([configFile.error], {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
const parsed = parseJsonConfigFileContent(configFile.config, sys, basePath);
if (parsed.errors.length) {
throw new Error(
formatDiagnosticsWithColorAndContext(parsed.errors, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
}),
);
}
return createProgram(parsed.fileNames, parsed.options);
};
const isDecoratorNamed = (node: Decorator, name: string): node is Decorator => {
const expr = node.expression;
if (isCallExpression(expr)) {
if (isIdentifier(expr.expression)) return expr.expression.text === name;
if (isPropertyAccessExpression(expr.expression))
return expr.expression.name.text === name;
}
return false;
};
const exprToValue = (expr: Expression): JSONValue => {
if (isStringLiteralLike(expr)) return expr.text;
if (isNumericLiteral(expr)) return Number(expr.text);
if (expr.kind === SyntaxKind.TrueKeyword) return true;
if (expr.kind === SyntaxKind.FalseKeyword) return false;
if (expr.kind === SyntaxKind.NullKeyword) return null;
if (isArrayLiteralExpression(expr)) {
return expr.elements.map((e) =>
e.kind === SyntaxKind.SpreadElement ? [] : exprToValue(e),
);
}
if (isObjectLiteralExpression(expr)) {
const obj: Record<string, JSONValue> = {};
for (const prop of expr.properties) {
if (isPropertyAssignment(prop)) {
const key =
isIdentifier(prop.name) || isStringLiteralLike(prop.name)
? prop.name.text
: isComputedPropertyName(prop.name) &&
isStringLiteralLike(prop.name.expression)
? prop.name.expression.text
: undefined;
if (key) obj[key] = exprToValue(prop.initializer);
} else if (isShorthandPropertyAssignment(prop)) {
// Unsupported without a checker; skip to keep it "light".
// Could resolve via typechecker if needed.
}
// getters/setters/methods are ignored intentionally
}
return obj;
}
// Keep it intentionally strict/lightweight: anything non-literal becomes a string fallback.
// You can throw instead if you prefer to fail fast.
return isIdentifier(expr)
? expr.text
: String((expr as any).getText?.() ?? '');
};
const collectObjects = (program: Program) => {
const manifest: ObjectManifest[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) {
continue;
}
const visit = (node: Node) => {
if (isClassDeclaration(node) && getDecorators(node)?.length) {
const decorators = getDecorators(node);
const objectDec = decorators?.find((d) =>
isDecoratorNamed(d, 'ObjectMetadata'),
);
if (objectDec && isCallExpression(objectDec.expression)) {
const [firstArg] = objectDec.expression.arguments;
if (firstArg && isObjectLiteralExpression(firstArg)) {
const config = exprToValue(firstArg);
if (
config &&
typeof config === 'object' &&
!Array.isArray(config)
) {
manifest.push({
...config,
} as ObjectManifest);
}
}
}
}
forEachChild(node, visit);
};
visit(sf);
}
return manifest;
};
const validateProgram = (program: Program) => {
const diagnostics = [
...program.getSyntacticDiagnostics(),
...program.getSemanticDiagnostics(),
...program.getGlobalDiagnostics(),
];
if (diagnostics.length > 0) {
const formatted = formatDiagnosticsWithColorAndContext(diagnostics, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: sys.getCurrentDirectory,
getNewLine: () => sys.newLine,
});
throw new Error(`TypeScript validation failed:\n${formatted}`);
}
};
export const loadManifestFromDecorators = (): Pick<AppManifest, 'objects'> => {
const program = getProgramFromTsconfig('tsconfig.json');
validateProgram(program);
const objects = collectObjects(program);
return { objects };
};