From 801878cb2bcde609b55a812465ce5aca0dbd56d9 Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 20 Jan 2026 15:10:01 +0100 Subject: [PATCH] 2091 extensibility twenty sdk add command twenty app function logs and twenty app function test (#17278) add `function:logs` and `function:execute` cli commands --- packages/create-twenty-app/README.md | 2 +- .../src/constants/base-application/README.md | 3 +- .../src/utils/app-template.ts | 3 +- .../developers/extend/capabilities/apps.mdx | 5 +- .../developers/extend/capabilities/apps.mdx | 5 +- .../developers/extend/capabilities/apps.mdx | 5 +- .../developers/extend/capabilities/apps.mdx | 5 +- .../developers/extend/capabilities/apps.mdx | 5 +- .../developers/extend/capabilities/apps.mdx | 5 +- .../developers/extend/capabilities/apps.mdx | 5 +- .../src/generated-metadata/graphql.ts | 1 + .../twenty-front/src/generated/graphql.ts | 1 + packages/twenty-sdk/README.md | 21 ++- .../src/cli/commands/app.command.ts | 68 +++++--- .../cli/commands/function/function-execute.ts | 165 ++++++++++++++++++ .../app-logs.ts => function/function-logs.ts} | 2 +- .../cli/utilities/api/services/api.service.ts | 129 ++++++++++++++ .../dtos/serverless-function.dto.ts | 5 + 18 files changed, 400 insertions(+), 35 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/commands/function/function-execute.ts rename packages/twenty-sdk/src/cli/commands/{app/app-logs.ts => function/function-logs.ts} (98%) diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index bdc0f6b6a9..e2ca654d57 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -47,7 +47,7 @@ yarn app:dev yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/create-twenty-app/src/constants/base-application/README.md b/packages/create-twenty-app/src/constants/base-application/README.md index e35357e492..69b5390478 100644 --- a/packages/create-twenty-app/src/constants/base-application/README.md +++ b/packages/create-twenty-app/src/constants/base-application/README.md @@ -35,7 +35,8 @@ yarn app:dev # Start dev mode (sync + watch) yarn app:sync # One-time sync yarn entity:add # Add a new entity (function, object, role) yarn app:generate # Generate typed Twenty client -yarn app:logs # Stream function logs +yarn function:logs # Stream function logs +yarn function:execute # Execute a function with JSON payload yarn app:uninstall # Uninstall app from workspace ``` diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index ad05941918..74c8008ccf 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -199,7 +199,8 @@ const createPackageJson = async ({ 'app:sync': 'twenty app:sync', 'entity:add': 'twenty entity:add', 'app:generate': 'twenty app:generate', - 'app:logs': 'twenty app:logs', + 'function:logs': 'twenty function:logs', + 'function:execute': 'twenty function:execute', 'app:uninstall': 'twenty app:uninstall', help: 'twenty help', lint: 'eslint', diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index a8179a0803..026ec75730 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -53,7 +53,10 @@ yarn app:generate yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs + +# Execute a function by name +yarn function:execute -n my-function -p '{"name": "test"}' # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/twenty-docs/l/ar/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/l/ar/developers/extend/capabilities/apps.mdx index 32866c107d..8b71555924 100644 --- a/packages/twenty-docs/l/ar/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/l/ar/developers/extend/capabilities/apps.mdx @@ -55,7 +55,10 @@ yarn app:generate yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs + +# Execute a function by name +yarn function:execute -n my-function -p '{"name": "test"}' # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/twenty-docs/l/cs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/l/cs/developers/extend/capabilities/apps.mdx index aafbe4a04d..adeefa95a5 100644 --- a/packages/twenty-docs/l/cs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/l/cs/developers/extend/capabilities/apps.mdx @@ -55,7 +55,10 @@ yarn app:generate yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs + +# Execute a function by name +yarn function:execute -n my-function -p '{"name": "test"}' # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/twenty-docs/l/de/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/l/de/developers/extend/capabilities/apps.mdx index 2255ec7eaa..5788359885 100644 --- a/packages/twenty-docs/l/de/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/l/de/developers/extend/capabilities/apps.mdx @@ -55,7 +55,10 @@ yarn app:generate yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs + +# Execute a function by name +yarn function:execute -n my-function -p '{"name": "test"}' # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/twenty-docs/l/it/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/l/it/developers/extend/capabilities/apps.mdx index c68db1b845..22979cf1fe 100644 --- a/packages/twenty-docs/l/it/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/l/it/developers/extend/capabilities/apps.mdx @@ -55,7 +55,10 @@ yarn app:generate yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs + +# Execute a function by name +yarn function:execute -n my-function -p '{"name": "test"}' # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/twenty-docs/l/ru/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/l/ru/developers/extend/capabilities/apps.mdx index 6c4a5a192c..6cfd0c86ea 100644 --- a/packages/twenty-docs/l/ru/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/l/ru/developers/extend/capabilities/apps.mdx @@ -55,7 +55,10 @@ yarn app:generate yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs + +# Execute a function by name +yarn function:execute -n my-function -p '{"name": "test"}' # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/twenty-docs/l/tr/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/l/tr/developers/extend/capabilities/apps.mdx index 11be86d17c..2ad8fba38e 100644 --- a/packages/twenty-docs/l/tr/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/l/tr/developers/extend/capabilities/apps.mdx @@ -55,7 +55,10 @@ yarn app:generate yarn app:sync # Watch your application's functions logs -yarn app:logs +yarn function:logs + +# Execute a function by name +yarn function:execute -n my-function -p '{"name": "test"}' # Uninstall the application from the current workspace yarn app:uninstall diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 71a2c04333..9f42abbfd6 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4283,6 +4283,7 @@ export type ServerlessFunction = { runtime: Scalars['String']; timeoutSeconds: Scalars['Float']; toolInputSchema?: Maybe; + universalIdentifier?: Maybe; updatedAt: Scalars['DateTime']; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 834a99fb6c..381c1ffb17 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -4127,6 +4127,7 @@ export type ServerlessFunction = { runtime: Scalars['String']; timeoutSeconds: Scalars['Float']; toolInputSchema?: Maybe; + universalIdentifier?: Maybe; updatedAt: Scalars['DateTime']; }; diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index 7015d0f30b..2ff335f3ca 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -113,11 +113,17 @@ Application development commands. - `twenty app:generate [appPath]` — Generate the typed Twenty client for your application. -- `twenty app:logs [appPath]` — Stream application function logs. +- `twenty function:logs [appPath]` — Stream application function logs. - Options: - `-u, --functionUniversalIdentifier `: Only show logs for a specific function universal ID. - `-n, --functionName `: Only show logs for a specific function name. +- `twenty function:execute [appPath]` — Execute a serverless function with a JSON payload. + - Options: + - `-n, --functionName `: Name of the function to execute (required if `-u` not provided). + - `-u, --functionUniversalIdentifier `: Universal ID of the function to execute (required if `-n` not provided). + - `-p, --payload `: JSON payload to send to the function (default: `{}`). + Examples: ```bash @@ -140,10 +146,19 @@ twenty entity:add twenty app:generate # Watch all function logs -twenty app:logs +twenty function:logs # Watch logs for a specific function by name -twenty app:logs -n my-function +twenty function:logs -n my-function + +# Execute a function by name (with empty payload) +twenty function:execute -n my-function + +# Execute a function with a JSON payload +twenty function:execute -n my-function -p '{"name": "test"}' + +# Execute a function by universal identifier +twenty function:execute -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "value"}' ``` ## Configuration diff --git a/packages/twenty-sdk/src/cli/commands/app.command.ts b/packages/twenty-sdk/src/cli/commands/app.command.ts index f6d816ee22..388c35a5ae 100644 --- a/packages/twenty-sdk/src/cli/commands/app.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app.command.ts @@ -4,12 +4,13 @@ import type { Command } from 'commander'; import { AppBuildCommand } from './app/app-build'; import { AppDevCommand } from './app/app-dev'; import { AppGenerateCommand } from './app/app-generate'; -import { AppLogsCommand } from './app/app-logs'; import { AppSyncCommand } from './app/app-sync'; import { AppUninstallCommand } from './app/app-uninstall'; import { AuthLoginCommand } from './auth/auth-login'; import { AuthLogoutCommand } from './auth/auth-logout'; import { AuthStatusCommand } from './auth/auth-status'; +import { FunctionExecuteCommand } from './function/function-execute'; +import { FunctionLogsCommand } from './function/function-logs'; import { EntityAddCommand, isSyncableEntity, @@ -51,7 +52,8 @@ export const registerCommands = (program: Command): void => { const uninstallCommand = new AppUninstallCommand(); const addCommand = new EntityAddCommand(); const generateCommand = new AppGenerateCommand(); - const logsCommand = new AppLogsCommand(); + const logsCommand = new FunctionLogsCommand(); + const executeCommand = new FunctionExecuteCommand(); const buildCommand = new AppBuildCommand(); program @@ -113,24 +115,6 @@ export const registerCommands = (program: Command): void => { } }); - // Keeping to avoid breaking changes - program - .command('app:delete [appPath]', { hidden: true }) - .description('Delete application from Twenty') - .action(async (appPath?: string) => { - try { - const result = await uninstallCommand.execute({ - appPath: formatPath(appPath), - askForConfirmation: true, - }); - if (!result.success) { - process.exit(1); - } - } catch { - process.exit(1); - } - }); - program .command('entity:add [entityType]') .option('--path ', 'Path in which the entity should be created.') @@ -156,8 +140,9 @@ export const registerCommands = (program: Command): void => { await generateCommand.execute(formatPath(appPath)); }); + // Function commands program - .command('app:logs [appPath]') + .command('function:logs [appPath]') .option( '-u, --functionUniversalIdentifier ', 'Only show logs for the function with this universal ID', @@ -181,4 +166,45 @@ export const registerCommands = (program: Command): void => { }); }, ); + + program + .command('function:execute [appPath]') + .option( + '-p, --payload ', + 'JSON payload to send to the function', + '{}', + ) + .option( + '-u, --functionUniversalIdentifier ', + 'Universal ID of the function to execute', + ) + .option( + '-n, --functionName ', + 'Name of the function to execute', + ) + .description('Execute a serverless function with a JSON payload') + .action( + async ( + appPath?: string, + options?: { + payload?: string; + functionUniversalIdentifier?: string; + functionName?: string; + }, + ) => { + if (!options?.functionUniversalIdentifier && !options?.functionName) { + console.error( + chalk.red( + 'Error: Either --functionName (-n) or --functionUniversalIdentifier (-u) is required.', + ), + ); + process.exit(1); + } + await executeCommand.execute({ + ...options, + payload: options?.payload ?? '{}', + appPath: formatPath(appPath), + }); + }, + ); }; diff --git a/packages/twenty-sdk/src/cli/commands/function/function-execute.ts b/packages/twenty-sdk/src/cli/commands/function/function-execute.ts new file mode 100644 index 0000000000..9c396b0a8c --- /dev/null +++ b/packages/twenty-sdk/src/cli/commands/function/function-execute.ts @@ -0,0 +1,165 @@ +import chalk from 'chalk'; +import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/current-execution-directory'; +import { ApiService } from '@/cli/utilities/api/services/api.service'; +import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; +import { type ApplicationManifest } from 'twenty-shared/application'; + +export class FunctionExecuteCommand { + private apiService = new ApiService(); + + async execute({ + appPath = CURRENT_EXECUTION_DIRECTORY, + functionUniversalIdentifier, + functionName, + payload = '{}', + }: { + appPath?: string; + functionUniversalIdentifier?: string; + functionName?: string; + payload?: string; + }): Promise { + try { + let parsedPayload: Record; + try { + parsedPayload = JSON.parse(payload); + } catch { + console.error( + chalk.red('Invalid JSON payload. Please provide valid JSON.'), + ); + process.exit(1); + } + + const { manifest } = await buildManifest(appPath); + + const functionsResult = await this.apiService.findServerlessFunctions(); + if (!functionsResult.success) { + console.error( + chalk.red('Failed to fetch functions:'), + functionsResult.error instanceof Error + ? functionsResult.error.message + : functionsResult.error, + ); + process.exit(1); + } + + const appFunctions = functionsResult.data.filter( + (fn) => + fn.universalIdentifier && this.belongsToApplication(fn, manifest), + ); + + const targetFunction = appFunctions.find((fn) => { + if (functionUniversalIdentifier) { + return fn.universalIdentifier === functionUniversalIdentifier; + } + if (functionName) { + return fn.name === functionName; + } + return false; + }); + + if (!targetFunction) { + const identifier = functionUniversalIdentifier || functionName; + console.error( + chalk.red(`Function "${identifier}" not found in application.`), + ); + console.log(''); + if (appFunctions.length > 0) { + console.log(chalk.cyan('Available functions:')); + appFunctions.forEach((fn) => { + console.log( + ` - ${chalk.white(fn.name)} (${fn.universalIdentifier})`, + ); + }); + } else { + console.log( + chalk.yellow( + 'No functions found for this application. Have you synced your app with `yarn app:sync`?', + ), + ); + } + process.exit(1); + } + + console.log( + chalk.blue(`🚀 Executing function "${targetFunction.name}"...`), + ); + console.log(chalk.gray(` Payload: ${JSON.stringify(parsedPayload)}`)); + console.log(''); + + const result = await this.apiService.executeServerlessFunction({ + functionId: targetFunction.id, + payload: parsedPayload, + version: 'draft', + }); + + if (!result.success) { + console.error( + chalk.red('Execution failed:'), + result.error instanceof Error ? result.error.message : result.error, + ); + process.exit(1); + } + + const executionResult = result.data!; + + console.log(chalk.cyan('─'.repeat(60))); + console.log(chalk.cyan('Execution Result')); + console.log(chalk.cyan('─'.repeat(60))); + + const statusColor = + executionResult.status === 'SUCCESS' ? chalk.green : chalk.red; + console.log( + `${chalk.bold('Status:')} ${statusColor(executionResult.status)}`, + ); + + console.log(`${chalk.bold('Duration:')} ${executionResult.duration}ms`); + + if (executionResult.data !== undefined && executionResult.data !== null) { + console.log(''); + console.log(chalk.bold('Data:')); + console.log(chalk.white(JSON.stringify(executionResult.data, null, 2))); + } + + if (executionResult.error) { + console.log(''); + console.log(chalk.bold.red('Error:')); + console.log(chalk.red(` Type: ${executionResult.error.errorType}`)); + console.log( + chalk.red(` Message: ${executionResult.error.errorMessage}`), + ); + if (executionResult.error.stackTrace) { + console.log(''); + console.log(chalk.gray('Stack trace:')); + console.log(chalk.gray(executionResult.error.stackTrace)); + } + } + + if (executionResult.logs) { + console.log(''); + console.log(chalk.bold('Logs:')); + console.log(chalk.gray(executionResult.logs)); + } + + console.log(chalk.cyan('─'.repeat(60))); + + if (executionResult.status !== 'SUCCESS') { + process.exit(1); + } + } catch (error) { + console.error( + chalk.red('Execution failed:'), + error instanceof Error ? error.message : error, + ); + process.exit(1); + } + } + + private belongsToApplication( + fn: { universalIdentifier: string; applicationId: string | null }, + manifest: ApplicationManifest, + ): boolean { + return manifest.serverlessFunctions.some( + (manifestFn) => manifestFn.universalIdentifier === fn.universalIdentifier, + ); + } +} diff --git a/packages/twenty-sdk/src/cli/commands/app/app-logs.ts b/packages/twenty-sdk/src/cli/commands/function/function-logs.ts similarity index 98% rename from packages/twenty-sdk/src/cli/commands/app/app-logs.ts rename to packages/twenty-sdk/src/cli/commands/function/function-logs.ts index ebaaec6348..ecd94d3522 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-logs.ts +++ b/packages/twenty-sdk/src/cli/commands/function/function-logs.ts @@ -3,7 +3,7 @@ import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/constants/cu import { ApiService } from '@/cli/utilities/api/services/api.service'; import { buildManifest } from '@/cli/utilities/manifest/utils/manifest-build'; -export class AppLogsCommand { +export class FunctionLogsCommand { private apiService = new ApiService(); async execute({ diff --git a/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts b/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts index 9964336800..a8fa4db970 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/services/api.service.ts @@ -237,6 +237,135 @@ export class ApiService { } } + async findServerlessFunctions(): Promise< + ApiResponse< + Array<{ + id: string; + name: string; + universalIdentifier: string; + applicationId: string | null; + }> + > + > { + try { + const query = ` + query FindManyServerlessFunctions { + findManyServerlessFunctions { + id + name + universalIdentifier + applicationId + } + } + `; + + const response = await this.client.post( + '/metadata', + { query }, + { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + }, + }, + ); + + if (response.data.errors) { + return { + success: false, + error: response.data.errors[0]?.message || 'Failed to fetch functions', + }; + } + + return { + success: true, + data: response.data.data.findManyServerlessFunctions, + }; + } catch (error) { + return { + success: false, + error, + }; + } + } + + async executeServerlessFunction({ + functionId, + payload, + version = 'latest', + }: { + functionId: string; + payload: Record; + version?: string; + }): Promise< + ApiResponse<{ + data: unknown; + logs: string; + duration: number; + status: string; + error?: { + errorType: string; + errorMessage: string; + stackTrace: string; + }; + }> + > { + try { + const mutation = ` + mutation ExecuteOneServerlessFunction($input: ExecuteServerlessFunctionInput!) { + executeOneServerlessFunction(input: $input) { + data + logs + duration + status + error + } + } + `; + + const variables = { + input: { + id: functionId, + payload, + version, + }, + }; + + const response = await this.client.post( + '/metadata', + { + query: mutation, + variables, + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + }, + }, + ); + + if (response.data.errors) { + return { + success: false, + error: + response.data.errors[0]?.message || + 'Failed to execute serverless function', + }; + } + + return { + success: true, + data: response.data.data.executeOneServerlessFunction, + }; + } catch (error) { + return { + success: false, + error, + }; + } + } + async subscribeToLogs({ applicationUniversalIdentifier, functionUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts index 8a36af98cf..981aa0f8a4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto.ts @@ -96,6 +96,11 @@ export class ServerlessFunctionDTO { @Field(() => UUIDScalarType, { nullable: true }) applicationId?: string; + @IsUUID() + @IsOptional() + @Field(() => UUIDScalarType, { nullable: true }) + universalIdentifier?: string; + @HideField() workspaceId: string;