From 53c314d0fa08dbfcc607fd5f1954b1af676b3248 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 18 Feb 2026 16:38:22 +0100 Subject: [PATCH] 2094 extensibility define postinstall orand preinstall function to run in application (#18037) - add a new optional key `postInstallLogicFunctionUniversalIdentifier` in applicationConfig - seed postInstall function in create-twenty-app - update execute:function options - update doc --- packages/create-twenty-app/README.md | 4 ++ .../src/utils/app-template.ts | 41 ++++++++++++- .../developers/extend/capabilities/apps.mdx | 60 ++++++++++++++++++- packages/twenty-sdk/README.md | 8 ++- .../src/cli/commands/app-command.ts | 10 +++- .../src/cli/commands/app/app-uninstall.ts | 4 +- .../logic-function/logic-function-execute.ts | 16 ++++- .../logic-function/logic-function-logs.ts | 4 +- .../build/manifest/manifest-reader.ts | 21 +++++++ .../entity/entity-logic-function-template.ts | 1 - .../src/application/applicationType.ts | 1 + 11 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index 00b29fd23b4..b02f851816d 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -54,6 +54,9 @@ yarn twenty function:logs # Execute a function with a JSON payload yarn twenty function:execute -n my-function -p '{"key": "value"}' +# Execute the post-install function +yarn twenty function:execute --postInstall + # Uninstall the application from the current workspace yarn twenty app:uninstall ``` @@ -63,6 +66,7 @@ yarn twenty app:uninstall - `application-config.ts` - Application metadata configuration - `roles/default-role.ts` - Default role for logic functions - `logic-functions/hello-world.ts` - Example logic function with HTTP trigger + - `logic-functions/post-install.ts` - Post-install logic function (runs after app installation) - `front-components/hello-world.tsx` - Example front component - TypeScript configuration - A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index ea42b898684..1c2d28e62cb 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -49,6 +49,12 @@ export const copyBaseApplicationProject = async ({ fileName: 'hello-world.ts', }); + await createDefaultPostInstallFunction({ + appDirectory: sourceFolderPath, + fileFolder: 'logic-functions', + fileName: 'post-install.ts', + }); + await createApplicationConfig({ displayName: appDisplayName, description: appDescription, @@ -196,7 +202,6 @@ const handler = async (): Promise<{ message: string }> => { return { message: 'Hello, World!' }; }; -// Logic function handler - rename and implement your logic export default defineLogicFunction({ universalIdentifier: '${universalIdentifier}', name: 'hello-world-logic-function', @@ -215,6 +220,38 @@ export default defineLogicFunction({ await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); }; +const createDefaultPostInstallFunction = async ({ + appDirectory, + fileFolder, + fileName, +}: { + appDirectory: string; + fileFolder?: string; + fileName: string; +}) => { + const universalIdentifier = v4(); + + const content = `import { defineLogicFunction } from 'twenty-sdk'; + +export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}'; + +const handler = async (): Promise => { + console.log('Post install logic function executed successfully!'); +}; + +export default defineLogicFunction({ + universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER, + name: 'post-install', + description: 'Runs after installation to set up the application.', + timeoutSeconds: 300, + handler, +}); +`; + + await fs.ensureDir(join(appDirectory, fileFolder ?? '')); + await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); +}; + const createApplicationConfig = async ({ displayName, description, @@ -230,12 +267,14 @@ const createApplicationConfig = async ({ }) => { const content = `import { defineApplication } from 'twenty-sdk'; import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'; +import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install'; export default defineApplication({ universalIdentifier: '${v4()}', displayName: '${displayName}', description: '${description ?? ''}', defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER, }); `; diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index 76786addfc1..33575baeea7 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -53,6 +53,9 @@ yarn twenty function:logs # Execute a function by name yarn twenty function:execute -n my-function -p '{"name": "test"}' +# Execute the post-install function +yarn twenty function:execute --postInstall + # Uninstall the application from the current workspace yarn twenty app:uninstall @@ -69,7 +72,7 @@ When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder: - Copies a minimal base application into `my-twenty-app/` - Adds a local `twenty-sdk` dependency and Yarn 4 configuration - Creates config files and scripts wired to the `twenty` CLI -- Generates a default application config and a default function role +- Generates a default application config, a default function role, and a post-install function A freshly scaffolded app looks like this: @@ -91,7 +94,8 @@ my-twenty-app/ ├── roles/ │ └── default-role.ts # Default role for logic functions ├── logic-functions/ - │ └── hello-world.ts # Example logic function + │ ├── hello-world.ts # Example logic function + │ └── post-install.ts # Post-install logic function └── front-components/ └── hello-world.tsx # Example front component ``` @@ -289,6 +293,7 @@ Every app has a single `application-config.ts` file that describes: - **Who the app is**: identifiers, display name, and description. - **How its functions run**: which role they use for permissions. - **(Optional) variables**: key–value pairs exposed to your functions as environment variables. +- **(Optional) post-install function**: a logic function that runs after the app is installed. Use `defineApplication()` to define your application configuration: @@ -296,6 +301,7 @@ Use `defineApplication()` to define your application configuration: // src/application-config.ts import { defineApplication } from 'twenty-sdk'; import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'; +import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install'; export default defineApplication({ universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', @@ -311,6 +317,7 @@ export default defineApplication({ }, }, defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER, }); ``` @@ -318,6 +325,7 @@ Notes: - `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs. - `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`). - `defaultRoleUniversalIdentifier` must match the role file (see below). +- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions). #### Roles and permissions @@ -450,6 +458,54 @@ Notes: - The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions. - You can mix multiple trigger types in a single function. +### Post-install functions + +A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings. + +When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`: + +```typescript +// src/logic-functions/post-install.ts +import { defineLogicFunction } from 'twenty-sdk'; + +export const POST_INSTALL_UNIVERSAL_IDENTIFIER = ''; + +const handler = async (): Promise => { + console.log('Post install logic function executed successfully!'); +}; + +export default defineLogicFunction({ + universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER, + name: 'post-install', + description: 'Runs after installation to set up the application.', + timeoutSeconds: 300, + handler, +}); +``` + +The function is wired into your app by referencing its universal identifier in `application-config.ts`: + +```typescript +import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install'; + +export default defineApplication({ + // ... + postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER, +}); +``` + +You can also manually execute the post-install function at any time using the CLI: + +```bash filename="Terminal" +yarn twenty function:execute --postInstall +``` + +Key points: +- Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function. +- The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation. +- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding. +- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`. + ### Route trigger payload diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index 4cb04ca94b6..a319d2c3f16 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -148,8 +148,9 @@ Application development commands. - `twenty function:execute [appPath]` — Execute a logic 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). + - `--postInstall`: Execute the post-install logic function defined in the application config (required if `-n` and `-u` not provided). + - `-n, --functionName `: Name of the function to execute (required if `--postInstall` and `-u` not provided). + - `-u, --functionUniversalIdentifier `: Universal ID of the function to execute (required if `--postInstall` and `-n` not provided). - `-p, --payload `: JSON payload to send to the function (default: `{}`). Examples: @@ -187,6 +188,9 @@ 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"}' + +# Execute the post-install function +twenty function:execute --postInstall ``` ## Configuration diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts index 095b17d4002..b468c5c0eb0 100644 --- a/packages/twenty-sdk/src/cli/commands/app-command.ts +++ b/packages/twenty-sdk/src/cli/commands/app-command.ts @@ -129,6 +129,7 @@ export const registerCommands = (program: Command): void => { program .command('function:execute [appPath]') + .option('--postInstall', 'Execute post-install logic function if defined') .option( '-p, --payload ', 'JSON payload to send to the function', @@ -147,15 +148,20 @@ export const registerCommands = (program: Command): void => { async ( appPath?: string, options?: { + postInstall?: boolean; payload?: string; functionUniversalIdentifier?: string; functionName?: string; }, ) => { - if (!options?.functionUniversalIdentifier && !options?.functionName) { + if ( + !options?.postInstall && + !options?.functionUniversalIdentifier && + !options?.functionName + ) { console.error( chalk.red( - 'Error: Either --functionName (-n) or --functionUniversalIdentifier (-u) is required.', + 'Error: Either --postInstall or --functionName (-n) or --functionUniversalIdentifier (-u) is required.', ), ); process.exit(1); diff --git a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts b/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts index ae28ed8a704..e51e91a6c28 100644 --- a/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts +++ b/packages/twenty-sdk/src/cli/commands/app/app-uninstall.ts @@ -3,7 +3,7 @@ import { type ApiResponse } from '@/cli/utilities/api/api-response-type'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import chalk from 'chalk'; import inquirer from 'inquirer'; -import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; +import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader'; export class AppUninstallCommand { private apiService = new ApiService(); @@ -25,7 +25,7 @@ export class AppUninstallCommand { process.exit(1); } - const { manifest } = await buildManifest(appPath); + const manifest = await readManifestFromFile(appPath); if (!manifest) { return { success: false, error: 'Build failed' }; diff --git a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts b/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts index dd914f4f6de..aa4c4b18cbc 100644 --- a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts +++ b/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-execute.ts @@ -3,18 +3,20 @@ import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-exec import chalk from 'chalk'; import { type Manifest } from 'twenty-shared/application'; import { isDefined } from 'twenty-shared/utils'; -import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; +import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader'; export class LogicFunctionExecuteCommand { private apiService = new ApiService(); async execute({ appPath = CURRENT_EXECUTION_DIRECTORY, + postInstall = false, functionUniversalIdentifier, functionName, payload = '{}', }: { appPath?: string; + postInstall?: boolean; functionUniversalIdentifier?: string; functionName?: string; payload?: string; @@ -30,7 +32,7 @@ export class LogicFunctionExecuteCommand { process.exit(1); } - const { manifest } = await buildManifest(appPath); + const manifest = await readManifestFromFile(appPath); if (!manifest) { console.error(chalk.red('Failed to build manifest.')); @@ -54,6 +56,12 @@ export class LogicFunctionExecuteCommand { ); const targetFunction = appFunctions.find((fn) => { + if (postInstall) { + return ( + fn.universalIdentifier === + manifest.application.postInstallLogicFunctionUniversalIdentifier + ); + } if (functionUniversalIdentifier) { return fn.universalIdentifier === functionUniversalIdentifier; } @@ -64,7 +72,9 @@ export class LogicFunctionExecuteCommand { }); if (!targetFunction) { - const identifier = functionUniversalIdentifier || functionName; + const identifier = postInstall + ? 'post install' + : functionUniversalIdentifier || functionName; console.error( chalk.red(`Function "${identifier}" not found in application.`), ); diff --git a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-logs.ts b/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-logs.ts index 86cbcb8a779..49e66cbe9b2 100644 --- a/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-logs.ts +++ b/packages/twenty-sdk/src/cli/commands/logic-function/logic-function-logs.ts @@ -1,7 +1,7 @@ import { ApiService } from '@/cli/utilities/api/api-service'; import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory'; import chalk from 'chalk'; -import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; +import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader'; export class LogicFunctionLogsCommand { private apiService = new ApiService(); @@ -16,7 +16,7 @@ export class LogicFunctionLogsCommand { functionName?: string; }): Promise { try { - const { manifest } = await buildManifest(appPath); + const manifest = await readManifestFromFile(appPath); if (!manifest) { process.exit(1); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts new file mode 100644 index 00000000000..e3778badd13 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-reader.ts @@ -0,0 +1,21 @@ +import * as fs from 'fs-extra'; +import path from 'path'; +import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application'; +import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build'; + +export const readManifestFromFile = async ( + appPath: string, +): Promise => { + const outputDir = path.join(appPath, OUTPUT_DIR); + await fs.ensureDir(outputDir); + + const manifestPath = path.join(outputDir, 'manifest.json'); + + if (!(await fs.pathExists(manifestPath))) { + const { manifest } = await buildManifest(appPath); + + return manifest; + } + + return await fs.readJson(manifestPath); +}; diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts index a67a218e925..d61eae9de3f 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-logic-function-template.ts @@ -46,7 +46,6 @@ export default defineLogicFunction({ // databaseEventTriggerSettings: { // eventName: 'objectName.created', // }, - ], }); `; }; diff --git a/packages/twenty-shared/src/application/applicationType.ts b/packages/twenty-shared/src/application/applicationType.ts index 0473def806e..64f4ebd3743 100644 --- a/packages/twenty-shared/src/application/applicationType.ts +++ b/packages/twenty-shared/src/application/applicationType.ts @@ -14,6 +14,7 @@ export type ApplicationMarketplaceData = { export type ApplicationManifest = SyncableEntityOptions & { defaultRoleUniversalIdentifier: string; + postInstallLogicFunctionUniversalIdentifier?: string; displayName: string; description: string; icon?: string;