Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code e2a0122f1e GetCurrentUser query causes 3.6s response due to redundant role/permission DB queries
https://sonarly.com/issue/3776?type=bug

The `POST /metadata` endpoint handling `GetCurrentUser` takes 3.6 seconds because `getRolesByUserWorkspaces()` is called 3 times redundantly within the same request, and multiple core entities (User, UserWorkspace) are re-queried across auth middleware and resolver fields without request-scoped caching.

Fix: ## Fix: Eliminate redundant `getRolesByUserWorkspaces()` DB call in `workspaceMember()` resolver

The root cause is that `getRolesByUserWorkspaces()` is called 3 times per `GetCurrentUser` request, each executing an expensive `RoleTargetEntity` JOIN query (~533ms each, ~1,600ms total). Calls #1 and #2 fetch data for the **exact same** `userWorkspaceId` (the currently authenticated user's workspace).

### Changes

**`permissions.service.ts`** — Add optional `preloadedRoles` parameter to `getUserWorkspacePermissions()`:
```typescript file=packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts lines=38-54
public async getUserWorkspacePermissions({
  userWorkspaceId,
  workspaceId,
  preloadedRoles,
}: {
  userWorkspaceId: string;
  workspaceId: string;
  preloadedRoles?: Map<string, RoleEntity[]>;
}): Promise<UserWorkspacePermissions> {
  const [roleOfUserWorkspace] = preloadedRoles
    ? (preloadedRoles.get(userWorkspaceId) ?? [])
    : await this.userRoleService
        .getRolesByUserWorkspaces({ userWorkspaceIds: [userWorkspaceId], workspaceId })
        .then((roles) => roles?.get(userWorkspaceId) ?? []);
```

**`user.resolver.ts`** — Refactor private helper to fetch roles once and return them alongside permissions, then store the result on the user object for reuse by `workspaceMember()`:

```typescript file=packages/twenty-server/src/engine/core-modules/user/user.resolver.ts lines=98-134
private async getUserWorkspacePermissions({...}): Promise<{
  userWorkspacePermissions: UserWorkspacePermissions;
  rolesCache: Map<string, RoleEntity[]> | undefined;
}> {
  // ...pending workspace check...

  const rolesCache = await this.userRoleService.getRolesByUserWorkspaces({
    userWorkspaceIds: [currentUserWorkspace.id],
    workspaceId: workspace.id,
  });

  const userWorkspacePermissions =
    await this.permissionsService.getUserWorkspacePermissions({
      userWorkspaceId: currentUserWorkspace.id,
      workspaceId: workspace.id,
      preloadedRoles: rolesCache,  // skip internal DB fetch
    });

  return { userWorkspacePermissions, rolesCache };
}
```

`currentUser()` stores `rolesCache` on the returned object:
```typescript
return {
  ...user,
  currentUserWorkspace: { ...currentUserWorkspace, ...userWorkspacePermissionsDto, ... },
  currentWorkspace: workspace,
  __rolesCache: rolesCache,   // available to workspaceMember() ResolveField
} as UserEntity;
```

`workspaceMember()` uses cached roles when available, skipping the redundant DB call:
```typescript file=packages/twenty-server/src/engine/core-modules/user/user.resolver.ts lines=254-263
const cachedRoles = (
  user as UserEntity & { __rolesCache?: Map<string, RoleEntity[]> }
).__rolesCache;

const roleOfUserWorkspace =
  cachedRoles ??
  (await this.userRoleService.getRolesByUserWorkspaces({
    userWorkspaceIds: [userWorkspace.id],
    workspaceId,
  }));
```

### Result

- **Before**: 3 `RoleTargetEntity` DB queries per `GetCurrentUser` request (~1,600ms)
- **After**: 2 `RoleTargetEntity` DB queries (Call #1 + Call #3 remain; Call #2 eliminated when `__rolesCache` is populated by `currentUser()`)
- The fallback in `workspaceMember()` ensures correctness when called outside the `currentUser()` context (cache miss gracefully falls back to DB)
- No changes to the `workspaceMembers()` call path (Call #3 remains independent)
2026-03-04 17:56:24 +00:00
3590 changed files with 59833 additions and 106424 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ alwaysApply: true
## Formatting Standards
- **Prettier**: 2-space indentation, single quotes, trailing commas, semicolons
- **Print width**: 80 characters
- **Oxlint**: No unused imports, consistent import ordering, prefer const over let
- **ESLint**: No unused imports, consistent import ordering, prefer const over let
## Naming Conventions
```typescript
-53
View File
@@ -1,53 +0,0 @@
---
description: ESM dependency guidelines for twenty-sdk and create-twenty-app packages
globs: ["packages/twenty-sdk/**", "packages/create-twenty-app/**"]
alwaysApply: false
---
# ESM Dependency Guidelines
## Context
`twenty-sdk` and `create-twenty-app` are published as dual-format npm packages (ESM `.mjs` + CJS `.cjs`). Dependencies listed in `dependencies` are **externalized** by the Vite/Rollup build — they are not bundled, and consumers resolve them from `node_modules` at runtime.
This means **CJS-only dependencies break the ESM output**. When Rollup emits `import { foo } from 'cjs-package'`, Node.js ESM cannot resolve named exports from CommonJS modules, causing `SyntaxError: Named export 'foo' not found`.
## Rules
### Only add ESM-compatible dependencies
Before adding a new dependency to `package.json`, verify it supports ESM:
- Check for `"type": "module"` in its `package.json`
- Or check for an `"exports"` map with ESM entries
- Or check for a `"module"` field pointing to an ESM build
### Use native `node:fs/promises` for standard fs operations
```typescript
// ✅ Import native fs functions directly
import { readFile, writeFile, mkdir, rm, cp } from 'node:fs/promises';
import { createWriteStream, existsSync } from 'node:fs';
// ✅ Import only custom helpers from fs-utils (no native re-exports)
import { pathExists, ensureDir, emptyDir, copy, move, remove, readJson, writeJson, ensureFile } from '@/cli/utilities/file/fs-utils';
// ❌ Don't use fs-extra (CJS-only, breaks ESM bundle)
import * as fs from 'fs-extra';
// ❌ Don't use import * as fs from fs-utils (it doesn't re-export native fs)
import * as fs from '@/cli/utilities/file/fs-utils';
```
### Use `@/cli/utilities/string/kebab-case` instead of lodash
```typescript
// ✅ Use internal utility
import { kebabCase } from '@/cli/utilities/string/kebab-case';
// ❌ Don't use lodash single-function packages (CJS-only, unmaintained)
import kebabCase from 'lodash.kebabcase';
```
### When no ESM alternative exists
If a CJS-only package has no ESM replacement (e.g. `archiver`), add it to the `cjsOnlyPackages` list in `vite.config.node.ts` so it gets inlined into the bundle instead of externalized.
-8
View File
@@ -15,16 +15,8 @@ inputs:
runs:
using: "composite"
steps:
- name: Fetch main branch for diff
shell: bash
run: git fetch origin main --depth=1
- name: Get last successful commit
if: env.NX_BASE == ''
uses: nrwl/nx-set-shas@v4
- name: Fallback to origin/main if no base found
if: env.NX_BASE == ''
shell: bash
run: echo "NX_BASE=$(git rev-parse origin/main)" >> $GITHUB_ENV
- name: Run affected command
shell: bash
env:
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Check for changed files
id: changed-files
uses: tj-actions/changed-files@v45
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
- name: Checkout current branch
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Try to merge main into current branch
id: merge_attempt
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
+4 -3
View File
@@ -21,6 +21,7 @@ jobs:
files: |
package.json
packages/twenty-docs/**
eslint.config.mjs
docs-lint:
needs: changed-files-check
@@ -36,11 +37,11 @@ jobs:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Docs / Lint
run: npx nx lint twenty-docs
- name: Docs / Lint English MDX files
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
+2 -2
View File
@@ -25,12 +25,12 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
runs-on: ubuntu-latest-8-cores
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-emails
+165 -32
View File
@@ -1,7 +1,8 @@
name: CI Front
name: CI Front and E2E
on:
pull_request:
merge_group:
permissions:
@@ -18,7 +19,6 @@ env:
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
@@ -29,6 +29,15 @@ jobs:
packages/twenty-shared/**
packages/twenty-sdk/**
!packages/twenty-sdk/package.json
changed-files-check-e2e:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
playwright.config.ts
.github/workflows/ci-front.yaml
front-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
@@ -44,7 +53,7 @@ jobs:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Diagnostic disk space issue
@@ -53,19 +62,13 @@ jobs:
run: npx nx reset:env twenty-front
- name: Front / Build storybook
run: npx nx storybook:build twenty-front
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
retention-days: 1
- name: Save storybook build cache
uses: ./.github/actions/save-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
front-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: ubuntu-latest-8-cores
needs: front-sb-build
strategy:
fail-fast: false
@@ -75,41 +78,26 @@ jobs:
env:
SHARD_COUNTER: 4
REACT_APP_SERVER_BASE_URL: http://localhost:3000
STORYBOOK_URL: http://localhost:6006
steps:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore storybook build cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
- name: Clean stale storybook vitest cache
run: rm -rf packages/twenty-front/node_modules/.cache/storybook
- name: Build dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-ui
npx nx build twenty-sdk
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-front
npx playwright install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front/storybook-static --port 6006 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6006 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
# - name: Rename coverage file
# run: |
# if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
@@ -137,7 +125,7 @@ jobs:
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 10
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@v4
@@ -151,6 +139,32 @@ jobs:
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
front-chromatic-deployment:
timeout-minutes: 30
if: false
needs: front-sb-build
runs-on: ubuntu-latest-8-cores
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore storybook build cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
- name: Front / Write .env
run: |
cd packages/twenty-front
touch .env
echo "" >> .env
echo "REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL" >> .env
- name: Publish to Chromatic
run: npx nx run twenty-front:chromatic:ci
front-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
@@ -170,7 +184,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore ${{ matrix.task }} cache
@@ -200,7 +214,6 @@ jobs:
runs-on: ubuntu-latest-8-cores
env:
NODE_OPTIONS: "--max-old-space-size=10240"
ANALYZE: "true"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -209,7 +222,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Front / Write .env
@@ -222,6 +235,117 @@ jobs:
# name: frontend-build
# path: packages/twenty-front/build
# retention-days: 1
e2e-test:
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, front-build]
if: |
always() &&
needs.changed-files-check-e2e.outputs.any_changed == 'true' &&
(needs.front-build.result == 'success' || needs.front-build.result == 'skipped') &&
(github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
timeout-minutes: 30
env:
NODE_OPTIONS: "--max-old-space-size=10240"
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Check system resources
run: |
echo "Available memory:"
free -h
echo "Available disk space:"
df -h
echo "CPU info:"
lscpu
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Install Playwright Browsers
run: npx nx setup twenty-e2e-testing
- name: Setup environment files
run: |
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env:e2e-testing-server twenty-server
# - name: Download frontend build artifact
# if: needs.front-build.result == 'success'
# uses: actions/download-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
# - name: Build frontend (if not available from front-build)
# if: needs.front-build.result == 'skipped'
# run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build frontend
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start frontend
run: |
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
echo "Waiting for frontend to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
# - uses: actions/upload-artifact@v4
# if: always()
# with:
# name: playwright-report
# path: packages/twenty-e2e-testing/run_results/
# retention-days: 30
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
@@ -239,3 +363,12 @@ jobs:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-140
View File
@@ -1,140 +0,0 @@
name: CI Merge Queue
on:
merge_group:
pull_request:
types: [labeled, synchronize, opened, reopened]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref || github.ref }}
cancel-in-progress: true
jobs:
e2e-test:
if: >
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'run-merge-queue'))
runs-on: ubuntu-latest-8-cores
timeout-minutes: 30
env:
NODE_OPTIONS: "--max-old-space-size=10240"
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore Nx build cache
uses: actions/cache/restore@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
v4-e2e-build-${{ github.ref_name }}-
v4-e2e-build-main-
path: |
.nx
node_modules/.cache
packages/*/node_modules/.cache
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Install Playwright Browsers
run: npx nx setup twenty-e2e-testing
- name: Setup environment files
run: |
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env:e2e-testing-server twenty-server
- name: Build frontend
run: NODE_ENV=production npx nx build twenty-front
- name: Build server
run: npx nx build twenty-server
- name: Save Nx build cache
if: always()
uses: actions/cache/save@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
path: |
.nx
node_modules/.cache
packages/*/node_modules/.cache
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start frontend
run: |
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
echo "Waiting for frontend to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
- name: Upload Playwright results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: |
packages/twenty-e2e-testing/run_results/
packages/twenty-e2e-testing/test-results/
retention-days: 7
ci-merge-queue-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+5 -6
View File
@@ -1,9 +1,10 @@
name: CI SDK
on:
pull_request:
merge_group:
pull_request:
permissions:
contents: read
@@ -13,12 +14,10 @@ concurrency:
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-sdk/**
packages/twenty-server/**
!packages/twenty-sdk/package.json
sdk-test:
needs: changed-files-check
@@ -36,7 +35,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
@@ -51,7 +50,7 @@ jobs:
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: ubuntu-latest-8-cores
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
@@ -79,7 +78,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
+36 -99
View File
@@ -2,6 +2,7 @@ name: CI Server
on:
pull_request:
merge_group:
permissions:
@@ -12,11 +13,10 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
SERVER_BUILD_CACHE_KEY: server-build
SERVER_SETUP_CACHE_KEY: server-setup
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
@@ -25,62 +25,13 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-sdk/src/clients/generated/metadata/**
packages/twenty-emails/**
packages/twenty-shared/**
server-build:
server-setup:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server build cache
id: restore-server-build-cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Save server build cache
uses: ./.github/actions/save-cache
with:
key: ${{ steps.restore-server-build-cache.outputs.cache-primary-key }}
server-lint-typecheck:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
server-validation:
needs: server-build
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: ubuntu-latest-8-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -104,15 +55,21 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server build cache
- name: Restore server setup
id: restore-server-setup-cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
@@ -127,8 +84,10 @@ jobs:
run: |
timeout 30s npx nx run twenty-server:worker || exit_code=$?
if [ $exit_code -eq 124 ]; then
# If timeout was reached (exit code 124), consider it a success
exit 0
elif [ $exit_code -ne 0 ]; then
# If worker failed for other reasons, fail the build
exit $exit_code
fi
- name: Server / Start
@@ -159,13 +118,13 @@ jobs:
exit 1
fi
- name: Check for Pending Code Generation
- name: GraphQL / Check for Pending Generation
run: |
HAS_ERRORS=false
# Run GraphQL generation commands
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
# Check if GraphQL generated files were modified
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
@@ -174,42 +133,29 @@ jobs:
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata
echo "==================================================="
echo ""
HAS_ERRORS=true
fi
npx nx run twenty-sdk:generate-metadata-client
if ! git diff --quiet -- packages/twenty-sdk/src/clients/generated/metadata; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-sdk:generate-metadata-client' and commit the changes."
echo "Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
echo "The following SDK metadata client changes were detected:"
echo "==================================================="
git diff -- packages/twenty-sdk/src/clients/generated/metadata
echo "==================================================="
echo ""
HAS_ERRORS=true
fi
if [ "$HAS_ERRORS" = true ]; then
exit 1
fi
- name: Save server setup
uses: ./.github/actions/save-cache
with:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
needs: server-build
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: ubuntu-latest-8-cores
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server build cache
- name: Restore server setup
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Run Tests
uses: ./.github/actions/nx-affected
with:
@@ -218,12 +164,12 @@ jobs:
server-integration-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: server-build
runs-on: ubuntu-latest-8-cores
needs: server-setup
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -261,12 +207,12 @@ jobs:
ANALYTICS_ENABLED: true
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
CLICKHOUSE_PASSWORD: clickhousePassword
SHARD_COUNTER: 10
SHARD_COUNTER: 8
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Update .env.test for integrations tests
@@ -277,10 +223,10 @@ jobs:
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Restore server build cache
- name: Restore server setup
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Build
run: npx nx build twenty-server
- name: Build dependencies
@@ -301,20 +247,11 @@ jobs:
tasks: 'test:integration'
configuration: 'with-db-reset'
args: --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
server-build,
server-lint-typecheck,
server-validation,
server-test,
server-integration-test,
]
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
+4 -4
View File
@@ -1,9 +1,10 @@
name: CI Shared
on:
pull_request:
merge_group:
pull_request:
permissions:
contents: read
@@ -13,7 +14,6 @@ concurrency:
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
@@ -36,13 +36,13 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:shared
tag: scope:frontend
tasks: ${{ matrix.task }}
ci-shared-status-check:
if: always() && !cancelled()
@@ -4,16 +4,16 @@ permissions:
contents: read
on:
pull_request:
merge_group:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
+3 -3
View File
@@ -4,16 +4,16 @@ permissions:
contents: read
on:
pull_request:
merge_group:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
@@ -42,7 +42,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
+5 -3
View File
@@ -3,6 +3,8 @@ name: CI Zapier
on:
pull_request:
merge_group:
permissions:
contents: read
@@ -26,7 +28,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: ubuntu-latest-8-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -50,7 +52,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
@@ -105,7 +107,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
+1 -1
View File
@@ -164,4 +164,4 @@ jobs:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": ${{ toJSON(format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id)) }}}'
+1 -1
View File
@@ -28,7 +28,7 @@ coverage
dist
storybook-static
*.tsbuildinfo
.oxlintcache
.eslintcache
.nyc_output
test-results/
dump.rdb
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"oxc.oxc-vscode",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"firsttris.vscode-jest-runner",
+7 -10
View File
@@ -4,28 +4,25 @@
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
@@ -51,7 +48,7 @@
"search.exclude": {
"**/.yarn": true
},
"oxc.lint.enable": true,
"eslint.debug": true,
"files.associations": {
".cursorrules": "markdown"
},
+9 -13
View File
@@ -37,8 +37,8 @@
"path": "../packages/twenty-zapier"
},
{
"name": "packages/twenty-oxlint-rules",
"path": "../packages/twenty-oxlint-rules"
"name": "tools/eslint-rules",
"path": "../tools/eslint-rules"
},
{
"name": "packages/twenty-e2e-testing",
@@ -49,26 +49,23 @@
"editor.formatOnSave": false,
"files.eol": "auto",
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
@@ -91,7 +88,7 @@
"typescript.preferences.importModuleSpecifier": "non-relative",
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
@@ -101,7 +98,6 @@
"files.exclude": {
"packages/": true
},
"oxc.lint.enable": true,
"jest.runMode": "on-demand",
"jest.disabledWorkspaceFolders": [
"ROOT",
+226
View File
@@ -0,0 +1,226 @@
import js from '@eslint/js';
import nxPlugin from '@nx/eslint-plugin';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import * as mdxPlugin from 'eslint-plugin-mdx';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
const twentyRules = await nxPlugin.loadWorkspaceRules(
'packages/twenty-eslint-rules',
);
export default [
// Base JavaScript configuration
js.configs.recommended,
// Lingui recommended rules
linguiPlugin.configs['flat/recommended'],
// Global ignores
{
ignores: ['**/node_modules/**'],
},
// Base configuration for all files
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
prettier: prettierPlugin,
lingui: linguiPlugin,
'@nx': nxPlugin,
'prefer-arrow': preferArrowPlugin,
import: importPlugin,
'unused-imports': unusedImportsPlugin,
unicorn: unicornPlugin,
},
rules: {
// General rules
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': [
'warn',
{ allow: ['group', 'groupCollapsed', 'groupEnd'] },
],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
// Nx rules
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:apps',
onlyDependOnLibsWithTags: ['scope:apps', 'scope:sdk'],
},
{
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
},
{
sourceTag: 'scope:create-app',
onlyDependOnLibsWithTags: ['scope:create-app', 'scope:shared'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:zapier'],
},
],
},
],
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
// TypeScript specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// TypeScript rules
'no-redeclare': 'off', // Turn off base rule for TypeScript
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'with-single-extends',
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
// JavaScript specific configuration
{
files: ['*.{js,jsx}'],
rules: {
// JavaScript-specific rules if needed
},
},
// Test files
{
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// JSON files
{
files: ['**/*.json'],
languageOptions: {
parser: jsoncParser,
},
},
// MDX files
{
...mdxPlugin.flat,
plugins: {
...mdxPlugin.flat.plugins,
'@nx': nxPlugin,
twenty: { rules: twentyRules },
},
},
mdxPlugin.flatCodeBlocks,
{
files: ['**/*.mdx'],
rules: {
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'off',
'unused-imports/no-unused-vars': 'off',
// Enforce JSX tags on separate lines to prevent Crowdin translation issues
'twenty/mdx-component-newlines': 'error',
// Disallow angle bracket placeholders to prevent Crowdin translation errors
'twenty/no-angle-bracket-placeholders': 'error',
},
},
];
+23 -9
View File
@@ -40,30 +40,34 @@
"dependsOn": ["^build"]
},
"lint": {
"executor": "nx:run-commands",
"executor": "@nx/eslint:lint",
"cache": true,
"outputs": ["{options.outputFile}"],
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
"eslintConfig": "{projectRoot}/eslint.config.mjs",
"cache": true,
"cacheLocation": "{workspaceRoot}/.cache/eslint"
},
"configurations": {
"ci": {},
"ci": {
"cacheStrategy": "content"
},
"fix": {
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
"fix": true
}
},
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
"dependsOn": ["^build"]
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs \"$@\"; fi' _",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs --fix \"$@\"; fi' _"
}
}
},
@@ -143,7 +147,7 @@
"outputs": ["{projectRoot}/{options.output-dir}"],
"options": {
"cwd": "{projectRoot}",
"command": "NODE_OPTIONS='--max-old-space-size=10240' storybook build --test",
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
"output-dir": "storybook-static",
"config-dir": ".storybook"
},
@@ -251,6 +255,14 @@
}
}
},
"@nx/eslint:lint": {
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/packages/twenty-eslint-rules/**/*"
]
},
"@nx/vite:build": {
"cache": true,
"dependsOn": ["^build"],
@@ -265,6 +277,7 @@
"@nx/react": {
"application": {
"style": "@linaria/react",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
"unitTestRunner": "jest",
@@ -272,6 +285,7 @@
},
"library": {
"style": "@linaria/react",
"linter": "eslint",
"bundler": "vite",
"compiler": "swc",
"unitTestRunner": "jest",
+35 -14
View File
@@ -46,7 +46,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.30.3",
"react-router-dom": "^6.4.4",
"react-tooltip": "^5.13.1",
"remark-gfm": "^4.0.1",
"rxjs": "^7.2.0",
@@ -72,13 +72,14 @@
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@nx/jest": "22.5.4",
"@nx/js": "22.5.4",
"@nx/react": "22.5.4",
"@nx/storybook": "22.5.4",
"@nx/vite": "22.5.4",
"@nx/web": "22.5.4",
"@oxlint/plugins": "^1.51.0",
"@nx/eslint": "22.3.3",
"@nx/eslint-plugin": "22.3.3",
"@nx/jest": "22.3.3",
"@nx/js": "22.3.3",
"@nx/react": "22.3.3",
"@nx/storybook": "22.3.3",
"@nx/vite": "22.3.3",
"@nx/web": "22.3.3",
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
@@ -88,10 +89,11 @@
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.2.13",
"@storybook/test-runner": "^0.24.2",
"@swc-node/register": "^1.11.1",
"@swc/cli": "^0.7.10",
"@swc/core": "^1.15.11",
"@swc/helpers": "~0.5.19",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.11.1",
"@swc/cli": "^0.3.12",
"@swc/core": "1.15.11",
"@swc/helpers": "~0.5.18",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -130,6 +132,9 @@
"@types/react-dom": "^18.2.15",
"@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@typescript-eslint/utils": "^8.39.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitest/browser-playwright": "^4.0.18",
@@ -141,6 +146,22 @@
"danger": "^13.0.4",
"dotenv-cli": "^7.4.4",
"esbuild": "^0.25.10",
"eslint": "^9.32.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-lingui": "^0.9.0",
"eslint-plugin-mdx": "^3.6.2",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-project-structure": "^3.9.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^10.2.13",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
"jest": "29.7.0",
"jest-environment-jsdom": "30.0.0-beta.3",
@@ -149,7 +170,7 @@
"jsdom": "~22.1.0",
"msw": "^2.12.7",
"msw-storybook-addon": "^2.0.6",
"nx": "22.5.4",
"nx": "22.3.3",
"prettier": "^3.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
@@ -209,7 +230,7 @@
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-oxlint-rules"
"packages/twenty-eslint-rules"
]
},
"prettier": {
-47
View File
@@ -1,47 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}
@@ -1 +0,0 @@
dist
+22 -15
View File
@@ -19,11 +19,9 @@ Create Twenty App is the official scaffolding CLI for building apps on top of [T
- Strong TypeScript support and typed client generation
## Documentation
See Twenty application documentation https://docs.twenty.com/developers/extend/capabilities/apps
## Prerequisites
- Node.js 24+ (recommended) and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
@@ -43,7 +41,7 @@ yarn twenty auth:login
yarn twenty entity:add
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed CoreApiClient MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
yarn twenty app:dev
# Watch your application's function logs
@@ -66,10 +64,11 @@ yarn twenty app:uninstall
Control which example files are included when creating a new app:
| Flag | Behavior |
| ------------------ | ----------------------------------------------------------------------- |
| `-e, --exhaustive` | **(default)** Creates all example files |
| `-m, --minimal` | Creates only core files (`application-config.ts` and `default-role.ts`) |
| Flag | Behavior |
|------|----------|
| `-e, --exhaustive` | **(default)** Creates all example files without prompting |
| `-m, --minimal` | Creates only core files (`application-config.ts` and `default-role.ts`) |
| `-i, --interactive` | Prompts you to select which examples to include |
```bash
# Default: all examples included
@@ -77,21 +76,32 @@ npx create-twenty-app@latest my-app
# Minimal: only core files
npx create-twenty-app@latest my-app -m
# Interactive: choose which examples to include
npx create-twenty-app@latest my-app -i
```
In interactive mode, you can pick from:
- **Example object** — a custom CRM object definition (`objects/example-object.ts`)
- **Example field** — a custom field on the example object (`fields/example-field.ts`)
- **Example logic function** — a server-side handler with HTTP trigger (`logic-functions/hello-world.ts`)
- **Example front component** — a React UI component (`front-components/hello-world.tsx`)
- **Example view** — a saved view for the example object (`views/example-view.ts`)
- **Example navigation menu item** — a sidebar link (`navigation-menu-items/example-navigation-menu-item.ts`)
- **Example skill** — an AI agent skill definition (`skills/example-skill.ts`)
- **Integration test** — a vitest integration test verifying app installation (`__tests__/app-install.integration-test.ts`)
## What gets scaffolded
**Core files (always created):**
- `application-config.ts` — Application metadata configuration
- `roles/default-role.ts` — Default role for logic functions
- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation)
- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation)
- TypeScript configuration, Oxlint, package.json, .gitignore
- TypeScript configuration, ESLint, package.json, .gitignore
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
**Example files (controlled by scaffolding mode):**
- `objects/example-object.ts` — Example custom object with a text field
- `fields/example-field.ts` — Example standalone field extending the example object
- `logic-functions/hello-world.ts` — Example logic function with HTTP trigger
@@ -102,15 +112,14 @@ npx create-twenty-app@latest my-app -m
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty app:dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
- Two typed API clients are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
## Publish your application
Applications are currently stored in `twenty/packages/twenty-apps`.
You can share your application with all Twenty users:
@@ -135,11 +144,9 @@ git push
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Auth prompts not appearing: run `yarn twenty auth:login` again and verify the API key permissions.
- Types not generated: ensure `yarn twenty app:dev` is running — it autogenerates the typed client.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
@@ -0,0 +1,20 @@
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
{
ignores: ['**/dist/**'],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
rules: {
'prettier/prettier': 'error',
},
},
{
rules: {
'no-console': 'off',
},
ignores: ['src/**/*.ts', '!src/cli/**/*.ts'],
},
];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.7.0-canary.0",
"version": "0.6.3",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+13 -1
View File
@@ -25,7 +25,19 @@
}
},
"typecheck": {},
"lint": {},
"lint": {
"options": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"configurations": {
"ci": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"fix": {}
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+16 -3
View File
@@ -18,6 +18,10 @@ const program = new Command(packageJson.name)
'-m, --minimal',
'Create only core entities (application-config and default-role)',
)
.option(
'-i, --interactive',
'Interactively choose which entity examples to include',
)
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
@@ -25,14 +29,19 @@ const program = new Command(packageJson.name)
options?: {
exhaustive?: boolean;
minimal?: boolean;
interactive?: boolean;
},
) => {
const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean);
const modeFlags = [
options?.exhaustive,
options?.minimal,
options?.interactive,
].filter(Boolean);
if (modeFlags.length > 1) {
console.error(
chalk.red(
'Error: --exhaustive and --minimal are mutually exclusive.',
'Error: --exhaustive, --minimal, and --interactive are mutually exclusive.',
),
);
process.exit(1);
@@ -47,7 +56,11 @@ const program = new Command(packageJson.name)
process.exit(1);
}
const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive';
const mode: ScaffoldingMode = options?.minimal
? 'minimal'
: options?.interactive
? 'interactive'
: 'exhaustive';
await new CreateAppCommand().execute(directory, mode);
},
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -4,7 +4,6 @@
- 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
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -27,11 +27,5 @@
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
]
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.integration-test.ts"]
}
@@ -24,7 +24,7 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
const exampleOptions = this.resolveExampleOptions(mode);
const exampleOptions = await this.resolveExampleOptions(mode);
await this.validateDirectory(appDirectory);
@@ -103,7 +103,9 @@ export class CreateAppCommand {
return { appName, appDisplayName, appDirectory, appDescription };
}
private resolveExampleOptions(mode: ScaffoldingMode): ExampleOptions {
private async resolveExampleOptions(
mode: ScaffoldingMode,
): Promise<ExampleOptions> {
if (mode === 'minimal') {
return {
includeExampleObject: false,
@@ -113,21 +115,98 @@ export class CreateAppCommand {
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleSkill: false,
includeExampleAgent: false,
includeExampleIntegrationTest: false,
};
}
if (mode === 'exhaustive') {
return {
includeExampleObject: true,
includeExampleField: true,
includeExampleLogicFunction: true,
includeExampleFrontComponent: true,
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
includeExampleIntegrationTest: true,
};
}
const { selectedExamples } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedExamples',
message: 'Select which example files to include:',
choices: [
{
name: 'Example object (custom object definition)',
value: 'object',
checked: true,
},
{
name: 'Example field (custom field on the example object)',
value: 'field',
checked: true,
},
{
name: 'Example logic function (server-side handler)',
value: 'logicFunction',
checked: true,
},
{
name: 'Example front component (React UI component)',
value: 'frontComponent',
checked: true,
},
{
name: 'Example view (saved view for the example object)',
value: 'view',
checked: true,
},
{
name: 'Example navigation menu item (sidebar link)',
value: 'navigationMenuItem',
checked: true,
},
{
name: 'Example skill (AI agent skill definition)',
value: 'skill',
checked: true,
},
{
name: 'Integration test (vitest test verifying app installation)',
value: 'integrationTest',
checked: true,
},
],
},
]);
const includeField = selectedExamples.includes('field');
const includeView = selectedExamples.includes('view');
const includeExampleIntegrationTest =
selectedExamples.includes('integrationTest');
const includeObject =
selectedExamples.includes('object') || includeField || includeView;
if ((includeField || includeView) && !selectedExamples.includes('object')) {
console.log(
chalk.yellow(
'Note: Example object auto-included because example field/view depends on it.',
),
);
}
return {
includeExampleObject: true,
includeExampleField: true,
includeExampleLogicFunction: true,
includeExampleFrontComponent: true,
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
includeExampleIntegrationTest: true,
includeExampleAgent: true,
includeExampleObject: includeObject,
includeExampleField: includeField,
includeExampleLogicFunction: selectedExamples.includes('logicFunction'),
includeExampleFrontComponent: selectedExamples.includes('frontComponent'),
includeExampleView: includeView,
includeExampleNavigationMenuItem:
selectedExamples.includes('navigationMenuItem'),
includeExampleSkill: selectedExamples.includes('skill'),
includeExampleIntegrationTest,
};
}
@@ -1,4 +1,4 @@
export type ScaffoldingMode = 'exhaustive' | 'minimal';
export type ScaffoldingMode = 'exhaustive' | 'minimal' | 'interactive';
export type ExampleOptions = {
includeExampleObject: boolean;
@@ -8,6 +8,5 @@ export type ExampleOptions = {
includeExampleView: boolean;
includeExampleNavigationMenuItem: boolean;
includeExampleSkill: boolean;
includeExampleAgent: boolean;
includeExampleIntegrationTest: boolean;
};
@@ -25,7 +25,6 @@ const ALL_EXAMPLES: ExampleOptions = {
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
includeExampleAgent: true,
includeExampleIntegrationTest: true,
};
@@ -42,7 +41,6 @@ const NO_EXAMPLES: ExampleOptions = {
includeExampleObject: false,
includeExampleField: false,
includeExampleSkill: false,
includeExampleAgent: false,
includeExampleLogicFunction: false,
includeExampleFrontComponent: false,
includeExampleView: false,
@@ -478,7 +476,6 @@ describe('copyBaseApplicationProject', () => {
includeExampleObject: false,
includeExampleField: false,
includeExampleSkill: false,
includeExampleAgent: false,
includeExampleLogicFunction: false,
includeExampleFrontComponent: true,
includeExampleView: false,
@@ -516,7 +513,6 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: {
includeExampleObject: false,
includeExampleSkill: false,
includeExampleAgent: false,
includeExampleField: false,
includeExampleLogicFunction: true,
includeExampleFrontComponent: false,
@@ -677,24 +673,15 @@ describe('copyBaseApplicationProject', () => {
const content = await fs.readFile(viewPath, 'utf8');
expect(content).toContain("import { defineView } from 'twenty-sdk'");
expect(content).toContain(
"import { defineView, ViewKey } from 'twenty-sdk'",
);
expect(content).toContain(
"import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'",
"import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'",
);
expect(content).toContain('export default defineView({');
expect(content).toContain(
'objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER',
);
expect(content).toContain("name: 'All example items'");
expect(content).toContain('fields: [');
expect(content).toContain(
'fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER',
);
expect(content).toContain('isVisible: true');
expect(content).toContain('key: ViewKey.INDEX');
expect(content).toContain('size: 200');
expect(content).toContain("name: 'example-view'");
});
});
@@ -725,7 +712,6 @@ describe('copyBaseApplicationProject', () => {
expect(content).toContain('export default defineNavigationMenuItem({');
expect(content).toContain("name: 'example-navigation-menu-item'");
expect(content).toContain("icon: 'IconList'");
expect(content).toContain("color: 'blue'");
expect(content).toContain('position: 0');
});
});
@@ -50,14 +50,15 @@ describe('scaffoldIntegrationTest', () => {
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-sdk/clients'",
"import { MetadataApiClient } from 'twenty-sdk/generated'",
);
expect(content).toContain(
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
);
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('assertServerIsReachable');
expect(content).toContain('appBuild');
expect(content).toContain('appUninstall');
expect(content).toContain('new MetadataApiClient()');
expect(content).toContain('findManyApplications');
expect(content).toContain('APPLICATION_UNIVERSAL_IDENTIFIER');
});
@@ -83,8 +84,7 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('.twenty-sdk-test');
expect(content).toContain('config.json');
expect(content).toContain('process.env.TWENTY_API_URL');
expect(content).toContain('process.env.TWENTY_API_KEY');
expect(content).toContain('assertServerIsReachable');
expect(content).toContain('process.env.TWENTY_TEST_API_KEY');
});
});
@@ -101,8 +101,7 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(vitestConfigPath, 'utf8');
expect(content).toContain('TWENTY_API_KEY');
expect(content).not.toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('setup-test.ts');
expect(content).toContain('tsconfig.spec.json');
@@ -103,14 +103,6 @@ export const copyBaseApplicationProject = async ({
});
}
if (exampleOptions.includeExampleAgent) {
await createExampleAgent({
appDirectory: sourceFolderPath,
fileFolder: 'agents',
fileName: 'example-agent.ts',
});
}
if (exampleOptions.includeExampleIntegrationTest) {
await scaffoldIntegrationTest({
appDirectory,
@@ -439,29 +431,16 @@ const createExampleView = async ({
fileName: string;
}) => {
const universalIdentifier = v4();
const viewFieldUniversalIdentifier = v4();
const content = `import { defineView, ViewKey } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
const content = `import { defineView } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export default defineView({
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All example items',
universalIdentifier: '${universalIdentifier}',
name: 'example-view',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '${viewFieldUniversalIdentifier}',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
});
`;
@@ -481,15 +460,18 @@ const createExampleNavigationMenuItem = async ({
const universalIdentifier = v4();
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
position: 0,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
// Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',
});
`;
@@ -527,36 +509,6 @@ export default defineSkill({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExampleAgent = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineAgent } from 'twenty-sdk';
export const EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineAgent({
universalIdentifier: EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER,
name: 'example-agent',
label: 'Example Agent',
description: 'A sample AI agent for your application',
icon: 'IconRobot',
prompt: 'You are a helpful assistant. Help users with their questions and tasks.',
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createApplicationConfig = async ({
displayName,
description,
@@ -601,8 +553,8 @@ const createPackageJson = async ({
}) => {
const scripts: Record<string, string> = {
twenty: 'twenty',
lint: 'oxlint -c .oxlintrc.json .',
'lint:fix': 'oxlint --fix -c .oxlintrc.json .',
lint: 'eslint',
'lint:fix': 'eslint --fix',
};
const devDependencies: Record<string, string> = {
@@ -610,7 +562,8 @@ const createPackageJson = async ({
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
oxlint: '^0.16.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
'twenty-sdk': createTwentyAppPackageJson.version,
};
@@ -45,7 +45,7 @@ export default defineConfig({
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_KEY:
TWENTY_TEST_API_KEY:
'${SEED_API_KEY}',
},
},
@@ -93,36 +93,16 @@ import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
} catch {
throw new Error(
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(\`Server at \${TWENTY_API_URL} returned \${response.status}\`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
beforeAll(() => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
apiKey: process.env.TWENTY_TEST_API_KEY,
},
},
};
@@ -149,15 +129,35 @@ const createIntegrationTest = async ({
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
} catch {
throw new Error(
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(\`Server at \${TWENTY_API_URL} returned \${response.status}\`);
}
};
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(\`[build] \${message}\`),
@@ -165,7 +165,7 @@ describe('App installation', () => {
if (!buildResult.success) {
throw new Error(
\`Build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
\`App build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
);
}
@@ -187,7 +187,20 @@ describe('App installation', () => {
});
it('should find the installed app in the applications list', async () => {
const metadataClient = new MetadataApiClient();
const apiKey = process.env.TWENTY_TEST_API_KEY;
if (!apiKey) {
throw new Error(
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
);
}
const metadataClient = new MetadataApiClient({
url: \`\${TWENTY_API_URL}/metadata\`,
headers: {
Authorization: \`Bearer \${apiKey}\`,
},
});
const result = await metadataClient.query({
findManyApplications: {
+3 -1
View File
@@ -24,5 +24,7 @@
"vite.config.ts",
"jest.config.mjs"
],
"exclude": ["src/constants/base-application/vitest.config.ts"]
"exclude": [
"src/constants/base-application/vitest.config.ts"
]
}
@@ -1,38 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -10,8 +10,8 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest"
@@ -19,8 +19,9 @@
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
@@ -1,6 +1,7 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
import { MetadataApiClient } from 'twenty-sdk/generated';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
type ApolloTokenResponse = {
@@ -52,8 +53,16 @@ const handler = async (event: RoutePayload): Promise<any> => {
const apolloClientSecret = process.env.APOLLO_CLIENT_SECRET ?? '';
const applicationId = process.env.APPLICATION_ID ?? '';
const metadataClient = new MetadataApiClient({});
const tokenPairs = await getAuthenticationTokenPairs(
code,
apolloClientId,
@@ -5,6 +5,8 @@ import {
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
type CompanyRecord = {
id: string;
name?: string;
@@ -36,6 +38,8 @@ type ApolloEnrichResponse = {
organization?: ApolloOrganization;
};
const extractDomain = (
domainName?: CompanyRecord['domainName'],
): string | undefined => {
@@ -168,6 +172,7 @@ const updateCompanyInTwenty = async (
}
};
type CompanyUpdateEvent = DatabaseEventPayload<
ObjectRecordUpdateEvent<CompanyRecord>
>;
@@ -176,6 +181,7 @@ const handler = async (
event: CompanyUpdateEvent,
): Promise<object | undefined> => {
const { recordId, properties } = event;
const { after: companyAfter } = properties;
@@ -200,6 +206,7 @@ const handler = async (
return { skipped: true, reason: 'no enrichment data to apply' };
}
await updateCompanyInTwenty(recordId, updateData);
const result = {
File diff suppressed because it is too large Load Diff
@@ -1,40 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -26,6 +26,21 @@
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {}
"lint": {
"executor": "@nx/eslint:lint",
"outputs": [
"{options.outputFile}"
],
"options": {
"lintFilePatterns": [
"packages/twenty-apps/community/fireflies/**/*.{ts,tsx,js,jsx}"
]
},
"configurations": {
"fix": {
"fix": true
}
}
}
}
}
@@ -5,7 +5,7 @@
* Usage: yarn setup:fields
*/
/* oxlint-disable no-console */
/* eslint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
@@ -1,4 +1,4 @@
/* oxlint-disable no-console */
/* eslint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -1,4 +1,4 @@
/* oxlint-disable no-console */
/* eslint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
@@ -1,4 +1,4 @@
/* oxlint-disable no-console */
/* eslint-disable no-console */
/**
* Fetch historical Fireflies meetings and insert into Twenty.
*
@@ -219,3 +219,4 @@ main().catch((error) => {
process.exit(1);
});
@@ -1,4 +1,4 @@
/* oxlint-disable no-console */
/* eslint-disable no-console */
/**
* Fetch a Fireflies meeting by ID and insert it into Twenty using the same path
* as the webhook handler.
@@ -1,4 +1,4 @@
/* oxlint-disable no-console */
/* eslint-disable no-console */
/**
* Test script for Fireflies webhook against local Twenty instance
*
@@ -81,3 +81,4 @@ describe('HistoricalImporter', () => {
});
});
@@ -158,3 +158,4 @@ export class HistoricalImporter {
}
}
@@ -72,7 +72,7 @@ export class AppLogger {
debug(message: string, ...args: unknown[]): void {
this.captureLog('debug', message, ...args);
if (this.shouldLog('debug')) {
// oxlint-disable-next-line no-console
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
@@ -80,7 +80,7 @@ export class AppLogger {
info(message: string, ...args: unknown[]): void {
this.captureLog('info', message, ...args);
if (this.shouldLog('info')) {
// oxlint-disable-next-line no-console
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
@@ -88,7 +88,7 @@ export class AppLogger {
warn(message: string, ...args: unknown[]): void {
this.captureLog('warn', message, ...args);
if (this.shouldLog('warn')) {
// oxlint-disable-next-line no-console
// eslint-disable-next-line no-console
console.warn(`[${this.context}] ${message}`, ...args);
}
}
@@ -96,7 +96,7 @@ export class AppLogger {
error(message: string, ...args: unknown[]): void {
this.captureLog('error', message, ...args);
if (this.shouldLog('error')) {
// oxlint-disable-next-line no-console
// eslint-disable-next-line no-console
console.error(`[${this.context}] ${message}`, ...args);
}
}
@@ -104,7 +104,7 @@ export class AppLogger {
// For fatal errors, security issues, or data corruption - always visible
critical(message: string, ...args: unknown[]): void {
this.captureLog('error', `CRITICAL: ${message}`, ...args);
// oxlint-disable-next-line no-console
// eslint-disable-next-line no-console
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
}
@@ -1,2 +1,3 @@
export { Meeting } from './meeting';
@@ -330,6 +330,7 @@ export class WebhookHandler {
}
}
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
@@ -1,54 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": [
"node_modules"
],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"react/jsx-key": "off",
"react/display-name": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-no-useless-fragment": "off",
"react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -0,0 +1,69 @@
import typescriptParser from '@typescript-eslint/parser';
import path from 'path';
import { fileURLToPath } from 'url';
import reactConfig from '../../../../twenty-eslint-rules/eslint.config.react.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default [
// Extend shared React configuration
...reactConfig,
// Global ignores
{
ignores: [
'**/node_modules/**',
],
},
// TypeScript project-specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
project: [path.resolve(__dirname, 'tsconfig.*.json')],
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:browser-extension',
onlyDependOnLibsWithTags: ['scope:twenty-ui', 'scope:browser-extension']
}
],
},
],
}
},
];
@@ -43,6 +43,7 @@ export default defineContentScript({
},
});
ctx.addEventListener(window, 'wxt:locationchange', ({newUrl, }) => {
const injectedBtn = document.querySelector('[data-id="twenty-btn"]');
if(personPattern.includes(newUrl) && !injectedBtn) ui.mount();
@@ -8,6 +8,7 @@ const StyledButton = styled.button`
--text-color: #0a66c2;
--bg-color: #00000000;
font-size: ${({theme}) => theme.spacing(3.5)};
font-weight: ${({theme}) => theme.font.weight.semiBold};
font-family: ${({theme}) => theme.font.family};
@@ -25,6 +26,7 @@ const StyledButton = styled.button`
transition-timing-function: cubic-bezier(.4, 0, .2, 1);
transition-duration: 167ms;
&:hover {
background-color: var(--hover-bg-color);
color: var(--hover-color);
@@ -1,5 +1,6 @@
import { ThemeProvider } from '@emotion/react';
import type React from 'react';
import { ColorSchemeProvider } from 'twenty-ui/theme-constants';
import { THEME_DARK, ThemeContextProvider } from 'twenty-ui/theme';
type ThemeContextProps = {
children: React.ReactNode;
@@ -7,6 +8,10 @@ type ThemeContextProps = {
export const ThemeContext = ({ children }: ThemeContextProps) => {
return (
<ColorSchemeProvider colorScheme="dark">{children}</ColorSchemeProvider>
<ThemeProvider theme={THEME_DARK}>
<ThemeContextProvider theme={THEME_DARK}>
{children}
</ThemeContextProvider>
</ThemeProvider>
);
};
@@ -0,0 +1,5 @@
import type { ThemeType } from 'twenty-ui/theme';
declare module '@emotion/react' {
export interface Theme extends ThemeType {}
}
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
-1
View File
@@ -4,7 +4,6 @@
- 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
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -10,18 +10,19 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"lint": "eslint",
"lint:fix": "eslint --fix",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"twenty-sdk": "0.6.4",
"twenty-sdk": "0.6.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
@@ -1,14 +1,34 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[build] ${message}`),
@@ -16,7 +36,7 @@ describe('App installation', () => {
if (!buildResult.success) {
throw new Error(
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
`App build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
@@ -38,7 +58,20 @@ describe('App installation', () => {
});
it('should find the installed app in the applications list', async () => {
const metadataClient = new MetadataApiClient();
const apiKey = process.env.TWENTY_TEST_API_KEY;
if (!apiKey) {
throw new Error(
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
);
}
const metadataClient = new MetadataApiClient({
url: `${TWENTY_API_URL}/metadata`,
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const result = await metadataClient.query({
findManyApplications: {
@@ -3,36 +3,16 @@ import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
let response: Response;
try {
response = await fetch(`${TWENTY_API_URL}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
}
};
beforeAll(async () => {
await assertServerIsReachable();
beforeAll(() => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
apiKey: process.env.TWENTY_TEST_API_KEY,
},
},
};
@@ -2,7 +2,7 @@ import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'6563e091-9f5b-4026-a3ea-7e3b3d09e218';
'1badae7c-8a42-4dea-b4b8-3c56e77c2f9a';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
@@ -3,7 +3,7 @@ import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'
export default defineField({
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '770d32c2-cf12-4ab2-b66d-73f92dc239b5',
universalIdentifier: '2c503a0d-36c9-49ec-b82f-4fafe0eb6f47',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -10,7 +10,7 @@ export const HelloWorld = () => {
};
export default defineFrontComponent({
universalIdentifier: 'd371f098-5b2c-42f0-898d-94459f1ee337',
universalIdentifier: '26c17445-fbfb-4b34-99d6-f461e734ca97',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -5,7 +5,7 @@ const handler = async (): Promise<{ message: string }> => {
};
export default defineLogicFunction({
universalIdentifier: '2baa26eb-9aaf-4856-a4f4-30d6fd6480ee',
universalIdentifier: '4f0b7137-1399-4e50-ac00-3c3bb2555c38',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePostInstallLogicFunction({
universalIdentifier: '7a3f4684-51db-494d-833b-a747a3b90507',
universalIdentifier: 'c1410017-8536-42aa-a188-4bfc5a1c3dae',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePreInstallLogicFunction({
universalIdentifier: '1272ffdb-8e2f-492c-ab37-66c2b97e9c23',
universalIdentifier: '68d005d4-1110-4fa0-8227-71e06d6b9f30',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
@@ -1,11 +1,14 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
universalIdentifier: '574a895f-1511-4b38-9d28-d6b8436738ff',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
position: 0,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
// Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',
});
@@ -1,10 +1,10 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'dfd43356-39b3-4b55-b4a7-279bec689928';
'b75cfe84-18ce-47da-812a-53e25ee094af';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'd2d7f6cd-33f6-456f-bf00-17adeca926ba';
'6ab9c690-06ce-455e-a2c9-8067a9747f96';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'9238bc7b-d38f-4a1c-9d19-31ab7bc67a2f';
'f14afc30-f2fa-4f70-9b12-903c5f852225';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'd0940029-9d3c-40be-903a-52d65393028f';
'4f00dd76-c07b-4d55-a43a-7f17e7f6440a';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
@@ -1,22 +1,10 @@
import { defineView, ViewKey } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = 'e004df40-29f3-47ba-b39d-d3a5c444367a';
import { defineView } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export default defineView({
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All example items',
universalIdentifier: 'e574b32c-c058-492a-8a5c-780b844a8735',
name: 'example-view',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '496c40c2-5766-419c-93bf-20fdad3f34bb',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
],
});
@@ -15,7 +15,7 @@ export default defineConfig({
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_KEY:
TWENTY_TEST_API_KEY:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
},
},
File diff suppressed because it is too large Load Diff
@@ -1,38 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -10,8 +10,8 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"@emotion/react": "^11.11.1",
@@ -23,8 +23,9 @@
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,40 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -10,12 +10,11 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"devDependencies": {
"@types/node": "^24.7.2",
"oxlint": "^0.16.0",
"twenty-sdk": "0.6.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
+2 -2
View File
@@ -45,8 +45,8 @@ typings/
# Optional npm cache directory
.npm
# Optional oxlint cache
.oxlintcache
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ COPY ./yarn.lock .
COPY ./.yarnrc.yml .
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
COPY ./packages/twenty-oxlint-rules /app/packages/twenty-oxlint-rules
COPY ./packages/twenty-eslint-rules /app/packages/twenty-eslint-rules
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
COPY ./packages/twenty-website/package.json /app/packages/twenty-website/package.json
-34
View File
@@ -1,34 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"ignorePatterns": ["node_modules", ".next", "storybook-static"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -92,7 +92,7 @@ Here's what the tech stack now looks like.
**Tooling**
- [Yarn](https://yarnpkg.com/)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html)
- [ESLint](https://eslint.org/)
**Development**
- [AWS EKS](https://aws.amazon.com/eks/)
@@ -64,7 +64,7 @@ The project has a clean and simple stack, with minimal boilerplate code.
- [Yarn](https://yarnpkg.com/)
- [Craco](https://craco.js.org/docs/)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter.html)
- [ESLint](https://eslint.org/)
## Architecture
@@ -260,7 +260,7 @@ const StyledButton = styled.button`
```
## Enforcing No-Type Imports
Avoid type imports. To enforce this standard, an Oxlint rule checks for and reports any type imports. This helps maintain consistency and readability in the TypeScript code.
Avoid type imports. To enforce this standard, an ESLint rule checks for and reports any type imports. This helps maintain consistency and readability in the TypeScript code.
```tsx
// ❌ Bad
@@ -281,11 +281,11 @@ import { Meta, StoryObj } from '@storybook/react';
- **Maintainability**: It enhances codebase maintainability because developers can identify and locate type-only imports when reviewing or modifying code.
### Oxlint Rule
### ESLint Rule
An Oxlint rule, `typescript/consistent-type-imports`, enforces the no-type import standard. This rule will generate errors or warnings for any type import violations.
An ESLint rule, `@typescript-eslint/consistent-type-imports`, enforces the no-type import standard. This rule will generate errors or warnings for any type import violations.
Please note that this rule specifically addresses rare edge cases where unintentional type imports occur. TypeScript itself discourages this practice, as mentioned in the [TypeScript 3.8 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html). In most situations, you should not need to use type-only imports.
To ensure your code complies with this rule, make sure to run Oxlint as part of your development workflow.
To ensure your code complies with this rule, make sure to run ESLint as part of your development workflow.
@@ -14,7 +14,7 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf
**What you can do today:**
- Define custom objects and fields as code (managed data model)
- Build logic functions with custom triggers
- Define skills and agents for AI
- Define skills for AI agents
- Deploy the same app across multiple workspaces
## Prerequisites
@@ -35,14 +35,17 @@ cd my-twenty-app
yarn twenty app:dev
```
The scaffolder supports two modes for controlling which example files are included:
The scaffolder supports three modes for controlling which example files are included:
```bash filename="Terminal"
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent)
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill)
npx create-twenty-app@latest my-app
# Minimal: only core files (application-config.ts and default-role.ts)
npx create-twenty-app@latest my-app --minimal
# Interactive: select which examples to include
npx create-twenty-app@latest my-app --interactive
```
From here you can:
@@ -92,7 +95,7 @@ my-twenty-app/
.yarnrc.yml
.yarn/
install-state.gz
.oxlintrc.json
eslint.config.mjs
tsconfig.json
README.md
public/ # Public assets folder (images, fonts, etc.)
@@ -114,13 +117,11 @@ 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
└── agents/
└── example-agent.ts # Example AI agent definition
── skills/
└── example-skill.ts # Example AI agent skill 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`).
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`). With `--interactive`, you choose which example files to include.
At a high level:
@@ -128,7 +129,7 @@ At a high level:
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `generated/` (typed client), `dist/`, `build/`, coverage folders, log files, and `.env*` files.
- **yarn.lock**, **.yarnrc.yml**, **.yarn/**: Lock and configure the Yarn 4 toolchain used by the project.
- **.nvmrc**: Pins the Node.js version expected by the project.
- **.oxlintrc.json** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources.
- **eslint.config.mjs** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources.
- **README.md**: A short README in the app root with basic instructions.
- **public/**: A folder for storing public assets (images, fonts, static files) that will be served with your application. Files placed here are uploaded during sync and accessible at runtime.
- **src/**: The main place where you define your application-as-code
@@ -149,7 +150,6 @@ The SDK detects entities by parsing your TypeScript files for **`export default
| `defineView()` | Saved view definitions |
| `defineNavigationMenuItem()` | Navigation menu item definitions |
| `defineSkill()` | AI agent skill definitions |
| `defineAgent()` | AI agent definitions |
<Note>
**File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define<Entity>({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement.
@@ -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/clients`: `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/generated`: `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
@@ -226,7 +226,6 @@ The SDK provides helper functions for defining your app entities. As described i
| `defineView()` | Define saved views for objects |
| `defineNavigationMenuItem()` | Define sidebar navigation links |
| `defineSkill()` | Define AI agent skills |
| `defineAgent()` | Define AI agents with system prompts |
These functions validate your configuration at build time and provide IDE autocompletion and type safety.
@@ -431,7 +430,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/clients';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -669,7 +668,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/clients';
import { CoreApiClient } from 'twenty-sdk/generated';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
@@ -794,50 +793,15 @@ 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
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`,
});
```
Key points:
- `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.
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/clients` based on your workspace schema:
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` 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 } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
@@ -846,7 +810,7 @@ const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
`CoreApiClient` is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change. `MetadataApiClient` ships pre-built with the SDK.
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
#### Runtime credentials in logic functions
@@ -862,10 +826,10 @@ Notes:
#### Uploading files
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.
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.
```typescript
import { MetadataApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/generated';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();

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