Compare commits

...
Author SHA1 Message Date
Félix Malfait 62f6fb0ba5 Extract frontend changes to separate PR (#18549)
Made-with: Cursor
2026-03-10 20:21:00 +01:00
Félix Malfait 6d266e3e7b Add tests for CoreApiClient stub issue (PR #18460) with clean tsconfig fix
Exclude test app src/ directories from SDK tsconfig.json instead of
using a .d.ts hack. These directories contain standalone app code
(with their own tsconfig.json) that should not be typechecked as SDK
root files.

- Exclude src/app-seeds/*/src/** and src/cli/__tests__/apps/*/src/**
- Add check-core-client logic function that verifies CoreApiClient
- Add E2E test asserting CoreApiClient has real query/mutation methods
- Add app-build integration test documenting current stub behavior

Made-with: Cursor
2026-03-10 09:28:19 +01:00
github-actions fc84ff040d chore: sync docs artifacts 2026-03-10 08:21:19 +00:00
Félix Malfait f57677c078 Extract docs changes to separate PR (#18517)
Made-with: Cursor
2026-03-10 09:17:33 +01:00
Félix Malfait 4ff372fff3 Revert "Add tests"
This reverts commit 1c7dbf9a11.
2026-03-10 09:12:28 +01:00
Félix Malfait e858b0c75b Revert "Fix SDK typecheck: add type declaration for twenty-sdk/generated"
This reverts commit bf3f606ad1.
2026-03-10 09:12:10 +01:00
Félix Malfait bf3f606ad1 Fix SDK typecheck: add type declaration for twenty-sdk/generated
- Add twenty-sdk-generated.d.ts so TypeScript can resolve the
  twenty-sdk/generated import used by check-core-client.function.ts
- Restore the 4th integration test assertion (stub check) that was
  lost during merge

Made-with: Cursor
2026-03-09 13:41:45 +01:00
Félix Malfait d6ca54cec3 Merge remote-tracking branch 'origin/main' into cursor/npm-claim-verification-448c
Made-with: Cursor

# Conflicts:
#	packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts
2026-03-09 13:31:26 +01:00
Félix Malfait 1c7dbf9a11 Add tests 2026-03-09 13:29:35 +01:00
Félix Malfait 3f38c2b2a7 Lint 2026-03-09 12:41:17 +01:00
Félix Malfait 6d8eb8fb6a Cleanup 2026-03-09 12:26:54 +01:00
Félix Malfait 0871053565 Fix 2026-03-09 11:55:11 +01:00
Félix MalfaitandGitHub 2224075d08 Merge branch 'main' into cursor/npm-claim-verification-448c 2026-03-09 11:40:26 +01:00
Félix Malfait b4b68f579a Seeder 2026-03-09 11:34:28 +01:00
Félix Malfait ed3895b456 Remove MutationRegisterNpmPackageArgs from generated GraphQL types
Made-with: Cursor
2026-03-08 08:37:45 +01:00
Félix Malfait f11980dafa Fix integration test: remove isListed column reference
The isListed column was removed during migration consolidation.

Made-with: Cursor
2026-03-08 08:28:13 +01:00
github-actions 769a2d27d5 chore: sync docs artifacts 2026-03-08 07:16:36 +00:00
Félix Malfait 79c0b37487 Remove app:register CLI command, registerNpmPackage mutation, and GitHub Action
Auto-discovery via the marketplace catalog sync cron makes manual registration
unnecessary. Replace the publish-twenty-app composite action with inline CI
steps in the scaffolded workflow template. Consolidate unmerged migrations and
clean up dead code (fetchPackument, upsertFromNpmRegistration).

Made-with: Cursor
2026-03-08 08:14:58 +01:00
Félix Malfait 6c293b890e Improve model 2026-03-07 15:18:54 +01:00
Félix Malfait 4e3e12aeb7 Fix TS2550: use regex global flag instead of replaceAll for ES target compat
Made-with: Cursor
2026-03-07 11:17:59 +01:00
Félix Malfait 8d6ec2a92b Fix CI: lint, formatting, test, CodeQL, and generated types ordering
- Sort CSS properties alphabetically in StyledPublishMethodLabel
- Fix prettier formatting in app-register.ts and server test/service files
- Update test assertion for twenty-app- prefix normalization
- Remove unused ApplicationExceptionCode import in test
- Use replaceAll instead of replace for URL encoding (CodeQL)
- Correct alphabetical ordering in generated GraphQL types

Made-with: Cursor
2026-03-07 11:12:11 +01:00
Félix Malfait d4ce4414e2 Replace JWT claim system with npm maintainer email matching and provenance metadata
- Remove ApplicationNpmClaimService, NpmClaimTokenDTO, NPM_CLAIM JWT type,
  and all claim-related mutations/methods across server, SDK, and frontend
- Add provenanceRepositoryUrl, isProvenanceVerified, provenanceVerifiedAt
  fields to ApplicationRegistrationEntity with core migration
- Create ApplicationNpmRegistrationService with email-based ownership
  verification and lightweight npm provenance attestation fetching
- Add registerNpmPackage GraphQL mutation with workspace auth guards
- Enforce twenty-app- prefix for all npm package names in validation
  and marketplace search filtering
- Create .github/actions/publish-twenty-app composite action for
  building and publishing apps with --provenance
- Scaffold publish.yml GitHub workflow in create-twenty-app
- Add app:register CLI command and public operation in SDK
- Update frontend empty state to show app:register instructions
- Enrich MarketplaceCatalogSyncService to fetch and store provenance
  metadata during npm app discovery
- Add unit tests for registration service and package name validation

Made-with: Cursor
2026-03-07 11:02:47 +01:00
Cursor Agentandfelix ae8a0713b0 refactor(sdk): keep one export per file in claim commands
Remove export keyword from types that are only used internally:
- AppClaimOptions, AppClaimResult in app-claim.ts
- AppVerifyClaimOptions, AppVerifyClaimResult in app-verify-claim.ts
- AppClaimCommandOptions in app-claim command
- AppVerifyClaimCommandOptions in app-verify-claim command

Co-authored-by: felix <[email protected]>
2026-03-06 17:59:18 +00:00
Cursor Agentandfelix 47fb76ea38 merge: resolve conflicts with main
- Keep both AppClaimCommand and AppGenerateClientCommand imports
- Remove ApplicationPackageFetcherService from resolver (removed by main),
  keep ApplicationNpmClaimService
- Keep claimForNpm/createFromNpmClaim methods, drop old createFromNpmPackage
  (removed by main)
- Re-add ApplicationPackageModule import for claim service dependency
- Fix duplicate PUBLISH_FAILED in error codes

Co-authored-by: felix <[email protected]>
2026-03-06 17:51:22 +00:00
Cursor Agentandfelix be6d5b2184 feat(sdk): add app:claim and app:verify-claim CLI commands
New CLI commands for npm package ownership verification:

- `twenty app:claim <packageName> [appPath]`
  Generates a server-signed JWT claim token and writes it to
  twenty-claim.jwt in the app directory.

- `twenty app:verify-claim <packageName>`
  Verifies the claim by resolving the npm package, reading the
  twenty-claim.jwt file, and verifying the JWT signature server-side.

Also:
- Adds generateNpmClaimToken and verifyNpmPackageClaim to ApiService
- Copies twenty-claim.jwt to build output during app:build
- Adds CLAIM_FAILED and PUBLISH_FAILED error codes

Co-authored-by: felix <[email protected]>
2026-03-06 17:07:28 +00:00
Cursor Agentandfelix 9bd28ed28b feat(server): add npm package claim verification via JWT
Add NPM_CLAIM token type and ApplicationNpmClaimService for verifying
npm package ownership. The flow:
1. Server generates a signed JWT containing packageName + workspaceId
2. Developer includes the JWT file in their npm package
3. Server resolves the package from npm, reads the claim file, verifies
   the JWT signature, and attributes ownerWorkspaceId

New GraphQL mutations:
- generateNpmClaimToken(packageName) - returns a signed claim JWT
- verifyNpmPackageClaim(packageName) - resolves npm package, verifies
  claim, creates/updates registration with ownership

Also adds claimForNpm and createFromNpmClaim methods to
ApplicationRegistrationService for the claim-specific registration logic.

Co-authored-by: felix <[email protected]>
2026-03-06 17:07:05 +00:00
114 changed files with 3226 additions and 436 deletions
@@ -66,7 +66,7 @@ export class CreateAppCommand {
name: 'name',
message: 'Application name:',
when: () => !directory,
default: 'my-awesome-app',
default: 'twenty-app-my-awesome-app',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
@@ -101,7 +101,7 @@ describe('copyBaseApplicationProject', () => {
expect(await fs.pathExists(packageJsonPath)).toBe(true);
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.name).toBe('twenty-app-my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
@@ -36,6 +36,8 @@ export const copyBaseApplicationProject = async ({
await createYarnLock(appDirectory);
await createPublishWorkflow(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
await fs.ensureDir(sourceFolderPath);
@@ -149,6 +151,40 @@ const createYarnLock = async (appDirectory: string) => {
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
};
const createPublishWorkflow = async (appDirectory: string) => {
const workflowDir = join(appDirectory, '.github', 'workflows');
await fs.ensureDir(workflowDir);
const workflowContent = `name: Publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: https://registry.npmjs.org
- run: yarn install --immutable
- run: npx twenty app:build
- run: npm publish --provenance --access public
working-directory: .twenty/output
env:
NODE_AUTH_TOKEN: \${{ secrets.NPM_TOKEN }}
`;
await fs.writeFile(join(workflowDir, 'publish.yml'), workflowContent);
};
const createGitignore = async (appDirectory: string) => {
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
@@ -621,10 +657,18 @@ const createPackageJson = async ({
devDependencies['vite-tsconfig-paths'] = '^4.2.1';
}
const normalizedName = appName.startsWith('twenty-app-')
? appName
: `twenty-app-${appName}`;
const packageJson = {
name: appName,
name: normalizedName,
version: '0.1.0',
license: 'MIT',
keywords: ['twenty-app'],
publishConfig: {
access: 'public' as const,
},
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
+1
View File
@@ -4,6 +4,7 @@
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
@@ -169,7 +169,7 @@ export default defineObject({
Later commands will add more files and folders:
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/clients`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
## Authentication
@@ -431,7 +431,7 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
// src/app/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
import { CoreApiClient, type Person } from 'twenty-sdk/clients';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -669,7 +669,7 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { CoreApiClient } from 'twenty-sdk/clients';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
@@ -830,13 +830,14 @@ You can create new agents in two ways:
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/clients` based on your workspace schema:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
```typescript
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
import { CoreApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/clients';
const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
@@ -845,7 +846,7 @@ const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
`CoreApiClient` is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change. `MetadataApiClient` ships pre-built with the SDK.
#### Runtime credentials in logic functions
@@ -861,10 +862,10 @@ Notes:
#### Uploading files
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
The `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
```typescript
import { MetadataApiClient } from 'twenty-sdk/generated';
import { MetadataApiClient } from 'twenty-sdk/clients';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
@@ -15,7 +15,7 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
* عرِّف كائنات وحقولًا مخصصة على شكل كود (نموذج بيانات مُدار)
* أنشئ وظائف منطقية مع مشغلات مخصصة
* حدد المهارات لوكلاء الذكاء الاصطناعي
* تعريف المهارات والوكلاء للذكاء الاصطناعي
* انشر التطبيق نفسه عبر مساحات عمل متعددة
## المتطلبات الأساسية
@@ -39,7 +39,7 @@ yarn twenty app:dev
يدعم المُنشئ وضعين للتحكم في ملفات الأمثلة التي سيتم تضمينها:
```bash filename="Terminal"
# الافتراضي (شامل): جميع الأمثلة (كائن، حقل، دالة منطقية، مكوّن الواجهة الأمامية، عرض، عنصر قائمة التنقل، مهارة)
# الافتراضي (شامل): جميع الأمثلة (كائن، حقل، دالة منطقية، مكوّن الواجهة الأمامية، عرض، عنصر قائمة التنقل، مهارة، وكيل)
npx create-twenty-app@latest my-app
# الأدنى: الملفات الأساسية فقط (application-config.ts و default-role.ts)
@@ -115,8 +115,10 @@ my-twenty-app/
│ └── example-view.ts # تعريف عرض محفوظ — مثال
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # رابط تنقّل في الشريط الجانبي — مثال
── skills/
└── example-skill.ts # تعريف مهارة لوكيل الذكاء الاصطناعي — مثال},{
── skills/
└── example-skill.ts # تعريف مهارة لوكيل الذكاء الاصطناعي — مثال
└── agents/
└── example-agent.ts # تعريف وكيل الذكاء الاصطناعي — مثال
```
مع `--minimal`، سيتم إنشاء الملفات الأساسية فقط (`application-config.ts`، `roles/default-role.ts`، `logic-functions/pre-install.ts`، و`logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ my-twenty-app/
| `defineView()` | تعريفات العروض المحفوظة |
| `defineNavigationMenuItem()` | تعريفات عناصر قائمة التنقل |
| `defineSkill()` | تعريفات مهارات وكيل الذكاء الاصطناعي |
| `defineAgent()` | تعريفات وكلاء الذكاء الاصطناعي |
<Note>
**تسمية الملفات مرنة.** يعتمد اكتشاف الكيانات على بنية الشجرة المجردة (AST) — إذ يقوم SDK بفحص ملفات المصدر لديك بحثًا عن النمط `export default define<Entity>({...})`. يمكنك تنظيم ملفاتك ومجلداتك كيفما تشاء. التجميع حسب نوع الكيان (مثلًا، `logic-functions/` و`roles/`) هو مجرد عرف لتنظيم الشيفرة، وليس مطلبًا إلزاميًا.
@@ -225,6 +228,7 @@ yarn twenty auth:status
| `defineView()` | تعريف العروض المحفوظة للكائنات |
| `defineNavigationMenuItem()` | تعريف روابط التنقل في الشريط الجانبي |
| `defineSkill()` | عرّف مهارات وكيل الذكاء الاصطناعي |
| `defineAgent()` | Define AI agents with system prompts |
تتحقق هذه الدوال من تكوينك وقت البناء وتوفّر إكمالًا تلقائيًا في بيئة التطوير وأمان الأنواع.
@@ -802,6 +806,41 @@ export default defineSkill({
* **مُنشأ بالقالب**: شغِّل `yarn twenty entity:add` واختر خيار إضافة مهارة جديدة.
* **يدوي**: أنشئ ملفًا جديدًا واستخدم `defineSkill()` مع اتباع النمط نفسه.
### Agents
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
النقاط الرئيسية:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` هو اسم العرض المقروء للبشر الظاهر في واجهة المستخدم.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (اختياري) يحدّد الأيقونة المعروضة في واجهة المستخدم.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### عملاء مُولَّدون مضبوطو الأنواع
يتم توليد عميلين مضبوطي الأنواع تلقائيًا بواسطة `yarn twenty app:dev` وتخزينهما في `node_modules/twenty-sdk/generated` استنادًا إلى مخطط مساحة العمل لديك:
@@ -15,7 +15,7 @@ Aplikace vám umožňují vytvářet a spravovat přizpůsobení Twenty **jako k
* Definujte vlastní objekty a pole jako kód (spravovaný datový model)
* Vytvářejte logické funkce s vlastními spouštěči
* Definujte dovednosti agentů AI
* Definujte dovednosti a agenty AI!
* Nasazujte stejnou aplikaci do více pracovních prostorů
## Předpoklady
@@ -39,7 +39,7 @@ yarn twenty app:dev
Nástroj pro generování kostry podporuje dva režimy pro řízení toho, které ukázkové soubory jsou zahrnuty:
```bash filename="Terminal"
# Výchozí (úplný): všechny příklady (objekt, pole, logická funkce, front-endová komponenta, zobrazení, položka navigační nabídky, dovednost)
# Výchozí (úplný): všechny příklady (objekt, pole, logická funkce, front-endová komponenta, zobrazení, položka navigační nabídky, dovednost, agent)
npx create-twenty-app@latest my-app
# Minimální: pouze základní soubory (application-config.ts a default-role.ts)
@@ -115,8 +115,10 @@ my-twenty-app/
│ └── example-view.ts # Ukázková definice uloženého zobrazení
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Ukázkový odkaz postranní navigace
── skills/
└── example-skill.ts # Ukázková definice dovednosti agenta AI
── skills/
└── example-skill.ts # Ukázková definice dovednosti agenta AI
└── agents/
└── example-agent.ts # Ukázková definice agenta AI
```
S volbou `--minimal` se vytvoří pouze základní soubory (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` a `logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ SDK detekuje entity analýzou vašich souborů TypeScript a hledá volání **`e
| `defineView()` | Definice uložených zobrazení |
| `defineNavigationMenuItem()` | Definice položek navigační nabídky |
| `defineSkill()` | Definice dovedností agenta AI |
| `defineAgent()` | Definice agentů AI |
<Note>
**Pojmenování souborů je flexibilní.** Detekce entit je založená na AST — SDK prochází vaše zdrojové soubory a hledá vzor `export default define<Entity>({...})`. Soubory a složky můžete organizovat, jak chcete. Seskupování podle typu entity (např. `logic-functions/`, `roles/`) je pouze konvence pro organizaci kódu, nikoli požadavek.
@@ -225,6 +228,7 @@ SDK poskytuje pomocné funkce pro definování entit vaší aplikace. Jak je pop
| `defineView()` | Definujte uložená zobrazení pro objekty |
| `defineNavigationMenuItem()` | Definujte odkazy postranní navigace |
| `defineSkill()` | Definuje dovednosti agenta AI |
| `defineAgent()` | Define AI agents with system prompts |
Tyto funkce validují vaši konfiguraci v době sestavení a poskytují automatické doplňování v IDE a typovou bezpečnost.
@@ -802,6 +806,41 @@ Nové dovednosti můžete vytvářet dvěma způsoby:
* **Vygenerované**: Spusťte `yarn twenty entity:add` a zvolte možnost přidat novou dovednost.
* **Ruční**: Vytvořte nový soubor a použijte `defineSkill()` podle stejného vzoru.
### Agenti
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
Hlavní body:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` je uživatelsky čitelný název zobrazovaný v UI.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (volitelné) nastavuje ikonu zobrazovanou v UI.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### Generované typované klienty
Dva typované klienty jsou automaticky generovány pomocí `yarn twenty app:dev` a ukládají se do `node_modules/twenty-sdk/generated` podle schématu vašeho pracovního prostoru:
@@ -15,7 +15,7 @@ Mit Apps können Sie Twenty-Anpassungen **als Code** erstellen und verwalten. An
* Benutzerdefinierte Objekte und Felder als Code definieren (verwaltetes Datenmodell)
* Logikfunktionen mit benutzerdefinierten Triggern erstellen
* Fähigkeiten für KI-Agenten definieren
* Skills und Agenten für KI definieren
* Dieselbe App in mehreren Workspaces bereitstellen
## Voraussetzungen
@@ -39,7 +39,7 @@ yarn twenty app:dev
Das Scaffolding-Tool unterstützt zwei Modi, um zu steuern, welche Beispieldateien enthalten sind:
```bash filename="Terminal"
# Standard (umfassend): alle Beispiele (Objekt, Feld, Logikfunktion, Frontend-Komponente, View, Navigationsmenüeintrag, Skill)
# Standard (umfassend): alle Beispiele (Objekt, Feld, Logikfunktion, Frontend-Komponente, View, Navigationsmenüeintrag, Skill, Agent)
npx create-twenty-app@latest my-app
# Minimal: nur Kerndateien (application-config.ts und default-role.ts)
@@ -96,27 +96,29 @@ my-twenty-app/
.oxlintrc.json
tsconfig.json
README.md
public/ # Public assets folder (images, fonts, etc.)
public/ # Ordner für öffentliche Assets (Bilder, Schriftarten usw.)
src/
├── application-config.ts # Required - main application configuration
├── application-config.ts # Erforderlich - Hauptkonfiguration der Anwendung
├── roles/
│ └── default-role.ts # Default role for logic functions
│ └── default-role.ts # Standardrolle für Logikfunktionen
├── objects/
│ └── example-object.ts # Example custom object definition
│ └── example-object.ts # Beispiel für eine benutzerdefinierte Objektdefinition
├── fields/
│ └── example-field.ts # Example standalone field definition
│ └── example-field.ts # Beispiel für eine eigenständige Felddefinition
├── logic-functions/
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
│ ├── hello-world.ts # Beispiel für eine Logikfunktion
│ ├── pre-install.ts # Pre-Installations-Logikfunktion
│ └── post-install.ts # Post-Installations-Logikfunktion
├── front-components/
│ └── hello-world.tsx # Example front component
│ └── hello-world.tsx # Beispiel für eine Frontend-Komponente
├── views/
│ └── example-view.ts # Example saved view definition
│ └── example-view.ts # Beispiel für eine gespeicherte View-Definition
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
── skills/
└── example-skill.ts # Example AI agent skill definition
│ └── example-navigation-menu-item.ts # Beispiel für einen Navigationslink in der Seitenleiste
── skills/
└── example-skill.ts # Beispiel für eine Skill-Definition eines KI-Agenten
└── agents/
└── example-agent.ts # Beispiel für eine KI-Agenten-Definition
```
Mit `--minimal` werden nur die Kerndateien erstellt (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` und `logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ Das SDK erkennt Entitäten, indem es Ihre TypeScript-Dateien nach Aufrufen von *
| `defineView()` | Gespeicherte View-Definitionen |
| `defineNavigationMenuItem()` | Definitionen von Navigationsmenüeinträgen |
| `defineSkill()` | Skill-Definitionen für KI-Agenten |
| `defineAgent()` | KI-Agenten-Definitionen |
<Note>
**Dateibenennung ist flexibel.** Die Entitätserkennung ist AST-basiert — das SDK durchsucht Ihre Quelldateien nach dem Muster `export default define<Entity>({...})`. Sie können Ihre Dateien und Ordner nach Belieben organisieren. Die Gruppierung nach Entitätstyp (z. B. `logic-functions/`, `roles/`) ist lediglich eine Konvention zur Codeorganisation, keine Voraussetzung.
@@ -225,6 +228,7 @@ Das SDK stellt Hilfsfunktionen bereit, um die Entitäten Ihrer App zu definieren
| `defineView()` | Gespeicherte Views für Objekte definieren |
| `defineNavigationMenuItem()` | Seitenleisten-Navigationslinks definieren |
| `defineSkill()` | Definieren Sie Skills für KI-Agenten |
| `defineAgent()` | Define AI agents with system prompts |
Diese Funktionen validieren Ihre Konfiguration zur Build-Zeit und bieten IDE-Autovervollständigung sowie Typsicherheit.
@@ -802,6 +806,41 @@ Sie können neue Skills auf zwei Arten erstellen:
* **Generiert**: Führen Sie `yarn twenty entity:add` aus und wählen Sie die Option zum Hinzufügen eines neuen Skills.
* **Manuell**: Erstellen Sie eine neue Datei und verwenden Sie `defineSkill()` nach demselben Muster.
### Agenten
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
Hauptpunkte:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` ist der menschenlesbare Anzeigename, der in der UI angezeigt wird.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (optional) legt das in der UI angezeigte Symbol fest.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### Generierte typisierte Clients
Zwei typisierte Clients werden von `yarn twenty app:dev` automatisch generiert und basierend auf Ihrem Arbeitsbereichs-Schema in `node_modules/twenty-sdk/generated` gespeichert:
@@ -15,7 +15,7 @@ Le app ti consentono di creare e gestire le personalizzazioni di Twenty **come c
* Definisci oggetti e campi personalizzati come codice (modello dati gestito)
* Crea funzioni logiche con trigger personalizzati
* Definire le abilità per gli agenti IA
* Definire skill e agenti per l'IA
* Distribuisci la stessa app su più spazi di lavoro
## Prerequisiti
@@ -39,7 +39,7 @@ yarn twenty app:dev
Lo strumento di scaffolding supporta due modalità per controllare quali file di esempio vengono inclusi:
```bash filename="Terminal"
# Predefinita (esaustiva): tutti gli esempi (oggetto, campo, funzione logica, componente front-end, vista, voce del menu di navigazione, skill)
# Predefinita (esaustiva): tutti gli esempi (oggetto, campo, funzione logica, componente front-end, vista, voce del menu di navigazione, skill, agente)
npx create-twenty-app@latest my-app
# Minimale: solo i file principali (application-config.ts e default-role.ts)
@@ -96,27 +96,29 @@ my-twenty-app/
.oxlintrc.json
tsconfig.json
README.md
public/ # Public assets folder (images, fonts, etc.)
public/ # Cartella delle risorse pubbliche (immagini, font, ecc.)
src/
├── application-config.ts # Required - main application configuration
├── application-config.ts # Obbligatorio - configurazione principale dell'applicazione
├── roles/
│ └── default-role.ts # Default role for logic functions
│ └── default-role.ts # Ruolo predefinito per le funzioni logiche
├── objects/
│ └── example-object.ts # Example custom object definition
│ └── example-object.ts # Definizione di oggetto personalizzato di esempio
├── fields/
│ └── example-field.ts # Example standalone field definition
│ └── example-field.ts # Definizione di campo autonomo di esempio
├── logic-functions/
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
│ ├── hello-world.ts # Funzione logica di esempio
│ ├── pre-install.ts # Funzione logica di pre-installazione
│ └── post-install.ts # Funzione logica di post-installazione
├── front-components/
│ └── hello-world.tsx # Example front component
│ └── hello-world.tsx # Componente front-end di esempio
├── views/
│ └── example-view.ts # Example saved view definition
│ └── example-view.ts # Definizione di vista salvata di esempio
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
── skills/
└── example-skill.ts # Example AI agent skill definition
│ └── example-navigation-menu-item.ts # Link di navigazione della barra laterale di esempio
── skills/
└── example-skill.ts # Definizione di skill per agente IA di esempio
└── agents/
└── example-agent.ts # Definizione di agente IA di esempio
```
Con `--minimal`, vengono creati solo i file principali (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` e `logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ L'SDK rileva le entità analizzando i tuoi file TypeScript alla ricerca di chiam
| `defineView()` | Definizioni di viste salvate |
| `defineNavigationMenuItem()` | Definizioni delle voci del menu di navigazione |
| `defineSkill()` | AI agent skill definitions |
| `defineAgent()` | Definizioni di agenti IA |
<Note>
**La denominazione dei file è flessibile.** Il rilevamento delle entità è basato sull'AST — l'SDK esegue la scansione dei file sorgente alla ricerca del pattern `export default define<Entity>({...})`. Puoi organizzare file e cartelle come preferisci. Raggruppare per tipo di entità (ad es., `logic-functions/`, `roles/`) è solo una convenzione per l'organizzazione del codice, non un requisito.
@@ -225,6 +228,7 @@ L'SDK fornisce funzioni helper per definire le entità della tua app. Come descr
| `defineView()` | Definisce viste salvate per gli oggetti |
| `defineNavigationMenuItem()` | Definisce i link di navigazione della barra laterale |
| `defineSkill()` | Define AI agent skills |
| `defineAgent()` | Define AI agents with system prompts |
Queste funzioni convalidano la configurazione in fase di build e offrono il completamento automatico nell'IDE e la sicurezza dei tipi.
@@ -802,6 +806,41 @@ You can create new skills in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
* **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
### Agenti
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
Punti chiave:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` is the human-readable display name shown in the UI.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (optional) sets the icon displayed in the UI.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### Client tipizzati generati
Due client tipizzati sono generati automaticamente da `yarn twenty app:dev` e salvati in `node_modules/twenty-sdk/generated` in base allo schema della tua area di lavoro:
@@ -15,7 +15,7 @@ Os aplicativos permitem criar e gerenciar personalizações do Twenty **como có
* Defina objetos e campos personalizados como código (modelo de dados gerenciado)
* Crie funções de lógica com gatilhos personalizados
* Definir habilidades para agentes de IA
* Defina habilidades e agentes de IA
* Implemente o mesmo aplicativo em vários espaços de trabalho
## Pré-requisitos
@@ -39,7 +39,7 @@ yarn twenty app:dev
O gerador de estrutura oferece suporte a dois modos para controlar quais arquivos de exemplo são incluídos:
```bash filename="Terminal"
# Padrão (exhaustivo): todos os exemplos (objeto, campo, função de lógica, componente de front-end, visualização, item do menu de navegação, habilidade)
# Padrão (exhaustivo): todos os exemplos (objeto, campo, função de lógica, componente de front-end, visualização, item do menu de navegação, habilidade, agente)
npx create-twenty-app@latest my-app
# Mínimo: apenas arquivos principais (application-config.ts e default-role.ts)
@@ -96,27 +96,29 @@ my-twenty-app/
.oxlintrc.json
tsconfig.json
README.md
public/ # Pasta de recursos públicos (imagens, fontes, etc.)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Obrigatório - configuração principal da aplicação
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # Papel padrão para funções de lógica
│ └── default-role.ts # Default role for logic functions
├── objects/
│ └── example-object.ts # Exemplo de definição de objeto personalizado
│ └── example-object.ts # Example custom object definition
├── fields/
│ └── example-field.ts # Exemplo de definição de campo independente
│ └── example-field.ts # Example standalone field definition
├── logic-functions/
│ ├── hello-world.ts # Exemplo de função de lógica
│ ├── pre-install.ts # Função de lógica de pré-instalação
│ └── post-install.ts # Função de lógica de pós-instalação
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
├── front-components/
│ └── hello-world.tsx # Exemplo de componente de front-end
│ └── hello-world.tsx # Example front component
├── views/
│ └── example-view.ts # Exemplo de definição de visualização salva
│ └── example-view.ts # Example saved view definition
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Exemplo de link de navegação da barra lateral
── skills/
└── example-skill.ts # Exemplo de definição de habilidade de agente de IA
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
── skills/
└── example-skill.ts # Example AI agent skill definition
└── agents/
└── example-agent.ts # Example AI agent definition
```
With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ O SDK detecta entidades analisando seus arquivos TypeScript em busca de chamadas
| `defineView()` | Definições de visualizações salvas |
| `defineNavigationMenuItem()` | Definições de itens do menu de navegação |
| `defineSkill()` | Definições de habilidades de agente de IA |
| `defineAgent()` | Definições de agentes de IA |
<Note>
**A nomeação de arquivos é flexível.** A detecção de entidades é baseada em AST — o SDK varre seus arquivos fonte em busca do padrão `export default define<Entity>({...})`. Você pode organizar seus arquivos e pastas como quiser. Agrupar por tipo de entidade (por exemplo, `logic-functions/`, `roles/`) é apenas uma convenção para organização do código, não um requisito.
@@ -225,6 +228,7 @@ O SDK fornece funções utilitárias para definir as entidades do seu app. Confo
| `defineView()` | Define visualizações salvas para objetos |
| `defineNavigationMenuItem()` | Define links de navegação da barra lateral |
| `defineSkill()` | Define habilidades de agente de IA |
| `defineAgent()` | Define AI agents with system prompts |
Essas funções validam sua configuração em tempo de compilação e oferecem autocompletar na IDE e segurança de tipos.
@@ -803,6 +807,41 @@ Você pode criar novas habilidades de duas formas:
* **Gerado automaticamente**: Execute `yarn twenty entity:add` e escolha a opção para adicionar uma nova habilidade.
* **Manual**: Crie um novo arquivo e use `defineSkill()`, seguindo o mesmo padrão.
### Agentes
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
Pontos-chave:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` é o nome de exibição legível por humanos mostrado na UI.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (opcional) define o ícone exibido na UI.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### Clientes tipados gerados
Dois clientes tipados são gerados automaticamente pelo `yarn twenty app:dev` e armazenados em `node_modules/twenty-sdk/generated` com base no esquema do seu espaço de trabalho:
@@ -15,7 +15,7 @@ Aplicațiile vă permit să construiți și să gestionați personalizările Twe
* Definiți obiecte și câmpuri personalizate sub formă de cod (model de date gestionat)
* Creați funcții de logică cu declanșatoare personalizate
* Definiți abilități pentru agenți de IA
* Definiți abilități și agenți pentru AI
* Implementați aceeași aplicație în mai multe spații de lucru
## Cerințe
@@ -39,7 +39,7 @@ yarn twenty app:dev
Generatorul de schelet acceptă două moduri pentru a controla ce fișiere de exemplu sunt incluse:
```bash filename="Terminal"
# Implicit (exhaustiv): toate exemplele (obiect, câmp, funcție logică, componentă de interfață, vizualizare, element de meniu de navigare, abilitate)
# Implicit (exhaustiv): toate exemplele (obiect, câmp, funcție logică, componentă de interfață, vizualizare, element de meniu de navigare, abilitate, agent)
npx create-twenty-app@latest my-app
# Minimal: doar fișierele de bază (application-config.ts și default-role.ts)
@@ -96,27 +96,29 @@ my-twenty-app/
.oxlintrc.json
tsconfig.json
README.md
public/ # Public assets folder (images, fonts, etc.)
public/ # Director pentru resurse publice (imagini, fonturi etc.)
src/
├── application-config.ts # Required - main application configuration
├── application-config.ts # Obligatoriu - configurația principală a aplicației
├── roles/
│ └── default-role.ts # Default role for logic functions
│ └── default-role.ts # Rol implicit pentru funcțiile logice
├── objects/
│ └── example-object.ts # Example custom object definition
│ └── example-object.ts # Exemplu de definiție a unui obiect personalizat
├── fields/
│ └── example-field.ts # Example standalone field definition
│ └── example-field.ts # Exemplu de definiție de câmp independent
├── logic-functions/
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
│ ├── hello-world.ts # Exemplu de funcție logică
│ ├── pre-install.ts # Funcție logică de pre-instalare
│ └── post-install.ts # Funcție logică post-instalare
├── front-components/
│ └── hello-world.tsx # Example front component
│ └── hello-world.tsx # Exemplu de componentă de interfață
├── views/
│ └── example-view.ts # Example saved view definition
│ └── example-view.ts # Exemplu de definiție a unei vizualizări salvate
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
── skills/
└── example-skill.ts # Example AI agent skill definition
│ └── example-navigation-menu-item.ts # Exemplu de link de navigare în bara laterală
── skills/
└── example-skill.ts # Exemplu de definiție a unei abilități a agentului AI
└── agents/
└── example-agent.ts # Exemplu de definiție a unui agent AI
```
Cu `--minimal`, sunt create doar fișierele de bază (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` și `logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ SDK-ul detectează entitățile analizând fișierele TypeScript pentru apeluri
| `defineView()` | Definiții pentru vizualizări salvate |
| `defineNavigationMenuItem()` | Definiții pentru elemente de meniu de navigare |
| `defineSkill()` | Definiții ale abilităților agentului AI |
| `defineAgent()` | Definiții ale agenților AI |
<Note>
**Denumirea fișierelor este flexibilă.** Detectarea entităților se bazează pe AST — SDK-ul scanează fișierele sursă pentru tiparul `export default define<Entity>({...})`. Puteți organiza fișierele și folderele cum doriți. Gruparea după tipul de entitate (de exemplu, `logic-functions/`, `roles/`) este doar o convenție pentru organizarea codului, nu o cerință.
@@ -225,6 +228,7 @@ SDK-ul oferă funcții ajutătoare pentru definirea entităților aplicației. D
| `defineView()` | Definește vizualizări salvate pentru obiecte |
| `defineNavigationMenuItem()` | Definește linkuri de navigare în bara laterală |
| `defineSkill()` | Definiți abilități pentru agentul AI |
| `defineAgent()` | Define AI agents with system prompts |
Aceste funcții validează configurația în timpul build-ului și oferă completare automată în IDE și siguranța tipurilor.
@@ -802,6 +806,41 @@ Puteți crea abilități noi în două moduri:
* **Generat**: Rulați `yarn twenty entity:add` și alegeți opțiunea de a adăuga o abilitate nouă.
* **Manual**: Creați un fișier nou și folosiți `defineSkill()`, urmând același model.
### Agenți
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
Puncte cheie:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` este numele lizibil afișat în interfața cu utilizatorul (UI).
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (opțional) setează pictograma afișată în UI.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
@@ -15,7 +15,7 @@ description: Создавайте и управляйте настройками
* Определяйте пользовательские объекты и поля в виде кода (управляемая модель данных)
* Создавайте логические функции с пользовательскими триггерами
* Определите навыки для ИИ-агентов
* Определите навыки и агентов для ИИ
* Развёртывайте одно и то же приложение в нескольких рабочих пространствах
## Требования
@@ -39,7 +39,7 @@ yarn twenty app:dev
Генератор каркаса поддерживает два режима для управления тем, какие файлы-примеры включаются:
```bash filename="Terminal"
# По умолчанию (полный набор): все примеры (объект, поле, логическая функция, фронтенд-компонент, представление, пункт меню навигации, навык)
# По умолчанию (полный набор): все примеры (объект, поле, логическая функция, фронтенд-компонент, представление, пункт меню навигации, навык, агент)
npx create-twenty-app@latest my-app
# Минимальный: только основные файлы (application-config.ts и default-role.ts)
@@ -96,27 +96,29 @@ my-twenty-app/
.oxlintrc.json
tsconfig.json
README.md
public/ # Public assets folder (images, fonts, etc.)
public/ # Папка публичных ресурсов (изображения, шрифты и т. д.)
src/
├── application-config.ts # Required - main application configuration
├── application-config.ts # Обязательный — основная конфигурация приложения
├── roles/
│ └── default-role.ts # Default role for logic functions
│ └── default-role.ts # Роль по умолчанию для логических функций
├── objects/
│ └── example-object.ts # Example custom object definition
│ └── example-object.ts # Пример определения пользовательского объекта
├── fields/
│ └── example-field.ts # Example standalone field definition
│ └── example-field.ts # Пример определения отдельного поля
├── logic-functions/
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
│ ├── hello-world.ts # Пример логической функции
│ ├── pre-install.ts # Предустановочная логическая функция
│ └── post-install.ts # Послеустановочная логическая функция
├── front-components/
│ └── hello-world.tsx # Example front component
│ └── hello-world.tsx # Пример фронтенд-компонента
├── views/
│ └── example-view.ts # Example saved view definition
│ └── example-view.ts # Пример определения сохранённого представления
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
── skills/
└── example-skill.ts # Example AI agent skill definition
│ └── example-navigation-menu-item.ts # Пример ссылки боковой панели навигации
── skills/
└── example-skill.ts # Пример определения навыка агента ИИ
└── agents/
└── example-agent.ts # Пример определения агента ИИ
```
С `--minimal` создаются только основные файлы (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` и `logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ SDK обнаруживает сущности, разбирая ваши фай
| `defineView()` | Определения сохранённых представлений |
| `defineNavigationMenuItem()` | Определения пунктов меню навигации |
| `defineSkill()` | Определения навыков агента ИИ |
| `defineAgent()` | Определения агентов ИИ |
<Note>
**Имена файлов заданы гибко.** Обнаружение сущностей основано на AST — SDK сканирует ваши исходные файлы в поисках шаблона `export default define<Entity>({...})`. Вы можете организовывать файлы и папки как угодно. Группировка по типу сущности (например, `logic-functions/`, `roles/`) — это лишь соглашение для организации кода, а не требование.
@@ -225,6 +228,7 @@ SDK предоставляет вспомогательные функции д
| `defineView()` | Определяйте сохранённые представления для объектов |
| `defineNavigationMenuItem()` | Определяйте ссылки боковой панели навигации |
| `defineSkill()` | Определение навыков агента ИИ |
| `defineAgent()` | Define AI agents with system prompts |
Эти функции проверяют вашу конфигурацию на этапе сборки и обеспечивают автодополнение в IDE и безопасность типов.
@@ -802,6 +806,41 @@ export default defineSkill({
* **Сгенерировано**: Запустите `yarn twenty entity:add` и выберите опцию добавления нового навыка.
* **Вручную**: Создайте новый файл и используйте `defineSkill()`, следуя тому же шаблону.
### Агенты
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
Основные моменты:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` — читаемое человеком отображаемое имя, показываемое в UI.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (необязательно) задаёт значок, отображаемый в UI.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### Сгенерированные типизированные клиенты
Два типизированных клиента автоматически генерируются с помощью `yarn twenty app:dev` и сохраняются в `node_modules/twenty-sdk/generated` на основе схемы вашего рабочего пространства:
@@ -15,7 +15,7 @@ Uygulamalar, Twenty özelleştirmelerini **kod olarak** oluşturup yönetmenizi
* Özel nesneleri ve alanları kod olarak tanımlayın (yönetilen veri modeli)
* Özel tetikleyicilerle mantık fonksiyonları oluşturun
* Yapay zekâ ajanları için becerileri tanımlayın
* Define skills and agents for AI
* Aynı uygulamayı birden çok çalışma alanına dağıtın
## Ön Gereksinimler
@@ -39,10 +39,10 @@ yarn twenty app:dev
İskelet oluşturucu, hangi örnek dosyaların dahil edileceğini kontrol etmek için iki modu destekler:
```bash filename="Terminal"
# Varsayılan (kapsamlı): tüm örnekler (nesne, alan, mantık fonksiyonu, ön bileşen, görünüm, gezinme menüsü öğesi, yetenek)
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent)
npx create-twenty-app@latest my-app
# Minimal: yalnızca çekirdek dosyalar (application-config.ts ve default-role.ts)
# Minimal: only core files (application-config.ts and default-role.ts)
npx create-twenty-app@latest my-app --minimal
```
@@ -96,27 +96,29 @@ my-twenty-app/
.oxlintrc.json
tsconfig.json
README.md
public/ # Genel varlıklar klasörü (görseller, yazı tipleri vb.)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Gerekli - ana uygulama yapılandırması
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # Mantık fonksiyonları için varsayılan rol
│ └── default-role.ts # Default role for logic functions
├── objects/
│ └── example-object.ts # Örnek özel nesne tanımı
│ └── example-object.ts # Example custom object definition
├── fields/
│ └── example-field.ts # Örnek bağımsız alan tanımı
│ └── example-field.ts # Example standalone field definition
├── logic-functions/
│ ├── hello-world.ts # Örnek mantık fonksiyonu
│ ├── pre-install.ts # Kurulum öncesi mantık fonksiyonu
│ └── post-install.ts # Kurulum sonrası mantık fonksiyonu
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
├── front-components/
│ └── hello-world.tsx # Örnek ön bileşen
│ └── hello-world.tsx # Example front component
├── views/
│ └── example-view.ts # Örnek kaydedilmiş görünüm tanımı
│ └── example-view.ts # Example saved view definition
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Örnek kenar çubuğu gezinme bağlantısı
── skills/
└── example-skill.ts # Örnek yapay zekâ ajanı yetenek tanımı
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
── skills/
└── example-skill.ts # Example AI agent skill definition
└── agents/
└── example-agent.ts # Example AI agent definition
```
`--minimal` ile yalnızca çekirdek dosyalar oluşturulur (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` ve `logic-functions/post-install.ts`).
@@ -148,6 +150,7 @@ SDK, TypeScript dosyalarınızı **`export default define<Entity>({...})`** ça
| `defineView()` | Kaydedilmiş görünüm tanımları |
| `defineNavigationMenuItem()` | Gezinme menüsü öğesi tanımları |
| `defineSkill()` | Yapay zekâ ajanı yetenek tanımları |
| `defineAgent()` | AI agent definitions |
<Note>
**Dosya adlandırma esnektir.** Varlık algılama AST tabanlıdır — SDK, kaynak dosyalarınızı `export default define<Entity>({...})` desenini bulmak için tarar. Dosyalarınızı ve klasörlerinizi dilediğiniz gibi düzenleyebilirsiniz. Varlık türüne göre gruplama (örn. `logic-functions/`, `roles/`) bir gereklilik değil, yalnızca kod organizasyonu için bir gelenektir.
@@ -225,6 +228,7 @@ SDK, uygulama varlıklarınızı tanımlamak için yardımcı fonksiyonlar sağl
| `defineView()` | Nesneler için kaydedilmiş görünümler tanımlayın |
| `defineNavigationMenuItem()` | Kenar çubuğu gezinme bağlantılarını tanımlayın |
| `defineSkill()` | Yapay zekâ ajanı yeteneklerini tanımlayın |
| `defineAgent()` | Define AI agents with system prompts |
Bu fonksiyonlar, derleme zamanında yapılandırmanızı doğrular ve IDE otomatik tamamlama ile tür güvenliği sağlar.
@@ -802,6 +806,41 @@ Yeni yetenekleri iki şekilde oluşturabilirsiniz:
* **Şablondan**: `yarn twenty entity:add` komutunu çalıştırın ve yeni bir yetenek ekleme seçeneğini seçin.
* **Manuel**: Yeni bir dosya oluşturun ve aynı deseni izleyerek `defineSkill()` kullanın.
### Temsilciler
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
Önemli noktalar:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label`, UI'de gösterilen, insan tarafından okunabilir addır.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (isteğe bağlı), UI'de gösterilen simgeyi ayarlar.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### Oluşturulan tiplendirilmiş istemciler
Çalışma alanı şemanıza göre `yarn twenty app:dev` tarafından iki tiplendirilmiş istemci otomatik olarak oluşturulur ve `node_modules/twenty-sdk/generated` içine kaydedilir:
@@ -15,7 +15,7 @@ description: 以代码的形式构建并管理 Twenty 自定义项。
* 以代码定义自定义对象和字段(受管理的数据模型)
* 构建带有自定义触发器的逻辑函数
* 为 AI 智能体定义技能
* Define skills and agents for AI
* 将同一个应用部署到多个工作空间
## 先决条件
@@ -39,10 +39,10 @@ yarn twenty app:dev
脚手架工具支持两种模式,用于控制包含哪些示例文件:
```bash filename="Terminal"
# 默认(完整):所有示例(对象、字段、逻辑函数、前端组件、视图、导航菜单项、技能)
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent)
npx create-twenty-app@latest my-app
# 最小化:仅核心文件(application-config.ts default-role.ts
# Minimal: only core files (application-config.ts and default-role.ts)
npx create-twenty-app@latest my-app --minimal
```
@@ -115,8 +115,10 @@ my-twenty-app/
│ └── example-view.ts # Example saved view definition
├── navigation-menu-items/
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
── skills/
└── example-skill.ts # Example AI agent skill definition
── skills/
└── example-skill.ts # Example AI agent skill definition
└── agents/
└── example-agent.ts # Example AI agent definition
```
使用 `--minimal` 时,只会创建核心文件(`application-config.ts`、`roles/default-role.ts`、`logic-functions/pre-install.ts` 和 `logic-functions/post-install.ts`)。
@@ -148,6 +150,7 @@ my-twenty-app/
| `defineView()` | Saved view definitions |
| `defineNavigationMenuItem()` | Navigation menu item definitions |
| `defineSkill()` | AI agent skill definitions |
| `defineAgent()` | AI agent definitions |
<Note>
**文件命名是灵活的。** 实体检测基于 AST — SDK 会扫描你的源文件以查找 `export default define<Entity>({...})` 模式。 你可以按照自己的喜好组织文件和文件夹。 按实体类型分组(例如 `logic-functions/`、`roles/`)只是代码组织的一种约定,并非必需。
@@ -212,19 +215,20 @@ twenty-sdk 提供你在应用中使用的类型化构件和辅助函数。 以
该 SDK 提供辅助函数用于定义你的应用实体。 如 [实体检测](#entity-detection) 中所述,你必须使用 `export default define<Entity>({...})` 才能让你的实体被检测到:
| 函数 | 目的 |
| ---------------------------------- | ------------------------------- |
| `defineApplication()` | 配置应用元数据(必需,每个应用一个) |
| `defineObject()` | 定义带字段的自定义对象 |
| `defineLogicFunction()` | 定义带处理程序的逻辑函数 |
| `definePreInstallLogicFunction()` | 定义一个安装前逻辑函数(每个应用一个) |
| `definePostInstallLogicFunction()` | 定义一个安装后逻辑函数(每个应用一个) |
| `defineFrontComponent()` | 为自定义 UI 定义前端组件 |
| `defineRole()` | 配置角色权限和对象访问 |
| `defineField()` | 为现有对象扩展额外字段 |
| `defineView()` | Define saved views for objects |
| `defineNavigationMenuItem()` | Define sidebar navigation links |
| `defineSkill()` | Define AI agent skills |
| 函数 | 目的 |
| ---------------------------------- | ------------------------------------ |
| `defineApplication()` | 配置应用元数据(必需,每个应用一个) |
| `defineObject()` | 定义带字段的自定义对象 |
| `defineLogicFunction()` | 定义带处理程序的逻辑函数 |
| `definePreInstallLogicFunction()` | 定义一个安装前逻辑函数(每个应用一个) |
| `definePostInstallLogicFunction()` | 定义一个安装后逻辑函数(每个应用一个) |
| `defineFrontComponent()` | 为自定义 UI 定义前端组件 |
| `defineRole()` | 配置角色权限和对象访问 |
| `defineField()` | 为现有对象扩展额外字段 |
| `defineView()` | Define saved views for objects |
| `defineNavigationMenuItem()` | Define sidebar navigation links |
| `defineSkill()` | Define AI agent skills |
| `defineAgent()` | Define AI agents with system prompts |
这些函数会在构建时校验你的配置,并提供 IDE 自动补全和类型安全。
@@ -802,6 +806,41 @@ You can create new skills in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
* **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
### 代理
Agents define AI agents with system prompts that can operate within your workspace. Use `defineAgent()` to define agents with built-in validation:
```typescript
// src/agents/example-agent.ts
import { defineAgent } from 'twenty-sdk';
export default defineAgent({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-assistant',
label: 'Sales Assistant',
description: 'An AI agent that helps with sales tasks',
icon: 'IconRobot',
prompt: `You are a sales assistant. Help users with:
1. Researching prospects and companies
2. Drafting personalized outreach messages
3. Tracking follow-ups and next steps
4. Analyzing deal pipeline and suggesting actions`,
});
```
关键点:
* `name` is a unique identifier string for the agent (kebab-case recommended).
* `label` is the human-readable display name shown in the UI.
* `prompt` contains the system prompt — this is the instruction text that defines the agent's behavior.
* `icon` (optional) sets the icon displayed in the UI.
* `description` (optional) provides additional context about the agent's purpose.
You can create new agents in two ways:
* **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new agent.
* **Manual**: Create a new file and use `defineAgent()`, following the same pattern.
### 生成的类型化客户端
两个类型化客户端由 `yarn twenty app:dev` 自动生成(基于你的工作区架构),并存放在 `node_modules/twenty-sdk/generated`
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
import { readdir, rm } from 'node:fs/promises';
import { join } from 'path';
import { appBuild } from '@/cli/public-operations/app-build';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import { GENERATED_DIR, OUTPUT_DIR } from 'twenty-shared/application';
const APP_PATH = join(__dirname, '../..');
describe('rich-app app:build', () => {
let buildResult: Awaited<ReturnType<typeof appBuild>>;
beforeAll(async () => {
const generatedPath = join(
APP_PATH,
'node_modules',
'twenty-sdk',
GENERATED_DIR,
);
await rm(generatedPath, { recursive: true, force: true });
buildResult = await appBuild({ appPath: APP_PATH });
}, 60_000);
it('should complete build successfully', () => {
expect(buildResult.success).toBe(true);
});
it('should produce manifest in output directory', async () => {
const manifestPath = join(APP_PATH, OUTPUT_DIR, 'manifest.json');
expect(await pathExists(manifestPath)).toBe(true);
});
it('should produce built logic function files', async () => {
const outputDir = join(APP_PATH, OUTPUT_DIR);
const files = await readdir(outputDir, { recursive: true });
const functionFiles = files
.map((file) => file.toString())
.filter((file) => file.endsWith('.function.mjs'))
.sort();
expect(functionFiles.length).toBeGreaterThan(0);
});
// app:build only creates client stubs (empty CoreApiClient / MetadataApiClient)
// because it doesn't sync with the server or generate the real API client.
// Logic functions that use CoreApiClient will get an empty class at runtime.
// When this is fixed, update this test to assert the client IS fully generated.
// See: https://github.com/twentyhq/twenty/pull/18460
it('should only produce client stubs (not a fully generated CoreApiClient)', async () => {
const clientIndexPath = join(
APP_PATH,
'node_modules',
'twenty-sdk',
GENERATED_DIR,
'core',
'index.ts',
);
const { readFile } = await import('node:fs/promises');
const content = await readFile(clientIndexPath, 'utf-8');
expect(content).toBe('export class CoreApiClient {}\n');
});
});

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 570 B

@@ -1,10 +1,10 @@
import {
POST_CARD_ON_POST_CARD_RECIPIENT_ID,
POST_CARD_RECIPIENTS_ON_POST_CARD_ID,
} from '@/cli/__tests__/apps/rich-app/src/fields/post-card-recipients-on-post-card.field';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
} from '@/app-seeds/rich-app/src/fields/post-card-recipients-on-post-card.field';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card-recipient.object';
import { defineField, FieldType, OnDeleteAction, RelationType } from '@/sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card.object';
export default defineField({
universalIdentifier: POST_CARD_ON_POST_CARD_RECIPIENT_ID,
@@ -1,6 +1,6 @@
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card-recipient.object';
import { defineField, FieldType, RelationType } from '@/sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card.object';
export const POST_CARD_RECIPIENTS_ON_POST_CARD_ID =
'a1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d';
@@ -1,6 +1,6 @@
import { defineField, FieldType, RelationType } from '@/sdk';
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/recipient.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/recipient.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card-recipient.object';
export const POST_CARD_RECIPIENTS_ON_RECIPIENT_ID =
'a1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d';
@@ -1,5 +1,5 @@
import { defineField, FieldType } from '@/sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -1,5 +1,5 @@
import { defineField, FieldType } from '@/sdk';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -1,10 +1,10 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from '@/sdk';
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/recipient.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/recipient.object';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/app-seeds/rich-app/src/objects/post-card-recipient.object';
import {
POST_CARD_RECIPIENTS_ON_RECIPIENT_ID,
RECIPIENT_ON_POST_CARD_RECIPIENT_ID,
} from '@/cli/__tests__/apps/rich-app/src/fields/post-card-recipients-on-recipient.field';
} from '@/app-seeds/rich-app/src/fields/post-card-recipients-on-recipient.field';
export default defineField({
universalIdentifier: RECIPIENT_ON_POST_CARD_RECIPIENT_ID,
@@ -1,5 +1,5 @@
import { defineNavigationMenuItem } from '@/sdk';
import { ALL_POST_CARD_RECIPIENTS_VIEW_ID } from '@/cli/__tests__/apps/rich-app/src/views/all-post-card-recipients.view';
import { ALL_POST_CARD_RECIPIENTS_VIEW_ID } from '@/app-seeds/rich-app/src/views/all-post-card-recipients.view';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
@@ -1,5 +1,5 @@
import { defineNavigationMenuItem } from '@/sdk';
import { ALL_POST_CARDS_VIEW_ID } from '@/cli/__tests__/apps/rich-app/src/views/all-post-cards.view';
import { ALL_POST_CARDS_VIEW_ID } from '@/app-seeds/rich-app/src/views/all-post-cards.view';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
@@ -1,5 +1,5 @@
import { defineNavigationMenuItem } from '@/sdk';
import { ALL_RECIPIENTS_VIEW_ID } from '@/cli/__tests__/apps/rich-app/src/views/all-recipients.view';
import { ALL_RECIPIENTS_VIEW_ID } from '@/app-seeds/rich-app/src/views/all-recipients.view';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
@@ -2,7 +2,7 @@ import { PermissionFlag, defineRole } from '@/sdk';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
} from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
} from '@/app-seeds/rich-app/src/objects/post-card.object';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
@@ -1,7 +1,7 @@
import {
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
} from '@/app-seeds/rich-app/src/objects/post-card-recipient.object';
import { defineView } from '@/sdk';
import { ViewType } from 'twenty-shared/types';
@@ -2,7 +2,7 @@ import { defineView } from '@/sdk';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
} from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
} from '@/app-seeds/rich-app/src/objects/post-card.object';
import { ViewType } from 'twenty-shared/types';
export const ALL_POST_CARDS_VIEW_ID = 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d';
@@ -1,7 +1,7 @@
import {
RECIPIENT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RECIPIENT_UNIVERSAL_IDENTIFIER,
} from '@/cli/__tests__/apps/rich-app/src/objects/recipient.object';
} from '@/app-seeds/rich-app/src/objects/recipient.object';
import { defineView } from '@/sdk';
import { ViewType } from 'twenty-shared/types';
@@ -9,7 +9,7 @@
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["../../../../../src/*"]
"@/*": ["../../../src/*"]
}
},
"include": ["**/*"],
@@ -1,7 +1,7 @@
import { join } from 'path';
import { type Manifest } from 'twenty-shared/application';
import { EXPECTED_MANIFEST } from '@/cli/__tests__/apps/root-app/__integration__/app-dev/expected-manifest';
import { EXPECTED_MANIFEST } from '@/app-seeds/root-app/__integration__/app-dev/expected-manifest';
import { normalizeManifestForComparison } from '@/cli/__tests__/integration/utils/normalize-manifest.util';
import { pathExists, readJson } from '@/cli/utilities/file/fs-utils';
@@ -9,7 +9,7 @@
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["../../../../../src/*"]
"@/*": ["../../../src/*"]
}
},
"include": ["**/*"]
@@ -76,4 +76,24 @@ describe('functionExecute E2E', () => {
error: { code: 'FUNCTION_NOT_FOUND' },
});
});
// Verifies that CoreApiClient is fully generated (not just stubs) after
// appGenerateClient. If app:build is used without client generation, the
// bundled CoreApiClient would be an empty stub and this test would fail.
// See: https://github.com/twentyhq/twenty/pull/18460
it('should execute a function that uses CoreApiClient with real query/mutation methods', async () => {
const result = await functionExecute({
appPath: APP_PATH,
functionName: 'check-core-client',
});
expect(result).toMatchObject({
success: true,
data: {
functionName: 'check-core-client',
status: 'SUCCESS',
data: { hasQuery: true, hasMutation: true },
},
});
});
});
@@ -0,0 +1,26 @@
import { CoreApiClient } from 'twenty-sdk/generated';
import { defineLogicFunction } from '@/sdk';
export const CHECK_CORE_CLIENT_UNIVERSAL_IDENTIFIER =
'b7c8d9e0-1a2b-4c3d-8e5f-6a7b8c9d0e1f';
const checkCoreClientHandler = () => {
const client = new CoreApiClient();
return {
hasQuery: typeof client.query === 'function',
hasMutation: typeof client.mutation === 'function',
};
};
export default defineLogicFunction({
universalIdentifier: CHECK_CORE_CLIENT_UNIVERSAL_IDENTIFIER,
name: 'check-core-client',
timeoutSeconds: 5,
handler: checkCoreClientHandler,
httpRouteTriggerSettings: {
path: '/check-core-client',
httpMethod: 'GET',
isAuthRequired: false,
},
});
+3 -1
View File
@@ -36,6 +36,8 @@
"src/front-component-renderer/host/generated/host-component-registry.ts",
"src/front-component-renderer/remote/generated/remote-components.ts",
"src/front-component-renderer/remote/generated/remote-elements.ts",
"src/front-component-renderer/__stories__/**/*"
"src/front-component-renderer/__stories__/**/*",
"src/app-seeds/*/src/**/*",
"src/cli/__tests__/apps/*/src/**/*"
]
}
+4
View File
@@ -39,6 +39,10 @@
{
"include": "engine/workspace-manager/dev-seeder/data/sample-files/**",
"outDir": "dist/assets"
},
{
"include": "engine/workspace-manager/dev-seeder/core/constants/*.json",
"outDir": "dist"
}
]
},
@@ -10,23 +10,13 @@ export class AddWorkspaceIdToApplicationRegistration1772267875869
`ALTER TABLE "core"."applicationRegistration" ADD "workspaceId" uuid`,
);
// Delete any orphaned registrations that can't be assigned a workspace
await queryRunner.query(`
DELETE FROM "core"."applicationRegistration"
WHERE "workspaceId" IS NULL
`);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ALTER COLUMN "workspaceId" SET NOT NULL`,
);
await queryRunner.query(`
CREATE INDEX "IDX_APPLICATION_REGISTRATION_WORKSPACE_ID"
ON "core"."applicationRegistration" ("workspaceId")
`);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_94ab20372e448d45088357f884e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_94ab20372e448d45088357f884e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
@@ -36,9 +36,28 @@ export class AddAppRegistrationSourceFields1772267875870
ADD CONSTRAINT "CHK_NPM_HAS_SOURCE_PACKAGE"
CHECK ("sourceType" <> 'npm' OR "sourcePackage" IS NOT NULL)`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD COLUMN "provenanceRepositoryUrl" text`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD COLUMN "isProvenanceVerified" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD COLUMN "provenanceVerifiedAt" timestamptz`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "provenanceVerifiedAt"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "isProvenanceVerified"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "provenanceRepositoryUrl"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT IF EXISTS "CHK_NPM_HAS_SOURCE_PACKAGE"`,
);
@@ -1,61 +0,0 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddIsListedToAppRegistration1772732588833
implements MigrationInterface
{
name = 'AddIsListedToAppRegistration1772732588833';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD "isListed" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ALTER COLUMN "workspaceId" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT IF EXISTS "FK_applicationRegistration_workspaceId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_applicationRegistration_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE SET NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_applicationRegistration_workspaceId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_94ab20372e448d45088357f884e"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ALTER COLUMN "workspaceId" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_94ab20372e448d45088357f884e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_applicationRegistration_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_94ab20372e448d45088357f884e"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ALTER COLUMN "workspaceId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_94ab20372e448d45088357f884e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT IF EXISTS "FK_applicationRegistration_workspaceId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_applicationRegistration_workspaceId" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" ALTER COLUMN "workspaceId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationRegistration" DROP COLUMN "isListed"`,
);
}
}
@@ -9,6 +9,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationQueryResolver } from 'src/engine/core-modules/application/application-install/application-query.resolver';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -23,7 +24,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
PermissionsModule,
FileStorageModule,
],
providers: [ApplicationInstallResolver, ApplicationInstallService],
providers: [
ApplicationInstallResolver,
ApplicationQueryResolver,
ApplicationInstallService,
],
exports: [ApplicationInstallService],
})
export class ApplicationInstallModule {}
@@ -1,15 +1,12 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { Args, Mutation } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -27,36 +24,9 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
export class ApplicationInstallResolver {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationInstallService: ApplicationInstallService,
) {}
@Query(() => [ApplicationDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findManyApplications(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.applicationService.findManyApplications(workspaceId);
}
@Query(() => ApplicationDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findOneApplication(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
@Args('universalIdentifier', {
type: () => UUIDScalarType,
nullable: true,
})
universalIdentifier?: string,
) {
return await this.applicationService.findOneApplicationOrThrow({
id,
universalIdentifier,
workspaceId,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { promises as fs } from 'fs';
import { join, relative } from 'path';
import { type Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -21,6 +22,7 @@ import {
type ResolvedPackage,
} from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { readJsonFileOrThrow } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -94,6 +96,32 @@ export class ApplicationInstallService {
);
}
async installFromLocalDirectory(params: {
appRegistrationId: string;
extractedDir: string;
workspaceId: string;
}): Promise<void> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
where: { id: params.appRegistrationId },
});
const manifest = await readJsonFileOrThrow<Manifest>(
params.extractedDir,
'manifest.json',
);
await this.syncFromExtractedDirectory({
appRegistration,
extractedDir: params.extractedDir,
manifest,
workspaceId: params.workspaceId,
});
this.logger.log(
`Successfully installed app ${appRegistration.universalIdentifier} from local directory`,
);
}
private async doInstallApplication(
appRegistration: ApplicationRegistrationEntity,
params: { version?: string; workspaceId: string },
@@ -111,30 +139,15 @@ export class ApplicationInstallService {
return true;
}
const universalIdentifier = appRegistration.universalIdentifier;
await this.ensureApplicationExists({
universalIdentifier,
name: resolvedPackage.manifest.application.displayName,
workspaceId: params.workspaceId,
applicationRegistrationId: appRegistration.id,
sourceType: appRegistration.sourceType,
});
await this.writeFilesToStorage(
resolvedPackage.extractedDir,
universalIdentifier,
params.workspaceId,
);
await this.applicationSyncService.synchronizeFromManifest({
workspaceId: params.workspaceId,
await this.syncFromExtractedDirectory({
appRegistration,
extractedDir: resolvedPackage.extractedDir,
manifest: resolvedPackage.manifest,
applicationRegistrationId: appRegistration.id,
workspaceId: params.workspaceId,
});
this.logger.log(
`Successfully installed app ${universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
`Successfully installed app ${appRegistration.universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
);
return true;
@@ -153,6 +166,35 @@ export class ApplicationInstallService {
}
}
private async syncFromExtractedDirectory(params: {
appRegistration: ApplicationRegistrationEntity;
extractedDir: string;
manifest: Manifest;
workspaceId: string;
}): Promise<void> {
const { appRegistration, extractedDir, manifest, workspaceId } = params;
await this.ensureApplicationExists({
universalIdentifier: appRegistration.universalIdentifier,
name: manifest.application.displayName,
workspaceId,
applicationRegistrationId: appRegistration.id,
sourceType: appRegistration.sourceType,
});
await this.writeFilesToStorage(
extractedDir,
appRegistration.universalIdentifier,
workspaceId,
);
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
applicationRegistrationId: appRegistration.id,
});
}
private async writeFilesToStorage(
extractedDir: string,
applicationUniversalIdentifier: string,
@@ -0,0 +1,51 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { FeatureFlagGuard } from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
export class ApplicationQueryResolver {
constructor(private readonly applicationService: ApplicationService) {}
@Query(() => [ApplicationDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findManyApplications(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.applicationService.findManyApplications(workspaceId);
}
@Query(() => ApplicationDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findOneApplication(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
@Args('universalIdentifier', {
type: () => UUIDScalarType,
nullable: true,
})
universalIdentifier?: string,
) {
return await this.applicationService.findOneApplicationOrThrow({
id,
universalIdentifier,
workspaceId,
});
}
}
@@ -11,10 +11,6 @@ import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { RunWorkspaceMigrationInput } from 'src/engine/core-modules/application/application-manifest/dtos/run-workspace-migration.input';
@@ -29,7 +25,6 @@ import {
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@@ -43,7 +38,6 @@ export class ApplicationManifestResolver {
private readonly applicationService: ApplicationService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@Mutation(() => Boolean)
@@ -53,22 +47,6 @@ export class ApplicationManifestResolver {
@Args() { workspaceMigration: { actions } }: RunWorkspaceMigrationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
const { featureFlagsMap } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['featureFlagsMap'],
);
if (
featureFlagsMap[
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED
] !== true
) {
throw new ApplicationException(
'Application installation from tarball is not enabled',
ApplicationExceptionCode.FORBIDDEN,
);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
@@ -246,6 +246,11 @@ export class MarketplaceAppDTO {
@Field()
aboutDescription: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
readme?: string;
@IsArray()
@Field(() => [String])
providers: string[];
@@ -1,8 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationNpmRegistrationService } from 'src/engine/core-modules/application/application-registration/application-npm-registration.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
import { type NpmPackument } from 'src/engine/core-modules/application/application-marketplace/types/npm-packument.type';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
@Injectable()
@@ -12,6 +17,7 @@ export class MarketplaceCatalogSyncService {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly marketplaceService: MarketplaceService,
private readonly applicationNpmRegistrationService: ApplicationNpmRegistrationService,
) {}
async syncCatalog(): Promise<void> {
@@ -36,7 +42,6 @@ export class MarketplaceCatalogSyncService {
websiteUrl: entry.websiteUrl ?? null,
termsUrl: entry.termsUrl ?? null,
latestAvailableVersion: entry.richDisplayData.version ?? null,
isListed: true,
isFeatured: entry.isFeatured,
marketplaceDisplayData: entry.richDisplayData,
ownerWorkspaceId: null,
@@ -62,21 +67,50 @@ export class MarketplaceCatalogSyncService {
}
try {
const packageName = app.sourcePackage ?? app.name;
let isProvenanceVerified = false;
let provenanceRepositoryUrl: string | null = null;
let provenanceVerifiedAt: Date | null = null;
if (app.version) {
const provenance =
await this.applicationNpmRegistrationService.fetchProvenanceMetadata(
packageName,
app.version,
);
if (provenance?.hasProvenance) {
isProvenanceVerified = true;
provenanceRepositoryUrl = provenance.repositoryUrl;
provenanceVerifiedAt = new Date();
}
}
const packument =
await this.marketplaceService.fetchPackument(packageName);
const displayData = isDefined(packument)
? this.buildDisplayDataFromPackument(packument)
: null;
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: app.id,
name: app.name,
description: app.description,
author: app.author,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: app.sourcePackage ?? app.name,
sourcePackage: packageName,
logoUrl: null,
websiteUrl: app.websiteUrl ?? null,
termsUrl: null,
latestAvailableVersion: app.version ?? null,
isListed: true,
isFeatured: false,
marketplaceDisplayData: null,
marketplaceDisplayData: displayData,
ownerWorkspaceId: null,
isProvenanceVerified,
provenanceRepositoryUrl,
provenanceVerifiedAt,
});
} catch (error) {
this.logger.error(
@@ -85,4 +119,37 @@ export class MarketplaceCatalogSyncService {
}
}
}
private buildDisplayDataFromPackument(
packument: NpmPackument,
): MarketplaceDisplayData {
const repositoryUrl = this.extractRepositoryUrl(packument.repository);
return {
readme:
isDefined(packument.readme) &&
packument.readme !== 'ERROR: No README data found!'
? packument.readme
: undefined,
aboutDescription: packument.description,
providers: isDefined(repositoryUrl) ? [repositoryUrl] : [],
};
}
private extractRepositoryUrl(
repository?: { type?: string; url?: string } | string,
): string | null {
if (!isDefined(repository)) {
return null;
}
const url =
typeof repository === 'string' ? repository : (repository.url ?? null);
if (!isDefined(url)) {
return null;
}
return url.replace(/^git\+/, '').replace(/\.git$/, '');
}
}
@@ -35,7 +35,7 @@ export class MarketplaceQueryService {
}
const registrations =
await this.applicationRegistrationService.findManyListed();
await this.applicationRegistrationService.findManyNpm();
if (registrations.length === 0) {
if (!this.hasSyncBeenEnqueued) {
@@ -105,6 +105,7 @@ export class MarketplaceQueryService {
screenshots: displayData?.screenshots ?? [],
aboutDescription:
displayData?.aboutDescription ?? registration.description ?? '',
readme: displayData?.readme,
providers: displayData?.providers ?? [],
websiteUrl: registration.websiteUrl ?? undefined,
termsUrl: registration.termsUrl ?? undefined,
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { type NpmPackument } from 'src/engine/core-modules/application/application-marketplace/types/npm-packument.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmSearchResultSchema = z.object({
@@ -28,6 +29,31 @@ export class MarketplaceService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async fetchPackument(packageName: string): Promise<NpmPackument | null> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
try {
const { data } = await axios.get<NpmPackument>(
`${registryUrl}/${encodeURIComponent(packageName)}`,
{
headers: {
Accept: 'application/json',
'User-Agent': 'Twenty-Marketplace',
},
timeout: 10_000,
},
);
return data;
} catch (error) {
this.logger.warn(
`Failed to fetch packument for ${packageName}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
@@ -53,6 +79,11 @@ export class MarketplaceService {
return parsed.data.objects
.map((result) => {
const { name, version, description, author, links } = result.package;
if (!this.hasValidAppPrefix(name)) {
return null;
}
const twentyKeyword = (result.package.keywords ?? []).find(
(keyword) => keyword.startsWith('twenty-uid:'),
);
@@ -92,4 +123,11 @@ export class MarketplaceService {
return [];
}
}
private hasValidAppPrefix(packageName: string): boolean {
return (
packageName.startsWith('twenty-app-') ||
/^@[^/]+\/twenty-app-/.test(packageName)
);
}
}
@@ -8,6 +8,7 @@ export type MarketplaceDisplayData = {
logo?: string;
screenshots?: string[];
aboutDescription?: string;
readme?: string;
providers?: string[];
objects?: MarketplaceDisplayObject[];
fields?: MarketplaceDisplayField[];
@@ -0,0 +1,10 @@
// Subset of the npm registry packument response (GET /{packageName}).
// Only the fields we actually use are typed; the full response is much larger.
export type NpmPackument = {
name: string;
description?: string;
readme?: string;
license?: string;
repository?: { type?: string; url?: string } | string;
homepage?: string;
};
@@ -65,13 +65,6 @@ export class ApplicationPackageFetcherService implements OnModuleInit {
}
}
async resolveNpmPackage(
packageName: string,
targetVersion?: string,
): Promise<ResolvedPackage> {
return this.resolveFromNpm(packageName, targetVersion);
}
async resolvePackage(
appRegistration: ApplicationRegistrationEntity,
options?: { targetVersion?: string },
@@ -0,0 +1,42 @@
import { ApplicationException } from 'src/engine/core-modules/application/application.exception';
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/application-package/utils/assert-valid-npm-package-name.util';
describe('assertValidNpmPackageName', () => {
it('should accept valid twenty-app- prefixed names', () => {
expect(() =>
assertValidNpmPackageName('twenty-app-my-cool-app'),
).not.toThrow();
expect(() => assertValidNpmPackageName('twenty-app-hello')).not.toThrow();
});
it('should accept scoped packages with twenty-app- prefix', () => {
expect(() =>
assertValidNpmPackageName('@myorg/twenty-app-my-app'),
).not.toThrow();
});
it('should reject names without twenty-app- prefix', () => {
expect(() => assertValidNpmPackageName('my-cool-app')).toThrow(
ApplicationException,
);
});
it('should reject scoped packages without twenty-app- prefix', () => {
expect(() => assertValidNpmPackageName('@myorg/my-cool-app')).toThrow(
ApplicationException,
);
});
it('should reject invalid npm package names', () => {
expect(() => assertValidNpmPackageName('Twenty-App-Test')).toThrow(
ApplicationException,
);
expect(() => assertValidNpmPackageName('twenty-app-../evil')).toThrow(
ApplicationException,
);
});
it('should reject empty or malformed names', () => {
expect(() => assertValidNpmPackageName('')).toThrow(ApplicationException);
});
});
@@ -8,6 +8,9 @@ import {
const NPM_PACKAGE_NAME_REGEX =
/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
const TWENTY_APP_PREFIX = 'twenty-app-';
const TWENTY_APP_SCOPED_PREFIX = /^@[^/]+\/twenty-app-/;
export const assertValidNpmPackageName = (name: string): void => {
if (!NPM_PACKAGE_NAME_REGEX.test(name) || name.includes('..')) {
throw new ApplicationException(
@@ -15,4 +18,14 @@ export const assertValidNpmPackageName = (name: string): void => {
ApplicationExceptionCode.INVALID_INPUT,
);
}
if (
!name.startsWith(TWENTY_APP_PREFIX) &&
!TWENTY_APP_SCOPED_PREFIX.test(name)
) {
throw new ApplicationException(
'npm package name must start with "twenty-app-" (or "@scope/twenty-app-")',
ApplicationExceptionCode.INVALID_INPUT,
);
}
};
@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export type ProvenanceMetadata = {
repositoryUrl: string | null;
hasProvenance: boolean;
};
@Injectable()
export class ApplicationNpmRegistrationService {
private readonly logger = new Logger(ApplicationNpmRegistrationService.name);
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async fetchProvenanceMetadata(
packageName: string,
version: string,
): Promise<ProvenanceMetadata | null> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
try {
const { data } = await axios.get(
`${registryUrl}/-/npm/v1/attestations/${encodeURIComponent(packageName)}@${version}`,
{
headers: { 'User-Agent': 'Twenty-Provenance' },
timeout: 10_000,
},
);
const repositoryUrl = this.extractRepositoryUrl(data);
return { repositoryUrl, hasProvenance: true };
} catch {
return null;
}
}
private extractRepositoryUrl(attestationData: unknown): string | null {
try {
const data = attestationData as {
attestations?: Array<{
predicateType?: string;
bundle?: {
dsseEnvelope?: {
payload?: string;
};
};
}>;
};
if (!data?.attestations?.length) {
return null;
}
for (const attestation of data.attestations) {
if (attestation.predicateType !== 'https://slsa.dev/provenance/v1') {
continue;
}
const payload = attestation.bundle?.dsseEnvelope?.payload;
if (!payload) {
continue;
}
const decoded = JSON.parse(
Buffer.from(payload, 'base64').toString('utf-8'),
);
const resolvedDependencies =
decoded?.predicate?.buildDefinition?.resolvedDependencies;
if (Array.isArray(resolvedDependencies)) {
for (const dep of resolvedDependencies) {
if (
typeof dep.uri === 'string' &&
dep.uri.startsWith('git+https://')
) {
return dep.uri.replace(/^git\+/, '').replace(/@[^@]+$/, '');
}
}
}
}
return null;
} catch (error) {
this.logger.warn(
`Failed to extract repository URL from attestation: ${error}`,
);
return null;
}
}
}
@@ -136,10 +136,6 @@ export class ApplicationRegistrationEntity {
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
isListed: boolean;
@Field(() => Boolean)
@Column({ name: 'isFeatured', type: 'boolean', default: false })
isFeatured: boolean;
@@ -147,6 +143,17 @@ export class ApplicationRegistrationEntity {
@Column({ type: 'jsonb', nullable: true })
marketplaceDisplayData: MarketplaceDisplayData | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
provenanceRepositoryUrl: string | null;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
isProvenanceVerified: boolean;
@Column({ nullable: true, type: 'timestamptz' })
provenanceVerifiedAt: Date | null;
@OneToMany(
() => ApplicationRegistrationVariableEntity,
(variable) => variable.applicationRegistration,
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationNpmRegistrationService } from 'src/engine/core-modules/application/application-registration/application-npm-registration.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
@@ -34,9 +35,11 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ApplicationRegistrationService,
ApplicationRegistrationResolver,
ApplicationTarballService,
ApplicationNpmRegistrationService,
],
exports: [
ApplicationRegistrationService,
ApplicationNpmRegistrationService,
ApplicationRegistrationVariableModule,
],
})
@@ -71,21 +71,6 @@ export class ApplicationRegistrationService {
return registration;
}
async findOneByIdGlobal(id: string): Promise<ApplicationRegistrationEntity> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new ApplicationRegistrationException(
`Application registration with id ${id} not found`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
// Global lookup — used by OAuth flow (no workspace scoping)
async findOneByClientId(
clientId: string,
@@ -220,8 +205,6 @@ export class ApplicationRegistrationService {
updateData.oAuthScopes = update.oAuthScopes;
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
if (isDefined(update.isListed)) updateData.isListed = update.isListed;
if (Object.keys(updateData).length > 0) {
await this.applicationRegistrationRepository.update(id, updateData);
}
@@ -276,11 +259,18 @@ export class ApplicationRegistrationService {
| 'websiteUrl'
| 'termsUrl'
| 'latestAvailableVersion'
| 'isListed'
| 'isFeatured'
| 'marketplaceDisplayData'
| 'ownerWorkspaceId'
>,
> &
Partial<
Pick<
ApplicationRegistrationEntity,
| 'isProvenanceVerified'
| 'provenanceRepositoryUrl'
| 'provenanceVerifiedAt'
>
>,
): Promise<void> {
const existing = await this.findOneByUniversalIdentifier(
params.universalIdentifier,
@@ -299,6 +289,11 @@ export class ApplicationRegistrationService {
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
marketplaceDisplayData: params.marketplaceDisplayData,
...(isDefined(params.isProvenanceVerified) && {
isProvenanceVerified: params.isProvenanceVerified,
provenanceRepositoryUrl: params.provenanceRepositoryUrl ?? null,
provenanceVerifiedAt: params.provenanceVerifiedAt ?? null,
}),
});
return;
@@ -315,29 +310,23 @@ export class ApplicationRegistrationService {
websiteUrl: params.websiteUrl,
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
isListed: params.isListed,
isFeatured: params.isFeatured,
marketplaceDisplayData: params.marketplaceDisplayData,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
isProvenanceVerified: params.isProvenanceVerified ?? false,
provenanceRepositoryUrl: params.provenanceRepositoryUrl ?? null,
provenanceVerifiedAt: params.provenanceVerifiedAt ?? null,
});
await this.applicationRegistrationRepository.save(registration);
}
async findManyBySourceType(
sourceType: ApplicationRegistrationSourceType,
): Promise<ApplicationRegistrationEntity[]> {
async findManyNpm(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { sourceType },
});
}
async findManyListed(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { isListed: true },
where: { sourceType: ApplicationRegistrationSourceType.NPM },
});
}
@@ -63,6 +63,10 @@ export class ApplicationTarballService {
};
}>(contentDir, 'manifest.json');
const packageJson = await readJsonFile<{
version?: string;
}>(contentDir, 'package.json');
if (manifest === null) {
throw new ApplicationRegistrationException(
'manifest.json not found or invalid in tarball',
@@ -137,6 +141,9 @@ export class ApplicationTarballService {
await this.appRegistrationRepository.update(appRegistration.id, {
sourceType: ApplicationRegistrationSourceType.TARBALL,
tarballFileId: savedFile.id,
...(isDefined(packageJson?.version) && {
latestAvailableVersion: packageJson.version,
}),
});
this.logger.log(

Some files were not shown because too many files have changed in this diff Show More