1751 extensibility twenty sdk v2 use twenty sdk to define a serverless function trigger (#15347)
This PR adds 2 columns handlerPath and handlerName in serverlessFunction
to locate the entrypoint of a serverless in a codebase
It adds the following decorators in twenty-sdk:
- ServerlessFunction
- DatabaseEventTrigger
- RouteTrigger
- CronTrigger
- ApplicationVariable
It still supports deprecated entity.manifest.jsonc
Overall code needs to be cleaned a little bit, but it should work
properly so you can try to test if the DEVX fits your needs
See updates in hello-world application
```typescript
import axios from 'axios';
import {
DatabaseEventTrigger,
ServerlessFunction,
RouteTrigger,
CronTrigger,
ApplicationVariable,
} from 'twenty-sdk';
@ApplicationVariable({
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
key: 'TWENTY_API_KEY',
description: 'Twenty API Key',
isSecret: true,
})
@DatabaseEventTrigger({
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
})
@RouteTrigger({
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
})
@CronTrigger({
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
class CreateNewPostCard {
main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
}
export const createNewPostCardHandler = new CreateNewPostCard().main;
```
### [edit] V2
After the v1 proposal, I see that using a class method to define the
serverless function handler is pretty confusing. Lets leave
serverlessFunction configuration decorators on the class, but move the
handler like before. Here is the v2 hello-world serverless function:
```typescript
import axios from 'axios';
import {
DatabaseEventTrigger,
ServerlessFunction,
RouteTrigger,
CronTrigger,
ApplicationVariable,
} from 'twenty-sdk';
@ApplicationVariable({
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
key: 'TWENTY_API_KEY',
description: 'Twenty API Key',
isSecret: true,
})
@DatabaseEventTrigger({
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
})
@RouteTrigger({
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
})
@CronTrigger({
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
export class ServerlessFunctionDefinition {}
export const main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
```
### [edit] V3
After the v2 proposal, we don't really like decorators on empty classes.
We decided to go with a Vercel approach with a config constant
```typescript
import axios from 'axios';
import { ServerlessFunctionConfig } from 'twenty-sdk';
export const main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
routeTriggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
}
],
cronTriggers: [
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
}
],
databaseEventTriggers: [
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
}
]
}
```
This commit is contained in:
+14
-9
@@ -19,27 +19,28 @@ export default [
|
||||
|
||||
// Global ignores
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
],
|
||||
ignores: ['**/node_modules/**'],
|
||||
},
|
||||
|
||||
// Base configuration for all files
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
'prettier': prettierPlugin,
|
||||
'lingui': linguiPlugin,
|
||||
prettier: prettierPlugin,
|
||||
lingui: linguiPlugin,
|
||||
'@nx': nxPlugin,
|
||||
'prefer-arrow': preferArrowPlugin,
|
||||
'import': importPlugin,
|
||||
import: importPlugin,
|
||||
'unused-imports': unusedImportsPlugin,
|
||||
'unicorn': unicornPlugin,
|
||||
unicorn: unicornPlugin,
|
||||
},
|
||||
rules: {
|
||||
// General rules
|
||||
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
|
||||
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
|
||||
'no-console': [
|
||||
'warn',
|
||||
{ allow: ['group', 'groupCollapsed', 'groupEnd'] },
|
||||
],
|
||||
'no-control-regex': 0,
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
@@ -53,6 +54,10 @@ export default [
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: [],
|
||||
depConstraints: [
|
||||
{
|
||||
sourceTag: 'scope:apps',
|
||||
onlyDependOnLibsWithTags: ['scope:apps', 'scope:sdk'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:sdk',
|
||||
onlyDependOnLibsWithTags: ['scope:sdk'],
|
||||
@@ -129,7 +134,7 @@ export default [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports'
|
||||
fixStyle: 'inline-type-imports',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
displayName: 'Hello World',
|
||||
description: 'A simple hello world app',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
|
||||
description: 'Twenty API Key',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"name": "hello-world",
|
||||
"version": "0.0.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -7,20 +8,9 @@
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
|
||||
"universalIdentifier": "4ec0391d-18d5-411c-b2f3-266ddc1c3ef7",
|
||||
"name": "Hello world",
|
||||
"description": "A hello-world application example",
|
||||
"env": {
|
||||
"TWENTY_API_KEY": {
|
||||
"isSecret": true,
|
||||
"value": "",
|
||||
"description": "Twenty api key"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"twenty-sdk": "^0.0.2"
|
||||
"twenty-sdk": "0.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
|
||||
"universalIdentifier": "e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf",
|
||||
"name": "create-new-post-card",
|
||||
"triggers": [
|
||||
{
|
||||
"universalIdentifier": "203f1df3-4a82-4d06-a001-b8cf22a31156",
|
||||
"type": "databaseEvent",
|
||||
"eventName": "person.created"
|
||||
},
|
||||
{
|
||||
"universalIdentifier": "c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6",
|
||||
"type": "route",
|
||||
"path": "/post-card/create",
|
||||
"httpMethod": "GET",
|
||||
"isAuthRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const main = async (params: { recipient: string }): Promise<object> => {
|
||||
const { recipient } = params;
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: 'http://localhost:3000/rest/postCards',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
|
||||
},
|
||||
data: { name: recipient ?? 'Unknown' },
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await axios.request(options);
|
||||
|
||||
console.log(`New post card to "${recipient}" created`);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import axios from 'axios';
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
|
||||
export const main = async (params: { recipient: string }): Promise<string> => {
|
||||
const { recipient } = params;
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: 'http://localhost:3000/rest/postCards',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
|
||||
},
|
||||
data: { name: recipient ?? 'Unknown' },
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await axios.request(options);
|
||||
|
||||
console.log(`New post card to "${recipient}" created`);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const config: ServerlessFunctionConfig = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'create-new-post-card',
|
||||
timeoutSeconds: 2,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route',
|
||||
path: '/post-card/create',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
type: 'cron',
|
||||
pattern: '0 0 1 1 *', // Every year 1st of January
|
||||
},
|
||||
{
|
||||
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'person.created',
|
||||
},
|
||||
],
|
||||
};
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
import { ObjectMetadata } from 'twenty-sdk';
|
||||
import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
|
||||
@ObjectMetadata({
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
@@ -6,5 +6,7 @@ import { ObjectMetadata } from 'twenty-sdk';
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post card',
|
||||
labelPlural: 'Post cards',
|
||||
description: ' A post card object',
|
||||
icon: 'IconMail',
|
||||
})
|
||||
export class PostCard {}
|
||||
@@ -17,6 +17,10 @@
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["**/*.ts"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,16 +14,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"Hello world@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "Hello world@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
axios: "npm:^1.12.2"
|
||||
twenty-sdk: "npm:^0.0.2"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-function@npm:1.0.0"
|
||||
@@ -228,6 +218,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hello-world@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "hello-world@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
axios: "npm:^1.12.2"
|
||||
twenty-sdk: "npm:0.0.3-alpha"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"math-intrinsics@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "math-intrinsics@npm:1.1.0"
|
||||
@@ -258,10 +258,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"twenty-sdk@npm:^0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "twenty-sdk@npm:0.0.2"
|
||||
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
|
||||
"twenty-sdk@npm:0.0.3-alpha":
|
||||
version: 0.0.3-alpha
|
||||
resolution: "twenty-sdk@npm:0.0.3-alpha"
|
||||
checksum: 10c0/e8028f47767e3fa6318100f26542e0477b68e36d363df5f3a6c20d8442951fecba60089d07e97224b0b3533a729bbd3526cca08728079577cfa204dbffaecb03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"tags": ["scope:apps"]}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Command-line interface for Twenty application development",
|
||||
"main": "dist/cli.js",
|
||||
"bin": {
|
||||
@@ -38,7 +38,8 @@
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.capitalize": "^4.2.1",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"typescript": "^5.9.2"
|
||||
"typescript": "^5.9.2",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
@@ -50,6 +51,7 @@
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^29.5.0",
|
||||
"tsx": "^4.7.0",
|
||||
"twenty-sdk": "workspace:*",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
"projectType": "application",
|
||||
"tags": ["scope:cli"],
|
||||
"targets": {
|
||||
"after-build": {
|
||||
"before-build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "packages/twenty-cli",
|
||||
"commands": ["rimraf dist", "tsc --project tsconfig.lib.json"]
|
||||
},
|
||||
"dependsOn": ["^after-build"]
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
@@ -23,7 +22,7 @@
|
||||
"cp -R src/constants/schemas dist/constants"
|
||||
]
|
||||
},
|
||||
"dependsOn": ["after-build"]
|
||||
"dependsOn": ["^build", "before-build"]
|
||||
},
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
@@ -86,6 +85,7 @@
|
||||
},
|
||||
"parallel": false,
|
||||
"dependsOn": [
|
||||
"build",
|
||||
{
|
||||
"target": "database:reset",
|
||||
"projects": "twenty-server"
|
||||
|
||||
@@ -5,32 +5,19 @@ 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';
|
||||
import { getObjectMetadataDecoratedClass } from '../utils/get-object-metadata-decorated-class';
|
||||
import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file';
|
||||
|
||||
const ROOT_FOLDER = 'src';
|
||||
|
||||
export enum SyncableEntity {
|
||||
AGENT = 'agent',
|
||||
OBJECT = 'object',
|
||||
SERVERLESS_FUNCTION = 'serverlessFunction',
|
||||
TRIGGER = 'trigger',
|
||||
}
|
||||
|
||||
const getFolderName = (entity: SyncableEntity) => {
|
||||
switch (entity) {
|
||||
case SyncableEntity.AGENT:
|
||||
return 'agents';
|
||||
case SyncableEntity.OBJECT:
|
||||
return 'objects';
|
||||
case SyncableEntity.SERVERLESS_FUNCTION:
|
||||
return 'serverlessFunctions';
|
||||
default:
|
||||
throw new Error(`Unknown entity type: ${entity}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const isSyncableEntity = (value: string): value is SyncableEntity => {
|
||||
return Object.values(SyncableEntity).includes(value as SyncableEntity);
|
||||
};
|
||||
@@ -38,22 +25,12 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => {
|
||||
export class AppAddCommand {
|
||||
async execute(entityType?: SyncableEntity): Promise<void> {
|
||||
try {
|
||||
const appPath = CURRENT_EXECUTION_DIRECTORY;
|
||||
const appPath = path.join(CURRENT_EXECUTION_DIRECTORY, ROOT_FOLDER);
|
||||
|
||||
await fs.ensureDir(appPath);
|
||||
|
||||
const entity = entityType ?? (await this.getEntity());
|
||||
|
||||
const appExists = await fs.pathExists(appPath);
|
||||
|
||||
if (!appExists) {
|
||||
console.error(chalk.red('App does not exist'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (entity === SyncableEntity.TRIGGER) {
|
||||
await this.addTriggerToServerlessFunction(appPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const entityName = await this.getEntityName(entity);
|
||||
|
||||
const entityData = await this.getEntityToCreateData(entity, entityName);
|
||||
@@ -64,7 +41,7 @@ export class AppAddCommand {
|
||||
|
||||
const objectFileName = `${camelcase(entityName)}.ts`;
|
||||
|
||||
const decoratedObject = getDecoratedClass({
|
||||
const decoratedObject = getObjectMetadataDecoratedClass({
|
||||
data: entityData,
|
||||
name: entityName,
|
||||
});
|
||||
@@ -74,18 +51,20 @@ export class AppAddCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderName = getFolderName(entity);
|
||||
if (entity === SyncableEntity.SERVERLESS_FUNCTION) {
|
||||
const objectFileName = `${camelcase(entityName)}.ts`;
|
||||
|
||||
const entitiesDir = path.join(appPath, folderName, entityName);
|
||||
const decoratedServerlessFunction = getServerlessFunctionBaseFile({
|
||||
name: entityName,
|
||||
});
|
||||
|
||||
await fs.ensureDir(entitiesDir);
|
||||
await fs.writeFile(
|
||||
path.join(appPath, objectFileName),
|
||||
decoratedServerlessFunction,
|
||||
);
|
||||
|
||||
await writeJsoncFile(
|
||||
path.join(entitiesDir, `${entity}.manifest.jsonc`),
|
||||
entityData,
|
||||
);
|
||||
|
||||
await this.addEntityInitFiles(entity, entitiesDir);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Add new entity failed:`),
|
||||
@@ -123,11 +102,7 @@ export class AppAddCommand {
|
||||
name: 'entity',
|
||||
message: `What entity do you want to create?`,
|
||||
default: '',
|
||||
choices: [
|
||||
SyncableEntity.SERVERLESS_FUNCTION,
|
||||
SyncableEntity.OBJECT,
|
||||
SyncableEntity.TRIGGER,
|
||||
],
|
||||
choices: [SyncableEntity.SERVERLESS_FUNCTION, SyncableEntity.OBJECT],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -212,178 +187,4 @@ export class AppAddCommand {
|
||||
|
||||
return entityToCreateData;
|
||||
}
|
||||
|
||||
private async addTriggerToServerlessFunction(appPath: string) {
|
||||
const serverlessFunctionsDir = path.join(appPath, 'serverlessFunctions');
|
||||
|
||||
if (!(await fs.pathExists(serverlessFunctionsDir))) {
|
||||
console.error(chalk.red('No serverless functions found in this app'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const serverlessFunctions = await fs.readdir(serverlessFunctionsDir);
|
||||
|
||||
if (serverlessFunctions.length === 0) {
|
||||
console.error(chalk.red('No serverless functions found in this app'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { serverlessFunctionName } = await inquirer.prompt<{
|
||||
serverlessFunctionName: string;
|
||||
}>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'serverlessFunctionName',
|
||||
message: 'Select a serverless function to add a trigger to:',
|
||||
choices: serverlessFunctions,
|
||||
},
|
||||
]);
|
||||
|
||||
const { triggerType } = await inquirer.prompt<{ triggerType: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'triggerType',
|
||||
message: 'Select the type of trigger:',
|
||||
choices: ['databaseEvent', 'cron', 'route'],
|
||||
},
|
||||
]);
|
||||
|
||||
let triggerData: any;
|
||||
|
||||
if (triggerType === 'databaseEvent') {
|
||||
triggerData = await this.createDatabaseEventTrigger();
|
||||
} else if (triggerType === 'cron') {
|
||||
triggerData = await this.createCronTrigger();
|
||||
} else if (triggerType === 'route') {
|
||||
triggerData = await this.createRouteTrigger();
|
||||
}
|
||||
|
||||
const manifestPath = path.join(
|
||||
serverlessFunctionsDir,
|
||||
serverlessFunctionName,
|
||||
'serverlessFunction.manifest.jsonc',
|
||||
);
|
||||
|
||||
const manifest = await parseJsoncFile(manifestPath);
|
||||
|
||||
if (!manifest.triggers) {
|
||||
manifest.triggers = [];
|
||||
}
|
||||
|
||||
manifest.triggers.push(triggerData);
|
||||
|
||||
await writeJsoncFile(manifestPath, manifest);
|
||||
|
||||
console.log(
|
||||
chalk.green(`✅ Trigger added successfully to ${serverlessFunctionName}`),
|
||||
);
|
||||
}
|
||||
|
||||
private async createDatabaseEventTrigger() {
|
||||
const uuid = randomUUID();
|
||||
|
||||
const { eventName } = await inquirer.prompt<{ eventName: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'eventName',
|
||||
message:
|
||||
'Enter the database event name (e.g. company.created, *.updated, person.*):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'Event name is required';
|
||||
}
|
||||
if (!/^(?:[a-zA-Z]+|\*)\.(created|updated|deleted|\*)$/.test(input)) {
|
||||
return 'Event name must be in format: (objectName|*).(created|updated|deleted|*)';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
universalIdentifier: uuid,
|
||||
type: 'databaseEvent',
|
||||
eventName,
|
||||
};
|
||||
}
|
||||
|
||||
private async createCronTrigger() {
|
||||
const uuid = randomUUID();
|
||||
|
||||
const { schedule } = await inquirer.prompt<{ schedule: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'schedule',
|
||||
message: 'Enter the cron schedule (e.g., 0 9 * * * for daily at 9 AM):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'Schedule is required';
|
||||
}
|
||||
|
||||
const parts = input.trim().split(/\s+/);
|
||||
|
||||
if (parts.length < 5 || parts.length > 6) {
|
||||
return 'Cron schedule must have 5 or 6 fields (e.g., 0 9 * * *)';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
universalIdentifier: uuid,
|
||||
type: 'cron',
|
||||
schedule,
|
||||
};
|
||||
}
|
||||
|
||||
private async createRouteTrigger() {
|
||||
const uuid = randomUUID();
|
||||
|
||||
const { path } = await inquirer.prompt<{ path: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'path',
|
||||
message: 'Enter the route path (e.g., /webhook/company):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'Path is required';
|
||||
}
|
||||
if (!input.startsWith('/')) {
|
||||
return 'Path must start with /';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { httpMethod } = await inquirer.prompt<{ httpMethod: HTTPMethod }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'httpMethod',
|
||||
message: 'Select the HTTP method:',
|
||||
choices: Object.values(HTTPMethod),
|
||||
default: HTTPMethod.GET,
|
||||
},
|
||||
]);
|
||||
|
||||
const { isAuthRequired } = await inquirer.prompt<{
|
||||
isAuthRequired: boolean;
|
||||
}>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'isAuthRequired',
|
||||
message: 'Is authentication required?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
universalIdentifier: uuid,
|
||||
type: 'route',
|
||||
path,
|
||||
httpMethod,
|
||||
isAuthRequired,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ApiResponse } from '../types/config.types';
|
||||
import { loadManifest } from '../utils/app-manifest-loader';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
|
||||
export class AppDeleteCommand {
|
||||
private apiService = new ApiService();
|
||||
@@ -25,9 +25,11 @@ export class AppDeleteCommand {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { packageJson } = await loadManifest(appPath);
|
||||
const { manifest } = await loadManifest(appPath);
|
||||
|
||||
const result = await this.apiService.deleteApplication(packageJson);
|
||||
const result = await this.apiService.deleteApplication(
|
||||
manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red('❌ Deletion failed:'), result.error);
|
||||
|
||||
@@ -2,14 +2,17 @@ import chalk from 'chalk';
|
||||
import * as chokidar from 'chokidar';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { loadManifest } from '../utils/app-manifest-loader';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
|
||||
export class AppDevCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
async execute(options: { debounce: string }): Promise<void> {
|
||||
async execute(options: {
|
||||
appPath?: string;
|
||||
debounce: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const appPath = CURRENT_EXECUTION_DIRECTORY;
|
||||
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
|
||||
|
||||
const debounceMs = parseInt(options.debounce, 10);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import kebabCase from 'lodash.kebabcase';
|
||||
export class AppInitCommand {
|
||||
async execute(directory?: string): Promise<void> {
|
||||
try {
|
||||
const { appName, appDirectory, appDescription } =
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
await this.getAppInfos(directory);
|
||||
|
||||
await this.validateDirectory(appDirectory);
|
||||
@@ -19,6 +19,7 @@ export class AppInitCommand {
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
@@ -35,19 +36,31 @@ export class AppInitCommand {
|
||||
|
||||
private async getAppInfos(directory?: string): Promise<{
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}> {
|
||||
const { name, description } = await inquirer.prompt([
|
||||
const { name, displayName, description } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Application name (eg: My awesome application):',
|
||||
message: 'Application name (eg: my-awesome-app):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'displayName',
|
||||
message: 'Display name (eg: My awesome app):',
|
||||
default: (answers: any) => {
|
||||
return answers.name
|
||||
.split('-')
|
||||
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
@@ -58,13 +71,15 @@ export class AppInitCommand {
|
||||
|
||||
const appName = name.trim();
|
||||
|
||||
const appDisplayName = displayName.trim();
|
||||
|
||||
const appDescription = description.trim();
|
||||
|
||||
const appDirectory = directory
|
||||
? path.join(process.cwd(), kebabCase(directory))
|
||||
: path.join(process.cwd(), kebabCase(appName));
|
||||
|
||||
return { appName, appDirectory, appDescription };
|
||||
return { appName, appDisplayName, appDirectory, appDescription };
|
||||
}
|
||||
|
||||
private async validateDirectory(appDirectory: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ApiResponse } from '../types/config.types';
|
||||
import { loadManifest } from '../utils/app-manifest-loader';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
|
||||
export class AppSyncCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppDeleteCommand } from './app-delete.command';
|
||||
import { AppDevCommand } from './app-dev.command';
|
||||
import { AppInitCommand } from './app-init.command';
|
||||
import { AppSyncCommand } from './app-sync.command';
|
||||
import { formatPath } from '../utils/format-path';
|
||||
|
||||
export class AppCommand {
|
||||
private devCommand = new AppDevCommand();
|
||||
@@ -22,19 +23,22 @@ export class AppCommand {
|
||||
appCommand.description('Application development commands');
|
||||
|
||||
appCommand
|
||||
.command('dev')
|
||||
.command('dev [appPath]')
|
||||
.description('Watch and sync local application changes')
|
||||
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
|
||||
.action(async (options) => {
|
||||
await this.devCommand.execute(options);
|
||||
.action(async (appPath, options) => {
|
||||
await this.devCommand.execute({
|
||||
...options,
|
||||
appPath: formatPath(appPath),
|
||||
});
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('sync')
|
||||
.command('sync [appPath]')
|
||||
.description('Sync application to Twenty')
|
||||
.action(async () => {
|
||||
.action(async (appPath?: string) => {
|
||||
try {
|
||||
const result = await this.syncCommand.execute();
|
||||
const result = await this.syncCommand.execute(formatPath(appPath));
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -44,11 +48,12 @@ export class AppCommand {
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('delete')
|
||||
.command('delete [appPath]')
|
||||
.description('Delete application from Twenty')
|
||||
.action(async () => {
|
||||
.action(async (appPath?: string) => {
|
||||
try {
|
||||
const result = await this.deleteCommand.execute({
|
||||
appPath: formatPath(appPath),
|
||||
askForConfirmation: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
|
||||
@@ -5,9 +5,20 @@
|
||||
# You can access them directly in your code using:
|
||||
# const myValue = process.env.KEY;
|
||||
#
|
||||
# To make these variables available to your application,
|
||||
# add them to package.json "env" key. This "env" key defines all
|
||||
# environment variables that will be provided to your serverless
|
||||
# To make these variables available to your application, add them in application.config.ts file
|
||||
#
|
||||
# const config: ApplicationConfig = {
|
||||
# ...
|
||||
# applicationVariables: {
|
||||
# KEY: {
|
||||
# universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
|
||||
# description: 'Description',
|
||||
# isSecret: true,
|
||||
# },
|
||||
# },
|
||||
# };
|
||||
#
|
||||
# Those environment variables will be provided to your serverless
|
||||
# functions at runtime.
|
||||
#
|
||||
# Example:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "my-application",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8,7 +9,7 @@
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"twenty-sdk": "^0.0.2"
|
||||
"twenty-sdk": "0.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"include": ["**/*.ts"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@
|
||||
"type": "object",
|
||||
"title": "Environment Variable Definition",
|
||||
"properties": {
|
||||
"universalIdentifier": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier (UUID format recommended)",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Description for this environment variable."
|
||||
@@ -58,7 +63,6 @@
|
||||
"description": "If true, the value will be treated as sensitive and hidden from logs or UI."
|
||||
}
|
||||
},
|
||||
"required": ["isSecret"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
"title": "Twenty Serverless Function Manifest",
|
||||
"description": "Schema for Twenty AI serverless function configuration files",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"universalIdentifier",
|
||||
"name"
|
||||
],
|
||||
"required": ["universalIdentifier"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
"type": { "enum": ["cron", "databaseEvent", "route"] },
|
||||
|
||||
"schedule": { "type": "string" },
|
||||
"pattern": { "type": "string" },
|
||||
|
||||
"eventName": { "type": "string" },
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "cron" } } },
|
||||
"then": { "required": ["schedule"] }
|
||||
"then": { "required": ["pattern"] }
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "type": { "const": "databaseEvent" } } },
|
||||
|
||||
@@ -128,7 +128,7 @@ export class ApiService {
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.syncApplication,
|
||||
message: `Successfully synced application: ${manifest.name}`,
|
||||
message: `Successfully synced application: ${packageJson.name}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
@@ -141,15 +141,15 @@ export class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteApplication(packageJson: PackageJson): Promise<ApiResponse> {
|
||||
async deleteApplication(universalIdentifier: string): Promise<ApiResponse> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation DeleteApplication($packageJson: JSON!) {
|
||||
deleteApplication(packageJson: $packageJson)
|
||||
mutation DeleteApplication($universalIdentifier: String!) {
|
||||
deleteApplication(universalIdentifier: $universalIdentifier)
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = { packageJson };
|
||||
const variables = { universalIdentifier };
|
||||
|
||||
const response: AxiosResponse = await this.client.post(
|
||||
'/metadata',
|
||||
@@ -176,7 +176,7 @@ export class ApiService {
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.data.deleteApplication,
|
||||
message: `Successfully deleted application: ${packageJson.name}`,
|
||||
message: 'Successfully deleted application',
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
|
||||
@@ -5,79 +5,75 @@ export interface TwentyConfig {
|
||||
}
|
||||
|
||||
export type PackageJson = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
license: string;
|
||||
description?: string;
|
||||
engines: {
|
||||
node: string;
|
||||
npm: string;
|
||||
yarn: string;
|
||||
};
|
||||
packageManager: string;
|
||||
icon?: string;
|
||||
version: string;
|
||||
dependencies?: object;
|
||||
devDependencies?: object;
|
||||
};
|
||||
|
||||
export type AppManifest = PackageJson & {
|
||||
agents: AgentManifest[];
|
||||
objects: ObjectManifest[];
|
||||
serverlessFunctions: ServerlessFunctionManifest[];
|
||||
type ApplicationVariable = {
|
||||
universalIdentifier: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type CoreEntityManifest =
|
||||
| AgentManifest
|
||||
| ObjectManifest
|
||||
| ServerlessFunctionManifest;
|
||||
export type Application = {
|
||||
universalIdentifier: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
applicationVariables?: Record<string, ApplicationVariable>;
|
||||
};
|
||||
|
||||
export type AppManifest = {
|
||||
application: Application;
|
||||
objects: ObjectManifest[];
|
||||
serverlessFunctions: ServerlessFunctionManifest[];
|
||||
sources: Sources;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionManifest = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
triggers: ServerlessFunctionTriggerManifest[];
|
||||
code: ServerlessFunctionCodeManifest;
|
||||
handlerPath: string;
|
||||
handlerName: string;
|
||||
};
|
||||
|
||||
export enum HTTPMethod {
|
||||
GET = 'GET',
|
||||
POST = 'POST',
|
||||
PUT = 'PUT',
|
||||
PATCH = 'PATCH',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export type ServerlessFunctionTriggerManifest =
|
||||
| {
|
||||
type: 'cron';
|
||||
schedule: string;
|
||||
}
|
||||
| {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
}
|
||||
| {
|
||||
type: 'route';
|
||||
path: string;
|
||||
httpMethod: HTTPMethod;
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
type Sources = { [key: string]: string | Sources };
|
||||
|
||||
export type ServerlessFunctionCodeManifest = {
|
||||
src: {
|
||||
'index.ts': string;
|
||||
} & Sources;
|
||||
export type DatabaseEventTrigger = {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
};
|
||||
|
||||
export type CronTrigger = {
|
||||
type: 'cron';
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
export type RouteTrigger = {
|
||||
type: 'route';
|
||||
path: string;
|
||||
httpMethod: string;
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionTriggerManifest = {
|
||||
universalIdentifier: string;
|
||||
} & (CronTrigger | DatabaseEventTrigger | RouteTrigger);
|
||||
|
||||
export type Sources = { [key: string]: string | Sources };
|
||||
|
||||
export type ObjectManifest = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
@@ -87,24 +83,6 @@ export type ObjectManifest = {
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type AgentManifest = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
responseFormat?: AgentResponseFormat;
|
||||
};
|
||||
|
||||
export interface AgentResponseFormat {
|
||||
type: 'json' | 'text';
|
||||
schema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getDecoratedClass } from '../../utils/get-decorated-class';
|
||||
import { getObjectMetadataDecoratedClass } from '../../utils/get-object-metadata-decorated-class';
|
||||
|
||||
describe('getDecoratedClass', () => {
|
||||
it('should return properly formatted class', () => {
|
||||
const result = getDecoratedClass({
|
||||
const result = getObjectMetadataDecoratedClass({
|
||||
data: { nameSingular: 'Name', namePlural: 'Names' },
|
||||
name: 'MyNewObject',
|
||||
});
|
||||
|
||||
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk';
|
||||
const expectedResult = `import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
|
||||
@ObjectMetadata({
|
||||
nameSingular: 'Name',
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { ensureDirSync, writeFileSync, removeSync } from 'fs-extra';
|
||||
import { copyBaseApplicationProject } from '../app-template';
|
||||
import { loadManifest } from '../load-manifest';
|
||||
|
||||
const write = (root: string, file: string, content: string) => {
|
||||
const abs = join(root, file);
|
||||
ensureDirSync(resolve(abs, '..'));
|
||||
writeFileSync(abs, content, 'utf8');
|
||||
};
|
||||
|
||||
const tsLibMock = `declare module 'tslib' {
|
||||
export const __decorate: any;
|
||||
export const __metadata: any;
|
||||
export const __param: any;
|
||||
export const __awaiter: any;
|
||||
export const __read: any;
|
||||
export const __spread: any;
|
||||
export const __spreadArray: any;
|
||||
export const __assign: any;
|
||||
}`;
|
||||
const twentySdkTypesMock = `
|
||||
declare module 'twenty-sdk/application' {
|
||||
export type SyncableEntityOptions = { universalIdentifier: string };
|
||||
|
||||
type ApplicationVariable = SyncableEntityOptions & {
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type ApplicationConfig = SyncableEntityOptions & {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
applicationVariables?: Record<string, ApplicationVariable>;
|
||||
};
|
||||
|
||||
type RouteTrigger = {
|
||||
type: 'route';
|
||||
path: string;
|
||||
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
type CronTrigger = {
|
||||
type: 'cron';
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
type DatabaseEventTrigger = {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
};
|
||||
|
||||
type ServerlessFunctionTrigger = SyncableEntityOptions &
|
||||
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
|
||||
|
||||
export type ServerlessFunctionConfig = SyncableEntityOptions & {
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
triggers?: ServerlessFunctionTrigger[];
|
||||
};
|
||||
|
||||
type ObjectMetadataOptions = SyncableEntityOptions & {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export const ObjectMetadata = (_: ObjectMetadataOptions): ClassDecorator => {
|
||||
return () => {};
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
const serverlessFunctionMock = `
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
|
||||
export const main = async (params: any): Promise<any> => {
|
||||
return {};
|
||||
}
|
||||
|
||||
export const config: ServerlessFunctionConfig = {
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'hello',
|
||||
timeoutSeconds: 2,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route',
|
||||
path: '/post-card/create',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
type: 'cron',
|
||||
pattern: '0 0 1 1 *', // Every year 1st of January
|
||||
},
|
||||
{
|
||||
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'person.created'
|
||||
}
|
||||
]
|
||||
};`;
|
||||
|
||||
const objectMock = `import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
|
||||
@ObjectMetadata({
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post card',
|
||||
labelPlural: 'Post cards',
|
||||
description: ' A post card object',
|
||||
icon: 'IconMail',
|
||||
})
|
||||
export class PostCard {}
|
||||
`;
|
||||
|
||||
describe('loadManifest (integration)', () => {
|
||||
const appName = 'my-app';
|
||||
const appDisplayName = 'My App';
|
||||
const appDescription = 'My app description';
|
||||
const appDirectory = join(tmpdir(), 'twenty-manifest-');
|
||||
|
||||
beforeEach(async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
|
||||
write(appDirectory, 'src/Account.ts', objectMock);
|
||||
|
||||
write(appDirectory, 'src/hello.ts', serverlessFunctionMock);
|
||||
|
||||
write(
|
||||
appDirectory,
|
||||
'src/types/twenty-sdk-application.d.ts',
|
||||
twentySdkTypesMock,
|
||||
);
|
||||
|
||||
write(
|
||||
appDirectory,
|
||||
'src/types/tslib.d.ts',
|
||||
// minimal + future-proof
|
||||
tsLibMock,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeSync(appDirectory);
|
||||
});
|
||||
|
||||
it('builds a full manifest for a valid workspace', async () => {
|
||||
const { packageJson, yarnLock, manifest } =
|
||||
await loadManifest(appDirectory);
|
||||
|
||||
expect(packageJson.name).toBe('my-app');
|
||||
expect(packageJson.version).toBe('0.0.1');
|
||||
expect(packageJson.license).toBe('MIT');
|
||||
expect(yarnLock).toContain('# This file is generated by running ');
|
||||
|
||||
// application
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { universalIdentifier: _, ...otherInfo } = manifest.application;
|
||||
expect(otherInfo).toEqual({
|
||||
displayName: 'My App',
|
||||
description: 'My app description',
|
||||
});
|
||||
|
||||
// objects collected from @ObjectMetadata
|
||||
for (const object of manifest.objects) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { universalIdentifier: _, ...otherInfo } = object;
|
||||
expect(otherInfo).toEqual({
|
||||
description: ' A post card object',
|
||||
icon: 'IconMail',
|
||||
labelPlural: 'Post cards',
|
||||
labelSingular: 'Post card',
|
||||
namePlural: 'postCards',
|
||||
nameSingular: 'postCard',
|
||||
});
|
||||
}
|
||||
|
||||
// serverless functions
|
||||
for (const serverlessFunction of manifest.serverlessFunctions) {
|
||||
const {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
universalIdentifier: _,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
handlerPath: __,
|
||||
triggers,
|
||||
...otherInfo
|
||||
} = serverlessFunction;
|
||||
|
||||
expect(otherInfo).toEqual({
|
||||
handlerName: 'main',
|
||||
name: 'hello',
|
||||
timeoutSeconds: 2,
|
||||
});
|
||||
|
||||
for (const trigger of triggers) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { universalIdentifier: _, ...otherInfo } = trigger;
|
||||
switch (trigger.type) {
|
||||
case 'route':
|
||||
expect(otherInfo).toEqual({
|
||||
isAuthRequired: false,
|
||||
httpMethod: 'GET',
|
||||
path: '/post-card/create',
|
||||
type: 'route',
|
||||
});
|
||||
break;
|
||||
case 'cron':
|
||||
expect(otherInfo).toEqual({
|
||||
pattern: '0 0 1 1 *',
|
||||
type: 'cron',
|
||||
});
|
||||
break;
|
||||
case 'databaseEvent':
|
||||
expect(otherInfo).toEqual({
|
||||
eventName: 'person.created',
|
||||
type: 'databaseEvent',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not define serverless for util file', async () => {
|
||||
write(
|
||||
appDirectory,
|
||||
'src/utils/format.ts',
|
||||
`
|
||||
export const format = async (params: any): Promise<any> => {
|
||||
return {};
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const { manifest } = await loadManifest(appDirectory);
|
||||
expect(manifest.serverlessFunctions.length).toBe(1);
|
||||
});
|
||||
|
||||
it('manifest should contains typescript sources', async () => {
|
||||
const { manifest } = await loadManifest(appDirectory);
|
||||
// the method is already exercised in loadManifest; just assert again:
|
||||
expect(Object.keys(manifest.sources)).toEqual([
|
||||
'application.config.ts',
|
||||
'src',
|
||||
]);
|
||||
expect(Object.keys(manifest.sources['src'])).toEqual([
|
||||
'Account.ts',
|
||||
'hello.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails fast if TS validation fails', async () => {
|
||||
write(appDirectory, 'src/utils/broken.ts', `const x: number = 'oops';`);
|
||||
|
||||
await expect(loadManifest(appDirectory)).rejects.toThrow(
|
||||
/TypeScript validation failed/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import dotenv from 'dotenv';
|
||||
import assert from 'assert';
|
||||
import * as fs from 'fs-extra';
|
||||
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 };
|
||||
|
||||
const findPathFile = async (
|
||||
appPath: string,
|
||||
fileName: string,
|
||||
): Promise<string> => {
|
||||
const jsonPath = path.join(appPath, fileName);
|
||||
|
||||
if (await fs.pathExists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
throw new Error(`${fileName} not found in ${appPath}`);
|
||||
};
|
||||
|
||||
const loadCoreEntity = async (
|
||||
coreEntityPath: string,
|
||||
validator: (manifest: CoreEntityManifest, path: string) => Promise<void>,
|
||||
): Promise<CoreEntityManifest[]> => {
|
||||
const coreEntities: CoreEntityManifest[] = [];
|
||||
|
||||
if (await fs.pathExists(coreEntityPath)) {
|
||||
const entities = await fs.readdir(coreEntityPath);
|
||||
|
||||
for (const entity of entities) {
|
||||
const entityPath = path.join(coreEntityPath, entity);
|
||||
const entityResources = await fs.readdir(entityPath);
|
||||
|
||||
const entityManifests = entityResources.filter(
|
||||
(file) =>
|
||||
file.endsWith('.manifest.jsonc') || file.endsWith('.manifest.json'),
|
||||
);
|
||||
|
||||
assert(
|
||||
entityManifests.length === 1,
|
||||
'Entity should have strictly one manifest file',
|
||||
);
|
||||
|
||||
const entityManifest = entityManifests[0];
|
||||
|
||||
const coreEntityManifest = await parseJsoncFile(
|
||||
path.join(coreEntityPath, entity, entityManifest),
|
||||
);
|
||||
|
||||
const entitySources = entityResources.filter(
|
||||
(folder) => folder === 'src',
|
||||
);
|
||||
|
||||
assert(
|
||||
entitySources.length <= 1,
|
||||
'Entity should have less than one src folder or file',
|
||||
);
|
||||
|
||||
if (entitySources.length === 1) {
|
||||
const entitySourcePath = path.join(
|
||||
coreEntityPath,
|
||||
entity,
|
||||
entitySources[0],
|
||||
);
|
||||
|
||||
const sources = await loadFolderContentIntoJson(entitySourcePath);
|
||||
|
||||
coreEntityManifest['code'] = { src: sources };
|
||||
}
|
||||
|
||||
await validator(coreEntityManifest, coreEntityPath);
|
||||
|
||||
coreEntities.push(coreEntityManifest);
|
||||
}
|
||||
}
|
||||
|
||||
return coreEntities;
|
||||
};
|
||||
|
||||
const loadFolderContentIntoJson = async (
|
||||
sourcePath: string,
|
||||
): Promise<Sources> => {
|
||||
const sources: Sources = {};
|
||||
|
||||
const resources = await fs.readdir(sourcePath);
|
||||
|
||||
for (const resource of resources) {
|
||||
const resourcePath = path.join(sourcePath, resource);
|
||||
const stats = await fs.stat(resourcePath);
|
||||
if (stats.isFile()) {
|
||||
sources[resource] = await fs.readFile(resourcePath, 'utf8');
|
||||
} else {
|
||||
sources[resource] = await loadFolderContentIntoJson(resourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
};
|
||||
|
||||
export const loadManifest = async (
|
||||
appPath: string,
|
||||
): Promise<{
|
||||
packageJson: PackageJson;
|
||||
yarnLock: string;
|
||||
manifest: AppManifest;
|
||||
}> => {
|
||||
const packageJsonPath = await findPathFile(appPath, 'package.json');
|
||||
|
||||
const rawPackageJson = await parseJsoncFile(packageJsonPath);
|
||||
|
||||
const yarnLockPath = await findPathFile(appPath, 'yarn.lock');
|
||||
|
||||
const rawYarnLock = await fs.readFile(yarnLockPath, 'utf8');
|
||||
|
||||
let envFile = '';
|
||||
|
||||
try {
|
||||
const envFilePath = await findPathFile(appPath, '.env');
|
||||
|
||||
envFile = await fs.readFile(envFilePath, 'utf8');
|
||||
} catch {
|
||||
// Allow missing .env
|
||||
}
|
||||
|
||||
const envVariables = dotenv.parse(envFile);
|
||||
|
||||
const packageJsonEnv = rawPackageJson.env || {};
|
||||
|
||||
for (const key of Object.keys(envVariables)) {
|
||||
if (packageJsonEnv[key]) {
|
||||
packageJsonEnv[key] = {
|
||||
isSecret: false,
|
||||
...packageJsonEnv[key],
|
||||
value: envVariables[key],
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Environment variable "${key}" is defined in .env but missing from package.json. Please add it to the "env" section in package.json.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const packageJson = { ...rawPackageJson, env: packageJsonEnv };
|
||||
|
||||
await validateSchema('appManifest', packageJson, packageJsonPath);
|
||||
|
||||
const agents = await loadCoreEntity(
|
||||
path.join(appPath, 'agents'),
|
||||
(manifest, path) => validateSchema('agent', manifest, path),
|
||||
);
|
||||
|
||||
const objectFromManifests = await loadCoreEntity(
|
||||
path.join(appPath, 'objects'),
|
||||
(manifest, path) => validateSchema('object', manifest, path),
|
||||
);
|
||||
|
||||
const serverlessFunctions = await loadCoreEntity(
|
||||
path.join(appPath, 'serverlessFunctions'),
|
||||
(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,
|
||||
manifest: {
|
||||
...packageJson,
|
||||
agents,
|
||||
objects,
|
||||
serverlessFunctions,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,13 +5,16 @@ import { BASE_APPLICATION_PROJECT_PATH } from '../constants/constants-path';
|
||||
import { writeJsoncFile } from '../utils/jsonc-parser';
|
||||
import { join } from 'path';
|
||||
import path from 'path';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const copyBaseApplicationProject = async ({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
@@ -26,24 +29,50 @@ export const copyBaseApplicationProject = async ({
|
||||
|
||||
await createBasePackageJson({
|
||||
appName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
displayName: appDisplayName,
|
||||
description: appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
|
||||
await createReadmeContent({
|
||||
appName,
|
||||
displayName: appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
};
|
||||
|
||||
const createApplicationConfig = async ({
|
||||
displayName,
|
||||
description,
|
||||
appDirectory,
|
||||
}: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const content = `import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '${v4()}',
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
};
|
||||
|
||||
export default config;
|
||||
`;
|
||||
|
||||
await fs.writeFile(path.join(appDirectory, 'application.config.ts'), content);
|
||||
};
|
||||
|
||||
const createBasePackageJson = async ({
|
||||
appName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
const base = JSON.parse(await readBaseApplicationProjectFile('package.json'));
|
||||
@@ -52,27 +81,23 @@ const createBasePackageJson = async ({
|
||||
|
||||
base['$schema'] = schemas.appManifest;
|
||||
base['universalIdentifier'] = randomUUID();
|
||||
base['name'] = appName
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
base['description'] = appDescription;
|
||||
base['name'] = appName;
|
||||
|
||||
await writeJsoncFile(join(appDirectory, 'package.json'), base);
|
||||
};
|
||||
|
||||
const createReadmeContent = async ({
|
||||
appName,
|
||||
displayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
}: {
|
||||
appName: string;
|
||||
displayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
let readmeContent = await readBaseApplicationProjectFile('README.md');
|
||||
|
||||
readmeContent = readmeContent.replace(/\{title}/g, appName);
|
||||
readmeContent = readmeContent.replace(/\{title}/g, displayName);
|
||||
|
||||
readmeContent = readmeContent.replace(/\{description}/g, appDescription);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from 'path';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
export const findPathFile = async (
|
||||
appPath: string,
|
||||
fileName: string,
|
||||
): Promise<string> => {
|
||||
const jsonPath = path.join(appPath, fileName);
|
||||
|
||||
if (await fs.pathExists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
throw new Error(`${fileName} not found in ${appPath}`);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { join } from 'path';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
|
||||
export const formatPath = (appPath?: string) => {
|
||||
return appPath && !appPath?.startsWith('/')
|
||||
? join(CURRENT_EXECUTION_DIRECTORY, appPath)
|
||||
: appPath;
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import camelcase from 'lodash.camelcase';
|
||||
|
||||
export const getDecoratedClass = ({
|
||||
export const getObjectMetadataDecoratedClass = ({
|
||||
data,
|
||||
name,
|
||||
}: {
|
||||
@@ -15,7 +15,7 @@ export const getDecoratedClass = ({
|
||||
|
||||
const className = camelCaseName[0].toUpperCase() + camelCaseName.slice(1);
|
||||
|
||||
return `import { ObjectMetadata } from 'twenty-sdk';
|
||||
return `import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
|
||||
@ObjectMetadata({
|
||||
${decoratorOptions}
|
||||
@@ -0,0 +1,29 @@
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const getServerlessFunctionBaseFile = ({ name }: { name: string }) => {
|
||||
const kebabCaseName = kebabCase(name);
|
||||
|
||||
return `import { ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
|
||||
export const main = async (params: {
|
||||
a: string;
|
||||
b: number;
|
||||
}): Promise<{ message: string }> => {
|
||||
const { a, b } = params;
|
||||
|
||||
// Rename the parameters and code below with your own logic
|
||||
// This is just an example
|
||||
const message = \`Hello, input: $\{a} and $\{b}\`;
|
||||
|
||||
return { message };
|
||||
};
|
||||
|
||||
export const config: ServerlessFunctionConfig = {
|
||||
universalIdentifier: '${v4()}',
|
||||
name: '${kebabCaseName}',
|
||||
timeoutSeconds: 5,
|
||||
};
|
||||
|
||||
`;
|
||||
};
|
||||
@@ -43,6 +43,10 @@ export const parseJsoncString = (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const parseTextFile = async (filePath: string) => {
|
||||
return await fs.readFile(filePath, 'utf8');
|
||||
};
|
||||
|
||||
export const parseJsoncFile = async (
|
||||
filePath: string,
|
||||
options: JsoncParseOptions = {},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import dotenv from 'dotenv';
|
||||
import { findPathFile } from './find-path-file';
|
||||
|
||||
export const loadEnvVariables = async (appPath: string) => {
|
||||
let envFile = '';
|
||||
|
||||
try {
|
||||
const envFilePath = await findPathFile(appPath, '.env');
|
||||
|
||||
envFile = await fs.readFile(envFilePath, 'utf8');
|
||||
} catch {
|
||||
// Allow missing .env
|
||||
}
|
||||
|
||||
return dotenv.parse(envFile);
|
||||
};
|
||||
@@ -1,175 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,476 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import {
|
||||
sys,
|
||||
getDecorators,
|
||||
readConfigFile,
|
||||
parseJsonConfigFileContent,
|
||||
formatDiagnosticsWithColorAndContext,
|
||||
createProgram,
|
||||
Decorator,
|
||||
isPropertyAccessExpression,
|
||||
isNumericLiteral,
|
||||
SyntaxKind,
|
||||
isArrayLiteralExpression,
|
||||
Expression,
|
||||
isPropertyAssignment,
|
||||
isComputedPropertyName,
|
||||
isStringLiteralLike,
|
||||
isShorthandPropertyAssignment,
|
||||
isIdentifier,
|
||||
FunctionDeclaration,
|
||||
VariableDeclaration,
|
||||
Program,
|
||||
Node,
|
||||
isClassDeclaration,
|
||||
isCallExpression,
|
||||
isObjectLiteralExpression,
|
||||
forEachChild,
|
||||
SourceFile,
|
||||
isVariableStatement,
|
||||
isArrowFunction,
|
||||
isFunctionExpression,
|
||||
isExportAssignment,
|
||||
Modifier,
|
||||
} from 'typescript';
|
||||
import {
|
||||
AppManifest,
|
||||
Application,
|
||||
ObjectManifest,
|
||||
PackageJson,
|
||||
ServerlessFunctionManifest,
|
||||
Sources,
|
||||
} from '../types/config.types';
|
||||
import { posix, relative, sep, resolve, join } from 'path';
|
||||
import { parseJsoncFile, parseTextFile } from '../utils/jsonc-parser';
|
||||
import { findPathFile } from '../utils/find-path-file';
|
||||
|
||||
type JSONValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JSONValue[]
|
||||
| { [k: string]: JSONValue };
|
||||
|
||||
const getProgramFromTsconfig = (
|
||||
appPath?: string,
|
||||
tsconfigPath = 'tsconfig.json',
|
||||
) => {
|
||||
const basePath = appPath ?? process.cwd();
|
||||
const configFile = readConfigFile(join(basePath, 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 getFirstArgObject = (dec: Decorator) => {
|
||||
if (!isCallExpression(dec.expression)) return undefined;
|
||||
const [firstArg] = dec.expression.arguments;
|
||||
return firstArg && isObjectLiteralExpression(firstArg)
|
||||
? (exprToValue(firstArg) as Record<string, JSONValue>)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
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) {
|
||||
const cfg = getFirstArgObject(objectDec);
|
||||
if (cfg && typeof cfg === 'object' && !Array.isArray(cfg)) {
|
||||
manifest.push({ ...(cfg as any) } as ObjectManifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sf);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
};
|
||||
|
||||
// Add if you want a small guard for "export" presence on statements
|
||||
const hasExportModifier = (st: any) =>
|
||||
st.modifiers?.some((m: Modifier) => m.kind === SyntaxKind.ExportKeyword) ??
|
||||
false;
|
||||
|
||||
/**
|
||||
* Finds (and validates) the new serverless file shape:
|
||||
* - exactly 2 exported bindings
|
||||
* - one must be `config` (typed ServerlessFunctionConfig)
|
||||
* - the other must be a function (exported function declaration, or const initialized with arrow/function expression)
|
||||
*/
|
||||
const findHandlerAndConfig = (
|
||||
sf: SourceFile,
|
||||
): {
|
||||
handlerName: ServerlessFunctionManifest['handlerName'];
|
||||
configObject: Pick<
|
||||
ServerlessFunctionManifest,
|
||||
| 'universalIdentifier'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'timeoutSeconds'
|
||||
| 'triggers'
|
||||
>;
|
||||
} => {
|
||||
type Exported = {
|
||||
name: string;
|
||||
kind: 'function' | 'const';
|
||||
init?: Expression;
|
||||
declNode: Node;
|
||||
};
|
||||
|
||||
const exported: Exported[] = [];
|
||||
|
||||
// 1) export const X = <arrow|function expr>
|
||||
for (const st of sf.statements) {
|
||||
if (!isVariableStatement(st) || !hasExportModifier(st)) continue;
|
||||
|
||||
for (const decl of st.declarationList.declarations) {
|
||||
if (!isIdentifier(decl.name)) continue;
|
||||
|
||||
const name = decl.name.text;
|
||||
const init = decl.initializer ?? undefined;
|
||||
|
||||
exported.push({
|
||||
name,
|
||||
kind: 'const',
|
||||
init,
|
||||
declNode: decl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2) export function X() { ... }
|
||||
for (const st of sf.statements) {
|
||||
if (st.kind === SyntaxKind.FunctionDeclaration && hasExportModifier(st)) {
|
||||
const fd = st as FunctionDeclaration;
|
||||
if (fd.name && isIdentifier(fd.name)) {
|
||||
exported.push({
|
||||
name: fd.name.text,
|
||||
kind: 'function',
|
||||
init: undefined,
|
||||
declNode: fd,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce exactly two exports
|
||||
const unique = Array.from(new Map(exported.map((e) => [e.name, e])).values());
|
||||
if (unique.length !== 2) {
|
||||
throw new Error(
|
||||
`Serverless file ${sf.fileName} must export exactly 2 bindings (handler + config). Found: ${unique.map((e) => e.name).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Find config
|
||||
const configExport = unique.find((e) => e.name === 'config');
|
||||
if (!configExport) {
|
||||
throw new Error(
|
||||
`Serverless file ${sf.fileName} must export a binding named "config".`,
|
||||
);
|
||||
}
|
||||
// Must be initialized to an object literal
|
||||
if (!configExport.init || !isObjectLiteralExpression(configExport.init)) {
|
||||
throw new Error(
|
||||
`"config" in ${sf.fileName} must be initialized to an object literal.`,
|
||||
);
|
||||
}
|
||||
// (Light) type guard: ensure declared type mentions ServerlessFunctionConfig if present
|
||||
const maybeVarDecl = configExport.declNode as VariableDeclaration;
|
||||
if ('type' in maybeVarDecl && maybeVarDecl.type) {
|
||||
const typeText = maybeVarDecl.type.getText(sf);
|
||||
if (!/\bServerlessFunctionConfig\b/.test(typeText)) {
|
||||
throw new Error(
|
||||
`"config" in ${sf.fileName} must be typed as ServerlessFunctionConfig (got: ${typeText}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const configObject = exprToValue(configExport.init) as Pick<
|
||||
ServerlessFunctionManifest,
|
||||
| 'universalIdentifier'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'timeoutSeconds'
|
||||
| 'triggers'
|
||||
>;
|
||||
|
||||
// Identify the handler: the other export
|
||||
const handlerExport = unique.find((e) => e.name !== 'config');
|
||||
if (!handlerExport) {
|
||||
throw new Error(`Could not find the handler export in ${sf.fileName}.`);
|
||||
}
|
||||
|
||||
// If it's a const, make sure it’s a function-ish initializer
|
||||
if (handlerExport.kind === 'const') {
|
||||
const init = handlerExport.init;
|
||||
const isFuncLike =
|
||||
!!init && (isArrowFunction(init) || isFunctionExpression(init));
|
||||
if (!isFuncLike) {
|
||||
throw new Error(
|
||||
`Handler "${handlerExport.name}" in ${sf.fileName} must be a function (arrow or function expression).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handlerName: handlerExport.name,
|
||||
configObject,
|
||||
};
|
||||
};
|
||||
|
||||
const posixRelativeFromCwd = (absPath: string) => {
|
||||
const rel = relative(process.cwd(), absPath);
|
||||
// normalize to posix separators for portability / manifest stability
|
||||
return rel.split(sep).join(posix.sep);
|
||||
};
|
||||
|
||||
const collectServerlessFunctions = (program: Program) => {
|
||||
const serverlessFunctions: ServerlessFunctionManifest[] = [];
|
||||
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
if (sf.isDeclarationFile) continue;
|
||||
|
||||
try {
|
||||
const { handlerName, configObject } = findHandlerAndConfig(sf);
|
||||
|
||||
const handlerPath = posixRelativeFromCwd(sf.fileName);
|
||||
|
||||
serverlessFunctions.push({
|
||||
...configObject,
|
||||
handlerPath,
|
||||
handlerName,
|
||||
});
|
||||
} catch {
|
||||
// Not a serverless file under the new format — ignore and continue scanning.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return serverlessFunctions;
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
const setNested = (root: Sources, parts: string[], value: string) => {
|
||||
let cur: Sources = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const key = parts[i];
|
||||
if (i === parts.length - 1) {
|
||||
cur[key] = value;
|
||||
} else {
|
||||
cur[key] = (cur[key] ?? {}) as Sources;
|
||||
cur = cur[key] as Sources;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadFolderContentIntoJson = async (
|
||||
sourcePath = '.',
|
||||
tsconfigPath = 'tsconfig.json',
|
||||
): Promise<Sources> => {
|
||||
const sources: Sources = {};
|
||||
const baseAbs = resolve(sourcePath);
|
||||
|
||||
// Build the program from tsconfig (uses your getProgramFromTsconfig)
|
||||
const program: Program = getProgramFromTsconfig(baseAbs, tsconfigPath);
|
||||
|
||||
// Iterate only files the TS program knows about.
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
const abs = sf.fileName;
|
||||
|
||||
// Skip .d.ts and anything outside sourcePath
|
||||
if (sf.isDeclarationFile) continue;
|
||||
if (!abs.startsWith(baseAbs + sep) && abs !== baseAbs) continue;
|
||||
|
||||
// Keep only TS/TSX files
|
||||
if (!(abs.endsWith('.ts') || abs.endsWith('.tsx'))) continue;
|
||||
|
||||
// Optional extra guard (usually unnecessary if tsconfig excludes node_modules)
|
||||
if (abs.includes(`${sep}node_modules${sep}`)) continue;
|
||||
|
||||
const relFromRoot = relative(baseAbs, abs);
|
||||
const parts = relFromRoot.split(sep);
|
||||
|
||||
const content = await fs.readFile(abs, 'utf8');
|
||||
setNested(sources, parts, content);
|
||||
}
|
||||
|
||||
return sources;
|
||||
};
|
||||
|
||||
export const extractTwentyAppConfig = (program: Program): Application => {
|
||||
for (const sf of program.getSourceFiles()) {
|
||||
if (sf.isDeclarationFile || !sf.fileName.endsWith('application.config.ts'))
|
||||
continue;
|
||||
|
||||
let found: Application | undefined;
|
||||
|
||||
const visit = (node: any): void => {
|
||||
// Look for "export default twentyAppConfig"
|
||||
if (isExportAssignment(node) && isIdentifier(node.expression)) {
|
||||
const varName = node.expression.text;
|
||||
|
||||
// find the corresponding variable declaration
|
||||
for (const stmt of sf.statements) {
|
||||
if (isVariableStatement(stmt)) {
|
||||
for (const decl of stmt.declarationList.declarations) {
|
||||
if (isIdentifier(decl.name) && decl.name.text === varName) {
|
||||
if (
|
||||
decl.initializer &&
|
||||
isObjectLiteralExpression(decl.initializer)
|
||||
) {
|
||||
found = exprToValue(decl.initializer) as Application;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sf);
|
||||
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
throw new Error('Could not find default exported ApplicationConfig');
|
||||
};
|
||||
|
||||
export const loadManifest = async (
|
||||
path?: string,
|
||||
): Promise<{
|
||||
packageJson: PackageJson;
|
||||
yarnLock: string;
|
||||
manifest: AppManifest;
|
||||
}> => {
|
||||
const appPath = path ?? process.cwd();
|
||||
|
||||
const packageJson = await parseJsoncFile(
|
||||
await findPathFile(appPath, 'package.json'),
|
||||
);
|
||||
|
||||
const yarnLock = await parseTextFile(
|
||||
await findPathFile(appPath, 'yarn.lock'),
|
||||
);
|
||||
|
||||
const program = getProgramFromTsconfig(appPath, 'tsconfig.json');
|
||||
|
||||
validateProgram(program);
|
||||
|
||||
const [objects, serverlessFunctions, application, sources] =
|
||||
await Promise.all([
|
||||
Promise.resolve(collectObjects(program)),
|
||||
Promise.resolve(collectServerlessFunctions(program)),
|
||||
Promise.resolve(extractTwentyAppConfig(program)),
|
||||
loadFolderContentIntoJson(appPath),
|
||||
]);
|
||||
|
||||
return {
|
||||
packageJson,
|
||||
yarnLock,
|
||||
manifest: {
|
||||
application,
|
||||
objects,
|
||||
serverlessFunctions,
|
||||
sources,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -2131,7 +2131,7 @@ export type MutationDeactivateWorkflowVersionArgs = {
|
||||
|
||||
|
||||
export type MutationDeleteApplicationArgs = {
|
||||
packageJson: Scalars['JSON'];
|
||||
universalIdentifier: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
@@ -3786,6 +3786,8 @@ export type ServerlessFunction = {
|
||||
cronTriggers?: Maybe<Array<CronTrigger>>;
|
||||
databaseEventTriggers?: Maybe<Array<DatabaseEventTrigger>>;
|
||||
description?: Maybe<Scalars['String']>;
|
||||
handlerName: Scalars['String'];
|
||||
handlerPath: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
latestVersion?: Maybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
@@ -4222,6 +4224,8 @@ export type UpdateServerlessFunctionInput = {
|
||||
export type UpdateServerlessFunctionInputUpdates = {
|
||||
code: Scalars['JSON'];
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
handlerName?: InputMaybe<Scalars['String']>;
|
||||
handlerPath?: InputMaybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
timeoutSeconds?: InputMaybe<Scalars['Float']>;
|
||||
};
|
||||
@@ -4901,7 +4905,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{
|
||||
|
||||
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
|
||||
|
||||
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
|
||||
export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -4913,7 +4917,7 @@ export type FindOneApplicationQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } };
|
||||
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } };
|
||||
|
||||
export type UploadFileMutationVariables = Exact<{
|
||||
file: Scalars['Upload'];
|
||||
@@ -5739,21 +5743,21 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
|
||||
|
||||
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
|
||||
|
||||
export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null };
|
||||
export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null };
|
||||
|
||||
export type CreateOneServerlessFunctionItemMutationVariables = Exact<{
|
||||
input: CreateServerlessFunctionInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type DeleteOneServerlessFunctionMutationVariables = Exact<{
|
||||
input: ServerlessFunctionIdInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type ExecuteOneServerlessFunctionMutationVariables = Exact<{
|
||||
input: ExecuteServerlessFunctionInput;
|
||||
@@ -5767,14 +5771,14 @@ export type PublishOneServerlessFunctionMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type UpdateOneServerlessFunctionMutationVariables = Exact<{
|
||||
input: UpdateServerlessFunctionInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type FindManyAvailablePackagesQueryVariables = Exact<{
|
||||
input: ServerlessFunctionIdInput;
|
||||
@@ -5786,14 +5790,14 @@ export type FindManyAvailablePackagesQuery = { __typename?: 'Query', getAvailabl
|
||||
export type GetManyServerlessFunctionsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
|
||||
export type GetOneServerlessFunctionQueryVariables = Exact<{
|
||||
input: ServerlessFunctionIdInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{
|
||||
input: GetServerlessFunctionSourceCodeInput;
|
||||
@@ -6414,6 +6418,8 @@ export const ServerlessFunctionFieldsFragmentDoc = gql`
|
||||
timeoutSeconds
|
||||
latestVersion
|
||||
publishedVersions
|
||||
handlerPath
|
||||
handlerName
|
||||
cronTriggers {
|
||||
id
|
||||
settings
|
||||
|
||||
@@ -2067,7 +2067,7 @@ export type MutationDeactivateWorkflowVersionArgs = {
|
||||
|
||||
|
||||
export type MutationDeleteApplicationArgs = {
|
||||
packageJson: Scalars['JSON'];
|
||||
universalIdentifier: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
@@ -3632,6 +3632,8 @@ export type ServerlessFunction = {
|
||||
cronTriggers?: Maybe<Array<CronTrigger>>;
|
||||
databaseEventTriggers?: Maybe<Array<DatabaseEventTrigger>>;
|
||||
description?: Maybe<Scalars['String']>;
|
||||
handlerName: Scalars['String'];
|
||||
handlerPath: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
latestVersion?: Maybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
@@ -4060,6 +4062,8 @@ export type UpdateServerlessFunctionInput = {
|
||||
export type UpdateServerlessFunctionInputUpdates = {
|
||||
code: Scalars['JSON'];
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
handlerName?: InputMaybe<Scalars['String']>;
|
||||
handlerPath?: InputMaybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
timeoutSeconds?: InputMaybe<Scalars['Float']>;
|
||||
};
|
||||
|
||||
+2
@@ -9,6 +9,8 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql`
|
||||
timeoutSeconds
|
||||
latestVersion
|
||||
publishedVersions
|
||||
handlerPath
|
||||
handlerName
|
||||
cronTriggers {
|
||||
id
|
||||
settings
|
||||
|
||||
+17
-13
@@ -1,4 +1,3 @@
|
||||
import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName';
|
||||
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
|
||||
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
|
||||
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
|
||||
@@ -6,9 +5,10 @@ import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { type FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
|
||||
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
|
||||
import { type ServerlessFunction } from '~/generated/graphql';
|
||||
import { type Sources } from '@/serverless-functions/types/sources.type';
|
||||
import { flattenSources } from '@/serverless-functions/utils/flattenSources';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type ServerlessFunctionNewFormValues = {
|
||||
name: string;
|
||||
@@ -68,19 +68,23 @@ export const useServerlessFunctionUpdateFormState = ({
|
||||
}));
|
||||
|
||||
if (serverlessFunctionTestData.shouldInitInput) {
|
||||
const sourceCode =
|
||||
data?.getServerlessFunctionSourceCode?.[SOURCE_FOLDER_NAME]?.[
|
||||
INDEX_FILE_NAME
|
||||
];
|
||||
const flattenedCode = flattenSources(code);
|
||||
|
||||
const functionInput =
|
||||
await getFunctionInputFromSourceCode(sourceCode);
|
||||
const sourceCode = flattenedCode.find(
|
||||
(flatCode) => flatCode.path === serverlessFunction?.handlerPath,
|
||||
);
|
||||
|
||||
setServerlessFunctionTestData((prev) => ({
|
||||
...prev,
|
||||
input: functionInput,
|
||||
shouldInitInput: false,
|
||||
}));
|
||||
if (isDefined(sourceCode)) {
|
||||
const functionInput = await getFunctionInputFromSourceCode(
|
||||
sourceCode.content,
|
||||
);
|
||||
|
||||
setServerlessFunctionTestData((prev) => ({
|
||||
...prev,
|
||||
input: functionInput,
|
||||
shouldInitInput: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+13
-5
@@ -105,11 +105,19 @@ export const SettingsServerlessFunctionDetail = () => {
|
||||
|
||||
const flattenedCode = flattenSources(formValues.code);
|
||||
|
||||
const files = flattenedCode.map((file) => ({
|
||||
path: file.path,
|
||||
language: 'typescript',
|
||||
content: file.content,
|
||||
}));
|
||||
const files = flattenedCode
|
||||
.map((file) => ({
|
||||
path: file.path,
|
||||
language: 'typescript',
|
||||
content: file.content,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.path === serverlessFunction?.handlerPath
|
||||
? -1
|
||||
: b.path === serverlessFunction?.handlerPath
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
|
||||
const renderActiveTabContent = () => {
|
||||
switch (activeTabId) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.3",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"application"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
@@ -15,5 +16,24 @@
|
||||
"typescript": "5.9.2",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-dts": "3.8.1"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./application": {
|
||||
"types": "./dist/application/index.d.ts",
|
||||
"import": "./dist/application.mjs",
|
||||
"require": "./dist/application.cjs"
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"application": [
|
||||
"dist/application/index.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,36 @@
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/twenty-sdk/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:sdk"],
|
||||
"tags": [
|
||||
"scope:sdk"
|
||||
],
|
||||
"targets": {
|
||||
"build": {
|
||||
"outputs": ["{projectRoot}/dist"]
|
||||
"dependsOn": [
|
||||
"generateBarrels",
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/dist",
|
||||
"{projectRoot}/application/package.json",
|
||||
"{projectRoot}/application/dist"
|
||||
]
|
||||
},
|
||||
"generateBarrels": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"production",
|
||||
"{projectRoot}/scripts/generateBarrels.ts"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/src/index.ts",
|
||||
"{projectRoot}/src/*/index.ts",
|
||||
"{projectRoot}/package.json"
|
||||
],
|
||||
"options": {
|
||||
"command": "tsx {projectRoot}/scripts/generateBarrels.ts"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"configurations": {
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// @ts-ignore
|
||||
import prettier from '@prettier/sync';
|
||||
import * as fs from 'fs';
|
||||
import { globSync } from 'glob';
|
||||
// @ts-ignore
|
||||
import path from 'path';
|
||||
import { type Options } from 'prettier';
|
||||
import slash from 'slash';
|
||||
// @ts-ignore
|
||||
import ts from 'typescript';
|
||||
|
||||
// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI
|
||||
|
||||
const INDEX_FILENAME = 'index';
|
||||
const PACKAGE_JSON_FILENAME = 'package.json';
|
||||
const NX_PROJECT_CONFIGURATION_FILENAME = 'project.json';
|
||||
const PACKAGE_PATH = path.resolve('packages/twenty-sdk');
|
||||
const SRC_PATH = path.resolve(`${PACKAGE_PATH}/src`);
|
||||
const PACKAGE_JSON_PATH = path.join(PACKAGE_PATH, PACKAGE_JSON_FILENAME);
|
||||
const NX_PROJECT_CONFIGURATION_PATH = path.join(
|
||||
PACKAGE_PATH,
|
||||
NX_PROJECT_CONFIGURATION_FILENAME,
|
||||
);
|
||||
|
||||
const prettierConfigFile = prettier.resolveConfigFile();
|
||||
if (prettierConfigFile == null) {
|
||||
throw new Error('Prettier config file not found');
|
||||
}
|
||||
const prettierConfiguration = prettier.resolveConfig(prettierConfigFile);
|
||||
const prettierFormat = (str: string, parser: Options['parser']) =>
|
||||
prettier.format(str, {
|
||||
...prettierConfiguration,
|
||||
parser,
|
||||
});
|
||||
type createTypeScriptFileArgs = {
|
||||
path: string;
|
||||
content: string;
|
||||
filename: string;
|
||||
};
|
||||
const createTypeScriptFile = ({
|
||||
content,
|
||||
path: filePath,
|
||||
filename,
|
||||
}: createTypeScriptFileArgs) => {
|
||||
const header = `
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
|
||||
* | | \\ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, |
|
||||
* |___/
|
||||
*/
|
||||
`;
|
||||
const formattedContent = prettierFormat(
|
||||
`${header}\n${content}\n`,
|
||||
'typescript',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(filePath, `${filename}.ts`),
|
||||
formattedContent,
|
||||
'utf-8',
|
||||
);
|
||||
};
|
||||
|
||||
const getLastPathFolder = (pathStr: string) => path.basename(pathStr);
|
||||
|
||||
const getSubDirectoryPaths = (directoryPath: string): string[] => {
|
||||
const pattern = slash(path.join(directoryPath, '*/'));
|
||||
return globSync(pattern, {
|
||||
ignore: [...EXCLUDED_DIRECTORIES],
|
||||
cwd: SRC_PATH,
|
||||
nodir: false,
|
||||
maxDepth: 1,
|
||||
}).sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const partitionFileExportsByType = (declarations: DeclarationOccurrence[]) => {
|
||||
return declarations.reduce<{
|
||||
typeAndInterfaceDeclarations: DeclarationOccurrence[];
|
||||
otherDeclarations: DeclarationOccurrence[];
|
||||
}>(
|
||||
(acc, { kind, name }) => {
|
||||
if (kind === 'type' || kind === 'interface') {
|
||||
return {
|
||||
...acc,
|
||||
typeAndInterfaceDeclarations: [
|
||||
...acc.typeAndInterfaceDeclarations,
|
||||
{ kind, name },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
otherDeclarations: [...acc.otherDeclarations, { kind, name }],
|
||||
};
|
||||
},
|
||||
{
|
||||
typeAndInterfaceDeclarations: [],
|
||||
otherDeclarations: [],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => {
|
||||
return exportByBarrel.map<createTypeScriptFileArgs>(
|
||||
({ barrel: { moduleDirectory }, allFileExports }) => {
|
||||
const content = allFileExports
|
||||
.sort((a, b) => a.file.localeCompare(b.file))
|
||||
.map(({ exports, file }) => {
|
||||
const { otherDeclarations, typeAndInterfaceDeclarations } =
|
||||
partitionFileExportsByType(exports);
|
||||
|
||||
const fileWithoutExtension = path.parse(file).name;
|
||||
const pathToImport = slash(
|
||||
path.relative(
|
||||
moduleDirectory,
|
||||
path.join(path.dirname(file), fileWithoutExtension),
|
||||
),
|
||||
);
|
||||
const mapDeclarationNameAndJoin = (
|
||||
declarations: DeclarationOccurrence[],
|
||||
) => declarations.map(({ name }) => name).join(', ');
|
||||
|
||||
const typeExport =
|
||||
typeAndInterfaceDeclarations.length > 0
|
||||
? `export type { ${mapDeclarationNameAndJoin(typeAndInterfaceDeclarations)} } from "./${pathToImport}"`
|
||||
: '';
|
||||
const othersExport =
|
||||
otherDeclarations.length > 0
|
||||
? `export { ${mapDeclarationNameAndJoin(otherDeclarations)} } from "./${pathToImport}"`
|
||||
: '';
|
||||
|
||||
return [typeExport, othersExport]
|
||||
.filter((el) => el !== '')
|
||||
.join('\n');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content,
|
||||
path: moduleDirectory,
|
||||
filename: INDEX_FILENAME,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
type JsonUpdate = Record<string, any>;
|
||||
type WriteInJsonFileArgs = {
|
||||
content: JsonUpdate;
|
||||
file: string;
|
||||
};
|
||||
const updateJsonFile = ({ content, file }: WriteInJsonFileArgs) => {
|
||||
const updatedJsonFile = JSON.stringify(content);
|
||||
const formattedContent = prettierFormat(updatedJsonFile, 'json-stringify');
|
||||
fs.writeFileSync(file, formattedContent, 'utf-8');
|
||||
};
|
||||
|
||||
const writeInPackageJson = (update: JsonUpdate) => {
|
||||
const rawJsonFile = fs.readFileSync(PACKAGE_JSON_PATH, 'utf-8');
|
||||
const initialJsonFile = JSON.parse(rawJsonFile);
|
||||
|
||||
updateJsonFile({
|
||||
file: PACKAGE_JSON_PATH,
|
||||
content: {
|
||||
...initialJsonFile,
|
||||
...update,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateNxProjectConfigurationBuildOutputs = (outputs: JsonUpdate) => {
|
||||
const rawJsonFile = fs.readFileSync(NX_PROJECT_CONFIGURATION_PATH, 'utf-8');
|
||||
const initialJsonFile = JSON.parse(rawJsonFile);
|
||||
|
||||
updateJsonFile({
|
||||
file: NX_PROJECT_CONFIGURATION_PATH,
|
||||
content: {
|
||||
...initialJsonFile,
|
||||
targets: {
|
||||
...initialJsonFile.targets,
|
||||
build: {
|
||||
...initialJsonFile.targets.build,
|
||||
outputs,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type ExportOccurrence = {
|
||||
types: string;
|
||||
import: string;
|
||||
require: string;
|
||||
};
|
||||
type ExportsConfig = Record<string, ExportOccurrence | string>;
|
||||
|
||||
const generateModulePackageExports = (moduleDirectories: string[]) => {
|
||||
return moduleDirectories.reduce<ExportsConfig>((acc, moduleDirectory) => {
|
||||
const moduleName = getLastPathFolder(moduleDirectory);
|
||||
if (moduleName === undefined) {
|
||||
throw new Error(
|
||||
`Should never occur, moduleName is undefined ${moduleDirectory}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[`./${moduleName}`]: {
|
||||
types: `./dist/${moduleName}/index.d.ts`,
|
||||
import: `./dist/${moduleName}.mjs`,
|
||||
require: `./dist/${moduleName}.cjs`,
|
||||
},
|
||||
};
|
||||
}, {});
|
||||
};
|
||||
|
||||
const computePackageJsonFilesAndExportsConfig = (
|
||||
moduleDirectories: string[],
|
||||
) => {
|
||||
const entrypoints = moduleDirectories.map(getLastPathFolder);
|
||||
const exports = {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
import: './dist/index.mjs',
|
||||
require: './dist/index.cjs',
|
||||
},
|
||||
...generateModulePackageExports(moduleDirectories),
|
||||
} satisfies ExportsConfig;
|
||||
|
||||
const typesVersionsEntries = entrypoints.reduce<Record<string, string[]>>(
|
||||
(acc, moduleName) => ({
|
||||
...acc,
|
||||
[`${moduleName}`]: [`dist/${moduleName}/index.d.ts`],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
exports,
|
||||
typesVersions: { '*': typesVersionsEntries },
|
||||
files: ['dist', ...entrypoints],
|
||||
};
|
||||
};
|
||||
|
||||
const computeProjectNxBuildOutputsPath = (moduleDirectories: string[]) => {
|
||||
const dynamicOutputsPath = moduleDirectories
|
||||
.map(getLastPathFolder)
|
||||
.flatMap((barrelName) =>
|
||||
['package.json', 'dist'].map(
|
||||
(subPath) => `{projectRoot}/${barrelName}/${subPath}`,
|
||||
),
|
||||
);
|
||||
|
||||
return ['{projectRoot}/dist', ...dynamicOutputsPath];
|
||||
};
|
||||
|
||||
const EXCLUDED_EXTENSIONS = [
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.stories.ts',
|
||||
'**/*.stories.tsx',
|
||||
] as const;
|
||||
const EXCLUDED_DIRECTORIES = [
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'**/__stories__/**',
|
||||
'**/internal/**',
|
||||
] as const;
|
||||
function getTypeScriptFiles(
|
||||
directoryPath: string,
|
||||
includeIndex: boolean = false,
|
||||
): string[] {
|
||||
const pattern = slash(path.join(directoryPath, '**', '*.{ts,tsx}'));
|
||||
const files = globSync(pattern, {
|
||||
cwd: SRC_PATH,
|
||||
nodir: true,
|
||||
ignore: [...EXCLUDED_EXTENSIONS, ...EXCLUDED_DIRECTORIES],
|
||||
});
|
||||
|
||||
return files.filter(
|
||||
(file) =>
|
||||
!file.endsWith('.d.ts') &&
|
||||
(includeIndex ? true : !file.endsWith('index.ts')),
|
||||
);
|
||||
}
|
||||
|
||||
const getKind = (
|
||||
node: ts.VariableStatement,
|
||||
): Extract<ExportKind, 'const' | 'let' | 'var'> => {
|
||||
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
||||
if (isConst) {
|
||||
return 'const';
|
||||
}
|
||||
|
||||
const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0;
|
||||
if (isLet) {
|
||||
return 'let';
|
||||
}
|
||||
|
||||
return 'var';
|
||||
};
|
||||
|
||||
function extractExportsFromSourceFile(sourceFile: ts.SourceFile) {
|
||||
const exports: DeclarationOccurrence[] = [];
|
||||
|
||||
function visit(node: ts.Node) {
|
||||
if (!ts.canHaveModifiers(node)) {
|
||||
return ts.forEachChild(node, visit);
|
||||
}
|
||||
const modifiers = ts.getModifiers(node);
|
||||
const isExport = modifiers?.some(
|
||||
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
|
||||
);
|
||||
|
||||
if (!isExport && !ts.isExportDeclaration(node)) {
|
||||
return ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case ts.isTypeAliasDeclaration(node):
|
||||
exports.push({
|
||||
kind: 'type',
|
||||
name: node.name.text,
|
||||
});
|
||||
break;
|
||||
|
||||
case ts.isInterfaceDeclaration(node):
|
||||
exports.push({
|
||||
kind: 'interface',
|
||||
name: node.name.text,
|
||||
});
|
||||
break;
|
||||
|
||||
case ts.isEnumDeclaration(node):
|
||||
exports.push({
|
||||
kind: 'enum',
|
||||
name: node.name.text,
|
||||
});
|
||||
break;
|
||||
|
||||
case ts.isFunctionDeclaration(node) && node.name !== undefined:
|
||||
exports.push({
|
||||
kind: 'function',
|
||||
name: node.name.text,
|
||||
});
|
||||
break;
|
||||
|
||||
case ts.isVariableStatement(node):
|
||||
node.declarationList.declarations.forEach((decl) => {
|
||||
const kind = getKind(node);
|
||||
|
||||
if (ts.isIdentifier(decl.name)) {
|
||||
exports.push({
|
||||
kind,
|
||||
name: decl.name.text,
|
||||
});
|
||||
} else if (ts.isObjectBindingPattern(decl.name)) {
|
||||
decl.name.elements.forEach((element) => {
|
||||
if (
|
||||
!ts.isBindingElement(element) ||
|
||||
!ts.isIdentifier(element.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
exports.push({
|
||||
kind,
|
||||
name: element.name.text,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case ts.isClassDeclaration(node) && node.name !== undefined:
|
||||
exports.push({
|
||||
kind: 'class',
|
||||
name: node.name.text,
|
||||
});
|
||||
break;
|
||||
case ts.isExportDeclaration(node):
|
||||
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
||||
node.exportClause.elements.forEach((element) => {
|
||||
const exportName = element.name.text;
|
||||
|
||||
// Check both the declaration and the individual specifier for type-only exports
|
||||
const isTypeExport =
|
||||
node.isTypeOnly || ts.isTypeOnlyExportDeclaration(node);
|
||||
if (isTypeExport) {
|
||||
// should handle kind
|
||||
exports.push({
|
||||
kind: 'type',
|
||||
name: exportName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
exports.push({
|
||||
kind: 'const',
|
||||
name: exportName,
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
return ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return exports;
|
||||
}
|
||||
|
||||
type ExportKind =
|
||||
| 'type'
|
||||
| 'interface'
|
||||
| 'enum'
|
||||
| 'function'
|
||||
| 'const'
|
||||
| 'let'
|
||||
| 'var'
|
||||
| 'class';
|
||||
type DeclarationOccurrence = { kind: ExportKind; name: string };
|
||||
type FileExports = Array<{
|
||||
file: string;
|
||||
exports: DeclarationOccurrence[];
|
||||
}>;
|
||||
|
||||
function findAllExports(directoryPath: string): FileExports {
|
||||
const results: FileExports = [];
|
||||
|
||||
const files = getTypeScriptFiles(directoryPath);
|
||||
|
||||
for (const file of files) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
fs.readFileSync(file, 'utf8'),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
|
||||
const exports = extractExportsFromSourceFile(sourceFile);
|
||||
if (exports.length > 0) {
|
||||
results.push({
|
||||
file,
|
||||
exports,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
type ExportByBarrel = {
|
||||
barrel: {
|
||||
moduleName: string;
|
||||
moduleDirectory: string;
|
||||
};
|
||||
allFileExports: FileExports;
|
||||
};
|
||||
const retrieveExportsByBarrel = (barrelDirectories: string[]) => {
|
||||
return barrelDirectories.map<ExportByBarrel>((moduleDirectory) => {
|
||||
const moduleExportsPerFile = findAllExports(moduleDirectory);
|
||||
const moduleName = getLastPathFolder(moduleDirectory);
|
||||
if (!moduleName) {
|
||||
throw new Error(
|
||||
`Should never occur moduleName not found ${moduleDirectory}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
barrel: {
|
||||
moduleName,
|
||||
moduleDirectory,
|
||||
},
|
||||
allFileExports: moduleExportsPerFile,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const main = () => {
|
||||
const moduleDirectories = getSubDirectoryPaths(SRC_PATH);
|
||||
const exportsByBarrel = retrieveExportsByBarrel(moduleDirectories);
|
||||
const moduleIndexFiles = generateModuleIndexFiles(exportsByBarrel);
|
||||
const packageJsonConfig =
|
||||
computePackageJsonFilesAndExportsConfig(moduleDirectories);
|
||||
const nxBuildOutputsPath =
|
||||
computeProjectNxBuildOutputsPath(moduleDirectories);
|
||||
|
||||
updateNxProjectConfigurationBuildOutputs(nxBuildOutputsPath);
|
||||
writeInPackageJson(packageJsonConfig);
|
||||
moduleIndexFiles.forEach(createTypeScriptFile);
|
||||
};
|
||||
main();
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
type ObjectMetadataOptions = {
|
||||
universalIdentifier: string;
|
||||
import { type SyncableEntityOptions } from '@/application/types/syncable-entity-options.type';
|
||||
|
||||
type ObjectMetadataOptions = SyncableEntityOptions & {
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, |
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { ObjectMetadata } from './decorators/object-metadata.decorator';
|
||||
export type { ApplicationConfig } from './types/application-config';
|
||||
export type { ServerlessFunctionConfig } from './types/serverless-function-config';
|
||||
export type { SyncableEntityOptions } from './types/syncable-entity-options.type';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type SyncableEntityOptions } from '@/application/types/syncable-entity-options.type';
|
||||
|
||||
type ApplicationVariable = SyncableEntityOptions & {
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type ApplicationConfig = SyncableEntityOptions & {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
applicationVariables?: Record<string, ApplicationVariable>;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SyncableEntityOptions } from '@/application/types/syncable-entity-options.type';
|
||||
|
||||
type RouteTrigger = {
|
||||
type: 'route';
|
||||
path: string;
|
||||
httpMethod: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
isAuthRequired: boolean;
|
||||
};
|
||||
|
||||
type CronTrigger = {
|
||||
type: 'cron';
|
||||
pattern: string;
|
||||
};
|
||||
|
||||
type DatabaseEventTrigger = {
|
||||
type: 'databaseEvent';
|
||||
eventName: string;
|
||||
};
|
||||
|
||||
type ServerlessFunctionTrigger = SyncableEntityOptions &
|
||||
(RouteTrigger | CronTrigger | DatabaseEventTrigger);
|
||||
|
||||
export type ServerlessFunctionConfig = SyncableEntityOptions & {
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
triggers?: ServerlessFunctionTrigger[];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export type SyncableEntityOptions = { universalIdentifier: string };
|
||||
@@ -1 +0,0 @@
|
||||
export { ObjectMetadata } from './object-metadata.decorator';
|
||||
@@ -1 +1 @@
|
||||
export * from './decorators';
|
||||
export default {};
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// @ts-ignore
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/index.ts',
|
||||
@@ -10,5 +17,9 @@ export default defineConfig({
|
||||
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
|
||||
},
|
||||
},
|
||||
plugins: [dts()],
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: 'src',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddHandlerToServerlessFunction1761210191095
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddHandlerToServerlessFunction1761210191095';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" ADD "handlerPath" character varying NOT NULL DEFAULT 'src/index.ts'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" ADD "handlerName" character varying NOT NULL DEFAULT 'main'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" DROP COLUMN "handlerName"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."serverlessFunction" DROP COLUMN "handlerPath"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+57
-82
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { parse } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ALL_METADATA_NAME, AllMetadataName } from 'twenty-shared/metadata';
|
||||
|
||||
@@ -11,13 +13,11 @@ import {
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import {
|
||||
AgentManifest,
|
||||
ObjectManifest,
|
||||
ServerlessFunctionManifest,
|
||||
ServerlessFunctionTriggerManifest,
|
||||
} from 'src/engine/core-modules/application/types/application.types';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { CronTriggerV2Service } from 'src/engine/metadata-modules/cron-trigger/services/cron-trigger-v2.service';
|
||||
import { FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
@@ -34,6 +34,7 @@ import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serv
|
||||
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
|
||||
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
|
||||
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationSyncService {
|
||||
@@ -47,7 +48,6 @@ export class ApplicationSyncService {
|
||||
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
private readonly agentService: AgentService,
|
||||
private readonly databaseEventTriggerV2Service: DatabaseEventTriggerV2Service,
|
||||
private readonly cronTriggerV2Service: CronTriggerV2Service,
|
||||
private readonly routeTriggerV2Service: RouteTriggerV2Service,
|
||||
@@ -69,12 +69,6 @@ export class ApplicationSyncService {
|
||||
yarnLock,
|
||||
});
|
||||
|
||||
await this.syncAgents({
|
||||
agentsToSync: manifest.agents,
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
await this.syncObjects({
|
||||
objectsToSync: manifest.objects,
|
||||
workspaceId,
|
||||
@@ -83,6 +77,7 @@ export class ApplicationSyncService {
|
||||
|
||||
await this.syncServerlessFunctions({
|
||||
serverlessFunctionsToSync: manifest.serverlessFunctions,
|
||||
code: manifest.sources,
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
serverlessFunctionLayerId: application.serverlessFunctionLayerId,
|
||||
@@ -100,10 +95,12 @@ export class ApplicationSyncService {
|
||||
workspaceId: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
const application = await this.applicationService.findByUniversalIdentifier(
|
||||
manifest.universalIdentifier,
|
||||
manifest.application.universalIdentifier,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const name = manifest.application.displayName ?? packageJson.name;
|
||||
|
||||
if (!isDefined(application)) {
|
||||
const serverlessFunctionLayer =
|
||||
await this.serverlessFunctionLayerService.create(
|
||||
@@ -115,18 +112,18 @@ export class ApplicationSyncService {
|
||||
);
|
||||
|
||||
const application = await this.applicationService.create({
|
||||
universalIdentifier: manifest.universalIdentifier,
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
universalIdentifier: manifest.application.universalIdentifier,
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
version: packageJson.version,
|
||||
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
|
||||
serverlessFunctionLayerId: serverlessFunctionLayer.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntitys(
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntities(
|
||||
{
|
||||
env: manifest.env,
|
||||
applicationVariables: manifest.application.applicationVariables,
|
||||
applicationId: application.id,
|
||||
},
|
||||
);
|
||||
@@ -143,62 +140,21 @@ export class ApplicationSyncService {
|
||||
);
|
||||
|
||||
await this.applicationService.update(application.id, {
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
version: packageJson.version,
|
||||
});
|
||||
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntitys({
|
||||
env: manifest.env,
|
||||
applicationId: application.id,
|
||||
});
|
||||
await this.applicationVariableService.upsertManyApplicationVariableEntities(
|
||||
{
|
||||
applicationVariables: manifest.application.applicationVariables,
|
||||
applicationId: application.id,
|
||||
},
|
||||
);
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
private async syncAgents({
|
||||
agentsToSync,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
}: {
|
||||
agentsToSync: AgentManifest[];
|
||||
workspaceId: string;
|
||||
applicationId: string;
|
||||
}) {
|
||||
for (const agentToSync of agentsToSync) {
|
||||
const existingAgent =
|
||||
await this.agentService.findOneByApplicationAndStandardId({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
standardId: agentToSync.standardId,
|
||||
});
|
||||
|
||||
if (isDefined(existingAgent)) {
|
||||
await this.agentService.updateOneAgent(
|
||||
{ id: existingAgent.id, ...agentToSync },
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentService.createOneAgent(
|
||||
{
|
||||
name: agentToSync.name,
|
||||
label: agentToSync.label,
|
||||
description: agentToSync.description,
|
||||
icon: agentToSync.icon,
|
||||
prompt: agentToSync.prompt,
|
||||
modelId: agentToSync.modelId,
|
||||
standardId: agentToSync.standardId,
|
||||
isCustom: true,
|
||||
applicationId,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncObjects({
|
||||
objectsToSync,
|
||||
workspaceId,
|
||||
@@ -224,27 +180,31 @@ export class ApplicationSyncService {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any[];
|
||||
|
||||
const objectsToSyncStandardIds = objectsToSync.map((obj) => obj.standardId);
|
||||
const objectsToSyncUniversalIds = objectsToSync.map(
|
||||
(obj) => obj.universalIdentifier,
|
||||
);
|
||||
|
||||
const applicationObjectsStandardIds = applicationObjects.map(
|
||||
(obj) => obj.standardId,
|
||||
(obj) => obj.universalIdentifier,
|
||||
);
|
||||
|
||||
const objectsToDelete = applicationObjects.filter(
|
||||
(obj) =>
|
||||
isDefined(obj.standardId) &&
|
||||
!objectsToSyncStandardIds.includes(obj.standardId),
|
||||
isDefined(obj.universalIdentifier) &&
|
||||
!objectsToSyncUniversalIds.includes(obj.universalIdentifier),
|
||||
);
|
||||
|
||||
const objectsToUpdate = applicationObjects.filter(
|
||||
(obj) =>
|
||||
isDefined(obj.standardId) &&
|
||||
objectsToSyncStandardIds.includes(obj.standardId),
|
||||
isDefined(obj.universalIdentifier) &&
|
||||
objectsToSyncUniversalIds.includes(obj.universalIdentifier),
|
||||
);
|
||||
|
||||
const objectsToCreate = objectsToSync.filter(
|
||||
(objectToSync) =>
|
||||
!applicationObjectsStandardIds.includes(objectToSync.standardId),
|
||||
!applicationObjectsStandardIds.includes(
|
||||
objectToSync.universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
for (const objectToDelete of objectsToDelete) {
|
||||
@@ -257,12 +217,12 @@ export class ApplicationSyncService {
|
||||
|
||||
for (const objectToUpdate of objectsToUpdate) {
|
||||
const objectToSync = objectsToSync.find(
|
||||
(obj) => obj.standardId === objectToUpdate.standardId,
|
||||
(obj) => obj.universalIdentifier === objectToUpdate.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!objectToSync) {
|
||||
throw new ApplicationException(
|
||||
`Failed to find object to sync with standardId ${objectToUpdate.standardId}`,
|
||||
`Failed to find object to sync with universalIdentifier ${objectToUpdate.universalIdentifier}`,
|
||||
ApplicationExceptionCode.OBJECT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
@@ -298,7 +258,8 @@ export class ApplicationSyncService {
|
||||
labelPlural: objectToCreate.labelPlural,
|
||||
icon: objectToCreate.icon || undefined,
|
||||
description: objectToCreate.description || undefined,
|
||||
standardId: objectToCreate.standardId || undefined,
|
||||
standardId: objectToCreate.universalIdentifier,
|
||||
universalIdentifier: objectToCreate.universalIdentifier,
|
||||
dataSourceId: dataSourceMetadata.id,
|
||||
applicationId,
|
||||
};
|
||||
@@ -312,12 +273,14 @@ export class ApplicationSyncService {
|
||||
|
||||
private async syncServerlessFunctions({
|
||||
serverlessFunctionsToSync,
|
||||
code,
|
||||
workspaceId,
|
||||
applicationId,
|
||||
serverlessFunctionLayerId,
|
||||
}: {
|
||||
serverlessFunctionsToSync: ServerlessFunctionManifest[];
|
||||
workspaceId: string;
|
||||
code: Sources;
|
||||
applicationId: string;
|
||||
serverlessFunctionLayerId: string;
|
||||
}) {
|
||||
@@ -392,12 +355,18 @@ export class ApplicationSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
const name =
|
||||
serverlessFunctionToSync.name ??
|
||||
parse(serverlessFunctionToSync.handlerName).name;
|
||||
|
||||
const updateServerlessFunctionInput = {
|
||||
id: serverlessFunctionToUpdate.id,
|
||||
update: {
|
||||
name: serverlessFunctionToSync.name,
|
||||
name,
|
||||
code,
|
||||
timeoutSeconds: serverlessFunctionToSync.timeoutSeconds,
|
||||
code: serverlessFunctionToSync.code,
|
||||
handlerPath: serverlessFunctionToSync.handlerPath,
|
||||
handlerName: serverlessFunctionToSync.handlerName,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -426,11 +395,17 @@ export class ApplicationSyncService {
|
||||
}
|
||||
|
||||
for (const serverlessFunctionToCreate of serverlessFunctionsToCreate) {
|
||||
const name =
|
||||
serverlessFunctionToCreate.name ??
|
||||
parse(serverlessFunctionToCreate.handlerName).name;
|
||||
|
||||
const createServerlessFunctionInput = {
|
||||
name: serverlessFunctionToCreate.name,
|
||||
code: serverlessFunctionToCreate.code,
|
||||
name,
|
||||
code,
|
||||
universalIdentifier: serverlessFunctionToCreate.universalIdentifier,
|
||||
timeoutSeconds: serverlessFunctionToCreate.timeoutSeconds,
|
||||
handlerPath: serverlessFunctionToCreate.handlerPath,
|
||||
handlerName: serverlessFunctionToCreate.handlerName,
|
||||
applicationId,
|
||||
serverlessFunctionLayerId,
|
||||
};
|
||||
@@ -659,7 +634,7 @@ export class ApplicationSyncService {
|
||||
id: triggerToUpdate.id,
|
||||
update: {
|
||||
settings: {
|
||||
pattern: triggerToSync.schedule,
|
||||
pattern: triggerToSync.pattern,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -677,7 +652,7 @@ export class ApplicationSyncService {
|
||||
|
||||
const createCronTriggerInput = {
|
||||
settings: {
|
||||
pattern: triggerToCreate.schedule,
|
||||
pattern: triggerToCreate.pattern,
|
||||
},
|
||||
universalIdentifier: triggerToCreate.universalIdentifier,
|
||||
serverlessFunctionId,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
|
||||
@@ -25,7 +24,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
ObjectMetadataModule,
|
||||
DataSourceModule,
|
||||
AgentModule,
|
||||
ApplicationVariableEntityModule,
|
||||
ServerlessFunctionLayerModule,
|
||||
ServerlessFunctionModule,
|
||||
|
||||
@@ -57,11 +57,11 @@ export class ApplicationResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplication(
|
||||
@Args() { packageJson }: DeleteApplicationInput,
|
||||
@Args() { universalIdentifier }: DeleteApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.applicationSyncService.deleteApplication({
|
||||
applicationUniversalIdentifier: packageJson.universalIdentifier,
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
|
||||
+2
-6
@@ -1,11 +1,7 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { PackageJson } from 'src/engine/core-modules/application/types/application.types';
|
||||
|
||||
@ArgsType()
|
||||
export class DeleteApplicationInput {
|
||||
@Field(() => GraphQLJSON, { nullable: false })
|
||||
packageJson: PackageJson;
|
||||
@Field(() => String)
|
||||
universalIdentifier: string;
|
||||
}
|
||||
|
||||
+22
-18
@@ -1,51 +1,56 @@
|
||||
import { type HTTPMethod } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
export type PackageJson = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
license: string;
|
||||
engines: {
|
||||
node: string;
|
||||
npm: string;
|
||||
yarn: string;
|
||||
};
|
||||
env?: EnvManifest;
|
||||
icon?: string;
|
||||
packageManager: string;
|
||||
version: string;
|
||||
dependencies?: object;
|
||||
devDependencies?: object;
|
||||
};
|
||||
|
||||
export type EnvManifest = Record<string, EnvVariableManifest>;
|
||||
|
||||
export type EnvVariableManifest = {
|
||||
type ApplicationVariable = {
|
||||
universalIdentifier: string;
|
||||
value?: string;
|
||||
description?: string;
|
||||
isSecret: boolean;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
export type AppManifest = PackageJson & {
|
||||
agents: AgentManifest[];
|
||||
type Sources = { [key: string]: string | Sources };
|
||||
|
||||
type Application = {
|
||||
universalIdentifier: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
applicationVariables?: Record<string, ApplicationVariable>;
|
||||
};
|
||||
export type AppManifest = {
|
||||
application: Application;
|
||||
objects: ObjectManifest[];
|
||||
serverlessFunctions: ServerlessFunctionManifest[];
|
||||
sources: Sources;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionManifest = {
|
||||
$schema?: string;
|
||||
universalIdentifier: string;
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
handlerPath: string;
|
||||
handlerName: string;
|
||||
triggers: ServerlessFunctionTriggerManifest[];
|
||||
code: ServerlessFunctionCode;
|
||||
};
|
||||
|
||||
export type ServerlessFunctionTriggerManifest = (
|
||||
| {
|
||||
type: 'cron';
|
||||
schedule: string;
|
||||
pattern: string;
|
||||
}
|
||||
| {
|
||||
type: 'databaseEvent';
|
||||
@@ -62,8 +67,7 @@ export type ServerlessFunctionTriggerManifest = (
|
||||
};
|
||||
|
||||
export type ObjectManifest = {
|
||||
$schema?: string;
|
||||
standardId: string;
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
|
||||
+31
-12
@@ -4,7 +4,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { EnvManifest } from 'src/engine/core-modules/application/types/application.types';
|
||||
import { AppManifest } from 'src/engine/core-modules/application/types/application.types';
|
||||
|
||||
export class ApplicationVariableEntityService {
|
||||
constructor(
|
||||
@@ -27,33 +27,52 @@ export class ApplicationVariableEntityService {
|
||||
);
|
||||
}
|
||||
|
||||
async upsertManyApplicationVariableEntitys({
|
||||
env,
|
||||
async upsertManyApplicationVariableEntities({
|
||||
applicationVariables,
|
||||
applicationId,
|
||||
}: {
|
||||
env?: EnvManifest;
|
||||
applicationVariables?: AppManifest['application']['applicationVariables'];
|
||||
applicationId: string;
|
||||
}) {
|
||||
if (!isDefined(env)) {
|
||||
if (!isDefined(applicationVariables)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, { value, description, isSecret }] of Object.entries(env)) {
|
||||
await this.applicationVariableRepository.upsert(
|
||||
{
|
||||
for (const [key, { value, description, isSecret }] of Object.entries(
|
||||
applicationVariables,
|
||||
)) {
|
||||
if (
|
||||
await this.applicationVariableRepository.findOne({
|
||||
where: {
|
||||
key,
|
||||
applicationId,
|
||||
},
|
||||
})
|
||||
) {
|
||||
await this.applicationVariableRepository.update(
|
||||
{
|
||||
key,
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
description,
|
||||
isSecret,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await this.applicationVariableRepository.save({
|
||||
key,
|
||||
value,
|
||||
description,
|
||||
isSecret,
|
||||
applicationId,
|
||||
},
|
||||
{ conflictPaths: ['key', 'applicationId'] },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.applicationVariableRepository.delete({
|
||||
applicationId,
|
||||
key: Not(In(Object.keys(env))),
|
||||
key: Not(In(Object.keys(applicationVariables))),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -6,20 +6,23 @@ export const handler = async (event) => {
|
||||
|
||||
const mainPath = `/tmp/${randomId}.mjs`;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const oldProcessEnv = { ...process.env };
|
||||
|
||||
try {
|
||||
const { code, params, env } = event;
|
||||
const { code, params, env, handlerName } = event;
|
||||
|
||||
await fs.writeFile(mainPath, code, 'utf8');
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
process.env = { ...process.env, ...(env ?? {}) };
|
||||
|
||||
const mainFile = await import(mainPath);
|
||||
|
||||
return await mainFile.main(params);
|
||||
return await mainFile[handlerName](params);
|
||||
} finally {
|
||||
await fs.rm(mainPath, { force: true });
|
||||
// eslint-disable-next-line no-undef
|
||||
process.env = oldProcessEnv;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,6 +51,13 @@ import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/bu
|
||||
const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60;
|
||||
const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h
|
||||
|
||||
type LambdaDriverExecutorPayload = {
|
||||
code: string;
|
||||
params: object;
|
||||
env: Record<string, string>;
|
||||
handlerName: string;
|
||||
};
|
||||
|
||||
export interface LambdaDriverOptions extends LambdaClientConfig {
|
||||
fileStorageService: FileStorageService;
|
||||
region: string;
|
||||
@@ -326,8 +333,10 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
let builtBundleFilePath = '';
|
||||
|
||||
try {
|
||||
builtBundleFilePath =
|
||||
await buildServerlessFunctionInMemory(sourceTemporaryDir);
|
||||
builtBundleFilePath = await buildServerlessFunctionInMemory({
|
||||
sourceTemporaryDir,
|
||||
handlerPath: serverlessFunction.handlerPath,
|
||||
});
|
||||
} catch (error) {
|
||||
return formatBuildError(error, startTime);
|
||||
}
|
||||
@@ -336,10 +345,11 @@ export class LambdaDriver implements ServerlessDriver {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const executorPayload = {
|
||||
const executorPayload: LambdaDriverExecutorPayload = {
|
||||
params: payload,
|
||||
code: compiledCode,
|
||||
env: buildEnvVar(serverlessFunction),
|
||||
handlerName: serverlessFunction.handlerName,
|
||||
};
|
||||
|
||||
const params: InvokeCommandInput = {
|
||||
|
||||
@@ -61,27 +61,6 @@ export class LocalDriver implements ServerlessDriver {
|
||||
await this.createLayerIfNotExists(serverlessFunction);
|
||||
}
|
||||
|
||||
private async executeWithTimeout<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Task timed out after ${timeoutMs / 1_000} seconds`));
|
||||
}, timeoutMs);
|
||||
|
||||
fn()
|
||||
.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async execute(
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
payload: object,
|
||||
@@ -109,8 +88,10 @@ export class LocalDriver implements ServerlessDriver {
|
||||
let builtBundleFilePath = '';
|
||||
|
||||
try {
|
||||
builtBundleFilePath =
|
||||
await buildServerlessFunctionInMemory(sourceTemporaryDir);
|
||||
builtBundleFilePath = await buildServerlessFunctionInMemory({
|
||||
sourceTemporaryDir,
|
||||
handlerPath: serverlessFunction.handlerPath,
|
||||
});
|
||||
} catch (error) {
|
||||
return formatBuildError(error, startTime);
|
||||
}
|
||||
@@ -164,10 +145,11 @@ export class LocalDriver implements ServerlessDriver {
|
||||
});
|
||||
|
||||
try {
|
||||
const runnerPath = await this.writeBootstrapRunner(
|
||||
sourceTemporaryDir,
|
||||
builtBundleFilePath,
|
||||
);
|
||||
const runnerPath = await this.writeBootstrapRunner({
|
||||
dir: sourceTemporaryDir,
|
||||
builtFileAbsPath: builtBundleFilePath,
|
||||
handlerName: serverlessFunction.handlerName,
|
||||
});
|
||||
|
||||
const { ok, result, error, stack, stdout, stderr } =
|
||||
await this.runChildWithEnv({
|
||||
@@ -222,7 +204,15 @@ export class LocalDriver implements ServerlessDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async writeBootstrapRunner(dir: string, builtFileAbsPath: string) {
|
||||
async writeBootstrapRunner({
|
||||
dir,
|
||||
builtFileAbsPath,
|
||||
handlerName,
|
||||
}: {
|
||||
dir: string;
|
||||
builtFileAbsPath: string;
|
||||
handlerName: string;
|
||||
}) {
|
||||
const runnerPath = join(dir, '__runner.cjs');
|
||||
const code = `
|
||||
// Auto-generated. Do not edit.
|
||||
@@ -232,8 +222,8 @@ export class LocalDriver implements ServerlessDriver {
|
||||
try {
|
||||
const builtUrl = pathToFileURL(${JSON.stringify(builtFileAbsPath)});
|
||||
const mod = await import(builtUrl.href);
|
||||
if (typeof mod.main !== 'function') {
|
||||
throw new Error('Export "main" not found in serverless bundle');
|
||||
if (typeof mod.${handlerName} !== 'function') {
|
||||
throw new Error('Export "${handlerName}" not found in serverless bundle');
|
||||
}
|
||||
|
||||
let payload = undefined;
|
||||
@@ -241,7 +231,7 @@ export class LocalDriver implements ServerlessDriver {
|
||||
process.on('message', async (msg) => {
|
||||
if (!msg || msg.type !== 'run') return;
|
||||
try {
|
||||
const out = await mod.main(msg.payload);
|
||||
const out = await mod.${handlerName}(msg.payload);
|
||||
process.send && process.send({ ok: true, result: out });
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
@@ -253,7 +243,7 @@ export class LocalDriver implements ServerlessDriver {
|
||||
// Fallback: read payload from argv[2] (JSON) and print to stdout
|
||||
const json = process.argv[2];
|
||||
payload = json ? JSON.parse(json) : undefined;
|
||||
const out = await mod.main(payload);
|
||||
const out = await mod.${handlerName}(payload);
|
||||
console.log(JSON.stringify({ ok: true, result: out }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
+8
-4
@@ -2,10 +2,14 @@ import { join } from 'path';
|
||||
|
||||
import { build } from 'esbuild';
|
||||
|
||||
export const buildServerlessFunctionInMemory = async (
|
||||
sourceTemporaryDir: string,
|
||||
) => {
|
||||
const entryFilePath = join(sourceTemporaryDir, 'src', 'index.ts');
|
||||
export const buildServerlessFunctionInMemory = async ({
|
||||
sourceTemporaryDir,
|
||||
handlerPath,
|
||||
}: {
|
||||
sourceTemporaryDir: string;
|
||||
handlerPath: string;
|
||||
}) => {
|
||||
const entryFilePath = join(sourceTemporaryDir, handlerPath);
|
||||
|
||||
const builtBundleFilePath = join(sourceTemporaryDir, 'dist', 'main.mjs');
|
||||
|
||||
|
||||
+4
-2
@@ -45,13 +45,15 @@ export class CallDatabaseEventTriggerJobsJob {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const eventData of workspaceEventBatch.events) {
|
||||
const { events, ...batchEventInfo } = workspaceEventBatch;
|
||||
|
||||
for (const event of events) {
|
||||
await this.messageQueueService.add<ServerlessFunctionTriggerJobData>(
|
||||
ServerlessFunctionTriggerJob.name,
|
||||
{
|
||||
serverlessFunctionId: databaseEventListener.serverlessFunction.id,
|
||||
workspaceId: databaseEventListener.workspaceId,
|
||||
payload: eventData,
|
||||
payload: { ...batchEventInfo, ...event },
|
||||
},
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
|
||||
+2
@@ -6,4 +6,6 @@ export const FLAT_SERVERLESS_FUNCTION_EDITABLE_PROPERTIES = [
|
||||
'timeoutSeconds',
|
||||
'checksum',
|
||||
'code',
|
||||
'handlerPath',
|
||||
'handlerName',
|
||||
] as const satisfies (keyof FlatServerlessFunction)[];
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import {
|
||||
} from 'class-validator';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
@InputType()
|
||||
export class CreateServerlessFunctionInput {
|
||||
@@ -44,5 +44,5 @@ export class CreateServerlessFunctionInput {
|
||||
@Field(() => graphqlTypeJson, { nullable: true })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
code?: ServerlessFunctionCode;
|
||||
code?: Sources;
|
||||
}
|
||||
|
||||
+8
@@ -57,6 +57,14 @@ export class ServerlessFunctionDTO {
|
||||
@Field({ nullable: true })
|
||||
latestVersion?: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
handlerPath: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
handlerName: string;
|
||||
|
||||
@IsArray()
|
||||
@Field(() => [String], { nullable: false })
|
||||
publishedVersions: string[];
|
||||
|
||||
+14
-3
@@ -15,13 +15,14 @@ import {
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
import type { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
@InputType()
|
||||
class UpdateServerlessFunctionInputUpdates {
|
||||
@IsString()
|
||||
@Field()
|
||||
name: string;
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
@@ -37,7 +38,17 @@ class UpdateServerlessFunctionInputUpdates {
|
||||
|
||||
@Field(() => graphqlTypeJson)
|
||||
@IsObject()
|
||||
code: ServerlessFunctionCode;
|
||||
code: Sources;
|
||||
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
handlerName?: string;
|
||||
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
handlerPath?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
+9
@@ -28,6 +28,9 @@ export enum ServerlessFunctionRuntime {
|
||||
NODE22 = 'nodejs22.x',
|
||||
}
|
||||
|
||||
export const DEFAULT_HANDLER_PATH = 'src/index.ts';
|
||||
export const DEFAULT_HANDLER_NAME = 'main';
|
||||
|
||||
export const SERVERLESS_FUNCTION_ENTITY_RELATION_PROPERTIES = [
|
||||
'cronTriggers',
|
||||
'databaseEventTriggers',
|
||||
@@ -46,6 +49,12 @@ export class ServerlessFunctionEntity
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: false, default: DEFAULT_HANDLER_PATH })
|
||||
handlerPath: string;
|
||||
|
||||
@Column({ nullable: false, default: DEFAULT_HANDLER_NAME })
|
||||
handlerName: string;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
description: string | null;
|
||||
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/typ
|
||||
import { type RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
|
||||
import { type ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
|
||||
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
import { type ExtractRecordTypeOrmRelationProperties } from 'src/engine/workspace-manager/workspace-migration-v2/types/extract-record-typeorm-relation-properties.type';
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
export type ServerlessFunctionEntityRelationProperties =
|
||||
ExtractRecordTypeOrmRelationProperties<
|
||||
@@ -22,5 +22,5 @@ export type FlatServerlessFunction = FlatEntityFrom<
|
||||
ServerlessFunctionEntity,
|
||||
ServerlessFunctionEntityRelationProperties
|
||||
> & {
|
||||
code?: ServerlessFunctionCode;
|
||||
code?: Sources;
|
||||
};
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
export type ServerlessFunctionCode = {
|
||||
src: {
|
||||
'index.ts': string;
|
||||
} & Sources;
|
||||
};
|
||||
+7
-1
@@ -1,7 +1,11 @@
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
|
||||
import { ServerlessFunctionRuntime } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import {
|
||||
DEFAULT_HANDLER_NAME,
|
||||
DEFAULT_HANDLER_PATH,
|
||||
ServerlessFunctionRuntime,
|
||||
} from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
|
||||
@@ -23,6 +27,8 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
|
||||
id,
|
||||
name: rawCreateServerlessFunctionInput.name,
|
||||
description: rawCreateServerlessFunctionInput.description ?? null,
|
||||
handlerPath: DEFAULT_HANDLER_PATH,
|
||||
handlerName: DEFAULT_HANDLER_NAME,
|
||||
universalIdentifier:
|
||||
rawCreateServerlessFunctionInput.universalIdentifier ?? v4(),
|
||||
createdAt: currentDate,
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
|
||||
export const serverlessFunctionCreateCodeChecksum = (
|
||||
code: ServerlessFunctionCode,
|
||||
): string => {
|
||||
if (!isDefined(code) || typeof code !== 'object') {
|
||||
return serverlessFunctionCreateHash('');
|
||||
}
|
||||
|
||||
const codeObj = code as unknown as Record<string, string>;
|
||||
const sortedKeys = Object.keys(codeObj).sort();
|
||||
const concatenatedContent = sortedKeys
|
||||
.map((key) => `${key}:${codeObj[key]}`)
|
||||
.join('|');
|
||||
|
||||
return serverlessFunctionCreateHash(concatenatedContent);
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
|
||||
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { type ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
|
||||
export type CreateServerlessFunctionAction = {
|
||||
type: 'create_serverless_function';
|
||||
@@ -10,7 +10,7 @@ export type CreateServerlessFunctionAction = {
|
||||
export type UpdateServerlessFunctionAction = {
|
||||
type: 'update_serverless_function';
|
||||
serverlessFunctionId: string;
|
||||
code?: ServerlessFunctionCode;
|
||||
code?: Sources;
|
||||
updates: FlatEntityPropertiesUpdates<'serverlessFunction'>;
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
|
||||
|
||||
import { type Sources } from 'src/engine/core-modules/file-storage/types/source.type';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
|
||||
import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils';
|
||||
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
|
||||
import { ServerlessFunctionCode } from 'src/engine/metadata-modules/serverless-function/types/serverless-function-code.type';
|
||||
import { UpdateServerlessFunctionAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/types/workspace-migration-serverless-function-action-v2.type';
|
||||
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
|
||||
import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-flat-entity-properties-updates-to-partial-flat-entity';
|
||||
@@ -77,7 +77,7 @@ export class UpdateServerlessFunctionActionHandlerService extends WorkspaceMigra
|
||||
code,
|
||||
}: {
|
||||
serverlessFunction: FlatServerlessFunction;
|
||||
code: ServerlessFunctionCode;
|
||||
code: Sources;
|
||||
}) {
|
||||
const fileFolder = getServerlessFolder({
|
||||
serverlessFunction,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// @ts-ignore
|
||||
import prettier from '@prettier/sync';
|
||||
import * as fs from 'fs';
|
||||
import { globSync } from 'glob';
|
||||
// @ts-ignore
|
||||
import path from 'path';
|
||||
import { Options } from 'prettier';
|
||||
import slash from 'slash';
|
||||
// @ts-ignore
|
||||
import ts from 'typescript';
|
||||
|
||||
// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// @ts-ignore
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
// @ts-ignore
|
||||
import packageJson from './package.json';
|
||||
|
||||
const moduleEntries = Object.keys((packageJson as any).exports || {})
|
||||
.filter((key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'))
|
||||
.filter(
|
||||
(key) => key !== './style.css' && key !== '.' && !key.startsWith('./src/'),
|
||||
)
|
||||
.map((module) => `src/${module.replace(/^\.\//, '')}/index.ts`);
|
||||
|
||||
const entries = ['src/index.ts', ...moduleEntries];
|
||||
@@ -37,7 +41,10 @@ export default defineConfig(() => {
|
||||
return {
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/twenty-shared',
|
||||
plugins: [tsconfigPaths(), dts({ entryRoot: 'src', tsconfigPath: tsConfigPath })],
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
dts({ entryRoot: 'src', tsconfigPath: tsConfigPath }),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
lib: { entry: entries, name: 'twenty-shared' },
|
||||
@@ -58,8 +65,6 @@ export default defineConfig(() => {
|
||||
],
|
||||
},
|
||||
},
|
||||
logLevel: 'warn'
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -52510,7 +52510,9 @@ __metadata:
|
||||
lodash.capitalize: "npm:^4.2.1"
|
||||
lodash.kebabcase: "npm:^4.1.1"
|
||||
tsx: "npm:^4.7.0"
|
||||
twenty-sdk: "workspace:*"
|
||||
typescript: "npm:^5.9.2"
|
||||
uuid: "npm:^13.0.0"
|
||||
wait-on: "npm:^7.2.0"
|
||||
bin:
|
||||
twenty: dist/cli.js
|
||||
@@ -52663,7 +52665,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"twenty-sdk@workspace:packages/twenty-sdk":
|
||||
"twenty-sdk@workspace:*, twenty-sdk@workspace:packages/twenty-sdk":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "twenty-sdk@workspace:packages/twenty-sdk"
|
||||
dependencies:
|
||||
@@ -54392,6 +54394,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:^13.0.0":
|
||||
version: 13.0.0
|
||||
resolution: "uuid@npm:13.0.0"
|
||||
bin:
|
||||
uuid: dist-node/bin/uuid
|
||||
checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:^3.3.2":
|
||||
version: 3.4.0
|
||||
resolution: "uuid@npm:3.4.0"
|
||||
|
||||
Reference in New Issue
Block a user