Compare commits

..
Author SHA1 Message Date
Etienne 9a7d878a85 Fix kanban view when grouping select field has null value (#16998) 2026-01-07 19:26:05 +01:00
Etienne e83ed53d79 Fix memory crash when creating record in table view (#16984)
**Bug Fixed**
Creating a new record in the People table view caused a browser memory
crash ("Paused before potential out of memory crash").
**Root Cause**
In useResetVirtualizationBecauseDataChanged.ts, when creating a record,
a loop iterated from 0 to totalNumberOfRecordsToVirtualize (the total
database count).
**Fix Applied**
Changed the loop to only iterate through indices from actually loaded
pages

Fixes https://github.com/twentyhq/twenty/issues/16980
2026-01-07 15:27:20 +01:00
Paul RastoinandEtienne a42d7baef4 Workspace creation prefill fix (#16983)
# Introduction
Fixing prefill in v2 workspace creation code flow
2026-01-07 15:27:10 +01:00
7930 changed files with 319362 additions and 393811 deletions
+1 -3
View File
@@ -13,7 +13,6 @@ This directory contains Twenty's development guidelines and best practices in th
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files)
### Code Quality
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
@@ -40,7 +39,6 @@ You can manually reference any rule using the `@ruleName` syntax:
- `@nx-rules` - Include Nx-specific guidance
- `@react-general-guidelines` - Load React best practices
- `@testing-guidelines` - Get testing recommendations
- `@creating-syncable-entity` - Guide for creating new syncable entities
### Rule Types Used
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
@@ -55,7 +53,7 @@ You can manually reference any rule using the `@ruleName` syntax:
# Testing
npx nx test twenty-front # Run unit tests
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:test # Run Storybook tests
npx nx storybook:serve-and-test:static # Run Storybook tests
# Development
npx nx lint:diff-with-main twenty-front # Lint files changed vs main (fastest)
File diff suppressed because it is too large Load Diff
-2
View File
@@ -2,8 +2,6 @@ version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
exclude-paths:
- "packages/twenty-apps/community/**"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
+19 -24
View File
@@ -1,6 +1,10 @@
name: CI Front and E2E
on:
push:
branches:
- main
pull_request:
merge_group:
@@ -14,8 +18,8 @@ concurrency:
env:
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
jobs:
changed-files-check:
@@ -27,7 +31,6 @@ jobs:
packages/twenty-front/**
packages/twenty-ui/**
packages/twenty-shared/**
packages/twenty-sdk/**
changed-files-check-e2e:
uses: ./.github/workflows/changed-files.yaml
with:
@@ -39,7 +42,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
@@ -57,6 +60,8 @@ jobs:
run: df -h
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Front / Clean storybook-static
run: rm -rf packages/twenty-front/storybook-static
- name: Front / Build storybook
run: npx nx storybook:build twenty-front
- name: Save storybook build cache
@@ -65,7 +70,7 @@ jobs:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
front-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: front-sb-build
strategy:
fail-fast: false
@@ -82,28 +87,18 @@ jobs:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-ui
npx nx build twenty-sdk
- name: Install Playwright
run: |
cd packages/twenty-front
npx playwright install
run: cd packages/twenty-front && npx playwright install
- name: Restore storybook build cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }} --checkCoverage=false
- name: Rename coverage file
run: |
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
else
echo "Error: coverage-final.json not found"
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
exit 1
fi
run: mv packages/twenty-front/coverage/storybook/coverage-storybook.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
@@ -140,7 +135,7 @@ jobs:
timeout-minutes: 30
if: false
needs: front-sb-build
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
@@ -206,7 +201,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
NODE_OPTIONS: "--max-old-space-size=10240"
steps:
+16 -13
View File
@@ -1,7 +1,9 @@
name: CI SDK
on:
merge_group:
push:
branches:
- main
pull_request:
@@ -25,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
@@ -39,9 +41,6 @@ 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:
@@ -49,7 +48,7 @@ jobs:
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
@@ -80,16 +79,20 @@ jobs:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Server / Append billing config to .env.test
working-directory: packages/twenty-server
run: |
echo "" >> .env.test
echo "IS_BILLING_ENABLED=true" >> .env.test
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: SDK / Run e2e Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:e2e
- name: SDK / Run E2E Tests
run: npx nx test:e2e twenty-sdk
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
+7 -5
View File
@@ -1,9 +1,11 @@
name: CI Server
on:
pull_request:
push:
branches:
- main
merge_group:
pull_request:
permissions:
contents: read
@@ -31,7 +33,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -143,7 +145,7 @@ jobs:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
@@ -164,7 +166,7 @@ jobs:
server-integration-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: server-setup
strategy:
fail-fast: false
+3 -1
View File
@@ -1,7 +1,9 @@
name: CI Shared
on:
merge_group:
push:
branches:
- main
pull_request:
@@ -4,8 +4,6 @@ permissions:
contents: read
on:
merge_group:
pull_request:
concurrency:
+3 -1
View File
@@ -4,7 +4,9 @@ permissions:
contents: read
on:
merge_group:
push:
branches:
- main
pull_request:
-174
View File
@@ -1,174 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]
repository_dispatch:
types: [claude-core-team-issues]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.client_payload.issue_number }}
cancel-in-progress: false
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run Claude Code
id: claude-code
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Post Create-PR link if Claude ran out of turns
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "" ]; then
exit 0
fi
AHEAD=$(git rev-list --count main.."$BRANCH" 2>/dev/null || echo "0")
if [ "$AHEAD" = "0" ]; then
exit 0
fi
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
exit 0
fi
ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
ENCODED_BRANCH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$BRANCH', safe=''))")
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
if [ -n "$ISSUE_NUMBER" ]; then
gh issue comment "$ISSUE_NUMBER" --body "$(echo -e "$BODY")"
fi
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build prompt from dispatch payload
id: prompt
uses: actions/github-script@v7
with:
script: |
const p = context.payload.client_payload;
let prompt;
if (p.comment_body) {
prompt = `You are responding to a comment on issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository.\n\nThe comment by @${p.sender} says:\n\n${p.comment_body}\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
} else {
prompt = `You are responding to issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository, opened by @${p.sender}.\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
}
core.setOutput('prompt', prompt);
core.setOutput('repo', p.repo_full_name);
core.setOutput('issue_number', p.issue_number);
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.prompt.outputs.prompt }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Post response to source issue
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.TWENTY_DISPATCH_TOKEN }}
script: |
const [owner, repo] = '${{ steps.prompt.outputs.repo }}'.split('/');
const issueNumber = parseInt('${{ steps.prompt.outputs.issue_number }}', 10);
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Claude finished processing this request. [See workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
});
+3 -6
View File
@@ -24,7 +24,7 @@ on:
pull_request:
paths:
- 'packages/twenty-docs/**'
- '.github/crowdin-docs.yml'
- 'crowdin-docs.yml'
- '.github/workflows/docs-i18n-pull.yaml'
concurrency:
@@ -78,7 +78,7 @@ jobs:
for lang in $LANGUAGES; do
echo "=== Pulling translations for $lang ==="
crowdin download \
--config .github/crowdin-docs.yml \
--config crowdin-docs.yml \
--token "$CROWDIN_PERSONAL_TOKEN" \
--base-url "https://twenty.api.crowdin.com" \
--language "$lang" \
@@ -107,13 +107,10 @@ jobs:
- name: Regenerate docs.json
run: yarn docs:generate
- name: Regenerate documentation paths constants
run: yarn docs:generate-paths
- name: Commit artifacts to pull request branch
if: github.event_name == 'pull_request'
run: |
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json
if git diff --staged --quiet --exit-code; then
echo "No navigation/doc changes to commit."
exit 0
+2 -2
View File
@@ -12,7 +12,7 @@ on:
- 'packages/twenty-docs/**/*.mdx'
- '!packages/twenty-docs/l/**'
- 'packages/twenty-docs/navigation/navigation.template.json'
- '.github/crowdin-docs.yml'
- 'crowdin-docs.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
@@ -43,7 +43,7 @@ jobs:
download_translations: false
localization_branch_name: i18n-docs
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-docs.yml'
config: 'crowdin-docs.yml'
env:
# Docs translations project
CROWDIN_PROJECT_ID: '2'
+1 -1
View File
@@ -89,7 +89,7 @@ jobs:
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
config: '.github/crowdin-app.yml'
config: 'crowdin-app.yml'
env:
GITHUB_TOKEN: ${{ github.token }}
# App translations project
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
download_translations: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-app.yml'
config: 'crowdin-app.yml'
env:
# App translations project
CROWDIN_PROJECT_ID: '1'
+5 -3
View File
@@ -2,9 +2,11 @@
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "${PG_DATABASE_URL}"],
"env": {}
"command": "uv",
"args": ["run", "postgres-mcp", "--access-mode=unrestricted"],
"env": {
"DATABASE_URI": "${PG_DATABASE_URL}"
}
},
"playwright": {
"type": "stdio",
+4
View File
@@ -0,0 +1,4 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf"
}
View File
+1 -12
View File
@@ -68,14 +68,12 @@
"./jest-integration.config.ts",
"${relativeFile}",
"--silent=false",
"${input:updateSnapshot}"
],
"cwd": "${workspaceFolder}/packages/twenty-server",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
"NODE_ENV": "test"
}
},
{
@@ -99,14 +97,5 @@
"NODE_ENV": "test"
}
}
],
"inputs": [
{
"id": "updateSnapshot",
"type": "pickString",
"description": "Update snapshots?",
"options": ["", "--updateSnapshot"],
"default": ""
}
]
}
+1 -2
View File
@@ -53,6 +53,5 @@
".cursorrules": "markdown"
},
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.experimental.useTsgo": false
"typescript.tsdk": "node_modules/typescript/lib"
}
+7 -59
View File
@@ -2,64 +2,12 @@
"version": "2.0.0",
"tasks": [
{
"label": "twenty-server - run integration test file",
"type": "shell",
"command": "npx nx run twenty-server:jest -- --config ./jest-integration.config.ts ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
"options": {
"cwd": "${workspaceFolder}/packages/twenty-server",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
},
"shell": {
"executable": "/bin/zsh",
"args": ["-l", "-c"]
}
},
"presentation": {
"reveal": "always",
"panel": "new",
"close": false
},
"problemMatcher": []
},
{
"label": "twenty-server - run unit test file",
"type": "shell",
"command": "npx nx run twenty-server:jest -- --config ./jest.config.mjs ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
"options": {
"cwd": "${workspaceFolder}/packages/twenty-server",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
},
"shell": {
"executable": "/bin/zsh",
"args": ["-l", "-c"]
}
},
"presentation": {
"reveal": "always",
"panel": "new",
"close": false
},
"problemMatcher": []
}
],
"inputs": [
{
"id": "watchMode",
"type": "pickString",
"description": "Enable watch mode?",
"options": ["", "--watch"],
"default": ""
},
{
"id": "updateSnapshot",
"type": "pickString",
"description": "Update snapshots?",
"options": ["", "--updateSnapshot"],
"default": ""
"type": "npm",
"script": "start",
"path": "server",
"problemMatcher": [],
"label": "yarn: start - server",
"detail": "yarn start"
}
]
}
}
+30 -74
View File
@@ -21,31 +21,30 @@ npx nx run twenty-server:worker # Start background worker
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
# Run tests
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:serve-and-test:static twenty-front # Run Storybook tests
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (diff with main - fastest)
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
npx nx lint:diff-with-main twenty-server # Lint only files changed vs main
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix files changed vs main
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Linting (full project)
npx nx lint twenty-front # Lint all files in frontend
npx nx lint twenty-server # Lint all files in backend
npx nx lint twenty-front --fix # Auto-fix all linting issues
# Type checking
npx nx typecheck twenty-front
@@ -58,8 +57,7 @@ npx nx fmt twenty-server
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
# Build packages
npx nx build twenty-front
npx nx build twenty-server
```
@@ -71,7 +69,7 @@ npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run migrations
# Generate migration (replace [name] with kebab-case descriptive name)
# Generate migration
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
# Sync metadata
@@ -80,9 +78,8 @@ npx nx run twenty-server:command workspace:sync-metadata
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
# Generate GraphQL types
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
@@ -110,36 +107,13 @@ packages/
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **No 'any' type allowed**
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Recoil** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- **Recoil** for global state management
- Component-specific state with React hooks
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
### Backend Architecture
- **NestJS modules** for feature organization
@@ -148,54 +122,36 @@ packages/
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Migrations
### Database
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **TypeORM migrations** for schema management
- **ClickHouse** for analytics (when enabled)
- Always generate migrations when changing entity files
- Migration names must be kebab-case (e.g. `add-agent-turn-evaluation`)
- Include both `up` and `down` logic in migrations
- Never delete or rewrite committed migrations
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure database migrations are generated for entity changes
1. Always run linting and type checking after code changes
2. Test changes with relevant test suites
3. Ensure database migrations are properly structured
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Emotion** for styling with styled-components pattern
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
- Components should be in their own directories with tests and stories
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## CI Environment (GitHub Actions)
When running in CI, the dev environment is **not** pre-configured. Dependencies are installed but builds, env files, and databases are not set up.
- **Before running tests, builds, lint, type checks, or DB operations**, run: `bash packages/twenty-utils/setup-dev-env.sh`
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
- The script is idempotent and safe to run multiple times.
- **Unit tests** with Jest for both frontend and backend
- **Integration tests** for critical backend workflows
- **Storybook** for component development and testing
- **E2E tests** with Playwright for critical user flows
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
- `.cursor/rules/` - Development guidelines and best practices
+48
View File
@@ -0,0 +1,48 @@
DOCKER_NETWORK=twenty_network
ensure-docker-network:
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
postgres-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_pg \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
@echo "Waiting for PostgreSQL to be ready..."
@until docker exec twenty_pg psql -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
sleep 1; \
done
docker exec twenty_pg psql -U postgres -d postgres \
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
redis-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
grafana-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_grafana \
-p 4000:3000 \
-e GF_SECURITY_ADMIN_USER=admin \
-e GF_SECURITY_ADMIN_PASSWORD=admin \
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
-v $(PWD)/packages/twenty-docker/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
grafana/grafana-oss:latest
opentelemetry-collector-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_otlp_collector \
-p 4317:4317 \
-p 4318:4318 \
-p 13133:13133 \
-v $(PWD)/packages/twenty-docker/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
otel/opentelemetry-collector-contrib:latest \
--config /etc/otel-collector-config.yaml
@@ -5,7 +5,6 @@
#
"preserve_hierarchy": true
"base_path": ".."
files: [
{
@@ -6,7 +6,6 @@
"project_id": 2
"preserve_hierarchy": true
"base_url": "https://twenty.api.crowdin.com"
"base_path": ".."
files: [
{
+2 -11
View File
@@ -11,10 +11,6 @@ import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
const twentyRules = await nxPlugin.loadWorkspaceRules(
'packages/twenty-eslint-rules',
);
export default [
// Base JavaScript configuration
js.configs.recommended,
@@ -67,10 +63,6 @@ export default [
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
},
{
sourceTag: 'scope:create-app',
onlyDependOnLibsWithTags: ['scope:create-app', 'scope:shared'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
@@ -203,7 +195,6 @@ export default [
plugins: {
...mdxPlugin.flat.plugins,
'@nx': nxPlugin,
twenty: { rules: twentyRules },
},
},
mdxPlugin.flatCodeBlocks,
@@ -214,9 +205,9 @@ export default [
'unused-imports/no-unused-imports': 'off',
'unused-imports/no-unused-vars': 'off',
// Enforce JSX tags on separate lines to prevent Crowdin translation issues
'twenty/mdx-component-newlines': 'error',
'@nx/workspace-mdx-component-newlines': 'error',
// Disallow angle bracket placeholders to prevent Crowdin translation errors
'twenty/no-angle-bracket-placeholders': 'error',
'@nx/workspace-no-angle-bracket-placeholders': 'error',
},
},
];
@@ -13,8 +13,6 @@ import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
const twentyRules = await nxPlugin.loadWorkspaceRules('packages/twenty-eslint-rules');
export default [
// Base JavaScript configuration
js.configs.recommended,
@@ -36,7 +34,6 @@ export default [
'import': importPlugin,
'unused-imports': unusedImportsPlugin,
'unicorn': unicornPlugin,
'twenty': { rules: twentyRules },
},
settings: {
react: {
@@ -570,17 +567,17 @@ export default [
'@typescript-eslint/no-unused-vars': 'off',
// Custom workspace rules
'twenty/effect-components': 'error',
'twenty/no-hardcoded-colors': 'error',
'twenty/matching-state-variable': 'error',
'twenty/sort-css-properties-alphabetically': 'error',
'twenty/styled-components-prefixed-with-styled': 'error',
'twenty/no-state-useref': 'error',
'twenty/component-props-naming': 'error',
'twenty/explicit-boolean-predicates-in-if': 'error',
'twenty/use-getLoadable-and-getValue-to-get-atoms': 'error',
'twenty/useRecoilCallback-has-dependency-array': 'error',
'twenty/no-navigate-prefer-link': 'error',
'@nx/workspace-effect-components': 'error',
'@nx/workspace-no-hardcoded-colors': 'error',
'@nx/workspace-matching-state-variable': 'error',
'@nx/workspace-sort-css-properties-alphabetically': 'error',
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
'@nx/workspace-no-state-useref': 'error',
'@nx/workspace-component-props-naming': 'error',
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
'@nx/workspace-no-navigate-prefer-link': 'error',
},
},
@@ -724,7 +721,7 @@ export default [
},
},
],
'twenty/max-consts-per-file': ['error', { max: 1 }],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
@@ -781,7 +778,7 @@ export default [
},
},
],
'twenty/max-consts-per-file': ['error', { max: 1 }],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
+5
View File
@@ -0,0 +1,5 @@
import { getJestProjects } from '@nx/jest';
export default {
projects: getJestProjects(),
};
+1 -7
View File
@@ -1,9 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = {
...nxPreset,
// Override the new testEnvironmentOptions added in @nx/jest 22.3.3
// which breaks Lingui's module resolution
testEnvironmentOptions: {},
};
module.exports = { ...nxPreset };
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
command -v node >/dev/null 2>&1 || { echo >&2 "Nx requires NodeJS to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
command -v npm >/dev/null 2>&1 || { echo >&2 "Nx requires npm to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
path_to_root=$(dirname $BASH_SOURCE)
node $path_to_root/.nx/nxw.js $@
+113 -36
View File
@@ -4,7 +4,9 @@
"libsDir": "packages"
},
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"default": [
"{projectRoot}/**/*"
],
"excludeStories": [
"default",
"!{projectRoot}/.storybook/*",
@@ -32,17 +34,26 @@
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["^production", "production"],
"dependsOn": ["^build"]
"inputs": [
"^production",
"production"
],
"dependsOn": [
"^build"
]
},
"start": {
"cache": false,
"dependsOn": ["^build"]
"dependsOn": [
"^build"
]
},
"lint": {
"executor": "@nx/eslint:lint",
"cache": true,
"outputs": ["{options.outputFile}"],
"outputs": [
"{options.outputFile}"
],
"options": {
"eslintConfig": "{projectRoot}/eslint.config.mjs",
"cache": true,
@@ -56,7 +67,9 @@
"fix": true
}
},
"dependsOn": ["^build"]
"dependsOn": [
"^build"
]
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
@@ -90,36 +103,46 @@
"write": true
}
},
"dependsOn": ["^build"]
"dependsOn": [
"^build"
]
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "tsgo -p tsconfig.json"
"command": "tsc -b tsconfig.json --incremental"
},
"configurations": {
"watch": {
"command": "tsgo -p tsconfig.json --watch"
"watch": true
}
},
"dependsOn": ["^build"]
"dependsOn": [
"^build"
]
},
"test": {
"executor": "@nx/jest:jest",
"cache": true,
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"inputs": [
"^default",
"excludeStories",
"{workspaceRoot}/jest.preset.js"
],
"outputs": ["{projectRoot}/coverage"],
"outputs": [
"{projectRoot}/coverage"
],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs",
"coverage": true,
"coverageReporters": ["text-summary"],
"coverageReporters": [
"text-summary"
],
"cacheDirectory": "../../.cache/jest/{projectRoot}"
},
"configurations": {
@@ -128,7 +151,10 @@
"maxWorkers": 3
},
"coverage": {
"coverageReporters": ["lcov", "text"]
"coverageReporters": [
"lcov",
"text"
]
},
"watch": {
"watch": true
@@ -137,25 +163,36 @@
},
"test:e2e": {
"cache": true,
"dependsOn": ["^build"]
"dependsOn": [
"^build"
]
},
"storybook:build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["^default", "excludeTests"],
"outputs": ["{projectRoot}/{options.output-dir}"],
"inputs": [
"^default",
"excludeTests"
],
"outputs": [
"{projectRoot}/{options.output-dir}"
],
"options": {
"cwd": "{projectRoot}",
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
"output-dir": "storybook-static",
"config-dir": ".storybook"
},
"dependsOn": ["^build"]
"dependsOn": [
"^build"
]
},
"storybook:serve:dev": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["^build"],
"dependsOn": [
"^build"
],
"options": {
"cwd": "{projectRoot}",
"command": "storybook dev",
@@ -164,7 +201,9 @@
},
"storybook:serve:static": {
"executor": "nx:run-commands",
"dependsOn": ["storybook:build"],
"dependsOn": [
"storybook:build"
],
"options": {
"cwd": "{projectRoot}",
"command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}",
@@ -177,21 +216,38 @@
"storybook:test": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["^default", "excludeTests"],
"outputs": ["{projectRoot}/coverage/storybook"],
"inputs": [
"^default",
"excludeTests"
],
"outputs": [
"{projectRoot}/coverage/storybook"
],
"options": {
"cwd": "{projectRoot}",
"command": "vitest run --coverage --shard={args.shard}",
"shard": "1/1"
"commands": [
"test-storybook --url http://localhost:{args.port} --maxWorkers=3 --coverage --coverageDirectory={args.coverageDir} --shard={args.shard}",
"nx storybook:coverage {projectName} --coverageDir={args.coverageDir} --checkCoverage={args.checkCoverage}"
],
"shard": "1/1",
"parallel": false,
"coverageDir": "coverage/storybook",
"port": 6006,
"checkCoverage": true
}
},
"storybook:test:no-coverage": {
"executor": "nx:run-commands",
"inputs": ["^default", "excludeTests"],
"inputs": [
"^default",
"excludeTests"
],
"options": {
"cwd": "{projectRoot}",
"command": "vitest run --shard={args.shard}",
"shard": "1/1"
"commands": [
"test-storybook --url http://localhost:{args.port} --maxWorkers=2"
],
"port": 6006
}
},
"storybook:coverage": {
@@ -218,6 +274,17 @@
}
}
},
"storybook:serve-and-test:static": {
"executor": "nx:run-commands",
"options": {
"commands": [
"npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --shard={args.shard} --checkCoverage={args.checkCoverage} --port={args.port} --configuration={args.scope}'"
],
"shard": "1/1",
"checkCoverage": true,
"port": 6006
}
},
"chromatic": {
"executor": "nx:run-commands",
"options": {
@@ -259,21 +326,29 @@
"inputs": [
"default",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/packages/twenty-eslint-rules/**/*"
"{workspaceRoot}/tools/eslint-rules/**/*"
]
},
"@nx/vite:test": {
"cache": true,
"inputs": [
"default",
"^default"
]
},
"@nx/vite:build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["default", "^default"]
},
"@nx/vitest:test": {
"cache": true,
"inputs": ["default", "^default"]
"dependsOn": [
"^build"
],
"inputs": [
"default",
"^default"
]
}
},
"installation": {
"version": "22.3.3"
"version": "22.0.3"
},
"generators": {
"@nx/react": {
@@ -301,7 +376,9 @@
"tasksRunnerOptions": {
"default": {
"options": {
"cacheableOperations": ["storybook:build"]
"cacheableOperations": [
"storybook:build"
]
}
}
},
+35 -41
View File
@@ -52,6 +52,7 @@
"rxjs": "^7.2.0",
"semver": "^7.5.4",
"slash": "^5.1.0",
"storybook-addon-mock-date": "^0.6.0",
"temporal-polyfill": "^0.3.0",
"ts-key-enum": "^2.0.12",
"tslib": "^2.8.1",
@@ -66,36 +67,41 @@
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.24.6",
"@chromatic-com/storybook": "^4.1.3",
"@chromatic-com/storybook": "^3",
"@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/client-preset": "^4.1.0",
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@nx/eslint": "22.3.3",
"@nx/eslint-plugin": "22.3.3",
"@nx/jest": "22.3.3",
"@nx/js": "22.3.3",
"@nx/react": "22.3.3",
"@nx/storybook": "22.3.3",
"@nx/vite": "22.3.3",
"@nx/web": "22.3.3",
"@nx/eslint": "22.0.3",
"@nx/eslint-plugin": "22.0.3",
"@nx/jest": "22.0.3",
"@nx/js": "22.0.3",
"@nx/react": "22.0.3",
"@nx/storybook": "22.0.3",
"@nx/vite": "22.0.3",
"@nx/web": "22.0.3",
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-links": "^10.1.11",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.1.11",
"@storybook/test-runner": "^0.24.2",
"@storybook/addon-actions": "8.6.15",
"@storybook/addon-coverage": "^1.0.0",
"@storybook/addon-essentials": "8.6.15",
"@storybook/addon-interactions": "8.6.15",
"@storybook/addon-links": "8.6.15",
"@storybook/blocks": "8.6.15",
"@storybook/core-server": "8.6.15",
"@storybook/icons": "^1.2.9",
"@storybook/preview-api": "8.6.15",
"@storybook/react": "8.6.15",
"@storybook/react-vite": "8.6.15",
"@storybook/test": "8.6.15",
"@storybook/test-runner": "^0.23.0",
"@storybook/types": "8.6.15",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.8.0",
"@swc/cli": "^0.3.12",
"@swc/core": "1.13.3",
"@swc/helpers": "~0.5.2",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/addressparser": "^1.0.3",
@@ -103,6 +109,7 @@
"@types/bytes": "^3.1.1",
"@types/chrome": "^0.0.267",
"@types/deep-equal": "^1.0.1",
"@types/express": "^4.17.13",
"@types/fs-extra": "^11.0.4",
"@types/graphql-fields": "^1.3.6",
"@types/inquirer": "^9.0.9",
@@ -135,11 +142,7 @@
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@typescript-eslint/utils": "^8.39.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "3.11.0",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/coverage-istanbul": "^4.0.17",
"@vitest/coverage-v8": "^4.0.17",
"@yarnpkg/types": "^4.0.0",
"chromatic": "^6.18.0",
"concurrently": "^8.2.2",
@@ -159,7 +162,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^10.1.11",
"eslint-plugin-storybook": "^0.9.0",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
@@ -168,24 +171,23 @@
"jest-environment-node": "^29.4.1",
"jest-fetch-mock": "^3.0.3",
"jsdom": "~22.1.0",
"msw": "^2.12.7",
"msw-storybook-addon": "^2.0.6",
"nx": "22.3.3",
"msw": "^2.0.11",
"msw-storybook-addon": "^2.0.5",
"nx": "22.0.3",
"prettier": "^3.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "^10.1.11",
"storybook-addon-mock-date": "2.0.0",
"storybook-addon-pseudo-states": "^10.1.11",
"storybook": "8.6.15",
"storybook-addon-cookie": "^3.2.0",
"storybook-addon-pseudo-states": "^2.1.2",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
"ts-node": "10.9.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"vite": "^7.0.0",
"vitest": "^4.0.17"
"vite": "^7.0.0"
},
"engines": {
"node": "^24.5.0",
@@ -201,16 +203,13 @@
"typescript": "5.9.2",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16"
"prosemirror-transform": "1.10.4"
},
"version": "0.2.1",
"nx": {},
"scripts": {
"docs:generate": "tsx packages/twenty-docs/scripts/generate-docs-json.ts",
"docs:generate-navigation-template": "tsx packages/twenty-docs/scripts/generate-navigation-template.ts",
"docs:generate-paths": "tsx packages/twenty-docs/scripts/generate-documentation-paths.ts",
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
},
"workspaces": {
@@ -229,12 +228,7 @@
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-eslint-rules"
"tools/eslint-rules"
]
},
"prettier": {
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf"
}
}
+21 -32
View File
@@ -15,12 +15,9 @@
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), generate, uninstall, and function management
- Preconfigured scripts for auth, generate, dev sync, oneoff sync, uninstall
- 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)
@@ -31,49 +28,41 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# If you don't use yarn@4
corepack enable
yarn install
# Get help
yarn run help
# Authenticate using your API key (you'll be prompted)
yarn auth:login
yarn auth
# Add a new entity to your application (guided)
yarn entity:add
yarn create-entity
# Generate a typed Twenty client and workspace entity types
yarn app:generate
yarn generate
# Start dev mode: watches, builds, and syncs local changes to your workspace
yarn app:dev
# Start dev mode: automatically syncs local changes to your workspace
yarn dev
# Watch your application's function logs
yarn function:logs
# Or run a onetime sync
yarn sync
# Execute a function with a JSON payload
yarn function:execute -n my-function -p '{"key": "value"}'
# Watch your application's functions logs
yarn logs
# Uninstall the application from the current workspace
yarn app:uninstall
yarn uninstall
# Display commands' help
yarn help
```
## What gets scaffolded
- A minimal app structure ready for Twenty with example files:
- `application-config.ts` - Application metadata configuration
- `roles/default-role.ts` - Default role for logic functions
- `logic-functions/hello-world.ts` - Example logic function with HTTP trigger
- `front-components/hello-world.tsx` - Example front component
- A minimal app structure ready for Twenty
- TypeScript configuration
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
- Example placeholders to help you add entities, actions, and sync logic
## Next steps
- Use `yarn auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn entity:add` (logic functions, front components, objects, roles).
- Use `yarn app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- Keep your types uptodate using `yarn app:generate`.
- Explore the generated project and add your first entity with `yarn create-entity`.
- Keep your types uptodate using `yarn generate`.
- Use `yarn dev` while you iterate to see changes instantly in your workspace.
## Publish your application
@@ -101,8 +90,8 @@ git push
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Auth prompts not appearing: run `yarn auth:login` again and verify the API key permissions.
- Types not generated: ensure `yarn app:generate` runs without errors, then restart `yarn app:dev`.
- Auth prompts not appearing: run `yarn auth` again and verify the API key permissions.
- Types not generated: ensure `yarn generate` runs without errors, then restart `yarn dev`.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
+100 -9
View File
@@ -1,20 +1,111 @@
import baseConfig from '../../eslint.config.mjs';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import prettierPlugin from 'eslint-plugin-prettier';
export default [
...baseConfig,
js.configs.recommended,
{
ignores: ['**/dist/**'],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
// Browser globals that Node.js also has
URL: 'readonly',
URLSearchParams: 'readonly',
// Node.js types
NodeJS: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
prettier: prettierPlugin,
},
rules: {
...typescriptEslint.configs.recommended.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-useless-escape': 'off',
},
},
{
rules: {
'no-console': 'off',
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
},
},
ignores: ['src/**/*.ts', '!src/cli/**/*.ts'],
},
{
files: ['**/*.test.ts', '**/*.spec.ts', '**/__tests__/**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
globals: {
// Node.js globals
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
global: 'readonly',
// Jest globals
describe: 'readonly',
it: 'readonly',
test: 'readonly',
expect: 'readonly',
jest: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
prettier: prettierPlugin,
},
rules: {
...typescriptEslint.configs.recommended.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'no-useless-escape': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**'],
},
];
+3 -9
View File
@@ -1,18 +1,14 @@
{
"name": "create-twenty-app",
"version": "0.5.0",
"version": "0.2.4",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
"files": [
"dist",
"README.md",
"package.json"
"dist/**/*"
],
"scripts": {
"build": "npx rimraf dist && npx vite build",
"prepublishOnly": "tsx ../twenty-utils/pack-scripts/pre-publish-only.ts",
"postpublish": "tsx ../twenty-utils/pack-scripts/post-publish.ts"
"build": "npx rimraf dist && npx vite build"
},
"keywords": [
"twenty",
@@ -38,7 +34,6 @@
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-shared": "workspace:*",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -48,7 +43,6 @@
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -5,36 +5,17 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn auth:login
yarn auth
```
Then, start development mode to sync your app and watch for changes:
Then, install this app to your workspace:
```bash
yarn app:dev
yarn sync
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
```bash
# Authentication
yarn auth:login # Authenticate with Twenty
yarn auth:logout # Remove credentials
yarn auth:status # Check auth status
yarn auth:switch # Switch default workspace
yarn auth:list # List all configured workspaces
# Application
yarn app:dev # Start dev mode (watch, build, and sync)
yarn entity:add # Add a new entity (function, front-component, object, role)
yarn app:generate # Generate typed Twenty client
yarn function:logs # Stream function logs
yarn function:execute # Execute a function with JSON payload
yarn app:uninstall # Uninstall app from workspace
```
## Learn More
To learn more about Twenty applications, take a look at the following resources:
@@ -5,7 +5,6 @@
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
@@ -22,10 +21,6 @@
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -1,12 +1,12 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
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 * as path from 'path';
import { copyBaseApplicationProject } from '@/utils/app-template';
import kebabCase from 'lodash.kebabcase';
import { convertToLabel } from '@/utils/convert-to-label';
import { tryGitInit } from '@/utils/try-git-init';
import { install } from '@/utils/install';
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
@@ -119,15 +119,10 @@ export class CreateAppCommand {
}
private logSuccess(appDirectory: string): void {
const dirName = appDirectory.split('/').reverse()[0] ?? '';
console.log(chalk.green('✅ Application created!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
console.log(chalk.gray(` corepack enable # if you don't use yarn@4`));
console.log(chalk.gray(` yarn install # if you don't use yarn@4`));
console.log(chalk.gray(' yarn auth:login # Authenticate with Twenty'));
console.log(chalk.gray(' yarn app:dev # Start dev mode'));
console.log(`cd ${appDirectory.split('/').reverse()[0] ?? ''}`);
console.log('yarn auth');
}
}
@@ -1,302 +0,0 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { tmpdir } from 'os';
import { copyBaseApplicationProject } from '@/utils/app-template';
// Mock fs-extra's copy function to skip copying base template (not available during tests)
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
return {
...actual,
copy: jest.fn().mockResolvedValue(undefined),
};
});
const APPLICATION_FILE_NAME = 'application-config.ts';
const DEFAULT_ROLE_FILE_NAME = 'default-role.ts';
describe('copyBaseApplicationProject', () => {
let testAppDirectory: string;
beforeEach(async () => {
// Create a unique temp directory for each test
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fs.ensureDir(testAppDirectory);
jest.clearAllMocks();
});
afterEach(async () => {
// Clean up temp directory after each test
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
});
it('should create the correct folder structure with src/', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
// Verify src/ folder exists
const srcAppPath = join(testAppDirectory, 'src');
expect(await fs.pathExists(srcAppPath)).toBe(true);
// Verify application-config.ts exists in src/
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
expect(await fs.pathExists(appConfigPath)).toBe(true);
// Verify default-role.ts exists in src/
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
expect(await fs.pathExists(roleConfigPath)).toBe(true);
});
it('should create package.json with correct content', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const packageJsonPath = join(testAppDirectory, 'package.json');
expect(await fs.pathExists(packageJsonPath)).toBe(true);
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.dependencies['twenty-sdk']).toBe('0.5.0');
expect(packageJson.scripts['app:dev']).toBe('twenty app:dev');
});
it('should create .gitignore file', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const gitignorePath = join(testAppDirectory, '.gitignore');
expect(await fs.pathExists(gitignorePath)).toBe(true);
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('/node_modules');
expect(gitignoreContent).toContain('generated');
});
it('should create yarn.lock file', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
expect(await fs.pathExists(yarnLockPath)).toBe(true);
const yarnLockContent = await fs.readFile(yarnLockPath, 'utf8');
expect(yarnLockContent).toContain('yarn lockfile v1');
});
it('should create application-config.ts with defineApplication and correct values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
// Verify it uses defineApplication
expect(appConfigContent).toContain(
"import { defineApplication } from 'twenty-sdk'",
);
expect(appConfigContent).toContain('export default defineApplication({');
// Verify it imports the role identifier
expect(appConfigContent).toContain(
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
);
// Verify display name and description
expect(appConfigContent).toContain("displayName: 'My Test App'");
expect(appConfigContent).toContain("description: 'A test application'");
// Verify it has a universalIdentifier (UUID format)
expect(appConfigContent).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
// Verify it references the role
expect(appConfigContent).toContain(
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
});
it('should create default-role.ts with defineRole and correct values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const roleConfigPath = join(
testAppDirectory,
'src',
'roles',
DEFAULT_ROLE_FILE_NAME,
);
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
// Verify it uses defineRole
expect(roleConfigContent).toContain(
"import { defineRole } from 'twenty-sdk'",
);
expect(roleConfigContent).toContain('export default defineRole({');
// Verify it exports the universal identifier constant
expect(roleConfigContent).toContain(
'export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
// Verify role label includes app name
expect(roleConfigContent).toContain(
"label: 'My Test App default function role'",
);
// Verify default permissions
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
// Verify it has a universalIdentifier (UUID format)
expect(roleConfigContent).toMatch(
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
);
});
it('should call fs.copy to copy base application template', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
// Verify fs.copy was called with correct destination
expect(fs.copy).toHaveBeenCalledTimes(1);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('base-application'),
testAppDirectory,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: '',
appDirectory: testAppDirectory,
});
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
expect(appConfigContent).toContain("description: ''");
});
it('should generate unique UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
});
// Read both app configs
const firstAppConfig = await fs.readFile(
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
'utf8',
);
const secondAppConfig = await fs.readFile(
join(secondAppDir, 'src', APPLICATION_FILE_NAME),
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
const secondUuid = secondAppConfig.match(uuidRegex)?.[1];
expect(firstUuid).toBeDefined();
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
it('should generate unique role UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
});
const firstRoleConfig = await fs.readFile(
join(firstAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
'utf8',
);
const secondRoleConfig = await fs.readFile(
join(secondAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
const secondUuid = secondRoleConfig.match(uuidRegex)?.[1];
expect(firstUuid).toBeDefined();
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
});
@@ -1,9 +1,8 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { v4 } from 'uuid';
import { ASSETS_DIR } from 'twenty-shared/application';
const SRC_FOLDER = 'src';
const SOURCE_FOLDER = 'src';
export const copyBaseApplicationProject = async ({
appName,
@@ -22,45 +21,28 @@ export const copyBaseApplicationProject = async ({
await createGitignore(appDirectory);
await createPublicAssetDirectory(appDirectory);
await createYarnLock(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
const sourceFolderPath = join(appDirectory, SOURCE_FOLDER);
await fs.ensureDir(sourceFolderPath);
await createDefaultRoleConfig({
const defaultServerlessFunctionRoleUniversalIdentifier = v4();
await createDefaultServerlessFunctionRoleConfig({
displayName: appDisplayName,
appDirectory: sourceFolderPath,
fileFolder: 'roles',
fileName: 'default-role.ts',
});
await createDefaultFrontComponent({
appDirectory: sourceFolderPath,
fileFolder: 'front-components',
fileName: 'hello-world.tsx',
});
await createDefaultFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'hello-world.ts',
defaultServerlessFunctionRoleUniversalIdentifier,
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory: sourceFolderPath,
fileName: 'application-config.ts',
defaultServerlessFunctionRoleUniversalIdentifier,
});
};
const createPublicAssetDirectory = async (appDirectory: string) => {
await fs.ensureDir(join(appDirectory, ASSETS_DIR));
};
const createYarnLock = async (appDirectory: string) => {
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
@@ -86,9 +68,6 @@ generated
# dev
/dist/
.twenty/*
!.twenty/output/
# production
/build
@@ -112,135 +91,55 @@ yarn-error.log*
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
const createDefaultRoleConfig = async ({
const createDefaultServerlessFunctionRoleConfig = async ({
displayName,
appDirectory,
fileFolder,
fileName,
defaultServerlessFunctionRoleUniversalIdentifier,
}: {
displayName: string;
appDirectory: string;
fileFolder?: string;
fileName: string;
defaultServerlessFunctionRoleUniversalIdentifier: string;
}) => {
const universalIdentifier = v4();
const content = `import { type RoleConfig } from 'twenty-sdk';
const content = `import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
export const functionRole: RoleConfig = {
universalIdentifier: '${defaultServerlessFunctionRoleUniversalIdentifier}',
label: '${displayName} default function role',
description: '${displayName} default function role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
};
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultFrontComponent = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineFrontComponent } from 'twenty-sdk';
export const HelloWorld = () => {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '${universalIdentifier}',
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
return { message: 'Hello, World!' };
};
// Logic function handler - rename and implement your logic
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/hello-world-logic-function',
httpMethod: 'GET',
isAuthRequired: false,
},
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
await fs.writeFile(join(appDirectory, 'role.config.ts'), content);
};
const createApplicationConfig = async ({
displayName,
description,
appDirectory,
fileFolder,
fileName,
defaultServerlessFunctionRoleUniversalIdentifier,
}: {
displayName: string;
description?: string;
appDirectory: string;
fileFolder?: string;
fileName: string;
defaultServerlessFunctionRoleUniversalIdentifier: string;
}) => {
const content = `import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
const content = `import { type ApplicationConfig } from 'twenty-sdk';
export default defineApplication({
const config: ApplicationConfig = {
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
functionRoleUniversalIdentifier: '${defaultServerlessFunctionRoleUniversalIdentifier}',
};
export default config;
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
await fs.writeFile(join(appDirectory, 'application.config.ts'), content);
};
const createPackageJson = async ({
@@ -261,29 +160,23 @@ const createPackageJson = async ({
},
packageManager: '[email protected]',
scripts: {
'auth:login': 'twenty auth:login',
'auth:logout': 'twenty auth:logout',
'auth:status': 'twenty auth:status',
'auth:switch': 'twenty auth:switch',
'auth:list': 'twenty auth:list',
'app:dev': 'twenty app:dev',
'entity:add': 'twenty entity:add',
'app:generate': 'twenty app:generate',
'function:logs': 'twenty function:logs',
'function:execute': 'twenty function:execute',
'app:uninstall': 'twenty app:uninstall',
'create-entity': 'twenty app add',
dev: 'twenty app dev',
generate: 'twenty app generate',
sync: 'twenty app sync',
logs: 'twenty app logs',
uninstall: 'twenty app uninstall',
help: 'twenty help',
auth: 'twenty auth login',
lint: 'eslint',
'lint:fix': 'eslint --fix',
'lint-fix': 'eslint --fix',
},
dependencies: {
'twenty-sdk': '0.5.0',
'twenty-sdk': '0.2.4',
},
devDependencies: {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
},
+12 -12
View File
@@ -1,5 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"esModuleInterop": false,
@@ -9,18 +8,19 @@
"noImplicitAny": true,
"strictBindCallApply": false,
"noEmit": true,
"types": ["jest", "node"],
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react"
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"**/__mocks__/**/*",
"vite.config.ts",
"jest.config.mjs"
]
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": [
"**/__mocks__/**/*",
"vite.config.ts",
"jest.config.mjs",
"src/**/*.d.ts",
"src/**/*.spec.ts",
"src/**/*.spec.tsx",
"src/**/*.test.ts",
"src/**/*.test.tsx"
]
}
@@ -75,7 +75,6 @@ export default defineConfig(() => {
'path',
'fs',
'child_process',
'util',
],
output: [
{
-1
View File
@@ -1,2 +1 @@
generated
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -230,7 +230,7 @@ npm run test -- --watch
npx twenty-cli app dev
# Type checking
npx tsgo --noEmit
npx tsc --noEmit
```
## Testing
@@ -24,7 +24,10 @@
}
},
"typecheck": {
"dependsOn": ["^build"]
"executor": "nx:run-commands",
"options": {
"command": "tsc --noEmit --project {projectRoot}/tsconfig.json"
}
},
"lint": {
"executor": "@nx/eslint:lint",
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -1,7 +1,7 @@
import typescriptParser from '@typescript-eslint/parser';
import path from 'path';
import { fileURLToPath } from 'url';
import reactConfig from '../../../../twenty-eslint-rules/eslint.config.react.mjs';
import reactConfig from '../../eslint.config.react.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -3,8 +3,9 @@
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react",
"baseUrl": "src",
"paths": {
"@/*": ["./src/*"]
"@/*": ["*"]
}
}
}
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
File diff suppressed because one or more lines are too long
@@ -1 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -13,7 +13,7 @@ const config: ApplicationConfig = {
isSecret: false,
},
},
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
functionRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
export default config;
@@ -95,7 +95,7 @@ export class PostCard {
label: 'Notes',
icon: 'IconComment',
inverseSideTargetUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note.universalIdentifier,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note,
onDelete: OnDeleteAction.CASCADE,
})
notes: Note[];
@@ -14,7 +14,7 @@ export const functionRole: RoleConfig = {
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
objectNameSingular: 'postCard',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -23,8 +23,8 @@ export const functionRole: RoleConfig = {
],
fieldPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
objectNameSingular: 'postCard',
fieldName: 'content',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
@@ -0,0 +1,21 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
const config: ApplicationConfig = {
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
description: 'Twenty API key for creating selfHostingUser records',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
description: 'Twenty API URL (e.g., https://api.twenty.com)',
isSecret: false,
},
},
};
export default config;
@@ -8,25 +8,8 @@
"yarn": ">=4.0.2"
},
"packageManager": "[email protected]",
"scripts": {
"auth:login": "twenty auth login",
"auth:logout": "twenty auth logout",
"auth:status": "twenty auth status",
"auth:switch": "twenty auth switch",
"auth:list": "twenty auth list",
"app:dev": "twenty app dev",
"app:sync": "twenty app sync",
"entity:add": "twenty entity add",
"app:generate": "twenty app generate",
"function:logs": "twenty function logs",
"function:execute": "twenty function execute",
"app:uninstall": "twenty app uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "0.3.1"
"twenty-sdk": "0.0.4"
},
"devDependencies": {
"@types/node": "^24.7.2"
@@ -1,19 +0,0 @@
import { defineApp } from 'twenty-sdk';
export default defineApp({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
description: 'Twenty API key for creating selfHostingUser records',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
description: 'Twenty API URL (e.g., https://api.twenty.com)',
isSecret: false,
},
},
});
@@ -1,18 +0,0 @@
import { FieldType, defineObject } from 'twenty-sdk';
export default defineObject({
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
nameSingular: 'selfHostingUser',
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
fields: [
{
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f',
},
],
});
@@ -1,132 +0,0 @@
import { defineFunction } from 'twenty-sdk';
import { createClient } from '../../generated';
// TODO: import from twenty-sdk when 0.4.0 is deployed
type ServerlessFunctionEvent<TBody = object> = {
headers: Record<string, string | undefined>;
queryStringParameters: Record<string, string | undefined>;
pathParameters: Record<string, string | undefined>;
body: TBody | null;
isBase64Encoded: boolean;
requestContext: {
http: {
method: string;
path: string;
};
};
};
type TelemetryEventPayload = {
action: string;
timestamp: string;
version: string;
payload: {
userId: string | null;
workspaceId: string | null;
payload?: {
events?: Array<{
userId?: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl?: string;
}>;
};
};
};
export const main = async (
params: ServerlessFunctionEvent<TelemetryEventPayload>,
): Promise<{ success: boolean; message: string; error?: string }> => {
try {
const { action, payload } = params.body || {};
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
const userEmail =
payload?.payload?.events?.[0]?.userEmail ||
payload?.payload?.events?.[0]?.userId;
if (!userEmail) {
return {
success: false,
message: 'No email found in telemetry event',
error: 'Missing userEmail in payload',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = createClient({
url: `${process.env.TWENTY_API_URL}/graphql`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
// Create or update selfHostingUser record
const result = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name:
payload?.payload?.events?.[0]?.userFirstName +
' ' +
payload?.payload?.events?.[0]?.userLastName,
email: {
primaryEmail: userEmail,
additionalEmails: null,
},
},
upsert: true,
},
id: true,
email: {
primaryEmail: true,
},
},
});
return {
success: true,
message: `Self hosting user created/updated: ${result.createSelfHostingUser?.id}`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export default defineFunction({
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 5,
handler: main,
triggers: [
{
universalIdentifier: '7c8e3f5a-9b4c-4d1e-8f2a-1b3c4d5e6f7a',
type: 'route',
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
],
});
@@ -0,0 +1,18 @@
import { FieldMetadata, FieldMetadataType, ObjectMetadata } from 'twenty-sdk/application';
@ObjectMetadata({
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
nameSingular: 'selfHostingUser',
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
})
export class SelfHostingUser {
@FieldMetadata({
type: FieldMetadataType.EMAILS,
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
})
email: object;
}
@@ -0,0 +1,114 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import { createClient } from '../generated';
type TelemetryEventPayload = {
action: string;
timestamp: string;
version: string;
payload: {
userId: string | null;
workspaceId: string | null;
payload?: {
events?: Array<{
userId?: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl?: string;
}>;
};
};
};
export const main = async (
params: TelemetryEventPayload,
): Promise<{ success: boolean; message: string; error?: string }> => {
try {
const { action, payload } = params;
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
const userEmail =
payload?.payload?.events?.[0]?.userEmail ||
payload?.payload?.events?.[0]?.userId;
if (!userEmail) {
return {
success: false,
message: 'No email found in telemetry event',
error: 'Missing userEmail in payload',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = createClient({
url: `${process.env.TWENTY_API_URL}/graphql`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
// Create or update selfHostingUser record
const result = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name: payload?.payload?.events?.[0]?.userFirstName + ' ' + payload?.payload?.events?.[0]?.userLastName,
email: {
primaryEmail: userEmail,
additionalEmails: null,
},
},
upsert: true,
},
id: true,
email: {
primaryEmail: true,
},
},
});
return {
success: true,
message: `Self hosting user created/updated: ${result.createSelfHostingUser?.id}`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 5,
triggers: [
{
universalIdentifier: '7c8e3f5a-9b4c-4d1e-8f2a-1b3c4d5e6f7a',
type: 'route',
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};
@@ -20,11 +20,7 @@
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
"resolveJsonModule": true
},
"exclude": [
"node_modules",
File diff suppressed because it is too large Load Diff
+3 -61
View File
@@ -1,21 +1,15 @@
# Makefile for building and running Twenty CRM docker containers.
# Makefile for building Twenty CRM docker images.
# Set the tag and/or target build platform using make command-line variables assignments.
#
# Optional make variables:
# PLATFORM - defaults to 'linux/amd64'
# TAG - defaults to 'latest'
#
# Example: make prod-build
# Example: make PLATFORM=linux/aarch64 TAG=my-tag prod-build
# Example: make postgres-on-docker (for local development)
# Example: make
# Example: make PLATFORM=linux/aarch64 TAG=my-tag
PLATFORM ?= linux/amd64
TAG ?= latest
DOCKER_NETWORK=twenty_network
# =============================================================================
# Production Image Building
# =============================================================================
prod-build:
@cd ../.. && docker build -f ./packages/twenty-docker/twenty/Dockerfile --platform $(PLATFORM) --tag twenty:$(TAG) . && cd -
@@ -31,55 +25,3 @@ prod-website-build:
prod-website-run:
@docker run -d -p 3000:3000 --name twenty-website twenty-website:$(TAG)
# =============================================================================
# Local Development Services
# Run these from the repository root: make -C packages/twenty-docker <target>
# =============================================================================
ensure-docker-network:
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
postgres-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_pg \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
@echo "Waiting for PostgreSQL to be ready..."
@until docker exec twenty_pg psql -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
sleep 1; \
done
docker exec twenty_pg psql -U postgres -d postgres \
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
redis-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
grafana-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_grafana \
-p 4000:3000 \
-e GF_SECURITY_ADMIN_USER=admin \
-e GF_SECURITY_ADMIN_PASSWORD=admin \
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
-v $(PWD)/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
grafana/grafana-oss:latest
opentelemetry-collector-on-docker: ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_otlp_collector \
-p 4317:4317 \
-p 4318:4318 \
-p 13133:13133 \
-v $(PWD)/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
otel/opentelemetry-collector-contrib:latest \
--config /etc/otel-collector-config.yaml
@@ -1,7 +0,0 @@
apiVersion: v2
name: twenty
description: A Helm chart to deploy Twenty CRM (server + worker) with optional PostgreSQL and Redis dependencies.
type: application
version: 0.1.0
appVersion: "v1.14.0"
icon: https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg
@@ -1,51 +0,0 @@
# Twenty Helm Chart - Quick Install
## Simple Install
Set your domain and install:
```bash
export DOMAIN=crm.example.com
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace --wait \
--set "server.ingress.hosts[0].host=$DOMAIN" \
--set "server.ingress.tls[0].hosts[0]=$DOMAIN"
```
That's it! The chart will:
- Auto-generate a secure access token
- Create the PostgreSQL database "twenty" and schema "core" automatically
- Run TypeORM migrations via server init
- Enable TLS via cert-manager (acme: true by default for letsencrypt-prod)
## Access the App
Visit: `https://$DOMAIN`
Sign up to create your admin account through the web UI.
## Retrieve System Credentials
App secret token (for configuration/integrations):
```bash
kubectl get secret tokens -n twentycrm -o jsonpath='{.data.accessToken}' | base64 --decode && echo
```
Internal PostgreSQL credentials are managed by the chart and not exposed by default. If you need direct access, create your own user in the database pod or use an external PostgreSQL instance.
Jobs for DB creation and migrations have been removed to simplify deployments; the server handles readiness and migrations at startup.
## Advanced Configuration
See [full README](README.md) for:
- External PostgreSQL/Redis
- S3 storage configuration
- Custom resource limits
## Uninstall
```bash
helm uninstall my-twenty -n twentycrm
kubectl delete namespace twentycrm
```
@@ -1,86 +0,0 @@
# Twenty Helm Chart
Deploy Twenty CRM on Kubernetes with server, worker, PostgreSQL, and Redis components.
## Features
- Server and worker deployments with full env exposure via `values.yaml`.
- Internal PostgreSQL (Spilo) and Redis deployments included.
- PVC-based persistence using dynamic storage classes (no static PV manifests).
- Ingress with configurable annotations, hosts, and TLS.
- Database readiness and migrations handled by server/worker init containers by default.
Standard Kubernetes Jobs for DB creation/user and migrations have been removed to simplify installs. Readiness and migrations run in init containers.
## Quick Start
See [QUICKSTART.md](QUICKSTART.md) for a simple 2-line install with your domain.
## Installing
**Prerequisites:** Kubernetes 1.21+, Helm 3.8+, default StorageClass
Internal DB + Redis (default):
```bash
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace
```
External DB/Redis:
```bash
helm install my-twenty ./packages/twenty-docker/helm/twenty \
--namespace twentycrm --create-namespace \
--set db.enabled=false \
--set db.external.host=db.example.com \
--set redisInternal.enabled=false
```
## Key Values
See `values.yaml` for a comprehensive list.
## Notes
- Database URL and Redis URL are composed automatically from chart settings
- Database `twenty` and schema `core` are created automatically by server init container
- No optional jobs: the chart no longer provides separate Jobs for DB or migrations.
- Access token auto-generated (32 chars) if not provided; reuses existing secret if present
- For production, provide a strong `secrets.tokens.accessToken` value via a secure values file; the auto-generated token is a convenience fallback.
- TLS enabled by default via cert-manager (`acme: true`)
- Requires default StorageClass for PVC provisioning
## Testing
```bash
helm lint ./packages/twenty-docker/helm/twenty
helm template my-twenty ./packages/twenty-docker/helm/twenty
helm plugin install https://github.com/quintush/helm-unittest
helm unittest ./packages/twenty-docker/helm/twenty
```
## Storage
**Local (default):** Uses PVCs for persistence
**S3:** Set `storage.type=s3` and provide credentials using a values file. You can either pass credentials directly or reference an existing Kubernetes Secret.
```bash
# values-secrets.yaml (do not commit)
# storage:
# type: s3
# s3:
# bucket: my-bucket
# region: us-east-1
# # Option A: direct values
# accessKeyId: AKIA...
# secretAccessKey: ...
# # Option B: reference a Secret
# # secretName: my-s3-creds
# # accessKeyIdKey: accessKeyId
# # secretAccessKeyKey: secretAccessKey
helm install my-twenty ./packages/twenty-docker/helm/twenty -f values-secrets.yaml
```
## Production Tips
- **Image versioning:** The chart defaults to `Chart.yaml`'s `appVersion` (currently v1.14.0). Override via `image.tag` in values to pin a different version or use `latest` for rolling updates.
- **Keep secrets secure:** Avoid `--set` for sensitive values; use `-f values-secrets.yaml` or reference existing Kubernetes Secrets via `server.extraEnvFrom`.
- S3 credentials can be referenced via `storage.s3.secretName + accessKeyIdKey/secretAccessKeyKey` to avoid embedding them in pod specs.
@@ -1,17 +0,0 @@
Thank you for installing Twenty!
To access the application:
- If ingress enabled:
{{- if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) }}
- Primary host: {{ (index .Values.server.ingress.hosts 0).host | default "<set a host>" }}
{{- else }}
- Primary host: <set a host>
{{- end }}
- URL: {{ include "twenty.serverUrl" . }}
- If ingress disabled: expose the service as needed (e.g., `kubectl port-forward svc/{{ include "twenty.fullname" . }}-server 3000:{{ .Values.server.service.port }}` or create a LoadBalancer/NodePort).
Configuration:
- Using internal DB: {{ ternary "yes" "no" .Values.db.enabled }}
- Using external DB: {{ ternary "yes" "no" (not .Values.db.enabled) }}
- Using internal Redis: {{ ternary "yes" "no" .Values.redisInternal.enabled }}
@@ -1,199 +0,0 @@
{{- define "twenty.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "twenty.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "twenty.namespace" -}}
{{ .Release.Namespace }}
{{- end -}}
{{/* Server image fields merged with globals */}}
{{- define "twenty.server.image" -}}
{{- $repo := default $.Values.image.repository (index $.Values.server.image "repository" | default "") -}}
{{- $tag := default (default $.Chart.AppVersion $.Values.image.tag) (index $.Values.server.image "tag" | default "") -}}
{{- $pp := default $.Values.image.pullPolicy (index $.Values.server.image "pullPolicy" | default "") -}}
{{- printf "%s:%s|%s" $repo $tag $pp -}}
{{- end -}}
{{/* Worker image fields merged with globals */}}
{{- define "twenty.worker.image" -}}
{{- $repo := default $.Values.image.repository (index $.Values.worker.image "repository" | default "") -}}
{{- $tag := default (default $.Chart.AppVersion $.Values.image.tag) (index $.Values.worker.image "tag" | default "") -}}
{{- $pp := default $.Values.image.pullPolicy (index $.Values.worker.image "pullPolicy" | default "") -}}
{{- printf "%s:%s|%s" $repo $tag $pp -}}
{{- end -}}
{{/* Extract parts of image helper */}}
{{- define "twenty.image.repository" -}}
{{- regexFind "^([^:|]+)" . -}}
{{- end -}}
{{- define "twenty.image.tag" -}}
{{- regexFind ":([^|]+)" . | trimPrefix ":" -}}
{{- end -}}
{{- define "twenty.image.pullPolicy" -}}
{{- regexFind "\\|(.+)$" . | trimPrefix "|" -}}
{{- end -}}
{{/* Check if using external secret for database password */}}
{{- define "twenty.db.useExternalSecret" -}}
{{- if and (not .Values.db.enabled) .Values.db.external.secretName .Values.db.external.passwordKey -}}
true
{{- else -}}
false
{{- end -}}
{{- end -}}
{{/* Database URL secret name */}}
{{- define "twenty.dbUrl.secretName" -}}
{{- printf "%s-db-url" (include "twenty.fullname" .) -}}
{{- end -}}
{{/* Database password secret name */}}
{{- define "twenty.dbPassword.secretName" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- .Values.db.external.secretName -}}
{{- else -}}
{{- include "twenty.dbUrl.secretName" . -}}
{{- end -}}
{{- end -}}
{{/* Database password secret key */}}
{{- define "twenty.dbPassword.secretKey" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- .Values.db.external.passwordKey -}}
{{- else if .Values.db.enabled -}}
appPassword
{{- else -}}
password
{{- end -}}
{{- end -}}
{{/* Database URL template for external secret (will be evaluated at runtime) */}}
{{- define "twenty.dbUrl.template" -}}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" -}}
{{- $scheme := "postgres" -}}
{{- $host := .Values.db.external.host -}}
{{- $port := .Values.db.external.port | default 5432 -}}
{{- $user := .Values.db.external.user | default "postgres" -}}
{{- $db := .Values.db.external.database | default "twenty" -}}
{{- $qs := ternary "?sslmode=require" "" (eq .Values.db.external.ssl true) -}}
{{- printf "%s://%s:$(DB_PASSWORD)@%s:%v/%s%s" $scheme $user $host $port $db $qs -}}
{{- end -}}
{{- end -}}
{{/* Compose Redis URL */}}
{{- define "twenty.redisUrl" -}}
{{- if .Values.server.env.REDIS_URL -}}
{{- .Values.server.env.REDIS_URL -}}
{{- else if .Values.redisInternal.enabled -}}
{{- $host := printf "%s-redis" (include "twenty.fullname" .) -}}
{{- printf "redis://%s.%s.svc.cluster.local:6379" $host (include "twenty.namespace" .) -}}
{{- else -}}
{{- $host := .Values.redis.external.host | default "redis" -}}
{{- $port := .Values.redis.external.port | default 6379 -}}
{{- printf "redis://%s:%v" $host $port -}}
{{- end -}}
{{- end -}}
{{/* Compose Server URL from override, ingress, or service */}}
{{- define "twenty.serverUrl" -}}
{{- if .Values.server.env.SERVER_URL -}}
{{- .Values.server.env.SERVER_URL -}}
{{- else if and .Values.server.ingress.enabled (gt (len .Values.server.ingress.hosts) 0) -}}
{{- $host := (index .Values.server.ingress.hosts 0).host -}}
{{- $tls := gt (len .Values.server.ingress.tls) 0 -}}
{{- $scheme := ternary "https" "http" $tls -}}
{{- $port := ternary 443 80 $tls -}}
{{- printf "%s://%s:%v" $scheme $host $port -}}
{{- else -}}
{{- $svc := printf "%s-server" (include "twenty.fullname" .) -}}
{{- $ns := include "twenty.namespace" . -}}
{{- $port := .Values.server.service.port | default 3000 -}}
{{- printf "http://%s.%s.svc.cluster.local:%v" $svc $ns $port -}}
{{- end -}}
{{- end -}}
{{/* Tokens secret name */}}
{{- define "twenty.secret.tokens.name" -}}
{{- .Values.secrets.tokens.name | default "tokens" -}}
{{- end -}}
{{/* Access token value: reuse existing secret if present, else provided value, else generated */}}
{{- define "twenty.secret.tokens.access" -}}
{{- $name := include "twenty.secret.tokens.name" . -}}
{{- $ns := include "twenty.namespace" . -}}
{{- $existing := lookup "v1" "Secret" $ns $name -}}
{{- if and $existing $existing.data.accessToken -}}
{{- b64dec $existing.data.accessToken -}}
{{- else if .Values.secrets.tokens.accessToken -}}
{{- .Values.secrets.tokens.accessToken -}}
{{- else -}}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end -}}
{{/* Server container port */}}
{{- define "twenty.server.containerPort" -}}
{{- .Values.server.service.port | default 3000 -}}
{{- end -}}
{{/* Storage type: prefer top-level storage.type, else legacy server.env.STORAGE_TYPE, else local */}}
{{- define "twenty.storageType" -}}
{{- if .Values.storage.type -}}
{{- .Values.storage.type -}}
{{- else if .Values.server.env.STORAGE_TYPE -}}
{{- .Values.server.env.STORAGE_TYPE -}}
{{- else -}}
local
{{- end -}}
{{- end -}}
{{/* Additional storage env vars (e.g., S3) */}}
{{- define "twenty.storageEnv" -}}
{{- if eq (include "twenty.storageType" .) "s3" -}}
{{- with .Values.storage.s3.bucket }}
- name: STORAGE_S3_NAME
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.region }}
- name: STORAGE_S3_REGION
value: {{ . | quote }}
{{- end }}
{{- with .Values.storage.s3.endpoint }}
- name: STORAGE_S3_ENDPOINT
value: {{ . | quote }}
{{- end }}
{{- if and .Values.storage.s3.secretName .Values.storage.s3.accessKeyIdKey }}
- name: STORAGE_S3_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.secretName | quote }}
key: {{ .Values.storage.s3.accessKeyIdKey | quote }}
{{- else }}
{{- with .Values.storage.s3.accessKeyId }}
- name: STORAGE_S3_ACCESS_KEY_ID
value: {{ . | quote }}
{{- end }}
{{- end }}
{{- if and .Values.storage.s3.secretName .Values.storage.s3.secretAccessKeyKey }}
- name: STORAGE_S3_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.storage.s3.secretName | quote }}
key: {{ .Values.storage.s3.secretAccessKeyKey | quote }}
{{- else }}
{{- with .Values.storage.s3.secretAccessKey }}
- name: STORAGE_S3_SECRET_ACCESS_KEY
value: {{ . | quote }}
{{- end }}
{{- end }}
{{- end -}}
{{- end -}}
@@ -1,62 +0,0 @@
{{- if .Values.db.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-db
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: db
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: db
spec:
volumes:
{{- if .Values.db.internal.persistence.enabled }}
- name: db-data
persistentVolumeClaim:
claimName: {{ if .Values.db.internal.persistence.existingClaim }}{{ .Values.db.internal.persistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-db{{ end }}
{{- end }}
containers:
- name: db
image: {{ .Values.db.internal.image.repository }}:{{ .Values.db.internal.image.tag }}
imagePullPolicy: {{ .Values.db.internal.image.pullPolicy | default "IfNotPresent" }}
env:
- name: PGUSER_SUPERUSER
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: username
- name: PGPASSWORD_SUPERUSER
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: password
- name: SPILO_PROVIDER
value: {{ .Values.db.internal.env.SPILO_PROVIDER | quote }}
- name: ALLOW_NOSSL
value: {{ .Values.db.internal.env.ALLOW_NOSSL | quote }}
ports:
- containerPort: 5432
name: tcp
protocol: TCP
resources:
{{- toYaml .Values.db.internal.resources | nindent 12 }}
{{- if .Values.db.internal.persistence.enabled }}
volumeMounts:
- name: db-data
mountPath: /home/postgres/pgdata
{{- end }}
{{- end }}
@@ -1,57 +0,0 @@
{{- if .Values.redisInternal.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-redis
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: redis
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
containers:
- name: redis
image: {{ .Values.redisInternal.image.repository }}:{{ .Values.redisInternal.image.tag }}
imagePullPolicy: {{ default "IfNotPresent" .Values.redisInternal.image.pullPolicy }}
command:
- redis-stack-server
args:
- "--port"
- {{ .Values.redisInternal.service.port | quote }}
- "--maxmemory-policy"
- "noeviction"
ports:
- containerPort: {{ .Values.redisInternal.service.port }}
name: redis
protocol: TCP
resources:
{{- toYaml .Values.redisInternal.resources | nindent 12 }}
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
{{- if .Values.redisInternal.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.redisInternal.persistence.existingClaim | default (printf "%s-redis" (include "twenty.fullname" .)) }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
@@ -1,216 +0,0 @@
{{- if .Values.server.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-server
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
replicas: {{ .Values.server.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: server
strategy:
{{- if or .Values.server.dockerDataPersistence.enabled .Values.server.persistence.enabled }}
type: Recreate
{{- else }}
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
{{- end }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
spec:
securityContext:
{{- toYaml .Values.securityContext | nindent 8 }}
volumes:
{{- if .Values.server.dockerDataPersistence.enabled }}
- name: docker-data
persistentVolumeClaim:
claimName: {{ if .Values.server.dockerDataPersistence.existingClaim }}{{ .Values.server.dockerDataPersistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-docker-data{{ end }}
{{- end }}
{{- if .Values.server.persistence.enabled }}
- name: server-data
persistentVolumeClaim:
claimName: {{ if .Values.server.persistence.existingClaim }}{{ .Values.server.persistence.existingClaim }}{{ else }}{{ include "twenty.fullname" . }}-server{{ end }}
{{- end }}
initContainers:
- name: wait-for-db
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h {{ if .Values.db.enabled }}{{ include "twenty.fullname" . }}-db{{ else }}{{ .Values.db.external.host }}{{ end }} \
-p {{ if .Values.db.enabled }}5432{{ else }}{{ .Values.db.external.port | default 5432 }}{{ end }} \
-U postgres; do
echo "Waiting for database socket..."
sleep 2
done
echo "Database socket is ready!"
{{- if .Values.db.enabled }}
- name: ensure-database-exists
image: postgres:16-alpine
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-superuser
key: password
- name: APP_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.fullname" . }}-db-url
key: appPassword
command:
- sh
- -c
- |
DBNAME={{ .Values.db.internal.database | default "twenty" }}
APP_USER={{ .Values.db.internal.appUser | default "twenty_app_user" }}
export PGPASSWORD
export APP_PASSWORD
echo "Creating database ${DBNAME} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -Atc "SELECT 1 FROM pg_database WHERE datname = :'db'" | grep -q 1 || \
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v db="${DBNAME}" -c 'CREATE DATABASE :"db";'
echo "Creating app user ${APP_USER} if it doesn't exist..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d postgres -v app_user="${APP_USER}" -v app_password="${APP_PASSWORD}" <<'EOSQL'
DO
$do$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_user') THEN
EXECUTE format('CREATE USER %I WITH PASSWORD %L', :'app_user', :'app_password');
END IF;
END
$do$;
EOSQL
echo "Creating core schema and granting permissions..."
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'CREATE SCHEMA IF NOT EXISTS core'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v db="${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON SCHEMA core TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON SCHEMA public TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA core TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON TABLES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA core GRANT ALL ON SEQUENCES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO :"app_user";'
psql -h {{ include "twenty.fullname" . }}-db -p 5432 -U postgres -d "${DBNAME}" -v app_user="${APP_USER}" -c 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO :"app_user";'
echo "Database ${DBNAME} is ready."
{{- end }}
- name: run-migrations
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
- sh
- -c
- >-
npx -y typeorm migration:run -d dist/database/typeorm/core/core.datasource
env:
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
containers:
- name: server
{{- $img := include "twenty.server.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: SIGN_IN_PREFILLED
value: {{ .Values.server.env.SIGN_IN_PREFILLED | quote }}
- name: STORAGE_TYPE
value: {{ include "twenty.storageType" . | quote }}
- name: ACCESS_TOKEN_EXPIRES_IN
value: {{ .Values.server.env.ACCESS_TOKEN_EXPIRES_IN | quote }}
- name: LOGIN_TOKEN_EXPIRES_IN
value: {{ .Values.server.env.LOGIN_TOKEN_EXPIRES_IN | quote }}
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- end }}
ports:
- name: http-tcp
containerPort: {{ include "twenty.server.containerPort" . }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: {{ include "twenty.server.containerPort" . }}
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /
port: {{ include "twenty.server.containerPort" . }}
initialDelaySeconds: 40
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 5
resources:
{{- toYaml .Values.server.resources | nindent 12 }}
volumeMounts:
{{- if .Values.server.dockerDataPersistence.enabled }}
- name: docker-data
mountPath: /app/docker-data
{{- end }}
{{- if .Values.server.persistence.enabled }}
- name: server-data
mountPath: /app/packages/twenty-server/.local-storage
{{- end }}
{{- with .Values.server.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.server.stdin }}
stdin: {{ .Values.server.stdin }}
{{- end }}
{{- if .Values.server.tty }}
tty: {{ .Values.server.tty }}
{{- end }}
{{- end }}
@@ -1,87 +0,0 @@
{{- if .Values.worker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "twenty.fullname" . }}-worker
namespace: {{ include "twenty.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
spec:
replicas: {{ .Values.worker.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/component: worker
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "twenty.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: worker
spec:
securityContext:
{{- toYaml .Values.securityContext | nindent 8 }}
initContainers:
- name: wait-for-db
image: postgres:16-alpine
command:
- sh
- -c
- |
until pg_isready -h {{ if .Values.db.enabled }}{{ include "twenty.fullname" . }}-db{{ else }}{{ .Values.db.external.host }}{{ end }} \
-p {{ if .Values.db.enabled }}5432{{ else }}{{ .Values.db.external.port | default 5432 }}{{ end }} \
-U postgres; do
echo "Waiting for database socket..."
sleep 2
done
echo "Database socket is ready!"
containers:
- name: worker
{{- $img := include "twenty.worker.image" . }}
image: {{ include "twenty.image.repository" $img }}:{{ include "twenty.image.tag" $img }}
imagePullPolicy: {{ include "twenty.image.pullPolicy" $img }}
command:
{{- toYaml .Values.worker.command | nindent 12 }}
env:
- name: SERVER_URL
value: {{ include "twenty.serverUrl" . | quote }}
{{- if eq (include "twenty.db.useExternalSecret" .) "true" }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbPassword.secretName" . }}
key: {{ include "twenty.dbPassword.secretKey" . }}
- name: PG_DATABASE_URL
value: {{ include "twenty.dbUrl.template" . | quote }}
{{- else }}
- name: PG_DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "twenty.dbUrl.secretName" . }}
key: url
{{- end }}
- name: REDIS_URL
value: {{ include "twenty.redisUrl" . | quote }}
- name: STORAGE_TYPE
value: {{ include "twenty.storageType" . | quote }}
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: {{ include "twenty.secret.tokens.name" . }}
key: accessToken
{{- $storageEnv := (include "twenty.storageEnv" .) }}
{{- if $storageEnv }}
{{ $storageEnv | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.worker.resources | nindent 12 }}
stdin: {{ default true .Values.worker.stdin }}
tty: {{ default true .Values.worker.tty }}
{{- end }}

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