Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01c8a64f5b | ||
|
|
cb85a1b5a3 | ||
|
|
ef8789fad6 | ||
|
|
ee479001a1 | ||
|
|
6af7e32c54 | ||
|
|
d246b16063 | ||
|
|
0b2f435b3e | ||
|
|
039a04bc2f | ||
|
|
fe1377f18b | ||
|
|
58189e1c05 | ||
|
|
a3f468ef98 | ||
|
|
4d74cc0a28 | ||
|
|
05c0713474 | ||
|
|
28de34cf20 | ||
|
|
e8cb086b64 | ||
|
|
f651413297 | ||
|
|
ccaea1ba36 | ||
|
|
a51b5ed589 | ||
|
|
6efd9ad26e | ||
|
|
c407341912 | ||
|
|
81fc960712 | ||
|
|
cb44b22e15 | ||
|
|
9498a60f74 | ||
|
|
191a277ddf | ||
|
|
7f1814805d | ||
|
|
34b81adce8 | ||
|
|
cd2e08b912 | ||
|
|
fb21f3ccf5 | ||
|
|
281bb6d783 | ||
|
|
c96c034908 | ||
|
|
5efe69f8d3 | ||
|
|
c2b058a6a7 | ||
|
|
22c9693ce5 | ||
|
|
50ea560e57 | ||
|
|
56056b885d | ||
|
|
db9da3194f | ||
|
|
68f5e70ade | ||
|
|
08077476f3 | ||
|
|
6f0ac88e20 | ||
|
|
17424320e3 | ||
|
|
6360fb3bce | ||
|
|
da1b1f1cbc | ||
|
|
31718d163c | ||
|
|
f47608de07 | ||
|
|
695518a15e | ||
|
|
34ab72c460 | ||
|
|
9eba54134d | ||
|
|
34832815e6 | ||
|
|
7a341c6475 | ||
|
|
1c297e5ace | ||
|
|
c3a3cf9c67 | ||
|
|
8ef99671e2 | ||
|
|
e63a23ea00 | ||
|
|
39b9ae6a76 | ||
|
|
afeef8e04a | ||
|
|
c7f6036a47 | ||
|
|
ec5ab2b84a | ||
|
|
b171a23216 | ||
|
|
f771b13a20 | ||
|
|
92635b960b | ||
|
|
2e015ee68d | ||
|
|
82611de9b6 | ||
|
|
36c7c99e34 | ||
|
|
160a80cbcb | ||
|
|
052aecccc7 | ||
|
|
b651a74b1f | ||
|
|
29979f535d | ||
|
|
b1e449d764 | ||
|
|
c28e637ea7 | ||
|
|
b732b2efd4 | ||
|
|
3c5796bdb0 | ||
|
|
cfefe9273b | ||
|
|
d126d54bbc | ||
|
|
5041b3e14b | ||
|
|
1e2b31b040 | ||
|
|
340a01567a | ||
|
|
4985270e69 | ||
|
|
b87762b1c2 | ||
|
|
578d990b9c | ||
|
|
dfe9cb4346 | ||
|
|
91374262f2 | ||
|
|
7335d352a9 | ||
|
|
bfdbc93b1c | ||
|
|
3abef48663 | ||
|
|
441a9464d1 | ||
|
|
72dd3af155 | ||
|
|
6c1db2e7fb | ||
|
|
511d1bd7ab | ||
|
|
d47ddad4c5 | ||
|
|
33a474c8e6 | ||
|
|
cc247c2e8e | ||
|
|
4d6c8db205 | ||
|
|
a8dad6c882 | ||
|
|
895bb58fc6 | ||
|
|
4fbe0a92ae | ||
|
|
5f0d6553f6 | ||
|
|
13ea3ec75c | ||
|
|
e25ea6069d | ||
|
|
f7ef41959b | ||
|
|
ba0108944f | ||
|
|
03c94727be | ||
|
|
bf22373315 | ||
|
|
e1374e34a7 | ||
|
|
523289efad | ||
|
|
e6bb39deea | ||
|
|
790a58945b | ||
|
|
3295f5ee07 | ||
|
|
2bfd2f6b85 | ||
|
|
5de269a64e | ||
|
|
cc7131b0b5 | ||
|
|
fb34d2ce80 | ||
|
|
42d9e25bf2 | ||
|
|
b642d1b114 | ||
|
|
766f956a15 | ||
|
|
981e83ecb1 | ||
|
|
98c21f958a | ||
|
|
4a099ed097 | ||
|
|
01af7bc7fb | ||
|
|
d5b41b2801 | ||
|
|
37640521d5 | ||
|
|
0af7760441 | ||
|
|
a6519f2c97 | ||
|
|
5c99d7205f | ||
|
|
4ea2e32366 | ||
|
|
16451ee2ee | ||
|
|
96f3ff0e90 |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git stash:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,13 @@ 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.
|
||||
Pulls the specified semver image tag from Docker Hub.
|
||||
Accepts "latest" (pulls the latest Docker Hub image, checks out main) or a
|
||||
semver tag (e.g., v0.40.0).
|
||||
Designed to be consumed from external repositories (e.g., twenty-app).
|
||||
|
||||
inputs:
|
||||
twenty-version:
|
||||
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
|
||||
description: 'Twenty Docker Hub image tag — either "latest" or a semver tag (e.g., v0.40.0).'
|
||||
required: true
|
||||
twenty-repository:
|
||||
description: 'Twenty repository to checkout docker compose files from.'
|
||||
@@ -30,12 +31,19 @@ outputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Validate version
|
||||
- name: Resolve version
|
||||
id: resolve
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ inputs.twenty-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'"
|
||||
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'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -43,7 +51,7 @@ runs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ inputs.twenty-repository }}
|
||||
ref: ${{ inputs.twenty-version }}
|
||||
ref: ${{ steps.resolve.outputs.git-ref }}
|
||||
token: ${{ inputs.github-token }}
|
||||
sparse-checkout: |
|
||||
packages/twenty-docker
|
||||
@@ -56,7 +64,7 @@ runs:
|
||||
run: |
|
||||
cp .env.example .env
|
||||
echo "" >> .env
|
||||
echo "TAG=${{ inputs.twenty-version }}" >> .env
|
||||
echo "TAG=${{ steps.resolve.outputs.docker-tag }}" >> .env
|
||||
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
|
||||
echo "SERVER_URL=http://localhost:3000" >> .env
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ packages:
|
||||
'twenty-sdk':
|
||||
access: $all
|
||||
publish: $all
|
||||
'twenty-client-sdk':
|
||||
access: $all
|
||||
publish: $all
|
||||
'create-twenty-app':
|
||||
access: $all
|
||||
publish: $all
|
||||
|
||||
@@ -36,6 +36,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
@@ -50,10 +53,16 @@ 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"
|
||||
@@ -143,7 +152,6 @@ 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: |
|
||||
@@ -296,7 +304,6 @@ 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: |
|
||||
@@ -395,6 +402,12 @@ 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
|
||||
|
||||
@@ -21,10 +21,12 @@ jobs:
|
||||
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:
|
||||
@@ -35,6 +37,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
@@ -49,8 +54,13 @@ jobs:
|
||||
--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
|
||||
@@ -64,12 +74,13 @@ jobs:
|
||||
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 twenty-sdk create-twenty-app --releaseVersion=$CI_VERSION
|
||||
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
npx nx build twenty-sdk
|
||||
npx nx build create-twenty-app
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx build $pkg
|
||||
done
|
||||
|
||||
- name: Install and start Verdaccio
|
||||
run: |
|
||||
@@ -86,11 +97,13 @@ jobs:
|
||||
|
||||
- name: Publish packages to local registry
|
||||
run: |
|
||||
npm set //localhost:4873/:_authToken "ci-auth-token"
|
||||
yarn config set npmRegistryServer http://localhost:4873
|
||||
yarn config set unsafeHttpWhitelist --json '["localhost"]'
|
||||
yarn config set npmAuthToken ci-auth-token
|
||||
|
||||
for pkg in twenty-sdk create-twenty-app; do
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
cd packages/$pkg
|
||||
npm publish --registry http://localhost:4873 --tag ci
|
||||
yarn npm publish --tag ci
|
||||
cd ../..
|
||||
done
|
||||
|
||||
@@ -153,11 +166,15 @@ jobs:
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000
|
||||
|
||||
- name: Build scaffolded app
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty build
|
||||
test -d .twenty/output
|
||||
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: |
|
||||
@@ -166,6 +183,13 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -26,6 +26,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
@@ -40,6 +43,9 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
|
||||
@@ -57,6 +57,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
@@ -71,6 +74,9 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
packages/twenty-server/**
|
||||
packages/twenty-front/src/generated/**
|
||||
packages/twenty-front/src/generated-metadata/**
|
||||
packages/twenty-sdk/src/clients/generated/metadata/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-emails/**
|
||||
packages/twenty-shared/**
|
||||
|
||||
@@ -84,6 +84,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
@@ -98,6 +101,9 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
@@ -122,7 +128,6 @@ 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=$?
|
||||
@@ -177,14 +182,14 @@ jobs:
|
||||
HAS_ERRORS=true
|
||||
fi
|
||||
|
||||
npx nx run twenty-sdk:generate-metadata-client
|
||||
npx nx run twenty-client-sdk:generate-metadata-client
|
||||
|
||||
if ! git diff --quiet -- packages/twenty-sdk/src/clients/generated/metadata; then
|
||||
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-sdk:generate-metadata-client' and commit the changes."
|
||||
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."
|
||||
echo ""
|
||||
echo "The following SDK metadata client changes were detected:"
|
||||
echo "==================================================="
|
||||
git diff -- packages/twenty-sdk/src/clients/generated/metadata
|
||||
git diff -- packages/twenty-client-sdk/src/metadata/generated
|
||||
echo "==================================================="
|
||||
echo ""
|
||||
HAS_ERRORS=true
|
||||
@@ -227,6 +232,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
@@ -241,10 +249,16 @@ 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"
|
||||
|
||||
@@ -27,6 +27,11 @@ 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..."
|
||||
@@ -97,6 +102,11 @@ 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: Create frontend placeholder
|
||||
run: |
|
||||
mkdir -p packages/twenty-front/build
|
||||
|
||||
@@ -27,6 +27,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
|
||||
@@ -30,6 +30,9 @@ jobs:
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
@@ -44,6 +47,9 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
|
||||
@@ -17,6 +17,12 @@ 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..."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
**/**/.env
|
||||
.DS_Store
|
||||
/.idea
|
||||
.claude/settings.json
|
||||
**/**/node_modules/
|
||||
.cache
|
||||
|
||||
|
||||
+940
File diff suppressed because one or more lines are too long
Vendored
-942
File diff suppressed because one or more lines are too long
+1
-1
@@ -6,4 +6,4 @@ enableInlineHunks: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
|
||||
+5
-2
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"license": "AGPL-3.0",
|
||||
"name": "twenty",
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"packageManager": "yarn@4.13.0",
|
||||
"resolutions": {
|
||||
"graphql": "16.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
@@ -203,14 +203,17 @@
|
||||
"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-apps",
|
||||
"packages/twenty-cli",
|
||||
"packages/create-twenty-app",
|
||||
"packages/twenty-oxlint-rules"
|
||||
"packages/twenty-oxlint-rules",
|
||||
"packages/twenty-companion"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
|
||||
@@ -41,9 +41,10 @@ cd my-twenty-app
|
||||
|
||||
# Or do it manually:
|
||||
yarn twenty server start # Start local Twenty server
|
||||
yarn twenty remote add --local # Authenticate via OAuth
|
||||
yarn twenty remote add http://localhost:2020 --as local # Authenticate via OAuth
|
||||
|
||||
# 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
|
||||
|
||||
# Watch your application's function logs
|
||||
@@ -121,21 +122,13 @@ 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`).
|
||||
|
||||
### How to use a local Twenty instance
|
||||
|
||||
If you're already running a local Twenty instance, you can connect to it instead of using Docker. Pass the port your local server is listening on (default: `3000`):
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-app --port 3000
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- Run `yarn twenty help` to see all available commands.
|
||||
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth.
|
||||
- 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` (for workspace data via `/graphql`) 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, MetadataApiClient } from 'twenty-sdk/clients'`.
|
||||
- `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'`.
|
||||
|
||||
## Build and publish your application
|
||||
|
||||
@@ -176,7 +169,7 @@ Our team reviews contributions for quality, security, and reusability before mer
|
||||
## 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 --local`.
|
||||
- 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.8.0-canary.2",
|
||||
"version": "0.8.0-canary.6",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
@@ -36,6 +36,7 @@
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.startcase": "^4.4.0",
|
||||
"twenty-sdk": "workspace:*",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -45,7 +46,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -31,10 +31,6 @@ const program = new Command(packageJson.name)
|
||||
'--skip-local-instance',
|
||||
'Skip the local Twenty instance setup prompt',
|
||||
)
|
||||
.option(
|
||||
'-p, --port <port>',
|
||||
'Port of an existing Twenty server (skips Docker setup)',
|
||||
)
|
||||
.helpOption('-h, --help', 'Display this help message.')
|
||||
.action(
|
||||
async (
|
||||
@@ -46,7 +42,6 @@ const program = new Command(packageJson.name)
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
skipLocalInstance?: boolean;
|
||||
port?: string;
|
||||
},
|
||||
) => {
|
||||
const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean);
|
||||
@@ -76,8 +71,6 @@ const program = new Command(packageJson.name)
|
||||
|
||||
const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive';
|
||||
|
||||
const port = options?.port ? parseInt(options.port, 10) : undefined;
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
mode,
|
||||
@@ -85,7 +78,6 @@ const program = new Command(packageJson.name)
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
skipLocalInstance: options?.skipLocalInstance,
|
||||
port,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { basename } from 'path';
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { install } from '@/utils/install';
|
||||
import {
|
||||
type LocalInstanceResult,
|
||||
setupLocalInstance,
|
||||
} from '@/utils/setup-local-instance';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import { execSync } from 'node:child_process';
|
||||
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 {
|
||||
@@ -29,7 +29,6 @@ type CreateAppOptions = {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
skipLocalInstance?: boolean;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
export class CreateAppCommand {
|
||||
@@ -60,17 +59,22 @@ export class CreateAppCommand {
|
||||
|
||||
await tryGitInit(appDirectory);
|
||||
|
||||
let localResult: LocalInstanceResult = { running: false };
|
||||
let serverResult: ServerStartResult | undefined;
|
||||
|
||||
if (!options.skipLocalInstance) {
|
||||
localResult = await setupLocalInstance(appDirectory, options.port);
|
||||
const startResult = await serverStart({
|
||||
onProgress: (message: string) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (localResult.running && localResult.serverUrl) {
|
||||
await this.connectToLocal(appDirectory, localResult.serverUrl);
|
||||
if (startResult.success) {
|
||||
serverResult = startResult.data;
|
||||
await this.connectToLocal(serverResult.url);
|
||||
} else {
|
||||
console.log(chalk.yellow(`\n${startResult.error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
this.logSuccess(appDirectory, localResult);
|
||||
this.logSuccess(appDirectory, serverResult);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('\nCreate application failed:'),
|
||||
@@ -197,19 +201,24 @@ export class CreateAppCommand {
|
||||
);
|
||||
}
|
||||
|
||||
private async connectToLocal(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
): Promise<void> {
|
||||
private async connectToLocal(serverUrl: string): Promise<void> {
|
||||
try {
|
||||
execSync(`yarn twenty remote add ${serverUrl} --as local`, {
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
const result = await authLoginOAuth({
|
||||
apiUrl: serverUrl,
|
||||
remote: 'local',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -217,17 +226,17 @@ export class CreateAppCommand {
|
||||
|
||||
private logSuccess(
|
||||
appDirectory: string,
|
||||
localResult: LocalInstanceResult,
|
||||
serverResult?: ServerStartResult,
|
||||
): void {
|
||||
const dirName = basename(appDirectory);
|
||||
|
||||
console.log(chalk.blue('\nApplication created. Next steps:'));
|
||||
console.log(chalk.gray(`- cd ${dirName}`));
|
||||
|
||||
if (!localResult.running) {
|
||||
if (!serverResult) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'- yarn twenty remote add --local # Authenticate with Twenty',
|
||||
'- yarn twenty remote add # Authenticate with Twenty',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,9 @@ 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');
|
||||
});
|
||||
|
||||
@@ -362,11 +365,21 @@ 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);
|
||||
@@ -448,11 +461,21 @@ 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);
|
||||
@@ -480,7 +503,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
});
|
||||
|
||||
describe('selective examples', () => {
|
||||
it('should create only front component when only that option is enabled', async () => {
|
||||
it('should create front component and page layout when only front component option is enabled', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
@@ -506,6 +529,11 @@ 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);
|
||||
@@ -545,6 +573,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, 'objects', 'example-object.ts')),
|
||||
).toBe(false);
|
||||
|
||||
@@ -47,15 +47,17 @@ describe('scaffoldIntegrationTest', () => {
|
||||
const content = await fs.readFile(testPath, 'utf8');
|
||||
|
||||
expect(content).toContain(
|
||||
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
|
||||
"import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'",
|
||||
);
|
||||
expect(content).toContain(
|
||||
"import { MetadataApiClient } from 'twenty-sdk/clients'",
|
||||
"import { MetadataApiClient } from 'twenty-client-sdk/metadata'",
|
||||
);
|
||||
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');
|
||||
@@ -136,7 +138,7 @@ describe('scaffoldIntegrationTest', () => {
|
||||
expect(content).toContain('yarn install --immutable');
|
||||
expect(content).toContain('yarn test');
|
||||
expect(content).toContain('TWENTY_API_URL');
|
||||
expect(content).toContain('TWENTY_TEST_API_KEY');
|
||||
expect(content).toContain('TWENTY_API_KEY');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ export const copyBaseApplicationProject = async ({
|
||||
|
||||
await createGitignore(appDirectory);
|
||||
|
||||
await createNvmrc(appDirectory);
|
||||
|
||||
await createPublicAssetDirectory(appDirectory);
|
||||
|
||||
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
|
||||
@@ -69,6 +71,12 @@ 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) {
|
||||
@@ -77,6 +85,12 @@ 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) {
|
||||
@@ -186,6 +200,10 @@ 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,
|
||||
@@ -230,19 +248,59 @@ const createDefaultFrontComponent = async ({
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineFrontComponent } from 'twenty-sdk';
|
||||
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}';
|
||||
|
||||
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: '${universalIdentifier}',
|
||||
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'hello-world-front-component',
|
||||
description: 'A sample front component',
|
||||
component: HelloWorld,
|
||||
@@ -253,6 +311,56 @@ 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,
|
||||
@@ -288,6 +396,58 @@ 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,
|
||||
@@ -615,6 +775,7 @@ const createPackageJson = async ({
|
||||
'react-dom': '^19.0.0',
|
||||
oxlint: '^0.16.0',
|
||||
'twenty-sdk': createTwentyAppPackageJson.version,
|
||||
'twenty-client-sdk': createTwentyAppPackageJson.version,
|
||||
};
|
||||
|
||||
if (includeExampleIntegrationTest) {
|
||||
@@ -633,6 +794,7 @@ const createPackageJson = async ({
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
keywords: ['twenty-app'],
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts,
|
||||
devDependencies,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const LOCAL_PORTS = [2020, 3000];
|
||||
|
||||
// Minimal health check — the full implementation lives in twenty-sdk
|
||||
const isServerReady = async (port: number): Promise<boolean> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/healthz`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
return body.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const detectRunningServer = async (
|
||||
preferredPort?: number,
|
||||
): Promise<number | null> => {
|
||||
const ports = preferredPort ? [preferredPort] : LOCAL_PORTS;
|
||||
|
||||
for (const port of ports) {
|
||||
if (await isServerReady(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export type LocalInstanceResult = {
|
||||
running: boolean;
|
||||
serverUrl?: string;
|
||||
};
|
||||
|
||||
export const setupLocalInstance = async (
|
||||
appDirectory: string,
|
||||
preferredPort?: number,
|
||||
): Promise<LocalInstanceResult> => {
|
||||
const detectedPort = await detectRunningServer(preferredPort);
|
||||
|
||||
if (detectedPort) {
|
||||
const serverUrl = `http://localhost:${detectedPort}`;
|
||||
|
||||
console.log(chalk.green(`Twenty server detected on ${serverUrl}.\n`));
|
||||
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
if (preferredPort) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`No Twenty server found on port ${preferredPort}.\n` +
|
||||
'Start your server and run `yarn twenty remote add --local` manually.\n',
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
console.log(chalk.blue('Setting up local Twenty instance...\n'));
|
||||
|
||||
try {
|
||||
execSync('yarn twenty server start', {
|
||||
cwd: appDirectory,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch {
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
console.log(chalk.gray('Waiting for Twenty to be ready...\n'));
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = 180 * 1000;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await isServerReady(LOCAL_PORTS[0])) {
|
||||
const serverUrl = `http://localhost:${LOCAL_PORTS[0]}`;
|
||||
|
||||
console.log(chalk.green(`Server running on '${serverUrl}'\n`));
|
||||
|
||||
return { running: true, serverUrl };
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Twenty server did not become healthy in time.\n',
|
||||
"Check: 'yarn twenty server logs'\n",
|
||||
),
|
||||
);
|
||||
|
||||
return { running: false };
|
||||
};
|
||||
@@ -149,18 +149,17 @@ const createIntegrationTest = async ({
|
||||
fileName: string;
|
||||
}) => {
|
||||
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
|
||||
import { appBuild, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-sdk/clients';
|
||||
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
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}\`),
|
||||
});
|
||||
|
||||
@@ -170,14 +169,27 @@ describe('App installation', () => {
|
||||
);
|
||||
}
|
||||
|
||||
appInstalled = true;
|
||||
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'}\`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!appInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
@@ -257,7 +269,7 @@ jobs:
|
||||
run: yarn test
|
||||
env:
|
||||
TWENTY_API_URL: \${{ steps.twenty.outputs.server-url }}
|
||||
TWENTY_TEST_API_KEY: \${{ steps.twenty.outputs.access-token }}
|
||||
TWENTY_API_KEY: \${{ steps.twenty.outputs.access-token }}
|
||||
`;
|
||||
|
||||
const workflowDir = join(appDirectory, '.github', 'workflows');
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add --local
|
||||
yarn twenty remote add http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add --local # Authenticate with Twenty
|
||||
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
|
||||
|
||||
@@ -9,22 +9,11 @@
|
||||
},
|
||||
"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",
|
||||
"help": "twenty help",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
"twenty": "twenty"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest"
|
||||
"twenty-sdk": "latest",
|
||||
"twenty-client-sdk": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
|
||||
export default defineField({
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
|
||||
universalIdentifier: 'b602dbd9-e511-49ce-b6d3-b697218dc69c',
|
||||
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: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
|
||||
universalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
|
||||
type: FieldType.NUMBER,
|
||||
name: 'priority',
|
||||
label: 'Priority',
|
||||
|
||||
@@ -25,8 +25,8 @@ export default defineObject({
|
||||
{
|
||||
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
type: FieldType.ADDRESS,
|
||||
label: 'Address',
|
||||
name: 'address',
|
||||
label: 'Mailing Address',
|
||||
name: 'mailingAddress',
|
||||
icon: 'IconHome',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 }}
|
||||
@@ -4,6 +4,7 @@
|
||||
- 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
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add --local
|
||||
yarn twenty remote add http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add --local # Authenticate with Twenty
|
||||
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
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"@types/react": "^18.2.0",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^18.2.0",
|
||||
"twenty-sdk": "0.6.4",
|
||||
"twenty-client-sdk": "portal:../../twenty-client-sdk",
|
||||
"twenty-sdk": "portal:../../twenty-sdk",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^3.1.1"
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
|
||||
import { appBuild, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-sdk/clients';
|
||||
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
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}`),
|
||||
});
|
||||
|
||||
@@ -20,14 +19,27 @@ describe('App installation', () => {
|
||||
);
|
||||
}
|
||||
|
||||
appInstalled = true;
|
||||
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'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!appInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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 =
|
||||
'6563e091-9f5b-4026-a3ea-7e3b3d09e218';
|
||||
'bb1decf6-dee5-43ef-b881-9799f97b02a8';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'
|
||||
|
||||
export default defineField({
|
||||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: '770d32c2-cf12-4ab2-b66d-73f92dc239b5',
|
||||
universalIdentifier: 'be08a7c6-2586-4d91-9fa7-0a44e6eae30c',
|
||||
type: FieldType.NUMBER,
|
||||
name: 'priority',
|
||||
label: 'Priority',
|
||||
|
||||
@@ -1,16 +1,56 @@
|
||||
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: 'd371f098-5b2c-42f0-898d-94459f1ee337',
|
||||
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'hello-world-front-component',
|
||||
description: 'A sample front component',
|
||||
component: HelloWorld,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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: '2baa26eb-9aaf-4856-a4f4-30d6fd6480ee',
|
||||
universalIdentifier: 'b05e4b30-72d4-4d7f-8091-32e037b601da',
|
||||
name: 'hello-world-logic-function',
|
||||
description: 'A simple logic function',
|
||||
timeoutSeconds: 5,
|
||||
|
||||
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
};
|
||||
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '7a3f4684-51db-494d-833b-a747a3b90507',
|
||||
universalIdentifier: '8c726dcc-1709-4eac-aa8b-f99960a9ec1b',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
|
||||
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
};
|
||||
|
||||
export default definePreInstallLogicFunction({
|
||||
universalIdentifier: '1272ffdb-8e2f-492c-ab37-66c2b97e9c23',
|
||||
universalIdentifier: 'f8ad4b09-6a12-4b12-a52a-3472d3a78dc7',
|
||||
name: 'pre-install',
|
||||
description: 'Runs before installation to prepare the application.',
|
||||
timeoutSeconds: 300,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
|
||||
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
|
||||
name: 'example-navigation-menu-item',
|
||||
icon: 'IconList',
|
||||
color: 'blue',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'dfd43356-39b3-4b55-b4a7-279bec689928';
|
||||
'47fd9bd9-392b-4d9f-9091-9a91b1edf519';
|
||||
|
||||
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'd2d7f6cd-33f6-456f-bf00-17adeca926ba';
|
||||
'2d9ff841-cf8e-44ec-ad8e-468455f7eebd';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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 =
|
||||
'9238bc7b-d38f-4a1c-9d19-31ab7bc67a2f';
|
||||
'c38f4d11-760c-4d5c-89ed-e569c28b7b70';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineSkill } from 'twenty-sdk';
|
||||
|
||||
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
|
||||
'd0940029-9d3c-40be-903a-52d65393028f';
|
||||
'90cf9144-4811-4653-93a2-9a6780fe6aac';
|
||||
|
||||
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 = 'e004df40-29f3-47ba-b39d-d3a5c444367a';
|
||||
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = '965e3776-b966-4be8-83f7-6cd3bce5e1bd';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
@@ -12,7 +12,7 @@ export default defineView({
|
||||
position: 0,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '496c40c2-5766-419c-93bf-20fdad3f34bb',
|
||||
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
|
||||
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
|
||||
@@ -352,9 +352,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/aix-ppc64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/aix-ppc64@npm:0.27.3"
|
||||
"@esbuild/aix-ppc64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/aix-ppc64@npm:0.27.4"
|
||||
conditions: os=aix & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -366,9 +366,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/android-arm64@npm:0.27.3"
|
||||
"@esbuild/android-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/android-arm64@npm:0.27.4"
|
||||
conditions: os=android & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -380,9 +380,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-arm@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/android-arm@npm:0.27.3"
|
||||
"@esbuild/android-arm@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/android-arm@npm:0.27.4"
|
||||
conditions: os=android & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -394,9 +394,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/android-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/android-x64@npm:0.27.3"
|
||||
"@esbuild/android-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/android-x64@npm:0.27.4"
|
||||
conditions: os=android & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -408,9 +408,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/darwin-arm64@npm:0.27.3"
|
||||
"@esbuild/darwin-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/darwin-arm64@npm:0.27.4"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -422,9 +422,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/darwin-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/darwin-x64@npm:0.27.3"
|
||||
"@esbuild/darwin-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/darwin-x64@npm:0.27.4"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -436,9 +436,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/freebsd-arm64@npm:0.27.3"
|
||||
"@esbuild/freebsd-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/freebsd-arm64@npm:0.27.4"
|
||||
conditions: os=freebsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -450,9 +450,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/freebsd-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/freebsd-x64@npm:0.27.3"
|
||||
"@esbuild/freebsd-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/freebsd-x64@npm:0.27.4"
|
||||
conditions: os=freebsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -464,9 +464,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-arm64@npm:0.27.3"
|
||||
"@esbuild/linux-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-arm64@npm:0.27.4"
|
||||
conditions: os=linux & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -478,9 +478,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-arm@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-arm@npm:0.27.3"
|
||||
"@esbuild/linux-arm@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-arm@npm:0.27.4"
|
||||
conditions: os=linux & cpu=arm
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -492,9 +492,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ia32@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-ia32@npm:0.27.3"
|
||||
"@esbuild/linux-ia32@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-ia32@npm:0.27.4"
|
||||
conditions: os=linux & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -506,9 +506,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-loong64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-loong64@npm:0.27.3"
|
||||
"@esbuild/linux-loong64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-loong64@npm:0.27.4"
|
||||
conditions: os=linux & cpu=loong64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -520,9 +520,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-mips64el@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-mips64el@npm:0.27.3"
|
||||
"@esbuild/linux-mips64el@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-mips64el@npm:0.27.4"
|
||||
conditions: os=linux & cpu=mips64el
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -534,9 +534,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-ppc64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-ppc64@npm:0.27.3"
|
||||
"@esbuild/linux-ppc64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-ppc64@npm:0.27.4"
|
||||
conditions: os=linux & cpu=ppc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -548,9 +548,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-riscv64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-riscv64@npm:0.27.3"
|
||||
"@esbuild/linux-riscv64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-riscv64@npm:0.27.4"
|
||||
conditions: os=linux & cpu=riscv64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -562,9 +562,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-s390x@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-s390x@npm:0.27.3"
|
||||
"@esbuild/linux-s390x@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-s390x@npm:0.27.4"
|
||||
conditions: os=linux & cpu=s390x
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -576,9 +576,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/linux-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/linux-x64@npm:0.27.3"
|
||||
"@esbuild/linux-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/linux-x64@npm:0.27.4"
|
||||
conditions: os=linux & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -590,9 +590,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/netbsd-arm64@npm:0.27.3"
|
||||
"@esbuild/netbsd-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/netbsd-arm64@npm:0.27.4"
|
||||
conditions: os=netbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -604,9 +604,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/netbsd-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/netbsd-x64@npm:0.27.3"
|
||||
"@esbuild/netbsd-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/netbsd-x64@npm:0.27.4"
|
||||
conditions: os=netbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -618,9 +618,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/openbsd-arm64@npm:0.27.3"
|
||||
"@esbuild/openbsd-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/openbsd-arm64@npm:0.27.4"
|
||||
conditions: os=openbsd & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -632,9 +632,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openbsd-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/openbsd-x64@npm:0.27.3"
|
||||
"@esbuild/openbsd-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/openbsd-x64@npm:0.27.4"
|
||||
conditions: os=openbsd & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -646,9 +646,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/openharmony-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/openharmony-arm64@npm:0.27.3"
|
||||
"@esbuild/openharmony-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/openharmony-arm64@npm:0.27.4"
|
||||
conditions: os=openharmony & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -660,9 +660,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/sunos-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/sunos-x64@npm:0.27.3"
|
||||
"@esbuild/sunos-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/sunos-x64@npm:0.27.4"
|
||||
conditions: os=sunos & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -674,9 +674,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-arm64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/win32-arm64@npm:0.27.3"
|
||||
"@esbuild/win32-arm64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/win32-arm64@npm:0.27.4"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -688,9 +688,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-ia32@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/win32-ia32@npm:0.27.3"
|
||||
"@esbuild/win32-ia32@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/win32-ia32@npm:0.27.4"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -702,9 +702,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@esbuild/win32-x64@npm:0.27.3":
|
||||
version: 0.27.3
|
||||
resolution: "@esbuild/win32-x64@npm:0.27.3"
|
||||
"@esbuild/win32-x64@npm:0.27.4":
|
||||
version: 0.27.4
|
||||
resolution: "@esbuild/win32-x64@npm:0.27.4"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -1541,11 +1541,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:*":
|
||||
version: 25.3.5
|
||||
resolution: "@types/node@npm:25.3.5"
|
||||
version: 25.5.0
|
||||
resolution: "@types/node@npm:25.5.0"
|
||||
dependencies:
|
||||
undici-types: "npm:~7.18.0"
|
||||
checksum: 10c0/4cf0834a6f6933bf0aca6afead117ae3db3b8f02a5f7187a24f871db0fb9344e5e46573ba387bc53b7505e1e219c4c535cbe67221ced95bb5ad98573223b19d0
|
||||
checksum: 10c0/70c508165b6758c4f88d4f91abca526c3985eee1985503d4c2bd994dbaf588e52ac57e571160f18f117d76e963570ac82bd20e743c18987e82564312b3b62119
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3408,35 +3408,35 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"esbuild@npm:^0.27.0":
|
||||
version: 0.27.3
|
||||
resolution: "esbuild@npm:0.27.3"
|
||||
version: 0.27.4
|
||||
resolution: "esbuild@npm:0.27.4"
|
||||
dependencies:
|
||||
"@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"
|
||||
"@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"
|
||||
dependenciesMeta:
|
||||
"@esbuild/aix-ppc64":
|
||||
optional: true
|
||||
@@ -3492,7 +3492,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
esbuild: bin/esbuild
|
||||
checksum: 10c0/fdc3f87a3f08b3ef98362f37377136c389a0d180fda4b8d073b26ba930cf245521db0a368f119cc7624bc619248fff1439f5811f062d853576f8ffa3df8ee5f1
|
||||
checksum: 10c0/2a1c2bcccda279f2afd72a7f8259860cb4483b32453d17878e1ecb4ac416b9e7c1001e7aa0a25ba4c29c1e250a3ceaae5d8bb72a119815bc8db4e9b5f5321490
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3906,6 +3906,7 @@ __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"
|
||||
@@ -4952,9 +4953,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"preact@npm:^10.28.3":
|
||||
version: 10.28.4
|
||||
resolution: "preact@npm:10.28.4"
|
||||
checksum: 10c0/712384e92d6c676c8f4c230eab81329a66acfecf700390aebfe5fc46168fa3686345d47f2292068dcc0a6d86425a3d9a3d743730e356e867a8402442afb989db
|
||||
version: 10.29.0
|
||||
resolution: "preact@npm:10.29.0"
|
||||
checksum: 10c0/d111381e5b48335e3a797a03adb83521cf5e9bdf880570fb2eff4fe9da9c82e6dedcbdf54538b1ed8f60bf813a0df0f4891b03dc32140ad93f8f720a8812dd5c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -5750,6 +5751,17 @@ __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."
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add --local
|
||||
yarn twenty remote add http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,7 +22,7 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add --local # Authenticate with Twenty
|
||||
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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$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": "^_"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
generated
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "0.8.0-canary.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// 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`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CoreApiClient } from './generated/index';
|
||||
export * as CoreSchema from './generated/schema';
|
||||
+22
-53
@@ -1,8 +1,10 @@
|
||||
import { transform } from 'esbuild';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
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,
|
||||
@@ -13,17 +15,12 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
vi.mock('@/cli/constants/clients-dir', () => ({
|
||||
CLIENTS_GENERATED_DIR: 'src/clients/generated',
|
||||
}));
|
||||
import { buildClientWrapperSource } from '../client-wrapper';
|
||||
|
||||
vi.mock('twenty-shared/application', () => ({
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME: 'TWENTY_APP_ACCESS_TOKEN',
|
||||
DEFAULT_API_KEY_NAME: 'TWENTY_API_KEY',
|
||||
DEFAULT_API_URL_NAME: 'TWENTY_API_URL',
|
||||
}));
|
||||
|
||||
import { ClientService } from '@/cli/utilities/client/client-service';
|
||||
const twentyClientTemplateSource = readFileSync(
|
||||
join(__dirname, '..', 'twenty-client-template.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
type TwentyClassType = new (options?: {
|
||||
url?: string;
|
||||
@@ -110,59 +107,33 @@ const getAuthorizationHeaderValue = (requestInit: RequestInit | undefined) => {
|
||||
return new Headers(requestInit?.headers).get('Authorization');
|
||||
};
|
||||
|
||||
describe('ClientService generated Twenty auth behavior', () => {
|
||||
let temporaryGeneratedClientDirectory: string;
|
||||
describe('Generated client wrapper auth behavior', () => {
|
||||
let temporaryDir: string;
|
||||
let TwentyClass: TwentyClassType;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporaryGeneratedClientDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'twenty-generated-client-'),
|
||||
);
|
||||
temporaryDir = await mkdtemp(join(tmpdir(), 'twenty-generated-client-'));
|
||||
|
||||
const temporaryGeneratedIndexTsPath = join(
|
||||
temporaryGeneratedClientDirectory,
|
||||
'index.ts',
|
||||
);
|
||||
|
||||
await writeFile(temporaryGeneratedIndexTsPath, stubGeneratedIndexSource);
|
||||
|
||||
const clientService = new ClientService();
|
||||
await (
|
||||
clientService as unknown as {
|
||||
injectClientWrapper: (
|
||||
output: string,
|
||||
options: {
|
||||
apiClientName: string;
|
||||
defaultUrl: string;
|
||||
includeUploadFile: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
).injectClientWrapper(temporaryGeneratedClientDirectory, {
|
||||
const wrapperSource = buildClientWrapperSource(twentyClientTemplateSource, {
|
||||
apiClientName: 'MetadataApiClient',
|
||||
defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`',
|
||||
includeUploadFile: true,
|
||||
});
|
||||
|
||||
const generatedIndexContent = await readFile(
|
||||
temporaryGeneratedIndexTsPath,
|
||||
'utf-8',
|
||||
);
|
||||
const fullSource = stubGeneratedIndexSource + wrapperSource;
|
||||
|
||||
const transpiledModule = await transform(generatedIndexContent, {
|
||||
const transpiledModule = await transform(fullSource, {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'es2022',
|
||||
});
|
||||
|
||||
const temporaryGeneratedIndexMjsPath = join(
|
||||
temporaryGeneratedClientDirectory,
|
||||
'index.mjs',
|
||||
);
|
||||
await writeFile(temporaryGeneratedIndexMjsPath, transpiledModule.code);
|
||||
const outputPath = join(temporaryDir, 'index.mjs');
|
||||
|
||||
await writeFile(outputPath, transpiledModule.code);
|
||||
|
||||
const generatedModule = await import(
|
||||
`${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}`
|
||||
`${pathToFileURL(outputPath).href}?t=${Date.now()}`
|
||||
);
|
||||
|
||||
TwentyClass = generatedModule.MetadataApiClient as TwentyClassType;
|
||||
@@ -176,11 +147,8 @@ describe('ClientService generated Twenty auth behavior', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (temporaryGeneratedClientDirectory) {
|
||||
await rm(temporaryGeneratedClientDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
if (temporaryDir) {
|
||||
await rm(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -303,6 +271,7 @@ describe('ClientService generated Twenty auth behavior', () => {
|
||||
.fn<() => Promise<string>>()
|
||||
.mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
|
||||
return 'fresh-token';
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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}`;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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 });
|
||||
@@ -0,0 +1,122 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
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);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
GENERATED_CORE_DIR,
|
||||
generateCoreClientFromSchema,
|
||||
replaceCoreClient,
|
||||
} from './generate-core-client';
|
||||
export { generateMetadataClient } from './generate-metadata-client';
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module '*.ts?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// 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
-1
@@ -78,7 +78,7 @@ export const generateSubscriptionOp: (
|
||||
)
|
||||
}
|
||||
|
||||
// MetadataApiClient (auto-injected by twenty-sdk)
|
||||
// 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.
|
||||
|
||||
+1500
-1552
File diff suppressed because it is too large
Load Diff
+3051
-3201
File diff suppressed because it is too large
Load Diff
+4215
-4326
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export { MetadataApiClient } from './generated/index';
|
||||
export * as MetadataSchema from './generated/schema';
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import packageJson from './package.json';
|
||||
|
||||
const entries = [
|
||||
'src/core/index.ts',
|
||||
'src/metadata/index.ts',
|
||||
'src/generate/index.ts',
|
||||
];
|
||||
|
||||
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
|
||||
if (!chunk.isEntry) {
|
||||
throw new Error(
|
||||
`Should never occur, encountered a non entry chunk ${chunk.facadeModuleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
|
||||
if (splitFaceModuleId === undefined) {
|
||||
throw new Error(
|
||||
`Should never occur, splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
|
||||
if (moduleDirectory === 'src') {
|
||||
return `${chunk.name}.${extension}`;
|
||||
}
|
||||
return `${moduleDirectory}.${extension}`;
|
||||
};
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/packages/twenty-client-sdk',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': path.resolve(__dirname, 'src') + '/',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: __dirname,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: 'dist',
|
||||
lib: { entry: entries, name: 'twenty-client-sdk' },
|
||||
rollupOptions: {
|
||||
external: [
|
||||
...Object.keys((packageJson as any).dependencies || {}),
|
||||
...Object.keys((packageJson as any).devDependencies || {}).filter(
|
||||
(dep: string) => dep !== 'twenty-shared',
|
||||
),
|
||||
'node:fs/promises',
|
||||
'node:fs',
|
||||
'node:path',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
format: 'es',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
|
||||
},
|
||||
{
|
||||
format: 'cjs',
|
||||
interop: 'auto',
|
||||
esModule: true,
|
||||
exports: 'named',
|
||||
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
logLevel: 'warn',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
name: 'twenty-client-sdk',
|
||||
environment: 'node',
|
||||
include: [
|
||||
'src/**/__tests__/**/*.{test,spec}.{ts,tsx}',
|
||||
'src/**/*.{test,spec}.{ts,tsx}',
|
||||
],
|
||||
exclude: ['**/node_modules/**', '**/.git/**'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
-12998
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
"description": "Twenty meeting recorder",
|
||||
"main": ".webpack/main",
|
||||
"scripts": {
|
||||
"start": "concurrently \"npm run start:server\" \"npm run start:electron\"",
|
||||
"start": "concurrently \"yarn start:server\" \"yarn start:electron\"",
|
||||
"start:electron": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
@@ -37,10 +37,8 @@
|
||||
"node-loader": "^2.1.0",
|
||||
"style-loader": "^3.3.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@electron/packager": {
|
||||
"@electron/osx-sign": "github:recallai/osx-sign"
|
||||
}
|
||||
"resolutions": {
|
||||
"@electron/packager/@electron/osx-sign": "github:recallai/osx-sign"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.40.1",
|
||||
|
||||
@@ -16,3 +16,5 @@ STORAGE_TYPE=local
|
||||
# STORAGE_S3_REGION=eu-west3
|
||||
# STORAGE_S3_NAME=my-bucket
|
||||
# STORAGE_S3_ENDPOINT=
|
||||
# STORAGE_S3_ACCESS_KEY_ID=
|
||||
# STORAGE_S3_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -32,11 +32,11 @@ has_schema=$(PGPASSWORD=twenty psql -h localhost -U twenty -d default -tAc \
|
||||
|
||||
if [ "$has_schema" = "f" ]; then
|
||||
echo "Database appears to be empty, running initial setup..."
|
||||
NODE_OPTIONS="--max-old-space-size=1500" node ./dist/scripts/setup-db.js
|
||||
NODE_OPTIONS="--max-old-space-size=1500" node ./dist/database/scripts/setup-db.js
|
||||
fi
|
||||
|
||||
# Always run migrations (idempotent — skips already-applied ones)
|
||||
yarn database:migrate:prod
|
||||
yarn database:migrate:prod --force
|
||||
|
||||
yarn command:prod cache:flush
|
||||
yarn command:prod upgrade
|
||||
|
||||
@@ -17,6 +17,7 @@ COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
|
||||
COPY ./packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
COPY ./packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
COPY ./packages/twenty-client-sdk/package.json /app/packages/twenty-client-sdk/
|
||||
|
||||
RUN yarn && yarn cache clean && npx nx reset
|
||||
|
||||
@@ -27,6 +28,7 @@ COPY ./packages/twenty-emails /app/packages/twenty-emails
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
|
||||
COPY ./packages/twenty-client-sdk /app/packages/twenty-client-sdk
|
||||
COPY ./packages/twenty-server /app/packages/twenty-server
|
||||
|
||||
RUN npx nx run twenty-server:lingui:extract && \
|
||||
@@ -36,18 +38,12 @@ RUN npx nx run twenty-server:lingui:extract && \
|
||||
|
||||
RUN npx nx run twenty-server:build
|
||||
|
||||
# Bundle setup-db script into a standalone JS file so the final image
|
||||
# doesn't need tsx or the TypeScript source tree at runtime.
|
||||
RUN npx esbuild packages/twenty-server/scripts/setup-db.ts \
|
||||
--bundle --platform=node --outfile=packages/twenty-server/dist/scripts/setup-db.js \
|
||||
--external:typeorm --external:dotenv --external:pg
|
||||
|
||||
# Clean server build output (type declarations and compiled tests are not needed at runtime;
|
||||
# source maps are kept because twenty-infra extracts them from the image for Sentry uploads)
|
||||
RUN find /app/packages/twenty-server/dist -name '*.d.ts' -delete \
|
||||
&& rm -rf /app/packages/twenty-server/dist/packages/twenty-server/test
|
||||
|
||||
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-server
|
||||
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-client-sdk twenty-server
|
||||
|
||||
|
||||
FROM common-deps AS twenty-front-build
|
||||
@@ -58,6 +54,7 @@ COPY ./packages/twenty-front /app/packages/twenty-front
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
|
||||
COPY ./packages/twenty-client-sdk /app/packages/twenty-client-sdk
|
||||
RUN npx nx run twenty-front:lingui:extract && \
|
||||
npx nx run twenty-front:lingui:compile
|
||||
# To skip the memory-intensive frontend build, pre-build on the host:
|
||||
@@ -106,6 +103,8 @@ COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-shared/dist /a
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-emails/dist /app/packages/twenty-emails/dist
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-client-sdk/package.json /app/packages/twenty-client-sdk/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-client-sdk/dist /app/packages/twenty-client-sdk/dist
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
|
||||
@@ -180,6 +179,8 @@ COPY --from=twenty-server-build /app/packages/twenty-shared/dist /app/packages/t
|
||||
COPY --from=twenty-server-build /app/packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-emails/dist /app/packages/twenty-emails/dist
|
||||
COPY --from=twenty-server-build /app/packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-client-sdk/package.json /app/packages/twenty-client-sdk/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-client-sdk/dist /app/packages/twenty-client-sdk/dist
|
||||
COPY --from=twenty-server-build /app/packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ setup_and_migrate_db() {
|
||||
has_schema=$(psql -tAc "SELECT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'core')" ${PG_DATABASE_URL})
|
||||
if [ "$has_schema" = "f" ]; then
|
||||
echo "Database appears to be empty, running migrations."
|
||||
NODE_OPTIONS="--max-old-space-size=1500" node ./dist/scripts/setup-db.js
|
||||
yarn database:migrate:prod
|
||||
yarn database:init:prod
|
||||
fi
|
||||
|
||||
yarn command:prod cache:flush
|
||||
|
||||
@@ -19,15 +19,17 @@ The SDK provides helper functions for defining your app entities. As described i
|
||||
|----------|---------|
|
||||
| `defineApplication` | Configure application metadata (required, one per app) |
|
||||
| `defineObject` | Define custom objects with fields |
|
||||
| `defineField` | Extend existing objects with additional fields or define standalone relation fields |
|
||||
| `defineLogicFunction` | Define logic functions with handlers |
|
||||
| `definePreInstallLogicFunction` | Define a pre-install logic function (one per app) |
|
||||
| `definePostInstallLogicFunction` | Define a post-install logic function (one per app) |
|
||||
| `defineFrontComponent` | Define front components for custom UI |
|
||||
| `defineRole` | Configure role permissions and object access |
|
||||
| `defineField` | Extend existing objects with additional fields |
|
||||
| `defineView` | Define saved views for objects |
|
||||
| `defineNavigationMenuItem` | Define sidebar navigation links |
|
||||
| `defineSkill` | Define AI agent skills |
|
||||
| `defineAgent` | Define AI agents |
|
||||
| `definePageLayout` | Define custom page layouts |
|
||||
|
||||
These functions validate your configuration at build time and provide IDE autocompletion and type safety.
|
||||
|
||||
@@ -36,7 +38,7 @@ These functions validate your configuration at build time and provide IDE autoco
|
||||
Custom objects describe both schema and behavior for records in your workspace. Use `defineObject()` to define objects with built-in validation:
|
||||
|
||||
```typescript
|
||||
// src/app/postCard.object.ts
|
||||
// src/objects/postCard.object.ts
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
enum PostCardStatus {
|
||||
@@ -110,7 +112,7 @@ Key points:
|
||||
- The `universalIdentifier` must be unique and stable across deployments.
|
||||
- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`.
|
||||
- The `fields` array is optional — you can define objects without custom fields.
|
||||
- You can scaffold new objects using `yarn twenty entity:add`, which guides you through naming, fields, and relationships.
|
||||
- You can scaffold new objects using `yarn twenty add`, which guides you through naming, fields, and relationships.
|
||||
|
||||
<Note>
|
||||
**Base fields are created automatically.** When you define a custom object, Twenty automatically adds standard fields
|
||||
@@ -120,6 +122,195 @@ Key points:
|
||||
but this is not recommended.
|
||||
</Note>
|
||||
|
||||
### Defining fields on existing objects
|
||||
|
||||
Use `defineField()` to add fields to objects you don't own — such as standard Twenty objects (Person, Company, etc.) or objects from other apps. Unlike inline fields in `defineObject()`, standalone fields require an `objectUniversalIdentifier` to specify which object they extend:
|
||||
|
||||
```typescript
|
||||
// src/fields/company-loyalty-tier.field.ts
|
||||
import { defineField, FieldType } from 'twenty-sdk';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
|
||||
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
|
||||
name: 'loyaltyTier',
|
||||
type: FieldType.SELECT,
|
||||
label: 'Loyalty Tier',
|
||||
icon: 'IconStar',
|
||||
options: [
|
||||
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
|
||||
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
|
||||
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `objectUniversalIdentifier` identifies the target object. For standard objects, use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` exported from `twenty-sdk`.
|
||||
- When defining fields inline in `defineObject()`, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object.
|
||||
- `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`.
|
||||
|
||||
### Relations
|
||||
|
||||
Relations connect objects together. In Twenty, relations are always **bidirectional** — you define both sides, and each side references the other.
|
||||
|
||||
There are two relation types:
|
||||
|
||||
| Relation type | Description | Has foreign key? |
|
||||
|---------------|-------------|-----------------|
|
||||
| `MANY_TO_ONE` | Many records of this object point to one record of the target | Yes (`joinColumnName`) |
|
||||
| `ONE_TO_MANY` | One record of this object has many records of the target | No (inverse side) |
|
||||
|
||||
#### How relations work
|
||||
|
||||
Every relation requires **two fields** that reference each other:
|
||||
|
||||
1. The **MANY_TO_ONE** side — lives on the object that holds the foreign key
|
||||
2. The **ONE_TO_MANY** side — lives on the object that owns the collection
|
||||
|
||||
Both fields use `FieldType.RELATION` and cross-reference each other via `relationTargetFieldMetadataUniversalIdentifier`.
|
||||
|
||||
#### Example: Post Card has many Recipients
|
||||
|
||||
Suppose a `PostCard` can be sent to many `PostCardRecipient` records. Each recipient belongs to exactly one post card.
|
||||
|
||||
**Step 1: Define the ONE_TO_MANY side on PostCard** (the "one" side):
|
||||
|
||||
```typescript
|
||||
// src/fields/post-card-recipients-on-post-card.field.ts
|
||||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||||
|
||||
// Export so the other side can reference it
|
||||
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||
// Import from the other side
|
||||
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCardRecipients',
|
||||
label: 'Post Card Recipients',
|
||||
icon: 'IconUsers',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Define the MANY_TO_ONE side on PostCardRecipient** (the "many" side — holds the foreign key):
|
||||
|
||||
```typescript
|
||||
// src/fields/post-card-on-post-card-recipient.field.ts
|
||||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||||
|
||||
// Export so the other side can reference it
|
||||
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
|
||||
// Import from the other side
|
||||
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_FIELD_ID,
|
||||
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCard',
|
||||
label: 'Post Card',
|
||||
icon: 'IconMail',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'postCardId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Circular imports:** Both relation fields reference each other's `universalIdentifier`. To avoid circular import issues, export your field IDs as named constants from each file, and import them in the other file. The build system resolves these at compile time.
|
||||
</Note>
|
||||
|
||||
#### Relating to standard objects
|
||||
|
||||
To create a relation with a built-in Twenty object (Person, Company, etc.), use `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`:
|
||||
|
||||
```typescript
|
||||
// src/fields/person-on-self-hosting-user.field.ts
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
OnDeleteAction,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
|
||||
|
||||
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
|
||||
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PERSON_FIELD_ID,
|
||||
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'person',
|
||||
label: 'Person',
|
||||
description: 'Person matching with the self hosting user',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'personId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Relation field properties
|
||||
|
||||
| Property | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `type` | Yes | Must be `FieldType.RELATION` |
|
||||
| `relationTargetObjectMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the target object |
|
||||
| `relationTargetFieldMetadataUniversalIdentifier` | Yes | The `universalIdentifier` of the matching field on the target object |
|
||||
| `universalSettings.relationType` | Yes | `RelationType.MANY_TO_ONE` or `RelationType.ONE_TO_MANY` |
|
||||
| `universalSettings.onDelete` | MANY_TO_ONE only | What happens when the referenced record is deleted: `CASCADE`, `SET_NULL`, `RESTRICT`, or `NO_ACTION` |
|
||||
| `universalSettings.joinColumnName` | MANY_TO_ONE only | Database column name for the foreign key (e.g., `postCardId`) |
|
||||
|
||||
#### Inline relation fields in defineObject
|
||||
|
||||
You can also define relation fields directly inside `defineObject()`. In that case, omit `objectUniversalIdentifier` — it's inherited from the parent object:
|
||||
|
||||
```typescript
|
||||
export default defineObject({
|
||||
universalIdentifier: '...',
|
||||
nameSingular: 'postCardRecipient',
|
||||
// ...
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: POST_CARD_FIELD_ID,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCard',
|
||||
label: 'Post Card',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'postCardId',
|
||||
},
|
||||
},
|
||||
// ... other fields
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Application config (application-config.ts)
|
||||
|
||||
@@ -161,13 +352,29 @@ Notes:
|
||||
- `defaultRoleUniversalIdentifier` must match the role file (see below).
|
||||
- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions).
|
||||
|
||||
#### Marketplace metadata
|
||||
|
||||
If you plan to [publish your app](/developers/extend/apps/publishing), these optional fields control how your app appears in the marketplace:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `author` | Author or company name |
|
||||
| `category` | App category for marketplace filtering |
|
||||
| `logoUrl` | Path to your app logo (relative to `./assets/`) |
|
||||
| `screenshots` | Array of screenshot paths (relative to `./assets/`) |
|
||||
| `aboutDescription` | Longer markdown description for the "About" tab |
|
||||
| `websiteUrl` | Link to your website |
|
||||
| `termsUrl` | Link to terms of service |
|
||||
| `emailSupport` | Support email address |
|
||||
| `issueReportUrl` | Link to issue tracker |
|
||||
|
||||
#### Roles and permissions
|
||||
|
||||
Applications can define roles that encapsulate permissions on your workspace's objects and actions. The field `defaultRoleUniversalIdentifier` in `application-config.ts` designates the default role used by your app's logic functions.
|
||||
|
||||
- The runtime API key injected as `TWENTY_API_KEY` is derived from this default function role.
|
||||
- The typed client will be restricted to the permissions granted to that role.
|
||||
- Follow least‑privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier.
|
||||
- Follow least-privilege: create a dedicated role with only the permissions your functions need, then reference its universal identifier.
|
||||
|
||||
##### Default function role (*.role.ts)
|
||||
|
||||
@@ -219,7 +426,7 @@ The `universalIdentifier` of this role is then referenced in `application-config
|
||||
- **application-config.ts** points to that role so your functions inherit its permissions.
|
||||
|
||||
Notes:
|
||||
- Start from the scaffolded role, then progressively restrict it following least‑privilege.
|
||||
- Start from the scaffolded role, then progressively restrict it following least-privilege.
|
||||
- Replace the `objectPermissions` and `fieldPermissions` with the objects/fields your functions need.
|
||||
- `permissionFlags` control access to platform-level capabilities. Keep them minimal; add only what you need.
|
||||
- See a working example in the Hello World app: [`packages/twenty-apps/hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts).
|
||||
@@ -229,7 +436,7 @@ Notes:
|
||||
Each function file uses `defineLogicFunction()` to export a configuration with a handler and optional triggers.
|
||||
|
||||
```typescript
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
// src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
@@ -318,7 +525,7 @@ export default definePreInstallLogicFunction({
|
||||
You can also manually execute the pre-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --preInstall
|
||||
yarn twenty exec --preInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
@@ -327,7 +534,7 @@ Key points:
|
||||
- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected.
|
||||
- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
|
||||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks.
|
||||
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `function:execute --preInstall`.
|
||||
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `exec --preInstall`.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
@@ -355,7 +562,7 @@ export default definePostInstallLogicFunction({
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
yarn twenty exec --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
@@ -364,7 +571,7 @@ Key points:
|
||||
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
|
||||
- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
|
||||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `exec --postInstall`.
|
||||
|
||||
### Route trigger payload
|
||||
|
||||
@@ -412,7 +619,7 @@ The `RoutePayload` type has the following structure:
|
||||
|----------|------|-------------|
|
||||
| `headers` | `Record<string, string \| undefined>` | HTTP headers (only those listed in `forwardedRequestHeaders`) |
|
||||
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) |
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` → `{ id: '123' }`) |
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` -> `{ id: '123' }`) |
|
||||
| `body` | `object \| null` | Parsed request body (JSON) |
|
||||
| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded |
|
||||
| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) |
|
||||
@@ -458,7 +665,7 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
You can create new functions in two ways:
|
||||
|
||||
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
|
||||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new logic function. This generates a starter file with a handler and config.
|
||||
- **Manual**: Create a new `*.logic-function.ts` file and use `defineLogicFunction()`, following the same pattern.
|
||||
|
||||
### Marking a logic function as a tool
|
||||
@@ -470,7 +677,7 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -558,7 +765,7 @@ Key points:
|
||||
|
||||
You can create new front components in two ways:
|
||||
|
||||
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component.
|
||||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new front component.
|
||||
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
|
||||
|
||||
### Skills
|
||||
@@ -592,46 +799,122 @@ Key points:
|
||||
|
||||
You can create new skills in two ways:
|
||||
|
||||
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
|
||||
- **Scaffolded**: Run `yarn twenty add` and choose the option to add a new skill.
|
||||
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
|
||||
|
||||
### Generated typed clients
|
||||
### Typed API clients (`twenty-client-sdk`)
|
||||
|
||||
Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
|
||||
The `twenty-client-sdk` package provides two typed GraphQL clients for interacting with the Twenty API from your logic functions and front components:
|
||||
|
||||
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
|
||||
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
|
||||
| Client | Import | Endpoint | Generated? |
|
||||
|--------|--------|----------|------------|
|
||||
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — workspace data (records, objects) | Yes, at dev/build time |
|
||||
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — workspace config, file uploads | No, ships pre-built |
|
||||
|
||||
#### CoreApiClient
|
||||
|
||||
`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it's fully typed to match your objects and fields.
|
||||
|
||||
```typescript
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
// Query records
|
||||
const { companies } = await client.query({
|
||||
companies: {
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
domainName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a record
|
||||
const { createCompany } = await client.mutation({
|
||||
createCompany: {
|
||||
__args: {
|
||||
data: {
|
||||
name: 'Acme Corp',
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Both clients are re-generated automatically by `yarn twenty dev` whenever your objects or fields change.
|
||||
The client uses a selection-set syntax: pass `true` to include a field, use `__args` for arguments, and nest objects for relations. You get full autocompletion and type checking based on your workspace schema.
|
||||
|
||||
#### Runtime credentials in logic functions
|
||||
<Note>
|
||||
**CoreApiClient is generated at dev/build time.** If you try to use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema, generates a typed client using `@genql/cli`, writes the generated sources to `node_modules/twenty-client-sdk/dist/core/generated/`, and replaces the stubs in `node_modules/twenty-client-sdk/dist/core.mjs` and `node_modules/twenty-client-sdk/dist/core.cjs`.
|
||||
</Note>
|
||||
|
||||
When your function runs on Twenty, the platform injects credentials as environment variables before your code executes:
|
||||
#### Using CoreSchema for type annotations
|
||||
|
||||
- `TWENTY_API_URL`: Base URL of the Twenty API your app targets.
|
||||
- `TWENTY_API_KEY`: Short‑lived key scoped to your application's default function role.
|
||||
`CoreSchema` provides TypeScript types matching your workspace objects, useful for typing component state or function parameters:
|
||||
|
||||
Notes:
|
||||
- You do not need to pass URL or API key to the generated client. It reads `TWENTY_API_URL` and `TWENTY_API_KEY` from process.env at runtime.
|
||||
- The API key's permissions are determined by the role referenced in your `application-config.ts` via `defaultRoleUniversalIdentifier`. This is the default role used by logic functions of your application.
|
||||
- Applications can define roles to follow least‑privilege. Grant only the permissions your functions need, then point `defaultRoleUniversalIdentifier` to that role's universal identifier.
|
||||
```typescript
|
||||
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
const [company, setCompany] = useState<
|
||||
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
|
||||
>(undefined);
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const result = await client.query({
|
||||
company: {
|
||||
__args: { filter: { position: { eq: 1 } } },
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
setCompany(result.company);
|
||||
```
|
||||
|
||||
#### MetadataApiClient
|
||||
|
||||
`MetadataApiClient` ships pre-built with the SDK (no generation required). It queries the `/metadata` endpoint for workspace configuration, applications, and file uploads:
|
||||
|
||||
```typescript
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
// Query workspace info
|
||||
const { currentWorkspace } = await metadataClient.query({
|
||||
currentWorkspace: { id: true, displayName: true },
|
||||
});
|
||||
|
||||
// List installed applications
|
||||
const { findManyApplications } = await metadataClient.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
version: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Runtime credentials
|
||||
|
||||
When your code runs on Twenty (logic functions or front components), the platform injects credentials as environment variables:
|
||||
|
||||
- `TWENTY_API_URL` — Base URL of the Twenty API
|
||||
- `TWENTY_API_KEY` — Short-lived key scoped to your application's default function role
|
||||
|
||||
You do **not** need to pass these to the clients — they read from `process.env` automatically. The API key's permissions are determined by the role referenced in `defaultRoleUniversalIdentifier` in your `application-config.ts`.
|
||||
|
||||
#### Uploading files
|
||||
|
||||
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
|
||||
`MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields. It implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec):
|
||||
|
||||
```typescript
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
@@ -641,25 +924,14 @@ const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
|
||||
'application/pdf', // MIME type
|
||||
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
|
||||
);
|
||||
|
||||
console.log(uploadedFile);
|
||||
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
|
||||
```
|
||||
|
||||
The method signature:
|
||||
|
||||
```typescript
|
||||
uploadFile(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
fieldMetadataUniversalIdentifier: string,
|
||||
): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }>
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `fileBuffer` | `Buffer` | The raw file contents |
|
||||
@@ -668,8 +940,7 @@ uploadFile(
|
||||
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
|
||||
|
||||
Key points:
|
||||
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
|
||||
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
|
||||
- Uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed.
|
||||
- The returned `url` is a signed URL you can use to access the uploaded file.
|
||||
|
||||
### Hello World example
|
||||
|
||||
@@ -48,7 +48,7 @@ From here you can:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
yarn twenty add
|
||||
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
@@ -122,7 +122,7 @@ With `--minimal`, only the core files are created (`application-config.ts`, `rol
|
||||
At a high level:
|
||||
|
||||
- **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus a `twenty` script that delegates to the local `twenty` CLI. Run `yarn twenty help` to list all available commands.
|
||||
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `generated/` (typed client), `dist/`, `build/`, coverage folders, log files, and `.env*` files.
|
||||
- **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `.twenty/`, `dist/`, `build/`, coverage folders, log files, and `.env*` files.
|
||||
- **yarn.lock**, **.yarnrc.yml**, **.yarn/**: Lock and configure the Yarn 4 toolchain used by the project.
|
||||
- **.nvmrc**: Pins the Node.js version expected by the project.
|
||||
- **.oxlintrc.json** and **tsconfig.json**: Provide linting and TypeScript configuration for your app's TypeScript sources.
|
||||
@@ -165,8 +165,8 @@ export default defineObject({
|
||||
|
||||
Later commands will add more files and folders:
|
||||
|
||||
- `yarn twenty dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
|
||||
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
- `yarn twenty dev` will auto-generate the typed `CoreApiClient` (for workspace data via `/graphql`) into `node_modules/twenty-client-sdk/`. The `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built and is available immediately. Import them from `twenty-client-sdk/core` and `twenty-client-sdk/metadata` respectively.
|
||||
- `yarn twenty add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -12,7 +12,25 @@ Apps are currently in alpha testing. The feature is functional but still evolvin
|
||||
Once your app is [built and tested locally](/developers/extend/apps/building), you have two paths for distributing it:
|
||||
|
||||
- **Publish to npm** — list your app in the Twenty marketplace for any workspace to discover and install.
|
||||
- **Push a tarball** — deploy your app to a specific Twenty server for internal use without making it publicly available.
|
||||
- **Deploy a tarball** — upload your app directly to a specific Twenty server for internal or private use.
|
||||
|
||||
Both paths start from the same **build** step.
|
||||
|
||||
## Building your app
|
||||
|
||||
The `build` command compiles your TypeScript sources, transpiles logic functions and front components, and generates a `manifest.json` that describes your app's contents:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build
|
||||
```
|
||||
|
||||
The output is written to `.twenty/output/`. This directory contains everything needed for distribution: compiled code, assets, the manifest, and a copy of your `package.json`.
|
||||
|
||||
To also create a `.tgz` tarball (used by the deploy command internally, or for manual distribution):
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build --tarball
|
||||
```
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
@@ -21,29 +39,72 @@ Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twe
|
||||
### Requirements
|
||||
|
||||
- An [npm](https://www.npmjs.com) account
|
||||
- Your package name **must** use the `twenty-app-` prefix (e.g., `twenty-app-postcard-sender`)
|
||||
- The `twenty-app` keyword **must** be listed in your `package.json` `keywords` array
|
||||
|
||||
### Adding the required keyword
|
||||
|
||||
The Twenty marketplace discovers apps by searching the npm registry for packages with the `twenty-app` keyword. Add it to your `package.json`:
|
||||
|
||||
```json filename="package.json"
|
||||
{
|
||||
"name": "twenty-app-postcard-sender",
|
||||
"version": "1.0.0",
|
||||
"keywords": ["twenty-app"],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
The marketplace searches for `keywords:twenty-app` on the npm registry. Without this keyword, your package won't appear in the marketplace even if it has the `twenty-app-` name prefix.
|
||||
</Note>
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Build your app** — the CLI compiles your TypeScript sources and generates the application manifest:
|
||||
1. **Build your app:**
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build
|
||||
```
|
||||
|
||||
2. **Publish to npm** — push the built package to the npm registry:
|
||||
2. **Publish to npm:**
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx twenty publish
|
||||
yarn twenty publish
|
||||
```
|
||||
|
||||
### Auto-discovery
|
||||
This runs `npm publish` from the `.twenty/output/` directory.
|
||||
|
||||
Packages with the `twenty-app-` prefix are automatically discovered by the Twenty marketplace catalog. Once published, your app appears in the marketplace within a few minutes — no manual registration or approval required.
|
||||
To publish under a specific dist-tag (e.g., `beta` or `next`):
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty publish --tag beta
|
||||
```
|
||||
|
||||
### How marketplace discovery works
|
||||
|
||||
The Twenty server syncs its marketplace catalog from the npm registry **every hour**:
|
||||
|
||||
1. It searches for all npm packages with the `keywords:twenty-app` keyword
|
||||
2. For each package, it fetches the `manifest.json` from the npm CDN
|
||||
3. The app's metadata (name, description, author, logo, screenshots, category) is extracted from the manifest and displayed in the marketplace
|
||||
|
||||
After publishing, your app can take up to one hour to appear in the marketplace. To trigger the sync immediately instead of waiting for the next hourly run:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync
|
||||
```
|
||||
|
||||
To target a specific remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync -r production
|
||||
```
|
||||
|
||||
The metadata shown in the marketplace comes from your `defineApplication()` call in your app source code — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
|
||||
|
||||
### CI publishing
|
||||
|
||||
The scaffolded project includes a GitHub Actions workflow that publishes on every release. It runs `app:build`, then `npm publish --provenance` from the build output:
|
||||
The scaffolded project includes a GitHub Actions workflow that publishes on every release:
|
||||
|
||||
```yaml filename=".github/workflows/publish.yml"
|
||||
name: Publish
|
||||
@@ -72,48 +133,117 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
```
|
||||
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `npx twenty build`, then `npm publish` from `.twenty/output`.
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `yarn twenty build`, then `npm publish` from `.twenty/output`.
|
||||
|
||||
<Tip>
|
||||
**npm provenance** is optional but recommended. Publishing with `--provenance` adds a trust badge to your npm listing, letting users verify the package was built from a specific commit in a public CI pipeline. See the [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements) for setup instructions.
|
||||
</Tip>
|
||||
|
||||
## Internal distribution
|
||||
## Deploying to a server (tarball)
|
||||
|
||||
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can push a tarball directly to a Twenty server.
|
||||
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can deploy a tarball directly to a Twenty server.
|
||||
|
||||
### Push a tarball
|
||||
### Prerequisites
|
||||
|
||||
Build your app and deploy it to a specific server in one step:
|
||||
Before deploying, you need a configured remote pointing to the target server. Remotes store the server URL and authentication credentials locally in `~/.twenty/config.json`.
|
||||
|
||||
Add a remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx twenty publish --server <server-url>
|
||||
yarn twenty remote add --url https://your-twenty-server.com --as production
|
||||
```
|
||||
|
||||
Any workspace on that server can then install and upgrade the app from the **Applications** settings page.
|
||||
For a local development server:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote add --local --as local
|
||||
```
|
||||
|
||||
You can also authenticate with an API key for non-interactive environments:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote add --url https://your-twenty-server.com --token <api-key> --as production
|
||||
```
|
||||
|
||||
Manage your remotes:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote switch prod # Set the default remote
|
||||
yarn twenty remote status # Show active remote and auth status
|
||||
yarn twenty remote remove old # Remove a remote
|
||||
```
|
||||
|
||||
### Deploying
|
||||
|
||||
Build and upload your app to the server in one step:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty deploy
|
||||
```
|
||||
|
||||
This builds the app with `--tarball`, then uploads the tarball to the default remote via a GraphQL multipart upload.
|
||||
|
||||
To deploy to a specific remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty deploy -r production
|
||||
```
|
||||
|
||||
### Sharing a deployed app
|
||||
|
||||
Tarball apps are not listed in the public marketplace, so other workspaces on the same server won't discover them by browsing. To share a deployed app:
|
||||
|
||||
1. Go to **Settings > Applications > Registrations** and open your app
|
||||
2. In the **Distribution** tab, click **Copy share link**
|
||||
3. Share this link with users on other workspaces — it takes them directly to the app's install page
|
||||
|
||||
The share link uses the server's base URL (without any workspace subdomain) so it works for any workspace on the server.
|
||||
|
||||
### Version management
|
||||
|
||||
To release an update:
|
||||
|
||||
1. Bump the `version` field in your `package.json`
|
||||
2. Push a new tarball with `npx twenty publish --server <server-url>`
|
||||
3. Workspaces on that server will see the upgrade available in their settings
|
||||
2. Run `yarn twenty deploy` (or `yarn twenty deploy -r production`)
|
||||
3. Workspaces that have the app installed will see the upgrade available in their settings
|
||||
|
||||
<Note>
|
||||
Internal apps are scoped to the server they're pushed to. They won't appear in the public marketplace and can't be installed by workspaces on other servers.
|
||||
</Note>
|
||||
## Installing apps
|
||||
|
||||
## App categories
|
||||
Once an app is published (npm) or deployed (tarball), workspaces install it through the UI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty install
|
||||
```
|
||||
|
||||
Or from the **Settings > Applications** page in the Twenty UI, where both marketplace and tarball-deployed apps can be browsed and installed.
|
||||
|
||||
## App distribution categories
|
||||
|
||||
Twenty organizes apps into three categories based on how they're distributed:
|
||||
|
||||
| Category | How it works | Visible in marketplace? |
|
||||
|----------|-------------|------------------------|
|
||||
| **Development** | Local dev mode apps running via `yarn twenty dev`. Used for building and testing. | No |
|
||||
| **Published** | Apps published to npm with the `twenty-app-` prefix. Listed in the marketplace for any workspace to install. | Yes |
|
||||
| **Internal** | Apps deployed via tarball to a specific server. Available only to workspaces on that server. | No |
|
||||
| **Published (npm)** | Apps published to npm with the `twenty-app` keyword. Listed in the marketplace for any workspace to install. | Yes |
|
||||
| **Internal (tarball)** | Apps deployed via tarball to a specific server. Available only to workspaces on that server via a share link. | No |
|
||||
|
||||
<Tip>
|
||||
Start in **Development** mode while building your app. When it's ready, choose **Published** (npm) for broad distribution or **Internal** (tarball) for private deployment.
|
||||
</Tip>
|
||||
|
||||
## CLI reference
|
||||
|
||||
| Command | Description | Key flags |
|
||||
|---------|-------------|-----------|
|
||||
| `yarn twenty build` | Compile app and generate manifest | `--tarball` — also create a `.tgz` package |
|
||||
| `yarn twenty publish` | Build and publish to npm | `--tag <tag>` — npm dist-tag (e.g., `beta`, `next`) |
|
||||
| `yarn twenty deploy` | Build and upload tarball to a server | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty catalog-sync` | Trigger marketplace catalog sync on the server | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty install` | Install a deployed app on a workspace | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty dev` | Watch and sync local changes | Uses default remote |
|
||||
| `yarn twenty remote add` | Add a server connection | `--url`, `--token`, `--as`, `--local`, `--port` |
|
||||
| `yarn twenty remote list` | List configured remotes | — |
|
||||
| `yarn twenty remote switch` | Set default remote | — |
|
||||
| `yarn twenty remote status` | Show connection status | — |
|
||||
| `yarn twenty remote remove` | Remove a remote | — |
|
||||
|
||||
@@ -546,7 +546,7 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/clients';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -784,7 +784,7 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/clients';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -1200,8 +1200,8 @@ Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_mo
|
||||
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
|
||||
|
||||
```typescript
|
||||
import { CoreApiClient } from 'twenty-sdk/clients';
|
||||
import { MetadataApiClient } from 'twenty-sdk/clients';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
@@ -1229,7 +1229,7 @@ Notes:
|
||||
The `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
|
||||
|
||||
```typescript
|
||||
import { MetadataApiClient } from 'twenty-sdk/clients';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
@@ -289,6 +289,16 @@ yarn command:prod cron:workflow:automated-cron-trigger
|
||||
**Environment-only mode:** If you set `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`, add these variables to your `.env` file instead.
|
||||
</Warning>
|
||||
|
||||
## S3 Storage
|
||||
|
||||
<Warning>
|
||||
By default, Twenty stores uploaded files on the local filesystem. For production deployments, use S3 or an S3-compatible service (MinIO, DigitalOcean Spaces, etc.) to ensure files persist across container restarts and scale across multiple server instances.
|
||||
</Warning>
|
||||
|
||||
Set `STORAGE_TYPE=S_3` and configure the `STORAGE_S3_*` variables through the admin panel or `.env`. See the [config-variables.ts reference](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts) for the full list of S3 variables.
|
||||
|
||||
When using S3 with CORS-dependent features (e.g. in-browser file downloads), make sure your bucket allows your Twenty frontend origin in its CORS configuration.
|
||||
|
||||
## Logic Functions & Code Interpreter
|
||||
|
||||
Twenty supports logic functions for workflows and the code interpreter for AI data analysis. Both run user-provided code and require explicit configuration for security.
|
||||
|
||||
@@ -15,19 +15,21 @@ description: عرّف الكائنات، والدوال المنطقية، وم
|
||||
|
||||
يوفّر SDK دوالًا مساعدة لتعريف كيانات تطبيقك. كما هو موضح في [اكتشاف الكيانات](/l/ar/developers/extend/apps/getting-started#entity-detection)، يجب استخدام `export default define<Entity>({...})` كي يتم اكتشاف كياناتك:
|
||||
|
||||
| دالة | الغرض |
|
||||
| -------------------------------- | ---------------------------------------------------- |
|
||||
| `defineApplication` | تهيئة بيانات التعريف للتطبيق (مطلوب، واحد لكل تطبيق) |
|
||||
| `defineObject` | تعريف كائنات مخصصة مع حقول |
|
||||
| `defineLogicFunction` | تعريف وظائف منطقية مع معالجات |
|
||||
| `definePreInstallLogicFunction` | تعريف دالة منطقية لما قبل التثبيت (واحدة لكل تطبيق) |
|
||||
| `definePostInstallLogicFunction` | تعريف دالة منطقية لما بعد التثبيت (واحدة لكل تطبيق) |
|
||||
| `defineFrontComponent` | عرِّف مكوّنات أمامية لواجهة مستخدم مخصّصة |
|
||||
| `defineRole` | تهيئة صلاحيات الدور والوصول إلى الكائنات |
|
||||
| `defineField` | وسّع الكائنات الموجودة بحقول إضافية |
|
||||
| `defineView` | تعريف العروض المحفوظة للكائنات |
|
||||
| `defineNavigationMenuItem` | تعريف روابط التنقل في الشريط الجانبي |
|
||||
| `defineSkill` | عرّف مهارات وكيل الذكاء الاصطناعي |
|
||||
| دالة | الغرض |
|
||||
| -------------------------------- | -------------------------------------------------------------- |
|
||||
| `defineApplication` | تهيئة بيانات التعريف للتطبيق (مطلوب، واحد لكل تطبيق) |
|
||||
| `defineObject` | تعريف كائنات مخصصة مع حقول |
|
||||
| `defineField` | وسّع الكائنات الموجودة بحقول إضافية أو عرّف حقول علاقات مستقلة |
|
||||
| `defineLogicFunction` | تعريف وظائف منطقية مع معالجات |
|
||||
| `definePreInstallLogicFunction` | تعريف دالة منطقية لما قبل التثبيت (واحدة لكل تطبيق) |
|
||||
| `definePostInstallLogicFunction` | تعريف دالة منطقية لما بعد التثبيت (واحدة لكل تطبيق) |
|
||||
| `defineFrontComponent` | عرِّف مكوّنات أمامية لواجهة مستخدم مخصّصة |
|
||||
| `defineRole` | تهيئة صلاحيات الدور والوصول إلى الكائنات |
|
||||
| `defineView` | تعريف العروض المحفوظة للكائنات |
|
||||
| `defineNavigationMenuItem` | تعريف روابط التنقل في الشريط الجانبي |
|
||||
| `defineSkill` | عرّف مهارات وكيل الذكاء الاصطناعي |
|
||||
| `defineAgent` | عرّف وكلاء الذكاء الاصطناعي |
|
||||
| `definePageLayout` | عرّف تخطيطات صفحات مخصّصة |
|
||||
|
||||
تتحقق هذه الدوال من تكوينك وقت البناء وتوفّر إكمالًا تلقائيًا في بيئة التطوير وأمان الأنواع.
|
||||
|
||||
@@ -36,7 +38,7 @@ description: عرّف الكائنات، والدوال المنطقية، وم
|
||||
تصف الكائنات المخصصة كلًا من المخطط والسلوك للسجلات في مساحة عملك. استخدم `defineObject()` لتعريف كائنات مع تحقق مدمج:
|
||||
|
||||
```typescript
|
||||
// src/app/postCard.object.ts
|
||||
// src/objects/postCard.object.ts
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
enum PostCardStatus {
|
||||
@@ -110,7 +112,7 @@ export default defineObject({
|
||||
* `universalIdentifier` يجب أن يكون فريدًا وثابتًا عبر عمليات النشر.
|
||||
* يتطلب كل حقل `name` و`type` و`label` ومعرّف `universalIdentifier` ثابتًا خاصًا به.
|
||||
* المصفوفة `fields` اختيارية — يمكنك تعريف كائنات بدون حقول مخصصة.
|
||||
* يمكنك إنشاء كائنات جديدة باستخدام `yarn twenty entity:add`، والذي يرشدك خلال التسمية والحقول والعلاقات.
|
||||
* يمكنك إنشاء كائنات جديدة باستخدام `yarn twenty add`، والذي يرشدك خلال التسمية والحقول والعلاقات.
|
||||
|
||||
<Note>
|
||||
**يتم إنشاء الحقول الأساسية تلقائيًا.** عند تعريف كائن مخصص، يضيف Twenty تلقائيًا حقولًا قياسية
|
||||
@@ -120,6 +122,197 @@ export default defineObject({
|
||||
لكن هذا غير مستحسن.
|
||||
</Note>
|
||||
|
||||
### تعريف الحقول على الكائنات الموجودة
|
||||
|
||||
استخدم `defineField()` لإضافة حقول إلى كائنات لا تملكها — مثل كائنات Twenty القياسية (Person, Company, etc.) أو كائنات من تطبيقات أخرى. على خلاف الحقول المضمّنة في `defineObject()`، تتطلّب الحقول المستقلة `objectUniversalIdentifier` لتحديد الكائن الذي تقوم بتوسيعه:
|
||||
|
||||
```typescript
|
||||
// src/fields/company-loyalty-tier.field.ts
|
||||
import { defineField, FieldType } from 'twenty-sdk';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
|
||||
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
|
||||
name: 'loyaltyTier',
|
||||
type: FieldType.SELECT,
|
||||
label: 'Loyalty Tier',
|
||||
icon: 'IconStar',
|
||||
options: [
|
||||
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
|
||||
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
|
||||
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
النقاط الرئيسية:
|
||||
|
||||
* `objectUniversalIdentifier` يحدّد الكائن الهدف. بالنسبة للكائنات القياسية، استخدم `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS` المُصدَّر من `twenty-sdk`.
|
||||
* عند تعريف الحقول بشكل مضمّن في `defineObject()`، **لا** تحتاج إلى `objectUniversalIdentifier` — إذ يُورَّث من الكائن الأب.
|
||||
* `defineField()` هي الطريقة الوحيدة لإضافة حقول إلى كائنات لم تُنشئها باستخدام `defineObject()`.
|
||||
|
||||
### العلاقات
|
||||
|
||||
تربط العلاقات الكائنات معًا. في Twenty، تكون العلاقات دائمًا **ثنائية الاتجاه** — حيث تعرّف الجانبين، ويشير كل جانب إلى الآخر.
|
||||
|
||||
هناك نوعان من العلاقات:
|
||||
|
||||
| نوع العلاقة | الوصف | هل لديه مفتاح خارجي؟ |
|
||||
| ------------- | ------------------------------------------------------ | ---------------------- |
|
||||
| `MANY_TO_ONE` | تشير العديد من سجلات هذا الكائن إلى سجل واحد من الهدف | نعم (`joinColumnName`) |
|
||||
| `ONE_TO_MANY` | يحتوي سجل واحد من هذا الكائن على العديد من سجلات الهدف | لا (الجانب العكسي) |
|
||||
|
||||
#### كيف تعمل العلاقات
|
||||
|
||||
تتطلّب كل علاقة **حقلين** يشيران إلى بعضهما البعض:
|
||||
|
||||
1. جانب **MANY_TO_ONE** — يوجد على الكائن الذي يحمل المفتاح الخارجي
|
||||
2. جانب **ONE_TO_MANY** — يوجد على الكائن الذي يملك المجموعة
|
||||
|
||||
يستخدم كلا الحقلين `FieldType.RELATION` ويُحيل كلٌ منهما إلى الآخر عبر `relationTargetFieldMetadataUniversalIdentifier`.
|
||||
|
||||
#### مثال: البطاقة البريدية لديها العديد من المستلمين
|
||||
|
||||
افترض أن `PostCard` يمكن إرسالها إلى العديد من سجلات `PostCardRecipient`. ينتمي كل مستلم إلى بطاقة بريدية واحدة بالضبط.
|
||||
|
||||
**الخطوة 1: عرّف جانب ONE_TO_MANY على PostCard** (جانب "الواحد"):
|
||||
|
||||
```typescript
|
||||
// src/fields/post-card-recipients-on-post-card.field.ts
|
||||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||||
|
||||
// Export so the other side can reference it
|
||||
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
|
||||
// Import from the other side
|
||||
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCardRecipients',
|
||||
label: 'Post Card Recipients',
|
||||
icon: 'IconUsers',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**الخطوة 2: عرّف جانب MANY_TO_ONE على PostCardRecipient** (جانب "العديد" — يحمل المفتاح الخارجي):
|
||||
|
||||
```typescript
|
||||
// src/fields/post-card-on-post-card-recipient.field.ts
|
||||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||||
|
||||
// Export so the other side can reference it
|
||||
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
|
||||
// Import from the other side
|
||||
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_FIELD_ID,
|
||||
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCard',
|
||||
label: 'Post Card',
|
||||
icon: 'IconMail',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'postCardId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
**الاستيرادات الدائرية:** كلا حقلي العلاقة يُحيل كلٌ منهما إلى `universalIdentifier` الخاص بالآخر. لتجنّب مشكلات الاستيراد الدائري، صدّر معرّفات الحقول كثوابت مسمّاة من كل ملف، واستوردها في الملف الآخر. يقوم نظام البناء بحلّها في وقت الترجمة.
|
||||
</Note>
|
||||
|
||||
#### الربط مع الكائنات القياسية
|
||||
|
||||
لإنشاء علاقة مع كائن Twenty مضمّن (Person, Company, etc.)، استخدم `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`:
|
||||
|
||||
```typescript
|
||||
// src/fields/person-on-self-hosting-user.field.ts
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
OnDeleteAction,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
|
||||
|
||||
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
|
||||
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PERSON_FIELD_ID,
|
||||
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'person',
|
||||
label: 'Person',
|
||||
description: 'Person matching with the self hosting user',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'personId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### خصائص حقل العلاقة
|
||||
|
||||
| الخاصية | مطلوب | الوصف |
|
||||
| ------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------- |
|
||||
| `type` | نعم | يجب أن يكون `FieldType.RELATION` |
|
||||
| `relationTargetObjectMetadataUniversalIdentifier` | نعم | قيمة `universalIdentifier` للكائن الهدف |
|
||||
| `relationTargetFieldMetadataUniversalIdentifier` | نعم | قيمة `universalIdentifier` للحقل المطابق على الكائن الهدف |
|
||||
| `universalSettings.relationType` | نعم | `RelationType.MANY_TO_ONE` أو `RelationType.ONE_TO_MANY` |
|
||||
| `universalSettings.onDelete` | MANY_TO_ONE فقط | ماذا يحدث عند حذف السجل المشار إليه: `CASCADE`، `SET_NULL`، `RESTRICT`، أو `NO_ACTION` |
|
||||
| `universalSettings.joinColumnName` | MANY_TO_ONE فقط | اسم عمود قاعدة البيانات للمفتاح الخارجي (مثل `postCardId`) |
|
||||
|
||||
#### حقول العلاقات المضمّنة في defineObject
|
||||
|
||||
يمكنك أيضًا تعريف حقول العلاقات مباشرةً داخل `defineObject()`. في هذه الحالة، احذف `objectUniversalIdentifier` — إذ يُورَّث من الكائن الأب:
|
||||
|
||||
```typescript
|
||||
export default defineObject({
|
||||
universalIdentifier: '...',
|
||||
nameSingular: 'postCardRecipient',
|
||||
// ...
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: POST_CARD_FIELD_ID,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCard',
|
||||
label: 'Post Card',
|
||||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'postCardId',
|
||||
},
|
||||
},
|
||||
// ... other fields
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### تكوين التطبيق (application-config.ts)
|
||||
|
||||
كل تطبيق لديه ملف واحد `application-config.ts` يصف:
|
||||
@@ -161,6 +354,22 @@ export default defineApplication({
|
||||
* `defaultRoleUniversalIdentifier` يجب أن يطابق ملف الدور (انظر أدناه).
|
||||
* يتم اكتشاف دوال ما قبل التثبيت وما بعد التثبيت تلقائيًا أثناء إنشاء ملف البيان. راجع [دوال ما قبل التثبيت](#pre-install-functions) و[دوال ما بعد التثبيت](#post-install-functions).
|
||||
|
||||
#### بيانات التعريف لسوق التطبيقات
|
||||
|
||||
إذا كنت تخطط لـ [نشر تطبيقك](/l/ar/developers/extend/apps/publishing)، فإن هذه الحقول الاختيارية تتحكّم في كيفية ظهور تطبيقك في السوق:
|
||||
|
||||
| الحقل | الوصف |
|
||||
| ------------------ | ---------------------------------------------------- |
|
||||
| `author` | اسم المؤلف أو الشركة |
|
||||
| `category` | فئة التطبيق لتصفية سوق التطبيقات |
|
||||
| `logoUrl` | المسار إلى شعار تطبيقك (نسبيًا إلى `./assets/`) |
|
||||
| `screenshots` | مصفوفة لمسارات لقطات الشاشة (نسبيًا إلى `./assets/`) |
|
||||
| `aboutDescription` | وصف ماركداون أطول لعلامة التبويب "حول" |
|
||||
| `websiteUrl` | رابط إلى موقعك الإلكتروني |
|
||||
| `termsUrl` | رابط إلى شروط الخدمة |
|
||||
| `emailSupport` | عنوان البريد الإلكتروني للدعم |
|
||||
| `issueReportUrl` | رابط إلى متتبّع المشاكل |
|
||||
|
||||
#### الأدوار والصلاحيات
|
||||
|
||||
يمكن للتطبيقات تعريف أدوار تُغلّف الصلاحيات على كائنات وإجراءات مساحة العمل لديك. يعين الحقل `defaultRoleUniversalIdentifier` في `application-config.ts` الدور الافتراضي الذي تستخدمه وظائف المنطق في تطبيقك.
|
||||
@@ -230,7 +439,7 @@ export default defineRole({
|
||||
كل ملف وظيفة يستخدم `defineLogicFunction()` لتصدير تكوين مع معالج ومشغّلات اختيارية.
|
||||
|
||||
```typescript
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
// src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
@@ -324,7 +533,7 @@ export default definePreInstallLogicFunction({
|
||||
يمكنك أيضًا تنفيذ دالة ما قبل التثبيت يدويًا في أي وقت باستخدام CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --preInstall
|
||||
yarn twenty exec --preInstall
|
||||
```
|
||||
|
||||
النقاط الرئيسية:
|
||||
@@ -334,7 +543,7 @@ yarn twenty function:execute --preInstall
|
||||
* يُسمح بدالة ما قبل التثبيت واحدة فقط لكل تطبيق. سيُنتج إنشاء ملف البيان خطأً إذا تم اكتشاف أكثر من واحدة.
|
||||
* يتم تعيين `universalIdentifier` للدالة تلقائيًا كـ `preInstallLogicFunctionUniversalIdentifier` في بيان التطبيق أثناء الإنشاء — لست بحاجة إلى الإشارة إليه في `defineApplication()`.
|
||||
* تم ضبط المهلة الافتراضية على 300 ثانية (5 دقائق) للسماح بمهام التحضير الأطول.
|
||||
* لا تحتاج دوال ما قبل التثبيت إلى مُشغِّلات — إذ يستدعيها النظام الأساسي قبل التثبيت أو يدويًا عبر `function:execute --preInstall`.
|
||||
* لا تحتاج دوال ما قبل التثبيت إلى مشغّلات — إذ يستدعيها النظام الأساسي قبل التثبيت أو يدويًا عبر `exec --preInstall`.
|
||||
|
||||
### دوال ما بعد التثبيت
|
||||
|
||||
@@ -362,7 +571,7 @@ export default definePostInstallLogicFunction({
|
||||
يمكنك أيضًا تنفيذ دالة ما بعد التثبيت يدويًا في أي وقت باستخدام CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
yarn twenty exec --postInstall
|
||||
```
|
||||
|
||||
النقاط الرئيسية:
|
||||
@@ -372,7 +581,7 @@ yarn twenty function:execute --postInstall
|
||||
* يُسمح بدالة ما بعد التثبيت واحدة فقط لكل تطبيق. سيُنتج إنشاء ملف البيان خطأً إذا تم اكتشاف أكثر من واحدة.
|
||||
* يتم تعيين `universalIdentifier` للدالة تلقائيًا كـ `postInstallLogicFunctionUniversalIdentifier` في بيان التطبيق أثناء الإنشاء — لست بحاجة إلى الإشارة إليه في `defineApplication()`.
|
||||
* تم تعيين مهلة افتراضية إلى 300 ثانية (5 دقائق) للسماح بمهام الإعداد الأطول مثل تهيئة البيانات.
|
||||
* لا تحتاج دوال ما بعد التثبيت إلى مُشغِّلات — حيث يستدعيها النظام الأساسي أثناء التثبيت أو يدويًا عبر `function:execute --postInstall`.
|
||||
* لا تحتاج دوال ما بعد التثبيت إلى مشغّلات — حيث يستدعيها النظام الأساسي أثناء التثبيت أو يدويًا عبر `exec --postInstall`.
|
||||
|
||||
### حمولة مشغل المسار
|
||||
|
||||
@@ -416,15 +625,15 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
يحتوي نوع `RoutePayload` على البنية التالية:
|
||||
|
||||
| الخاصية | النوع | الوصف |
|
||||
| ---------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `headers` | `Record<string, string \| undefined>` | رؤوس HTTP (فقط تلك المدرجة في `forwardedRequestHeaders`) |
|
||||
| `queryStringParameters` | `Record<string, string \| undefined>` | معلمات سلسلة الاستعلام (تُضمّ القيم المتعددة باستخدام فواصل) |
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | معلمات المسار المستخرجة من نمط المسار (على سبيل المثال، `/users/:id` → `{ id: '123' }`) |
|
||||
| `المحتوى` | `object \| null` | جسم الطلب المُحلَّل (JSON) |
|
||||
| `isBase64Encoded` | `قيمة منطقية` | ما إذا كان جسم الطلب مُرمَّزًا بترميز base64 |
|
||||
| `requestContext.http.method` | `string` | طريقة HTTP (GET, POST, PUT, PATCH, DELETE) |
|
||||
| `requestContext.http.path` | `string` | المسار الخام للطلب |
|
||||
| الخاصية | النوع | الوصف |
|
||||
| ---------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `headers` | `Record<string, string \| undefined>` | رؤوس HTTP (فقط تلك المدرجة في `forwardedRequestHeaders`) |
|
||||
| `queryStringParameters` | `Record<string, string \| undefined>` | معلمات سلسلة الاستعلام (تُضمّ القيم المتعددة باستخدام فواصل) |
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | معلمات المسار المستخرجة من نمط المسار (على سبيل المثال، `/users/:id` -> `{ id: '123' }`) |
|
||||
| `المحتوى` | `object \| null` | جسم الطلب المُحلَّل (JSON) |
|
||||
| `isBase64Encoded` | `قيمة منطقية` | ما إذا كان جسم الطلب مُرمَّزًا بترميز base64 |
|
||||
| `requestContext.http.method` | `string` | طريقة HTTP (GET, POST, PUT, PATCH, DELETE) |
|
||||
| `requestContext.http.path` | `string` | المسار الخام للطلب |
|
||||
|
||||
### تمرير رؤوس HTTP
|
||||
|
||||
@@ -466,7 +675,7 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
يمكنك إنشاء وظائف جديدة بطريقتين:
|
||||
|
||||
* **مُنشأ بالقالب**: شغّل `yarn twenty entity:add` واختر خيار إضافة دالة منطقية جديدة. يُولّد هذا ملفًا مبدئيًا مع معالج وتكوين.
|
||||
* **مُنشأ بالقالب**: شغّل `yarn twenty add` واختر خيار إضافة وظيفة منطقية جديدة. يُولّد هذا ملفًا مبدئيًا مع معالج وتكوين.
|
||||
* **يدوي**: أنشئ ملفًا جديدًا `*.logic-function.ts` واستخدم `defineLogicFunction()` مع اتباع النمط نفسه.
|
||||
|
||||
### تمييز دالة منطقية كأداة
|
||||
@@ -478,7 +687,7 @@ const handler = async (event: RoutePayload) => {
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -567,7 +776,7 @@ export default defineFrontComponent({
|
||||
|
||||
يمكنك إنشاء مكوّنات أمامية جديدة بطريقتين:
|
||||
|
||||
* **مُنشأ بالقالب**: شغّل `yarn twenty entity:add` واختر خيار إضافة مكوّن أمامي جديد.
|
||||
* **مُنشأ بالقالب**: شغّل `yarn twenty add` واختر خيار إضافة مكوّن أمامي جديد.
|
||||
* **يدوي**: أنشئ ملفًا جديدًا `.tsx` واستخدم `defineFrontComponent()` مع اتباع النمط نفسه.
|
||||
|
||||
### المهارات
|
||||
@@ -602,47 +811,122 @@ export default defineSkill({
|
||||
|
||||
يمكنك إنشاء مهارات جديدة بطريقتين:
|
||||
|
||||
* **مُنشأ بالقالب**: شغِّل `yarn twenty entity:add` واختر خيار إضافة مهارة جديدة.
|
||||
* **مُنشأ بالقالب**: شغّل `yarn twenty add` واختر خيار إضافة مهارة جديدة.
|
||||
* **يدوي**: أنشئ ملفًا جديدًا واستخدم `defineSkill()` مع اتباع النمط نفسه.
|
||||
|
||||
### عملاء مُولَّدون مضبوطو الأنواع
|
||||
### عملاء واجهة برمجة التطبيقات ذات أنواع ثابتة (`twenty-client-sdk`)
|
||||
|
||||
يتم توليد عميلين مضبوطي الأنواع تلقائيًا بواسطة `yarn twenty dev` وتخزينهما في `node_modules/twenty-sdk/generated` استنادًا إلى مخطط مساحة العمل لديك:
|
||||
توفر حزمة `twenty-client-sdk` عميلين لـ GraphQL ذوي أنواع ثابتة للتفاعل مع واجهة Twenty البرمجية من وظائفك المنطقية ومكوّنات الواجهة الأمامية:
|
||||
|
||||
* **`CoreApiClient`** — يُجري استعلامات إلى نقطة النهاية `/graphql` للحصول على بيانات مساحة العمل
|
||||
* **`MetadataApiClient`** — يستعلم عن نقطة النهاية `/metadata` لتكوين مساحة العمل وتحميل الملفات.
|
||||
| العميل | استيراد | نقطة النهاية | مُولَّد؟ |
|
||||
| ------------------- | ---------------------------- | --------------------------------------------------- | -------------------------- |
|
||||
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — بيانات مساحة العمل (السجلات، الكائنات) | نعم، في وقت التطوير/البناء |
|
||||
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — تكوين مساحة العمل، رفع الملفات | لا، يأتي مُجهزًا مسبقًا |
|
||||
|
||||
#### CoreApiClient
|
||||
|
||||
`CoreApiClient` هو العميل الرئيسي للاستعلام وتعديل بيانات مساحة العمل. يتم توليده من مخطط مساحة العمل الخاصة بك أثناء `yarn twenty dev` أو `yarn twenty build`، لذا فهو مكتوب الأنواع بالكامل ليتوافق مع كائناتك وحقولك.
|
||||
|
||||
```typescript
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
// Query records
|
||||
const { companies } = await client.query({
|
||||
companies: {
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
domainName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a record
|
||||
const { createCompany } = await client.mutation({
|
||||
createCompany: {
|
||||
__args: {
|
||||
data: {
|
||||
name: 'Acme Corp',
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
يُعاد توليد كلا العميلين تلقائيًا بواسطة `yarn twenty dev` كلما تغيّرت كائناتك أو حقولك.
|
||||
يستخدم العميل صياغة مجموعة اختيار: مرِّر `true` لتضمين حقل، واستخدم `__args` للوسيطات، وعشّش الكائنات للعلاقات. ستحصل على إكمال تلقائي كامل وفحص للأنواع يعتمد على مخطط مساحة العمل لديك.
|
||||
|
||||
#### بيانات الاعتماد وقت التشغيل في الدوال المنطقية
|
||||
<Note>
|
||||
**يتم توليد CoreApiClient في وقت التطوير/البناء.** إذا حاولت استخدامه دون تشغيل `yarn twenty dev` أو `yarn twenty build` أولًا، فسوف ينتج خطأ. تتم عملية التوليد تلقائيًا — حيث يقوم CLI بفحص مخطط GraphQL الخاص بمساحة العمل لديك، ويولّد عميلًا مكتوب الأنواع باستخدام `@genql/cli`، ويكتب المصادر المُولَّدة إلى `node_modules/twenty-client-sdk/dist/core/generated/`، ويستبدل الأجزاء الوهمية في `node_modules/twenty-client-sdk/dist/core.mjs` و`node_modules/twenty-client-sdk/dist/core.cjs`.
|
||||
</Note>
|
||||
|
||||
عندما تعمل دالتك على Twenty، يقوم النظام الأساسي بحقن بيانات الاعتماد كمتغيرات بيئة قبل تنفيذ كودك:
|
||||
#### استخدام CoreSchema للتعليقات التوضيحية للأنواع
|
||||
|
||||
* `TWENTY_API_URL`: عنوان URL الأساسي لواجهة Twenty البرمجية التي يستهدفها تطبيقك.
|
||||
* `TWENTY_API_KEY`: مفتاح قصير العمر ذو نطاق يقتصر على الدور الافتراضي لوظيفة تطبيقك.
|
||||
يوفّر `CoreSchema` أنواع TypeScript المطابقة لكائنات مساحة العمل لديك، وهو مفيد لتعيين أنواع حالة المكوّن أو معاملات الدوال:
|
||||
|
||||
الملاحظات:
|
||||
```typescript
|
||||
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
* لا تحتاج إلى تمرير عنوان URL أو مفتاح واجهة برمجة التطبيقات إلى العميل المُولَّد. يقوم بقراءة `TWENTY_API_URL` و`TWENTY_API_KEY` من process.env وقت التشغيل.
|
||||
* تُحدَّد أذونات مفتاح واجهة برمجة التطبيقات بواسطة الدور المشار إليه في `application-config.ts` عبر `defaultRoleUniversalIdentifier`. هذا هو الدور الافتراضي الذي تستخدمه الدوال المنطقية في تطبيقك.
|
||||
* يمكن للتطبيقات تعريف أدوار لاتباع مبدأ أقل الامتياز. امنح فقط الأذونات التي تحتاجها وظائفك، ثم وجّه `defaultRoleUniversalIdentifier` إلى المعرّف الشامل لذلك الدور.
|
||||
const [company, setCompany] = useState<
|
||||
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
|
||||
>(undefined);
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const result = await client.query({
|
||||
company: {
|
||||
__args: { filter: { position: { eq: 1 } } },
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
setCompany(result.company);
|
||||
```
|
||||
|
||||
#### MetadataApiClient
|
||||
|
||||
يأتي `MetadataApiClient` مُجهّزًا مسبقًا مع SDK (لا حاجة للتوليد). يستعلم عن نقطة النهاية `/metadata` للحصول على تكوين مساحة العمل والتطبيقات ورفع الملفات:
|
||||
|
||||
```typescript
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
// Query workspace info
|
||||
const { currentWorkspace } = await metadataClient.query({
|
||||
currentWorkspace: { id: true, displayName: true },
|
||||
});
|
||||
|
||||
// List installed applications
|
||||
const { findManyApplications } = await metadataClient.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
version: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### بيانات الاعتماد أثناء وقت التشغيل
|
||||
|
||||
عند تشغيل كودك على Twenty (وظائف منطقية أو مكوّنات أمامية)، يقوم النظام الأساسي بحقن بيانات الاعتماد كمتغيرات بيئية:
|
||||
|
||||
* `TWENTY_API_URL` — عنوان URL الأساسي لواجهة Twenty البرمجية
|
||||
* `TWENTY_API_KEY` — مفتاح قصير العمر ذو نطاق يقتصر على الدور الافتراضي لوظيفة تطبيقك
|
||||
|
||||
لست **بحاجة** إلى تمرير هذه القيم إلى العملاء — فهي تُقرأ تلقائيًا من `process.env`. تُحدَّد أذونات مفتاح واجهة برمجة التطبيقات بواسطة الدور المشار إليه في `defaultRoleUniversalIdentifier` ضمن `application-config.ts`.
|
||||
|
||||
#### رفع الملفات
|
||||
|
||||
يتضمن `MetadataApiClient` المُولَّد طريقة `uploadFile` لإرفاق الملفات بالحقول من نوع ملف ضمن كائنات مساحة العمل الخاصة بك. نظرًا لأن عملاء GraphQL القياسيون لا يدعمون تحميل الملفات متعددة الأجزاء افتراضيًا، يوفر العميل هذه الطريقة المخصصة التي تطبق [مواصفة طلب GraphQL متعدد الأجزاء](https://github.com/jaydenseric/graphql-multipart-request-spec) في الخلفية.
|
||||
يتضمن `MetadataApiClient` طريقة `uploadFile` لإرفاق الملفات بالحقول من نوع الملف. يطبّق [مواصفة طلب GraphQL متعددة الأجزاء](https://github.com/jaydenseric/graphql-multipart-request-spec):
|
||||
|
||||
```typescript
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
@@ -652,25 +936,14 @@ const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
|
||||
'application/pdf', // MIME type
|
||||
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
|
||||
);
|
||||
|
||||
console.log(uploadedFile);
|
||||
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
|
||||
```
|
||||
|
||||
توقيع الطريقة:
|
||||
|
||||
```typescript
|
||||
uploadFile(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
fieldMetadataUniversalIdentifier: string,
|
||||
): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }>
|
||||
```
|
||||
|
||||
| المعلمة | النوع | الوصف |
|
||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------- |
|
||||
| `fileBuffer` | `Buffer` | المحتوى الخام للملف |
|
||||
@@ -680,8 +953,7 @@ uploadFile(
|
||||
|
||||
النقاط الرئيسية:
|
||||
|
||||
* تتوفر طريقة `uploadFile` على `MetadataApiClient` لأن عملية الـ mutation الخاصة بالرفع تُعالَج عبر نقطة النهاية `/metadata`.
|
||||
* تستخدم `universalIdentifier` الخاص بالحقل (وليس المعرّف الخاص بمساحة العمل)، ليعمل كود الرفع لديك عبر أي مساحة عمل مُثبَّت فيها تطبيقك — بما يتماشى مع كيفية إشارة التطبيقات إلى الحقول في كل مكان آخر.
|
||||
* يستخدم `universalIdentifier` الخاص بالحقل (وليس معرّفه الخاص بمساحة العمل)، بحيث يعمل كود الرفع لديك عبر أي مساحة عمل مُثبَّت فيها تطبيقك.
|
||||
* العنوان `url` المُعاد هو عنوان URL موقّع يمكنك استخدامه للوصول إلى الملف المرفوع.
|
||||
|
||||
### مثال Hello World
|
||||
|
||||
@@ -49,7 +49,7 @@ npx create-twenty-app@latest my-app --minimal
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
yarn twenty add
|
||||
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
@@ -123,7 +123,7 @@ my-twenty-app/
|
||||
بشكل عام:
|
||||
|
||||
* **package.json**: يصرّح باسم التطبيق والإصدار والمحرّكات (Node 24+، Yarn 4)، ويضيف `twenty-sdk` بالإضافة إلى نص برمجي `twenty` يفوِّض إلى `twenty` CLI المحلي. شغِّل `yarn twenty help` لعرض جميع الأوامر المتاحة.
|
||||
* **.gitignore**: يتجاهل العناصر الشائعة مثل `node_modules` و`.yarn` و`generated/` (عميل مضبوط الأنواع) و`dist/` و`build/` ومجلدات التغطية وملفات السجلات وملفات `.env*`.
|
||||
* **.gitignore**: Ignores common artifacts such as `node_modules`, `.yarn`, `.twenty/`, `dist/`, `build/`, coverage folders, log files, and `.env*` files.
|
||||
* **yarn.lock**، **.yarnrc.yml**، **.yarn/**: تقوم بقفل وتكوين حزمة أدوات Yarn 4 المستخدمة في المشروع.
|
||||
* **.nvmrc**: يثبّت إصدار Node.js المتوقع للمشروع.
|
||||
* **.oxlintrc.json** و **tsconfig.json**: يقدّمان إعدادات الفحص والتهيئة لـ TypeScript لمصادر TypeScript في تطبيقك.
|
||||
@@ -167,8 +167,8 @@ export default defineObject({
|
||||
|
||||
ستضيف الأوامر اللاحقة مزيدًا من الملفات والمجلدات:
|
||||
|
||||
* `yarn twenty dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
|
||||
* `yarn twenty entity:add` سيضيف ملفات تعريف الكيانات ضمن `src/` لكائناتك المخصّصة، والوظائف، ومكوّنات الواجهة الأمامية، والأدوار، والمهارات، وغير ذلك.
|
||||
* `yarn twenty dev` will auto-generate the typed `CoreApiClient` (for workspace data via `/graphql`) into `node_modules/twenty-client-sdk/`. The `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built and is available immediately. Import them from `twenty-client-sdk/core` and `twenty-client-sdk/metadata` respectively.
|
||||
* `yarn twenty add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
|
||||
## المصادقة
|
||||
|
||||
@@ -221,7 +221,7 @@ yarn add -D twenty-sdk
|
||||
}
|
||||
```
|
||||
|
||||
Now you can run all commands via `yarn twenty <command>`, e.g. `yarn twenty dev`, `yarn twenty help`, etc.
|
||||
الآن يمكنك تشغيل جميع الأوامر عبر `yarn twenty <command>`، مثلًا: `yarn twenty dev`، `yarn twenty help`، إلخ.
|
||||
|
||||
## كيفية استخدام مثيل محلي من Twenty
|
||||
|
||||
@@ -239,7 +239,7 @@ yarn twenty remote add --local --port 3000
|
||||
|
||||
* أخطاء المصادقة: شغّل `yarn twenty auth:login` وتأكد من أن مفتاح واجهة برمجة التطبيقات لديك يمتلك الأذونات المطلوبة.
|
||||
* يتعذّر الاتصال بالخادم: تحقق من عنوان URL لواجهة برمجة التطبيقات وأن خادم Twenty قابل للوصول.
|
||||
* Types or client missing/outdated: restart `yarn twenty dev` — it auto-generates the typed client.
|
||||
* Dev mode not syncing: ensure `yarn twenty dev` is running and that changes are not ignored by your environment.
|
||||
* الأنواع أو العميل مفقود/قديم: أعد تشغيل `yarn twenty dev` — فهو يولِّد العميل مضبوط الأنواع تلقائيًا.
|
||||
* وضع التطوير لا يزامن: تأكد من أن `yarn twenty dev` قيد التشغيل وأن التغييرات غير متجاهلة في بيئتك.
|
||||
|
||||
قناة المساعدة على Discord: https://discord.com/channels/1130383047699738754/1130386664812982322
|
||||
|
||||
@@ -12,7 +12,25 @@ description: وزّع تطبيق Twenty الخاص بك على سوق Twenty أ
|
||||
بمجرد أن يكون تطبيقك [مبنيًا ومختبرًا محليًا](/l/ar/developers/extend/apps/building)، لديك مساران لتوزيعه:
|
||||
|
||||
* **النشر على npm** — أدرج تطبيقك في سوق Twenty ليتسنى لأي مساحة عمل اكتشافه وتثبيته.
|
||||
* **إرسال tarball** — انشر تطبيقك إلى خادم Twenty معيّن للاستخدام الداخلي من دون جعله متاحًا للعامة.
|
||||
* **نشر أرشيف tar** — ارفع تطبيقك مباشرةً إلى خادم Twenty محدد للاستخدام الداخلي أو الخاص.
|
||||
|
||||
كلا المسارين يبدآن من نفس خطوة **build**.
|
||||
|
||||
## بناء تطبيقك
|
||||
|
||||
يقوم الأمر `build` بتجميع مصادر TypeScript الخاصة بك، وتحويل دوال المنطق ومكوّنات الواجهة الأمامية، وإنشاء ملف `manifest.json` يصف محتويات تطبيقك:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build
|
||||
```
|
||||
|
||||
يتم حفظ المخرجات في `.twenty/output/`. This directory contains everything needed for distribution: compiled code, assets, the manifest, and a copy of your `package.json`.
|
||||
|
||||
To also create a `.tgz` tarball (used by the deploy command internally, or for manual distribution):
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build --tarball
|
||||
```
|
||||
|
||||
## النشر على npm
|
||||
|
||||
@@ -21,29 +39,72 @@ description: وزّع تطبيق Twenty الخاص بك على سوق Twenty أ
|
||||
### المتطلبات
|
||||
|
||||
* حساب على [npm](https://www.npmjs.com)
|
||||
* يجب أن يستخدم اسم الحزمة البادئة `twenty-app-` (مثلًا، `twenty-app-postcard-sender`)
|
||||
* The `twenty-app` keyword **must** be listed in your `package.json` `keywords` array
|
||||
|
||||
### Adding the required keyword
|
||||
|
||||
The Twenty marketplace discovers apps by searching the npm registry for packages with the `twenty-app` keyword. Add it to your `package.json`:
|
||||
|
||||
```json filename="package.json"
|
||||
{
|
||||
"name": "twenty-app-postcard-sender",
|
||||
"version": "1.0.0",
|
||||
"keywords": ["twenty-app"],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
The marketplace searches for `keywords:twenty-app` on the npm registry. Without this keyword, your package won't appear in the marketplace even if it has the `twenty-app-` name prefix.
|
||||
</Note>
|
||||
|
||||
### الخطوات
|
||||
|
||||
1. **قم ببناء تطبيقك** — تقوم أداة CLI بتجميع مصادر TypeScript الخاصة بك وإنشاء ملف بيان التطبيق:
|
||||
1. **Build your app:**
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty build
|
||||
```
|
||||
|
||||
2. **النشر على npm** — ادفع الحزمة المبنية إلى سجل npm:
|
||||
2. **Publish to npm:**
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx twenty publish
|
||||
yarn twenty publish
|
||||
```
|
||||
|
||||
### الاكتشاف التلقائي
|
||||
This runs `npm publish` from the `.twenty/output/` directory.
|
||||
|
||||
تُكتشف الحِزم التي تحمل البادئة `twenty-app-` تلقائيًا بواسطة فهرس سوق Twenty. بعد نشره، سيظهر تطبيقك في السوق خلال بضع دقائق — من دون الحاجة إلى تسجيل يدوي أو موافقة يدوية.
|
||||
To publish under a specific dist-tag (e.g., `beta` or `next`):
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty publish --tag beta
|
||||
```
|
||||
|
||||
### How marketplace discovery works
|
||||
|
||||
The Twenty server syncs its marketplace catalog from the npm registry **every hour**:
|
||||
|
||||
1. It searches for all npm packages with the `keywords:twenty-app` keyword
|
||||
2. For each package, it fetches the `manifest.json` from the npm CDN
|
||||
3. The app's metadata (name, description, author, logo, screenshots, category) is extracted from the manifest and displayed in the marketplace
|
||||
|
||||
After publishing, your app can take up to one hour to appear in the marketplace. To trigger the sync immediately instead of waiting for the next hourly run:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync
|
||||
```
|
||||
|
||||
To target a specific remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync -r production
|
||||
```
|
||||
|
||||
The metadata shown in the marketplace comes from your `defineApplication()` call in your app source code — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
|
||||
|
||||
### النشر عبر CI
|
||||
|
||||
يتضمن المشروع المُولَّد سير عمل GitHub Actions يقوم بالنشر عند كل إصدار. يشغِّل `app:build`، ثم ينفِّذ `npm publish --provenance` من مخرجات البناء:
|
||||
يتضمن المشروع المُولَّد سير عمل GitHub Actions يقوم بالنشر عند كل إصدار:
|
||||
|
||||
```yaml filename=".github/workflows/publish.yml"
|
||||
name: Publish
|
||||
@@ -72,48 +133,117 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
```
|
||||
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `npx twenty build`, then `npm publish` from `.twenty/output`.
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `yarn twenty build`, then `npm publish` from `.twenty/output`.
|
||||
|
||||
<Tip>
|
||||
**npm provenance** اختياري ولكنه موصى به. يضيف النشر باستخدام `--provenance` شارة ثقة إلى إدراجك على npm، مما يتيح للمستخدمين التحقق من أن الحزمة تم بناؤها من التزام محدد ضمن خط أنابيب CI عام. راجع [وثائق npm provenance](https://docs.npmjs.com/generating-provenance-statements) للحصول على تعليمات الإعداد.
|
||||
</Tip>
|
||||
|
||||
## التوزيع الداخلي
|
||||
## Deploying to a server (tarball)
|
||||
|
||||
بالنسبة للتطبيقات التي لا تريد إتاحتها للعامة — مثل الأدوات المملوكة، أو عمليات التكامل الخاصة بالمؤسسات فقط، أو الإصدارات التجريبية — يمكنك إرسال tarball مباشرةً إلى خادم Twenty.
|
||||
For apps you don't want publicly available — proprietary tools, enterprise-only integrations, or experimental builds — you can deploy a tarball directly to a Twenty server.
|
||||
|
||||
### إرسال tarball
|
||||
### المتطلبات الأساسية
|
||||
|
||||
قم ببناء تطبيقك وانشره إلى خادم محدد في خطوة واحدة:
|
||||
Before deploying, you need a configured remote pointing to the target server. Remotes store the server URL and authentication credentials locally in `~/.twenty/config.json`.
|
||||
|
||||
Add a remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx twenty publish --server <server-url>
|
||||
yarn twenty remote add --url https://your-twenty-server.com --as production
|
||||
```
|
||||
|
||||
يمكن لأي مساحة عمل على ذلك الخادم بعدها تثبيت التطبيق وترقيته من صفحة الإعدادات **التطبيقات**.
|
||||
For a local development server:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote add --local --as local
|
||||
```
|
||||
|
||||
You can also authenticate with an API key for non-interactive environments:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote add --url https://your-twenty-server.com --token <api-key> --as production
|
||||
```
|
||||
|
||||
Manage your remotes:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote switch prod # Set the default remote
|
||||
yarn twenty remote status # Show active remote and auth status
|
||||
yarn twenty remote remove old # Remove a remote
|
||||
```
|
||||
|
||||
### Deploying
|
||||
|
||||
Build and upload your app to the server in one step:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty deploy
|
||||
```
|
||||
|
||||
This builds the app with `--tarball`, then uploads the tarball to the default remote via a GraphQL multipart upload.
|
||||
|
||||
To deploy to a specific remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty deploy -r production
|
||||
```
|
||||
|
||||
### Sharing a deployed app
|
||||
|
||||
Tarball apps are not listed in the public marketplace, so other workspaces on the same server won't discover them by browsing. To share a deployed app:
|
||||
|
||||
1. Go to **Settings > Applications > Registrations** and open your app
|
||||
2. In the **Distribution** tab, click **Copy share link**
|
||||
3. Share this link with users on other workspaces — it takes them directly to the app's install page
|
||||
|
||||
The share link uses the server's base URL (without any workspace subdomain) so it works for any workspace on the server.
|
||||
|
||||
### إدارة الإصدارات
|
||||
|
||||
لطرح تحديث:
|
||||
|
||||
1. ارفع قيمة الحقل `version` في ملف `package.json`
|
||||
2. Push a new tarball with `npx twenty publish --server <server-url>`
|
||||
3. سترى مساحات العمل على ذلك الخادم الترقية متاحة في إعداداتها
|
||||
2. Run `yarn twenty deploy` (or `yarn twenty deploy -r production`)
|
||||
3. Workspaces that have the app installed will see the upgrade available in their settings
|
||||
|
||||
<Note>
|
||||
التطبيقات الداخلية مقتصرة على الخادم الذي تُرسل إليه. لن تظهر في السوق العام ولا يمكن لمساحات العمل على خوادم أخرى تثبيتها.
|
||||
</Note>
|
||||
## Installing apps
|
||||
|
||||
## فئات التطبيقات
|
||||
Once an app is published (npm) or deployed (tarball), workspaces install it through the UI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty install
|
||||
```
|
||||
|
||||
Or from the **Settings > Applications** page in the Twenty UI, where both marketplace and tarball-deployed apps can be browsed and installed.
|
||||
|
||||
## App distribution categories
|
||||
|
||||
تُنظِّم Twenty التطبيقات في ثلاث فئات استنادًا إلى طريقة توزيعها:
|
||||
|
||||
| الفئة | كيف يعمل | مرئي في سوق Twenty؟ |
|
||||
| ----------- | ---------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| **التطوير** | Local dev mode apps running via `yarn twenty dev`. تُستخدم للبناء والاختبار. | لا |
|
||||
| **منشور** | تطبيقات منشورة على npm مع البادئة `twenty-app-`. مدرجة في سوق Twenty لتتمكن أي مساحة عمل من تثبيتها. | نعم |
|
||||
| **داخلي** | تطبيقات منشورة عبر tarball إلى خادم محدد. متاحة فقط لمساحات العمل على ذلك الخادم. | لا |
|
||||
| الفئة | كيف يعمل | مرئي في سوق Twenty؟ |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------- | ------------------- |
|
||||
| **التطوير** | تطبيقات وضع التطوير المحلي التي تعمل عبر `yarn twenty dev`. تُستخدم للبناء والاختبار. | لا |
|
||||
| **Published (npm)** | Apps published to npm with the `twenty-app` keyword. مدرجة في سوق Twenty لتتمكن أي مساحة عمل من تثبيتها. | نعم |
|
||||
| **Internal (tarball)** | تطبيقات منشورة عبر tarball إلى خادم محدد. Available only to workspaces on that server via a share link. | لا |
|
||||
|
||||
<Tip>
|
||||
ابدأ في وضع **التطوير** أثناء بناء تطبيقك. عندما يصبح جاهزًا، اختر **منشور** (npm) للتوزيع الواسع أو **داخلي** (tarball) للنشر الخاص.
|
||||
</Tip>
|
||||
|
||||
## CLI reference
|
||||
|
||||
| أمر | الوصف | Key flags |
|
||||
| --------------------------- | ---------------------------------------------- | --------------------------------------------------- |
|
||||
| `yarn twenty build` | Compile app and generate manifest | `--tarball` — also create a `.tgz` package |
|
||||
| `yarn twenty publish` | Build and publish to npm | `--tag <tag>` — npm dist-tag (e.g., `beta`, `next`) |
|
||||
| `yarn twenty deploy` | Build and upload tarball to a server | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty catalog-sync` | Trigger marketplace catalog sync on the server | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty install` | Install a deployed app on a workspace | `-r, --remote <name>` — target remote |
|
||||
| `yarn twenty dev` | Watch and sync local changes | Uses default remote |
|
||||
| `yarn twenty remote add` | Add a server connection | `--url`, `--token`, `--as`, `--local`, `--port` |
|
||||
| `yarn twenty remote list` | List configured remotes | — |
|
||||
| `yarn twenty remote switch` | Set default remote | — |
|
||||
| `yarn twenty remote status` | Show connection status | — |
|
||||
| `yarn twenty remote remove` | Remove a remote | — |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user