Compare commits

..
Author SHA1 Message Date
Devessier 133c01923d feat: recreating app 2026-03-18 17:06:58 +01:00
3848 changed files with 154390 additions and 321998 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
.git
.env
**/node_modules
node_modules
.nx/cache
packages/twenty-server/.env
@@ -3,13 +3,12 @@ description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Accepts "latest" (pulls the latest Docker Hub image, checks out main) or a
semver tag (e.g., v0.40.0).
Pulls the specified semver image tag from Docker Hub.
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag — either "latest" or a semver tag (e.g., v0.40.0).'
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
@@ -31,19 +30,12 @@ outputs:
runs:
using: 'composite'
steps:
- name: Resolve version
id: resolve
- name: Validate version
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if [ "$VERSION" = "latest" ]; then
echo "docker-tag=latest" >> "$GITHUB_OUTPUT"
echo "git-ref=main" >> "$GITHUB_OUTPUT"
elif echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "docker-tag=$VERSION" >> "$GITHUB_OUTPUT"
echo "git-ref=$VERSION" >> "$GITHUB_OUTPUT"
else
echo "::error::twenty-version must be \"latest\" or a semver tag (e.g., v0.40.0). Got: '$VERSION'"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
@@ -51,7 +43,7 @@ runs:
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ steps.resolve.outputs.git-ref }}
ref: ${{ inputs.twenty-version }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
@@ -64,7 +56,7 @@ runs:
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ steps.resolve.outputs.docker-tag }}" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
-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
+11 -27
View File
@@ -35,10 +35,12 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
@@ -75,8 +77,6 @@ jobs:
run: |
echo "Attempting to merge main into current branch..."
git config user.email "ci@twenty.com"
git config user.name "CI"
git fetch origin main
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
@@ -90,8 +90,8 @@ jobs:
echo "❌ Merge failed due to conflicts"
echo "⚠️ Falling back to comparing current branch against main without merge"
# Abort the failed merge (may not exist if merge never started)
git merge --abort 2>/dev/null || true
# Abort the failed merge
git merge --abort
echo "merged=false" >> $GITHUB_OUTPUT
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
@@ -143,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: |
@@ -295,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: |
@@ -393,30 +395,12 @@ jobs:
# Clean up temp directory
rm -rf /tmp/current-branch-files
- name: Validate downloaded schema files
id: validate-schemas
run: |
valid=true
for file in main-schema-introspection.json current-schema-introspection.json \
main-metadata-schema-introspection.json current-metadata-schema-introspection.json \
main-rest-api.json current-rest-api.json \
main-rest-metadata-api.json current-rest-metadata-api.json; do
if [ ! -f "$file" ] || ! jq empty "$file" 2>/dev/null; then
echo "::warning::Invalid or missing schema file: $file"
valid=false
fi
done
echo "valid=$valid" >> $GITHUB_OUTPUT
- name: Install OpenAPI Diff Tool
run: |
# Using the Java-based OpenAPITools/openapi-diff via Docker
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
npm install -g @graphql-inspector/cli
@@ -427,6 +411,7 @@ jobs:
echo "Checking GraphQL schema for changes..."
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
@@ -444,6 +429,7 @@ jobs:
echo "Checking GraphQL metadata schema for changes..."
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL metadata schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
@@ -462,7 +448,6 @@ jobs:
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
@@ -531,7 +516,6 @@ jobs:
fi
- name: Check REST Metadata API Breaking Changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
+15 -31
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:
@@ -36,10 +34,12 @@ jobs:
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
@@ -51,8 +51,6 @@ jobs:
image: redis
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
@@ -66,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: |
@@ -89,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
@@ -156,17 +151,13 @@ jobs:
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
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: |
@@ -175,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.*Hello World.*with id'
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000
@@ -1,114 +0,0 @@
name: CI Front Component Renderer
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-front-component-renderer/**
packages/twenty-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
renderer-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [build, typecheck, lint]
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-front-component-renderer
renderer-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-front-component-renderer
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
retention-days: 1
renderer-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: renderer-sb-build
env:
STORYBOOK_URL: http://localhost:6008
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-sdk
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-front-component-renderer
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front-component-renderer/storybook-static --port 6008 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6008 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-front-component-renderer
ci-front-component-renderer-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
renderer-task,
renderer-sb-build,
renderer-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+1 -2
View File
@@ -25,7 +25,6 @@ jobs:
package.json
yarn.lock
packages/twenty-front/**
packages/twenty-front-component-renderer/**
packages/twenty-ui/**
packages/twenty-shared/**
packages/twenty-sdk/**
@@ -94,7 +93,7 @@ jobs:
run: |
npx nx build twenty-shared
npx nx build twenty-ui
npx nx build twenty-front-component-renderer
npx nx build twenty-sdk
- name: Download storybook build
uses: actions/download-artifact@v4
with:
+7 -5
View File
@@ -10,8 +10,8 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
group: ${{ github.workflow }}-${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref || github.ref }}
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
jobs:
e2e-test:
@@ -25,10 +25,12 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=10240"
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
+9 -4
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, test:integration]
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -41,6 +41,9 @@ jobs:
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Install Playwright
if: contains(matrix.task, 'storybook')
run: npx playwright install chromium
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
@@ -53,10 +56,12 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
+16 -11
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/**
@@ -83,10 +83,12 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
@@ -120,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=$?
@@ -174,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
@@ -223,10 +226,12 @@ jobs:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
+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 2020:2020 \
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:2020/healthz 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
echo "Server is healthy!"
curl -s http://localhost:2020/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
+5 -3
View File
@@ -26,10 +26,12 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
+5 -3
View File
@@ -29,10 +29,12 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
-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)
+8 -13
View File
@@ -82,11 +82,11 @@
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.3.3",
"@storybook/addon-links": "^10.3.3",
"@storybook/addon-vitest": "^10.3.3",
"@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "^10.2.13",
"@storybook/addon-vitest": "^10.2.13",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.3.3",
"@storybook/react-vite": "^10.2.13",
"@storybook/test-runner": "^0.24.2",
"@swc-node/register": "^1.11.1",
"@swc/cli": "^0.7.10",
@@ -154,9 +154,9 @@
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "^10.3.3",
"storybook": "^10.2.13",
"storybook-addon-mock-date": "2.0.0",
"storybook-addon-pseudo-states": "^10.3.3",
"storybook-addon-pseudo-states": "^10.2.13",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
@@ -175,12 +175,11 @@
},
"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",
"typescript": "5.9.2",
"nodemailer": "8.0.4",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16",
@@ -204,18 +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-front-component-renderer",
"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": {
+124 -25
View File
@@ -12,50 +12,149 @@
</div>
The official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). Sets up a ready-to-run project with [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a readytorun project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
- Zeroconfig project bootstrap
- Preconfigured scripts for auth, dev mode (watch & sync), uninstall, and function management
- Strong TypeScript support and typed client generation
## Documentation
See Twenty application documentation https://docs.twenty.com/developers/extend/capabilities/apps
## Prerequisites
- Node.js 24+ (recommended) and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
## Quick start
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Get help and list all available commands
yarn twenty help
# Authenticate with your Twenty server
yarn twenty remote add --local
# Add a new entity to your application (guided)
yarn twenty add
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built with the SDK — both available via `twenty-sdk/clients`)
yarn twenty dev
# Watch your application's function logs
yarn twenty logs
# Execute a function with a JSON payload
yarn twenty exec -n my-function -p '{"key": "value"}'
# Execute the pre-install function
yarn twenty exec --preInstall
# Execute the post-install function
yarn twenty exec --postInstall
# Build the app for distribution
yarn twenty build
# Publish the app to npm or directly to a Twenty server
yarn twenty publish
# Uninstall the application from the current workspace
yarn twenty uninstall
```
The scaffolder will:
1. Create a new project with TypeScript, linting, and a preconfigured `twenty` CLI
2. Optionally start a local Twenty server (Docker)
3. Open the browser for OAuth authentication
4. Scaffold example entities and an integration test
## Scaffolding modes
| Flag | Behavior |
| -------------- | -------------------------------------------------------------------------------------------------------------- |
| `--minimal` | **(default)** Creates only core files (`application-config.ts`, `default-role.ts`, pre/post-install functions) |
| `--exhaustive` | Creates all example entities |
Control which example files are included when creating a new app:
Other flags:
| Flag | Behavior |
| ------------------ | ----------------------------------------------------------------------- |
| `-e, --exhaustive` | **(default)** Creates all example files |
| `-m, --minimal` | Creates only core files (`application-config.ts` and `default-role.ts`) |
- `--name <name>` — set the app name (skips the prompt)
- `--display-name <displayName>` — set the display name (skips the prompt)
- `--description <description>` — set the description (skips the prompt)
- `--skip-local-instance` — skip the local server setup prompt
```bash
# Default: all examples included
npx create-twenty-app@latest my-app
## Documentation
# Minimal: only core files
npx create-twenty-app@latest my-app -m
```
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started)**:
## What gets scaffolded
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started) — step-by-step setup, project structure, server management, CI
- [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing
- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace
**Core files (always created):**
- `application-config.ts` — Application metadata configuration
- `roles/default-role.ts` — Default role for logic functions
- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation)
- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation)
- TypeScript configuration, Oxlint, package.json, .gitignore
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
**Example files (controlled by scaffolding mode):**
- `objects/example-object.ts` — Example custom object with a text field
- `fields/example-field.ts` — Example standalone field extending the example object
- `logic-functions/hello-world.ts` — Example logic function with HTTP trigger
- `front-components/hello-world.tsx` — Example front component
- `views/example-view.ts` — Example saved view for the example object
- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link
- `skills/example-skill.ts` — Example AI agent skill definition
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
## Build and publish your application
Once your app is ready, build and publish it using the CLI:
```bash
# Build the app (output goes to .twenty/output/)
yarn twenty build
# Build and create a tarball (.tgz) for distribution
yarn twenty build --tarball
# Publish to npm (requires npm login)
yarn twenty publish
# Publish with a dist-tag (e.g. beta, next)
yarn twenty publish --tag beta
# Deploy directly to a Twenty server (builds, uploads, and installs in one step)
yarn twenty deploy
```
### Publish to the Twenty marketplace
You can also contribute your application to the curated marketplace:
```bash
git clone https://github.com/twentyhq/twenty.git
cd twenty
git checkout -b feature/my-awesome-app
```
- Copy your app folder into `twenty/packages/twenty-apps`.
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: make sure you are logged in to Twenty in the browser, then run `yarn twenty remote add`.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
- Auth prompts not appearing: run `yarn twenty remote add --local` again and verify the API key permissions.
- Types not generated: ensure `yarn twenty dev` is running — it autogenerates the typed client.
## Contributing
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.8.0-canary.8",
"version": "0.8.0-canary.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -36,7 +36,6 @@
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-sdk": "workspace:*",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -46,6 +45,7 @@
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"twenty-sdk": "workspace:*",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
+3 -5
View File
@@ -13,10 +13,10 @@ const program = new Command(packageJson.name)
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.option('-e, --exhaustive', 'Create all example entities')
.option('-e, --exhaustive', 'Create all example entities (default)')
.option(
'-m, --minimal',
'Create only core entities (application-config and default-role) (default)',
'Create only core entities (application-config and default-role)',
)
.option('-n, --name <name>', 'Application name (skips prompt)')
.option(
@@ -69,9 +69,7 @@ const program = new Command(packageJson.name)
process.exit(1);
}
const mode: ScaffoldingMode = options?.exhaustive
? 'exhaustive'
: 'minimal';
const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive';
await new CreateAppCommand().execute({
directory,
@@ -1,7 +1,7 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/postcard-app
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/app-seeds/rich-app
## UUID requirement
@@ -1,11 +1,60 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
Run `yarn twenty help` to list all available commands.
Start development mode — it auto-connects to your local Twenty server at localhost:3000:
```bash
yarn twenty dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & authentication
yarn twenty remote add --local # Connect to local Twenty server
yarn twenty remote add <url> # Connect to a remote server (OAuth)
yarn twenty remote list # List all configured remotes
yarn twenty remote switch # Switch default remote
yarn twenty remote status # Check auth status
yarn twenty remote remove <name> # Remove a remote
# Development
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty build # Build the application
yarn twenty deploy # Deploy to a Twenty server
yarn twenty publish # Publish to npm
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty exec # Execute a function with JSON payload
yarn twenty logs # Stream function logs
yarn twenty uninstall # Uninstall app from server
```
## Integration Tests
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
```bash
# Make sure a Twenty server is running at http://localhost:3000
yarn test
```
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/capabilities/apps)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -1,19 +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,
detectLocalServer,
serverStart,
type ServerStartResult,
} from 'twenty-sdk/cli';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -34,12 +32,12 @@ 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 ?? 'minimal',
options.mode ?? 'exhaustive',
);
await this.validateDirectory(appDirectory);
@@ -60,29 +58,32 @@ export class CreateAppCommand {
await tryGitInit(appDirectory);
let serverResult: ServerStartResult | undefined;
let localResult: LocalInstanceResult = { running: false };
if (!options.skipLocalInstance) {
const shouldStartServer = await this.shouldStartServer();
const { needsLocalInstance } = await inquirer.prompt([
{
type: 'confirm',
name: 'needsLocalInstance',
message:
'Do you need a local instance of Twenty? Recommended if you not have one already.',
default: true,
},
]);
if (shouldStartServer) {
const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
if (needsLocalInstance) {
localResult = await setupLocalInstance();
}
if (startResult.success) {
serverResult = startResult.data;
await this.promptConnectToLocal(serverResult.url);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
}
if (isDefined(localResult.apiKey)) {
this.runAuthLogin(appDirectory, localResult.apiKey);
}
}
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);
@@ -163,7 +164,7 @@ export class CreateAppCommand {
includeExampleNavigationMenuItem: false,
includeExampleSkill: false,
includeExampleAgent: false,
includeExampleIntegrationTest: true,
includeExampleIntegrationTest: false,
};
}
@@ -200,69 +201,23 @@ 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 shouldStartServer(): Promise<boolean> {
const existingServerUrl = await detectLocalServer();
if (existingServerUrl) {
return true;
}
const { startDocker } = await inquirer.prompt([
{
type: 'confirm',
name: 'startDocker',
message:
'No running Twenty instance found. Would you like to start one using Docker?',
default: true,
},
]);
return startDocker;
}
private async promptConnectToLocal(serverUrl: string): Promise<void> {
const { shouldAuthenticate } = await inquirer.prompt([
{
type: 'confirm',
name: 'shouldAuthenticate',
message: `Would you like to authenticate to the local Twenty instance (${serverUrl})?`,
default: true,
},
]);
if (!shouldAuthenticate) {
console.log(
chalk.gray(
'Authentication skipped. Run `yarn twenty remote add` manually.',
),
);
return;
}
private runAuthLogin(appDirectory: string, apiKey: string): void {
try {
const result = await authLoginOAuth({
apiUrl: serverUrl,
remote: 'local',
});
if (!result.success) {
console.log(
chalk.yellow(
'Authentication failed. Run `yarn twenty remote add` manually.',
),
);
}
execSync(
`yarn twenty auth:login --api-key "${apiKey}" --api-url http://localhost:3000`,
{ cwd: appDirectory, stdio: 'inherit' },
);
console.log(chalk.green('✅ Authenticated with local Twenty instance.'));
} catch {
console.log(
chalk.yellow(
'Authentication failed. Run `yarn twenty remote add` manually.',
'⚠️ Auto auth:login failed. Run `yarn twenty auth:login` manually.',
),
);
}
@@ -270,23 +225,31 @@ 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.apiKey) {
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
} else if (localResult.running) {
console.log(
chalk.gray(
'- yarn twenty remote add # Authenticate with Twenty',
' yarn twenty remote add --local # Authenticate with Twenty',
),
);
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
} else {
console.log(
chalk.gray(
' yarn twenty remote add --local # Authenticate with Twenty',
),
);
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
}
console.log(
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,62 +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,
},
});
if (!createCompany?.id || !createCompany?.name) {
throw new Error('Failed to create company: missing id or name in response');
}
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,
@@ -779,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) {
@@ -798,7 +633,6 @@ const createPackageJson = async ({
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
keywords: ['twenty-app'],
packageManager: 'yarn@4.9.2',
scripts,
devDependencies,
@@ -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,194 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import { execSync } from 'node:child_process';
import { isDefined } from 'twenty-shared/utils';
const INSTALL_SCRIPT_URL =
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-docker/scripts/install.sh';
const SERVER_CONTAINER = 'twenty-server-1';
const DB_CONTAINER = 'twenty-db-1';
const isDockerAvailable = (): boolean => {
try {
execSync('docker compose version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
const isDockerRunning = (): boolean => {
try {
execSync('docker info', { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
const isTwentyServerRunning = async (): Promise<boolean> => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch('http://localhost:3000/healthz', {
signal: controller.signal,
});
clearTimeout(timeoutId);
const body = await response.json();
return body.status === 'ok';
} catch {
return false;
}
};
const getActiveWorkspaceId = (): string | null => {
try {
const result = execSync(
`docker exec ${DB_CONTAINER} psql -U postgres -d default -t -c "SELECT id FROM core.workspace WHERE \\"activationStatus\\" = 'ACTIVE' LIMIT 1"`,
{ encoding: 'utf-8' },
).trim();
return result || null;
} catch {
return null;
}
};
const generateApiKeyToken = (workspaceId: string): string | null => {
try {
const output = execSync(
`docker exec -e NODE_ENV=development ${SERVER_CONTAINER} yarn command:prod workspace:generate-api-key -w ${workspaceId}`,
{ encoding: 'utf-8' },
);
const TOKEN_PREFIX = 'TOKEN:';
const tokenLine = output
.trim()
.split('\n')
.find((line) => line.includes(TOKEN_PREFIX));
if (!tokenLine) {
return null;
}
const tokenStartIndex =
tokenLine.indexOf(TOKEN_PREFIX) + TOKEN_PREFIX.length;
return tokenLine.slice(tokenStartIndex).trim();
} catch {
return null;
}
};
export type LocalInstanceResult = {
running: boolean;
apiKey?: string;
};
export const setupLocalInstance = async (): Promise<LocalInstanceResult> => {
console.log('');
console.log(chalk.blue('🐳 Setting up local Twenty instance...'));
if (await isTwentyServerRunning()) {
console.log(
chalk.green('✅ Twenty server is already running on localhost:3000.'),
);
} else {
if (!isDockerAvailable()) {
console.log(
chalk.yellow(
'⚠️ Docker Compose is not installed. Please install Docker first.',
),
);
console.log(chalk.gray(' See https://docs.docker.com/get-docker/'));
return { running: false };
}
if (!isDockerRunning()) {
console.log(
chalk.yellow(
'⚠️ Docker is not running. Please start Docker and try again.',
),
);
return { running: false };
}
try {
execSync(`bash <(curl -sL ${INSTALL_SCRIPT_URL})`, {
stdio: 'inherit',
shell: '/bin/bash',
});
} catch {
console.log(
chalk.yellow('⚠️ Local instance setup did not complete successfully.'),
);
return { running: false };
}
}
console.log('');
console.log(
chalk.blue(
'👉 Please create your workspace in the browser before continuing.',
),
);
const { workspaceCreated } = await inquirer.prompt([
{
type: 'confirm',
name: 'workspaceCreated',
message: 'Have you finished creating your workspace?',
default: true,
},
]);
if (!workspaceCreated) {
console.log(
chalk.yellow(
'⚠️ Skipping API key generation. Run `yarn twenty remote add --local` manually after creating your workspace.',
),
);
return { running: true };
}
console.log(chalk.blue('🔑 Generating API key for your workspace...'));
const workspaceId = getActiveWorkspaceId();
if (!isDefined(workspaceId)) {
console.log(
chalk.yellow(
'⚠️ No active workspace found. Make sure you completed the signup flow, then run `yarn twenty auth:login` manually.',
),
);
return { running: true };
}
const apiKey = generateApiKeyToken(workspaceId);
if (!isDefined(apiKey)) {
console.log(
chalk.yellow(
'⚠️ Could not generate API key. Run `yarn twenty auth:login` manually.',
),
);
return { running: true };
}
console.log(chalk.green('✅ API key generated for your workspace.'));
return { running: true, apiKey };
};
@@ -93,7 +93,7 @@ import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
const assertServerIsReachable = async () => {
@@ -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');
@@ -1,7 +1,7 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/postcard-app
- 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.
@@ -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
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -9,7 +9,17 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -9,7 +9,17 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -9,13 +9,22 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"remote:add": "twenty remote add --local",
"remote:status": "twenty remote status",
"remote:switch": "twenty remote switch",
"remote:list": "twenty remote list",
"remote:remove": "twenty remote remove",
"dev": "twenty dev",
"add": "twenty add",
"logs": "twenty logs",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"help": "twenty help",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"dependencies": {
"twenty-sdk": "latest",
"twenty-client-sdk": "latest"
"twenty-sdk": "latest"
},
"devDependencies": {
"typescript": "^5.9.3",
@@ -1,18 +0,0 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
// Field on existing company object
export default defineField({
universalIdentifier: 'f922fdb8-10a9-4f11-a1d0-992a779f6dff',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.BOOLEAN,
name: 'canReceivePostcards',
label: 'Can Receive Postcards',
description: 'Whether the company can receive postcards',
icon: 'IconMailbox',
defaultValue: false,
});
@@ -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',
@@ -6,9 +6,6 @@ export const RECIPIENT_UNIVERSAL_IDENTIFIER =
export const RECIPIENT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER =
'd2a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
export const RECIPIENT_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER =
'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
export default defineObject({
universalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
nameSingular: 'recipient',
@@ -26,10 +23,10 @@ export default defineObject({
icon: 'IconMail',
},
{
universalIdentifier: RECIPIENT_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.ADDRESS,
label: 'Mailing Address',
name: 'mailingAddress',
label: 'Address',
name: 'address',
icon: 'IconHome',
},
],
@@ -1,16 +1,13 @@
import { defineView } from 'twenty-sdk';
import { ViewType } from 'twenty-shared/types';
import {
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card-recipient.object';
import { defineView } from 'twenty-sdk';
import { ViewType } from 'twenty-shared/types';
export const ALL_POST_CARD_RECIPIENTS_VIEW_ID =
'b1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d';
export const POST_CARD_RECIPIENT_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER =
'fd959c6f-3465-4a3a-b7ad-3f4004fffc9a';
export default defineView({
universalIdentifier: ALL_POST_CARD_RECIPIENTS_VIEW_ID,
name: 'All Post Card Recipients',
@@ -20,8 +17,7 @@ export default defineView({
position: 2,
fields: [
{
universalIdentifier:
POST_CARD_RECIPIENT_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
universalIdentifier: 'bf1a2b3c-0004-4a7b-8c9d-0e1f2a3b4c5d',
fieldMetadataUniversalIdentifier: SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
@@ -1,10 +1,9 @@
import { defineView } from 'twenty-sdk';
import { ViewType } from 'twenty-shared/types';
import {
RECIPIENT_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
RECIPIENT_EMAIL_FIELD_UNIVERSAL_IDENTIFIER,
RECIPIENT_UNIVERSAL_IDENTIFIER,
} from '../objects/recipient.object';
import { defineView } from 'twenty-sdk';
import { ViewType } from 'twenty-shared/types';
export const ALL_RECIPIENTS_VIEW_ID = 'b1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d';
@@ -24,13 +23,5 @@ export default defineView({
isVisible: true,
size: 200,
},
{
universalIdentifier: 'bf1a2b3c-0004-4a7b-8c9d-0e1f2a3b4c5d',
fieldMetadataUniversalIdentifier:
RECIPIENT_ADDRESS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
},
],
});
@@ -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 -2
View File
@@ -1,10 +1,9 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/postcard-app
- 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 --api-url 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 --api-url 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": "0.8.0-canary.5",
"twenty-sdk": "0.8.0-canary.5",
"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,39 +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,
},
});
if (!createCompany?.id || !createCompany?.name) {
throw new Error('Failed to create company: missing id or name in response');
}
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,
@@ -14,8 +14,7 @@ export default defineObject({
labelPlural: 'Example items',
description: 'A sample custom object',
icon: 'IconBox',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_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,
+215 -243
View File
@@ -5,13 +5,13 @@ __metadata:
version: 8
cacheKey: 10c0
"@alcalzone/ansi-tokenize@npm:^0.2.4":
version: 0.2.5
resolution: "@alcalzone/ansi-tokenize@npm:0.2.5"
"@alcalzone/ansi-tokenize@npm:^0.1.3":
version: 0.1.3
resolution: "@alcalzone/ansi-tokenize@npm:0.1.3"
dependencies:
ansi-styles: "npm:^6.2.1"
is-fullwidth-code-point: "npm:^5.0.0"
checksum: 10c0/dd8622288426b5b7dbf8b68d51d4b930cea591d0e3b9dbc3f523131464d78ac922c165fcb7ba5307f133d35dbcaa0e6648a1c3fb6a0dc2725546d6f867b70af4
is-fullwidth-code-point: "npm:^4.0.0"
checksum: 10c0/b88c5708271bb64ce132fc80dac8d5b87fc1699bf3abfdf10ecae40dbb56ab82460818f5746ecdd9870a00be9854e039d5cb3a121b808d0ff6f5bc7d0146cb38
languageName: node
linkType: hard
@@ -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
@@ -2655,7 +2655,7 @@ __metadata:
languageName: node
linkType: hard
"ansi-escapes@npm:^7.3.0":
"ansi-escapes@npm:^7.0.0":
version: 7.3.0
resolution: "ansi-escapes@npm:7.3.0"
dependencies:
@@ -2717,7 +2717,7 @@ __metadata:
languageName: node
linkType: hard
"ansi-styles@npm:^6.2.1, ansi-styles@npm:^6.2.3":
"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.2.1":
version: 6.2.3
resolution: "ansi-styles@npm:6.2.3"
checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868
@@ -2948,7 +2948,7 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:^5.3.0, chalk@npm:^5.6.0":
"chalk@npm:^5.3.0":
version: 5.6.2
resolution: "chalk@npm:5.6.2"
checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976
@@ -3020,13 +3020,13 @@ __metadata:
languageName: node
linkType: hard
"cli-truncate@npm:^5.1.1":
version: 5.2.0
resolution: "cli-truncate@npm:5.2.0"
"cli-truncate@npm:^4.0.0":
version: 4.0.0
resolution: "cli-truncate@npm:4.0.0"
dependencies:
slice-ansi: "npm:^8.0.0"
string-width: "npm:^8.2.0"
checksum: 10c0/0d4ec94702ca85b64522ac93633837fb5ea7db17b79b1322a60f6045e6ae2b8cd7bd4c1d19ac7d1f9e10e3bbda1112e172e439b68c02b785ee00da8d6a5c5471
slice-ansi: "npm:^5.0.0"
string-width: "npm:^7.0.0"
checksum: 10c0/d7f0b73e3d9b88cb496e6c086df7410b541b56a43d18ade6a573c9c18bd001b1c3fba1ad578f741a4218fdc794d042385f8ac02c25e1c295a2d8b9f3cb86eb4c
languageName: node
linkType: hard
@@ -3306,7 +3306,7 @@ __metadata:
languageName: node
linkType: hard
"es-toolkit@npm:^1.39.10":
"es-toolkit@npm:^1.22.0":
version: 1.45.1
resolution: "es-toolkit@npm:1.45.1"
dependenciesMeta:
@@ -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
@@ -3727,7 +3727,7 @@ __metadata:
languageName: node
linkType: hard
"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0":
"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1":
version: 1.5.0
resolution: "get-east-asian-width@npm:1.5.0"
checksum: 10c0/bff8bbc8d81790b9477f7aa55b1806b9f082a8dc1359fff7bd8b96939622c86b729685afc2bfeb22def1fc6ef1e5228e4d87dd4e6da60bc43a5edfb03c4ee167
@@ -3906,8 +3906,7 @@ __metadata:
"@types/react": "npm:^18.2.0"
oxlint: "npm:^0.16.0"
react: "npm:^18.2.0"
twenty-client-sdk: "npm:0.8.0-canary.5"
twenty-sdk: "npm:0.8.0-canary.5"
twenty-sdk: "portal:../../twenty-sdk"
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^3.1.1"
@@ -4030,45 +4029,44 @@ __metadata:
languageName: node
linkType: hard
"ink@npm:^6.8.0":
version: 6.8.0
resolution: "ink@npm:6.8.0"
"ink@npm:^5.1.1":
version: 5.2.1
resolution: "ink@npm:5.2.1"
dependencies:
"@alcalzone/ansi-tokenize": "npm:^0.2.4"
ansi-escapes: "npm:^7.3.0"
"@alcalzone/ansi-tokenize": "npm:^0.1.3"
ansi-escapes: "npm:^7.0.0"
ansi-styles: "npm:^6.2.1"
auto-bind: "npm:^5.0.1"
chalk: "npm:^5.6.0"
chalk: "npm:^5.3.0"
cli-boxes: "npm:^3.0.0"
cli-cursor: "npm:^4.0.0"
cli-truncate: "npm:^5.1.1"
cli-truncate: "npm:^4.0.0"
code-excerpt: "npm:^4.0.0"
es-toolkit: "npm:^1.39.10"
es-toolkit: "npm:^1.22.0"
indent-string: "npm:^5.0.0"
is-in-ci: "npm:^2.0.0"
is-in-ci: "npm:^1.0.0"
patch-console: "npm:^2.0.0"
react-reconciler: "npm:^0.33.0"
scheduler: "npm:^0.27.0"
react-reconciler: "npm:^0.29.0"
scheduler: "npm:^0.23.0"
signal-exit: "npm:^3.0.7"
slice-ansi: "npm:^8.0.0"
slice-ansi: "npm:^7.1.0"
stack-utils: "npm:^2.0.6"
string-width: "npm:^8.1.1"
terminal-size: "npm:^4.0.1"
type-fest: "npm:^5.4.1"
widest-line: "npm:^6.0.0"
string-width: "npm:^7.2.0"
type-fest: "npm:^4.27.0"
widest-line: "npm:^5.0.0"
wrap-ansi: "npm:^9.0.0"
ws: "npm:^8.18.0"
yoga-layout: "npm:~3.2.1"
peerDependencies:
"@types/react": ">=19.0.0"
react: ">=19.0.0"
react-devtools-core: ">=6.1.2"
"@types/react": ">=18.0.0"
react: ">=18.0.0"
react-devtools-core: ^4.19.1
peerDependenciesMeta:
"@types/react":
optional: true
react-devtools-core:
optional: true
checksum: 10c0/50500e547fdf6a1f1d836d6befbd4770e3ab649ef0be1884500a6da411fb68a90e22dd7dcc9c404911d30e9f87506b3b9d8e997c6c6ceac85ee054b4dadefaff
checksum: 10c0/0e2607712783353fbe99438ca4220db3d5874c7ae1a07e2b232f7539489b13a9db929b3046eedeccf318a3ffa222a572287dbe2307c848b8e7f6ec4bba39e550
languageName: node
linkType: hard
@@ -4141,7 +4139,14 @@ __metadata:
languageName: node
linkType: hard
"is-fullwidth-code-point@npm:^5.0.0, is-fullwidth-code-point@npm:^5.1.0":
"is-fullwidth-code-point@npm:^4.0.0":
version: 4.0.0
resolution: "is-fullwidth-code-point@npm:4.0.0"
checksum: 10c0/df2a717e813567db0f659c306d61f2f804d480752526886954a2a3e2246c7745fd07a52b5fecf2b68caf0a6c79dcdace6166fdf29cc76ed9975cc334f0a018b8
languageName: node
linkType: hard
"is-fullwidth-code-point@npm:^5.0.0":
version: 5.1.0
resolution: "is-fullwidth-code-point@npm:5.1.0"
dependencies:
@@ -4159,12 +4164,12 @@ __metadata:
languageName: node
linkType: hard
"is-in-ci@npm:^2.0.0":
version: 2.0.0
resolution: "is-in-ci@npm:2.0.0"
"is-in-ci@npm:^1.0.0":
version: 1.0.0
resolution: "is-in-ci@npm:1.0.0"
bin:
is-in-ci: cli.js
checksum: 10c0/1e1d1056939a681e8206035de5ad84e0404556eaa7622bb55f0f1868b9788bff3df427bc0b1ed5a172623154a90fcb1e759a230817cd73d09435543ae3c71feb
checksum: 10c0/98f9cec4c35aece4cf731abf35ebf28359a9b0324fac810da05b842515d9ccb33b8999c1d9a679f0362e1a4df3292065c38d7f86327b1387fa667bc0150f4898
languageName: node
linkType: hard
@@ -4947,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
@@ -5008,14 +5013,15 @@ __metadata:
languageName: node
linkType: hard
"react-dom@npm:^19.0.0":
version: 19.2.4
resolution: "react-dom@npm:19.2.4"
"react-dom@npm:^18.2.0":
version: 18.3.1
resolution: "react-dom@npm:18.3.1"
dependencies:
scheduler: "npm:^0.27.0"
loose-envify: "npm:^1.1.0"
scheduler: "npm:^0.23.2"
peerDependencies:
react: ^19.2.4
checksum: 10c0/f0c63f1794dedb154136d4d0f59af00b41907f4859571c155940296808f4b94bf9c0c20633db75b5b2112ec13d8d7dd4f9bf57362ed48782f317b11d05a44f35
react: ^18.3.1
checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85
languageName: node
linkType: hard
@@ -5026,14 +5032,15 @@ __metadata:
languageName: node
linkType: hard
"react-reconciler@npm:^0.33.0":
version: 0.33.0
resolution: "react-reconciler@npm:0.33.0"
"react-reconciler@npm:^0.29.0":
version: 0.29.2
resolution: "react-reconciler@npm:0.29.2"
dependencies:
scheduler: "npm:^0.27.0"
loose-envify: "npm:^1.1.0"
scheduler: "npm:^0.23.2"
peerDependencies:
react: ^19.2.0
checksum: 10c0/3f7b27ea8d0ff4c8bf0e402a285e1af9b7d0e6f4c1a70a28f4384938bc1130bc82a90a31df0b79ef5e380e2e55e2598bd90b4dbf802b1203d735ba0355817d3a
react: ^18.3.1
checksum: 10c0/94f48ddc348a974256cf13c859f5a94efdb0cd72e04c51b1a4d5c72a8b960ccd35df2196057ee6a4cbcb26145e12b01e3f9ba3b183fddb901414db36a07cbf43
languageName: node
linkType: hard
@@ -5046,13 +5053,6 @@ __metadata:
languageName: node
linkType: hard
"react@npm:^19.0.0":
version: 19.2.4
resolution: "react@npm:19.2.4"
checksum: 10c0/cd2c9ff67a720799cc3b38a516009986f7fc4cb8d3e15716c6211cf098d1357ee3e348ab05ad0600042bbb0fd888530ba92e329198c92eafa0994f5213396596
languageName: node
linkType: hard
"readdirp@npm:^4.0.1":
version: 4.1.2
resolution: "readdirp@npm:4.1.2"
@@ -5297,10 +5297,12 @@ __metadata:
languageName: node
linkType: hard
"scheduler@npm:^0.27.0":
version: 0.27.0
resolution: "scheduler@npm:0.27.0"
checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452
"scheduler@npm:^0.23.0, scheduler@npm:^0.23.2":
version: 0.23.2
resolution: "scheduler@npm:0.23.2"
dependencies:
loose-envify: "npm:^1.1.0"
checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78
languageName: node
linkType: hard
@@ -5403,13 +5405,23 @@ __metadata:
languageName: node
linkType: hard
"slice-ansi@npm:^8.0.0":
version: 8.0.0
resolution: "slice-ansi@npm:8.0.0"
"slice-ansi@npm:^5.0.0":
version: 5.0.0
resolution: "slice-ansi@npm:5.0.0"
dependencies:
ansi-styles: "npm:^6.2.3"
is-fullwidth-code-point: "npm:^5.1.0"
checksum: 10c0/0ce4aa91febb7cea4a00c2c27bb820fa53b6d2862ce0f80f7120134719f7914fc416b0ed966cf35250a3169e152916392f35917a2d7cad0fcc5d8b841010fa9a
ansi-styles: "npm:^6.0.0"
is-fullwidth-code-point: "npm:^4.0.0"
checksum: 10c0/2d4d40b2a9d5cf4e8caae3f698fe24ae31a4d778701724f578e984dcb485ec8c49f0c04dab59c401821e80fcdfe89cace9c66693b0244e40ec485d72e543914f
languageName: node
linkType: hard
"slice-ansi@npm:^7.1.0":
version: 7.1.2
resolution: "slice-ansi@npm:7.1.2"
dependencies:
ansi-styles: "npm:^6.2.1"
is-fullwidth-code-point: "npm:^5.0.0"
checksum: 10c0/36742f2eb0c03e2e81a38ed14d13a64f7b732fe38c3faf96cce0599788a345011e840db35f1430ca606ea3f8db2abeb92a8d25c2753a819e3babaa10c2e289a2
languageName: node
linkType: hard
@@ -5519,7 +5531,7 @@ __metadata:
languageName: node
linkType: hard
"string-width@npm:^7.0.0":
"string-width@npm:^7.0.0, string-width@npm:^7.2.0":
version: 7.2.0
resolution: "string-width@npm:7.2.0"
dependencies:
@@ -5530,16 +5542,6 @@ __metadata:
languageName: node
linkType: hard
"string-width@npm:^8.1.0, string-width@npm:^8.1.1, string-width@npm:^8.2.0":
version: 8.2.0
resolution: "string-width@npm:8.2.0"
dependencies:
get-east-asian-width: "npm:^1.5.0"
strip-ansi: "npm:^7.1.2"
checksum: 10c0/d8915428b43519b0f494da6590dbe4491857d8a12e40250e50fc01fbb616ffd8400a436bbe25712255ee129511fe0414c49d3b6b9627e2bc3a33dcec1d2eda02
languageName: node
linkType: hard
"strip-ansi@npm:^3.0.0, strip-ansi@npm:^3.0.1":
version: 3.0.1
resolution: "strip-ansi@npm:3.0.1"
@@ -5567,7 +5569,7 @@ __metadata:
languageName: node
linkType: hard
"strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2":
"strip-ansi@npm:^7.1.0":
version: 7.2.0
resolution: "strip-ansi@npm:7.2.0"
dependencies:
@@ -5637,13 +5639,6 @@ __metadata:
languageName: node
linkType: hard
"tagged-tag@npm:^1.0.0":
version: 1.0.0
resolution: "tagged-tag@npm:1.0.0"
checksum: 10c0/91d25c9ffb86a91f20522cefb2cbec9b64caa1febe27ad0df52f08993ff60888022d771e868e6416cf2e72dab68449d2139e8709ba009b74c6c7ecd4000048d1
languageName: node
linkType: hard
"tar@npm:^7.5.4":
version: 7.5.11
resolution: "tar@npm:7.5.11"
@@ -5657,13 +5652,6 @@ __metadata:
languageName: node
linkType: hard
"terminal-size@npm:^4.0.1":
version: 4.0.1
resolution: "terminal-size@npm:4.0.1"
checksum: 10c0/89afd9d816dd9dbfe4499da9aeea70491bbde4ff4592226a9c8ac71074a7580afead6a78e95ecc35f6d42e09087b55ffcb1019302cd55e0cc957b6ce5c4847e8
languageName: node
linkType: hard
"tinybench@npm:^2.9.0":
version: 2.9.0
resolution: "tinybench@npm:2.9.0"
@@ -5762,21 +5750,9 @@ __metadata:
languageName: node
linkType: hard
"twenty-client-sdk@npm:0.8.0-canary.5":
version: 0.8.0-canary.5
resolution: "twenty-client-sdk@npm:0.8.0-canary.5"
dependencies:
"@genql/cli": "npm:^3.0.3"
"@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.25.0"
graphql: "npm:^16.8.1"
checksum: 10c0/754b92b732c8e03c9779f6557324710afe4c07f7e6efbe766bd8ff3b6dd8a00c0d9f3fecce2098d68ef9556cafa722b2189e490473e420fdb9cf30c56beb3619
languageName: node
linkType: hard
"twenty-sdk@npm:0.8.0-canary.5":
version: 0.8.0-canary.5
resolution: "twenty-sdk@npm:0.8.0-canary.5"
"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."
dependencies:
"@chakra-ui/react": "npm:^3.33.0"
"@emotion/react": "npm:^11.14.0"
@@ -5794,14 +5770,13 @@ __metadata:
esbuild: "npm:^0.25.0"
graphql: "npm:^16.8.1"
graphql-sse: "npm:^2.5.4"
ink: "npm:^6.8.0"
ink: "npm:^5.1.1"
inquirer: "npm:^10.0.0"
jsonc-parser: "npm:^3.2.0"
preact: "npm:^10.28.3"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
tinyglobby: "npm:^0.2.15"
twenty-client-sdk: "npm:0.8.0-canary.5"
typescript: "npm:^5.9.2"
uuid: "npm:^13.0.0"
vite: "npm:^7.0.0"
@@ -5809,9 +5784,8 @@ __metadata:
zod: "npm:^4.1.11"
bin:
twenty: dist/cli.cjs
checksum: 10c0/47d0970c88f59c092e8eca34dca38bf88d9d52678997349068ca8a8e5cebed2ea71dbd4e8b7299f40b99b5419d4cecd53b31f821b65959267e8d97eb91d2ddde
languageName: node
linkType: hard
linkType: soft
"type-fest@npm:^0.21.3":
version: 0.21.3
@@ -5820,12 +5794,10 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^5.4.1":
version: 5.5.0
resolution: "type-fest@npm:5.5.0"
dependencies:
tagged-tag: "npm:^1.0.0"
checksum: 10c0/60bf79a8df45abf99490e3204eceb5cf7f915413f8a69fb578c75cab37ddcb7d29ee21f185f0e1617323ac0b2a441e001b8dc691e220d0b087e9c29ea205538c
"type-fest@npm:^4.27.0":
version: 4.41.0
resolution: "type-fest@npm:4.41.0"
checksum: 10c0/f5ca697797ed5e88d33ac8f1fec21921839871f808dc59345c9cf67345bfb958ce41bd821165dbf3ae591cedec2bf6fe8882098dfdd8dc54320b859711a2c1e4
languageName: node
linkType: hard
@@ -6139,12 +6111,12 @@ __metadata:
languageName: node
linkType: hard
"widest-line@npm:^6.0.0":
version: 6.0.0
resolution: "widest-line@npm:6.0.0"
"widest-line@npm:^5.0.0":
version: 5.0.0
resolution: "widest-line@npm:5.0.0"
dependencies:
string-width: "npm:^8.1.0"
checksum: 10c0/735f1fdcd97fe765a07bb8b5e73c020bed8e53ab34e83ce0ef01693ba3c914d9e7977fe5f5facf0d0b670297a82dd5e376d3efa0896860dfcdaf7cd6924c0fb7
string-width: "npm:^7.0.0"
checksum: 10c0/6bd6cca8cda502ef50e05353fd25de0df8c704ffc43ada7e0a9cf9a5d4f4e12520485d80e0b77cec8a21f6c3909042fcf732aa9281e5dbb98cc9384a138b2578
languageName: node
linkType: hard
@@ -1,7 +1,7 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/postcard-app
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/app-seeds/rich-app
## Common Pitfalls
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote add --api-url 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 --api-url 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,15 +3,20 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# next.js
/.next/
/out/
# dev
/dist/
.twenty/*
!.twenty/output/
# production
/build
@@ -24,16 +29,10 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
next-env.d.ts
# DB
*.sqlite
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,9 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
@@ -0,0 +1,51 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn twenty auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn twenty app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Authentication
yarn twenty auth:login # Authenticate with Twenty
yarn twenty auth:logout # Remove credentials
yarn twenty auth:status # Check auth status
yarn twenty auth:switch # Switch default workspace
yarn twenty auth:list # List all configured workspaces
# Application
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty function:logs # Stream function logs
yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -0,0 +1,29 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -0,0 +1,28 @@
{
"name": "twentyfortwenty",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "latest",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^18.2.0",
"eslint": "^9.32.0",
"react": "^18.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.0"
}
}
@@ -0,0 +1,32 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: '0fac8de4-9d11-492b-9e6a-577e11e1c442',
displayName: 'Twenty for Twenty',
description: '',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
applicationVariables: {
CLICKHOUSE_URL: {
universalIdentifier: 'b204304f-d3d3-4ae2-aafa-24b21c159181',
description:
'The URL of the ClickHouse server, including the protocol and port (e.g., http://localhost:8123)',
isSecret: true,
},
CLICKHOUSE_USERNAME: {
universalIdentifier: '711e8ea8-8b19-4cf0-82ab-ab44417312bd',
description: 'The username for authenticating with the ClickHouse server',
isSecret: true,
},
CLICKHOUSE_PASSWORD: {
universalIdentifier: 'faba7ed7-9f94-4202-944b-c25c683e9504',
description: 'The password for authenticating with the ClickHouse server',
isSecret: true,
},
CLICKHOUSE_DATABASE: {
universalIdentifier: '3e6698fa-0c00-49e5-9c4d-34d5b177bff3',
description: 'The name of the ClickHouse database to connect to',
isSecret: true,
},
},
});
@@ -0,0 +1,37 @@
// Field metadata identifiers for cloudUser2 object
// These constants are used by both the object definition and views
export const CLOUD_USER_2_ACTIVITY_STATUS_FIELD_ID =
'56e3d6ce-d57e-4ded-830f-958eb18d4d36';
export const CLOUD_USER_2_AVG_DAILY_PAGEVIEWS_LAST_30D_FIELD_ID =
'1f7062c1-0e96-4599-a59c-6d7427794bb1';
export const CLOUD_USER_2_DATA_LAST_UPDATED_AT_FIELD_ID =
'e134ce6b-5ab0-4e9c-ba32-36d0851aca21';
export const CLOUD_USER_2_DAYS_SINCE_LAST_ACTIVITY_FIELD_ID =
'f305d341-7b12-4939-9299-e5e1b6d1591e';
export const CLOUD_USER_2_EMAIL_FIELD_ID =
'ee110a77-34d7-4c8b-bc10-8560b9e2333a';
export const CLOUD_USER_2_FULL_NAME_FIELD_ID =
'3c9540be-6e42-40f2-8598-973117bbe105';
export const CLOUD_USER_2_IS_ACTIVE_L24H_FIELD_ID =
'efaa6e54-8019-454c-a556-2e340f0b156d';
export const CLOUD_USER_2_IS_ACTIVE_L30D_FIELD_ID =
'86a07ca4-420e-4c82-82f8-8f8dfbfc2dc4';
export const CLOUD_USER_2_IS_ACTIVE_L7D_FIELD_ID =
'87bf8c09-1863-4c65-811f-6fb54a87238e';
export const CLOUD_USER_2_IS_TWENTY_FIELD_ID =
'f6dcd9f3-3f7e-4aea-84a5-41282a68910d';
export const CLOUD_USER_2_LAST_ACTIVITY_DATE_FIELD_ID =
'643ab4a2-b6f2-4d11-9173-6859884b8781';
export const CLOUD_USER_2_PAGE_VIEWS_L24H_FIELD_ID =
'294e95cb-13b3-4163-95f8-e4f31837cb47';
export const CLOUD_USER_2_PAGE_VIEWS_L30D_FIELD_ID =
'8dacb4f1-b4ef-42d6-a917-17c78d6273a7';
export const CLOUD_USER_2_PAGE_VIEWS_L7D_FIELD_ID =
'c7741332-d9b6-42ed-84e2-8afa184b58f3';
export const CLOUD_USER_2_UPDATED_BY_FIELD_ID =
'639c9a30-2926-44c3-ab44-100dbda91c64';
export const CLOUD_USER_2_USER_TENURE_FIELD_ID =
'0cc9ca63-c06f-4422-9325-5e99f98d05ed';
export const CLOUD_USER_2_WORKSPACE_COUNT_FIELD_ID =
'5af6ae62-b741-471b-90bb-7fb6678fc8c9';
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
} from 'twenty-sdk';
import {
CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
} from 'src/fields/cloud-user-workspaces-on-cloud-user.field';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
export default defineField({
universalIdentifier: CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
objectUniversalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudUser2',
label: 'Cloud User 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'cloudUser2Id',
},
});
@@ -0,0 +1,11 @@
// Field metadata identifiers for cloudUserWorkspace2 object
// These constants are used by both the object definition and views
export const CLOUD_USER_WORKSPACE_2_TWENTY_USER_IDENTIFIER_FIELD_ID =
'ad149693-0df5-4346-90ff-39e6d945b90f';
export const CLOUD_USER_WORKSPACE_2_TWENTY_WORKSPACE_IDENTIFIER_FIELD_ID =
'c5d02b59-ebaf-4604-809c-ce3f6e50dedc';
export const CLOUD_USER_WORKSPACE_2_ID_OF_THE_USER_WORKSPACE_FIELD_ID =
'6a0f8018-be3c-49b0-bb91-8d3e6b44adec';
export const CLOUD_USER_WORKSPACE_2_UPDATED_BY_FIELD_ID =
'29d47f11-1e45-4c67-b495-fcfa45bb67e2';
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
export const CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID =
'2290d722-d4b1-47ab-a895-4e2c3163a541';
export const CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID =
'49b08cf9-15e1-4583-826f-943fe3c6b0e8';
export default defineField({
universalIdentifier: CLOUD_USER_WORKSPACES_ON_CLOUD_USER_ID,
objectUniversalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudUserWorkspaces2',
label: 'Cloud User Workspaces 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_USER_ON_CLOUD_USER_WORKSPACE_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
import { CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-workspace-2';
export const CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID =
'90d9dfc7-7058-4d1c-aac2-1505bd7cb827';
export const CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID =
'7bcd2dda-d1f8-4aa7-a83f-6cf240be80b2';
export default defineField({
universalIdentifier: CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
objectUniversalIdentifier: CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudUserWorkspaces2',
label: 'Cloud User Workspaces 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,27 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
export const CLOUD_USERS_2_ON_PERSON_ID =
'f238e91c-11d8-4a27-a39b-c9fa3515d4a0';
export const PERSON_ON_CLOUD_USER_2_ID = '08d3aff0-7548-4a99-a097-20a0ad2c9ee7';
export default defineField({
universalIdentifier: CLOUD_USERS_2_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'cloudUsers2',
label: 'Cloud Users 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PERSON_ON_CLOUD_USER_2_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,83 @@
// Field metadata identifiers for cloudWorkspace2 object
// These constants are used by both the object definition and views
export const CLOUD_WORKSPACE_2_CUSTOM_DOMAIN_FIELD_ID =
'c30799d5-adc0-49be-82a1-6d41b5aa9e82';
export const CLOUD_WORKSPACE_2_WORKSPACE_BUSINESS_DOMAIN_FIELD_ID =
'bfa2f91d-1c6c-48de-bb11-ac2a1e2545a9';
export const CLOUD_WORKSPACE_2_COMPANY_LINKEDIN_FIELD_ID =
'f9f2ef3a-8235-4d21-9a45-d29cc2124456';
export const CLOUD_WORKSPACE_2_DATA_LAST_UPDATED_AT_FIELD_ID =
'fbeaab89-77e0-4e13-97ed-caeb757a38c7';
export const CLOUD_WORKSPACE_2_LAST_PAGE_VIEW_DATE_FIELD_ID =
'7d3d4ba3-5af2-4248-afc7-7f4ce7c82805';
export const CLOUD_WORKSPACE_2_TOTAL_EVER_ACTIVE_WORKSPACE_USERS_FIELD_ID =
'56e99339-dcc4-4950-ae64-1b6ae326af2c';
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L30D_FIELD_ID =
'e9c703e6-9ffc-497d-9755-018724ac8d66';
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L7D_FIELD_ID =
'81d8f622-2366-481f-b0e8-b14fb7efa4d5';
export const CLOUD_WORKSPACE_2_ACTIVE_USERS_L24H_FIELD_ID =
'3e53d606-1afa-4d49-be62-52f4bb1cb49e';
export const CLOUD_WORKSPACE_2_WORKSPACE_TENURE_FIELD_ID =
'3fdd272c-73dd-432e-99c7-782f237a7cd8';
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L30D_FIELD_ID =
'1931f3cd-a984-41e3-b8f5-510f67b508e6';
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L7D_FIELD_ID =
'1f6a0ef6-3faf-4157-88a7-f624b7655c6b';
export const CLOUD_WORKSPACE_2_PAGE_VIEWS_L24H_FIELD_ID =
'ab0f46aa-9fef-40c6-a229-6ed4e05867d4';
export const CLOUD_WORKSPACE_2_TOTAL_WORKSPACE_USERS_FIELD_ID =
'38095859-2efb-4f40-86a1-aa883fd88e3d';
export const CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_L30D_FIELD_ID =
'4ebd8329-a18c-4606-9ee0-f32fb1c4aa6f';
export const CLOUD_WORKSPACE_2_NUMBER_OF_EVENTS_TOTAL_FIELD_ID =
'754f137f-b759-47da-8b57-7de71ad34d37';
export const CLOUD_WORKSPACE_2_ALEXA_RANK_FIELD_ID =
'1627e6b8-e87a-4048-b954-8d2fe09b67c8';
export const CLOUD_WORKSPACE_2_EMPLOYEES_FIELD_ID =
'1d8bb210-b50d-4b38-a611-d78ba865cbae';
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L7D_FIELD_ID =
'42879662-d4fc-4806-bcfb-fdb586827491';
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L24H_FIELD_ID =
'44b2c12f-aedd-459a-848a-ade9fc4620b7';
export const CLOUD_WORKSPACE_2_IS_ACTIVE_L30D_FIELD_ID =
'cac2d3e0-c2ee-4e22-bd96-4fa051a36f73';
export const CLOUD_WORKSPACE_2_IS_ENRICHED_FIELD_ID =
'45cbddfa-54cc-4d94-88d2-db7a7a550414';
export const CLOUD_WORKSPACE_2_INDUSTRY_FIELD_ID =
'a6686308-dfcf-4abb-be0f-e8f48cc579d9';
export const CLOUD_WORKSPACE_2_COMPANY_FOUNDED_YEAR_FIELD_ID =
'0432af7e-7575-486e-a022-f4a625a8ac14';
export const CLOUD_WORKSPACE_2_DESCRIPTION_FIELD_ID =
'40854f41-4175-4b14-9567-c6bfb3f5d441';
export const CLOUD_WORKSPACE_2_LATEST_FUNDING_STAGE_FIELD_ID =
'44aba339-be2e-4364-a894-cf730081c8a6';
export const CLOUD_WORKSPACE_2_COMPANY_NAME_FIELD_ID =
'f11ddfc2-3356-453a-a6e1-494b6c6b12f6';
export const CLOUD_WORKSPACE_2_SUB_DOMAIN_FIELD_ID =
'8fb18f52-44e9-4288-87c1-9650222efb05';
export const CLOUD_WORKSPACE_2_ANNUAL_REVENUE_FIELD_ID =
'8f5dfc54-213b-43b2-a502-631f0f3240d1';
export const CLOUD_WORKSPACE_2_MRR_FIELD_ID =
'67125b6c-ff41-459f-8153-a338ed11c4f0';
export const CLOUD_WORKSPACE_2_POTENTIAL_ARR_FIELD_ID =
'0c38b297-0e9d-47cc-8f8f-a0e5402413ce';
export const CLOUD_WORKSPACE_2_ARR_FIELD_ID =
'f932f94b-c48b-4ef3-9eea-0c8dad370d89';
export const CLOUD_WORKSPACE_2_TOTAL_FUNDING_FIELD_ID =
'ae5da0c9-e9ac-401b-ad48-c37a31c6ecd1';
export const CLOUD_WORKSPACE_2_ACTIVATION_STATUS_FIELD_ID =
'ef44daa9-d272-4680-a19c-caf2bac32b70';
export const CLOUD_WORKSPACE_2_SUBSCRIPTION_STATUS_FIELD_ID =
'a88e8cab-333f-44a3-980e-f2cc22871aee';
export const CLOUD_WORKSPACE_2_PAYMENT_FREQUENCY_FIELD_ID =
'fccc73da-2b6e-434b-921b-e3bcbab1c56c';
export const CLOUD_WORKSPACE_2_NEXT_RENEWAL_DATE_FIELD_ID =
'488b38b4-971e-4c33-b60d-7e7934911579';
export const CLOUD_WORKSPACE_2_CREATOR_EMAIL_FIELD_ID =
'3815b992-a919-4c52-8629-b4b5f89a6060';
export const CLOUD_WORKSPACE_2_TAGS_FIELD_ID =
'0f41485f-4868-4bd6-8d76-709a6b7a54e3';
export const CLOUD_WORKSPACE_2_UPDATED_BY_FIELD_ID =
'252cdff1-facb-4f66-8608-6138a585d099';
@@ -0,0 +1,25 @@
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk';
import {
CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
} from 'src/fields/cloud-user-workspaces-on-cloud-workspace.field';
import { CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-workspace-2';
import { CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-workspace-2';
export default defineField({
universalIdentifier: CLOUD_WORKSPACE_ON_CLOUD_USER_WORKSPACE_ID,
objectUniversalIdentifier: CLOUD_USER_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'cloudWorkspace2',
label: 'Cloud Workspace 2',
relationTargetObjectMetadataUniversalIdentifier:
CLOUD_WORKSPACE_2_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
CLOUD_USER_WORKSPACES_ON_CLOUD_WORKSPACE_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'cloudWorkspace2Id',
},
});
@@ -0,0 +1,29 @@
import {
defineField,
FieldType,
OnDeleteAction,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import {
CLOUD_USERS_2_ON_PERSON_ID,
PERSON_ON_CLOUD_USER_2_ID,
} from 'src/fields/cloud-users-2-on-person.field';
import { CLOUD_USER_2_UNIVERSAL_IDENTIFIER } from 'src/objects/cloud-user-2';
export default defineField({
universalIdentifier: PERSON_ON_CLOUD_USER_2_ID,
objectUniversalIdentifier: CLOUD_USER_2_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'person',
label: 'Person',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: CLOUD_USERS_2_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
});
@@ -0,0 +1,49 @@
import { defineLogicFunction } from 'twenty-sdk';
import { syncCloudUserWorkspaces } from 'src/logic-functions/sync-product-data/sync-cloud-user-workspaces';
import { syncCloudUsers } from 'src/logic-functions/sync-product-data/sync-cloud-users';
import { syncCloudWorkspaces } from 'src/logic-functions/sync-product-data/sync-cloud-workspaces';
const handler = async (): Promise<{ message: string }> => {
try {
console.log('Starting product data sync');
const cloudUsersResult = await syncCloudUsers();
console.log(
`Cloud users sync complete: ${cloudUsersResult.syncedCount} users`,
);
const cloudWorkspacesResult = await syncCloudWorkspaces();
console.log(
`Cloud workspaces sync complete: ${cloudWorkspacesResult.syncedCount} workspaces`,
);
const cloudUserWorkspacesResult = await syncCloudUserWorkspaces();
console.log(
`Cloud user workspaces sync complete: ${cloudUserWorkspacesResult.syncedCount} user workspaces`,
);
return {
message: `Product data sync complete — ${cloudUsersResult.syncedCount} users, ${cloudWorkspacesResult.syncedCount} workspaces, ${cloudUserWorkspacesResult.syncedCount} user workspaces`,
};
} catch (err) {
console.log(err);
throw err;
}
};
export default defineLogicFunction({
universalIdentifier: '3897e059-715e-4a4b-b165-c44f17d2e30a',
name: 'sync-product-data',
description:
'Syncs cloud users, cloud workspaces, and cloud user workspaces from ClickHouse',
timeoutSeconds: 120,
handler,
cronTriggerSettings: {
pattern: '*/10 * * * *',
},
});
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { getApplicationConfig } from 'src/shared/application-config';
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
import { twentyClient } from 'src/shared/twenty-client';
const clickHouseUserWorkspaceSchema = z.object({
id: z.string(),
workspaceId: z.string(),
userId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string(),
is_active_membership: z.coerce.boolean(),
});
type ClickHouseUserWorkspace = z.infer<typeof clickHouseUserWorkspaceSchema>;
const fetchUserWorkspacesFromClickHouse = async (): Promise<
ClickHouseUserWorkspace[]
> => {
const { clickHouseDatabase } = getApplicationConfig();
// const nowDate = 'now()'
const nowDate = "'2026-02-04 14:28:52.000'";
const query = `
SELECT
*
FROM (
SELECT
*,
row_number() OVER (PARTITION BY id ORDER BY updatedAt DESC) AS rn
FROM
${clickHouseDatabase}.user_workspace
WHERE
updatedAt >= ${nowDate} - INTERVAL 500 MINUTE
AND
updatedAt <= ${nowDate}
)
WHERE
rn = 1
FORMAT
JSONEachRow;
`;
return fetchFromClickHouse(query, clickHouseUserWorkspaceSchema);
};
const buildCloudUserWorkspaceInput = (
userWorkspace: ClickHouseUserWorkspace,
) => ({
id: userWorkspace.id,
twentyUserIdentifier: userWorkspace.userId,
twentyWorkspaceIdentifier: userWorkspace.workspaceId,
idOfTheUserWorkspace: userWorkspace.id,
cloudUser2Id: userWorkspace.userId,
cloudWorkspace2Id: userWorkspace.workspaceId,
});
export const syncCloudUserWorkspaces = async (): Promise<{
syncedCount: number;
}> => {
const userWorkspaces = await fetchUserWorkspacesFromClickHouse();
console.log(
`Fetched ${userWorkspaces.length} user workspaces from ClickHouse`,
);
if (userWorkspaces.length === 0) {
return { syncedCount: 0 };
}
const cloudUserWorkspaceInputs = userWorkspaces.map(
buildCloudUserWorkspaceInput,
);
console.log(
`Batch-upserting ${cloudUserWorkspaceInputs.length} cloud user workspaces`,
);
await twentyClient.mutation({
createCloudUserWorkspaces2: {
__args: {
data: cloudUserWorkspaceInputs,
upsert: true,
},
__scalar: true,
},
});
return { syncedCount: userWorkspaces.length };
};
@@ -0,0 +1,268 @@
import { enumCloudUser2ActivityStatusEnum } from 'twenty-sdk/generated';
import { z } from 'zod';
import { getApplicationConfig } from 'src/shared/application-config';
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
import { clickHouseDateToIso } from 'src/shared/clickhouse-date-to-iso';
import { twentyClient } from 'src/shared/twenty-client';
const clickHouseUserSchema = z.object({
userId: z.uuid(),
firstName: z.string(),
lastName: z.string(),
email: z.email(),
fullName: z.string(),
isEmailVerified: z.boolean(),
disabled: z.boolean(),
canImpersonate: z.boolean(),
canAccessFullAdminPanel: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string(),
locale: z.string(),
createdDate: z.string(),
workspaceCount: z.coerce.number(),
workspaceIds: z.string(),
workspaceDomains: z.string(),
firstActivityDate: z.string(),
lastActivityDate: z.string(),
lastWorkspaceId: z.string(),
totalPageviews: z.coerce.number(),
pageviewsLast30d: z.coerce.number(),
pageviewsLast7d: z.coerce.number(),
pageviewsLast24h: z.coerce.number(),
userAgeDays: z.coerce.number(),
daysSinceLastActivity: z.coerce.number(),
isActiveLast30d: z.boolean(),
isActiveLast7d: z.boolean(),
isActiveLast24h: z.boolean(),
activityStatus: z
.string()
.transform((val) => val.toUpperCase())
.pipe(z.enum(enumCloudUser2ActivityStatusEnum)),
avgDailyPageviewsLast30d: z.coerce.number(),
isTwenty: z.coerce.boolean(),
maxWorkspaceMembers: z.coerce.number(),
inTrial: z.boolean(),
});
type ClickHouseUser = z.infer<typeof clickHouseUserSchema>;
const fetchUsersFromClickHouse = async (): Promise<ClickHouseUser[]> => {
const { clickHouseDatabase } = getApplicationConfig();
// const nowDate = 'now()'
const nowDate = "'2026-02-04 14:28:52.000'";
const query = `
SELECT
*
FROM
${clickHouseDatabase}.user
WHERE
lastActivityDate >= ${nowDate} - INTERVAL 500 MINUTE
AND
lastActivityDate <= ${nowDate}
FORMAT
JSONEachRow;
`;
return fetchFromClickHouse(query, clickHouseUserSchema);
};
const fetchAllPeopleFromTwentyByEmail = async (emails: string[]) => {
if (emails.length === 0) {
return [];
}
const allPeople = await twentyClient.query({
people: {
edges: {
node: {
id: true,
emails: {
primaryEmail: true,
},
},
},
__args: {
filter: {
emails: {
primaryEmail: {
in: emails,
},
},
},
},
},
});
return allPeople.people?.edges.map((edge) => edge.node) ?? [];
};
const buildCloudUserInput = ({
user,
personId,
}: {
user: ClickHouseUser;
personId: string;
}) => ({
id: user.userId,
name: user.fullName,
email: {
primaryEmail: user.email,
},
personId,
fullName: {
lastName: user.lastName,
firstName: user.firstName,
},
isTwenty: user.isTwenty,
userTenure: user.userAgeDays,
isActiveL7d: user.isActiveLast7d,
isActiveL24h: user.isActiveLast24h,
isActiveL30d: user.isActiveLast30d,
pageViewsL7d: user.pageviewsLast7d,
pageViewsL24h: user.pageviewsLast24h,
pageViewsL30d: user.pageviewsLast30d,
activityStatus: user.activityStatus,
workspaceCount: user.workspaceCount,
lastActivityDate: clickHouseDateToIso(user.lastActivityDate),
dataLastUpdatedAt: new Date().toISOString(),
avgDailyPageviewsLast30d: user.avgDailyPageviewsLast30d,
daysSinceLastActivity: user.daysSinceLastActivity,
});
export const syncCloudUsers = async (): Promise<{
syncedCount: number;
}> => {
const users = await fetchUsersFromClickHouse();
console.log('Fetched users from ClickHouse', users);
const emails = users.map((user) => user.email);
console.log('Fetching people from Twenty with emails', emails);
const people = await fetchAllPeopleFromTwentyByEmail(emails);
console.log('Fetched people from Twenty', people);
// Build a map of email -> personId from existing people
const emailToPersonId = new Map<string, string>();
for (const person of people) {
if (person.emails?.primaryEmail !== undefined) {
emailToPersonId.set(person.emails.primaryEmail.toLowerCase(), person.id);
}
}
// Partition users into those with/without an existing person
const usersWithoutPerson = users.filter(
(user) => !emailToPersonId.has(user.email.toLowerCase()),
);
// Step 1: Batch-create missing people
const newlyCreatedPersonIds: string[] = [];
if (usersWithoutPerson.length > 0) {
console.log(`Batch-creating ${usersWithoutPerson.length} people records`);
const createPeopleResult = await twentyClient.mutation({
createPeople: {
__args: {
data: usersWithoutPerson.map((user) => ({
name: {
firstName: user.firstName,
lastName: user.lastName,
},
emails: {
primaryEmail: user.email,
},
})),
},
id: true,
emails: {
primaryEmail: true,
},
},
});
const createdPeople = createPeopleResult.createPeople ?? [];
for (const person of createdPeople) {
if (
person.emails?.primaryEmail === undefined ||
person.id === undefined
) {
continue;
}
newlyCreatedPersonIds.push(person.id);
emailToPersonId.set(person.emails.primaryEmail.toLowerCase(), person.id);
}
}
// Step 2: Batch-upsert all cloud users, with rollback on failure
const cloudUserInputs = users.map((user) => {
const personId = emailToPersonId.get(user.email.toLowerCase());
if (personId === undefined) {
throw new Error(
`No personId found for user ${user.email} — this should not happen`,
);
}
return buildCloudUserInput({ user, personId });
});
try {
console.log(`Batch-upserting ${cloudUserInputs.length} cloud users`);
await twentyClient.mutation({
createCloudUsers2: {
__args: {
data: cloudUserInputs,
upsert: true,
},
__scalar: true,
},
});
} catch (cloudUserError) {
console.log(
'Cloud user upsert failed, rolling back newly created people',
cloudUserError,
);
// Rollback: hard-delete people that were created in step 1
if (newlyCreatedPersonIds.length > 0) {
try {
await twentyClient.mutation({
destroyPeople: {
__args: {
filter: {
id: {
in: newlyCreatedPersonIds,
},
},
},
id: true,
},
});
console.log(
`Rolled back ${newlyCreatedPersonIds.length} newly created people`,
);
} catch (rollbackError) {
console.log(
'Rollback of newly created people also failed',
rollbackError,
);
}
}
throw cloudUserError;
}
return { syncedCount: users.length };
};
@@ -0,0 +1,201 @@
import {
enumCloudWorkspace2ActivationStatusEnum,
enumCloudWorkspace2PaymentFrequencyEnum,
enumCloudWorkspace2SubscriptionStatusEnum,
} from 'twenty-sdk/generated';
import { z } from 'zod';
import { getApplicationConfig } from 'src/shared/application-config';
import { fetchFromClickHouse } from 'src/shared/clickhouse-client';
import { clickHouseDateToIso } from 'src/shared/clickhouse-date-to-iso';
import { twentyClient } from 'src/shared/twenty-client';
const clickHouseWorkspaceSchema = z.object({
workspaceId: z.string(),
workspaceName: z.string(),
subdomain: z.string(),
customDomain: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string(),
activationStatus: z
.string()
.transform((val) => {
const upper = val.toUpperCase();
if (upper === '') return 'EMPTY';
const valid: string[] = Object.values(
enumCloudWorkspace2ActivationStatusEnum,
);
return valid.includes(upper) ? upper : 'EMPTY';
})
.pipe(z.enum(enumCloudWorkspace2ActivationStatusEnum)),
createdDate: z.string(),
lastPageviewDate: z.string(),
pageviewsLast30d: z.coerce.number(),
pageviewsLast7d: z.coerce.number(),
pageviewsLast24h: z.coerce.number(),
totalEverActiveUsers: z.coerce.number(),
totalWorkspaceUsers: z.coerce.number(),
activeUsersLast30d: z.coerce.number(),
activeUsersLast7d: z.coerce.number(),
activeUsersLast24h: z.coerce.number(),
isActiveLast30d: z.coerce.boolean(),
isActiveLast7d: z.coerce.boolean(),
isActiveLast24h: z.coerce.boolean(),
activeUserRatioLast30d: z.coerce.number(),
activeUserRatioLast7d: z.coerce.number(),
workspaceAgeDays: z.coerce.number(),
totalEvents: z.coerce.number(),
eventsLast30d: z.coerce.number(),
eventsPerUser: z.coerce.number(),
subscription_status: z
.string()
.transform((val) => {
const upper = val.toUpperCase();
if (upper === '') return 'EMPTY';
const valid: string[] = Object.values(
enumCloudWorkspace2SubscriptionStatusEnum,
);
return valid.includes(upper) ? upper : 'OTHER';
})
.pipe(z.enum(enumCloudWorkspace2SubscriptionStatusEnum)),
payment_frequency: z
.string()
.transform((val) => {
const upper = val.toUpperCase();
if (upper === '') return 'EMPTY';
const valid: string[] = Object.values(
enumCloudWorkspace2PaymentFrequencyEnum,
);
return valid.includes(upper) ? upper : 'OTHER';
})
.pipe(z.enum(enumCloudWorkspace2PaymentFrequencyEnum)),
trial_status: z.string(),
mrr: z.coerce.number(),
potential_arr: z.coerce.number(),
arr: z.coerce.number().nullable(),
next_renewal_date: z.string(),
workspace_domain: z.string(),
domain_source: z.string(),
creatorUserId: z.string(),
creatorEmail: z.string(),
creator_domain_type: z.string(),
primary_business_domain: z.string(),
business_domain_user_count: z.coerce.number(),
isTwenty: z.coerce.boolean(),
});
type ClickHouseWorkspace = z.infer<typeof clickHouseWorkspaceSchema>;
const fetchWorkspacesFromClickHouse =
async (): Promise<ClickHouseWorkspace[]> => {
const { clickHouseDatabase } = getApplicationConfig();
// const nowDate = 'now()'
const nowDate = "'2026-02-04 14:28:52.000'";
const query = `
SELECT
*
FROM (
SELECT
*,
row_number() OVER (PARTITION BY workspaceId ORDER BY updatedAt DESC) AS rn
FROM
${clickHouseDatabase}.workspace
WHERE
updatedAt >= ${nowDate} - INTERVAL 500 MINUTE
AND
updatedAt <= ${nowDate}
)
WHERE
rn = 1
FORMAT
JSONEachRow;
`;
return fetchFromClickHouse(query, clickHouseWorkspaceSchema);
};
// Convert a dollar amount to micros (1 USD = 1_000_000 micros).
const dollarsToAmountMicros = (dollars: number) =>
Math.round(dollars * 1_000_000);
const buildCloudWorkspaceInput = (workspace: ClickHouseWorkspace) => ({
id: workspace.workspaceId,
name: workspace.workspaceName,
subDomain: workspace.subdomain,
customDomain: {
primaryLinkUrl: workspace.customDomain,
primaryLinkLabel: '',
},
activationStatus: workspace.activationStatus,
subscriptionStatus: workspace.subscription_status,
paymentFrequency: workspace.payment_frequency,
lastPageViewDate: workspace.lastPageviewDate,
pageViewsL30D: workspace.pageviewsLast30d,
pageViewsL7D: workspace.pageviewsLast7d,
pageViewsL24H: workspace.pageviewsLast24h,
totalEverActiveWorkspaceUsers: workspace.totalEverActiveUsers,
totalWorkspaceUsers: workspace.totalWorkspaceUsers,
activeUsersL30D: workspace.activeUsersLast30d,
activeUsersL7D: workspace.activeUsersLast7d,
activeUsersL24H: workspace.activeUsersLast24h,
isActiveL30D: workspace.isActiveLast30d,
isActiveL7D: workspace.isActiveLast7d,
isActiveL24H: workspace.isActiveLast24h,
workspaceTenure: workspace.workspaceAgeDays,
numberOfEventsTotal: workspace.totalEvents,
numberOfEventsL30D: workspace.eventsLast30d,
mrr: {
amountMicros: dollarsToAmountMicros(workspace.mrr),
currencyCode: 'USD',
},
potentialArr: {
amountMicros: dollarsToAmountMicros(workspace.potential_arr),
currencyCode: 'USD',
},
arr: {
amountMicros: dollarsToAmountMicros(workspace.arr ?? 0),
currencyCode: 'USD',
},
nextRenewalDate: clickHouseDateToIso(workspace.next_renewal_date),
creatorEmail: {
primaryEmail: workspace.creatorEmail,
},
workspaceBusinessDomain: {
primaryLinkUrl: workspace.primary_business_domain,
primaryLinkLabel: '',
},
dataLastUpdatedAt: new Date().toISOString(),
});
export const syncCloudWorkspaces = async (): Promise<{
syncedCount: number;
}> => {
const workspaces = await fetchWorkspacesFromClickHouse();
console.log(`Fetched ${workspaces.length} workspaces from ClickHouse`);
if (workspaces.length === 0) {
return { syncedCount: 0 };
}
const cloudWorkspaceInputs = workspaces.map(buildCloudWorkspaceInput);
console.log(
`Batch-upserting ${cloudWorkspaceInputs.length} cloud workspaces`,
);
await twentyClient.mutation({
createCloudWorkspaces2: {
__args: {
data: cloudWorkspaceInputs,
upsert: true,
},
__scalar: true,
},
});
return { syncedCount: workspaces.length };
};
@@ -0,0 +1,13 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { ALL_CLOUD_USER_2_VIEW_ID } from 'src/views/all-cloud-user-2';
export default defineNavigationMenuItem({
universalIdentifier: 'ac6df084-0f0f-404a-b3d1-b084cb624d76',
type: NavigationMenuItemType.VIEW,
name: 'cloud-user-2',
icon: 'IconList',
position: 0,
viewUniversalIdentifier: ALL_CLOUD_USER_2_VIEW_ID,
});
@@ -0,0 +1,13 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID } from 'src/views/all-cloud-user-workspace-2';
export default defineNavigationMenuItem({
universalIdentifier: 'e29251f0-78ad-493f-8f99-ec54ab0b5378',
type: NavigationMenuItemType.VIEW,
name: 'cloud-user-workspace-2',
icon: 'IconList',
position: 0,
viewUniversalIdentifier: ALL_CLOUD_USER_WORKSPACE_2_VIEW_ID,
});
@@ -0,0 +1,13 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { ALL_CLOUD_WORKSPACE_2_VIEW_ID } from 'src/views/all-cloud-workspace-2';
export default defineNavigationMenuItem({
universalIdentifier: '0ba6315e-0d79-47a5-8c05-29dec621a647',
type: NavigationMenuItemType.VIEW,
name: 'cloud-workspace-2',
icon: 'IconList',
position: 0,
viewUniversalIdentifier: ALL_CLOUD_WORKSPACE_2_VIEW_ID,
});

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