1635 extensibilitytwenty cli app vars (#15143)

- Update twenty-cli to support application env variable definition
- Update twenty-server to create a new `core.applicationVariable` entity
to store env variables and provide env var when executing serverless
function
- Update twenty-front to support application environment variable value
setting

<img width="1044" height="660" alt="image"
src="https://github.com/user-attachments/assets/24c3d323-5370-4a80-8174-fc4653cc3c22"
/>

<img width="1178" height="662" alt="image"
src="https://github.com/user-attachments/assets/c124f423-8ed8-4246-ae5b-a9bd6672c7dc"
/>

<img width="1163" height="823" alt="image"
src="https://github.com/user-attachments/assets/fb7425a3-facc-4895-a5eb-8a8e278e0951"
/>

<img width="1087" height="696" alt="image"
src="https://github.com/user-attachments/assets/113da8a2-5590-433c-b1b3-5ed3137f24ca"
/>

<img width="1512" height="715" alt="image"
src="https://github.com/user-attachments/assets/1d2110b7-301d-4f21-a45c-ddd54d6e3391"
/>

<img width="1287" height="581" alt="image"
src="https://github.com/user-attachments/assets/353b16c6-0527-444c-87d6-51447a96cbc7"
/>
This commit is contained in:
martmull
2025-10-17 10:54:38 +02:00
committed by GitHub
parent 54baa47fbb
commit d2e7f2a910
58 changed files with 1305 additions and 359 deletions
@@ -1 +1,2 @@
.yarn/install-state.gz
.env
@@ -11,7 +11,17 @@
"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"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,7 +1,5 @@
import axios from 'axios';
const TWENTY_API_KEY = '<SET_YOUR_TWENTY_API>';
export const main = async (params: { recipient: string }): Promise<object> => {
const { recipient } = params;
@@ -10,7 +8,7 @@ export const main = async (params: { recipient: string }): Promise<object> => {
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TWENTY_API_KEY}`,
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
@@ -20,8 +18,9 @@ export const main = async (params: { recipient: string }): Promise<object> => {
console.log(`New post card to "${recipient}" created`);
return { data };
return data;
} catch (error) {
console.error(error);
throw error;
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-cli",
"version": "0.1.2-alpha",
"version": "0.1.2-beta",
"description": "Command-line interface for Twenty application development",
"main": "dist/cli.js",
"bin": {
@@ -36,6 +36,34 @@
"title": "The application's license",
"description": "Currently only MIT is accepted, although more licenses will probably be available in the future."
},
"env": {
"type": "object",
"title": "Environment Variables",
"description": "Key-value pairs defining environment variables available to all serverless functions.",
"patternProperties": {
"^[A-Z_][A-Z0-9_]*$": {
"type": "object",
"title": "Environment Variable Definition",
"properties": {
"description": {
"type": "string",
"description": "Description for this environment variable."
},
"value": {
"type": "string",
"description": "Default value for this environment variable"
},
"isSecret": {
"type": "boolean",
"description": "If true, the value will be treated as sensitive and hidden from logs or UI."
}
},
"required": ["isSecret"],
"additionalProperties": false
}
},
"additionalProperties": false
},
"engines": {
"type": "object",
"title": "The application's engines",
@@ -0,0 +1,15 @@
# Set environment values for your application here.
# Use the format: KEY=value
#
# These variables are automatically loaded when running your serverless functions.
# 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
# functions at runtime.
#
# Example:
# API_TOKEN=your-api-token
# TIMEOUT_MS=3000
@@ -1 +1,2 @@
.yarn/install-state.gz
.env
@@ -6,5 +6,8 @@
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2"
"packageManager": "yarn@4.9.2",
"devDependencies": {
"@types/node": "^24.7.2"
}
}
@@ -1,3 +1,4 @@
import dotenv from 'dotenv';
import assert from 'assert';
import * as fs from 'fs-extra';
import * as path from 'path';
@@ -111,12 +112,44 @@ export const loadManifest = async (
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');
await validateSchema('appManifest', rawPackageJson, packageJsonPath);
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'),
@@ -134,10 +167,10 @@ export const loadManifest = async (
);
return {
packageJson: rawPackageJson,
packageJson,
yarnLock: rawYarnLock,
manifest: {
...rawPackageJson,
...packageJson,
agents,
objects,
serverlessFunctions,
@@ -1,6 +1,6 @@
import { defineConfig, devices } from '@playwright/test';
import { config } from 'dotenv';
import path from 'path';
import * as path from 'path';
const envResult = config({
path: path.resolve(__dirname, '.env'),
@@ -10,6 +10,7 @@ module.exports = {
'./src/modules/views/graphql/**/*.{ts,tsx}',
'./src/modules/ai/graphql/**/*.{ts,tsx}',
'./src/modules/applications/graphql/**/*.{ts,tsx}',
'./src/modules/application-variables/graphql/**/*.{ts,tsx}',
'./src/modules/workspace/graphql/**/*.{ts,tsx}',
'./src/modules/workspace-member/graphql/**/*.{ts,tsx}',
@@ -204,11 +204,22 @@ export type AppTokenEdge = {
export type Application = {
__typename?: 'Application';
agents: Array<Agent>;
applicationVariables: Array<ApplicationVariable>;
description: Scalars['String'];
id: Scalars['UUID'];
name: Scalars['String'];
objects: Array<Object>;
serverlessFunctions: Array<ServerlessFunction>;
version: Scalars['String'];
};
export type ApplicationVariable = {
__typename?: 'ApplicationVariable';
description: Scalars['String'];
id: Scalars['UUID'];
isSecret: Scalars['Boolean'];
key: Scalars['String'];
value: Scalars['String'];
};
export type ApprovedAccessDomain = {
@@ -1841,6 +1852,7 @@ export type Mutation = {
updateDatabaseConfigVariable: Scalars['Boolean'];
updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneAgent: Agent;
updateOneApplicationVariable: Scalars['Boolean'];
updateOneCronTrigger: CronTrigger;
updateOneDatabaseEventTrigger: DatabaseEventTrigger;
updateOneField: Field;
@@ -2584,6 +2596,13 @@ export type MutationUpdateOneAgentArgs = {
};
export type MutationUpdateOneApplicationVariableArgs = {
applicationId: Scalars['UUID'];
key: Scalars['String'];
value: Scalars['String'];
};
export type MutationUpdateOneCronTriggerArgs = {
input: UpdateCronTriggerInput;
};
@@ -4777,19 +4796,28 @@ export type TrackAnalyticsMutationVariables = Exact<{
export type TrackAnalyticsMutation = { __typename?: 'Mutation', trackAnalytics: { __typename?: 'Analytics', success: boolean } };
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, 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 UpdateOneApplicationVariableMutationVariables = Exact<{
key: Scalars['String'];
value: Scalars['String'];
applicationId: Scalars['UUID'];
}>;
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 FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description: string }> };
export type FindManyApplicationsQuery = { __typename?: 'Query', findManyApplications: Array<{ __typename?: 'Application', id: string, name: string, description: string, version: string }> };
export type FindOneApplicationQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, 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>, 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'];
@@ -6286,6 +6314,14 @@ export const ApplicationFieldsFragmentDoc = gql`
id
name
description
version
applicationVariables {
id
key
value
description
isSecret
}
agents {
...AgentFields
}
@@ -7323,12 +7359,50 @@ export function useTrackAnalyticsMutation(baseOptions?: Apollo.MutationHookOptio
export type TrackAnalyticsMutationHookResult = ReturnType<typeof useTrackAnalyticsMutation>;
export type TrackAnalyticsMutationResult = Apollo.MutationResult<TrackAnalyticsMutation>;
export type TrackAnalyticsMutationOptions = Apollo.BaseMutationOptions<TrackAnalyticsMutation, TrackAnalyticsMutationVariables>;
export const UpdateOneApplicationVariableDocument = gql`
mutation UpdateOneApplicationVariable($key: String!, $value: String!, $applicationId: UUID!) {
updateOneApplicationVariable(
key: $key
value: $value
applicationId: $applicationId
)
}
`;
export type UpdateOneApplicationVariableMutationFn = Apollo.MutationFunction<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>;
/**
* __useUpdateOneApplicationVariableMutation__
*
* To run a mutation, you first call `useUpdateOneApplicationVariableMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateOneApplicationVariableMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateOneApplicationVariableMutation, { data, loading, error }] = useUpdateOneApplicationVariableMutation({
* variables: {
* key: // value for 'key'
* value: // value for 'value'
* applicationId: // value for 'applicationId'
* },
* });
*/
export function useUpdateOneApplicationVariableMutation(baseOptions?: Apollo.MutationHookOptions<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>(UpdateOneApplicationVariableDocument, options);
}
export type UpdateOneApplicationVariableMutationHookResult = ReturnType<typeof useUpdateOneApplicationVariableMutation>;
export type UpdateOneApplicationVariableMutationResult = Apollo.MutationResult<UpdateOneApplicationVariableMutation>;
export type UpdateOneApplicationVariableMutationOptions = Apollo.BaseMutationOptions<UpdateOneApplicationVariableMutation, UpdateOneApplicationVariableMutationVariables>;
export const FindManyApplicationsDocument = gql`
query FindManyApplications {
findManyApplications {
id
name
description
version
}
}
`;
@@ -204,11 +204,22 @@ export type AppTokenEdge = {
export type Application = {
__typename?: 'Application';
agents: Array<Agent>;
applicationVariables: Array<ApplicationVariable>;
description: Scalars['String'];
id: Scalars['UUID'];
name: Scalars['String'];
objects: Array<Object>;
serverlessFunctions: Array<ServerlessFunction>;
version: Scalars['String'];
};
export type ApplicationVariable = {
__typename?: 'ApplicationVariable';
description: Scalars['String'];
id: Scalars['UUID'];
isSecret: Scalars['Boolean'];
key: Scalars['String'];
value: Scalars['String'];
};
export type ApprovedAccessDomain = {
@@ -1793,6 +1804,7 @@ export type Mutation = {
updateDatabaseConfigVariable: Scalars['Boolean'];
updateLabPublicFeatureFlag: FeatureFlagDto;
updateOneAgent: Agent;
updateOneApplicationVariable: Scalars['Boolean'];
updateOneCronTrigger: CronTrigger;
updateOneDatabaseEventTrigger: DatabaseEventTrigger;
updateOneField: Field;
@@ -2500,6 +2512,13 @@ export type MutationUpdateOneAgentArgs = {
};
export type MutationUpdateOneApplicationVariableArgs = {
applicationId: Scalars['UUID'];
key: Scalars['String'];
value: Scalars['String'];
};
export type MutationUpdateOneCronTriggerArgs = {
input: UpdateCronTriggerInput;
};
@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const UPDATE_ONE_APPLICATION_VARIABLE = gql`
mutation UpdateOneApplicationVariable(
$key: String!
$value: String!
$applicationId: UUID!
) {
updateOneApplicationVariable(
key: $key
value: $value
applicationId: $applicationId
)
}
`;
@@ -11,6 +11,14 @@ export const APPLICATION_FRAGMENT = gql`
id
name
description
version
applicationVariables {
id
key
value
description
isSecret
}
agents {
...AgentFields
}
@@ -6,6 +6,7 @@ export const FIND_MANY_APPLICATIONS = gql`
id
name
description
version
}
}
`;
@@ -1,6 +1,5 @@
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { type EditorProps, type Monaco } from '@monaco-editor/react';
import dotenv from 'dotenv';
import { type editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { useParams } from 'react-router-dom';
@@ -35,7 +34,6 @@ export const SettingsServerlessFunctionCodeEditor = ({
});
const currentFile = files.find((file) => file.path === currentFilePath);
const environmentVariablesFile = files.find((file) => file.path === '.env');
const handleEditorDidMount = async (
editor: editor.IStandaloneCodeEditor,
@@ -67,11 +65,10 @@ export const SettingsServerlessFunctionCodeEditor = ({
target: monaco.languages.typescript.ScriptTarget.ESNext,
});
if (isDefined(environmentVariablesFile)) {
const environmentVariables = dotenv.parse(
environmentVariablesFile.content,
);
// TODO load that with proper env variables
const environmentVariables = {};
if (isDefined(environmentVariables)) {
const environmentDefinition = `
declare namespace NodeJS {
interface ProcessEnv {
@@ -25,7 +25,7 @@ export const SettingsServerlessFunctionNewForm = ({
return (
<Section>
<H2Title title="About" description="Name and set your function" />
<H2Title title="About" description="Name and describe your function" />
<StyledInputsContainer>
<SettingsTextInput
instanceId={nameTextInputId}
@@ -0,0 +1,33 @@
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { LinkChip } from 'twenty-ui/components';
import { getSettingsPath } from 'twenty-shared/utils';
import { useParams } from 'react-router-dom';
import { t } from '@lingui/core/macro';
export const SettingsServerlessFunctionTabEnvironmentVariablesSection = () => {
const { applicationId = '' } = useParams<{ applicationId: string }>();
return (
<Section>
<H2Title
title={t`Environment Variables`}
description="Accessible in your function via process.env.KEY"
/>
Environment variables are defined at application level for all functions.
Please check{' '}
<LinkChip
label={'application detail page'}
to={getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'settings',
)}
/>
.
</Section>
);
};
@@ -44,11 +44,9 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
const HeaderTabList = (
<StyledTabList
tabs={files
.filter((file) => file.path !== '.env')
.map((file) => {
return { id: file.path, title: file.path.split('/').at(-1) || '' };
})}
tabs={files.map((file) => {
return { id: file.path, title: file.path.split('/').at(-1) || '' };
})}
componentInstanceId={SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID}
/>
);
@@ -1,17 +1,13 @@
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/SettingsServerlessFunctionTabEnvironmentVariablesSection';
export const SettingsServerlessFunctionSettingsTab = ({
formValues,
onChange,
onCodeChange,
serverlessFunctionId,
}: {
formValues: ServerlessFunctionFormValues;
serverlessFunctionId: string;
onChange: (key: string) => (value: string) => void;
onCodeChange: (filePath: string, value: string) => void;
}) => {
return (
<>
@@ -20,10 +16,7 @@ export const SettingsServerlessFunctionSettingsTab = ({
onChange={onChange}
readonly
/>
<SettingsServerlessFunctionTabEnvironmentVariablesSection
onCodeChange={onCodeChange}
serverlessFunctionId={serverlessFunctionId}
/>
<SettingsServerlessFunctionTabEnvironmentVariablesSection />
</>
);
};
@@ -1,4 +1,4 @@
import { type EnvironmentVariable } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
import { type EnvironmentVariable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow';
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -1,147 +0,0 @@
import { SettingsServerlessFunctionTabEnvironmentVariableTableRow } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariableTableRow';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { useMemo, useState } from 'react';
import { H2Title, IconPlus, IconSearch } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
import { v4 } from 'uuid';
import { serverlessFunctionEnvVarFamilyState } from '@/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState';
import { useRecoilState } from 'recoil';
const StyledSearchInput = styled(SettingsTextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-top: ${({ theme }) => theme.spacing(5)};
}
`;
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableRow = styled(TableRow)`
grid-template-columns: 180px auto 32px;
`;
export type EnvironmentVariable = { id: string; key: string; value: string };
export const SettingsServerlessFunctionTabEnvironmentVariablesSection = ({
onCodeChange,
serverlessFunctionId,
}: {
serverlessFunctionId: string;
onCodeChange: (filePath: string, value: string) => void;
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [newEnvVarAdded, setNewEnvVarAdded] = useState(false);
const [envVariables, setEnvVariables] = useRecoilState(
serverlessFunctionEnvVarFamilyState(serverlessFunctionId),
);
const filteredEnvVariable = useMemo(() => {
return envVariables.filter(
({ key, value }) =>
key.toLowerCase().includes(searchTerm.toLowerCase()) ||
value.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [envVariables, searchTerm]);
const getFormattedEnvironmentVariables = (
newEnvVariables: EnvironmentVariable[],
) => {
return [...newEnvVariables]
.reverse()
.reduce(
(acc, { key, value }) =>
key.length > 0 && value.length > 0 ? `${key}=${value}\n${acc}` : acc,
'',
);
};
const onEnvVarChange = (newEnvVariable: EnvironmentVariable) => {
const newEnvVariables: EnvironmentVariable[] = [];
for (const envVariable of envVariables) {
if (envVariable.id === newEnvVariable.id) {
newEnvVariables.push(newEnvVariable);
} else if (envVariable.key !== newEnvVariable.key) {
newEnvVariables.push(envVariable);
}
}
setEnvVariables(newEnvVariables);
onCodeChange('.env', getFormattedEnvironmentVariables(newEnvVariables));
};
return (
<Section>
<H2Title
title="Environment variables"
description="Set your function environment variables"
/>
<StyledSearchInput
instanceId="serverless-function-env-var-search"
LeftIcon={IconSearch}
placeholder="Search a variable"
value={searchTerm}
onChange={setSearchTerm}
/>
<Table>
<StyledTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Value</TableHeader>
<TableHeader></TableHeader>
</StyledTableRow>
{filteredEnvVariable.length > 0 && (
<StyledTableBody>
{filteredEnvVariable.map((envVariable) => (
<SettingsServerlessFunctionTabEnvironmentVariableTableRow
key={envVariable.id}
envVariable={envVariable}
initialEditMode={newEnvVarAdded && envVariable.value === ''}
onChange={onEnvVarChange}
onDelete={() => {
const newEnvVariables = envVariables.filter(
({ id }) => id !== envVariable.id,
);
setEnvVariables(newEnvVariables);
onCodeChange(
'.env',
getFormattedEnvironmentVariables(newEnvVariables),
);
}}
/>
))}
</StyledTableBody>
)}
</Table>
<StyledButtonContainer>
<Button
Icon={IconPlus}
title="Add Variable"
size="small"
variant="secondary"
onClick={() => {
setEnvVariables((prevState) => {
return [...prevState, { id: v4(), key: '', value: '' }];
});
setNewEnvVarAdded(true);
}}
/>
</StyledButtonContainer>
</Section>
);
};
@@ -4,14 +4,11 @@ import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hoo
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/code-action/states/serverlessFunctionTestDataFamilyState';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
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 { serverlessFunctionEnvVarFamilyState } from '@/settings/serverless-functions/states/serverlessFunctionEnvVarFamilyState';
import dotenv from 'dotenv';
import { v4 } from 'uuid';
export type ServerlessFunctionNewFormValues = {
name: string;
@@ -44,10 +41,6 @@ export const useServerlessFunctionUpdateFormState = ({
code: { src: { 'index.ts': '' } },
});
const setEnvVar = useSetRecoilState(
serverlessFunctionEnvVarFamilyState(serverlessFunctionId),
);
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
@@ -74,17 +67,6 @@ export const useServerlessFunctionUpdateFormState = ({
...newState,
}));
const environmentVariables =
code?.['.env'] && typeof code?.['.env'] === 'string'
? dotenv.parse(code['.env'])
: {};
const environmentVariablesList = Object.entries(
environmentVariables,
).map(([key, value]) => ({ id: v4(), key, value }));
setEnvVar(environmentVariablesList);
if (serverlessFunctionTestData.shouldInitInput) {
const sourceCode =
data?.getServerlessFunctionSourceCode?.[SOURCE_FOLDER_NAME]?.[
@@ -1,10 +0,0 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
import { type EnvironmentVariable } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection';
export const serverlessFunctionEnvVarFamilyState = createFamilyState<
EnvironmentVariable[],
string
>({
key: 'serverlessFunctionEnvVarFamilyState',
defaultValue: [],
});
@@ -5,58 +5,60 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { useParams } from 'react-router-dom';
import { useFindOneApplicationQuery } from '~/generated-metadata/graphql';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/display';
import { IconInfoCircle, IconSettings, IconBroadcast } from 'twenty-ui/display';
import { SettingsApplicationDetailSkeletonLoader } from '~/pages/settings/applications/components/SettingsApplicationDetailSkeletonLoader';
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable';
import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab';
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
const APPLICATION_DETAIL_ID = 'application-detail-id';
export const SettingsApplicationDetails = () => {
const { applicationId = '' } = useParams<{ applicationId: string }>();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
APPLICATION_DETAIL_ID,
);
const { data, loading } = useFindOneApplicationQuery({
const { data } = useFindOneApplicationQuery({
variables: { id: applicationId },
skip: !applicationId,
});
if (!isDefined(data?.findOneApplication)) {
return;
}
const application = data?.findOneApplication;
const {
name: applicationName,
serverlessFunctions,
agents,
objects,
} = data.findOneApplication;
const applicationName = application?.name;
const shouldDisplayServerlessFunctions =
!loading &&
isDefined(serverlessFunctions) &&
serverlessFunctions?.length > 0;
const shouldDisplayAgents =
!loading && isDefined(agents) && agents.length > 0;
const shouldDisplayObjects =
!loading && isDefined(objects) && objects.length > 0;
const objectIds = objects.map((object) => object.id);
const applicationObjectMetadataItems = shouldDisplayObjects
? objectMetadataItems.filter((objectMetadataItem) =>
objectIds.includes(objectMetadataItem.id),
)
: [];
const title = loading
const title = !isDefined(application)
? t`Application details`
: data?.findOneApplication?.name;
: applicationName;
const tabs = [
{ id: 'about', title: 'About', Icon: IconInfoCircle },
{ id: 'settings', title: 'Settings', Icon: IconSettings },
{ id: 'content', title: 'Content', Icon: IconBroadcast },
];
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'about':
return <SettingsApplicationDetailAboutTab application={application} />;
case 'settings':
return (
<SettingsApplicationDetailSettingsTab application={application} />
);
case 'content':
return (
<SettingsApplicationDetailContentTab application={application} />
);
default:
return <></>;
}
};
return (
<SubMenuTopBarContainer
@@ -70,43 +72,15 @@ export const SettingsApplicationDetails = () => {
children: t`Applications`,
href: getSettingsPath(SettingsPath.Applications),
},
{ children: `${applicationName}` },
{ children: `${title}` },
]}
>
<SettingsPageContainer>
{loading && <SettingsApplicationDetailSkeletonLoader />}
{shouldDisplayServerlessFunctions && (
<Section>
<H2Title
title={t`Application serverless functions`}
description={t`Serverless functions created by application`}
/>
<SettingsServerlessFunctionsTable
serverlessFunctions={serverlessFunctions}
/>
</Section>
)}
{shouldDisplayAgents && (
<Section>
<H2Title
title={t`Application agents`}
description={t`Agents created by application`}
/>
<SettingsAIAgentsTable agents={agents} withSearchBar={false} />
</Section>
)}
{shouldDisplayObjects && (
<Section>
<H2Title
title={t`Application objects`}
description={t`Objects created by application`}
/>
<SettingsObjectTable
activeObjects={applicationObjectMetadataItems}
inactiveObjects={[]}
withSearchBar={false}
/>
</Section>
<TabList tabs={tabs} componentInstanceId={APPLICATION_DETAIL_ID} />
{!isDefined(application) ? (
<SettingsApplicationDetailSkeletonLoader />
) : (
renderActiveTabContent()
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
@@ -16,20 +16,6 @@ const StyledFormSection = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconNameRow = styled.div`
align-items: flex-start;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledIconContainer = styled.div`
flex-shrink: 0;
`;
const StyledNameContainer = styled.div`
flex: 1;
`;
export const SettingsApplicationDetailSkeletonLoader = () => {
const theme = useTheme();
@@ -41,21 +27,6 @@ export const SettingsApplicationDetailSkeletonLoader = () => {
>
<StyledSkeletonContainer>
<StyledFormSection>
<StyledIconNameRow>
<StyledIconContainer>
<Skeleton
width={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
</StyledIconContainer>
<StyledNameContainer>
<Skeleton
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
width="100%"
/>
</StyledNameContainer>
</StyledIconNameRow>
<Skeleton
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
width="100%"
@@ -75,8 +46,6 @@ export const SettingsApplicationDetailSkeletonLoader = () => {
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
width="100%"
/>
<Skeleton height={120} width="100%" />
</StyledFormSection>
</StyledSkeletonContainer>
</SkeletonTheme>
@@ -0,0 +1,53 @@
import { SettingsAdminTableCard } from '@/settings/admin-panel/components/SettingsAdminTableCard';
import { SettingsAdminVersionDisplay } from '@/settings/admin-panel/components/SettingsAdminVersionDisplay';
import { t } from '@lingui/core/macro';
import { IconCircleDot, IconStatusChange } from 'twenty-ui/display';
import type { Application } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
export const SettingsApplicationVersionContainer = ({
application,
}: {
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
}) => {
const loading = !isDefined(application);
const currentVersion = application?.version;
// TODO fetch latestVersion of the application
// if published on twenty public application registry
const latestVersion = currentVersion;
const versionItems = [
{
Icon: IconCircleDot,
label: t`Current version`,
value: (
<SettingsAdminVersionDisplay
version={currentVersion}
loading={loading}
noVersionMessage={t`Unknown`}
/>
),
},
{
Icon: IconStatusChange,
label: t`Latest version`,
value: (
<SettingsAdminVersionDisplay
version={latestVersion}
loading={loading}
noVersionMessage={t`No latest version found`}
/>
),
},
];
return (
<SettingsAdminTableCard
rounded
items={versionItems}
gridAutoColumns="3fr 8fr"
/>
);
};
@@ -0,0 +1,29 @@
import { useMutation } from '@apollo/client';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { UPDATE_ONE_APPLICATION_VARIABLE } from '@/application-variables/graphql/mutations/updateOneApplicationVariable';
import {
type UpdateOneApplicationVariableMutation,
type UpdateOneApplicationVariableMutationVariables,
} from '~/generated-metadata/graphql';
export const useUpdateOneApplicationVariable = () => {
const apolloMetadataClient = useApolloCoreClient();
const [mutate] = useMutation<
UpdateOneApplicationVariableMutation,
UpdateOneApplicationVariableMutationVariables
>(UPDATE_ONE_APPLICATION_VARIABLE, { client: apolloMetadataClient });
const updateOneApplicationVariable = async ({
key,
value,
applicationId,
}: {
key: string;
value: string;
applicationId: string;
}) => {
return await mutate({ variables: { key, value, applicationId } });
};
return { updateOneApplicationVariable };
};
@@ -0,0 +1,52 @@
import type { Application } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { t } from '@lingui/core/macro';
import { Section } from 'twenty-ui/layout';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { SettingsApplicationVersionContainer } from '~/pages/settings/applications/components/SettingsApplicationVersionContainer';
export const SettingsApplicationDetailAboutTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
}) => {
if (!isDefined(application)) {
return null;
}
const { id, name, description } = application;
return (
<>
<Section>
<H2Title title={t`Name`} description={t`Name of the application`} />
<SettingsTextInput
instanceId={`application-name-${id}`}
value={name}
disabled
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Description`}
description={t`Description of the application`}
/>
<SettingsTextInput
instanceId={`application-description-${id}`}
value={description}
disabled
fullWidth
/>
</Section>
<Section>
<H2Title
title={t`Version`}
description={t`Version of the application`}
/>
<SettingsApplicationVersionContainer application={application} />
</Section>
</>
);
};
@@ -0,0 +1,75 @@
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/display';
import { t } from '@lingui/core/macro';
import { SettingsServerlessFunctionsTable } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsTable';
import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable';
import { SettingsObjectTable } from '~/pages/settings/data-model/SettingsObjectTable';
import { isDefined } from 'twenty-shared/utils';
import { useRecoilValue } from 'recoil';
import { type Application } from '~/generated/graphql';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
export const SettingsApplicationDetailContentTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
if (!isDefined(application)) {
return null;
}
const { serverlessFunctions, agents, objects } = application;
const shouldDisplayServerlessFunctions =
isDefined(serverlessFunctions) && serverlessFunctions?.length > 0;
const shouldDisplayAgents = isDefined(agents) && agents.length > 0;
const shouldDisplayObjects = isDefined(objects) && objects.length > 0;
const applicationObjectMetadataItems = shouldDisplayObjects
? objectMetadataItems.filter((objectMetadataItem) =>
objects.map((object) => object.id).includes(objectMetadataItem.id),
)
: [];
return (
<>
{shouldDisplayServerlessFunctions && (
<Section>
<H2Title
title={t`Application serverless functions`}
description={t`Serverless functions created by application`}
/>
<SettingsServerlessFunctionsTable
serverlessFunctions={serverlessFunctions}
/>
</Section>
)}
{shouldDisplayAgents && (
<Section>
<H2Title
title={t`Application agents`}
description={t`Agents created by application`}
/>
<SettingsAIAgentsTable agents={agents} withSearchBar={false} />
</Section>
)}
{shouldDisplayObjects && (
<Section>
<H2Title
title={t`Application objects`}
description={t`Objects created by application`}
/>
<SettingsObjectTable
activeObjects={applicationObjectMetadataItems}
inactiveObjects={[]}
withSearchBar={false}
/>
</Section>
)}
</>
);
};
@@ -0,0 +1,77 @@
import { Section } from 'twenty-ui/layout';
import { H2Title, IconSearch } from 'twenty-ui/display';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { useMemo, useState } from 'react';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import styled from '@emotion/styled';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import {
type EnvironmentVariable,
SettingsApplicationDetailEnvironmentVariablesTableRow,
StyledApplicationEnvironmentVariableTableRow,
} from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTableRow';
const StyledSearchInput = styled(SettingsTextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledTableBody = styled(TableBody)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
export const SettingsApplicationDetailEnvironmentVariablesTable = ({
envVariables,
onUpdate,
readonly,
}: {
envVariables: EnvironmentVariable[];
onUpdate: (newEnv: Pick<EnvironmentVariable, 'key' | 'value'>) => void;
readonly?: boolean;
}) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredEnvVariable = useMemo(() => {
return envVariables.filter(
({ key, value }) =>
key.toLowerCase().includes(searchTerm.toLowerCase()) ||
value.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [envVariables, searchTerm]);
return (
<Section>
<H2Title
title="Configuration"
description="Set your application configuration variables"
/>
<StyledSearchInput
instanceId="env-var-search"
LeftIcon={IconSearch}
placeholder="Search a variable"
value={searchTerm}
onChange={setSearchTerm}
/>
<Table>
<StyledApplicationEnvironmentVariableTableRow>
<TableHeader>Name</TableHeader>
<TableHeader>Value</TableHeader>
<TableHeader>Info</TableHeader>
<TableHeader></TableHeader>
</StyledApplicationEnvironmentVariableTableRow>
{filteredEnvVariable.length > 0 && (
<StyledTableBody>
{filteredEnvVariable.map((envVariable) => (
<SettingsApplicationDetailEnvironmentVariablesTableRow
key={envVariable.id}
envVariable={envVariable}
onChange={onUpdate}
readonly={readonly}
/>
))}
</StyledTableBody>
)}
</Table>
</Section>
);
};
@@ -0,0 +1,143 @@
import { TextInput } from '@/ui/input/components/TextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
AppTooltip,
IconCheck,
IconDotsVertical,
IconInfoCircle,
IconPencil,
OverflowingTextWithTooltip,
TooltipDelay,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { type ApplicationVariable } from '~/generated/graphql';
export const StyledApplicationEnvironmentVariableTableRow = styled(TableRow)`
grid-template-columns: auto 200px 36px 36px;
`;
export type EnvironmentVariable = ApplicationVariable;
export const SettingsApplicationDetailEnvironmentVariablesTableRow = ({
envVariable,
onChange,
readonly,
}: {
envVariable: EnvironmentVariable;
onChange: (
newEnvVariable: Pick<EnvironmentVariable, 'key' | 'value'>,
) => void;
readonly?: boolean;
}) => {
const [editedEnvVariable, setEditedEnvVariable] = useState(envVariable);
const [editMode, setEditMode] = useState(false);
const dropDownId = `settings-environment-variable-dropdown-${envVariable.key}`;
const { closeDropdown } = useCloseDropdown();
const theme = useTheme();
const { t } = useLingui();
const description =
envVariable.description.length > 0
? envVariable.description
: t`No description`;
const InfoTableCell = (
<TableCell>
<IconInfoCircle
id={`info-circle-id-description-${envVariable.key}`}
size={theme.icon.size.md}
color={theme.font.color.tertiary}
style={{ outline: 'none' }}
/>
<AppTooltip
anchorSelect={`#info-circle-id-description-${envVariable.key}`}
content={description}
offset={5}
noArrow
place="bottom"
positionStrategy="fixed"
delay={TooltipDelay.shortDelay}
/>
</TableCell>
);
return editMode && !readonly ? (
<StyledApplicationEnvironmentVariableTableRow>
<TableCell>
<OverflowingTextWithTooltip text={envVariable.key} />
</TableCell>
<TableCell>
<TextInput
value={editedEnvVariable.value}
onChange={(newValue) =>
setEditedEnvVariable({ ...editedEnvVariable, value: newValue })
}
placeholder={t`Value`}
fullWidth
/>
</TableCell>
{InfoTableCell}
<TableCell>
<LightIconButton
accent="tertiary"
Icon={IconCheck}
disabled={editedEnvVariable.value === ''}
onClick={() => {
onChange(editedEnvVariable);
setEditMode(false);
}}
/>
</TableCell>
</StyledApplicationEnvironmentVariableTableRow>
) : (
<StyledApplicationEnvironmentVariableTableRow
onClick={() => setEditMode(true)}
>
<TableCell>
<OverflowingTextWithTooltip text={envVariable.key} />
</TableCell>
<TableCell>
<OverflowingTextWithTooltip text={editedEnvVariable.value} />
</TableCell>
{InfoTableCell}
<TableCell>
{!readonly && (
<Dropdown
dropdownId={dropDownId}
clickableComponent={
<LightIconButton
aria-label="Env Variable Options"
Icon={IconDotsVertical}
accent="tertiary"
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
text={t`Edit`}
LeftIcon={IconPencil}
onClick={() => {
setEditMode(true);
closeDropdown(dropDownId);
}}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
</TableCell>
</StyledApplicationEnvironmentVariableTableRow>
);
};
@@ -0,0 +1,33 @@
import type { Application } from '~/generated/graphql';
import { isDefined } from 'twenty-shared/utils';
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
export const SettingsApplicationDetailSettingsTab = ({
application,
}: {
application?: Omit<Application, 'objects'> & { objects: { id: string }[] };
}) => {
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
if (!isDefined(application)) {
return null;
}
const envVariables = [...(application.applicationVariables ?? [])].sort(
(a, b) => a.key.localeCompare(b.key),
);
return (
<SettingsApplicationDetailEnvironmentVariablesTable
envVariables={envVariables}
onUpdate={({ key, value }) =>
updateOneApplicationVariable({
key,
value,
applicationId: application.id,
})
}
/>
);
};
@@ -9,8 +9,7 @@ import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
@@ -24,12 +23,15 @@ import { t } from '@lingui/core/macro';
import { useFindOneApplicationQuery } from '~/generated-metadata/graphql';
import { computeNewSources } from '@/serverless-functions/utils/computeNewSources';
import { flattenSources } from '@/serverless-functions/utils/flattenSources';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
const SERVERLESS_FUNCTION_DETAIL_ID = 'serverless-function-detail';
export const SettingsServerlessFunctionDetail = () => {
const { serverlessFunctionId = '', applicationId = '' } = useParams();
const navigate = useNavigate();
const { data } = useFindOneApplicationQuery({
variables: { id: applicationId },
skip: !applicationId,
@@ -37,9 +39,11 @@ export const SettingsServerlessFunctionDetail = () => {
const applicationName = data?.findOneApplication?.name;
const [activeTabId, setActiveTabId] = useRecoilComponentState(
const instanceId = `${SERVERLESS_FUNCTION_DETAIL_ID}-${serverlessFunctionId}`;
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SERVERLESS_FUNCTION_DETAIL_ID,
instanceId,
);
const { updateOneServerlessFunction } =
useUpdateOneServerlessFunction(serverlessFunctionId);
@@ -84,8 +88,8 @@ export const SettingsServerlessFunctionDetail = () => {
};
const handleTestFunction = async () => {
navigate('#test');
await testServerlessFunction();
setActiveTabId('test');
};
const tabs = [
@@ -97,17 +101,11 @@ export const SettingsServerlessFunctionDetail = () => {
const flattenedCode = flattenSources(formValues.code);
const files = flattenedCode
.map((file) => {
const language = file.path === '.env' ? 'ini' : 'typescript';
return {
path: file.path,
language,
content: file.content,
};
})
.reverse();
const files = flattenedCode.map((file) => ({
path: file.path,
language: 'typescript',
content: file.content,
}));
const renderActiveTabContent = () => {
switch (activeTabId) {
@@ -138,9 +136,7 @@ export const SettingsServerlessFunctionDetail = () => {
return (
<SettingsServerlessFunctionSettingsTab
formValues={formValues}
serverlessFunctionId={serverlessFunctionId}
onChange={onChange}
onCodeChange={onCodeChange}
/>
);
default:
@@ -171,10 +167,7 @@ export const SettingsServerlessFunctionDetail = () => {
]}
>
<SettingsPageContainer>
<TabList
tabs={tabs}
componentInstanceId={SERVERLESS_FUNCTION_DETAIL_ID}
/>
<TabList tabs={tabs} componentInstanceId={instanceId} />
{renderActiveTabContent()}
</SettingsPageContainer>
</SubMenuTopBarContainer>
@@ -0,0 +1,23 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddApplicationVariableCoreEntity1760640844181
implements MigrationInterface
{
name = 'AddApplicationVariableCoreEntity1760640844181';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."applicationVariable" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" text NOT NULL, "value" text NOT NULL DEFAULT '', "description" text NOT NULL DEFAULT '', "isSecret" boolean NOT NULL DEFAULT false, "applicationId" uuid, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE" UNIQUE ("key", "applicationId"), CONSTRAINT "PK_62f7823eb5f1e416c9d60614dfb" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationVariable" ADD CONSTRAINT "FK_51adb49e7f8df35dd23e01c4830" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationVariable" DROP CONSTRAINT "FK_51adb49e7f8df35dd23e01c4830"`,
);
await queryRunner.query(`DROP TABLE "core"."applicationVariable"`);
}
}
@@ -28,6 +28,7 @@ import { FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/type
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
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 { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
@Injectable()
export class ApplicationSyncService {
@@ -35,6 +36,7 @@ export class ApplicationSyncService {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationVariableService: ApplicationVariableService,
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
@@ -106,7 +108,7 @@ export class ApplicationSyncService {
workspaceId,
);
return await this.applicationService.create({
const application = await this.applicationService.create({
universalIdentifier: manifest.universalIdentifier,
name: manifest.name,
description: manifest.description,
@@ -115,6 +117,13 @@ export class ApplicationSyncService {
serverlessFunctionLayerId: serverlessFunctionLayer.id,
workspaceId,
});
await this.applicationVariableService.upsertManyApplicationVariables({
env: manifest.env,
applicationId: application.id,
});
return application;
}
await this.serverlessFunctionLayerService.update(
@@ -131,6 +140,11 @@ export class ApplicationSyncService {
version: manifest.version,
});
await this.applicationVariableService.upsertManyApplicationVariables({
env: manifest.env,
applicationId: application.id,
});
return application;
}
@@ -18,6 +18,7 @@ import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionLayerEntity } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.entity';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
@Entity({ name: 'application', schema: 'core' })
@Index('IDX_APPLICATION_WORKSPACE_ID', ['workspaceId'])
@@ -86,6 +87,15 @@ export class ApplicationEntity {
})
objects: Relation<ObjectMetadataEntity[]>;
@OneToMany(
() => ApplicationVariable,
(applicationVariable) => applicationVariable.application,
{
onDelete: 'CASCADE',
},
)
applicationVariables: Relation<ApplicationVariable[]>;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})
@@ -16,6 +16,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { ApplicationVariableModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
@Module({
imports: [
@@ -24,6 +25,7 @@ import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless
ObjectMetadataModule,
DataSourceModule,
AgentModule,
ApplicationVariableModule,
ServerlessFunctionLayerModule,
ServerlessFunctionModule,
DatabaseEventTriggerModule,
@@ -25,7 +25,12 @@ export class ApplicationService {
): Promise<ApplicationEntity[]> {
return this.applicationRepository.find({
where: { workspaceId },
relations: ['serverlessFunctions', 'agents', 'objects'],
relations: [
'serverlessFunctions',
'agents',
'objects',
'applicationVariables',
],
});
}
@@ -35,7 +40,12 @@ export class ApplicationService {
): Promise<ApplicationEntity> {
const application = await this.applicationRepository.findOne({
where: { workspaceId, id: applicationId },
relations: ['serverlessFunctions', 'agents', 'objects'],
relations: [
'serverlessFunctions',
'agents',
'objects',
'applicationVariables',
],
});
if (!isDefined(application)) {
@@ -6,6 +6,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ApplicationVariableDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
@ObjectType('Application')
export class ApplicationDTO {
@@ -22,6 +23,10 @@ export class ApplicationDTO {
@Field()
description: string;
@IsString()
@Field()
version: string;
@Field(() => [AgentDTO])
agents: AgentDTO[];
@@ -30,4 +35,7 @@ export class ApplicationDTO {
@Field(() => [ObjectMetadataDTO])
objects: ObjectMetadataDTO[];
@Field(() => [ApplicationVariableDTO])
applicationVariables: ApplicationVariableDTO[];
}
@@ -11,6 +11,15 @@ export type PackageJson = {
npm: string;
yarn: string;
};
env: Record<
string,
{
key: string;
value?: string;
description?: string;
isSecret: boolean;
}
>;
icon?: string;
version: string;
dependencies?: object;
@@ -0,0 +1,21 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
ApplicationVariableException,
ApplicationVariableExceptionCode,
} from 'src/engine/core-modules/applicationVariable/application-variable.exception';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(ApplicationVariableException)
export class ApplicationVariableExceptionFilter implements ExceptionFilter {
catch(exception: ApplicationVariableException) {
switch (exception.code) {
case ApplicationVariableExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
throw new NotFoundError(exception);
default:
assertUnreachable(exception.code);
}
}
}
@@ -0,0 +1,65 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@Entity({
name: 'applicationVariable',
schema: 'core',
})
@ObjectType()
@Unique('IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE', [
'key',
'applicationId',
])
export class ApplicationVariable {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'text' })
key: string;
@Column({ nullable: false, type: 'text', default: '' })
value: string;
@Column({ nullable: false, type: 'text', default: '' })
description: string;
@Column({ nullable: false, type: 'boolean', default: false })
isSecret: boolean;
@Column({ nullable: true, type: 'uuid' })
applicationId?: string;
@ManyToOne(
() => ApplicationEntity,
(application) => application.applicationVariables,
{
onDelete: 'CASCADE',
nullable: true,
},
)
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,7 @@
import { CustomException } from 'src/utils/custom-exception';
export class ApplicationVariableException extends CustomException<ApplicationVariableExceptionCode> {}
export enum ApplicationVariableExceptionCode {
APPLICATION_VARIABLE_NOT_FOUND = 'APPLICATION_VARIABLE_NOT_FOUND',
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { ApplicationVariableResolver } from 'src/engine/core-modules/applicationVariable/application-variable.resolver';
@Module({
imports: [NestjsQueryTypeOrmModule.forFeature([ApplicationVariable])],
providers: [ApplicationVariableService, ApplicationVariableResolver],
exports: [ApplicationVariableService],
})
export class ApplicationVariableModule {}
@@ -0,0 +1,25 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ApplicationVariableService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { UpdateApplicationVariableInput } from 'src/engine/core-modules/applicationVariable/dtos/update-application-variable.input';
import { ApplicationVariableExceptionFilter } from 'src/engine/core-modules/applicationVariable/application-variable-exception-filter';
@UseGuards(WorkspaceAuthGuard)
@Resolver()
@UseFilters(ApplicationVariableExceptionFilter)
export class ApplicationVariableResolver {
constructor(
private readonly applicationVariableService: ApplicationVariableService,
) {}
@Mutation(() => Boolean)
async updateOneApplicationVariable(
@Args() { key, value, applicationId }: UpdateApplicationVariableInput,
) {
await this.applicationVariableService.update({ key, value, applicationId });
return true;
}
}
@@ -0,0 +1,58 @@
import { InjectRepository } from '@nestjs/typeorm';
import { In, Not, Repository } from 'typeorm';
import { ApplicationVariable } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
export class ApplicationVariableService {
constructor(
@InjectRepository(ApplicationVariable)
private readonly applicationVariableRepository: Repository<ApplicationVariable>,
) {}
async update({
key,
value,
applicationId,
}: Pick<ApplicationVariable, 'key' | 'value'> & { applicationId: string }) {
await this.applicationVariableRepository.update(
{ key, applicationId },
{
value,
},
);
}
async upsertManyApplicationVariables({
env,
applicationId,
}: {
env: Record<
string,
{
value?: string;
description?: string;
isSecret: boolean;
}
>;
applicationId: string;
}) {
for (const [key, { value, description, isSecret }] of Object.entries(env)) {
await this.applicationVariableRepository.upsert(
{
key,
value,
description,
isSecret,
applicationId,
},
{ conflictPaths: ['key', 'applicationId'] },
);
}
await this.applicationVariableRepository.delete({
applicationId,
key: Not(In(Object.keys(env))),
});
}
}
@@ -0,0 +1,28 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean, IsString } from 'class-validator';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ApplicationVariable')
export class ApplicationVariableDTO {
@IDField(() => UUIDScalarType)
id: string;
@IsString()
@Field()
key: string;
@IsString()
@Field()
value: string;
@IsString()
@Field()
description: string;
@IsBoolean()
@Field()
isSecret: boolean;
}
@@ -0,0 +1,15 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ArgsType()
export class UpdateApplicationVariableInput {
@Field(() => String, { nullable: false })
key: string;
@Field(() => String, { nullable: false })
value: string;
@Field(() => UUIDScalarType, { nullable: false })
applicationId: string;
}
@@ -6,17 +6,20 @@ export const handler = async (event) => {
const mainPath = `/tmp/${randomId}.mjs`;
const oldProcessEnv = { ...process.env };
try {
const { code, params } = event;
const { code, params, env } = event;
await fs.writeFile(mainPath, code, 'utf8');
process.env = {};
process.env = { ...process.env, ...(env ?? {}) };
const mainFile = await import(mainPath);
return await mainFile.main(params);
} finally {
await fs.rm(mainPath, { force: true });
process.env = oldProcessEnv;
}
};
@@ -46,6 +46,7 @@ import {
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
const UPDATE_FUNCTION_DURATION_TIMEOUT_IN_SECONDS = 60;
const CREDENTIALS_DURATION_IN_SECONDS = 60 * 60; // 1h
@@ -338,6 +339,7 @@ export class LambdaDriver implements ServerlessDriver {
const executorPayload = {
params: payload,
code: compiledCode,
env: buildEnvVar(serverlessFunction),
};
const params: InvokeCommandInput = {
@@ -1,5 +1,6 @@
import { promises as fs } from 'fs';
import { join } from 'path';
import { spawn } from 'node:child_process';
import {
type ServerlessDriver,
@@ -16,6 +17,7 @@ import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serve
import { LambdaBuildDirectoryManager } from 'src/engine/core-modules/serverless/drivers/utils/lambda-build-directory-manager';
import { buildServerlessFunctionInMemory } from 'src/engine/core-modules/serverless/drivers/utils/build-serverless-function-in-memory';
import { formatBuildError } from 'src/engine/core-modules/serverless/drivers/utils/format-build-error';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
export interface LocalDriverOptions {
fileStorageService: FileStorageService;
@@ -162,30 +164,53 @@ export class LocalDriver implements ServerlessDriver {
});
try {
const mainFile = await import(builtBundleFilePath);
const result = await this.executeWithTimeout<object | null>(
() => mainFile.main(payload),
serverlessFunction.timeoutSeconds * 1_000,
const runnerPath = await this.writeBootstrapRunner(
sourceTemporaryDir,
builtBundleFilePath,
);
const { ok, result, error, stack, stdout, stderr } =
await this.runChildWithEnv({
runnerPath,
env: buildEnvVar(serverlessFunction),
payload,
timeoutMs: serverlessFunction.timeoutSeconds * 1_000,
});
if (stdout)
logs +=
stdout
.split('\n')
.filter(Boolean)
.map((l) => `${new Date().toISOString()} INFO ${l}`)
.join('\n') + '\n';
if (stderr)
logs +=
stderr
.split('\n')
.filter(Boolean)
.map((l) => `${new Date().toISOString()} ERROR ${l}`)
.join('\n') + '\n';
const duration = Date.now() - startTime;
return {
data: result,
logs,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};
} catch (error) {
if (ok) {
return {
data: (result ?? null) as object | null,
logs,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};
}
return {
data: null,
logs,
duration: Date.now() - startTime,
duration,
error: {
errorType: 'UnhandledError',
errorMessage: error.message || 'Unknown error',
stackTrace: error.stack ? error.stack.split('\n') : [],
errorMessage: error || 'Unknown error',
stackTrace: stack ? String(stack).split('\n') : [],
},
status: ServerlessFunctionExecutionStatus.ERROR,
};
@@ -196,4 +221,143 @@ export class LocalDriver implements ServerlessDriver {
await lambdaBuildDirectoryManager.clean();
}
}
async writeBootstrapRunner(dir: string, builtFileAbsPath: string) {
const runnerPath = join(dir, '__runner.cjs');
const code = `
// Auto-generated. Do not edit.
const { pathToFileURL } = require('node:url');
(async () => {
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');
}
let payload = undefined;
if (process.send) {
process.on('message', async (msg) => {
if (!msg || msg.type !== 'run') return;
try {
const out = await mod.main(msg.payload);
process.send && process.send({ ok: true, result: out });
process.exit(0);
} catch (err) {
process.send && process.send({ ok: false, error: String(err), stack: err?.stack });
process.exit(1);
}
});
} else {
// 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);
console.log(JSON.stringify({ ok: true, result: out }));
process.exit(0);
}
} catch (err) {
const msg = String(err);
if (process.send) {
process.send({ ok: false, error: msg, stack: err?.stack });
} else {
console.error(msg);
}
process.exit(1);
}
})();
`;
await fs.writeFile(runnerPath, code, 'utf8');
return runnerPath;
}
runChildWithEnv(options: {
runnerPath: string;
env: Record<string, string>;
payload: unknown;
timeoutMs: number;
}) {
const { runnerPath, env, payload, timeoutMs } = options;
return new Promise<{
ok: boolean;
result?: unknown;
error?: string;
stack?: string;
stdout: string;
stderr: string;
}>((resolve, _) => {
const child = spawn(process.execPath, [runnerPath], {
env: { ...process.env, ...env },
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let stdout = '';
let stderr = '';
let settled = false;
child.stdout?.on('data', (d) => (stdout += String(d)));
child.stderr?.on('data', (d) => (stderr += String(d)));
child.on(
'message',
(
msg:
| {
ok: true;
result?: unknown;
stdout?: string;
stderr?: string;
}
| {
ok: false;
error: string;
stack?: string;
stdout?: string;
stderr?: string;
},
) => {
if (settled) return;
settled = true;
resolve({ ...msg, stdout, stderr });
},
);
child.on('exit', (code) => {
if (settled) return;
settled = true;
if (code === 0) {
// Fallback path if no IPC (shouldnt happen with our stdio)
resolve({ ok: true, stdout, stderr });
} else {
resolve({
ok: false,
error: `Exited with code ${code}`,
stdout,
stderr,
});
}
});
const t = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
resolve({
ok: false,
error: `Timed out after ${timeoutMs}ms`,
stdout,
stderr,
});
}, timeoutMs);
// Kick it off
child.send?.({ type: 'run', payload });
child.on('close', () => clearTimeout(t));
});
}
}
@@ -0,0 +1,12 @@
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
export const buildEnvVar = (serverlessFunction: ServerlessFunctionEntity) => {
return (serverlessFunction.application?.applicationVariables ?? []).reduce(
(acc, v) => {
acc[v.key] = String(v.value ?? '');
return acc;
},
{} as Record<string, string>,
);
};
@@ -96,7 +96,10 @@ export class ServerlessFunctionService {
id,
workspaceId,
},
relations: ['serverlessFunctionLayer'],
relations: [
'serverlessFunctionLayer',
'application.applicationVariables',
],
});
const resultServerlessFunction = await this.serverlessService.execute(
@@ -4,5 +4,4 @@ export type ServerlessFunctionCode = {
src: {
'index.ts': string;
} & Sources;
'.env'?: string;
};
@@ -44,6 +44,7 @@ export {
IconBrandLinkedin,
IconBrandX,
IconBriefcase,
IconBroadcast,
IconBrowserMaximize,
IconBuildingSkyscraper,
IconCalendar,
+1
View File
@@ -107,6 +107,7 @@ export {
IconBrandLinkedin,
IconBrandX,
IconBriefcase,
IconBroadcast,
IconBrowserMaximize,
IconBuildingSkyscraper,
IconCalendar,