Compare commits

..

2 Commits

Author SHA1 Message Date
Thomas Trompette ccdaa31273 WIP¨ 2026-03-20 15:25:35 +01:00
Thomas Trompette f4f7ccd0e1 Improve workflow metrics with faillure reason 2026-03-19 15:30:54 +01:00
2896 changed files with 91601 additions and 261575 deletions
@@ -3,13 +3,12 @@ description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Accepts "latest" (pulls the latest Docker Hub image, checks out main) or a
semver tag (e.g., v0.40.0).
Pulls the specified semver image tag from Docker Hub.
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag — either "latest" or a semver tag (e.g., v0.40.0).'
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
@@ -31,19 +30,12 @@ outputs:
runs:
using: 'composite'
steps:
- name: Resolve version
id: resolve
- name: Validate version
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if [ "$VERSION" = "latest" ]; then
echo "docker-tag=latest" >> "$GITHUB_OUTPUT"
echo "git-ref=main" >> "$GITHUB_OUTPUT"
elif echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "docker-tag=$VERSION" >> "$GITHUB_OUTPUT"
echo "git-ref=$VERSION" >> "$GITHUB_OUTPUT"
else
echo "::error::twenty-version must be \"latest\" or a semver tag (e.g., v0.40.0). Got: '$VERSION'"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
@@ -51,7 +43,7 @@ runs:
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ steps.resolve.outputs.git-ref }}
ref: ${{ inputs.twenty-version }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
@@ -64,7 +56,7 @@ runs:
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ steps.resolve.outputs.docker-tag }}" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
-3
View File
@@ -10,9 +10,6 @@ packages:
'twenty-sdk':
access: $all
publish: $all
'twenty-client-sdk':
access: $all
publish: $all
'create-twenty-app':
access: $all
publish: $all
-68
View File
@@ -1,68 +0,0 @@
name: AI Catalog Sync
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
jobs:
sync-catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Run catalog sync
run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/ai-sync-models-dev.ts
- name: Check for changes
id: changes
run: |
if git diff --quiet packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-providers.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
title: 'chore: sync AI model catalog from models.dev'
body: |
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based on the latest data.
New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same model family.
**Please review before merging** — verify no critical models were incorrectly deprecated.
branch: chore/ai-catalog-sync
base: main
labels: ai, automated
delete-branch: true
- name: Trigger automerge
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: automated-pr-ready
+2 -15
View File
@@ -36,9 +36,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -53,16 +50,10 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
@@ -152,6 +143,7 @@ jobs:
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed current branch database with test data
run: |
@@ -304,6 +296,7 @@ jobs:
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed main branch database with test data
run: |
@@ -402,12 +395,6 @@ jobs:
# Clean up temp directory
rm -rf /tmp/current-branch-files
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Install OpenAPI Diff Tool
run: |
# Using the Java-based OpenAPITools/openapi-diff via Docker
+9 -33
View File
@@ -21,12 +21,10 @@ 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:
@@ -37,9 +35,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -54,13 +49,8 @@ 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
@@ -74,13 +64,12 @@ 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 $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
npx nx run-many -t set-local-version -p twenty-sdk create-twenty-app --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
npx nx build twenty-sdk
npx nx build create-twenty-app
- name: Install and start Verdaccio
run: |
@@ -97,13 +86,11 @@ jobs:
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
npm set //localhost:4873/:_authToken "ci-auth-token"
for pkg in $PUBLISHABLE_PACKAGES; do
for pkg in twenty-sdk create-twenty-app; do
cd packages/$pkg
yarn npm publish --tag ci
npm publish --registry http://localhost:4873 --tag ci
cd ../..
done
@@ -166,15 +153,11 @@ jobs:
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000
- name: Deploy scaffolded app
- name: Build scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
npx --no-install twenty build
test -d .twenty/output
- name: Execute hello-world logic function
run: |
@@ -183,13 +166,6 @@ 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
-6
View File
@@ -26,9 +26,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -43,9 +40,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
steps:
-6
View File
@@ -57,9 +57,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -74,9 +71,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
env:
+6 -20
View File
@@ -25,7 +25,7 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-client-sdk/**
packages/twenty-sdk/src/clients/generated/metadata/**
packages/twenty-emails/**
packages/twenty-shared/**
@@ -84,9 +84,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -101,9 +98,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
steps:
@@ -128,6 +122,7 @@ jobs:
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Worker / Run
run: |
timeout 30s npx nx run twenty-server:worker || exit_code=$?
@@ -182,14 +177,14 @@ jobs:
HAS_ERRORS=true
fi
npx nx run twenty-client-sdk:generate-metadata-client
npx nx run twenty-sdk:generate-metadata-client
if ! git diff --quiet -- packages/twenty-client-sdk/src/metadata/generated; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-client-sdk:generate-metadata-client' and commit the changes."
if ! git diff --quiet -- packages/twenty-sdk/src/clients/generated/metadata; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-sdk:generate-metadata-client' and commit the changes."
echo ""
echo "The following SDK metadata client changes were detected:"
echo "==================================================="
git diff -- packages/twenty-client-sdk/src/metadata/generated
git diff -- packages/twenty-sdk/src/clients/generated/metadata
echo "==================================================="
echo ""
HAS_ERRORS=true
@@ -232,9 +227,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -249,16 +241,10 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
+5 -68
View File
@@ -1,4 +1,4 @@
name: CI Docker
name: CI Docker Compose
permissions:
contents: read
@@ -19,7 +19,7 @@ jobs:
files: |
packages/twenty-docker/**
docker-compose.yml
test-compose:
test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
@@ -27,18 +27,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i docker-compose.yml
yq eval '.services.server.restart = "no"' -i docker-compose.yml
echo "Setting up .env file..."
@@ -94,69 +89,11 @@ jobs:
echo "Still waiting for server... (${count}/300s)"
done
working-directory: ./packages/twenty-docker/
test-app-dev:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Create frontend placeholder
run: |
mkdir -p packages/twenty-front/build
echo '<html><body>CI placeholder</body></html>' > packages/twenty-front/build/index.html
- name: Build app-dev image
run: |
docker build \
--target twenty-app-dev \
-f packages/twenty-docker/twenty/Dockerfile \
-t twenty-app-dev-ci \
.
- name: Start container
run: |
docker run -d --name twenty-app-dev \
-p 3000:3000 \
twenty-app-dev-ci
docker logs twenty-app-dev -f &
- name: Wait for server health
run: |
echo "Waiting for twenty-app-dev to become healthy..."
count=0
while true; do
status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/healthz 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
echo "Server is healthy!"
curl -s http://localhost:3000/healthz
break
fi
container_status=$(docker inspect --format='{{.State.Status}}' twenty-app-dev 2>/dev/null || echo "unknown")
if [ "$container_status" = "exited" ]; then
echo "Container exited unexpectedly"
docker logs twenty-app-dev
exit 1
fi
count=$((count+1))
if [ $count -gt 300 ]; then
echo "Server did not become healthy within 5 minutes"
docker logs twenty-app-dev
exit 1
fi
echo "Still waiting... (${count}/300s) [HTTP ${status}]"
sleep 1
done
ci-test-docker-status-check:
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, test-compose, test-app-dev]
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
-112
View File
@@ -1,112 +0,0 @@
name: CI UI
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-ui/**
packages/twenty-shared/**
ui-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }}
run: npx nx ${{ matrix.task }} twenty-ui
ui-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
retention-days: 1
ui-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: ui-sb-build
env:
STORYBOOK_URL: http://localhost:6007
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-ui
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-ui/storybook-static --port 6007 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6007 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-ui
ci-ui-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
ui-task,
ui-sb-build,
ui-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-3
View File
@@ -27,9 +27,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
-6
View File
@@ -30,9 +30,6 @@ jobs:
services:
postgres:
image: twentycrm/twenty-postgres-spilo
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
@@ -47,9 +44,6 @@ jobs:
--health-retries 5
redis:
image: redis
credentials:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
ports:
- 6379:6379
steps:
-8
View File
@@ -150,11 +150,3 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-8
View File
@@ -138,11 +138,3 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-8
View File
@@ -102,11 +102,3 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
@@ -17,12 +17,6 @@ jobs:
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
@@ -30,12 +24,10 @@ jobs:
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
@@ -1,144 +0,0 @@
name: Visual Regression Dispatch
# Uses workflow_run to dispatch visual regression to ci-privileged.
# This runs in the context of the base repo (not the fork), so it has
# access to secrets — making it work for external contributor PRs.
on:
workflow_run:
workflows: ['CI Front', 'CI UI']
types: [completed]
permissions:
actions: read
pull-requests: read
jobs:
dispatch:
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Determine project and artifact name
id: project
uses: actions/github-script@v7
with:
script: |
const workflowName = context.payload.workflow_run.name;
if (workflowName === 'CI Front') {
core.setOutput('project', 'twenty-front');
core.setOutput('artifact_name', 'storybook-static');
core.setOutput('tarball_name', 'storybook-twenty-front-tarball');
core.setOutput('tarball_file', 'storybook-twenty-front.tar.gz');
} else if (workflowName === 'CI UI') {
core.setOutput('project', 'twenty-ui');
core.setOutput('artifact_name', 'storybook-twenty-ui');
core.setOutput('tarball_name', 'storybook-twenty-ui-tarball');
core.setOutput('tarball_file', 'storybook-twenty-ui.tar.gz');
} else {
core.setFailed(`Unexpected workflow: ${workflowName}`);
}
- name: Check if storybook artifact exists
id: check-artifact
uses: actions/github-script@v7
with:
script: |
const artifactName = '${{ steps.project.outputs.artifact_name }}';
const runId = context.payload.workflow_run.id;
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
const found = artifacts.artifacts.some(a => a.name === artifactName);
core.setOutput('exists', found ? 'true' : 'false');
if (!found) {
core.info(`Artifact "${artifactName}" not found in run ${runId} — storybook build was likely skipped`);
}
- name: Get PR number
if: steps.check-artifact.outputs.exists == 'true'
id: pr-info
uses: actions/github-script@v7
with:
script: |
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head label (owner:branch)
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
const headLabel = `${headRepo.owner.login}:${headBranch}`;
core.info(`pull_requests is empty (likely a fork PR), searching by head label: ${headLabel}`);
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run — skipping');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}`);
- name: Download storybook artifact from triggering run
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/download-artifact@v4
with:
name: ${{ steps.project.outputs.artifact_name }}
path: storybook-static
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Package storybook
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
run: tar -czf /tmp/${{ steps.project.outputs.tarball_file }} -C storybook-static .
- name: Upload storybook tarball
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.project.outputs.tarball_name }}
path: /tmp/${{ steps.project.outputs.tarball_file }}
retention-days: 1
- name: Dispatch to ci-privileged
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ steps.pr-info.outputs.pr_number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "${{ steps.project.outputs.project }}",
"branch": "${{ github.event.workflow_run.head_branch }}",
"commit": "${{ github.event.workflow_run.head_sha }}"
}
-1
View File
@@ -1,7 +1,6 @@
**/**/.env
.DS_Store
/.idea
.claude/settings.json
**/**/node_modules/
.cache
+2 -2
View File
@@ -2,8 +2,8 @@
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
"env": {}
},
"playwright": {
-940
View File
File diff suppressed because one or more lines are too long
+942
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,4 +6,4 @@ enableInlineHunks: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
yarnPath: .yarn/releases/yarn-4.9.2.cjs
-11
View File
@@ -80,17 +80,6 @@ npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/mi
npx nx run twenty-server:command workspace:sync-metadata
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
+2 -5
View File
@@ -175,7 +175,7 @@
},
"license": "AGPL-3.0",
"name": "twenty",
"packageManager": "yarn@4.13.0",
"packageManager": "yarn@4.9.2",
"resolutions": {
"graphql": "16.8.1",
"type-fest": "4.10.1",
@@ -203,17 +203,14 @@
"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-companion"
"packages/twenty-oxlint-rules"
]
},
"prettier": {
+4 -5
View File
@@ -41,10 +41,9 @@ cd my-twenty-app
# Or do it manually:
yarn twenty server start # Start local Twenty server
yarn twenty remote add http://localhost:2020 --as local # Authenticate via OAuth
yarn twenty remote add --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
@@ -125,10 +124,10 @@ The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty remote add <url>` to authenticate with your Twenty workspace via OAuth.
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth.
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`.
- `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'`.
## Build and publish your application
@@ -169,7 +168,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 <url>`.
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add --local`.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.8.0-canary.5",
"version": "0.8.0-canary.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -36,7 +36,6 @@
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-sdk": "workspace:*",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -46,6 +45,7 @@
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"twenty-sdk": "workspace:*",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
@@ -1,18 +1,17 @@
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 {
@@ -33,10 +32,10 @@ type CreateAppOptions = {
export class CreateAppCommand {
async execute(options: CreateAppOptions = {}): Promise<void> {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(options);
try {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(options);
const exampleOptions = this.resolveExampleOptions(
options.mode ?? 'exhaustive',
);
@@ -47,6 +46,7 @@ export class CreateAppCommand {
await fs.ensureDir(appDirectory);
console.log(chalk.gray(' Scaffolding project files...'));
await copyBaseApplicationProject({
appName,
appDisplayName,
@@ -55,29 +55,27 @@ export class CreateAppCommand {
exampleOptions,
});
console.log(chalk.gray(' Installing dependencies...'));
await install(appDirectory);
console.log(chalk.gray(' Initializing git repository...'));
await tryGitInit(appDirectory);
let serverResult: ServerStartResult | undefined;
let localResult: LocalInstanceResult = { running: false };
if (!options.skipLocalInstance) {
const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
// Auto-detect a running server first
localResult = await setupLocalInstance(appDirectory);
if (startResult.success) {
serverResult = startResult.data;
await this.connectToLocal(serverResult.url);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
if (localResult.running && localResult.serverUrl) {
await this.connectToLocal(appDirectory, localResult.serverUrl);
}
}
this.logSuccess(appDirectory, serverResult);
this.logSuccess(appDirectory, localResult);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
chalk.red('Initialization failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
@@ -195,30 +193,29 @@ export class CreateAppCommand {
appDirectory: string;
appName: string;
}): void {
console.log(
chalk.blue('\n', 'Creating Twenty Application\n'),
chalk.gray(`- Directory: ${appDirectory}\n`, `- Name: ${appName}\n`),
);
console.log(chalk.blue('Creating Twenty Application'));
console.log(chalk.gray(` Directory: ${appDirectory}`));
console.log(chalk.gray(` Name: ${appName}`));
console.log('');
}
private async connectToLocal(serverUrl: string): Promise<void> {
private async connectToLocal(
appDirectory: string,
serverUrl: string,
): Promise<void> {
try {
const result = await authLoginOAuth({
apiUrl: serverUrl,
remote: 'local',
});
if (!result.success) {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
),
);
}
execSync(
`npx nx run twenty-sdk:start -- remote add ${serverUrl} --as local`,
{
cwd: appDirectory,
stdio: 'inherit',
},
);
console.log(chalk.green('Authenticated with local Twenty instance.'));
} catch {
console.log(
chalk.yellow(
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
'Authentication skipped. Run `npx nx run twenty-sdk:start -- remote add --local` manually.',
),
);
}
@@ -226,23 +223,25 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
serverResult?: ServerStartResult,
localResult: LocalInstanceResult,
): void {
const dirName = basename(appDirectory);
const dirName = appDirectory.split('/').reverse()[0] ?? '';
console.log(chalk.blue('\nApplication created. Next steps:'));
console.log(chalk.gray(`- cd ${dirName}`));
console.log(chalk.green('Application created!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
if (!serverResult) {
if (!localResult.running) {
console.log(
chalk.gray(
'- yarn twenty remote add --local # Authenticate with Twenty',
' yarn twenty remote add --local # Authenticate with Twenty',
),
);
}
console.log(
chalk.gray('- yarn twenty dev # Start dev mode'),
chalk.gray(' yarn twenty dev # Start dev mode'),
);
}
}
@@ -106,9 +106,6 @@ describe('copyBaseApplicationProject', () => {
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-client-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.scripts['twenty']).toBe('twenty');
});
@@ -365,21 +362,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(true);
@@ -461,21 +448,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(false);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(false);
@@ -503,7 +480,7 @@ describe('copyBaseApplicationProject', () => {
});
describe('selective examples', () => {
it('should create front component and page layout when only front component option is enabled', async () => {
it('should create only front component when only that option is enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
@@ -529,11 +506,6 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -573,11 +545,6 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -47,17 +47,15 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'",
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-client-sdk/metadata'",
"import { MetadataApiClient } from 'twenty-sdk/clients'",
);
expect(content).toContain(
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
);
expect(content).toContain('appBuild');
expect(content).toContain('appDeploy');
expect(content).toContain('appInstall');
expect(content).toContain('appUninstall');
expect(content).toContain('new MetadataApiClient()');
expect(content).toContain('findManyApplications');
@@ -138,7 +136,7 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('yarn install --immutable');
expect(content).toContain('yarn test');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('TWENTY_API_KEY');
expect(content).toContain('TWENTY_TEST_API_KEY');
});
});
@@ -34,8 +34,6 @@ export const copyBaseApplicationProject = async ({
await createGitignore(appDirectory);
await createNvmrc(appDirectory);
await createPublicAssetDirectory(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
@@ -71,12 +69,6 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'logic-functions',
fileName: 'hello-world.ts',
});
await createCreateCompanyFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'create-hello-world-company.ts',
});
}
if (exampleOptions.includeExampleFrontComponent) {
@@ -85,12 +77,6 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'front-components',
fileName: 'hello-world.tsx',
});
await createExamplePageLayout({
appDirectory: sourceFolderPath,
fileFolder: 'page-layouts',
fileName: 'example-record-page-layout.ts',
});
}
if (exampleOptions.includeExampleView) {
@@ -200,10 +186,6 @@ yarn-error.log*
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
const createNvmrc = async (appDirectory: string) => {
await fs.writeFile(join(appDirectory, '.nvmrc'), '24.5.0\n');
};
const createDefaultRoleConfig = async ({
displayName,
appDirectory,
@@ -248,59 +230,19 @@ const createDefaultFrontComponent = async ({
}) => {
const universalIdentifier = v4();
const content = `import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
const content = `import { defineFrontComponent } from 'twenty-sdk';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '${universalIdentifier}',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -311,56 +253,6 @@ export default defineFrontComponent({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExamplePageLayout = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const pageLayoutUniversalIdentifier = v4();
const tabUniversalIdentifier = v4();
const widgetUniversalIdentifier = v4();
const content = `import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '${pageLayoutUniversalIdentifier}',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '${tabUniversalIdentifier}',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '${widgetUniversalIdentifier}',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultFunction = async ({
appDirectory,
fileFolder,
@@ -396,58 +288,6 @@ export default defineLogicFunction({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createCreateCompanyFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: \`Created company "\${createCompany?.name}" with id \${createCompany?.id}\`,
};
};
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPreInstallFunction = async ({
appDirectory,
fileFolder,
@@ -775,7 +615,6 @@ const createPackageJson = async ({
'react-dom': '^19.0.0',
oxlint: '^0.16.0',
'twenty-sdk': createTwentyAppPackageJson.version,
'twenty-client-sdk': createTwentyAppPackageJson.version,
};
if (includeExampleIntegrationTest) {
@@ -5,7 +5,6 @@ import { exec } from 'child_process';
const execPromise = promisify(exec);
export const install = async (root: string) => {
console.log(chalk.gray('Installing yarn dependencies...'));
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
@@ -15,6 +14,6 @@ export const install = async (root: string) => {
try {
await execPromise('yarn install', { cwd: root });
} catch (error: any) {
console.warn(chalk.yellow('yarn install failed:'), error.stdout);
console.error(chalk.red('yarn install failed:'), error.stdout);
}
};
@@ -0,0 +1,101 @@
import chalk from 'chalk';
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
const DEFAULT_PORT = 2020;
// 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);
}
};
export type LocalInstanceResult = {
running: boolean;
serverUrl?: string;
};
export const setupLocalInstance = async (
appDirectory: string,
): Promise<LocalInstanceResult> => {
console.log('');
console.log(chalk.blue('Setting up local Twenty instance...'));
if (await isServerReady(DEFAULT_PORT)) {
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
console.log(chalk.green(`Twenty server detected on ${serverUrl}.`));
return { running: true, serverUrl };
}
// Delegate to `twenty server start` from the scaffolded app
console.log(chalk.gray('Starting local Twenty server...'));
try {
execSync('yarn twenty server start', {
cwd: appDirectory,
stdio: 'inherit',
});
} catch {
console.log(
chalk.yellow(
'Failed to start Twenty server. Run `yarn twenty server start` manually.',
),
);
return { running: false };
}
console.log(chalk.gray('Waiting for Twenty to be ready...'));
const startTime = Date.now();
const timeoutMs = 180 * 1000;
while (Date.now() - startTime < timeoutMs) {
if (await isServerReady(DEFAULT_PORT)) {
const serverUrl = `http://localhost:${DEFAULT_PORT}`;
console.log(chalk.green(`Twenty server is running on ${serverUrl}.`));
console.log(
chalk.gray(
'Workspace ready — login with tim@apple.dev / tim@apple.dev',
),
);
const openCommand = platform() === 'darwin' ? 'open' : 'xdg-open';
try {
execSync(`${openCommand} ${serverUrl}`, { stdio: 'ignore' });
} catch {
// Ignore if browser can't be opened
}
return { running: true, serverUrl };
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
console.log(
chalk.yellow(
'Twenty server did not become healthy in time. Check: yarn twenty server logs',
),
);
return { running: false };
};
@@ -149,17 +149,18 @@ const createIntegrationTest = async ({
fileName: string;
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
@@ -169,27 +170,14 @@ describe('App installation', () => {
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(\`[deploy] \${message}\`),
});
if (!deployResult.success) {
throw new Error(
\`Deploy failed: \${deployResult.error?.message ?? 'Unknown error'}\`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
\`Install failed: \${installResult.error?.message ?? 'Unknown error'}\`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
@@ -269,7 +257,7 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: \${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: \${{ steps.twenty.outputs.access-token }}
TWENTY_TEST_API_KEY: \${{ steps.twenty.outputs.access-token }}
`;
const workflowDir = join(appDirectory, '.github', 'workflows');
@@ -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 http://localhost:2020 --as local
yarn twenty remote add --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 http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote add --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
@@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: 'b602dbd9-e511-49ce-b6d3-b697218dc69c',
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
type: FieldType.SELECT,
name: 'category',
label: 'Category',
@@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -25,8 +25,8 @@ export default defineObject({
{
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.ADDRESS,
label: 'Mailing Address',
name: 'mailingAddress',
label: 'Address',
name: 'address',
icon: 'IconHome',
},
],
@@ -1,42 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
env:
TWENTY_VERSION: latest
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
-1
View File
@@ -4,7 +4,6 @@
- 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
+2 -2
View File
@@ -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 http://localhost:2020 --as local
yarn twenty remote add --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 http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote add --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,8 +20,7 @@
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"twenty-client-sdk": "portal:../../twenty-client-sdk",
"twenty-sdk": "portal:../../twenty-sdk",
"twenty-sdk": "0.6.4",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
@@ -1,15 +1,16 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
@@ -19,27 +20,14 @@ describe('App installation', () => {
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
@@ -1,13 +0,0 @@
import { defineAgent } from 'twenty-sdk';
export const EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER =
'110bebc2-f116-46b6-a35d-61e91c3c0a43';
export default defineAgent({
universalIdentifier: EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER,
name: 'example-agent',
label: 'Example Agent',
description: 'A sample AI agent for your application',
icon: 'IconRobot',
prompt: 'You are a helpful assistant. Help users with their questions and tasks.',
});
@@ -2,7 +2,7 @@ import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'bb1decf6-dee5-43ef-b881-9799f97b02a8';
'6563e091-9f5b-4026-a3ea-7e3b3d09e218';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
@@ -3,7 +3,7 @@ import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'
export default defineField({
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: 'be08a7c6-2586-4d91-9fa7-0a44e6eae30c',
universalIdentifier: '770d32c2-cf12-4ab2-b66d-73f92dc239b5',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',
@@ -1,56 +1,16 @@
import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'7a758f23-5e7d-497d-98c9-7ca8d6c085b0';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
universalIdentifier: 'd371f098-5b2c-42f0-898d-94459f1ee337',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@@ -1,35 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: `Created company "${createCompany?.name}" with id ${createCompany?.id}`,
};
};
export default defineLogicFunction({
universalIdentifier: '94abaa53-d265-4fa4-ae52-1d7ea711ecf2',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -5,7 +5,7 @@ const handler = async (): Promise<{ message: string }> => {
};
export default defineLogicFunction({
universalIdentifier: 'b05e4b30-72d4-4d7f-8091-32e037b601da',
universalIdentifier: '2baa26eb-9aaf-4856-a4f4-30d6fd6480ee',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePostInstallLogicFunction({
universalIdentifier: '8c726dcc-1709-4eac-aa8b-f99960a9ec1b',
universalIdentifier: '7a3f4684-51db-494d-833b-a747a3b90507',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePreInstallLogicFunction({
universalIdentifier: 'f8ad4b09-6a12-4b12-a52a-3472d3a78dc7',
universalIdentifier: '1272ffdb-8e2f-492c-ab37-66c2b97e9c23',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
@@ -3,7 +3,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',
@@ -1,10 +1,10 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'47fd9bd9-392b-4d9f-9091-9a91b1edf519';
'dfd43356-39b3-4b55-b4a7-279bec689928';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'2d9ff841-cf8e-44ec-ad8e-468455f7eebd';
'd2d7f6cd-33f6-456f-bf00-17adeca926ba';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
@@ -1,31 +0,0 @@
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1,7 +1,7 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'c38f4d11-760c-4d5c-89ed-e569c28b7b70';
'9238bc7b-d38f-4a1c-9d19-31ab7bc67a2f';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'90cf9144-4811-4653-93a2-9a6780fe6aac';
'd0940029-9d3c-40be-903a-52d65393028f';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
@@ -1,7 +1,7 @@
import { defineView, ViewKey } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = '965e3776-b966-4be8-83f7-6cd3bce5e1bd';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = 'e004df40-29f3-47ba-b39d-d3a5c444367a';
export default defineView({
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
@@ -12,7 +12,7 @@ export default defineView({
position: 0,
fields: [
{
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
universalIdentifier: '496c40c2-5766-419c-93bf-20fdad3f34bb',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
+113 -125
View File
@@ -352,9 +352,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/aix-ppc64@npm:0.27.4"
"@esbuild/aix-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/aix-ppc64@npm:0.27.3"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
@@ -366,9 +366,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm64@npm:0.27.4"
"@esbuild/android-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm64@npm:0.27.3"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
@@ -380,9 +380,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm@npm:0.27.4"
"@esbuild/android-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm@npm:0.27.3"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
@@ -394,9 +394,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-x64@npm:0.27.4"
"@esbuild/android-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-x64@npm:0.27.3"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
@@ -408,9 +408,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-arm64@npm:0.27.4"
"@esbuild/darwin-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-arm64@npm:0.27.3"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
@@ -422,9 +422,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-x64@npm:0.27.4"
"@esbuild/darwin-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-x64@npm:0.27.3"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
@@ -436,9 +436,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-arm64@npm:0.27.4"
"@esbuild/freebsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-arm64@npm:0.27.3"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
@@ -450,9 +450,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-x64@npm:0.27.4"
"@esbuild/freebsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-x64@npm:0.27.3"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
@@ -464,9 +464,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm64@npm:0.27.4"
"@esbuild/linux-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm64@npm:0.27.3"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
@@ -478,9 +478,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm@npm:0.27.4"
"@esbuild/linux-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm@npm:0.27.3"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
@@ -492,9 +492,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ia32@npm:0.27.4"
"@esbuild/linux-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ia32@npm:0.27.3"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
@@ -506,9 +506,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-loong64@npm:0.27.4"
"@esbuild/linux-loong64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-loong64@npm:0.27.3"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
@@ -520,9 +520,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-mips64el@npm:0.27.4"
"@esbuild/linux-mips64el@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-mips64el@npm:0.27.3"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
@@ -534,9 +534,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ppc64@npm:0.27.4"
"@esbuild/linux-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ppc64@npm:0.27.3"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
@@ -548,9 +548,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-riscv64@npm:0.27.4"
"@esbuild/linux-riscv64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-riscv64@npm:0.27.3"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
@@ -562,9 +562,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-s390x@npm:0.27.4"
"@esbuild/linux-s390x@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-s390x@npm:0.27.3"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
@@ -576,9 +576,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-x64@npm:0.27.4"
"@esbuild/linux-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-x64@npm:0.27.3"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
@@ -590,9 +590,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-arm64@npm:0.27.4"
"@esbuild/netbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-arm64@npm:0.27.3"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
@@ -604,9 +604,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-x64@npm:0.27.4"
"@esbuild/netbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-x64@npm:0.27.3"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
@@ -618,9 +618,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-arm64@npm:0.27.4"
"@esbuild/openbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-arm64@npm:0.27.3"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
@@ -632,9 +632,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-x64@npm:0.27.4"
"@esbuild/openbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-x64@npm:0.27.3"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
@@ -646,9 +646,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openharmony-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openharmony-arm64@npm:0.27.4"
"@esbuild/openharmony-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openharmony-arm64@npm:0.27.3"
conditions: os=openharmony & cpu=arm64
languageName: node
linkType: hard
@@ -660,9 +660,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/sunos-x64@npm:0.27.4"
"@esbuild/sunos-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/sunos-x64@npm:0.27.3"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
@@ -674,9 +674,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-arm64@npm:0.27.4"
"@esbuild/win32-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-arm64@npm:0.27.3"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
@@ -688,9 +688,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-ia32@npm:0.27.4"
"@esbuild/win32-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-ia32@npm:0.27.3"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
@@ -702,9 +702,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-x64@npm:0.27.4"
"@esbuild/win32-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-x64@npm:0.27.3"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -1541,11 +1541,11 @@ __metadata:
linkType: hard
"@types/node@npm:*":
version: 25.5.0
resolution: "@types/node@npm:25.5.0"
version: 25.3.5
resolution: "@types/node@npm:25.3.5"
dependencies:
undici-types: "npm:~7.18.0"
checksum: 10c0/70c508165b6758c4f88d4f91abca526c3985eee1985503d4c2bd994dbaf588e52ac57e571160f18f117d76e963570ac82bd20e743c18987e82564312b3b62119
checksum: 10c0/4cf0834a6f6933bf0aca6afead117ae3db3b8f02a5f7187a24f871db0fb9344e5e46573ba387bc53b7505e1e219c4c535cbe67221ced95bb5ad98573223b19d0
languageName: node
linkType: hard
@@ -3408,35 +3408,35 @@ __metadata:
linkType: hard
"esbuild@npm:^0.27.0":
version: 0.27.4
resolution: "esbuild@npm:0.27.4"
version: 0.27.3
resolution: "esbuild@npm:0.27.3"
dependencies:
"@esbuild/aix-ppc64": "npm:0.27.4"
"@esbuild/android-arm": "npm:0.27.4"
"@esbuild/android-arm64": "npm:0.27.4"
"@esbuild/android-x64": "npm:0.27.4"
"@esbuild/darwin-arm64": "npm:0.27.4"
"@esbuild/darwin-x64": "npm:0.27.4"
"@esbuild/freebsd-arm64": "npm:0.27.4"
"@esbuild/freebsd-x64": "npm:0.27.4"
"@esbuild/linux-arm": "npm:0.27.4"
"@esbuild/linux-arm64": "npm:0.27.4"
"@esbuild/linux-ia32": "npm:0.27.4"
"@esbuild/linux-loong64": "npm:0.27.4"
"@esbuild/linux-mips64el": "npm:0.27.4"
"@esbuild/linux-ppc64": "npm:0.27.4"
"@esbuild/linux-riscv64": "npm:0.27.4"
"@esbuild/linux-s390x": "npm:0.27.4"
"@esbuild/linux-x64": "npm:0.27.4"
"@esbuild/netbsd-arm64": "npm:0.27.4"
"@esbuild/netbsd-x64": "npm:0.27.4"
"@esbuild/openbsd-arm64": "npm:0.27.4"
"@esbuild/openbsd-x64": "npm:0.27.4"
"@esbuild/openharmony-arm64": "npm:0.27.4"
"@esbuild/sunos-x64": "npm:0.27.4"
"@esbuild/win32-arm64": "npm:0.27.4"
"@esbuild/win32-ia32": "npm:0.27.4"
"@esbuild/win32-x64": "npm:0.27.4"
"@esbuild/aix-ppc64": "npm:0.27.3"
"@esbuild/android-arm": "npm:0.27.3"
"@esbuild/android-arm64": "npm:0.27.3"
"@esbuild/android-x64": "npm:0.27.3"
"@esbuild/darwin-arm64": "npm:0.27.3"
"@esbuild/darwin-x64": "npm:0.27.3"
"@esbuild/freebsd-arm64": "npm:0.27.3"
"@esbuild/freebsd-x64": "npm:0.27.3"
"@esbuild/linux-arm": "npm:0.27.3"
"@esbuild/linux-arm64": "npm:0.27.3"
"@esbuild/linux-ia32": "npm:0.27.3"
"@esbuild/linux-loong64": "npm:0.27.3"
"@esbuild/linux-mips64el": "npm:0.27.3"
"@esbuild/linux-ppc64": "npm:0.27.3"
"@esbuild/linux-riscv64": "npm:0.27.3"
"@esbuild/linux-s390x": "npm:0.27.3"
"@esbuild/linux-x64": "npm:0.27.3"
"@esbuild/netbsd-arm64": "npm:0.27.3"
"@esbuild/netbsd-x64": "npm:0.27.3"
"@esbuild/openbsd-arm64": "npm:0.27.3"
"@esbuild/openbsd-x64": "npm:0.27.3"
"@esbuild/openharmony-arm64": "npm:0.27.3"
"@esbuild/sunos-x64": "npm:0.27.3"
"@esbuild/win32-arm64": "npm:0.27.3"
"@esbuild/win32-ia32": "npm:0.27.3"
"@esbuild/win32-x64": "npm:0.27.3"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
@@ -3492,7 +3492,7 @@ __metadata:
optional: true
bin:
esbuild: bin/esbuild
checksum: 10c0/2a1c2bcccda279f2afd72a7f8259860cb4483b32453d17878e1ecb4ac416b9e7c1001e7aa0a25ba4c29c1e250a3ceaae5d8bb72a119815bc8db4e9b5f5321490
checksum: 10c0/fdc3f87a3f08b3ef98362f37377136c389a0d180fda4b8d073b26ba930cf245521db0a368f119cc7624bc619248fff1439f5811f062d853576f8ffa3df8ee5f1
languageName: node
linkType: hard
@@ -3906,7 +3906,6 @@ __metadata:
"@types/react": "npm:^18.2.0"
oxlint: "npm:^0.16.0"
react: "npm:^18.2.0"
twenty-client-sdk: "portal:../../twenty-client-sdk"
twenty-sdk: "portal:../../twenty-sdk"
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
@@ -4953,9 +4952,9 @@ __metadata:
linkType: hard
"preact@npm:^10.28.3":
version: 10.29.0
resolution: "preact@npm:10.29.0"
checksum: 10c0/d111381e5b48335e3a797a03adb83521cf5e9bdf880570fb2eff4fe9da9c82e6dedcbdf54538b1ed8f60bf813a0df0f4891b03dc32140ad93f8f720a8812dd5c
version: 10.28.4
resolution: "preact@npm:10.28.4"
checksum: 10c0/712384e92d6c676c8f4c230eab81329a66acfecf700390aebfe5fc46168fa3686345d47f2292068dcc0a6d86425a3d9a3d743730e356e867a8402442afb989db
languageName: node
linkType: hard
@@ -5751,17 +5750,6 @@ __metadata:
languageName: node
linkType: hard
"twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A."
dependencies:
"@genql/cli": "npm:^3.0.3"
"@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.25.0"
graphql: "npm:^16.8.1"
languageName: node
linkType: soft
"twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A."
@@ -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 http://localhost:2020 --as local
yarn twenty remote add --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 http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote add --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
-47
View File
@@ -1,47 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist", "generated"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}
@@ -1,2 +0,0 @@
dist
generated
-60
View File
@@ -1,60 +0,0 @@
{
"name": "twenty-client-sdk",
"version": "0.8.0-canary.5",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
"build": "npx rimraf dist && npx vite build && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
},
"exports": {
"./core": {
"types": "./dist/core/index.d.ts",
"import": "./dist/core.mjs",
"require": "./dist/core.cjs"
},
"./metadata": {
"types": "./dist/metadata/index.d.ts",
"import": "./dist/metadata.mjs",
"require": "./dist/metadata.cjs"
},
"./generate": {
"types": "./dist/generate/index.d.ts",
"import": "./dist/generate.mjs",
"require": "./dist/generate.cjs"
}
},
"typesVersions": {
"*": {
"core": [
"dist/core/index.d.ts"
],
"metadata": [
"dist/metadata/index.d.ts"
],
"generate": [
"dist/generate/index.d.ts"
]
}
},
"files": [
"dist"
],
"dependencies": {
"@genql/cli": "^3.0.3",
"@genql/runtime": "^2.10.0",
"esbuild": "^0.25.0",
"graphql": "^16.8.1"
},
"devDependencies": {
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.18"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}
-44
View File
@@ -1,44 +0,0 @@
{
"name": "twenty-client-sdk",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-client-sdk/src",
"projectType": "library",
"tags": ["scope:sdk"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
],
"parallel": false
}
},
"generate-metadata-client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/src/metadata/generated"],
"options": {
"cwd": "packages/twenty-client-sdk",
"command": "tsx scripts/generate-metadata-client.ts"
}
},
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"config": "{projectRoot}/vitest.config.ts"
}
},
"set-local-version": {},
"typecheck": {},
"lint": {}
}
}
@@ -1,65 +0,0 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getIntrospectionQuery, buildClientSchema, printSchema } from 'graphql';
import { generateMetadataClient } from '../src/generate/generate-metadata-client';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TEMPLATE_PATH = path.resolve(
__dirname,
'..',
'src',
'generate',
'twenty-client-template.ts',
);
const introspectSchema = async (url: string): Promise<string> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: getIntrospectionQuery() }),
});
const json = await response.json();
if (json.errors) {
throw new Error(
`GraphQL introspection errors: ${JSON.stringify(json.errors)}`,
);
}
return printSchema(buildClientSchema(json.data));
};
const main = async () => {
const serverUrl = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const schema = await introspectSchema(`${serverUrl}/metadata`);
const clientWrapperTemplateSource = await readFile(TEMPLATE_PATH, 'utf-8');
const outputPath = path.resolve(
__dirname,
'..',
'src',
'metadata',
'generated',
);
await generateMetadataClient({
schema,
outputPath,
clientWrapperTemplateSource,
});
console.log(`Metadata client generated at ${outputPath}`);
};
main().catch((error) => {
console.error('Failed to generate metadata client:', error);
process.exit(1);
});
@@ -1,10 +0,0 @@
// Stub — replaced at runtime by the generated client when the app
// is installed on a Twenty instance or during `twenty app:dev`.
export class CoreApiClient {
constructor() {
throw new Error(
'CoreApiClient was not generated. ' +
'Install this app on a Twenty instance or run `twenty app:dev`.',
);
}
}
@@ -1,2 +0,0 @@
export { CoreApiClient } from './generated/index';
export * as CoreSchema from './generated/schema';
@@ -1,51 +0,0 @@
type ClientWrapperOptions = {
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
};
const STRIPPED_TYPES_START = '// __STRIPPED_DURING_INJECTION_START__';
const STRIPPED_TYPES_END = '// __STRIPPED_DURING_INJECTION_END__';
const UPLOAD_FILE_START = '// __UPLOAD_FILE_START__';
const UPLOAD_FILE_END = '// __UPLOAD_FILE_END__';
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const buildClientWrapperSource = (
templateSource: string,
options: ClientWrapperOptions,
): string => {
let source = templateSource;
source = source.replace(
new RegExp(
`${escapeRegExp(STRIPPED_TYPES_START)}[\\s\\S]*?${escapeRegExp(STRIPPED_TYPES_END)}\\n?`,
),
'',
);
source = source.replace("'__TWENTY_DEFAULT_URL__'", options.defaultUrl);
source = source.replace(/TwentyGeneratedClient/g, options.apiClientName);
if (!options.includeUploadFile) {
source = source.replace(
new RegExp(
`\\s*${escapeRegExp(UPLOAD_FILE_START)}[\\s\\S]*?${escapeRegExp(UPLOAD_FILE_END)}\\n?`,
),
'\n',
);
} else {
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_START)}\\n`),
'\n',
);
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_END)}\\n`),
'\n',
);
}
return `\n// ${options.apiClientName} (auto-injected by twenty-client-sdk)\n${source}`;
};
@@ -1,41 +0,0 @@
import { cp, mkdir, readdir, rename as fsRename, rm } from 'node:fs/promises';
import { join } from 'node:path';
export const ensureDir = (dirPath: string) =>
mkdir(dirPath, { recursive: true });
export const emptyDir = async (dirPath: string): Promise<void> => {
let entries: string[];
try {
entries = await readdir(dirPath);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
await mkdir(dirPath, { recursive: true });
return;
}
throw error;
}
await Promise.all(
entries.map((entry) =>
rm(join(dirPath, entry), { recursive: true, force: true }),
),
);
};
export const move = async (src: string, dest: string): Promise<void> => {
try {
await fsRename(src, dest);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
await cp(src, dest, { recursive: true });
await rm(src, { recursive: true, force: true });
} else {
throw error;
}
}
};
export const remove = (filePath: string) =>
rm(filePath, { recursive: true, force: true });
@@ -1,122 +0,0 @@
import { appendFile, copyFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { build } from 'esbuild';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir, move, remove } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const GENERATED_CORE_DIR = 'core/generated';
// Generates the core API client from a GraphQL schema string.
// Produces both TypeScript source and compiled ESM/CJS bundles.
export const generateCoreClientFromSchema = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
const tempPath = `${outputPath}.tmp`;
await ensureDir(tempPath);
await emptyDir(tempPath);
try {
await generate({
schema,
output: tempPath,
scalarTypes: COMMON_SCALAR_TYPES,
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: true,
});
await appendFile(join(tempPath, 'index.ts'), clientContent);
await remove(outputPath);
await move(tempPath, outputPath);
await compileGeneratedClient(outputPath);
} catch (error) {
await remove(tempPath);
throw error;
}
};
// Generates the core client and replaces the pre-built stub inside
// an installed twenty-client-sdk package (dist/core.mjs and dist/core.cjs).
// Generated source files are kept in dist/generated-core/ for consumers
// that need the raw .ts files (e.g. the app:dev upload step).
export const replaceCoreClient = async ({
packageRoot,
schema,
}: {
packageRoot: string;
schema: string;
}): Promise<void> => {
const generatedPath = join(packageRoot, 'dist', GENERATED_CORE_DIR);
await generateCoreClientFromSchema({ schema, outputPath: generatedPath });
await copyFile(
join(generatedPath, 'index.mjs'),
join(packageRoot, 'dist', 'core.mjs'),
);
await copyFile(
join(generatedPath, 'index.cjs'),
join(packageRoot, 'dist', 'core.cjs'),
);
};
const compileGeneratedClient = async (generatedDir: string): Promise<void> => {
const entryPoint = join(generatedDir, 'index.ts');
const outfile = join(generatedDir, 'index.mjs');
await build({
entryPoints: [entryPoint],
outfile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await build({
entryPoints: [entryPoint],
outfile: join(generatedDir, 'index.cjs'),
bundle: true,
format: 'cjs',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await writeFile(
join(generatedDir, 'package.json'),
JSON.stringify(
{ type: 'module', main: 'index.mjs', module: 'index.mjs' },
null,
2,
),
);
};
@@ -1,48 +0,0 @@
import { appendFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const generateMetadataClient = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
await ensureDir(outputPath);
await emptyDir(outputPath);
await generate({
schema,
output: outputPath,
scalarTypes: {
...COMMON_SCALAR_TYPES,
Upload: 'File',
},
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'MetadataApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
includeUploadFile: true,
});
await appendFile(join(outputPath, 'index.ts'), clientContent);
};
@@ -1,6 +0,0 @@
export {
GENERATED_CORE_DIR,
generateCoreClientFromSchema,
replaceCoreClient,
} from './generate-core-client';
export { generateMetadataClient } from './generate-metadata-client';
@@ -1,4 +0,0 @@
declare module '*.ts?raw' {
const content: string;
export default content;
}
@@ -1,434 +0,0 @@
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
// __STRIPPED_DURING_INJECTION_START__
type QueryGenqlSelection = Record<string, unknown>;
type MutationGenqlSelection = Record<string, unknown>;
type GraphqlOperation = Record<string, unknown>;
type ClientOptions = {
url?: string;
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
fetcher?: (
operation: GraphqlOperation | GraphqlOperation[],
) => Promise<unknown>;
fetch?: typeof globalThis.fetch;
batch?: unknown;
};
type Client = {
query: (
request: QueryGenqlSelection & { __name?: string },
) => Promise<unknown>;
mutation: (
request: MutationGenqlSelection & { __name?: string },
) => Promise<unknown>;
};
declare function createClient(options: ClientOptions): Client;
declare class GenqlError extends Error {
constructor(errors: unknown, data: unknown);
}
// __STRIPPED_DURING_INJECTION_END__
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type TwentyGeneratedClientOptions = ClientOptions;
type ProcessEnvironment = Record<string, string | undefined>;
type GraphqlErrorPayloadEntry = {
message?: string;
extensions?: { code?: string };
};
type GraphqlResponsePayload = {
data?: Record<string, unknown>;
errors?: GraphqlErrorPayloadEntry[];
};
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
};
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (
globalThis as { process?: { env?: ProcessEnvironment } }
).process;
return processObject?.env ?? {};
};
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
};
const getTokenFromHeaders = (
headers: HeadersInit | undefined,
): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(
headers.get('Authorization') ?? undefined,
);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
};
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((graphqlError) => {
return (
graphqlError.extensions?.code === 'UNAUTHENTICATED' ||
graphqlError.message?.toLowerCase() === 'unauthorized'
);
});
};
const defaultOptions: TwentyGeneratedClientOptions = {
url: '__TWENTY_DEFAULT_URL__',
headers: {
'Content-Type': 'application/json',
},
};
export class TwentyGeneratedClient {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: TwentyGeneratedClientOptions) {
const merged: TwentyGeneratedClientOptions = {
...defaultOptions,
...options,
};
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation =
customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > app access token > api key (legacy).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
// __UPLOAD_FILE_START__
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: `mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}`,
variables: {
file: null,
fieldMetadataUniversalIdentifier,
},
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append(
'0',
new Blob([fileBuffer as BlobPart], { type: contentType }),
filename,
);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
};
}
// __UPLOAD_FILE_END__
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global `fetch` function is not available, ' +
'pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', `Bearer ${token}`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body:
operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
};
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`${response.statusText}: ${response.rawBody}`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>;
};
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((refreshError: unknown) => {
console.error('Twenty client: token refresh failed', refreshError);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
}
}
@@ -1,2 +0,0 @@
export { MetadataApiClient } from './generated/index';
export * as MetadataSchema from './generated/schema';
-19
View File
@@ -1,19 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"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"]
}
@@ -1,17 +0,0 @@
{
"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"]
}
-78
View File
@@ -1,78 +0,0 @@
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',
};
});
@@ -1,14 +0,0 @@
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,
},
});
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -5,7 +5,7 @@
"description": "Twenty meeting recorder",
"main": ".webpack/main",
"scripts": {
"start": "concurrently \"yarn start:server\" \"yarn start:electron\"",
"start": "concurrently \"npm run start:server\" \"npm run start:electron\"",
"start:electron": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
@@ -37,8 +37,10 @@
"node-loader": "^2.1.0",
"style-loader": "^3.3.4"
},
"resolutions": {
"@electron/packager/@electron/osx-sign": "github:recallai/osx-sign"
"overrides": {
"@electron/packager": {
"@electron/osx-sign": "github:recallai/osx-sign"
}
},
"dependencies": {
"@anthropic-ai/sdk": "^0.40.1",
-2
View File
@@ -16,5 +16,3 @@ 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=
+1 -1
View File
@@ -18,7 +18,7 @@ DOCKER_NETWORK=twenty_network
# =============================================================================
prod-build:
@cd ../.. && docker build --target twenty -f ./packages/twenty-docker/twenty/Dockerfile --platform $(PLATFORM) --tag twenty:$(TAG) . && cd -
@cd ../.. && docker build -f ./packages/twenty-docker/twenty/Dockerfile --platform $(PLATFORM) --tag twenty:$(TAG) . && cd -
prod-run:
@docker run -d -p 3000:3000 --name twenty twenty:$(TAG)
@@ -89,15 +89,6 @@ password
{{- end -}}
{{- end -}}
{{/* Check if using external secret for redis password */}}
{{- define "twenty.redis.useExternalSecret" -}}
{{- if and (not .Values.redisInternal.enabled) .Values.redis.external.secretName .Values.redis.external.passwordKey -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{/* Compose Redis URL */}}
{{- define "twenty.redisUrl" -}}
{{- if .Values.server.env.REDIS_URL -}}
@@ -108,14 +99,9 @@ false
{{- else -}}
{{- $host := .Values.redis.external.host | default "redis" -}}
{{- $port := .Values.redis.external.port | default 6379 -}}
{{- if or (eq (include "twenty.redis.useExternalSecret" .) "true") (.Values.redis.external.password) -}}
{{- $auth := ":$(REDIS_PASSWORD)@" -}}
{{- printf "redis://%s%s:%v" $auth $host $port -}}
{{- else -}}
{{- printf "redis://%s:%v" $host $port -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Compose Server URL from override, ingress, or service */}}
{{- define "twenty.serverUrl" -}}
@@ -46,12 +46,12 @@ spec:
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -83,16 +83,16 @@ spec:
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -Atc "SELECT 1 FROM pg_database WHERE datname = :'db'" | grep -q 1 || \
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -c 'CREATE DATABASE :"db";'
echo "Creating app user ${APP_USER} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
echo "Creating core schema and granting permissions..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'CREATE SCHEMA IF NOT EXISTS core'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v db="${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"app_user";'
@@ -106,6 +106,31 @@ spec:
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO :"app_user";'
echo "Database ${DBNAME} is ready."
{{- end }}
- name: run-migrations
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
- sh
- -c
- >-
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
env:
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
containers:
- name: server
{{- $img := include "twenty.server.image" . }}
@@ -129,16 +154,6 @@ spec:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: SIGN_IN_PREFILLED
@@ -156,10 +171,7 @@ spec:
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{- $storageEnv | nindent 12 }}
{{- end }}
{{- with .Values.server.extraEnv }}
{{- toYaml . | nindent 12 }}
{{ $storageEnv | nindent 12 }}
{{- end }}
ports:
- name: http-tcp
@@ -67,16 +67,6 @@ spec:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
{{- if eq (include "twenty.redis.useExternalSecret" .) "true" }}
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.redis.external.secretName }}
key: {{ .Values.redis.external.passwordKey }}
{{- else if .Values.redis.external.password }}
- name: REDIS_PASSWORD
value: {{ .Values.redis.external.password | quote }}
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: STORAGE_TYPE
@@ -86,12 +76,9 @@ spec:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- with .Values.worker.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{- $storageEnv | nindent 12 }}
{{ $storageEnv | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
@@ -1,20 +0,0 @@
{{- if and .Values.redisInternal.enabled .Values.redisInternal.persistence.enabled (not .Values.redisInternal.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "twenty.fullname" . }}-redis
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
accessModes:
{{ toYaml .Values.redisInternal.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.redisInternal.persistence.size }}
{{- if .Values.redisInternal.persistence.storageClass }}
storageClassName: {{ .Values.redisInternal.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -1,58 +0,0 @@
suite: env mapping
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: renders server extraEnv with plain value
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: FEATURE_X_ENABLED
value: "true"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: FEATURE_X_ENABLED
value: "true"
- it: renders server extraEnv with valueFrom
template: templates/deployment-server.yaml
set:
server.extraEnv:
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-creds
key: password
- it: renders worker extraEnv with valueFrom
template: templates/deployment-worker.yaml
set:
worker.extraEnv:
- name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: CUSTOM_SECRET
valueFrom:
secretKeyRef:
name: my-secret
key: custom
@@ -1,89 +0,0 @@
suite: redis external authentication
templates:
- templates/deployment-server.yaml
- templates/deployment-worker.yaml
release:
name: my-twenty
namespace: default
tests:
- it: injects REDIS_PASSWORD from external secret into server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects REDIS_PASSWORD from external secret into worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.secretName: redis-creds
redis.external.passwordKey: password
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-creds
key: password
- it: injects plaintext REDIS_PASSWORD when password set directly in server
template: templates/deployment-server.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: injects plaintext REDIS_PASSWORD when password set directly in worker
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: false
redis.external.host: redis.example.com
redis.external.password: "s3cr3t"
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
value: "s3cr3t"
- it: does not inject REDIS_PASSWORD into server when using internal redis
template: templates/deployment-server.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true
- it: does not inject REDIS_PASSWORD into worker when using internal redis
template: templates/deployment-worker.yaml
set:
redisInternal.enabled: true
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: REDIS_PASSWORD
any: true
@@ -105,3 +105,18 @@ tests:
path: spec.template.spec.initContainers[?(@.name=="ensure-database-exists")].command[2]
pattern: ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES
# TypeORM Migration Tests
# ======================
# TypeORM migrations are configured to use the core.datasource which targets the
# 'core' schema. This ensures the _typeorm_migrations table and all application
# tables use the dedicated core schema.
- it: migrations run against core datasource
template: templates/deployment-server.yaml
set:
db.enabled: true
asserts:
- matchRegex:
path: spec.template.spec.initContainers[?(@.name=="run-migrations")].command[2]
pattern: core\.datasource
@@ -19,7 +19,7 @@ tests:
- crm.example.com
asserts:
- equal:
path: spec.template.spec.containers[0].env[?(@.name=="SERVER_URL")].value
path: spec.template.spec.containers[0].env[0].value
value: "https://crm.example.com:443"
- it: falls back to service when ingress disabled
set:
@@ -28,7 +28,7 @@ tests:
server.env.SERVER_URL: ""
asserts:
- matchRegex:
path: spec.template.spec.containers[0].env[?(@.name=="SERVER_URL")].value
path: spec.template.spec.containers[0].env[0].value
pattern: ^http://my-twenty-twenty-server\.default\.svc\.cluster\.local:3000$
---
suite: ingress configuration
@@ -66,7 +66,6 @@ tests:
set:
server.ingress.acme: true
asserts:
- isSubset:
path: metadata.annotations
content:
cert-manager.io/cluster-issuer: letsencrypt-prod
- equal:
path: metadata.annotations[cert-manager.io/cluster-issuer]
value: letsencrypt-prod
@@ -66,22 +66,6 @@
"PASSWORD_RESET_TOKEN_EXPIRES_IN": { "type": "string" }
}
},
"extraEnv": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"valueFrom": { "type": "object" }
},
"required": ["name"],
"oneOf": [
{ "required": ["value"] },
{ "required": ["valueFrom"] }
]
}
},
"service": {
"type": "object",
"properties": {
@@ -157,22 +141,6 @@
"STORAGE_TYPE": { "type": "string" },
"DISABLE_DB_MIGRATIONS": { "type": "string" }
}
},
"extraEnv": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"valueFrom": { "type": "object" }
},
"required": ["name"],
"oneOf": [
{ "required": ["value"] },
{ "required": ["valueFrom"] }
]
}
}
}
},
@@ -52,14 +52,6 @@ server:
SIGN_IN_PREFILLED: "false"
ACCESS_TOKEN_EXPIRES_IN: "7d"
LOGIN_TOKEN_EXPIRES_IN: "1h"
extraEnv: []
# - name: EMAIL_DRIVER
# value: smtp
# - name: SMTP_PASSWORD
# valueFrom:
# secretKeyRef:
# name: smtp-creds
# key: password
service:
type: ClusterIP
@@ -113,8 +105,6 @@ worker:
cpu: 1000m
memory: 2048Mi
extraEnv: []
# PostgreSQL
db:
enabled: true
@@ -184,6 +174,3 @@ redis:
external:
host: ""
port: 6379
password: ""
secretName: ""
passwordKey: ""
@@ -0,0 +1,130 @@
ARG APP_VERSION
# === Stage 1: Common dependencies ===
FROM node:22-alpine AS common-deps
WORKDIR /app
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
COPY ./packages/twenty-emails/package.json /app/packages/twenty-emails/
COPY ./packages/twenty-server/package.json /app/packages/twenty-server/
COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches
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/
RUN yarn && yarn cache clean && npx nx reset
# === Stage 2: Build server from source ===
FROM common-deps AS twenty-server-build
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-server /app/packages/twenty-server
RUN npx nx run twenty-server:build
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-server
# === Stage 3: Build frontend from source ===
# Requires ~10GB Docker memory. To skip, pre-build on host first:
# npx nx build twenty-front
# The COPY below will pick up packages/twenty-front/build/ if it exists.
FROM common-deps AS twenty-front-build
COPY --from=twenty-server-build /app/package.json /tmp/.build-sentinel
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
RUN if [ -d /app/packages/twenty-front/build ]; then \
echo "Using pre-built frontend from host"; \
else \
NODE_OPTIONS="--max-old-space-size=8192" npx nx build twenty-front; \
fi
# === Stage 4: s6-overlay ===
FROM alpine:3.20 AS s6-fetch
ARG S6_OVERLAY_VERSION=3.2.0.2
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64" > /tmp/s6arch; \
else echo "x86_64" > /tmp/s6arch; fi
RUN S6_ARCH=$(cat /tmp/s6arch) && \
wget -O /tmp/s6-overlay-noarch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" && \
wget -O /tmp/s6-overlay-noarch.tar.xz.sha256 \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz.sha256" && \
wget -O /tmp/s6-overlay-arch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" && \
wget -O /tmp/s6-overlay-arch.tar.xz.sha256 \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz.sha256" && \
cd /tmp && \
NOARCH_SUM=$(awk '{print $1}' s6-overlay-noarch.tar.xz.sha256) && \
ARCH_SUM=$(awk '{print $1}' s6-overlay-arch.tar.xz.sha256) && \
echo "$NOARCH_SUM s6-overlay-noarch.tar.xz" | sha256sum -c - && \
echo "$ARCH_SUM s6-overlay-arch.tar.xz" | sha256sum -c -
# === Stage 5: Final all-in-one image ===
FROM node:22-alpine
# s6-overlay
COPY --from=s6-fetch /tmp/s6-overlay-noarch.tar.xz /tmp/
COPY --from=s6-fetch /tmp/s6-overlay-arch.tar.xz /tmp/
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \
&& tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz \
&& rm /tmp/s6-overlay-*.tar.xz
# Infrastructure: Postgres, Redis, and utilities
RUN apk add --no-cache \
postgresql16 postgresql16-contrib \
redis \
curl jq su-exec
# tsx for database setup scripts
RUN npm install -g tsx
# Twenty application (both server and frontend built from source)
COPY --from=twenty-server-build /app /app
COPY --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
# s6 service definitions
COPY packages/twenty-docker/twenty-app-dev/rootfs/ /
# Data directories
RUN mkdir -p /data/postgres /data/redis /app/.local-storage \
&& chown -R postgres:postgres /data/postgres \
&& chown 1000:1000 /data/redis /app/.local-storage
ARG REACT_APP_SERVER_BASE_URL
ARG APP_VERSION=0.0.0
# Tell s6-overlay to preserve container environment variables
ENV S6_KEEP_ENV=1
# Environment defaults
ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \
SERVER_URL=http://localhost:2020 \
REDIS_URL=redis://localhost:6379 \
STORAGE_TYPE=local \
APP_SECRET=twenty-app-dev-secret-not-for-production \
REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL \
APP_VERSION=$APP_VERSION \
NODE_ENV=development \
NODE_PORT=3000 \
DISABLE_DB_MIGRATIONS=true \
DISABLE_CRON_JOBS_REGISTRATION=true \
IS_BILLING_ENABLED=false \
SIGN_IN_PREFILLED=true
EXPOSE 3000
VOLUME ["/data/postgres", "/app/.local-storage"]
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
LABEL org.opencontainers.image.description="All-in-one Twenty image for local development and SDK usage. Includes PostgreSQL, Redis, server, and worker."
ENTRYPOINT ["/init"]
@@ -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/database/scripts/setup-db.js
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
fi
# Always run migrations (idempotent — skips already-applied ones)
yarn database:migrate:prod --force
yarn database:migrate:prod
yarn command:prod cache:flush
yarn command:prod upgrade
+22 -161
View File
@@ -1,11 +1,9 @@
# ===========================================================================
# Shared build stages (used by both targets)
# ===========================================================================
# Base image for common dependencies
FROM node:24-alpine AS common-deps
WORKDIR /app
# Copy only the necessary files for dependency resolution
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/
COPY ./.yarn/releases /app/.yarn/releases
COPY ./.yarn/patches /app/.yarn/patches
@@ -17,35 +15,26 @@ 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/
# Install all dependencies
RUN yarn && yarn cache clean && npx nx reset
# Build the back
FROM common-deps AS twenty-server-build
# Copy sourcecode after installing dependences to accelerate subsequents builds
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 && \
npx nx run twenty-server:lingui:compile && \
npx nx run twenty-emails:lingui:extract && \
npx nx run twenty-emails:lingui:compile
RUN npx nx run twenty-server:build
# 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-client-sdk twenty-server
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-server
# Build the front
FROM common-deps AS twenty-front-build
ARG REACT_APP_SERVER_BASE_URL
@@ -54,27 +43,18 @@ 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:
# npx nx build twenty-front
# The check below will use packages/twenty-front/build/ if it already exists.
RUN if [ -d /app/packages/twenty-front/build ]; then \
echo "Using pre-built frontend from host"; \
else \
NODE_OPTIONS="--max-old-space-size=8192" npx nx build twenty-front; \
fi
RUN npx nx build twenty-front
# ===========================================================================
# Target: twenty (production)
# docker build --target twenty -f packages/twenty-docker/twenty/Dockerfile .
# ===========================================================================
# Final stage: Run the application
FROM node:24-alpine AS twenty
RUN apk add --no-cache curl jq postgresql-client
# Used to run healthcheck in docker
RUN apk add --no-cache curl jq
RUN npm install -g tsx
RUN apk add --no-cache postgresql-client
COPY ./packages/twenty-docker/twenty/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
@@ -86,139 +66,20 @@ ENV REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL
ARG APP_VERSION
ENV APP_VERSION=$APP_VERSION
# Workspace root config
COPY --chown=1000 --from=twenty-server-build /app/package.json /app/yarn.lock /app/.yarnrc.yml /app/
COPY --chown=1000 --from=twenty-server-build /app/tsconfig.base.json /app/nx.json /app/
COPY --chown=1000 --from=twenty-server-build /app/.yarn /app/.yarn
COPY --chown=1000 --from=twenty-server-build /app/node_modules /app/node_modules
# Server package (compiled dist + package.json only, no src/)
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-server/package.json /app/packages/twenty-server/
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-server/dist /app/packages/twenty-server/dist
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-server/patches /app/packages/twenty-server/patches
# Workspace packages (dist + package.json; node_modules symlinks resolve to these)
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-shared/package.json /app/packages/twenty-shared/
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-shared/dist /app/packages/twenty-shared/dist
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/
# Frontend static build
# Copy built applications from previous stages
COPY --chown=1000 --from=twenty-server-build /app /app
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-server /app/packages/twenty-server
COPY --chown=1000 --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
# Set metadata and labels
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
LABEL org.opencontainers.image.description="Production Twenty image with backend and frontend."
LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the backend and frontend, ensuring it deploys faster and runs the same way regardless of the deployment environment."
RUN mkdir -p /app/.local-storage /app/packages/twenty-server/.local-storage && \
chown 1000:1000 /app/.local-storage /app/packages/twenty-server/.local-storage
chown -R 1000:1000 /app
# Use non root user with uid 1000
USER 1000
CMD ["node", "dist/main"]
ENTRYPOINT ["/app/entrypoint.sh"]
# ===========================================================================
# Target: twenty-app-dev (all-in-one with Postgres + Redis)
# docker build --target twenty-app-dev -f packages/twenty-docker/twenty/Dockerfile .
# ===========================================================================
FROM alpine:3.20 AS s6-fetch
ARG S6_OVERLAY_VERSION=3.2.0.2
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64" > /tmp/s6arch; \
else echo "x86_64" > /tmp/s6arch; fi
RUN S6_ARCH=$(cat /tmp/s6arch) && \
wget -O /tmp/s6-overlay-noarch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" && \
wget -O /tmp/s6-overlay-noarch.tar.xz.sha256 \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz.sha256" && \
wget -O /tmp/s6-overlay-arch.tar.xz \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" && \
wget -O /tmp/s6-overlay-arch.tar.xz.sha256 \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz.sha256" && \
cd /tmp && \
NOARCH_SUM=$(awk '{print $1}' s6-overlay-noarch.tar.xz.sha256) && \
ARCH_SUM=$(awk '{print $1}' s6-overlay-arch.tar.xz.sha256) && \
echo "$NOARCH_SUM s6-overlay-noarch.tar.xz" | sha256sum -c - && \
echo "$ARCH_SUM s6-overlay-arch.tar.xz" | sha256sum -c -
FROM node:24-alpine AS twenty-app-dev
# s6-overlay
COPY --from=s6-fetch /tmp/s6-overlay-noarch.tar.xz /tmp/
COPY --from=s6-fetch /tmp/s6-overlay-arch.tar.xz /tmp/
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz \
&& tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz \
&& rm /tmp/s6-overlay-*.tar.xz
RUN apk add --no-cache \
postgresql16 postgresql16-contrib \
redis \
curl jq su-exec
# Workspace root config
COPY --from=twenty-server-build /app/package.json /app/yarn.lock /app/.yarnrc.yml /app/
COPY --from=twenty-server-build /app/tsconfig.base.json /app/nx.json /app/
COPY --from=twenty-server-build /app/.yarn /app/.yarn
COPY --from=twenty-server-build /app/node_modules /app/node_modules
# Server package (compiled dist + package.json only, no src/)
COPY --from=twenty-server-build /app/packages/twenty-server/package.json /app/packages/twenty-server/
COPY --from=twenty-server-build /app/packages/twenty-server/dist /app/packages/twenty-server/dist
COPY --from=twenty-server-build /app/packages/twenty-server/patches /app/packages/twenty-server/patches
# Workspace packages (dist + package.json; node_modules symlinks resolve to these)
COPY --from=twenty-server-build /app/packages/twenty-shared/package.json /app/packages/twenty-shared/
COPY --from=twenty-server-build /app/packages/twenty-shared/dist /app/packages/twenty-shared/dist
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/
# Frontend static build
COPY --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
# Source maps are not needed in the dev image (no Sentry)
RUN find /app/packages/twenty-server/dist -name '*.js.map' -delete
# s6 service definitions
COPY packages/twenty-docker/twenty-app-dev/rootfs/ /
RUN mkdir -p /data/postgres /data/redis /app/.local-storage \
&& chown -R postgres:postgres /data/postgres \
&& chown 1000:1000 /data/redis /app/.local-storage
ARG REACT_APP_SERVER_BASE_URL
ARG APP_VERSION=0.0.0
ENV S6_KEEP_ENV=1
ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \
SERVER_URL=http://localhost:2020 \
REDIS_URL=redis://localhost:6379 \
STORAGE_TYPE=local \
APP_SECRET=twenty-app-dev-secret-not-for-production \
REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL \
APP_VERSION=$APP_VERSION \
NODE_ENV=development \
NODE_PORT=3000 \
DISABLE_DB_MIGRATIONS=true \
DISABLE_CRON_JOBS_REGISTRATION=true \
IS_BILLING_ENABLED=false \
SIGN_IN_PREFILLED=true
EXPOSE 3000
VOLUME ["/data/postgres", "/app/.local-storage"]
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
LABEL org.opencontainers.image.description="All-in-one Twenty image for local development and SDK usage. Includes PostgreSQL, Redis, server, and worker."
ENTRYPOINT ["/init"]
+2 -1
View File
@@ -13,7 +13,8 @@ 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."
yarn database:init:prod
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
yarn database:migrate:prod
fi
yarn command:prod cache:flush
@@ -554,7 +554,7 @@ export default defineFrontComponent({
Key points:
- Front components are React components that render in isolated contexts within Twenty.
- The `component` field references your React component.
- Components are built and synced automatically during `yarn twenty dev`.
- Components are built and synced automatically during `yarn twenty app:dev`.
You can create new front components in two ways:
@@ -597,7 +597,7 @@ You can create new skills in two ways:
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
@@ -612,7 +612,7 @@ const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
Both clients are re-generated automatically by `yarn twenty dev` whenever your objects or fields change.
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
#### Runtime credentials in logic functions
@@ -19,7 +19,7 @@ Apps let you extend Twenty with custom objects, fields, logic functions, AI skil
## Prerequisites
- Node.js 24+ and Yarn 4
- Docker (for the local Twenty dev server)
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
## Getting Started
@@ -31,13 +31,13 @@ npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Start dev mode: automatically syncs local changes to your workspace
yarn twenty dev
yarn twenty app:dev
```
The scaffolder supports two modes for controlling which example files are included:
```bash filename="Terminal"
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill, agent)
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill)
npx create-twenty-app@latest my-app
# Minimal: only core files (application-config.ts and default-role.ts)
@@ -63,7 +63,7 @@ yarn twenty function:execute --preInstall
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty uninstall
yarn twenty app:uninstall
# Display commands' help
yarn twenty help
@@ -165,7 +165,7 @@ 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 app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
## Authentication
@@ -219,25 +219,13 @@ Then add a `twenty` script:
}
```
Now you can run all commands via `yarn twenty <command>`, e.g. `yarn twenty dev`, `yarn twenty help`, etc.
## How to use a local Twenty instance
If you're already running a Twenty instance locally (e.g. via `npx nx start twenty-server`), you can connect to it instead of using Docker:
```bash filename="Terminal"
# During scaffolding — skip Docker, connect to your running instance
npx create-twenty-app@latest my-app --port 3000
# Or after scaffolding — add a remote pointing to your instance
yarn twenty remote add --local --port 3000
```
Now you can run all commands via `yarn twenty <command>`, e.g. `yarn twenty app:dev`, `yarn twenty help`, etc.
## Troubleshooting
- Authentication errors: run `yarn twenty auth:login` and ensure your API key has the required permissions.
- Cannot connect to server: verify the API URL and that the Twenty server is reachable.
- 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.
- Types or client missing/outdated: restart `yarn twenty app:dev` — it auto-generates the typed client.
- Dev mode not syncing: ensure `yarn twenty app:dev` is running and that changes are not ignored by your environment.
Discord Help Channel: https://discord.com/channels/1130383047699738754/1130386664812982322
@@ -28,13 +28,13 @@ Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twe
1. **Build your app** — the CLI compiles your TypeScript sources and generates the application manifest:
```bash filename="Terminal"
yarn twenty build
yarn twenty app:build
```
2. **Publish to npm** — push the built package to the npm registry:
```bash filename="Terminal"
npx twenty publish
npx twenty app:publish
```
### Auto-discovery
@@ -65,14 +65,14 @@ jobs:
node-version: "24"
registry-url: https://registry.npmjs.org
- run: yarn install --immutable
- run: npx twenty build
- run: npx twenty app:build
- run: npm publish --provenance --access public
working-directory: .twenty/output
env:
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`, `npx twenty app: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.
@@ -87,7 +87,7 @@ For apps you don't want publicly available — proprietary tools, enterprise-onl
Build your app and deploy it to a specific server in one step:
```bash filename="Terminal"
npx twenty publish --server <server-url>
npx twenty app:publish --server <server-url>
```
Any workspace on that server can then install and upgrade the app from the **Applications** settings page.
@@ -97,7 +97,7 @@ Any workspace on that server can then install and upgrade the app from the **App
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>`
2. Push a new tarball with `npx twenty app:publish --server <server-url>`
3. Workspaces on that server will see the upgrade available in their settings
<Note>
@@ -110,7 +110,7 @@ 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 |
| **Development** | Local dev mode apps running via `yarn twenty app: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 |
@@ -77,18 +77,6 @@ npx create-twenty-app@latest my-app
npx create-twenty-app@latest my-app --minimal
```
### 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 filename="Terminal"
# During scaffolding
npx create-twenty-app@latest my-app --port 3000
# Or after scaffolding
yarn twenty remote add --local --port 3000
```
From here you can:
```bash filename="Terminal"
@@ -108,13 +96,13 @@ yarn twenty function:execute --preInstall
yarn twenty function:execute --postInstall
# Build the app for distribution
yarn twenty build
yarn twenty app:build
# Publish the app to npm or a Twenty server
yarn twenty publish
yarn twenty app:publish
# Uninstall the application from the current workspace
yarn twenty uninstall
yarn twenty app:uninstall
# Display commands' help
yarn twenty help
@@ -219,7 +207,7 @@ 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/clients`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/clients`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
## Authentication
@@ -546,7 +534,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-client-sdk/core';
import { CoreApiClient, type Person } from 'twenty-sdk/clients';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@@ -784,7 +772,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-client-sdk/core';
import { CoreApiClient } from 'twenty-sdk/clients';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
@@ -868,7 +856,7 @@ export default defineFrontComponent({
Key points:
- Front components are React components that render in isolated contexts within Twenty.
- The `component` field references your React component.
- Components are built and synced automatically during `yarn twenty dev`.
- Components are built and synced automatically during `yarn twenty app:dev`.
You can create new front components in two ways:
@@ -1194,14 +1182,14 @@ You can create new agents in two ways:
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_modules/twenty-sdk/clients` based on your workspace schema:
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/clients` based on your workspace schema:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
```typescript
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { CoreApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/clients';
const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
@@ -1210,7 +1198,7 @@ const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
`CoreApiClient` is re-generated automatically by `yarn twenty dev` whenever your objects or fields change. `MetadataApiClient` ships pre-built with the SDK.
`CoreApiClient` is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change. `MetadataApiClient` ships pre-built with the SDK.
#### Runtime credentials in logic functions
@@ -1229,7 +1217,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-client-sdk/metadata';
import { MetadataApiClient } from 'twenty-sdk/clients';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
@@ -1280,10 +1268,10 @@ Once you've developed your app with `app:dev`, use `app:build` to compile it int
```bash filename="Terminal"
# Build the app (output goes to .twenty/output/)
yarn twenty build
yarn twenty app:build
# Build and create a tarball (.tgz) for distribution
yarn twenty build --tarball
yarn twenty app:build --tarball
```
The build process:
@@ -1323,10 +1311,10 @@ Use `app:publish` to distribute your app — either to the npm registry or direc
```bash filename="Terminal"
# Publish to npm (requires npm login)
yarn twenty publish
yarn twenty app:publish
# Publish with a dist-tag (e.g. beta, next)
yarn twenty publish --tag beta
yarn twenty app:publish --tag beta
```
This builds the app and runs `npm publish` from the `.twenty/output/` directory. The published package can then be installed from the Twenty marketplace by any workspace.
@@ -1335,7 +1323,7 @@ This builds the app and runs `npm publish` from the `.twenty/output/` directory.
```bash filename="Terminal"
# Publish directly to a Twenty server
yarn twenty publish --server https://app.twenty.com
yarn twenty app:publish --server https://app.twenty.com
```
This builds the app with a tarball, uploads it to the server via the `uploadAppTarball` GraphQL mutation, and triggers installation in one step. This is useful for private deployments or testing against a specific server.
@@ -1399,13 +1387,13 @@ Then add a `twenty` script:
}
```
Now you can run all commands via `yarn twenty <command>`, e.g. `yarn twenty dev`, `yarn twenty help`, etc.
Now you can run all commands via `yarn twenty <command>`, e.g. `yarn twenty app:dev`, `yarn twenty help`, etc.
## Troubleshooting
- Authentication errors: run `yarn twenty auth:login` and ensure your API key has the required permissions.
- Cannot connect to server: verify the API URL and that the Twenty server is reachable.
- 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.
- Types or client missing/outdated: restart `yarn twenty app:dev` — it auto-generates the typed client.
- Dev mode not syncing: ensure `yarn twenty app:dev` is running and that changes are not ignored by your environment.
Discord Help Channel: https://discord.com/channels/1130383047699738754/1130386664812982322

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