Compare commits

..

2 Commits

Author SHA1 Message Date
Weiko 24b074f436 fix 2026-03-11 15:32:57 +01:00
Weiko 181b48105e RLS: Allow Relation predicates other than workspace member 2026-03-11 14:16:53 +01:00
5273 changed files with 174708 additions and 357092 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"install": "curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs && node --version && yarn install && echo 'Setting up Docker Compose environment...' && cd packages/twenty-docker && cp -n docker-compose.yml docker-compose.dev.yml || true && echo 'Dependencies installed and docker-compose prepared'",
"start": "sudo service docker start && echo 'Docker service started' && cd packages/twenty-docker && echo 'Installing yq for YAML processing...' && sudo apt-get update -qq && sudo apt-get install -y wget && wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq && echo 'Patching docker-compose for local development...' && yq eval 'del(.services.server.image)' -i docker-compose.dev.yml && yq eval '.services.server.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.server.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && yq eval 'del(.services.worker.image)' -i docker-compose.dev.yml && yq eval '.services.worker.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.worker.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && echo 'Setting up .env file with database configuration...' && echo 'SERVER_URL=http://localhost:3000' > .env && echo 'APP_SECRET='$(openssl rand -base64 32) >> .env && echo 'PG_DATABASE_PASSWORD='$(openssl rand -hex 16) >> .env && echo 'PG_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres' >> .env && echo 'SIGN_IN_PREFILLED=true' >> .env && echo 'Building and starting services...' && docker-compose -f docker-compose.dev.yml up -d --build && echo 'Waiting for services to initialize...' && sleep 30 && echo 'Checking service health...' && docker-compose -f docker-compose.dev.yml ps && echo 'Environment setup complete!'",
"terminals": [
{
"name": "Database Setup & Seed",
"command": "sleep 40 && cd packages/twenty-docker && echo 'Waiting for PostgreSQL to be ready...' && until docker-compose -f docker-compose.dev.yml exec -T db pg_isready -U postgres; do echo 'Waiting for PostgreSQL...'; sleep 5; done && echo 'PostgreSQL is ready!' && echo 'Waiting for Twenty server to be healthy...' && until docker-compose -f docker-compose.dev.yml exec -T server curl --fail http://localhost:3000/healthz 2>/dev/null; do echo 'Waiting for server...'; sleep 5; done && echo 'Server is healthy!' && echo 'Running database setup and seeding...' && docker-compose -f docker-compose.dev.yml exec -T server npx nx database:reset twenty-server && echo 'Database seeded successfully!' && bash"
},
{
"name": "Application Logs",
"command": "sleep 35 && cd packages/twenty-docker && echo 'Following application logs...' && docker-compose -f docker-compose.dev.yml logs -f server worker"
},
{
"name": "Service Monitor",
"command": "sleep 15 && cd packages/twenty-docker && echo '=== Service Status Monitor ===' && while true; do clear; echo '=== Service Status at $(date) ===' && docker-compose -f docker-compose.dev.yml ps && echo '\\n=== Health Status ===' && (docker-compose -f docker-compose.dev.yml exec -T server curl -s http://localhost:3000/healthz 2>/dev/null && echo '✅ Twenty Server: Healthy') || echo '❌ Twenty Server: Not Ready' && (docker-compose -f docker-compose.dev.yml exec -T db pg_isready -U postgres 2>/dev/null && echo '✅ PostgreSQL: Ready') || echo '❌ PostgreSQL: Not Ready' && echo '\\n=== Database Connection Test ===' && docker-compose -f docker-compose.dev.yml exec -T server node -e \"const { Client } = require('pg'); const client = new Client({connectionString: process.env.PG_DATABASE_URL}); client.connect().then(() => {console.log('✅ Database Connection: OK'); client.end();}).catch(e => console.log('❌ Database Connection: Failed -', e.message));\" || echo 'Connection test failed' && sleep 45; done"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"install": "yarn install",
"start": "(sudo service docker start || service docker start || true) && bash packages/twenty-utils/setup-dev-env.sh && npx nx database:reset twenty-server",
"start": "sudo service docker start && sleep 2 && (docker start twenty_pg 2>/dev/null || make -C packages/twenty-docker postgres-on-docker) && (docker start twenty_redis 2>/dev/null || make -C packages/twenty-docker redis-on-docker) && until docker exec twenty_pg pg_isready -U postgres -h localhost 2>/dev/null; do sleep 1; done && echo 'PostgreSQL ready' && until docker exec twenty_redis redis-cli ping 2>/dev/null | grep -q PONG; do sleep 1; done && echo 'Redis ready' && bash packages/twenty-utils/setup-dev-env.sh && npx nx database:reset twenty-server",
"terminals": [
{
"name": "Development Server",
+1 -1
View File
@@ -1,5 +1,5 @@
.git
.env
**/node_modules
node_modules
.nx/cache
packages/twenty-server/.env
@@ -3,13 +3,12 @@ description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Accepts "latest" (pulls the latest Docker Hub image, checks out main) or a
semver tag (e.g., v0.40.0).
Pulls the specified semver image tag from Docker Hub.
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag — either "latest" or a semver tag (e.g., v0.40.0).'
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
@@ -31,19 +30,12 @@ outputs:
runs:
using: 'composite'
steps:
- name: Resolve version
id: resolve
- name: Validate version
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if [ "$VERSION" = "latest" ]; then
echo "docker-tag=latest" >> "$GITHUB_OUTPUT"
echo "git-ref=main" >> "$GITHUB_OUTPUT"
elif echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "docker-tag=$VERSION" >> "$GITHUB_OUTPUT"
echo "git-ref=$VERSION" >> "$GITHUB_OUTPUT"
else
echo "::error::twenty-version must be \"latest\" or a semver tag (e.g., v0.40.0). Got: '$VERSION'"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
@@ -51,7 +43,7 @@ runs:
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ steps.resolve.outputs.git-ref }}
ref: ${{ inputs.twenty-version }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
@@ -64,7 +56,7 @@ runs:
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ steps.resolve.outputs.docker-tag }}" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
-22
View File
@@ -1,22 +0,0 @@
storage: /tmp/verdaccio-storage
auth:
htpasswd:
file: /tmp/verdaccio-htpasswd
max_users: 100
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'twenty-sdk':
access: $all
publish: $all
'twenty-client-sdk':
access: $all
publish: $all
'create-twenty-app':
access: $all
publish: $all
'**':
access: $all
proxy: npmjs
log: { type: stdout, format: pretty, level: warn }
-68
View File
@@ -1,68 +0,0 @@
name: AI Catalog Sync
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
jobs:
sync-catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Run catalog sync
run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/ai-sync-models-dev.ts
- name: Check for changes
id: changes
run: |
if git diff --quiet packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-providers.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
title: 'chore: sync AI model catalog from models.dev'
body: |
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based on the latest data.
New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same model family.
**Please review before merging** — verify no critical models were incorrectly deprecated.
branch: chore/ai-catalog-sync
base: main
labels: ai, automated
delete-branch: true
- name: Trigger automerge
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: automated-pr-ready
+2 -15
View File
@@ -36,9 +36,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -53,16 +50,10 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
@@ -152,6 +143,7 @@ jobs:
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed current branch database with test data
run: |
@@ -304,6 +296,7 @@ jobs:
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed main branch database with test data
run: |
@@ -402,12 +395,6 @@ jobs:
# Clean up temp directory
rm -rf /tmp/current-branch-files
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Install OpenAPI Diff Tool
run: |
# Using the Java-based OpenAPITools/openapi-diff via Docker
-208
View File
@@ -1,208 +0,0 @@
name: CI Create App E2E
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
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
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
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: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --exhaustive --display-name "Test App" --description "E2E test app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.devDependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- 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: Authenticate with twenty-server
env:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute hello-world logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-hello-world-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Created company"
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+2 -8
View File
@@ -10,8 +10,8 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
group: ${{ github.workflow }}-${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref || github.ref }}
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
jobs:
e2e-test:
@@ -26,9 +26,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -43,9 +40,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
steps:
-6
View File
@@ -57,9 +57,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -74,9 +71,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
env:
+6 -20
View File
@@ -25,7 +25,7 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-client-sdk/**
packages/twenty-sdk/src/clients/generated/metadata/**
packages/twenty-emails/**
packages/twenty-shared/**
@@ -84,9 +84,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -101,9 +98,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
steps:
@@ -128,6 +122,7 @@ jobs:
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:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Worker / Run
run: |
timeout 30s npx nx run twenty-server:worker || exit_code=$?
@@ -182,14 +177,14 @@ jobs:
HAS_ERRORS=true
fi
npx nx run twenty-client-sdk:generate-metadata-client
npx nx run twenty-sdk:generate-metadata-client
if ! git diff --quiet -- packages/twenty-client-sdk/src/metadata/generated; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-client-sdk:generate-metadata-client' and commit the changes."
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 ""
echo "The following SDK metadata client changes were detected:"
echo "==================================================="
git diff -- packages/twenty-client-sdk/src/metadata/generated
git diff -- packages/twenty-sdk/src/clients/generated/metadata
echo "==================================================="
echo ""
HAS_ERRORS=true
@@ -232,9 +227,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -249,16 +241,10 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
+5 -68
View File
@@ -1,4 +1,4 @@
name: CI Docker
name: CI Docker Compose
permissions:
contents: read
@@ -19,7 +19,7 @@ jobs:
files: |
packages/twenty-docker/**
docker-compose.yml
test-compose:
test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
@@ -27,18 +27,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i docker-compose.yml
yq eval '.services.server.restart = "no"' -i docker-compose.yml
echo "Setting up .env file..."
@@ -94,69 +89,11 @@ jobs:
echo "Still waiting for server... (${count}/300s)"
done
working-directory: ./packages/twenty-docker/
test-app-dev:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Create frontend placeholder
run: |
mkdir -p packages/twenty-front/build
echo '<html><body>CI placeholder</body></html>' > packages/twenty-front/build/index.html
- name: Build app-dev image
run: |
docker build \
--target twenty-app-dev \
-f packages/twenty-docker/twenty/Dockerfile \
-t twenty-app-dev-ci \
.
- name: Start container
run: |
docker run -d --name twenty-app-dev \
-p 3000:3000 \
twenty-app-dev-ci
docker logs twenty-app-dev -f &
- name: Wait for server health
run: |
echo "Waiting for twenty-app-dev to become healthy..."
count=0
while true; do
status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/healthz 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
echo "Server is healthy!"
curl -s http://localhost:3000/healthz
break
fi
container_status=$(docker inspect --format='{{.State.Status}}' twenty-app-dev 2>/dev/null || echo "unknown")
if [ "$container_status" = "exited" ]; then
echo "Container exited unexpectedly"
docker logs twenty-app-dev
exit 1
fi
count=$((count+1))
if [ $count -gt 300 ]; then
echo "Server did not become healthy within 5 minutes"
docker logs twenty-app-dev
exit 1
fi
echo "Still waiting... (${count}/300s) [HTTP ${status}]"
sleep 1
done
ci-test-docker-status-check:
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, test-compose, test-app-dev]
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
-112
View File
@@ -1,112 +0,0 @@
name: CI UI
on:
pull_request:
merge_group:
permissions:
contents: read
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: |
package.json
yarn.lock
packages/twenty-ui/**
packages/twenty-shared/**
ui-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- 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: Run ${{ matrix.task }}
run: npx nx ${{ matrix.task }} twenty-ui
ui-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- 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 storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
retention-days: 1
ui-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: ui-sb-build
env:
STORYBOOK_URL: http://localhost:6007
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 dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-ui
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-ui/storybook-static --port 6007 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6007 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-ui
ci-ui-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
ui-task,
ui-sb-build,
ui-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-3
View File
@@ -27,9 +27,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
-6
View File
@@ -30,9 +30,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -47,9 +44,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
steps:
+2 -10
View File
@@ -40,8 +40,7 @@ jobs:
uses: actions/checkout@v4
with:
token: ${{ github.token }}
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
- name: Install dependencies
uses: ./.github/actions/yarn-install
@@ -112,7 +111,7 @@ jobs:
run: yarn docs:generate-paths
- name: Commit artifacts to pull request branch
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
if: github.event_name == 'pull_request'
run: |
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
if git diff --staged --quiet --exit-code; then
@@ -151,10 +150,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-8
View File
@@ -138,11 +138,3 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-8
View File
@@ -102,11 +102,3 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
@@ -17,12 +17,6 @@ jobs:
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
@@ -30,12 +24,10 @@ jobs:
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
@@ -1,144 +0,0 @@
name: Visual Regression Dispatch
# Uses workflow_run to dispatch visual regression to ci-privileged.
# This runs in the context of the base repo (not the fork), so it has
# access to secrets — making it work for external contributor PRs.
on:
workflow_run:
workflows: ['CI Front', 'CI UI']
types: [completed]
permissions:
actions: read
pull-requests: read
jobs:
dispatch:
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Determine project and artifact name
id: project
uses: actions/github-script@v7
with:
script: |
const workflowName = context.payload.workflow_run.name;
if (workflowName === 'CI Front') {
core.setOutput('project', 'twenty-front');
core.setOutput('artifact_name', 'storybook-static');
core.setOutput('tarball_name', 'storybook-twenty-front-tarball');
core.setOutput('tarball_file', 'storybook-twenty-front.tar.gz');
} else if (workflowName === 'CI UI') {
core.setOutput('project', 'twenty-ui');
core.setOutput('artifact_name', 'storybook-twenty-ui');
core.setOutput('tarball_name', 'storybook-twenty-ui-tarball');
core.setOutput('tarball_file', 'storybook-twenty-ui.tar.gz');
} else {
core.setFailed(`Unexpected workflow: ${workflowName}`);
}
- name: Check if storybook artifact exists
id: check-artifact
uses: actions/github-script@v7
with:
script: |
const artifactName = '${{ steps.project.outputs.artifact_name }}';
const runId = context.payload.workflow_run.id;
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
const found = artifacts.artifacts.some(a => a.name === artifactName);
core.setOutput('exists', found ? 'true' : 'false');
if (!found) {
core.info(`Artifact "${artifactName}" not found in run ${runId} — storybook build was likely skipped`);
}
- name: Get PR number
if: steps.check-artifact.outputs.exists == 'true'
id: pr-info
uses: actions/github-script@v7
with:
script: |
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head label (owner:branch)
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
const headLabel = `${headRepo.owner.login}:${headBranch}`;
core.info(`pull_requests is empty (likely a fork PR), searching by head label: ${headLabel}`);
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run — skipping');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}`);
- name: Download storybook artifact from triggering run
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/download-artifact@v4
with:
name: ${{ steps.project.outputs.artifact_name }}
path: storybook-static
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Package storybook
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
run: tar -czf /tmp/${{ steps.project.outputs.tarball_file }} -C storybook-static .
- name: Upload storybook tarball
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.project.outputs.tarball_name }}
path: /tmp/${{ steps.project.outputs.tarball_file }}
retention-days: 1
- name: Dispatch to ci-privileged
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ steps.pr-info.outputs.pr_number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "${{ steps.project.outputs.project }}",
"branch": "${{ github.event.workflow_run.head_branch }}",
"commit": "${{ github.event.workflow_run.head_sha }}"
}
-1
View File
@@ -1,7 +1,6 @@
**/**/.env
.DS_Store
/.idea
.claude/settings.json
**/**/node_modules/
.cache
+2 -2
View File
@@ -2,8 +2,8 @@
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
"env": {}
},
"playwright": {
-940
View File
File diff suppressed because one or more lines are too long
+942
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,4 +6,4 @@ enableInlineHunks: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
yarnPath: .yarn/releases/yarn-4.9.2.cjs
+4 -24
View File
@@ -80,17 +80,6 @@ npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/mi
npx nx run twenty-server:command workspace:sync-metadata
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
@@ -199,22 +188,13 @@ IMPORTANT: Use Context7 for code generation, setup or configuration steps, or li
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## Dev Environment Setup
## CI Environment (GitHub Actions)
All dev environments (Claude Code web, Cursor, local) use one script:
When running in CI, the dev environment is **not** pre-configured. Dependencies are installed but builds, env files, and databases are not set up.
```bash
bash packages/twenty-utils/setup-dev-env.sh
```
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
- `--down` — stop services
- `--reset` — wipe data and restart fresh
- **Before running tests, builds, lint, type checks, or DB operations**, run: `bash packages/twenty-utils/setup-dev-env.sh`
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
- The script is idempotent and safe to run multiple times.
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
+3 -3
View File
@@ -25,8 +25,8 @@
# Installation
See:
🚀 [Self-hosting](https://docs.twenty.com/developers/self-host/capabilities/docker-compose)
🖥️ [Local Setup](https://docs.twenty.com/developers/contribute/capabilities/local-setup)
🚀 [Self-hosting](https://docs.twenty.com/developers/self-hosting/docker-compose)
🖥️ [Local Setup](https://docs.twenty.com/developers/local-setup)
# Why Twenty
@@ -36,7 +36,7 @@ We built Twenty for three reasons:
**A fresh start is required to build a better experience.** We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
**We believe in open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
**We believe in Open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
<br />
-8
View File
@@ -136,14 +136,6 @@
"cache": true,
"dependsOn": ["^build"]
},
"set-local-version": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"cwd": "{projectRoot}",
"command": "npm pkg set version={args.releaseVersion}"
}
},
"storybook:build": {
"executor": "nx:run-commands",
"cache": true,
+4 -7
View File
@@ -1,7 +1,7 @@
{
"private": true,
"dependencies": {
"@apollo/client": "^4.0.0",
"@apollo/client": "^3.7.17",
"@floating-ui/react": "^0.24.3",
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
@@ -164,7 +164,6 @@
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1",
"vite": "^7.0.0",
"vitest": "^4.0.18"
},
@@ -175,7 +174,7 @@
},
"license": "AGPL-3.0",
"name": "twenty",
"packageManager": "yarn@4.13.0",
"packageManager": "yarn@4.9.2",
"resolutions": {
"graphql": "16.8.1",
"type-fest": "4.10.1",
@@ -203,17 +202,15 @@
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
"packages/twenty-sdk",
"packages/twenty-client-sdk",
"packages/twenty-standard-application",
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-oxlint-rules",
"packages/twenty-companion"
"packages/twenty-oxlint-rules"
]
},
"prettier": {
+33 -66
View File
@@ -25,48 +25,41 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
## Prerequisites
- Node.js 24+ (recommended) and Yarn 4
- Docker (for the local Twenty dev server)
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
## Quick start
```bash
# Scaffold a new app — the CLI will offer to start a local Twenty server
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# The scaffolder can automatically:
# 1. Start a local Twenty server (Docker)
# 2. Open the browser to log in (tim@apple.dev / tim@apple.dev)
# 3. Authenticate your app via OAuth
# Get help and list all available commands
yarn twenty help
# Or do it manually:
yarn twenty server start # Start local Twenty server
yarn twenty remote add http://localhost:2020 --as local # Authenticate via OAuth
# Authenticate using your API key (you'll be prompted)
yarn twenty auth:login
# Add a new entity to your application (guided)
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 — both available via `twenty-client-sdk`)
yarn twenty dev
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
yarn twenty app:dev
# Watch your application's function logs
yarn twenty logs
yarn twenty function:logs
# Execute a function with a JSON payload
yarn twenty exec -n my-function -p '{"key": "value"}'
yarn twenty function:execute -n my-function -p '{"key": "value"}'
# Execute the pre-install function
yarn twenty exec --preInstall
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty exec --postInstall
# Build the app for distribution
yarn twenty build
# Publish the app to npm or directly to a Twenty server
yarn twenty publish
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty uninstall
yarn twenty app:uninstall
```
## Scaffolding modes
@@ -108,69 +101,43 @@ npx create-twenty-app@latest my-app -m
- `skills/example-skill.ts` — Example AI agent skill definition
- `__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)
## Local server
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker). You can also manage it manually:
```bash
yarn twenty server start # Start (pulls image if needed)
yarn twenty server status # Check if it's healthy
yarn twenty server logs # Stream logs
yarn twenty server stop # Stop (data is preserved)
yarn twenty server reset # Wipe all data and start fresh
```
The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty remote add <url>` to authenticate with your Twenty workspace via OAuth.
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`.
- 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'`.
## Build and publish your application
## Publish your application
Once your app is ready, build and publish it using the CLI:
```bash
# Build the app (output goes to .twenty/output/)
yarn twenty build
# Build and create a tarball (.tgz) for distribution
yarn twenty build --tarball
# Publish to npm (requires npm login)
yarn twenty publish
# Publish with a dist-tag (e.g. beta, next)
yarn twenty publish --tag beta
# Deploy directly to a Twenty server (builds, uploads, and installs in one step)
yarn twenty deploy
```
### Publish to the Twenty marketplace
You can also contribute your application to the curated marketplace:
Applications are currently stored in `twenty/packages/twenty-apps`.
You can share your application with all Twenty users:
```bash
# pull the Twenty project
git clone https://github.com/twentyhq/twenty.git
cd twenty
# create a new branch
git checkout -b feature/my-awesome-app
```
- Copy your app folder into `twenty/packages/twenty-apps`.
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
```bash
git commit -m "Add new application"
git push
```
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add <url>`.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
- 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.8.0-canary.5",
"version": "0.7.0-canary.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -36,7 +36,6 @@
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-sdk": "workspace:*",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -46,6 +45,7 @@
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"twenty-sdk": "workspace:*",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
-1
View File
@@ -24,7 +24,6 @@
"command": "node dist/cli.cjs"
}
},
"set-local-version": {},
"typecheck": {},
"lint": {},
"test": {
+1 -30
View File
@@ -18,19 +18,6 @@ const program = new Command(packageJson.name)
'-m, --minimal',
'Create only core entities (application-config and default-role)',
)
.option('-n, --name <name>', 'Application name (skips prompt)')
.option(
'-d, --display-name <displayName>',
'Application display name (skips prompt)',
)
.option(
'--description <description>',
'Application description (skips prompt)',
)
.option(
'--skip-local-instance',
'Skip the local Twenty instance setup prompt',
)
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
@@ -38,10 +25,6 @@ const program = new Command(packageJson.name)
options?: {
exhaustive?: boolean;
minimal?: boolean;
name?: string;
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
},
) => {
const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean);
@@ -64,21 +47,9 @@ const program = new Command(packageJson.name)
process.exit(1);
}
if (options?.name !== undefined && options.name.trim().length === 0) {
console.error(chalk.red('Error: --name cannot be empty.'));
process.exit(1);
}
const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive';
await new CreateAppCommand().execute({
directory,
mode,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
skipLocalInstance: options?.skipLocalInstance,
});
await new CreateAppCommand().execute(directory, mode);
},
);
@@ -11,4 +11,3 @@
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,11 +1,62 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
Run `yarn twenty help` to list all available commands.
First, authenticate to your workspace:
```bash
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## Integration Tests
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
```bash
# Make sure a Twenty server is running at http://localhost:3000
yarn test
```
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/capabilities/apps)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -7,13 +7,6 @@ import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import kebabCase from 'lodash.kebabcase';
import * as path from 'path';
import { basename } from 'path';
import {
authLoginOAuth,
serverStart,
type ServerStartResult,
} from 'twenty-sdk/cli';
import { isDefined } from 'twenty-shared/utils';
import {
type ExampleOptions,
@@ -22,24 +15,16 @@ import {
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
type CreateAppOptions = {
directory?: string;
mode?: ScaffoldingMode;
name?: string;
displayName?: string;
description?: string;
skipLocalInstance?: boolean;
};
export class CreateAppCommand {
async execute(options: CreateAppOptions = {}): Promise<void> {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(options);
async execute(
directory?: string,
mode: ScaffoldingMode = 'exhaustive',
): Promise<void> {
try {
const exampleOptions = this.resolveExampleOptions(
options.mode ?? 'exhaustive',
);
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
const exampleOptions = this.resolveExampleOptions(mode);
await this.validateDirectory(appDirectory);
@@ -59,50 +44,29 @@ export class CreateAppCommand {
await tryGitInit(appDirectory);
let serverResult: ServerStartResult | undefined;
if (!options.skipLocalInstance) {
const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
if (startResult.success) {
serverResult = startResult.data;
await this.connectToLocal(serverResult.url);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
}
}
this.logSuccess(appDirectory, serverResult);
this.logSuccess(appDirectory);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
chalk.red('Initialization failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getAppInfos(options: CreateAppOptions): Promise<{
private async getAppInfos(directory?: string): Promise<{
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}> {
const { directory } = options;
const hasName = isDefined(options.name) || isDefined(directory);
const hasDisplayName = isDefined(options.displayName);
const hasDescription = isDefined(options.description);
const { name, displayName, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name:',
when: () => !hasName,
default: 'my-twenty-app',
when: () => !directory,
default: 'my-awesome-app',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
@@ -112,33 +76,25 @@ export class CreateAppCommand {
type: 'input',
name: 'displayName',
message: 'Application display name:',
when: () => !hasDisplayName,
default: (answers: { name?: string }) => {
return convertToLabel(
answers?.name ?? options.name ?? directory ?? '',
);
default: (answers: any) => {
return convertToLabel(answers?.name ?? directory);
},
},
{
type: 'input',
name: 'description',
message: 'Application description (optional):',
when: () => !hasDescription,
default: '',
},
]);
const appName = (
options.name ??
name ??
directory ??
'my-twenty-app'
).trim();
const computedName = name ?? directory;
const appDisplayName =
(options.displayName ?? displayName)?.trim() || convertToLabel(appName);
const appName = computedName.trim();
const appDescription = (options.description ?? description ?? '').trim();
const appDisplayName = displayName.trim();
const appDescription = description.trim();
const appDirectory = directory
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
@@ -195,54 +151,22 @@ export class CreateAppCommand {
appDirectory: string;
appName: string;
}): void {
console.log(
chalk.blue('\n', 'Creating Twenty Application\n'),
chalk.gray(`- Directory: ${appDirectory}\n`, `- Name: ${appName}\n`),
);
console.log(chalk.blue('🎯 Creating Twenty Application'));
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
console.log(chalk.gray(`📝 Name: ${appName}`));
console.log('');
}
private async connectToLocal(serverUrl: string): Promise<void> {
try {
const result = await authLoginOAuth({
apiUrl: serverUrl,
remote: 'local',
});
if (!result.success) {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
),
);
}
} catch {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
),
);
}
}
private logSuccess(
appDirectory: string,
serverResult?: ServerStartResult,
): void {
const dirName = basename(appDirectory);
console.log(chalk.blue('\nApplication created. Next steps:'));
console.log(chalk.gray(`- cd ${dirName}`));
if (!serverResult) {
console.log(
chalk.gray(
'- yarn twenty remote add --local # Authenticate with Twenty',
),
);
}
private logSuccess(appDirectory: string): void {
const dirName = appDirectory.split('/').reverse()[0] ?? '';
console.log(chalk.green('✅ Application created!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
console.log(
chalk.gray('- yarn twenty dev # Start dev mode'),
chalk.gray(' yarn twenty auth:login # Authenticate with Twenty'),
);
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
}
}
@@ -106,9 +106,6 @@ describe('copyBaseApplicationProject', () => {
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-client-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.scripts['twenty']).toBe('twenty');
});
@@ -365,21 +362,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(true);
@@ -461,21 +448,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(false);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(false);
@@ -503,7 +480,7 @@ describe('copyBaseApplicationProject', () => {
});
describe('selective examples', () => {
it('should create front component and page layout when only front component option is enabled', async () => {
it('should create only front component when only that option is enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
@@ -529,11 +506,6 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -573,11 +545,6 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -47,17 +47,15 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'",
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-client-sdk/metadata'",
"import { MetadataApiClient } from 'twenty-sdk/clients'",
);
expect(content).toContain(
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
);
expect(content).toContain('appBuild');
expect(content).toContain('appDeploy');
expect(content).toContain('appInstall');
expect(content).toContain('appUninstall');
expect(content).toContain('new MetadataApiClient()');
expect(content).toContain('findManyApplications');
@@ -105,6 +103,7 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('TWENTY_API_KEY');
expect(content).not.toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('setup-test.ts');
expect(content).toContain('tsconfig.spec.json');
expect(content).toContain('integration-test.ts');
@@ -138,7 +137,7 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('yarn install --immutable');
expect(content).toContain('yarn test');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('TWENTY_API_KEY');
expect(content).toContain('TWENTY_TEST_API_KEY');
});
});
@@ -30,14 +30,12 @@ export const copyBaseApplicationProject = async ({
includeExampleIntegrationTest: exampleOptions.includeExampleIntegrationTest,
});
await createYarnLock(appDirectory);
await createGitignore(appDirectory);
await createNvmrc(appDirectory);
await createPublicAssetDirectory(appDirectory);
await createYarnLock(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
await fs.ensureDir(sourceFolderPath);
@@ -71,12 +69,6 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'logic-functions',
fileName: 'hello-world.ts',
});
await createCreateCompanyFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'create-hello-world-company.ts',
});
}
if (exampleOptions.includeExampleFrontComponent) {
@@ -85,12 +77,6 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'front-components',
fileName: 'hello-world.tsx',
});
await createExamplePageLayout({
appDirectory: sourceFolderPath,
fileFolder: 'page-layouts',
fileName: 'example-record-page-layout.ts',
});
}
if (exampleOptions.includeExampleView) {
@@ -156,6 +142,13 @@ const createPublicAssetDirectory = async (appDirectory: string) => {
await fs.ensureDir(join(appDirectory, ASSETS_DIR));
};
const createYarnLock = async (appDirectory: string) => {
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
};
const createGitignore = async (appDirectory: string) => {
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
@@ -200,10 +193,6 @@ yarn-error.log*
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
const createNvmrc = async (appDirectory: string) => {
await fs.writeFile(join(appDirectory, '.nvmrc'), '24.5.0\n');
};
const createDefaultRoleConfig = async ({
displayName,
appDirectory,
@@ -248,59 +237,19 @@ const createDefaultFrontComponent = async ({
}) => {
const universalIdentifier = v4();
const content = `import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
const content = `import { defineFrontComponent } from 'twenty-sdk';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '${universalIdentifier}',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -311,56 +260,6 @@ export default defineFrontComponent({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExamplePageLayout = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const pageLayoutUniversalIdentifier = v4();
const tabUniversalIdentifier = v4();
const widgetUniversalIdentifier = v4();
const content = `import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '${pageLayoutUniversalIdentifier}',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '${tabUniversalIdentifier}',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '${widgetUniversalIdentifier}',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultFunction = async ({
appDirectory,
fileFolder,
@@ -396,58 +295,6 @@ export default defineLogicFunction({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createCreateCompanyFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: \`Created company "\${createCompany?.name}" with id \${createCompany?.id}\`,
};
};
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPreInstallFunction = async ({
appDirectory,
fileFolder,
@@ -634,7 +481,7 @@ const createExampleNavigationMenuItem = async ({
const universalIdentifier = v4();
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
@@ -642,7 +489,6 @@ export default defineNavigationMenuItem({
icon: 'IconList',
color: 'blue',
position: 0,
type: 'VIEW',
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
`;
@@ -744,14 +590,6 @@ export default defineApplication({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createYarnLock = async (appDirectory: string) => {
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
`;
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
};
const createPackageJson = async ({
appName,
appDirectory,
@@ -770,12 +608,10 @@ const createPackageJson = async ({
const devDependencies: Record<string, string> = {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^19.0.0',
react: '^19.0.0',
'react-dom': '^19.0.0',
'@types/react': '^18.2.0',
react: '^18.2.0',
oxlint: '^0.16.0',
'twenty-sdk': createTwentyAppPackageJson.version,
'twenty-client-sdk': createTwentyAppPackageJson.version,
};
if (includeExampleIntegrationTest) {
@@ -5,7 +5,6 @@ import { exec } from 'child_process';
const execPromise = promisify(exec);
export const install = async (root: string) => {
console.log(chalk.gray('Installing yarn dependencies...'));
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
@@ -15,6 +14,6 @@ export const install = async (root: string) => {
try {
await execPromise('yarn install', { cwd: root });
} catch (error: any) {
console.warn(chalk.yellow('yarn install failed:'), error.stdout);
console.error(chalk.red('yarn install failed:'), error.stdout);
}
};
@@ -45,6 +45,7 @@ export default defineConfig({
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: 'http://localhost:3000',
TWENTY_API_KEY:
'${SEED_API_KEY}',
},
@@ -93,7 +94,7 @@ 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:2020';
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 () => {
@@ -119,13 +120,12 @@ beforeAll(async () => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
@@ -149,17 +149,18 @@ const createIntegrationTest = async ({
fileName: string;
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
@@ -169,27 +170,14 @@ describe('App installation', () => {
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(\`[deploy] \${message}\`),
});
if (!deployResult.success) {
throw new Error(
\`Deploy failed: \${deployResult.error?.message ?? 'Unknown error'}\`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
\`Install failed: \${installResult.error?.message ?? 'Unknown error'}\`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
@@ -269,7 +257,7 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: \${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: \${{ steps.twenty.outputs.access-token }}
TWENTY_TEST_API_KEY: \${{ steps.twenty.outputs.access-token }}
`;
const workflowDir = join(appDirectory, '.github', 'workflows');
@@ -30,7 +30,7 @@ type AnalysisResult = {
commitments: Commitment[];
};
type RichTextData = {
type RichTextV2Data = {
markdown: string;
blocknote: null;
};
@@ -123,7 +123,7 @@ const createNoteInTwenty = async (
bodyV2: {
markdown: noteBodyMarkdown,
blocknote: null,
} satisfies RichTextData,
} satisfies RichTextV2Data,
};
try {
@@ -159,7 +159,7 @@ const createTaskInTwenty = async (
const taskData: {
title: string;
bodyV2: RichTextData;
bodyV2: RichTextV2Data;
dueAt?: string;
} = {
title: actionItem.title,
@@ -10,4 +10,3 @@
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add http://localhost:2020 --as local
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty dev
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
@@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -32,7 +32,7 @@ type AnalysisResult = {
commitments: Commitment[];
};
type RichTextData = {
type RichTextV2Data = {
markdown: string;
blocknote: null;
};
@@ -362,7 +362,7 @@ const createNoteInTwenty = async (
bodyV2: {
markdown: noteBodyMarkdown,
blocknote: null,
} satisfies RichTextData,
} satisfies RichTextV2Data,
};
try {
@@ -451,7 +451,7 @@ const createTaskInTwenty = async (
const taskData: {
title: string;
bodyV2: RichTextData;
bodyV2: RichTextV2Data;
dueAt?: string;
assigneeId?: string;
} = {
@@ -9,9 +9,9 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"dev": "twenty dev",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"app:dev": "twenty app:dev",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -9,16 +9,16 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"entity:add": "twenty entity:add",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
@@ -9,16 +9,16 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"entity:add": "twenty entity:add",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
@@ -9,16 +9,16 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"auth:login": "twenty auth:login",
"auth:logout": "twenty auth:logout",
"auth:status": "twenty auth:status",
"auth:switch": "twenty auth:switch",
"auth:list": "twenty auth:list",
"app:dev": "twenty app:dev",
"entity:add": "twenty entity:add",
"function:logs": "twenty function:logs",
"function:execute": "twenty function:execute",
"app:uninstall": "twenty app:uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
@@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: 'b602dbd9-e511-49ce-b6d3-b697218dc69c',
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
type: FieldType.SELECT,
name: 'category',
label: 'Category',
@@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -1,10 +1,8 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
import { ALL_POST_CARD_RECIPIENTS_VIEW_ID } from '../views/all-post-card-recipients.view';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
position: 2,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: ALL_POST_CARD_RECIPIENTS_VIEW_ID,
});
@@ -1,10 +1,8 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import { ALL_POST_CARDS_VIEW_ID } from '../views/all-post-cards.view';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
position: 0,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: ALL_POST_CARDS_VIEW_ID,
});
@@ -1,10 +1,8 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/recipient.object';
import { ALL_RECIPIENTS_VIEW_ID } from '../views/all-recipients.view';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
position: 1,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: ALL_RECIPIENTS_VIEW_ID,
});
@@ -25,8 +25,8 @@ export default defineObject({
{
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.ADDRESS,
label: 'Mailing Address',
name: 'mailingAddress',
label: 'Address',
name: 'address',
icon: 'IconHome',
},
],
@@ -1,42 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
env:
TWENTY_VERSION: latest
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
-2
View File
@@ -4,11 +4,9 @@
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/app-seeds/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
+13 -13
View File
@@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add http://localhost:2020 --as local
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty dev
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
@@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## Integration Tests
@@ -20,8 +20,7 @@
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"twenty-client-sdk": "portal:../../twenty-client-sdk",
"twenty-sdk": "portal:../../twenty-sdk",
"twenty-sdk": "0.6.4",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
@@ -1,15 +1,16 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
@@ -19,27 +20,14 @@ describe('App installation', () => {
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
@@ -29,13 +29,12 @@ beforeAll(async () => {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
const configFile = {
remotes: {
local: {
profiles: {
default: {
apiUrl: process.env.TWENTY_API_URL,
apiKey: process.env.TWENTY_API_KEY,
},
},
defaultRemote: 'local',
};
fs.writeFileSync(
@@ -1,13 +0,0 @@
import { defineAgent } from 'twenty-sdk';
export const EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER =
'110bebc2-f116-46b6-a35d-61e91c3c0a43';
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.',
});
@@ -2,7 +2,7 @@ import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'bb1decf6-dee5-43ef-b881-9799f97b02a8';
'6563e091-9f5b-4026-a3ea-7e3b3d09e218';
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: 'be08a7c6-2586-4d91-9fa7-0a44e6eae30c',
universalIdentifier: '770d32c2-cf12-4ab2-b66d-73f92dc239b5',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -1,56 +1,16 @@
import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'7a758f23-5e7d-497d-98c9-7ca8d6c085b0';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
universalIdentifier: 'd371f098-5b2c-42f0-898d-94459f1ee337',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -1,35 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: `Created company "${createCompany?.name}" with id ${createCompany?.id}`,
};
};
export default defineLogicFunction({
universalIdentifier: '94abaa53-d265-4fa4-ae52-1d7ea711ecf2',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -5,7 +5,7 @@ const handler = async (): Promise<{ message: string }> => {
};
export default defineLogicFunction({
universalIdentifier: 'b05e4b30-72d4-4d7f-8091-32e037b601da',
universalIdentifier: '2baa26eb-9aaf-4856-a4f4-30d6fd6480ee',
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: '8c726dcc-1709-4eac-aa8b-f99960a9ec1b',
universalIdentifier: '7a3f4684-51db-494d-833b-a747a3b90507',
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: 'f8ad4b09-6a12-4b12-a52a-3472d3a78dc7',
universalIdentifier: '1272ffdb-8e2f-492c-ab37-66c2b97e9c23',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
@@ -1,13 +1,11 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,10 +1,10 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'47fd9bd9-392b-4d9f-9091-9a91b1edf519';
'dfd43356-39b3-4b55-b4a7-279bec689928';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'2d9ff841-cf8e-44ec-ad8e-468455f7eebd';
'd2d7f6cd-33f6-456f-bf00-17adeca926ba';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
@@ -1,31 +0,0 @@
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1,7 +1,7 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'c38f4d11-760c-4d5c-89ed-e569c28b7b70';
'9238bc7b-d38f-4a1c-9d19-31ab7bc67a2f';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'90cf9144-4811-4653-93a2-9a6780fe6aac';
'd0940029-9d3c-40be-903a-52d65393028f';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
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 = '965e3776-b966-4be8-83f7-6cd3bce5e1bd';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = 'e004df40-29f3-47ba-b39d-d3a5c444367a';
export default defineView({
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
@@ -12,7 +12,7 @@ export default defineView({
position: 0,
fields: [
{
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
universalIdentifier: '496c40c2-5766-419c-93bf-20fdad3f34bb',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
+113 -125
View File
@@ -352,9 +352,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/aix-ppc64@npm:0.27.4"
"@esbuild/aix-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/aix-ppc64@npm:0.27.3"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
@@ -366,9 +366,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm64@npm:0.27.4"
"@esbuild/android-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm64@npm:0.27.3"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
@@ -380,9 +380,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm@npm:0.27.4"
"@esbuild/android-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm@npm:0.27.3"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
@@ -394,9 +394,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-x64@npm:0.27.4"
"@esbuild/android-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-x64@npm:0.27.3"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
@@ -408,9 +408,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-arm64@npm:0.27.4"
"@esbuild/darwin-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-arm64@npm:0.27.3"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
@@ -422,9 +422,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-x64@npm:0.27.4"
"@esbuild/darwin-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-x64@npm:0.27.3"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
@@ -436,9 +436,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-arm64@npm:0.27.4"
"@esbuild/freebsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-arm64@npm:0.27.3"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
@@ -450,9 +450,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-x64@npm:0.27.4"
"@esbuild/freebsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-x64@npm:0.27.3"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
@@ -464,9 +464,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm64@npm:0.27.4"
"@esbuild/linux-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm64@npm:0.27.3"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
@@ -478,9 +478,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm@npm:0.27.4"
"@esbuild/linux-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm@npm:0.27.3"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
@@ -492,9 +492,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ia32@npm:0.27.4"
"@esbuild/linux-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ia32@npm:0.27.3"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
@@ -506,9 +506,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-loong64@npm:0.27.4"
"@esbuild/linux-loong64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-loong64@npm:0.27.3"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
@@ -520,9 +520,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-mips64el@npm:0.27.4"
"@esbuild/linux-mips64el@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-mips64el@npm:0.27.3"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
@@ -534,9 +534,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ppc64@npm:0.27.4"
"@esbuild/linux-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ppc64@npm:0.27.3"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
@@ -548,9 +548,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-riscv64@npm:0.27.4"
"@esbuild/linux-riscv64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-riscv64@npm:0.27.3"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
@@ -562,9 +562,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-s390x@npm:0.27.4"
"@esbuild/linux-s390x@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-s390x@npm:0.27.3"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
@@ -576,9 +576,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-x64@npm:0.27.4"
"@esbuild/linux-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-x64@npm:0.27.3"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
@@ -590,9 +590,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-arm64@npm:0.27.4"
"@esbuild/netbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-arm64@npm:0.27.3"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
@@ -604,9 +604,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-x64@npm:0.27.4"
"@esbuild/netbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-x64@npm:0.27.3"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
@@ -618,9 +618,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-arm64@npm:0.27.4"
"@esbuild/openbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-arm64@npm:0.27.3"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
@@ -632,9 +632,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-x64@npm:0.27.4"
"@esbuild/openbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-x64@npm:0.27.3"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
@@ -646,9 +646,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openharmony-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openharmony-arm64@npm:0.27.4"
"@esbuild/openharmony-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openharmony-arm64@npm:0.27.3"
conditions: os=openharmony & cpu=arm64
languageName: node
linkType: hard
@@ -660,9 +660,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/sunos-x64@npm:0.27.4"
"@esbuild/sunos-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/sunos-x64@npm:0.27.3"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
@@ -674,9 +674,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-arm64@npm:0.27.4"
"@esbuild/win32-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-arm64@npm:0.27.3"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
@@ -688,9 +688,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-ia32@npm:0.27.4"
"@esbuild/win32-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-ia32@npm:0.27.3"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
@@ -702,9 +702,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-x64@npm:0.27.4"
"@esbuild/win32-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-x64@npm:0.27.3"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -1541,11 +1541,11 @@ __metadata:
linkType: hard
"@types/node@npm:*":
version: 25.5.0
resolution: "@types/node@npm:25.5.0"
version: 25.3.5
resolution: "@types/node@npm:25.3.5"
dependencies:
undici-types: "npm:~7.18.0"
checksum: 10c0/70c508165b6758c4f88d4f91abca526c3985eee1985503d4c2bd994dbaf588e52ac57e571160f18f117d76e963570ac82bd20e743c18987e82564312b3b62119
checksum: 10c0/4cf0834a6f6933bf0aca6afead117ae3db3b8f02a5f7187a24f871db0fb9344e5e46573ba387bc53b7505e1e219c4c535cbe67221ced95bb5ad98573223b19d0
languageName: node
linkType: hard
@@ -3408,35 +3408,35 @@ __metadata:
linkType: hard
"esbuild@npm:^0.27.0":
version: 0.27.4
resolution: "esbuild@npm:0.27.4"
version: 0.27.3
resolution: "esbuild@npm:0.27.3"
dependencies:
"@esbuild/aix-ppc64": "npm:0.27.4"
"@esbuild/android-arm": "npm:0.27.4"
"@esbuild/android-arm64": "npm:0.27.4"
"@esbuild/android-x64": "npm:0.27.4"
"@esbuild/darwin-arm64": "npm:0.27.4"
"@esbuild/darwin-x64": "npm:0.27.4"
"@esbuild/freebsd-arm64": "npm:0.27.4"
"@esbuild/freebsd-x64": "npm:0.27.4"
"@esbuild/linux-arm": "npm:0.27.4"
"@esbuild/linux-arm64": "npm:0.27.4"
"@esbuild/linux-ia32": "npm:0.27.4"
"@esbuild/linux-loong64": "npm:0.27.4"
"@esbuild/linux-mips64el": "npm:0.27.4"
"@esbuild/linux-ppc64": "npm:0.27.4"
"@esbuild/linux-riscv64": "npm:0.27.4"
"@esbuild/linux-s390x": "npm:0.27.4"
"@esbuild/linux-x64": "npm:0.27.4"
"@esbuild/netbsd-arm64": "npm:0.27.4"
"@esbuild/netbsd-x64": "npm:0.27.4"
"@esbuild/openbsd-arm64": "npm:0.27.4"
"@esbuild/openbsd-x64": "npm:0.27.4"
"@esbuild/openharmony-arm64": "npm:0.27.4"
"@esbuild/sunos-x64": "npm:0.27.4"
"@esbuild/win32-arm64": "npm:0.27.4"
"@esbuild/win32-ia32": "npm:0.27.4"
"@esbuild/win32-x64": "npm:0.27.4"
"@esbuild/aix-ppc64": "npm:0.27.3"
"@esbuild/android-arm": "npm:0.27.3"
"@esbuild/android-arm64": "npm:0.27.3"
"@esbuild/android-x64": "npm:0.27.3"
"@esbuild/darwin-arm64": "npm:0.27.3"
"@esbuild/darwin-x64": "npm:0.27.3"
"@esbuild/freebsd-arm64": "npm:0.27.3"
"@esbuild/freebsd-x64": "npm:0.27.3"
"@esbuild/linux-arm": "npm:0.27.3"
"@esbuild/linux-arm64": "npm:0.27.3"
"@esbuild/linux-ia32": "npm:0.27.3"
"@esbuild/linux-loong64": "npm:0.27.3"
"@esbuild/linux-mips64el": "npm:0.27.3"
"@esbuild/linux-ppc64": "npm:0.27.3"
"@esbuild/linux-riscv64": "npm:0.27.3"
"@esbuild/linux-s390x": "npm:0.27.3"
"@esbuild/linux-x64": "npm:0.27.3"
"@esbuild/netbsd-arm64": "npm:0.27.3"
"@esbuild/netbsd-x64": "npm:0.27.3"
"@esbuild/openbsd-arm64": "npm:0.27.3"
"@esbuild/openbsd-x64": "npm:0.27.3"
"@esbuild/openharmony-arm64": "npm:0.27.3"
"@esbuild/sunos-x64": "npm:0.27.3"
"@esbuild/win32-arm64": "npm:0.27.3"
"@esbuild/win32-ia32": "npm:0.27.3"
"@esbuild/win32-x64": "npm:0.27.3"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
@@ -3492,7 +3492,7 @@ __metadata:
optional: true
bin:
esbuild: bin/esbuild
checksum: 10c0/2a1c2bcccda279f2afd72a7f8259860cb4483b32453d17878e1ecb4ac416b9e7c1001e7aa0a25ba4c29c1e250a3ceaae5d8bb72a119815bc8db4e9b5f5321490
checksum: 10c0/fdc3f87a3f08b3ef98362f37377136c389a0d180fda4b8d073b26ba930cf245521db0a368f119cc7624bc619248fff1439f5811f062d853576f8ffa3df8ee5f1
languageName: node
linkType: hard
@@ -3906,7 +3906,6 @@ __metadata:
"@types/react": "npm:^18.2.0"
oxlint: "npm:^0.16.0"
react: "npm:^18.2.0"
twenty-client-sdk: "portal:../../twenty-client-sdk"
twenty-sdk: "portal:../../twenty-sdk"
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
@@ -4953,9 +4952,9 @@ __metadata:
linkType: hard
"preact@npm:^10.28.3":
version: 10.29.0
resolution: "preact@npm:10.29.0"
checksum: 10c0/d111381e5b48335e3a797a03adb83521cf5e9bdf880570fb2eff4fe9da9c82e6dedcbdf54538b1ed8f60bf813a0df0f4891b03dc32140ad93f8f720a8812dd5c
version: 10.28.4
resolution: "preact@npm:10.28.4"
checksum: 10c0/712384e92d6c676c8f4c230eab81329a66acfecf700390aebfe5fc46168fa3686345d47f2292068dcc0a6d86425a3d9a3d743730e356e867a8402442afb989db
languageName: node
linkType: hard
@@ -5751,17 +5750,6 @@ __metadata:
languageName: node
linkType: hard
"twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A."
dependencies:
"@genql/cli": "npm:^3.0.3"
"@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.25.0"
graphql: "npm:^16.8.1"
languageName: node
linkType: soft
"twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A."
@@ -7,4 +7,3 @@
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -5,13 +5,13 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add http://localhost:2020 --as local
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty dev
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
@@ -21,19 +21,19 @@ Open your Twenty instance and go to `/settings/applications` section to see the
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote add http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -236,7 +236,7 @@ const handler = async (event: any) => {
},
});
// TODO: remove `as any` after running `yarn twenty dev` to regenerate the typed client
// TODO: remove `as any` after running `yarn twenty app:dev` to regenerate the typed client
const updateSummary = async (markdown: string) => {
await client.mutation({
updateCallRecording: {
@@ -1,12 +1,10 @@
import { CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/call-recording-view';
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
export default defineNavigationMenuItem({
universalIdentifier: '5248a62d-7d2e-43a7-ba45-6e8f61876a71',
name: 'Call recordings',
icon: 'IconPhone',
position: 0,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -81,7 +81,7 @@ export default defineObject({
},
{
universalIdentifier: TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT,
type: FieldType.RICH_TEXT_V2,
name: 'transcript',
label: 'Transcript',
description: 'Human-readable transcript of the call',
@@ -114,7 +114,7 @@ export default defineObject({
},
{
universalIdentifier: SUMMARY_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.RICH_TEXT,
type: FieldType.RICH_TEXT_V2,
name: 'summary',
label: 'Summary',
description: 'AI-generated summary of the call',
@@ -16,7 +16,7 @@ Use this skill when a user asks you to summarize, analyze, or extract insights f
## How to Access the Data
1. Use \`find_one_callRecording\` to fetch the call recording by its ID.
2. Read the \`transcript\` field (RICH_TEXT, markdown format) which contains the full conversation.
2. Read the \`transcript\` field (RICH_TEXT_V2, markdown format) which contains the full conversation.
3. The transcript uses the format: **Speaker Name:** spoken text
## What to Produce
@@ -1,5 +1,4 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineNavigationMenuItem({
@@ -7,7 +6,6 @@ export default defineNavigationMenuItem({
name: 'Self host user',
icon: 'IconList',
position: 1,
type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
});
-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", "generated"],
"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,2 +0,0 @@
dist
generated
-60
View File
@@ -1,60 +0,0 @@
{
"name": "twenty-client-sdk",
"version": "0.8.0-canary.5",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
"build": "npx rimraf dist && npx vite build && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
},
"exports": {
"./core": {
"types": "./dist/core/index.d.ts",
"import": "./dist/core.mjs",
"require": "./dist/core.cjs"
},
"./metadata": {
"types": "./dist/metadata/index.d.ts",
"import": "./dist/metadata.mjs",
"require": "./dist/metadata.cjs"
},
"./generate": {
"types": "./dist/generate/index.d.ts",
"import": "./dist/generate.mjs",
"require": "./dist/generate.cjs"
}
},
"typesVersions": {
"*": {
"core": [
"dist/core/index.d.ts"
],
"metadata": [
"dist/metadata/index.d.ts"
],
"generate": [
"dist/generate/index.d.ts"
]
}
},
"files": [
"dist"
],
"dependencies": {
"@genql/cli": "^3.0.3",
"@genql/runtime": "^2.10.0",
"esbuild": "^0.25.0",
"graphql": "^16.8.1"
},
"devDependencies": {
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.18"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}
-44
View File
@@ -1,44 +0,0 @@
{
"name": "twenty-client-sdk",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-client-sdk/src",
"projectType": "library",
"tags": ["scope:sdk"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
],
"parallel": false
}
},
"generate-metadata-client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/src/metadata/generated"],
"options": {
"cwd": "packages/twenty-client-sdk",
"command": "tsx scripts/generate-metadata-client.ts"
}
},
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"config": "{projectRoot}/vitest.config.ts"
}
},
"set-local-version": {},
"typecheck": {},
"lint": {}
}
}
@@ -1,65 +0,0 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getIntrospectionQuery, buildClientSchema, printSchema } from 'graphql';
import { generateMetadataClient } from '../src/generate/generate-metadata-client';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TEMPLATE_PATH = path.resolve(
__dirname,
'..',
'src',
'generate',
'twenty-client-template.ts',
);
const introspectSchema = async (url: string): Promise<string> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: getIntrospectionQuery() }),
});
const json = await response.json();
if (json.errors) {
throw new Error(
`GraphQL introspection errors: ${JSON.stringify(json.errors)}`,
);
}
return printSchema(buildClientSchema(json.data));
};
const main = async () => {
const serverUrl = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const schema = await introspectSchema(`${serverUrl}/metadata`);
const clientWrapperTemplateSource = await readFile(TEMPLATE_PATH, 'utf-8');
const outputPath = path.resolve(
__dirname,
'..',
'src',
'metadata',
'generated',
);
await generateMetadataClient({
schema,
outputPath,
clientWrapperTemplateSource,
});
console.log(`Metadata client generated at ${outputPath}`);
};
main().catch((error) => {
console.error('Failed to generate metadata client:', error);
process.exit(1);
});
@@ -1,10 +0,0 @@
// Stub — replaced at runtime by the generated client when the app
// is installed on a Twenty instance or during `twenty app:dev`.
export class CoreApiClient {
constructor() {
throw new Error(
'CoreApiClient was not generated. ' +
'Install this app on a Twenty instance or run `twenty app:dev`.',
);
}
}
@@ -1,2 +0,0 @@
// Stub — overwritten by `twenty build` or `twenty dev`
export type CoreSchema = {};
@@ -1,2 +0,0 @@
export { CoreApiClient } from './generated/index';
export * as CoreSchema from './generated/schema';
@@ -1,510 +0,0 @@
import { readFileSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { transform } from 'esbuild';
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { buildClientWrapperSource } from '../client-wrapper';
const twentyClientTemplateSource = readFileSync(
join(__dirname, '..', 'twenty-client-template.ts'),
'utf-8',
);
type TwentyClassType = new (options?: {
url?: string;
fetch?: typeof globalThis.fetch;
}) => {
query: (request: Record<string, unknown>) => Promise<unknown>;
uploadFile: (
fileBuffer: Buffer,
filename: string,
contentType: string,
fieldMetadataUniversalIdentifier: string,
) => Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}>;
};
const stubGeneratedIndexSource = `
export type QueryGenqlSelection = Record<string, unknown>
export type MutationGenqlSelection = Record<string, unknown>
export type GraphqlOperation = Record<string, unknown>
export type ClientOptions = {
url?: string
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
fetcher?: (operation: GraphqlOperation | GraphqlOperation[]) => Promise<unknown>
fetch?: typeof globalThis.fetch
batch?: unknown
}
export type Client = {
query: (request: QueryGenqlSelection & { __name?: string }) => Promise<unknown>
mutation: (
request: MutationGenqlSelection & { __name?: string },
) => Promise<unknown>
}
export class GenqlError extends Error {
constructor(
public readonly errors: unknown,
public readonly data: unknown,
) {
super('GenqlError')
}
}
export const createClient = (options: ClientOptions): Client => {
return {
query: (request) => {
return options.fetcher?.({
query: 'query',
variables: request,
})
},
mutation: (request) => {
return options.fetcher?.({
query: 'mutation',
variables: request,
})
},
}
}
`;
const createJsonResponse = ({
body,
status = 200,
statusText = 'OK',
}: {
body: unknown;
status?: number;
statusText?: string;
}) =>
new Response(JSON.stringify(body), {
status,
statusText,
headers: { 'Content-Type': 'application/json' },
});
const getAuthorizationHeaderValue = (requestInit: RequestInit | undefined) => {
return new Headers(requestInit?.headers).get('Authorization');
};
describe('Generated client wrapper auth behavior', () => {
let temporaryDir: string;
let TwentyClass: TwentyClassType;
beforeAll(async () => {
temporaryDir = await mkdtemp(join(tmpdir(), 'twenty-generated-client-'));
const wrapperSource = buildClientWrapperSource(twentyClientTemplateSource, {
apiClientName: 'MetadataApiClient',
defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`',
includeUploadFile: true,
});
const fullSource = stubGeneratedIndexSource + wrapperSource;
const transpiledModule = await transform(fullSource, {
loader: 'ts',
format: 'esm',
target: 'es2022',
});
const outputPath = join(temporaryDir, 'index.mjs');
await writeFile(outputPath, transpiledModule.code);
const generatedModule = await import(
`${pathToFileURL(outputPath).href}?t=${Date.now()}`
);
TwentyClass = generatedModule.MetadataApiClient as TwentyClassType;
});
beforeEach(() => {
delete process.env.TWENTY_APP_ACCESS_TOKEN;
delete process.env.TWENTY_API_KEY;
delete (globalThis as Record<string, unknown>)
.frontComponentHostCommunicationApi;
});
afterAll(async () => {
if (temporaryDir) {
await rm(temporaryDir, { recursive: true, force: true });
}
});
it('uses TWENTY_APP_ACCESS_TOKEN before TWENTY_API_KEY', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'application-token';
process.env.TWENTY_API_KEY = 'api-key-token';
const capturedAuthorizationHeaders: string[] = [];
const fetchMock = vi.fn(
async (_url: string | URL | Request, requestInit?: RequestInit) => {
const authorizationHeaderValue =
getAuthorizationHeaderValue(requestInit);
if (authorizationHeaderValue) {
capturedAuthorizationHeaders.push(authorizationHeaderValue);
}
return createJsonResponse({
body: { data: { record: { id: 'record-id' } } },
});
},
);
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
await twentyClient.query({ record: { id: true } });
expect(capturedAuthorizationHeaders).toEqual(['Bearer application-token']);
});
it('falls back to TWENTY_API_KEY when TWENTY_APP_ACCESS_TOKEN is absent', async () => {
process.env.TWENTY_API_KEY = 'legacy-api-key-token';
const capturedAuthorizationHeaders: string[] = [];
const fetchMock = vi.fn(
async (_url: string | URL | Request, requestInit?: RequestInit) => {
const authorizationHeaderValue =
getAuthorizationHeaderValue(requestInit);
if (authorizationHeaderValue) {
capturedAuthorizationHeaders.push(authorizationHeaderValue);
}
return createJsonResponse({
body: { data: { record: { id: 'record-id' } } },
});
},
);
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
await twentyClient.query({ record: { id: true } });
expect(capturedAuthorizationHeaders).toEqual([
'Bearer legacy-api-key-token',
]);
});
it('refreshes and retries once after auth error when refresh callback is available', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
process.env.TWENTY_API_KEY = 'legacy-api-key-token';
const requestAccessTokenRefresh = vi
.fn<() => Promise<string>>()
.mockResolvedValue('fresh-token');
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
{
requestAccessTokenRefresh,
};
const fetchMock = vi.fn(
async (_url: string | URL | Request, requestInit?: RequestInit) => {
const authorizationHeaderValue =
getAuthorizationHeaderValue(requestInit);
if (authorizationHeaderValue === 'Bearer stale-token') {
return createJsonResponse({
body: {
errors: [
{
extensions: { code: 'UNAUTHENTICATED' },
message: 'Unauthorized',
},
],
},
});
}
return createJsonResponse({
body: { data: { record: { id: 'record-id' } } },
});
},
);
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
const queryResult = await twentyClient.query({ record: { id: true } });
expect(queryResult).toEqual({ data: { record: { id: 'record-id' } } });
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(process.env.TWENTY_APP_ACCESS_TOKEN).toBe('fresh-token');
expect(process.env.TWENTY_API_KEY).toBe('legacy-api-key-token');
});
it('deduplicates concurrent token refresh requests', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
const requestAccessTokenRefresh = vi
.fn<() => Promise<string>>()
.mockImplementation(async () => {
await Promise.resolve();
return 'fresh-token';
});
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
{
requestAccessTokenRefresh,
};
const fetchMock = vi.fn(
async (_url: string | URL | Request, requestInit?: RequestInit) => {
const authorizationHeaderValue =
getAuthorizationHeaderValue(requestInit);
if (authorizationHeaderValue === 'Bearer stale-token') {
return createJsonResponse({
body: {
errors: [
{
extensions: { code: 'UNAUTHENTICATED' },
message: 'Unauthorized',
},
],
},
});
}
return createJsonResponse({
body: { data: { record: { id: 'record-id' } } },
});
},
);
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
await Promise.all([
twentyClient.query({ record: { id: true } }),
twentyClient.query({ record: { id: true } }),
]);
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(4);
});
it('retries uploadFile once after 401 when refresh callback is available', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
const requestAccessTokenRefresh = vi
.fn<() => Promise<string>>()
.mockResolvedValue('fresh-token');
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
{
requestAccessTokenRefresh,
};
const fetchMock = vi.fn(
async (_url: string | URL | Request, requestInit?: RequestInit) => {
const authorizationHeaderValue =
getAuthorizationHeaderValue(requestInit);
if (authorizationHeaderValue === 'Bearer stale-token') {
return createJsonResponse({
body: { message: 'Unauthorized' },
status: 401,
statusText: 'Unauthorized',
});
}
return createJsonResponse({
body: {
data: {
uploadFilesFieldFileByUniversalIdentifier: {
id: 'uploaded-file-id',
path: 'test/path.txt',
size: 10,
createdAt: '2026-02-24T00:00:00.000Z',
url: 'https://example.com/test/path.txt',
},
},
},
});
},
);
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
const uploadResult = await twentyClient.uploadFile(
Buffer.from('content'),
'test.txt',
'text/plain',
'field-uuid',
);
expect(uploadResult.id).toBe('uploaded-file-id');
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('bubbles auth error when refresh callback throws', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
const requestAccessTokenRefresh = vi
.fn<() => Promise<string>>()
.mockRejectedValue(new Error('refresh failed'));
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
{
requestAccessTokenRefresh,
};
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const fetchMock = vi.fn(async () => {
return createJsonResponse({
body: { message: 'Unauthorized' },
status: 401,
statusText: 'Unauthorized',
});
});
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow(
'Unauthorized',
);
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Twenty client: token refresh failed',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
it('bubbles auth error when refresh callback returns an empty token', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
const requestAccessTokenRefresh = vi
.fn<() => Promise<string>>()
.mockResolvedValue('');
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
{
requestAccessTokenRefresh,
};
const fetchMock = vi.fn(async () => {
return createJsonResponse({
body: { message: 'Unauthorized' },
status: 401,
statusText: 'Unauthorized',
});
});
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow(
'Unauthorized',
);
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('bubbles auth error without retry when refresh callback is unavailable', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
const fetchMock = vi.fn(async () => {
return createJsonResponse({
body: { message: 'Unauthorized' },
status: 401,
statusText: 'Unauthorized',
});
});
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
fetch: fetchMock as unknown as typeof globalThis.fetch,
});
await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow(
'Unauthorized',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('calls global fetch with globalThis binding when no custom fetch is provided', async () => {
process.env.TWENTY_APP_ACCESS_TOKEN = 'application-token';
const originalFetch = globalThis.fetch;
const capturedFetchThisValues: unknown[] = [];
const fetchMock = vi.fn(function (
this: unknown,
_url: string | URL | Request,
_requestInit?: RequestInit,
) {
capturedFetchThisValues.push(this);
return Promise.resolve(
createJsonResponse({
body: { data: { record: { id: 'record-id' } } },
}),
);
}) as unknown as typeof globalThis.fetch;
globalThis.fetch = fetchMock;
try {
const twentyClient = new TwentyClass({
url: 'https://example.com/graphql',
});
await twentyClient.query({ record: { id: true } });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(capturedFetchThisValues).toEqual([globalThis]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
@@ -1,51 +0,0 @@
type ClientWrapperOptions = {
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
};
const STRIPPED_TYPES_START = '// __STRIPPED_DURING_INJECTION_START__';
const STRIPPED_TYPES_END = '// __STRIPPED_DURING_INJECTION_END__';
const UPLOAD_FILE_START = '// __UPLOAD_FILE_START__';
const UPLOAD_FILE_END = '// __UPLOAD_FILE_END__';
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const buildClientWrapperSource = (
templateSource: string,
options: ClientWrapperOptions,
): string => {
let source = templateSource;
source = source.replace(
new RegExp(
`${escapeRegExp(STRIPPED_TYPES_START)}[\\s\\S]*?${escapeRegExp(STRIPPED_TYPES_END)}\\n?`,
),
'',
);
source = source.replace("'__TWENTY_DEFAULT_URL__'", options.defaultUrl);
source = source.replace(/TwentyGeneratedClient/g, options.apiClientName);
if (!options.includeUploadFile) {
source = source.replace(
new RegExp(
`\\s*${escapeRegExp(UPLOAD_FILE_START)}[\\s\\S]*?${escapeRegExp(UPLOAD_FILE_END)}\\n?`,
),
'\n',
);
} else {
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_START)}\\n`),
'\n',
);
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_END)}\\n`),
'\n',
);
}
return `\n// ${options.apiClientName} (auto-injected by twenty-client-sdk)\n${source}`;
};
@@ -1,41 +0,0 @@
import { cp, mkdir, readdir, rename as fsRename, rm } from 'node:fs/promises';
import { join } from 'node:path';
export const ensureDir = (dirPath: string) =>
mkdir(dirPath, { recursive: true });
export const emptyDir = async (dirPath: string): Promise<void> => {
let entries: string[];
try {
entries = await readdir(dirPath);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
await mkdir(dirPath, { recursive: true });
return;
}
throw error;
}
await Promise.all(
entries.map((entry) =>
rm(join(dirPath, entry), { recursive: true, force: true }),
),
);
};
export const move = async (src: string, dest: string): Promise<void> => {
try {
await fsRename(src, dest);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
await cp(src, dest, { recursive: true });
await rm(src, { recursive: true, force: true });
} else {
throw error;
}
}
};
export const remove = (filePath: string) =>
rm(filePath, { recursive: true, force: true });
@@ -1,122 +0,0 @@
import { appendFile, copyFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { build } from 'esbuild';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir, move, remove } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const GENERATED_CORE_DIR = 'core/generated';
// Generates the core API client from a GraphQL schema string.
// Produces both TypeScript source and compiled ESM/CJS bundles.
export const generateCoreClientFromSchema = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
const tempPath = `${outputPath}.tmp`;
await ensureDir(tempPath);
await emptyDir(tempPath);
try {
await generate({
schema,
output: tempPath,
scalarTypes: COMMON_SCALAR_TYPES,
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: true,
});
await appendFile(join(tempPath, 'index.ts'), clientContent);
await remove(outputPath);
await move(tempPath, outputPath);
await compileGeneratedClient(outputPath);
} catch (error) {
await remove(tempPath);
throw error;
}
};
// Generates the core client and replaces the pre-built stub inside
// an installed twenty-client-sdk package (dist/core.mjs and dist/core.cjs).
// Generated source files are kept in dist/generated-core/ for consumers
// that need the raw .ts files (e.g. the app:dev upload step).
export const replaceCoreClient = async ({
packageRoot,
schema,
}: {
packageRoot: string;
schema: string;
}): Promise<void> => {
const generatedPath = join(packageRoot, 'dist', GENERATED_CORE_DIR);
await generateCoreClientFromSchema({ schema, outputPath: generatedPath });
await copyFile(
join(generatedPath, 'index.mjs'),
join(packageRoot, 'dist', 'core.mjs'),
);
await copyFile(
join(generatedPath, 'index.cjs'),
join(packageRoot, 'dist', 'core.cjs'),
);
};
const compileGeneratedClient = async (generatedDir: string): Promise<void> => {
const entryPoint = join(generatedDir, 'index.ts');
const outfile = join(generatedDir, 'index.mjs');
await build({
entryPoints: [entryPoint],
outfile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await build({
entryPoints: [entryPoint],
outfile: join(generatedDir, 'index.cjs'),
bundle: true,
format: 'cjs',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await writeFile(
join(generatedDir, 'package.json'),
JSON.stringify(
{ type: 'module', main: 'index.mjs', module: 'index.mjs' },
null,
2,
),
);
};
@@ -1,48 +0,0 @@
import { appendFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const generateMetadataClient = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
await ensureDir(outputPath);
await emptyDir(outputPath);
await generate({
schema,
output: outputPath,
scalarTypes: {
...COMMON_SCALAR_TYPES,
Upload: 'File',
},
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'MetadataApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
includeUploadFile: true,
});
await appendFile(join(outputPath, 'index.ts'), clientContent);
};
@@ -1,6 +0,0 @@
export {
GENERATED_CORE_DIR,
generateCoreClientFromSchema,
replaceCoreClient,
} from './generate-core-client';
export { generateMetadataClient } from './generate-metadata-client';
@@ -1,4 +0,0 @@
declare module '*.ts?raw' {
const content: string;
export default content;
}
@@ -1,434 +0,0 @@
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
// __STRIPPED_DURING_INJECTION_START__
type QueryGenqlSelection = Record<string, unknown>;
type MutationGenqlSelection = Record<string, unknown>;
type GraphqlOperation = Record<string, unknown>;
type ClientOptions = {
url?: string;
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
fetcher?: (
operation: GraphqlOperation | GraphqlOperation[],
) => Promise<unknown>;
fetch?: typeof globalThis.fetch;
batch?: unknown;
};
type Client = {
query: (
request: QueryGenqlSelection & { __name?: string },
) => Promise<unknown>;
mutation: (
request: MutationGenqlSelection & { __name?: string },
) => Promise<unknown>;
};
declare function createClient(options: ClientOptions): Client;
declare class GenqlError extends Error {
constructor(errors: unknown, data: unknown);
}
// __STRIPPED_DURING_INJECTION_END__
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type TwentyGeneratedClientOptions = ClientOptions;
type ProcessEnvironment = Record<string, string | undefined>;
type GraphqlErrorPayloadEntry = {
message?: string;
extensions?: { code?: string };
};
type GraphqlResponsePayload = {
data?: Record<string, unknown>;
errors?: GraphqlErrorPayloadEntry[];
};
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
};
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (
globalThis as { process?: { env?: ProcessEnvironment } }
).process;
return processObject?.env ?? {};
};
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
};
const getTokenFromHeaders = (
headers: HeadersInit | undefined,
): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(
headers.get('Authorization') ?? undefined,
);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
};
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((graphqlError) => {
return (
graphqlError.extensions?.code === 'UNAUTHENTICATED' ||
graphqlError.message?.toLowerCase() === 'unauthorized'
);
});
};
const defaultOptions: TwentyGeneratedClientOptions = {
url: '__TWENTY_DEFAULT_URL__',
headers: {
'Content-Type': 'application/json',
},
};
export class TwentyGeneratedClient {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: TwentyGeneratedClientOptions) {
const merged: TwentyGeneratedClientOptions = {
...defaultOptions,
...options,
};
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation =
customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > app access token > api key (legacy).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
// __UPLOAD_FILE_START__
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: `mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}`,
variables: {
file: null,
fieldMetadataUniversalIdentifier,
},
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append(
'0',
new Blob([fileBuffer as BlobPart], { type: contentType }),
filename,
);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
};
}
// __UPLOAD_FILE_END__
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global `fetch` function is not available, ' +
'pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', `Bearer ${token}`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body:
operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
};
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`${response.statusText}: ${response.rawBody}`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>;
};
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((refreshError: unknown) => {
console.error('Twenty client: token refresh failed', refreshError);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
}
}
@@ -1,482 +0,0 @@
// @ts-nocheck
import type {
QueryGenqlSelection,
Query,
MutationGenqlSelection,
Mutation,
SubscriptionGenqlSelection,
Subscription,
} from './schema'
import {
linkTypeMap,
createClient as createClientOriginal,
generateGraphqlOperation,
type FieldsSelection,
type GraphqlOperation,
type ClientOptions,
GenqlError,
} from './runtime'
export type { FieldsSelection } from './runtime'
export { GenqlError }
import types from './types'
export * from './schema'
const typeMap = linkTypeMap(types as any)
export interface Client {
query<R extends QueryGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Query, R>>
mutation<R extends MutationGenqlSelection>(
request: R & { __name?: string },
): Promise<FieldsSelection<Mutation, R>>
}
export const createClient = function (options?: ClientOptions): Client {
return createClientOriginal({
url: undefined,
...options,
queryRoot: typeMap.Query!,
mutationRoot: typeMap.Mutation!,
subscriptionRoot: typeMap.Subscription!,
}) as any
}
export const everything = {
__scalar: true,
}
export type QueryResult<fields extends QueryGenqlSelection> = FieldsSelection<
Query,
fields
>
export const generateQueryOp: (
fields: QueryGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation('query', typeMap.Query!, fields as any)
}
export type MutationResult<fields extends MutationGenqlSelection> =
FieldsSelection<Mutation, fields>
export const generateMutationOp: (
fields: MutationGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation('mutation', typeMap.Mutation!, fields as any)
}
export type SubscriptionResult<fields extends SubscriptionGenqlSelection> =
FieldsSelection<Subscription, fields>
export const generateSubscriptionOp: (
fields: SubscriptionGenqlSelection & { __name?: string },
) => GraphqlOperation = function (fields) {
return generateGraphqlOperation(
'subscription',
typeMap.Subscription!,
fields as any,
)
}
// MetadataApiClient (auto-injected by twenty-client-sdk)
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type MetadataApiClientOptions = ClientOptions;
type ProcessEnvironment = Record<string, string | undefined>;
type GraphqlErrorPayloadEntry = {
message?: string;
extensions?: { code?: string };
};
type GraphqlResponsePayload = {
data?: Record<string, unknown>;
errors?: GraphqlErrorPayloadEntry[];
};
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
};
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (
globalThis as { process?: { env?: ProcessEnvironment } }
).process;
return processObject?.env ?? {};
};
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
};
const getTokenFromHeaders = (
headers: HeadersInit | undefined,
): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(
headers.get('Authorization') ?? undefined,
);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
};
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((graphqlError) => {
return (
graphqlError.extensions?.code === 'UNAUTHENTICATED' ||
graphqlError.message?.toLowerCase() === 'unauthorized'
);
});
};
const defaultOptions: MetadataApiClientOptions = {
url: `${process.env.TWENTY_API_URL}/metadata`,
headers: {
'Content-Type': 'application/json',
},
};
export class MetadataApiClient {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: MetadataApiClientOptions) {
const merged: MetadataApiClientOptions = {
...defaultOptions,
...options,
};
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation =
customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > app access token > api key (legacy).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: `mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}`,
variables: {
file: null,
fieldMetadataUniversalIdentifier,
},
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append(
'0',
new Blob([fileBuffer as BlobPart], { type: contentType }),
filename,
);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
};
}
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global `fetch` function is not available, ' +
'pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', `Bearer ${token}`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body:
operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
};
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`${response.statusText}: ${response.rawBody}`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>;
};
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((refreshError: unknown) => {
console.error('Twenty client: token refresh failed', refreshError);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
}
}

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