Compare commits

..
Author SHA1 Message Date
Charles Bochet 64710fb57e fix(server): backport relationTargetFieldMetadataId column-add to 2.4 fast instance
Backport of the column-add to a new 2.4 fast instance command so that
users sitting on a v2.3.x baseline can upgrade past v2.4 without later
hitting the 2.5 workspace command NormalizeCompositeFieldDefaults
failing on `column ViewFilterEntity.relationTargetFieldMetadataId does
not exist`.

Same fix as main #20721, scoped to the 2.4 file only for this release.
ADD COLUMN IF NOT EXISTS keeps it idempotent.
2026-05-19 15:34:27 +02:00
Etienne a630874c35 fix(ai-agent-node) - agent node execution error (#20534)
**Root cause:** getWorkflowRunContext(stepInfos) builds a Record<string,
unknown> from the previous steps' results. There is no workspaceId key
in it, so context.workspaceId as string silently evaluated to undefined.
That undefined was then passed all the way down to
WorkspaceCacheService.getOrRecompute, **which correctly throws** when
workspaceId is not a valid UUID.

Before : 
<img width="525" height="130" alt="Screenshot 2026-05-13 at 14 58 54"
src="https://github.com/user-attachments/assets/0549b4dc-7063-44e5-95a1-00a460a6d7f1"
/>

Introduced with billing v2 yesterday, since then, workspaceId is needed
to bill credit usage
2026-05-13 15:37:27 +02:00
Etienne 9dc8333908 Billing - Add default ff (#20480) 2026-05-12 13:28:24 +02:00
1537 changed files with 24658 additions and 71108 deletions
-72
View File
@@ -1,72 +0,0 @@
---
description: GitHub Actions security guidelines for supply chain protection
globs: **/.github/**/*.yml, **/.github/**/*.yaml
alwaysApply: false
---
# GitHub Actions Security
## Pin Third-Party Actions to Commit SHAs
Always reference external actions and reusable workflows by their full commit SHA, never by a mutable tag or branch. Tags can be force-pushed by a compromised maintainer account.
```yaml
# ❌ Mutable tag — vulnerable to supply chain attacks
uses: actions/checkout@v4
uses: actions/setup-node@v4
# ✅ Pinned to commit SHA with tag comment for readability
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
```
## Prefer `gh api` Over Third-Party Dispatch Actions
For repository dispatch calls, use `gh api` directly instead of third-party actions like `peter-evans/repository-dispatch`. This eliminates a supply-chain dependency entirely.
```yaml
# ✅ Use env vars + bracket notation to prevent injection
- name: Dispatch to target repo
env:
GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
gh api repos/org/repo/dispatches \
-f event_type=my-event \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[branch]=$BRANCH"
# ✅ Simple dispatch without payload
- name: Trigger workflow
env:
GH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
run: |
gh api repos/org/repo/dispatches -f event_type=my-event
# ❌ Third-party action dependency
- uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.DISPATCH_TOKEN }}
repository: org/repo
event-type: my-event
# ❌ Inline ${{ }} in shell — vulnerable to injection
- run: |
gh api repos/org/repo/dispatches --input - <<EOF
{"event_type": "x", "client_payload": {"branch": "${{ github.head_ref }}"}}
EOF
```
## Minimal Permissions
Always declare explicit `permissions` at the job level with the least privilege required. Never rely on the default `GITHUB_TOKEN` permissions.
```yaml
# ✅ Explicit minimal permissions
permissions:
contents: read
# ❌ Overly broad or implicit permissions
permissions: write-all
```
-6
View File
@@ -1,6 +0,0 @@
/.github/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/CODEOWNERS @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/workflows/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/actions/ @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.github/dependabot.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
/.yarnrc.yml @charlesBochet @FelixMalfait @Weiko @prastoin @bosiraphael @etiennejouan @ijreilly @martmull @thomtrp
+1 -1
View File
@@ -21,7 +21,7 @@ runs:
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
@@ -21,7 +21,7 @@ runs:
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
+1 -1
View File
@@ -20,7 +20,7 @@ runs:
run: git fetch origin main --depth=1
- name: Get last successful commit
if: env.NX_BASE == ''
uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4
uses: nrwl/nx-set-shas@v4
- name: Fallback to origin/main if no base found
if: env.NX_BASE == ''
shell: bash
+1 -1
View File
@@ -25,7 +25,7 @@ runs:
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (restore)
uses: actions/cache/restore@v4
id: restore-cache
with:
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
+1 -4
View File
@@ -9,11 +9,8 @@ inputs:
runs:
using: "composite"
steps:
# Fork PRs on pull_request already can't write to the base repo's cache (GitHub built-in).
# The fork guard is defense-in-depth for pull_request_target, which does have write access.
- name: Save cache
if: ${{ format('{0}', github.event.pull_request.head.repo.fork) != 'true' }}
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (save)
uses: actions/cache/save@v4
with:
key: ${{ inputs.key }}
path: |
@@ -48,7 +48,7 @@ runs:
fi
- name: Checkout docker compose files
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ steps.resolve.outputs.git-ref }}
+4 -7
View File
@@ -29,12 +29,12 @@ runs:
echo "packages/*/node_modules" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
- name: Setup Node.js and get yarn cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Restore node_modules
id: cache-node-modules
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (restore)
uses: actions/cache/restore@v4
with:
key: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
@@ -44,13 +44,10 @@ runs:
shell: ${{ steps.globals.outputs.ACTION_SHELL }}
run: |
yarn config set enableHardenedMode true
yarn config set enableScripts false
yarn --immutable --check-cache
# Fork PRs on pull_request already can't write to the base repo's cache (GitHub built-in).
# The fork guard is defense-in-depth for pull_request_target, which does have write access.
- name: Save cache
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' && format('{0}', github.event.pull_request.head.repo.fork) != 'true' }}
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (save)
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
uses: actions/cache/save@v4
with:
key: ${{ steps.cache-node-modules.outputs.cache-primary-key }}
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
+6 -5
View File
@@ -14,8 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches \
-f event_type=auto-deploy-main
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: auto-deploy-main
client-payload: '{"github": ${{ toJson(github) }}}' # Passes the entire github context to the downstream workflow
+6 -7
View File
@@ -14,10 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
REF_NAME: ${{ github.ref_name }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches \
-f event_type=auto-deploy-tag \
-f "client_payload[github][ref_name]=$REF_NAME"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: auto-deploy-tag
client-payload: '{"github": ${{ toJson(github) }}}' # Passes the entire github context to the downstream workflow
+2 -2
View File
@@ -21,11 +21,11 @@ jobs:
any_changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Check for changed files
id: changed-files
uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45.0.9
uses: tj-actions/changed-files@v45
with:
files: ${{ inputs.files }}
+7 -6
View File
@@ -17,7 +17,7 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: main
@@ -41,7 +41,7 @@ jobs:
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.6
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
@@ -61,7 +61,8 @@ jobs:
- name: Trigger automerge
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=automated-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: automated-pr-ready
+14 -40
View File
@@ -66,7 +66,7 @@ jobs:
steps:
- name: Checkout current branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
@@ -164,17 +164,9 @@ jobs:
interval=5
elapsed=0
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
while [ $elapsed -lt $timeout ]; do
GRAPHQL_RESPONSE=$(curl -s -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d '{"query":"{ __schema { queryType { name } } }"}' 2>/dev/null || echo '{}')
if echo "$GRAPHQL_RESPONSE" | jq -e '.data.__schema' > /dev/null 2>&1 && \
curl -fsS "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" > /dev/null 2>&1; then
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Current branch server is ready!"
break
fi
@@ -185,9 +177,10 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
echo "Timeout waiting for current branch server to start"
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
@@ -331,17 +324,9 @@ jobs:
interval=5
elapsed=0
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
while [ $elapsed -lt $timeout ]; do
GRAPHQL_RESPONSE=$(curl -s -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d '{"query":"{ __schema { queryType { name } } }"}' 2>/dev/null || echo '{}')
if echo "$GRAPHQL_RESPONSE" | jq -e '.data.__schema' > /dev/null 2>&1 && \
curl -fsS "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" > /dev/null 2>&1; then
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Main branch server is ready!"
break
fi
@@ -352,9 +337,10 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
echo "Timeout waiting for main branch server to start"
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
@@ -421,23 +407,11 @@ jobs:
valid=true
for file in main-schema-introspection.json current-schema-introspection.json \
main-metadata-schema-introspection.json current-metadata-schema-introspection.json; do
if [ ! -f "$file" ]; then
echo "::warning::Missing GraphQL schema file: $file"
valid=false
elif ! jq -e '.data.__schema' "$file" >/dev/null 2>&1; then
echo "::warning::File $file is not a valid GraphQL introspection result. First 200 bytes: $(head -c 200 "$file")"
valid=false
fi
done
for file in main-rest-api.json current-rest-api.json \
main-metadata-schema-introspection.json current-metadata-schema-introspection.json \
main-rest-api.json current-rest-api.json \
main-rest-metadata-api.json current-rest-metadata-api.json; do
if [ ! -f "$file" ]; then
echo "::warning::Missing OpenAPI spec file: $file"
valid=false
elif ! jq -e '.openapi // .swagger' "$file" >/dev/null 2>&1; then
echo "::warning::File $file is not a valid OpenAPI spec. First 200 bytes: $(head -c 200 "$file")"
if [ ! -f "$file" ] || ! jq empty "$file" 2>/dev/null; then
echo "::warning::Invalid or missing schema file: $file"
valid=false
fi
done
@@ -634,7 +608,7 @@ jobs:
- name: Upload breaking changes report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: breaking-changes-report
path: |
@@ -0,0 +1,201 @@
name: CI Hello world App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx run $pkg:set-local-version --releaseVersion=$CI_VERSION
done
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example hello-world --display-name "Test hello-world app" --description "E2E test hello-world app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute hello-world logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-hello-world-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -55,7 +55,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --skip-local-instance --yes
- name: Install scaffolded app dependencies
run: |
@@ -0,0 +1,199 @@
name: CI Postcard App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example postcard --display-name "Test postcard app" --description "E2E test postcard app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute postcard logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-postcard-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+5 -1
View File
@@ -30,8 +30,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+6 -1
View File
@@ -28,8 +28,13 @@ jobs:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -54,7 +54,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
+1 -28
View File
@@ -23,11 +23,9 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
example-app-postcard:
needs: changed-files-check
@@ -56,7 +54,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
@@ -85,31 +83,6 @@ jobs:
working-directory: packages/twenty-apps/examples/postcard
run: npx vitest run
- name: Configure remote for SDK CLI
run: |
mkdir -p ~/.twenty
cat > ~/.twenty/config.json <<EOF
{
"version": 1,
"remotes": {
"target": {
"apiUrl": "${TWENTY_API_URL}",
"apiKey": "${TWENTY_API_KEY}",
"accessToken": "${TWENTY_API_KEY}"
}
},
"defaultRemote": "target"
}
EOF
- name: Deploy postcard app (registry install path)
working-directory: packages/twenty-apps/examples/postcard
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs deploy --remote target
- name: Install postcard app (registry install path)
working-directory: packages/twenty-apps/examples/postcard
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs install --remote target
ci-example-app-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
@@ -32,8 +32,12 @@ jobs:
matrix:
task: [build, typecheck, lint]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -46,8 +50,12 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -55,7 +63,7 @@ jobs:
- name: Build storybook
run: npx nx storybook:build twenty-front-component-renderer
- name: Upload storybook build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
@@ -68,7 +76,7 @@ jobs:
STORYBOOK_URL: http://localhost:6008
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -76,7 +84,7 @@ jobs:
- name: Build dependencies
run: npx nx build twenty-sdk
- name: Download storybook build
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
+22 -10
View File
@@ -38,8 +38,12 @@ jobs:
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -51,7 +55,7 @@ jobs:
- name: Front / Build storybook
run: npx nx storybook:build twenty-front
- name: Upload storybook build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
@@ -75,7 +79,7 @@ jobs:
STORYBOOK_URL: http://localhost:6006
steps:
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -92,7 +96,7 @@ jobs:
npx nx build twenty-ui
npx nx build twenty-front-component-renderer
- name: Download storybook build
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
@@ -117,7 +121,7 @@ jobs:
# exit 1
# fi
# - name: Upload coverage artifact
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# uses: actions/upload-artifact@v4
# with:
# retention-days: 1
# name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
@@ -132,12 +136,12 @@ jobs:
# matrix:
# storybook_scope: [modules, pages, performance]
# steps:
# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
# - uses: actions/checkout@v4
# with:
# fetch-depth: 10
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
# - uses: actions/download-artifact@v4
# with:
# pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
# merge-multiple: true
@@ -160,8 +164,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -195,8 +203,12 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=10240"
ANALYZE: "true"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -206,7 +218,7 @@ jobs:
- name: Build frontend
run: npx nx build twenty-front
# - name: Upload frontend build artifact
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# uses: actions/upload-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
+5 -5
View File
@@ -41,11 +41,11 @@ jobs:
ports:
- 6379:6379
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@v4
with:
node-version: lts/*
@@ -53,7 +53,7 @@ jobs:
uses: ./.github/actions/yarn-install
- name: Restore Nx build cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (restore)
uses: actions/cache/restore@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
@@ -83,7 +83,7 @@ jobs:
- name: Save Nx build cache
if: always()
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 (save)
uses: actions/cache/save@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
path: |
@@ -119,7 +119,7 @@ jobs:
- name: Upload Playwright results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: |
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
@@ -47,7 +47,7 @@ jobs:
printf '%s\n' "$VERSION" > version.txt
- name: Create Pull Request
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
uses: peter-evans/create-pull-request@v6
with:
branch: release/${{ steps.sanitize.outputs.version }}
commit-message: "chore: release v${{ steps.sanitize.outputs.version }}"
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
fi
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: main
@@ -55,7 +55,7 @@ jobs:
git tag "v${{ env.VERSION }}"
git push origin "v${{ env.VERSION }}"
- uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5
- uses: release-drafter/release-drafter@v5
if: contains(github.event.pull_request.labels.*.name, 'create_release')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -2
View File
@@ -30,8 +30,12 @@ jobs:
matrix:
task: [lint, typecheck, test:unit, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -70,7 +74,7 @@ jobs:
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+7 -7
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -83,12 +83,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Get changed upgrade-version-command files
id: changed-files
uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45.0.9
uses: tj-actions/changed-files@v45
with:
files: |
packages/twenty-server/src/database/commands/upgrade-version-command/**
@@ -178,7 +178,7 @@ jobs:
- 6379:6379
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -278,7 +278,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -341,7 +341,7 @@ jobs:
SHARD_COUNTER: 10
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+5 -1
View File
@@ -29,8 +29,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -26,9 +26,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
@@ -101,9 +101,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
+13 -5
View File
@@ -30,8 +30,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -44,8 +48,12 @@ jobs:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -53,7 +61,7 @@ jobs:
- name: Build storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
@@ -66,7 +74,7 @@ jobs:
STORYBOOK_URL: http://localhost:6007
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -74,7 +82,7 @@ jobs:
- name: Build dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Utils / Run Danger.js
@@ -38,7 +38,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run congratulate-dangerfile.js
+5 -1
View File
@@ -32,8 +32,12 @@ jobs:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+6 -2
View File
@@ -46,7 +46,7 @@ jobs:
- 6379:6379
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
@@ -97,8 +97,12 @@ jobs:
matrix:
task: [lint, typecheck, validate]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
+16 -45
View File
@@ -8,12 +8,10 @@ on:
pull_request_review:
types: [submitted]
issues:
types: [opened]
types: [opened, assigned]
repository_dispatch:
types: [claude-core-team-issues]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.client_payload.issue_number }}
cancel-in-progress: false
@@ -21,30 +19,10 @@ concurrency:
jobs:
claude:
if: |
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
github.event.comment.user.type != 'Bot' &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) ||
(
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
github.event.comment.user.type != 'Bot' &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
) ||
(
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude') &&
github.event.review.user.type != 'Bot' &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)
) ||
(
github.event_name == 'issues' &&
github.event.action == 'opened' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
)
(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:
@@ -72,14 +50,14 @@ jobs:
- 6379:6379
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
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@dde2242db6af13460b916652159b6ba19a598f30 # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
@@ -144,14 +122,14 @@ jobs:
ports:
- 6379:6379
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- 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@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const p = context.payload.client_payload;
@@ -166,7 +144,7 @@ jobs:
core.setOutput('issue_number', p.issue_number);
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@dde2242db6af13460b916652159b6ba19a598f30 # v1
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.prompt.outputs.prompt }}
@@ -181,16 +159,9 @@ jobs:
}
- name: Dispatch response to ci-privileged
if: always()
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
REPO: ${{ steps.prompt.outputs.repo }}
ISSUE_NUMBER: ${{ steps.prompt.outputs.issue_number }}
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=claude-cross-repo-response \
-f "client_payload[repo]=$REPO" \
-f "client_payload[issue_number]=$ISSUE_NUMBER" \
-f "client_payload[run_id]=$RUN_ID" \
-f "client_payload[run_url]=$RUN_URL"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
+6 -5
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
@@ -153,7 +153,8 @@ jobs:
- name: Trigger i18n automerge
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.ref }}
@@ -36,7 +36,7 @@ jobs:
run: yarn docs:generate-navigation-template
- name: Upload docs to Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
+7 -6
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.head_ref || github.ref_name }}
@@ -69,7 +69,7 @@ jobs:
- name: Pull translations from Crowdin
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
@@ -139,7 +139,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+7 -6
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: main
@@ -80,7 +80,7 @@ jobs:
- name: Upload missing translations
if: steps.check_extract_changes.outputs.changes_detected == 'true'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
@@ -105,7 +105,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+7 -14
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Get PR number from workflow run
id: pr-info
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run.id;
@@ -63,16 +63,9 @@ jobs:
- name: Dispatch to ci-privileged
if: steps.pr-info.outputs.has_pr == 'true'
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
RUN_ID: ${{ steps.pr-info.outputs.run_id }}
REPOSITORY: ${{ github.repository }}
BRANCH_STATE: ${{ github.event.workflow_run.head_branch }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=breaking-changes-report \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[run_id]=$RUN_ID" \
-f "client_payload[repo]=$REPOSITORY" \
-f "client_payload[branch_state]=$BRANCH_STATE"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: breaking-changes-report
client-payload: '{"pr_number": ${{ toJSON(steps.pr-info.outputs.pr_number) }}, "run_id": ${{ toJSON(steps.pr-info.outputs.run_id) }}, "repo": ${{ toJSON(github.repository) }}, "branch_state": ${{ toJSON(github.event.workflow_run.head_branch) }}}'
+12 -22
View File
@@ -36,27 +36,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger preview environment workflow
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/"$REPOSITORY"/dispatches \
-f event_type=preview-environment \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[repo_full_name]=$REPOSITORY"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
event-type: preview-environment
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
- name: Dispatch to ci-privileged for PR comment
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=preview-env-url \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
-f "client_payload[repo]=$REPOSITORY"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: preview-env-url
client-payload: '{"pr_number": ${{ toJSON(github.event.pull_request.number) }}, "keepalive_dispatch_time": ${{ toJSON(github.event.pull_request.updated_at) }}, "repo": ${{ toJSON(github.repository) }}}'
+10 -57
View File
@@ -13,12 +13,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
@@ -56,54 +56,10 @@ jobs:
- name: Create Tunnel
id: expose-tunnel
env:
CLOUDFLARED_VERSION: '2026.3.0'
run: |
set -euo pipefail
# Install cloudflared (pinned for reproducibility)
sudo curl -fsSL -o /usr/local/bin/cloudflared \
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
sudo chmod +x /usr/local/bin/cloudflared
cloudflared --version
# Start an account-less "quick tunnel" pointing at the server container.
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
log_file="$RUNNER_TEMP/cloudflared.log"
: > "$log_file"
cloudflared tunnel \
--url http://localhost:3000 \
--no-autoupdate \
--logfile "$log_file" \
--loglevel info \
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
pid=$!
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
echo "cloudflared PID: $pid"
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
url=''
for _ in $(seq 1 60); do
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
[ -n "$url" ] && break
if ! kill -0 "$pid" 2>/dev/null; then
echo "cloudflared exited before producing a URL"
cat "$log_file" || true
exit 1
fi
sleep 2
done
if [ -z "$url" ]; then
echo "Timed out waiting for tunnel URL"
cat "$log_file" || true
exit 1
fi
echo "Tunnel URL: $url"
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
uses: codetalkio/expose-tunnel@v1.5.0
with:
service: bore.pub
port: 3000
- name: Start services with correct SERVER_URL
env:
@@ -143,9 +99,9 @@ jobs:
- name: Seed Dev Workspace
run: |
cd packages/twenty-docker/
echo "Seeding light dev workspace (Apple only)..."
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
echo "❌ Seeding light dev workspace failed. Dumping server logs..."
echo "Seeding full dev workspace..."
if ! docker compose exec -T server yarn command:prod -- workspace:seed:dev; then
echo "❌ Seeding full dev workspace failed. Dumping server logs..."
docker compose logs server
exit 1
fi
@@ -166,7 +122,7 @@ jobs:
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: tunnel-url
path: tunnel-url.txt
@@ -178,9 +134,6 @@ jobs:
- name: Cleanup
if: always()
run: |
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
fi
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Determine project and artifact name
id: project
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const workflowName = context.payload.workflow_run.name;
@@ -43,7 +43,7 @@ jobs:
- name: Check if storybook artifact exists
id: check-artifact
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const artifactName = '${{ steps.project.outputs.artifact_name }}';
@@ -65,7 +65,7 @@ jobs:
- name: Get PR number
if: steps.check-artifact.outputs.exists == 'true'
id: pr-info
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
uses: actions/github-script@v7
with:
script: |
const headBranch = context.payload.workflow_run.head_branch;
@@ -107,7 +107,7 @@ jobs:
- name: Download storybook artifact from triggering run
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
uses: actions/download-artifact@v4
with:
name: ${{ steps.project.outputs.artifact_name }}
path: storybook-static
@@ -120,7 +120,7 @@ jobs:
- name: Upload storybook tarball
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4
with:
name: ${{ steps.project.outputs.tarball_name }}
path: /tmp/${{ steps.project.outputs.tarball_file }}
@@ -128,20 +128,17 @@ jobs:
- name: Dispatch to ci-privileged
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
RUN_ID: ${{ github.run_id }}
REPOSITORY: ${{ github.repository }}
PROJECT: ${{ steps.project.outputs.project }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
COMMIT: ${{ github.event.workflow_run.head_sha }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=visual-regression \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[run_id]=$RUN_ID" \
-f "client_payload[repo]=$REPOSITORY" \
-f "client_payload[project]=$PROJECT" \
-f "client_payload[branch]=$BRANCH" \
-f "client_payload[commit]=$COMMIT"
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ steps.pr-info.outputs.pr_number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "${{ steps.project.outputs.project }}",
"branch": "${{ github.event.workflow_run.head_branch }}",
"commit": "${{ github.event.workflow_run.head_sha }}"
}
+7 -6
View File
@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.head_ref || github.ref_name }}
@@ -66,7 +66,7 @@ jobs:
- name: Pull website translations from Crowdin
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
@@ -128,7 +128,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+7 -6
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: main
@@ -78,7 +78,7 @@ jobs:
- name: Upload missing website translations
if: steps.check_extract_changes.outputs.changes_detected == 'true'
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
@@ -104,7 +104,8 @@ jobs:
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
env:
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
run: |
gh api repos/twentyhq/twenty-infra/dispatches -f event_type=i18n-pr-ready
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-40
View File
@@ -43,46 +43,6 @@
{
"name": "packages/twenty-e2e-testing",
"path": "../packages/twenty-e2e-testing"
},
{
"name": "packages/twenty-docs",
"path": "../packages/twenty-docs"
},
{
"name": "packages/create-twenty-app",
"path": "../packages/create-twenty-app"
},
{
"name": "packages/twenty-apps",
"path": "../packages/twenty-apps"
},
{
"name": "packages/twenty-claude-skills",
"path": "../packages/twenty-claude-skills"
},
{
"name": "packages/twenty-cli",
"path": "../packages/twenty-cli"
},
{
"name": "packages/twenty-client-sdk",
"path": "../packages/twenty-client-sdk"
},
{
"name": "packages/twenty-companion",
"path": "../packages/twenty-companion"
},
{
"name": "packages/twenty-front-component-renderer",
"path": "../packages/twenty-front-component-renderer"
},
{
"name": "packages/twenty-sdk",
"path": "../packages/twenty-sdk"
},
{
"name": "packages/twenty-website-new",
"path": "../packages/twenty-website-new"
}
],
"settings": {
-9
View File
@@ -4,15 +4,6 @@ enableHardenedMode: true
enableInlineHunks: true
enableScripts: false
nodeLinker: node-modules
npmMinimalAgeGate: 3d
npmPreapprovedPackages:
- twenty-sdk
- twenty-client-sdk
- create-twenty-app
yarnPath: .yarn/releases/yarn-4.13.0.cjs
-1
View File
@@ -57,7 +57,6 @@
"lint:diff-with-main": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
+1 -2
View File
@@ -63,8 +63,7 @@
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-oxlint-rules",
"packages/twenty-companion",
"packages/twenty-claude-skills"
"packages/twenty-companion"
]
},
"prettier": {
+18 -10
View File
@@ -25,18 +25,26 @@ yarn twenty dev
The scaffolder will:
1. Create a new project with TypeScript, linting, tests, and a preconfigured `twenty` CLI
2. Start a local Twenty server via Docker (pulls the latest image automatically)
3. Authenticate with the development API key
2. Optionally start a local Twenty server (Docker)
3. Open the browser for OAuth authentication
## Options
| Flag | Description |
| ---------------------------------- | --------------------------------------------------------------------- |
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
| Flag | Description |
| ------------------------------ | --------------------------------------- |
| `--example <name>` | Initialize from an example |
| `--name <name>` | Set the app name (skips the prompt) |
| `--display-name <displayName>` | Set the display name (skips the prompt) |
| `--description <description>` | Set the description (skips the prompt) |
| `--skip-local-instance` | Skip the local server setup prompt |
By default (no flags), a minimal app is generated with core files and an integration test. Use `--example` to start from a richer example:
```bash
npx create-twenty-app@latest my-twenty-app --example hello-world
```
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
## Documentation
@@ -49,7 +57,7 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
- Auth not working: make sure you are logged in to Twenty in the browser, then run `yarn twenty remote add`.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.5.1",
"version": "2.3.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -32,6 +32,7 @@
"chalk": "^5.3.0",
"commander": "^12.0.0",
"fs-extra": "^11.2.0",
"inquirer": "^10.0.0",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
@@ -42,6 +43,7 @@
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/jest": "^30.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.kebabcase": "^4.1.7",
+18 -37
View File
@@ -1,10 +1,7 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import {
type AuthenticationMethod,
CreateAppCommand,
} from '@/create-app.command';
import { CreateAppCommand } from '@/create-app.command';
import packageJson from '../package.json';
const program = new Command(packageJson.name)
@@ -15,29 +12,32 @@ const program = new Command(packageJson.name)
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('--example <name>', 'Initialize from an example')
.option('-n, --name <name>', 'Application name (skips prompt)')
.option(
'--workspace-url <workspaceUrl>',
'Twenty workspace URL (default: http://localhost:2020)',
'-d, --display-name <displayName>',
'Application display name (skips prompt)',
)
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
'--description <description>',
'Application description (skips prompt)',
)
.option(
'--skip-local-instance',
'Skip the local Twenty instance setup prompt',
)
.option('-y, --yes', 'Auto-confirm prompts (e.g. start existing container)')
.helpOption('-h, --help', 'Display this help message.')
.action(
async (
directory?: string,
options?: {
example?: string;
name?: string;
displayName?: string;
description?: string;
workspaceUrl?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
skipLocalInstance?: boolean;
yes?: boolean;
},
) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
@@ -54,33 +54,14 @@ const program = new Command(packageJson.name)
process.exit(1);
}
if (
options?.authenticationMethod &&
!['oauth', 'apiKey'].includes(options.authenticationMethod)
) {
console.error(
chalk.red(
'Error: --authentication-method must be "oauth" or "apiKey".',
),
);
process.exit(1);
}
if (options?.apiUrl) {
console.warn(
chalk.yellow(
'Warning: --api-url is deprecated. Use --workspace-url instead.',
),
);
}
await new CreateAppCommand().execute({
directory,
example: options?.example,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
skipLocalInstance: options?.skipLocalInstance,
yes: options?.yes,
});
},
);
@@ -2,19 +2,8 @@ This is a [Twenty](https://twenty.com) application bootstrapped with [`create-tw
## Getting Started
This app was scaffolded with a local Twenty server running at [http://localhost:2020](http://localhost:2020).
Login with the default development credentials: `tim@apple.dev` / `tim@apple.dev`.
Run `yarn twenty help` to list all available commands.
## Useful Commands
- `yarn twenty dev` - Start the development server and sync your app
- `yarn twenty server status` - Check the local Twenty server status
- `yarn twenty server start` - Start the local Twenty server
- `yarn test` - Run integration tests
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
@@ -11,7 +11,7 @@ permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="16" fill="#141414"/>
<rect x="20" y="20" width="16" height="16" rx="4" fill="#FAFAFA"/>
<rect x="44" y="20" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="20" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="44" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 454 B

@@ -2,10 +2,3 @@ export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
@@ -1,279 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { useState } from 'react';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
const DOCS_BASE_URL = 'https://docs.twenty.com/developers/extend/apps';
const CATEGORIES = [
{
title: 'Data model',
color: '#73D08D',
items: [
{ label: 'CUSTOM OBJECT', href: `${DOCS_BASE_URL}/data/objects` },
{
label: 'CUSTOM FIELDS',
href: `${DOCS_BASE_URL}/data/extending-objects`,
},
],
rotation: '2.4deg',
},
{
title: 'Logic',
color: '#F4D345',
items: [
{
label: 'TOOLS',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SERVERLESS FUNCT.',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SKILLS',
href: `${DOCS_BASE_URL}/logic/skills-and-agents`,
},
],
rotation: '0deg',
},
{
title: 'Layout',
color: '#C4A2E0',
items: [
{ label: 'VIEWS', href: `${DOCS_BASE_URL}/layout/views` },
{ label: 'WIDGETS', href: `${DOCS_BASE_URL}/layout/page-layouts` },
{
label: 'LAYOUT PAGES',
href: `${DOCS_BASE_URL}/layout/page-layouts`,
},
{
label: 'COMMANDS',
href: `${DOCS_BASE_URL}/layout/command-menu-items`,
},
],
rotation: '-2.8deg',
},
] as const;
const ItemIcon = ({ color }: { color: string }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
</svg>
);
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M4.5 3.5H10.5V9.5M10.5 3.5L3.5 10.5"
stroke={color}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CategoryCard = ({
title,
color,
items,
rotation,
}: {
title: string;
color: string;
items: ReadonlyArray<{ label: string; href: string }>;
rotation: string;
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
border: `1px solid ${color}80`,
borderRadius: '12px',
overflow: 'hidden',
width: '240px',
background: '#FFFFFF',
transform: `rotate(${rotation})`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '16px 20px',
background: `${color}22`,
}}
>
<span
style={{
fontSize: '16px',
fontWeight: 600,
color: color,
}}
>
{title}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '8px',
gap: '4px',
}}
>
{items.map((item) => {
const isHovered = hoveredItem === item.label;
return (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHoveredItem(item.label)}
onMouseLeave={() => setHoveredItem(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
cursor: 'pointer',
padding: '10px 12px',
borderRadius: '8px',
background: isHovered ? '#0000000A' : 'transparent',
transition: 'background 0.15s',
}}
>
<ItemIcon color={color} />
<span
style={{
fontSize: '13px',
fontWeight: 300,
color: '#333',
letterSpacing: '0.5px',
flex: 1,
}}
>
{item.label}
</span>
{isHovered && <ArrowUpRight />}
</a>
);
})}
</div>
</div>
);
};
const MainPage = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
gap: '8px',
padding: '40px',
}}
>
{/*<Avatar
avatarUrl={getPublicAssetUrl('logo.svg')}
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
type="squared"
size="xl"
/>*/}
<span
style={{
fontSize: '24px',
fontWeight: 600,
color: '#333',
marginTop: '8px',
}}
>
{APP_DISPLAY_NAME}
</span>
<span
style={{
fontSize: '13px',
color: '#888',
textAlign: 'center',
lineHeight: '1.5',
}}
>
Was installed successfully.
<br />
You can now add content to your app.
</span>
<a
href="/settings/applications"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '16px',
fontSize: '13px',
color: '#333',
textDecoration: 'none',
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
background: '#fafafa',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
Open app settings
<ArrowUpRight color="#333" />
</a>
<div
style={{
display: 'flex',
gap: '16px',
marginTop: '32px',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'flex-start',
}}
>
{CATEGORIES.map((category) => (
<CategoryCard
key={category.title}
title={category.title}
color={category.color}
items={category.items}
rotation={category.rotation}
/>
))}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
description: `${APP_DISPLAY_NAME} front component displaying the app logo and name`,
component: MainPage,
});
@@ -1,19 +0,0 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
icon: 'IconFile',
position: -1,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});
@@ -1,37 +0,0 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Overview',
position: 0,
icon: 'IconApps',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
title: ' ',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1,109 +1,96 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { downloadExample } from '@/utils/download-example';
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 { basename } from 'path';
import { spawn } from 'node:child_process';
import {
authLogin,
authLoginOAuth,
checkDockerRunning,
ConfigService,
DEV_API_KEY,
DEV_API_URL,
containerExists,
detectLocalServer,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
} from '@/utils/docker-install';
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
const IMAGE = 'twentycrm/twenty-app-dev:latest';
export type AuthenticationMethod = 'oauth' | 'apiKey';
type CreateAppOptions = {
directory?: string;
example?: string;
name?: string;
displayName?: string;
description?: string;
workspaceUrl?: string;
authenticationMethod?: AuthenticationMethod;
skipLocalInstance?: boolean;
yes?: boolean;
};
export class CreateAppCommand {
private stepCounter = 0;
private totalSteps = 0;
private static TOTAL_STEPS = 4;
async execute(options: CreateAppOptions = {}): Promise<void> {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
process.exit(1);
}
if (skipLocalInstance && options.authenticationMethod === 'apiKey') {
console.log(
chalk.yellow(
'API key authentication is only supported on a local Docker instance. Ignoring and switching to OAuth authentication.',
),
);
}
const authenticationMethod = skipLocalInstance
? 'oauth'
: (options.authenticationMethod ?? 'apiKey');
await this.getAppInfos(options);
try {
await this.validateDirectory(appDirectory);
this.totalSteps = this.computeTotalSteps({
skipLocalInstance,
});
this.stepCounter = 0;
const dockerPullPromise =
!skipLocalInstance && checkDockerRunning()
? this.pullImageInBackground()
: Promise.resolve(false);
this.logPlan({ appName, appDisplayName, appDescription, appDirectory });
this.logNextStep('Creating project directory');
await fs.ensureDir(appDirectory);
this.logDetail(appDirectory);
this.logNextStep('Scaffolding project files');
await copyBaseApplicationProject({
const confirmed = await this.promptScaffoldConfirmation({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
autoConfirm: options.yes,
});
this.logNextStep('Installing dependencies');
if (!confirmed) {
console.log(chalk.gray('\nScaffolding cancelled.'));
process.exit(0);
}
console.log('');
this.logStep(1, 'Creating project directory');
await fs.ensureDir(appDirectory);
this.logDetail(appDirectory);
this.logStep(2, 'Scaffolding project files');
if (options.example) {
const exampleSucceeded = await this.tryDownloadExample(
options.example,
appDirectory,
);
if (!exampleSucceeded) {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
} else {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
this.logStep(3, 'Installing dependencies');
await install(appDirectory, (message) => this.logDetail(message));
this.logNextStep('Initializing Git repository');
this.logStep(4, 'Initializing Git repository');
const gitInitialized = await tryGitInit(appDirectory);
if (gitInitialized) {
@@ -117,58 +104,38 @@ export class CreateAppCommand {
console.log('');
let hasLocalServer = false;
let authSucceeded = false;
let resolvedWorkspaceUrl = workspaceUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
this.logNextStep('Starting Twenty server');
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (!options.skipLocalInstance) {
const existingServerUrl = await detectLocalServer();
if (isDefined(serverResult.url)) {
resolvedWorkspaceUrl = serverResult.url;
serverReady = true;
}
}
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded =
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
if (existingServerUrl) {
hasLocalServer = true;
authSucceeded = await this.promptConnectToLocal(existingServerUrl);
} else {
this.logDetail('Using development API key');
authSucceeded =
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
const shouldStart = await this.shouldStartServer(options.yes);
if (shouldStart) {
const startResult = await serverStart({
onProgress: (message: string) => console.log(chalk.gray(message)),
});
if (startResult.success) {
hasLocalServer = true;
authSucceeded = await this.promptConnectToLocal(
startResult.data.url,
);
} else {
console.log(chalk.yellow(`\n${startResult.error.message}`));
}
} else {
this.logServerSkipped();
}
}
}
this.logNextStep('Installing application');
let syncSucceeded = false;
if (serverReady && authSucceeded) {
syncSucceeded = await this.syncApplication(appDirectory);
if (!syncSucceeded) {
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
return;
}
} else {
this.logDetail('Skipped (server or authentication not available)');
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
}
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
this.logSuccess(appDirectory, hasLocalServer, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -178,42 +145,66 @@ export class CreateAppCommand {
}
}
private computeTotalSteps({
skipLocalInstance,
}: {
skipLocalInstance: boolean;
}): number {
let steps = 4; // directory, scaffold, install, git
if (!skipLocalInstance) {
steps += 1; // start server
}
steps += 1; // authenticate (oauth or apiKey)
steps += 1; // sync application
return steps;
}
private getAppInfos(options: CreateAppOptions): {
private async getAppInfos(options: CreateAppOptions): Promise<{
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
} {
}> {
const { directory } = options;
const hasName = isDefined(options.name) || isDefined(directory);
const hasDisplayName = isDefined(options.displayName);
const hasDescription = isDefined(options.description);
const hasExample = isDefined(options.example);
const { name, displayName, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name:',
when: () => !hasName && !hasExample,
default: 'my-twenty-app',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
},
},
{
type: 'input',
name: 'displayName',
message: 'Application display name:',
when: () => !hasDisplayName && !hasExample,
default: (answers: { name?: string }) => {
return convertToLabel(
answers?.name ?? options.name ?? directory ?? '',
);
},
},
{
type: 'input',
name: 'description',
message: 'Application description (optional):',
when: () => !hasDescription && !hasExample,
default: '',
},
]);
const appName = (
options.name ??
options.directory ??
name ??
directory ??
options.example ??
'my-twenty-app'
).trim();
const appDisplayName =
options.displayName?.trim() || convertToLabel(appName);
(options.displayName ?? displayName)?.trim() || convertToLabel(appName);
const appDescription = (options.description ?? '').trim();
const appDescription = (options.description ?? description ?? '').trim();
const appDirectory = options.directory
? path.join(CURRENT_EXECUTION_DIRECTORY, options.directory)
const appDirectory = directory
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
return { appName, appDisplayName, appDirectory, appDescription };
@@ -232,17 +223,54 @@ export class CreateAppCommand {
}
}
private logPlan({
private async tryDownloadExample(
example: string,
appDirectory: string,
): Promise<boolean> {
try {
await downloadExample(example, appDirectory);
return true;
} catch (error) {
console.error(
chalk.red(
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
),
);
const { useTemplate } = await inquirer.prompt([
{
type: 'confirm',
name: 'useTemplate',
message: 'Would you like to create a default template app instead?',
default: true,
},
]);
if (!useTemplate) {
process.exit(1);
}
// Clean up any partial files from the failed download
await fs.emptyDir(appDirectory);
return false;
}
}
private async promptScaffoldConfirmation({
appName,
appDisplayName,
appDescription,
appDirectory,
autoConfirm,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}): void {
autoConfirm?: boolean;
}): Promise<boolean> {
console.log(chalk.blue('\nCreating Twenty Application\n'));
console.log(chalk.white(` Name: ${appName}`));
console.log(chalk.white(` Display name: ${appDisplayName}`));
@@ -252,13 +280,43 @@ export class CreateAppCommand {
}
console.log(chalk.white(` Directory: ${appDirectory}`));
console.log(chalk.white('\nThe following steps will be performed:\n'));
console.log(chalk.gray(' 1. Create project directory'));
console.log(
chalk.gray(
' 2. Scaffold project files from base template\n' +
' - Copy template files\n' +
' - Configure dotfiles (.gitignore, .github)\n' +
' - Generate unique application identifiers\n' +
' - Update package.json with app name and SDK versions',
),
);
console.log(chalk.gray(' 3. Install dependencies (yarn)'));
console.log(
chalk.gray(' 4. Initialize Git repository with initial commit'),
);
console.log('');
if (autoConfirm) {
return true;
}
const { proceed } = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message: 'Proceed?',
default: true,
},
]);
return proceed;
}
private logNextStep(title: string): void {
this.stepCounter++;
private logStep(step: number, title: string): void {
console.log(
chalk.blue(`\n[${this.stepCounter}/${this.totalSteps}]`) +
chalk.blue(`\n[${step}/${CreateAppCommand.TOTAL_STEPS}]`) +
chalk.white(` ${title}...`),
);
}
@@ -267,299 +325,100 @@ export class CreateAppCommand {
console.log(chalk.gray(`${message}`));
}
private pullImageInBackground(): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('docker', ['pull', IMAGE], { stdio: 'ignore' });
private async shouldStartServer(autoConfirm?: boolean): Promise<boolean> {
console.log(
chalk.white(
'\n A local Twenty instance is required for app development.\n' +
' It provides the API and schema your application connects to.\n',
),
);
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
if (checkDockerRunning() && containerExists()) {
if (autoConfirm) {
return true;
}
const { startExisting } = await inquirer.prompt([
{
type: 'confirm',
name: 'startExisting',
message:
'An existing Twenty server container was found. Would you like to start it?',
default: true,
},
]);
return startExisting;
}
if (autoConfirm) {
return true;
}
const { startDocker } = await inquirer.prompt([
{
type: 'confirm',
name: 'startDocker',
message:
'No running Twenty instance found. Would you like to start one using Docker?',
default: true,
},
]);
return startDocker;
}
private async ensureDockerServer(
dockerPullPromise: Promise<boolean>,
): Promise<{ url?: string }> {
if (!checkDockerRunning()) {
private logServerSkipped(): void {
console.log(
chalk.gray(
'\n To start a Twenty instance later:\n' +
' yarn twenty server start\n\n' +
' To connect to a remote instance instead:\n' +
' yarn twenty remote add\n',
),
);
}
private async promptConnectToLocal(serverUrl: string): Promise<boolean> {
console.log(
chalk.white(
'\n Authentication links your app to a Twenty instance so you can\n' +
' sync custom objects, fields, and roles during development.\n' +
' This will open a browser window to complete the OAuth flow.\n',
),
);
const { shouldAuthenticate } = await inquirer.prompt([
{
type: 'confirm',
name: 'shouldAuthenticate',
message: `Authenticate to the local Twenty instance (${serverUrl})?`,
default: true,
},
]);
if (!shouldAuthenticate) {
console.log(
chalk.yellow(
'\n Docker is installed but not running.\n' +
' Please start Docker and run this command again.\n',
chalk.gray(
'\n Authentication skipped. To authenticate later:\n' +
` yarn twenty remote add --local\n`,
),
);
return {};
}
this.logDetail('Ensuring latest Twenty server image...');
const pullSucceeded = await dockerPullPromise;
if (!pullSucceeded) {
this.logDetail(
'Image pull failed, continuing with cached image if available...',
);
}
const startResult = await serverStart({
onProgress: (message: string) => this.logDetail(message),
});
if (startResult.success) {
return { url: startResult.data.url };
}
console.log(chalk.yellow(`\n ${startResult.error.message}`));
return {};
}
private async openMainPage(
appDirectory: string,
workspaceUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
const config = await configService.getConfig();
const token = config.twentyCLIAccessToken ?? config.apiKey;
if (!token) {
return;
}
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
return;
}
const pageLayoutId = await this.resolvePageLayoutId(
workspaceUrl,
universalIdentifier,
token,
);
if (!pageLayoutId) {
return;
}
const url = `${frontUrl}/page/${pageLayoutId}`;
this.logDetail(`Opening app welcome page: ${url}`);
this.openInBrowser(url);
} catch {
// Best-effort — don't fail the scaffold if browser open fails
}
}
private async resolveWorkspaceFrontUrl(
workspaceUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
currentWorkspace?: {
workspaceUrls?: { subdomainUrl?: string; customUrl?: string };
};
};
};
const urls = body.data?.currentWorkspace?.workspaceUrls;
if (!urls) {
return null;
}
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl?.replace(/\/+$/, '') ?? null;
}
private async readMainPageLayoutUniversalIdentifier(
appDirectory: string,
): Promise<string | null> {
const filePath = path.join(
appDirectory,
'src',
'constants',
'universal-identifiers.ts',
);
const content = await fs.readFile(filePath, 'utf-8');
const match = content.match(
/MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER\s*=\s*'([^']+)'/,
);
return match?.[1] ?? null;
}
private async resolvePageLayoutId(
workspaceUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
getPageLayouts?: { id: string; universalIdentifier: string }[];
};
};
const matching = body.data?.getPageLayouts?.find(
(layout) => layout.universalIdentifier === universalIdentifier,
);
return matching?.id ?? null;
}
private sanitizeBrowserUrl(url: string): string | null {
if (/[^\u0020-\u007E]/.test(url)) {
return null;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
private openInBrowser(url: string): void {
const safeUrl = this.sanitizeBrowserUrl(url);
if (!safeUrl) {
return;
}
const isWindows = process.platform === 'win32';
const command = isWindows
? 'rundll32'
: process.platform === 'darwin'
? 'open'
: 'xdg-open';
const args = isWindows
? ['url.dll,FileProtocolHandler', safeUrl]
: [safeUrl];
const child = spawn(command, args, {
stdio: 'ignore',
detached: !isWindows,
});
child.on('error', () => undefined);
if (!isWindows) {
child.unref();
}
}
private async syncApplication(appDirectory: string): Promise<boolean> {
this.logDetail('Running `yarn twenty dev --once`...');
return new Promise((resolve) => {
const child = spawn('yarn', ['twenty', 'dev', '--once'], {
cwd: appDirectory,
stdio: ['inherit', 'pipe', 'pipe'],
});
child.stdout?.resume();
child.stderr?.resume();
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== workspaceUrl) {
continue;
}
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
if (!token) {
continue;
}
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: '{ currentWorkspace { id } }',
}),
});
if (!response.ok) {
continue;
}
const body = (await response.json()) as {
data?: { currentWorkspace?: { id: string } };
errors?: unknown[];
};
if (isDefined(body.data?.currentWorkspace) && !body.errors) {
ConfigService.setActiveRemote(remoteName);
await configService.setDefaultRemote(remoteName);
return true;
}
}
return false;
} catch {
return false;
}
}
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
await inquirer.prompt([
{
type: 'input',
name: 'confirm',
message: 'Press Enter to open the browser for authentication...',
},
]);
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: workspaceUrl,
const result = await authLoginOAuth({
apiUrl: serverUrl,
remote: 'local',
});
@@ -567,68 +426,21 @@ export class CreateAppCommand {
const configService = new ConfigService();
await configService.setDefaultRemote('local');
this.logDetail('Authenticated as tim@apple.dev (development API key)');
return true;
} else {
console.log(
chalk.yellow(
'Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
return false;
}
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
return false;
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
return false;
}
}
private deriveRemoteName(url: string): string {
try {
return new URL(url).hostname.replace(/\./g, '-');
} catch {
return 'remote';
}
}
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(workspaceUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
return true;
}
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
return false;
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
'Authentication failed. Run `yarn twenty remote add` manually.',
),
);
@@ -638,13 +450,12 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
workspaceUrl: string,
hasLocalServer: boolean,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
console.log(chalk.green('\n✔ Application created successfully!\n'));
console.log(chalk.white(' Next steps:\n'));
let stepNumber = 1;
@@ -654,21 +465,17 @@ export class CreateAppCommand {
stepNumber++;
if (!authSucceeded) {
const remoteCommand = hasLocalServer
? 'yarn twenty remote add --local'
: 'yarn twenty remote add';
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(
' yarn twenty remote add --api-url <your-instance-url>\n',
),
);
console.log(chalk.cyan(` ${remoteCommand}\n`));
stepNumber++;
}
console.log(chalk.white(` ${stepNumber}. Start developing`));
console.log(chalk.cyan(' yarn twenty dev\n'));
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${workspaceUrl}\n`));
console.log(
chalk.gray(
@@ -1,33 +0,0 @@
import { execSync } from 'node:child_process';
const DOCKER_DOWNLOAD_URLS: Record<string, string> = {
darwin: 'https://docs.docker.com/desktop/setup/install/mac-install/',
win32: 'https://docs.docker.com/desktop/setup/install/windows-install/',
linux: 'https://docs.docker.com/engine/install/',
};
export const isDockerInstalled = (): boolean => {
try {
execSync('docker --version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
export const getDockerInstallInstructions = (): string => {
const url =
DOCKER_DOWNLOAD_URLS[process.platform] ?? DOCKER_DOWNLOAD_URLS.linux;
return [
' Docker is required but not installed.',
'',
` Install Docker: ${url}`,
'',
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
].join('\n');
};
@@ -0,0 +1,179 @@
import { execSync } from 'node:child_process';
import * as fs from 'fs-extra';
import { join } from 'path';
import { tmpdir } from 'node:os';
import chalk from 'chalk';
const TWENTY_REPO_OWNER = 'twentyhq';
const TWENTY_REPO_NAME = 'twenty';
const TWENTY_FALLBACK_REF = 'main';
const TWENTY_EXAMPLES_PATH = 'packages/twenty-apps/examples';
const TWENTY_EXAMPLES_URL = `https://github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tree/${TWENTY_FALLBACK_REF}/${TWENTY_EXAMPLES_PATH}`;
// Fetches the latest release tag from the repo, or falls back to main
const resolveRef = async (): Promise<string> => {
const response = await fetch(
`https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/releases/latest`,
{ headers: { Accept: 'application/vnd.github.v3+json' } },
);
if (response.ok) {
const release = (await response.json()) as { tag_name: string };
return release.tag_name;
}
return TWENTY_FALLBACK_REF;
};
// Uses the GitHub Contents API to list directories — fast and doesn't download the repo
const fetchGitHubDirectoryContents = async (
path: string,
ref: string,
): Promise<{ name: string; type: string }[] | null> => {
const apiUrl = `https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (!response.ok) {
return null;
}
const data = await response.json();
if (!Array.isArray(data)) {
return null;
}
return data as { name: string; type: string }[];
};
const listAvailableExamples = async (ref: string): Promise<string[]> => {
const contents = await fetchGitHubDirectoryContents(
TWENTY_EXAMPLES_PATH,
ref,
);
if (!contents) {
return [];
}
return contents
.filter((entry) => entry.type === 'dir')
.map((entry) => entry.name);
};
const validateExampleExists = async (
exampleName: string,
ref: string,
): Promise<void> => {
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
const contents = await fetchGitHubDirectoryContents(examplePath, ref);
if (contents !== null) {
return;
}
const availableExamples = await listAvailableExamples(ref);
throw new Error(
`Example "${exampleName}" not found.\n\n` +
(availableExamples.length > 0
? `Available examples:\n${availableExamples.map((name) => ` - ${name}`).join('\n')}\n\n`
: '') +
`Browse all examples: ${TWENTY_EXAMPLES_URL}`,
);
};
export const downloadExample = async (
exampleName: string,
targetDirectory: string,
): Promise<void> => {
if (
exampleName.includes('/') ||
exampleName.includes('\\') ||
exampleName.includes('..')
) {
throw new Error(
`Invalid example name: "${exampleName}". Example names must be simple directory names (e.g., "hello-world").`,
);
}
const ref = await resolveRef();
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
console.log(chalk.gray(`Resolving examples from ref '${ref}'...`));
await validateExampleExists(exampleName, ref);
console.log(chalk.gray(`Example '${examplePath}' validated successfully.`));
const tarballUrl = `https://codeload.github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tar.gz/${ref}`;
const tempDir = join(
tmpdir(),
`create-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await fs.ensureDir(tempDir);
console.log(chalk.gray(`Downloading tarball from ${tarballUrl}...`));
const response = await fetch(tarballUrl);
if (!response.ok) {
if (response.status === 404) {
throw new Error(
`Could not find repository: ${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME} (ref: ${ref})`,
);
}
throw new Error(
`Failed to download from GitHub: ${response.status} ${response.statusText}`,
);
}
console.log(chalk.gray('Tarball downloaded. Writing to disk...'));
const tarballPath = join(tempDir, 'archive.tar.gz');
const buffer = Buffer.from(await response.arrayBuffer());
await fs.writeFile(tarballPath, buffer);
console.log(
chalk.gray(
`Tarball saved (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Extracting...`,
),
);
execSync(`tar xzf "${tarballPath}" -C "${tempDir}"`, {
stdio: 'pipe',
});
// GitHub tarballs extract to a directory named {repo}-{ref}/
const extractedEntries = await fs.readdir(tempDir);
const extractedDir = extractedEntries.find(
(entry) => entry !== 'archive.tar.gz',
);
if (!extractedDir) {
throw new Error('Failed to extract archive: no directory found');
}
const sourcePath = join(tempDir, extractedDir, examplePath);
if (!(await fs.pathExists(sourcePath))) {
throw new Error(
`Example directory not found in archive: "${examplePath}"`,
);
}
await fs.copy(sourcePath, targetDirectory);
} finally {
await fs.remove(tempDir);
}
};
@@ -28,6 +28,12 @@ yarn install
yarn twenty dev
```
Or use it as a template via create-twenty-app:
```bash
npx create-twenty-app@latest my-app --example postcard
```
## Learn more
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started)
@@ -1,18 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1,173 +0,0 @@
# Discord for Twenty
Connect Discord to your Twenty workflows. Post messages, edit them, react,
delete, and browse channels — all from the workflow builder or the AI chat.
## What you can do
Once installed and the admin has provided the bot token, five tools become
available in workflows and the AI chat:
- **Send Discord message** — *"post 'New deal closed 🎉' to #general"*. As a
workflow step: provide `channelId` + `messageText`. Optional
`replyToMessageId` turns it into a reply to a previous message.
- **Update Discord message** — edit a message the bot already sent. Useful
for status messages that change as a workflow progresses.
- **Delete Discord message** — remove a bot message (for example after a
mistake or when a workflow is cancelled).
- **Add Discord reaction** — react with a unicode emoji (`👍`, `✅`, `🎉`)
or a custom server emoji (`partyparrot:643452342342342342`). Great for
signalling status at a glance.
- **List Discord channels** — discovers the text-postable channels in a
Discord server, useful as the first step in a workflow that picks a
channel dynamically. **Leave `guildId` blank if your bot is in only one
server — it auto-picks.** If the bot is in multiple servers, the error
response lists each server's name and ID so you can copy the right one.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Discord** in the available apps and click **Install**.
3. Use any of the tools above in a workflow step or via the AI chat.
> **Heads up:** if you see *"Discord is not configured"* on the first run,
> your Twenty admin needs to follow the [Self-hosting setup](#self-hosting-setup-admin-only)
> section below — they need to create a Discord application and provide the
> bot token before the tools work.
## Finding channel and message IDs
Discord IDs are long numeric strings (e.g. `1234567890123456789`). To copy
them from the Discord client:
1. **Enable Developer Mode** once: **User Settings → Advanced → Developer
Mode** (toggle on). Discord then shows extra "Copy ID" options on
right-click.
2. **Channel ID** — right-click any channel → *Copy Channel ID*.
3. **Message ID** — right-click any message → *Copy Message ID*.
4. **Server (guild) ID** — right-click the server icon → *Copy Server ID*.
Only needed for `discord-list-channels` when your bot is in multiple
servers (otherwise leave it blank for auto-pick).
> **Tip:** when chaining steps, pass `messageId` from a previous *Send
> Discord message* step directly into a follow-up *Update / Delete / Add
> Reaction* step. No copy/paste needed.
## Limitations
What this connector intentionally does **not** support in v1:
- **Direct messages to users.** Discord's DM API needs a different identity
flow.
- **Threads.** Replying inside a thread or starting a new one isn't exposed.
- **Webhooks.** Uses the bot REST API only.
- **Slash commands / interactions.** The bot doesn't register or respond to
`/commands`.
- **Per-workspace identity.** All Twenty workspaces in the same Twenty
deployment share the same Discord bot — see
[Why bot token instead of OAuth?](#why-bot-token-instead-of-oauth) below.
- **2000-character message limit.** Discord rejects longer payloads with
HTTP 400.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `Discord is not configured` | `DISCORD_BOT_TOKEN` not set | Admin: paste the bot token in Settings → Applications → Discord → **Settings tab** |
| `401 Unauthorized` | Token wrong, copied with whitespace, or rotated | Reset the token in the Discord Developer Portal and re-paste |
| `Missing Access (50001)` | Bot wasn't invited to that server | Re-run the OAuth invite URL for that server (see admin setup) |
| `Missing Permissions (50013)` | Bot is in the server but lacks Send / Manage / React permissions | In Discord: server settings → roles → grant the bot's role the missing permission |
| `Unknown Channel (10003)` | `channelId` is wrong, or bot can't see that channel | Verify the ID; check the bot has *View Channel* permission |
| `Unknown Message (10008)` | `messageId` is wrong, or message was deleted | Verify the ID is from a still-existing message |
| `Cannot edit a message authored by another user (50005)` | Trying to update / delete a message the bot didn't send | The bot can only edit its own messages; it can delete others' messages only with the *Manage Messages* permission |
| `Bot is in N Discord servers — please specify which one` (from list-channels) | Bot is in multiple servers and `guildId` was left blank | Copy the server ID from the error message and paste it into the `guildId` field |
| `Discord API responded with 429` | Hit Discord's rate limit | Reduce workflow concurrency; this connector doesn't yet auto-retry |
---
## Self-hosting setup (admin-only)
This section is for Twenty server admins. If you're on Twenty Cloud, skip
this — the bot credentials are already configured.
### 1. Create a Discord application
1. Visit https://discord.com/developers/applications and click
**New Application**.
2. Name it (e.g. *"Twenty Bot"*) and create. Use a dedicated app — don't
reuse one configured for other bots.
3. Sidebar → **Bot** tab → **Reset Token** → copy the token immediately
(it's only shown once; if you lose it, reset again).
4. *Recommended:* toggle **Public Bot** OFF so randoms can't invite the
bot to their servers.
### 2. Generate the invite URL and add the bot to your server(s)
1. In the Discord Developer Portal: sidebar → **OAuth2****URL Generator**.
2. **Scopes**: tick `bot`.
3. **Bot Permissions**: tick `Send Messages`, `Manage Messages`,
`Add Reactions`, `Read Message History`.
4. Copy the generated URL.
5. Open the URL in your browser, pick the Discord server, authorize.
Repeat for each server you want workflows to post in.
### 3. Wire the bot token into Twenty
1. In Twenty: **Settings → Applications → Discord → Settings tab**
(gear icon).
2. Paste the bot token into the `DISCORD_BOT_TOKEN` row. Saves on blur.
Workspace users can now use the Discord workflow tools immediately — no
further per-user configuration needed (which is unique vs Linear / Slack
where each user connects their own account).
---
## Why bot token instead of OAuth?
Slack and Linear use OAuth-per-workspace
(`defineConnectionProvider({ type: 'oauth' })`) so each Twenty workspace
stores its own access token. Discord works differently:
- Discord's `bot` scope **does** have an OAuth flow, but the `access_token`
it returns is a *user* bearer token — useless for bot actions like posting
messages. To actually send messages as the bot you need the static
**bot token** from the Developer Portal.
- That bot token is global to the Discord application (and therefore to
the Twenty deployment). Discord deprecated per-install bot tokens years
ago.
- Webhooks are a separate auth model but only support posting — no edit,
delete, or reactions — so they don't cover Slack/Linear parity.
The result: this connector skips `defineConnectionProvider` entirely and
reads `DISCORD_BOT_TOKEN` from an `applicationVariable` set once at
deployment scope. See
[Discord's OAuth2 docs](https://discord.com/developers/docs/topics/oauth2#bot-users)
for the underlying reason bot users authenticate via static tokens.
---
## Developers only
If you're working on this app rather than installing the published version:
```bash
cd packages/twenty-apps/internal/twenty-discord
# Day-to-day development (publish + install + watch in one):
yarn twenty dev
# Run unit tests:
yarn test
# Lint:
yarn lint
```
`twenty dev` is recommended for iteration — it publishes to your local
Twenty server, installs the app, and watches for changes in one command.
The Discord REST API (v10) is called directly via `fetch` — no `discord.js`
or other SDK dependency. See
`src/logic-functions/utils/discord-api-request.ts` for the auth and
error-handling wrapper that all handlers go through.
@@ -1,31 +0,0 @@
{
"name": "twenty-discord",
"version": "0.1.0",
"description": "Discord workflow connector for Twenty",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"oxlint": "^0.16.0",
"typescript": "^5.9.3",
"vitest": "^3.1.1"
}
}
@@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 127 127"
fill="none"
role="img"
>
<title>Discord</title>
<path
fill="#5865F2"
d="M107.7 26.4A104.6 104.6 0 0 0 81.6 18a0.4 0.4 0 0 0-0.4 0.2 73 73 0 0 0-3.2 6.6 96.6 96.6 0 0 0-29 0A66.8 66.8 0 0 0 45.6 18.2a0.4 0.4 0 0 0-0.4-0.2 104.2 104.2 0 0 0-26.1 8.4 0.4 0.4 0 0 0-0.2 0.2C2.3 51.4-2.2 75.7 0 99.7a0.4 0.4 0 0 0 0.2 0.3 105.2 105.2 0 0 0 31.6 16 0.4 0.4 0 0 0 0.5-0.1 75.6 75.6 0 0 0 6.5-10.6 0.4 0.4 0 0 0-0.2-0.5 69.4 69.4 0 0 1-9.9-4.7 0.4 0.4 0 0 1 0-0.7 54.6 54.6 0 0 0 2-1.6 0.4 0.4 0 0 1 0.4-0.1 75 75 0 0 0 63.9 0 0.4 0.4 0 0 1 0.4 0.1c0.6 0.6 1.3 1.1 2 1.6a0.4 0.4 0 0 1 0 0.7 65.2 65.2 0 0 1-9.9 4.7 0.4 0.4 0 0 0-0.2 0.5 84.8 84.8 0 0 0 6.5 10.6 0.4 0.4 0 0 0 0.5 0.1 104.8 104.8 0 0 0 31.6-16 0.4 0.4 0 0 0 0.2-0.3c2.7-27.8-4.5-51.9-18.9-73.1a0.4 0.4 0 0 0-0.2-0.2zM42.5 85a12.3 12.3 0 0 1-11.5-12.9c0-7.1 5-12.9 11.5-12.9 6.6 0 11.7 5.8 11.6 12.9 0 7-5.1 12.9-11.6 12.9zm42.8 0a12.3 12.3 0 0 1-11.5-12.9c0-7.1 5-12.9 11.5-12.9 6.6 0 11.6 5.8 11.5 12.9 0 7-5 12.9-11.5 12.9z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,30 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Twenty Discord',
description:
'Connect Discord to Twenty. Workflow steps post, update, and delete bot messages and add reactions using a Discord bot token shared across the deployment.',
logoUrl: 'public/twenty-discord.svg',
author: 'Twenty',
category: 'Communication',
aboutDescription:
'Official Discord connector for Twenty CRM. Create a Discord application at https://discord.com/developers/applications, copy its bot token into the DISCORD_BOT_TOKEN application variable, then invite the bot to each server you want workflows to post in. Use workflow actions to post, update, or delete bot messages and add reactions.',
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
applicationVariables: {
DISCORD_BOT_TOKEN: {
universalIdentifier: DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'Bot token from your Discord application (Developer Portal → Bot tab → Reset Token). Used with the `Bot` auth prefix to call the Discord REST API. The same token authenticates the bot across every guild it has been invited to.',
isSecret: true,
},
},
});
@@ -1,17 +0,0 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
SEND_DISCORD_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineCommandMenuItem({
universalIdentifier: SEND_DISCORD_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
label: 'Send Discord message',
shortLabel: 'Discord message',
icon: 'IconBrandDiscord',
isPinned: false,
availabilityType: 'GLOBAL',
frontComponentUniversalIdentifier:
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});
@@ -1,670 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
closeSidePanel,
enqueueSnackbar,
unmountFrontComponent,
} from 'twenty-sdk/front-component';
import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
type DiscordGuild = {
id: string;
name: string;
};
type DiscordChannel = {
id: string;
name: string;
type: number;
parentId: string | null;
position: number;
};
const DISCORD_MESSAGE_MAX_LENGTH = 2000;
// The front component sandbox dispatches events with a non-standard shape.
// Values may live on e.detail.value, e.value, or e.target.value.
const readSerializedValue = (
e: React.SyntheticEvent<HTMLElement>,
): string | undefined => {
const obj = e as {
detail?: { value?: string };
value?: string;
target?: { value?: string };
};
if (typeof obj.detail?.value === 'string') return obj.detail.value;
if (typeof obj.value === 'string') return obj.value;
if (typeof obj.target?.value === 'string') return obj.target.value;
return undefined;
};
const onValueChange =
(fn: (value: string) => void) => (e: React.SyntheticEvent<HTMLElement>) => {
const v = readSerializedValue(e);
if (typeof v === 'string') fn(v);
};
const callAppRoute = async (
path: string,
method: 'GET' | 'POST',
body?: Record<string, unknown>,
) => {
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
throw new Error('API configuration missing');
}
const response = await fetch(`${apiBaseUrl}/s${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`Request failed (${response.status}): ${text.slice(0, 200)}`,
);
}
return response.json();
};
const COLOR = {
bg: '#f1f1f1',
card: '#ffffff',
surface: '#fcfcfc',
border: '#ebebeb',
borderStrong: '#d6d6d6',
text: '#333333',
textSecondary: '#666666',
textTertiary: '#999999',
placeholder: '#cccccc',
accent: '#5865f2',
error: '#e05252',
};
const INLINE_SELECT: React.CSSProperties = {
appearance: 'none',
WebkitAppearance: 'none' as const,
border: 'none',
background: 'transparent',
color: 'inherit',
font: 'inherit',
cursor: 'pointer',
outline: 'none',
padding: 0,
};
const STYLES = {
outer: {
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
fontSize: '13px',
backgroundColor: COLOR.bg,
padding: '12px',
height: '100%',
boxSizing: 'border-box' as const,
},
container: {
backgroundColor: COLOR.card,
borderRadius: '8px',
border: `1px solid ${COLOR.border}`,
display: 'flex',
flexDirection: 'column' as const,
height: '100%',
boxSizing: 'border-box' as const,
color: COLOR.text,
},
topBar: {
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
borderBottom: `1px solid ${COLOR.border}`,
flexShrink: 0,
},
guildSelect: {
appearance: 'none' as const,
WebkitAppearance: 'none' as const,
background: COLOR.surface,
border: `1px solid ${COLOR.border}`,
borderRadius: '6px',
padding: '3px 8px',
color: COLOR.text,
fontSize: '12px',
fontFamily: 'inherit',
cursor: 'pointer',
outline: 'none',
},
channelPicker: {
flexShrink: 0,
padding: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px',
borderBottom: `1px solid ${COLOR.border}`,
},
channelChip: {
appearance: 'none' as const,
WebkitAppearance: 'none' as const,
background: 'transparent',
border: `1px solid ${COLOR.border}`,
borderRadius: '20px',
padding: '3px 9px',
color: COLOR.textSecondary,
fontSize: '12px',
fontFamily: 'inherit',
cursor: 'pointer',
outline: 'none',
display: 'inline-flex' as const,
alignItems: 'center',
gap: '4px',
whiteSpace: 'nowrap' as const,
lineHeight: '16px',
},
chipIcon: {
fontSize: '12px',
lineHeight: 1,
flexShrink: 0,
color: COLOR.textTertiary,
},
optionsLoadingIndicator: {
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
},
spinner: {
width: '14px',
height: '14px',
border: `2px solid ${COLOR.border}`,
borderTopColor: COLOR.textSecondary,
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
},
body: {
flex: 1,
display: 'flex',
flexDirection: 'column' as const,
padding: '24px',
overflow: 'auto',
minHeight: 0,
gap: '6px',
},
messageInput: {
background: 'transparent',
border: 'none',
outline: 'none',
color: COLOR.text,
fontSize: '14px',
fontFamily: 'inherit',
width: '100%',
padding: 0,
resize: 'none' as const,
flex: 1,
minHeight: '120px',
},
charCount: {
fontSize: '11px',
textAlign: 'right' as const,
color: COLOR.textTertiary,
flexShrink: 0,
},
charCountOverLimit: {
color: COLOR.error,
},
actionBar: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '6px',
padding: '6px 12px',
borderTop: `1px solid ${COLOR.border}`,
flexShrink: 0,
},
cancelButton: {
background: 'transparent',
border: 'none',
color: COLOR.textSecondary,
fontSize: '12px',
fontFamily: 'inherit',
cursor: 'pointer',
padding: '5px 10px',
borderRadius: '6px',
},
sendButton: {
background: COLOR.accent,
border: 'none',
color: '#ffffff',
fontSize: '12px',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
padding: '5px 14px',
borderRadius: '6px',
},
sendButtonDisabled: {
background: COLOR.borderStrong,
color: COLOR.textTertiary,
cursor: 'not-allowed',
},
successOuter: {
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
backgroundColor: COLOR.bg,
padding: '12px',
height: '100%',
boxSizing: 'border-box' as const,
},
successContainer: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
boxSizing: 'border-box' as const,
backgroundColor: COLOR.card,
borderRadius: '8px',
border: `1px solid ${COLOR.border}`,
color: COLOR.text,
},
successCheck: {
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: '#2ea043',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
color: '#fff',
flexShrink: 0,
},
successText: {
color: COLOR.text,
fontWeight: 500,
fontSize: '13px',
flex: 1,
overflow: 'hidden' as const,
textOverflow: 'ellipsis' as const,
whiteSpace: 'nowrap' as const,
},
closeButton: {
background: 'transparent',
border: `1px solid ${COLOR.border}`,
color: COLOR.textSecondary,
fontSize: '12px',
fontFamily: 'inherit',
cursor: 'pointer',
padding: '5px 14px',
borderRadius: '6px',
flexShrink: 0,
},
loadingOuter: {
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
backgroundColor: COLOR.bg,
padding: '12px',
height: '100%',
boxSizing: 'border-box' as const,
},
loadingContainer: {
color: COLOR.textSecondary,
fontSize: '13px',
textAlign: 'center' as const,
backgroundColor: COLOR.card,
borderRadius: '8px',
border: `1px solid ${COLOR.border}`,
padding: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
errorOuter: {
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
backgroundColor: COLOR.bg,
padding: '12px',
height: '100%',
boxSizing: 'border-box' as const,
},
errorContainer: {
backgroundColor: COLOR.card,
borderRadius: '8px',
border: `1px solid ${COLOR.border}`,
padding: '24px',
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
},
errorText: {
color: COLOR.error,
fontSize: '12px',
textAlign: 'center' as const,
},
retryButton: {
background: COLOR.surface,
border: `1px solid ${COLOR.border}`,
color: COLOR.text,
fontSize: '12px',
fontFamily: 'inherit',
cursor: 'pointer',
padding: '5px 12px',
borderRadius: '6px',
},
};
const SendMessageForm = () => {
const [guilds, setGuilds] = useState<DiscordGuild[]>([]);
const [guildsLoading, setGuildsLoading] = useState(true);
const [guildsError, setGuildsError] = useState<string | null>(null);
const [channels, setChannels] = useState<DiscordChannel[]>([]);
const [channelsLoading, setChannelsLoading] = useState(false);
const [selectedGuildId, setSelectedGuildId] = useState('');
const [selectedChannelId, setSelectedChannelId] = useState('');
const [messageText, setMessageText] = useState('');
const [sending, setSending] = useState(false);
const [sentChannelName, setSentChannelName] = useState<string | null>(null);
const [closing, setClosing] = useState(false);
const fetchChannels = useCallback(async (guildId: string) => {
if (!guildId) {
setChannels([]);
setSelectedChannelId('');
return;
}
setChannelsLoading(true);
try {
const result = await callAppRoute(
`/discord/channels?guildId=${encodeURIComponent(guildId)}`,
'GET',
);
if (!result.success) {
setChannels([]);
await enqueueSnackbar({
message: result.error ?? 'Failed to load channels',
variant: 'error',
});
return;
}
setChannels(result.channels ?? []);
} catch (error) {
setChannels([]);
await enqueueSnackbar({
message:
error instanceof Error ? error.message : 'Failed to load channels',
variant: 'error',
});
} finally {
setChannelsLoading(false);
}
}, []);
const handleGuildChange = useCallback(
(newGuildId: string) => {
setSelectedGuildId(newGuildId);
setSelectedChannelId('');
fetchChannels(newGuildId);
},
[fetchChannels],
);
const fetchGuilds = useCallback(async () => {
try {
setGuildsError(null);
const result = await callAppRoute('/discord/guilds', 'GET');
if (!result.success) {
setGuildsError(result.error ?? 'Failed to load Discord servers');
return;
}
const fetchedGuilds: DiscordGuild[] = result.guilds ?? [];
setGuilds(fetchedGuilds);
// Auto-select when the bot is in exactly one server to skip a needless step.
if (fetchedGuilds.length === 1) {
setSelectedGuildId(fetchedGuilds[0].id);
fetchChannels(fetchedGuilds[0].id);
}
} catch (error) {
setGuildsError(
error instanceof Error
? error.message
: 'Failed to load Discord servers',
);
} finally {
setGuildsLoading(false);
}
}, [fetchChannels]);
useEffect(() => {
fetchGuilds();
}, [fetchGuilds]);
const handleSubmit = async () => {
const trimmedMessage = (messageText ?? '').trim();
if (
!selectedGuildId ||
!selectedChannelId ||
trimmedMessage.length === 0 ||
trimmedMessage.length > DISCORD_MESSAGE_MAX_LENGTH
) {
return;
}
setSending(true);
try {
const result = await callAppRoute('/discord/messages', 'POST', {
channelId: selectedChannelId,
messageText: trimmedMessage,
});
if (!result.success) {
await enqueueSnackbar({
message: result.error ?? 'Failed to send message',
variant: 'error',
});
setSending(false);
return;
}
const channelName =
channels.find((channel) => channel.id === selectedChannelId)?.name ??
'channel';
setSentChannelName(channelName);
await enqueueSnackbar({
message: `Message sent to #${channelName}`,
variant: 'success',
});
} catch (error) {
await enqueueSnackbar({
message:
error instanceof Error ? error.message : 'Failed to send message',
variant: 'error',
});
setSending(false);
}
};
const handleCancel = () => {
setClosing(true);
unmountFrontComponent();
closeSidePanel();
};
if (closing) {
return null;
}
if (sentChannelName) {
return (
<div style={STYLES.successOuter}>
<div style={STYLES.successContainer}>
<div style={STYLES.successCheck}>{'✓'}</div>
<span style={STYLES.successText}>
Message sent to #{sentChannelName}
</span>
<button
type="button"
style={STYLES.closeButton}
onClick={handleCancel}
>
Close
</button>
</div>
</div>
);
}
if (guildsLoading) {
return (
<div style={STYLES.loadingOuter}>
<div style={STYLES.loadingContainer}>Loading...</div>
</div>
);
}
if (guildsError) {
return (
<div style={STYLES.errorOuter}>
<div style={STYLES.errorContainer}>
<p style={STYLES.errorText}>{guildsError}</p>
<button
type="button"
style={STYLES.retryButton}
onClick={fetchGuilds}
>
Retry
</button>
</div>
</div>
);
}
const trimmedLength = (messageText ?? '').trim().length;
const isOverLimit = (messageText ?? '').length > DISCORD_MESSAGE_MAX_LENGTH;
const canSubmit =
!!selectedGuildId &&
!!selectedChannelId &&
trimmedLength > 0 &&
!isOverLimit &&
!sending;
return (
<div style={STYLES.outer}>
<style>{'@keyframes spin{to{transform:rotate(360deg)}}'}</style>
<div style={STYLES.container}>
<div style={STYLES.topBar}>
<select
value={selectedGuildId}
onChange={onValueChange(handleGuildChange)}
style={STYLES.guildSelect}
>
<option value="">Select server...</option>
{guilds.map((guild) => (
<option key={guild.id} value={guild.id}>
{guild.name}
</option>
))}
</select>
{channelsLoading && (
<div style={STYLES.optionsLoadingIndicator}>
<div style={STYLES.spinner} />
</div>
)}
</div>
<div style={STYLES.channelPicker}>
<label style={STYLES.channelChip}>
<span style={STYLES.chipIcon}>{'#'}</span>
<select
value={selectedChannelId}
onChange={onValueChange(setSelectedChannelId)}
style={INLINE_SELECT}
disabled={!selectedGuildId || channelsLoading}
>
<option value="">
{channels.length === 0 ? 'No channels' : 'Select channel'}
</option>
{channels.map((channel) => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
</label>
</div>
<div style={STYLES.body}>
<textarea
value={messageText}
onInput={onValueChange(setMessageText)}
onChange={onValueChange(setMessageText)}
style={STYLES.messageInput}
placeholder="Type a message..."
/>
<div
style={{
...STYLES.charCount,
...(isOverLimit ? STYLES.charCountOverLimit : {}),
}}
>
{(messageText ?? '').length} / {DISCORD_MESSAGE_MAX_LENGTH}
</div>
</div>
<div style={STYLES.actionBar}>
<button
type="button"
style={STYLES.cancelButton}
onClick={handleCancel}
>
Cancel
</button>
<button
type="button"
style={{
...STYLES.sendButton,
...(!canSubmit ? STYLES.sendButtonDisabled : {}),
}}
disabled={!canSubmit}
onClick={handleSubmit}
>
{sending ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'send-discord-message-form',
description:
'Form to send a message to a Discord channel: pick the server, pick the channel, type the message, send.',
component: SendMessageForm,
});
@@ -1,41 +0,0 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'9b1b7628-ac98-4e44-8e1e-91b401378a5f';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'6aa50eb2-e8c2-470f-8c4d-09debbf6e051';
export const DISCORD_BOT_TOKEN_VARIABLE_UNIVERSAL_IDENTIFIER =
'ac986a3b-19a5-4cee-a87a-2f8d3885ad84';
export const DISCORD_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
'6c03d456-c1fa-4acc-9928-d97ced0925fe';
export const DISCORD_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
'b37285d9-cb11-4fe5-963d-0434780122ba';
export const DISCORD_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
'a2efc27c-eae0-4713-b327-bdb1aaeff95f';
export const DISCORD_ADD_REACTION_UNIVERSAL_IDENTIFIER =
'32c77c8a-ebd0-4308-aa1b-d21bb94422ac';
export const DISCORD_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
'ef2296f6-1b48-4edd-81ac-cafa4fe73f99';
export const DISCORD_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
'550cd993-af6e-46ae-b697-f838f34cfe39';
export const DISCORD_LIST_GUILDS_UNIVERSAL_IDENTIFIER =
'0aaaca4f-3156-4b7c-a9f5-ee6a2e17cbf1';
export const DISCORD_LIST_GUILDS_ROUTE_UNIVERSAL_IDENTIFIER =
'3cfa36d9-3c04-495c-bc09-f8019ad85dce';
export const DISCORD_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
'eda60c95-450b-4a86-b5a0-6b85f488ae81';
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'ee8d8efa-2f9d-4d2c-b759-bb3cafc7797d';
export const SEND_DISCORD_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
'8c80b9d8-2f18-4b6c-8933-30d170790685';
@@ -1,35 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { DISCORD_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordAddReactionHandler } from 'src/logic-functions/handlers/discord-add-reaction-handler';
import { discordAddReactionInputSchema } from './schemas/discord-add-reaction-input.schema';
export default defineLogicFunction({
universalIdentifier: DISCORD_ADD_REACTION_UNIVERSAL_IDENTIFIER,
name: 'discord-add-reaction',
description:
'Add an emoji reaction to a Discord message (for example a checkmark ✅) so the channel can see status at a glance.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: discordAddReactionInputSchema,
},
workflowActionTriggerSettings: {
label: 'Add Discord Reaction',
icon: 'IconBrandDiscord',
inputSchema: jsonSchemaToInputSchema(discordAddReactionInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
messageId: { type: 'string' },
channelId: { type: 'string' },
},
},
],
},
handler: discordAddReactionHandler,
});
@@ -1,35 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { DISCORD_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordDeleteMessageHandler } from 'src/logic-functions/handlers/discord-delete-message-handler';
import { discordDeleteMessageInputSchema } from './schemas/discord-delete-message-input.schema';
export default defineLogicFunction({
universalIdentifier: DISCORD_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER,
name: 'discord-delete-message',
description:
'Remove a Discord message this bot sent (for example after a mistake or when a workflow is cancelled).',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: discordDeleteMessageInputSchema,
},
workflowActionTriggerSettings: {
label: 'Delete Discord Message',
icon: 'IconBrandDiscord',
inputSchema: jsonSchemaToInputSchema(discordDeleteMessageInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
messageId: { type: 'string' },
channelId: { type: 'string' },
},
},
],
},
handler: discordDeleteMessageHandler,
});
@@ -1,27 +0,0 @@
import type { RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { DISCORD_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordListChannelsHandler } from 'src/logic-functions/handlers/discord-list-channels-handler';
const handler = async (event: RoutePayload) => {
const params = event.queryStringParameters ?? {};
const guildId =
typeof params.guildId === 'string' && params.guildId.length > 0
? params.guildId
: undefined;
return discordListChannelsHandler({ guildId });
};
export default defineLogicFunction({
universalIdentifier: DISCORD_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'discord-list-channels-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/discord/channels',
httpMethod: 'GET',
isAuthRequired: true,
},
});
@@ -1,46 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { DISCORD_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordListChannelsHandler } from 'src/logic-functions/handlers/discord-list-channels-handler';
import { discordListChannelsInputSchema } from './schemas/discord-list-channels-input.schema';
export default defineLogicFunction({
universalIdentifier: DISCORD_LIST_CHANNELS_UNIVERSAL_IDENTIFIER,
name: 'discord-list-channels',
description:
'List the text-postable channels (types `GUILD_TEXT` and `GUILD_ANNOUNCEMENT`) in a Discord server, sorted by Discord position. Each entry exposes `id`, `name`, `type`, `parentId` (category), and `position`. Pass the server (guild) ID to scope the listing; if omitted and the bot is in exactly one server, that server is auto-selected. If the bot is in multiple servers, the error response lists each server name and ID for explicit selection. Voice channels, threads, forums, and categories are excluded.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: discordListChannelsInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Discord Channels',
icon: 'IconBrandDiscord',
inputSchema: jsonSchemaToInputSchema(discordListChannelsInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
channels: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
type: { type: 'number' },
parentId: { type: 'string' },
position: { type: 'number' },
},
},
},
count: { type: 'number' },
error: { type: 'string' },
},
},
],
},
handler: discordListChannelsHandler,
});
@@ -1,21 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/define';
import { DISCORD_LIST_GUILDS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordListGuildsHandler } from 'src/logic-functions/handlers/discord-list-guilds-handler';
const handler = async (_event: RoutePayload) => {
return discordListGuildsHandler();
};
export default defineLogicFunction({
universalIdentifier: DISCORD_LIST_GUILDS_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'discord-list-guilds-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/discord/guilds',
httpMethod: 'GET',
isAuthRequired: true,
},
});
@@ -1,43 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { DISCORD_LIST_GUILDS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordListGuildsHandler } from 'src/logic-functions/handlers/discord-list-guilds-handler';
import { discordListGuildsInputSchema } from './schemas/discord-list-guilds-input.schema';
export default defineLogicFunction({
universalIdentifier: DISCORD_LIST_GUILDS_UNIVERSAL_IDENTIFIER,
name: 'discord-list-guilds',
description:
'List the Discord servers (guilds) the bot has been invited to. Each entry exposes `id` and `name`. Use this to discover which servers the bot can post in, then pass the chosen server ID to `discord-list-channels` or other server-scoped actions.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: discordListGuildsInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Discord Servers',
icon: 'IconBrandDiscord',
inputSchema: jsonSchemaToInputSchema(discordListGuildsInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
guilds: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
},
},
},
count: { type: 'number' },
error: { type: 'string' },
},
},
],
},
handler: discordListGuildsHandler,
});
@@ -1,45 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import type { RoutePayload } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { DISCORD_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordPostMessageHandler } from 'src/logic-functions/handlers/discord-post-message-handler';
const handler = async (event: RoutePayload) => {
const body = event.body as Record<string, unknown> | null;
const rawChannelId = body?.channelId;
const rawMessageText = body?.messageText;
if (typeof rawChannelId !== 'string' || typeof rawMessageText !== 'string') {
return {
success: false,
message: 'Failed to post Discord message',
error: '`channelId` and `messageText` must be strings.',
};
}
const channelId = rawChannelId.trim();
const messageText = rawMessageText.trim();
if (!isNonEmptyString(channelId) || !isNonEmptyString(messageText)) {
return {
success: false,
message: 'Failed to post Discord message',
error: '`channelId` and `messageText` are required.',
};
}
return discordPostMessageHandler({ channelId, messageText });
};
export default defineLogicFunction({
universalIdentifier: DISCORD_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER,
name: 'discord-post-message-route',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/discord/messages',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,35 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { DISCORD_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordPostMessageHandler } from 'src/logic-functions/handlers/discord-post-message-handler';
import { discordPostMessageInputSchema } from './schemas/discord-post-message-input.schema';
export default defineLogicFunction({
universalIdentifier: DISCORD_POST_MESSAGE_UNIVERSAL_IDENTIFIER,
name: 'discord-post-message',
description:
'Send a message to a Discord channel as the bot. Optionally reply to an existing message using its message ID from a previous step.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: discordPostMessageInputSchema,
},
workflowActionTriggerSettings: {
label: 'Send Discord Message',
icon: 'IconBrandDiscord',
inputSchema: jsonSchemaToInputSchema(discordPostMessageInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
messageId: { type: 'string' },
channelId: { type: 'string' },
},
},
],
},
handler: discordPostMessageHandler,
});
@@ -1,35 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { DISCORD_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { discordUpdateMessageHandler } from 'src/logic-functions/handlers/discord-update-message-handler';
import { discordUpdateMessageInputSchema } from './schemas/discord-update-message-input.schema';
export default defineLogicFunction({
universalIdentifier: DISCORD_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER,
name: 'discord-update-message',
description:
'Change the text of a Discord message this bot already sent. You need the channel ID and the message ID from when it was posted.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: discordUpdateMessageInputSchema,
},
workflowActionTriggerSettings: {
label: 'Update Discord Message',
icon: 'IconBrandDiscord',
inputSchema: jsonSchemaToInputSchema(discordUpdateMessageInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
messageId: { type: 'string' },
channelId: { type: 'string' },
},
},
],
},
handler: discordUpdateMessageHandler,
});
@@ -1,52 +0,0 @@
import { type DiscordAddReactionInput } from 'src/logic-functions/types/discord-add-reaction-input.type';
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
export const discordAddReactionHandler = async (
parameters: DiscordAddReactionInput,
): Promise<DiscordToolResult> => {
const tokenResult = getDiscordBotToken();
if (!tokenResult.success) {
return {
success: false,
message: 'Discord is not configured',
error: tokenResult.error,
};
}
const channelId = parameters.channelId.trim();
const messageId = parameters.messageId.trim();
const emoji = parameters.emoji.trim();
const encodedChannelId = encodeURIComponent(channelId);
const encodedMessageId = encodeURIComponent(messageId);
const encodedEmoji = encodeURIComponent(emoji);
try {
const result = await discordApiRequest({
botToken: tokenResult.botToken,
method: 'PUT',
path: `/channels/${encodedChannelId}/messages/${encodedMessageId}/reactions/${encodedEmoji}/@me`,
});
if (!result.ok) {
return {
success: false,
message: 'Failed to add Discord reaction',
error: result.errorMessage,
};
}
return {
success: true,
message: `Reaction "${emoji}" added to the message.`,
messageId,
channelId,
};
} catch (error) {
return buildDiscordFailureResult('Failed to add Discord reaction', error);
}
};
@@ -1,46 +0,0 @@
import { type DiscordDeleteMessageInput } from 'src/logic-functions/types/discord-delete-message-input.type';
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
export const discordDeleteMessageHandler = async (
parameters: DiscordDeleteMessageInput,
): Promise<DiscordToolResult> => {
const tokenResult = getDiscordBotToken();
if (!tokenResult.success) {
return {
success: false,
message: 'Discord is not configured',
error: tokenResult.error,
};
}
try {
const result = await discordApiRequest({
botToken: tokenResult.botToken,
method: 'DELETE',
path: `/channels/${encodeURIComponent(
parameters.channelId,
)}/messages/${encodeURIComponent(parameters.messageId)}`,
});
if (!result.ok) {
return {
success: false,
message: 'Failed to delete Discord message',
error: result.errorMessage,
};
}
return {
success: true,
message: 'Discord message deleted.',
messageId: parameters.messageId,
channelId: parameters.channelId,
};
} catch (error) {
return buildDiscordFailureResult('Failed to delete Discord message', error);
}
};
@@ -1,125 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type DiscordListChannelsInput } from 'src/logic-functions/types/discord-list-channels-input.type';
import {
type DiscordListChannelsResult,
type DiscordListChannelsResultChannel,
} from 'src/logic-functions/types/discord-list-channels-output.type';
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
import { fetchDiscordGuilds } from 'src/logic-functions/utils/fetch-discord-guilds';
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
const GUILD_TEXT_CHANNEL_TYPE = 0;
const GUILD_ANNOUNCEMENT_CHANNEL_TYPE = 5;
const POSTABLE_CHANNEL_TYPES = new Set<number>([
GUILD_TEXT_CHANNEL_TYPE,
GUILD_ANNOUNCEMENT_CHANNEL_TYPE,
]);
type DiscordChannelResponse = {
id: string;
name: string | null;
type: number;
parent_id: string | null;
position: number;
};
const buildFailureResult = (error: string): DiscordListChannelsResult => ({
success: false,
channels: [],
count: 0,
error,
});
const resolveGuildId = async ({
botToken,
providedGuildId,
}: {
botToken: string;
providedGuildId: string | undefined;
}): Promise<{ ok: true; guildId: string } | { ok: false; error: string }> => {
const trimmed = providedGuildId?.trim();
if (isDefined(trimmed) && trimmed.length > 0) {
return { ok: true, guildId: trimmed };
}
const guildsResult = await fetchDiscordGuilds(botToken);
if (!guildsResult.ok) {
return { ok: false, error: guildsResult.errorMessage };
}
const guilds = guildsResult.data;
if (guilds.length === 0) {
return {
ok: false,
error:
'Bot has not been invited to any Discord server yet. Use the OAuth2 → URL Generator in the Discord Developer Portal (scope `bot`) to invite it to a server, then retry.',
};
}
if (guilds.length > 1) {
const guildList = guilds
.map((guild) => `"${guild.name}" (${guild.id})`)
.join(', ');
return {
ok: false,
error: `Bot is in ${guilds.length} Discord servers — please specify which one via the guildId input. Available: ${guildList}.`,
};
}
return { ok: true, guildId: guilds[0].id };
};
export const discordListChannelsHandler = async (
parameters: DiscordListChannelsInput,
): Promise<DiscordListChannelsResult> => {
const tokenResult = getDiscordBotToken();
if (!tokenResult.success) {
return buildFailureResult(tokenResult.error);
}
const guildIdResult = await resolveGuildId({
botToken: tokenResult.botToken,
providedGuildId: parameters.guildId,
});
if (!guildIdResult.ok) {
return buildFailureResult(guildIdResult.error);
}
const result = await discordApiRequest<DiscordChannelResponse[]>({
botToken: tokenResult.botToken,
method: 'GET',
path: `/guilds/${encodeURIComponent(guildIdResult.guildId)}/channels`,
});
if (!result.ok) {
return buildFailureResult(result.errorMessage);
}
const channels: DiscordListChannelsResultChannel[] = result.data
.filter(
(channel) =>
POSTABLE_CHANNEL_TYPES.has(channel.type) && isDefined(channel.name),
)
.map((channel) => ({
id: channel.id,
name: channel.name as string,
type: channel.type,
parentId: channel.parent_id,
position: channel.position,
}))
.sort((a, b) => a.position - b.position);
return {
success: true,
channels,
count: channels.length,
};
};
@@ -1,36 +0,0 @@
import { type DiscordListGuildsResult } from 'src/logic-functions/types/discord-list-guilds-output.type';
import { fetchDiscordGuilds } from 'src/logic-functions/utils/fetch-discord-guilds';
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
const buildFailureResult = (error: string): DiscordListGuildsResult => ({
success: false,
guilds: [],
count: 0,
error,
});
export const discordListGuildsHandler =
async (): Promise<DiscordListGuildsResult> => {
const tokenResult = getDiscordBotToken();
if (!tokenResult.success) {
return buildFailureResult(tokenResult.error);
}
const result = await fetchDiscordGuilds(tokenResult.botToken);
if (!result.ok) {
return buildFailureResult(result.errorMessage);
}
const guilds = result.data.map((guild) => ({
id: guild.id,
name: guild.name,
}));
return {
success: true,
guilds,
count: guilds.length,
};
};
@@ -1,61 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type DiscordPostMessageInput } from 'src/logic-functions/types/discord-post-message-input.type';
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
type DiscordMessageResponse = {
id: string;
channel_id: string;
};
export const discordPostMessageHandler = async (
parameters: DiscordPostMessageInput,
): Promise<DiscordToolResult> => {
const tokenResult = getDiscordBotToken();
if (!tokenResult.success) {
return {
success: false,
message: 'Discord is not configured',
error: tokenResult.error,
};
}
const replyToMessageId = parameters.replyToMessageId?.trim();
const body: Record<string, unknown> = {
content: parameters.messageText,
};
if (isDefined(replyToMessageId) && replyToMessageId.length > 0) {
body.message_reference = { message_id: replyToMessageId };
}
try {
const result = await discordApiRequest<DiscordMessageResponse>({
botToken: tokenResult.botToken,
method: 'POST',
path: `/channels/${encodeURIComponent(parameters.channelId)}/messages`,
body,
});
if (!result.ok) {
return {
success: false,
message: 'Failed to post Discord message',
error: result.errorMessage,
};
}
return {
success: true,
message: `Message posted to Discord (id=${result.data.id}).`,
messageId: result.data.id,
channelId: result.data.channel_id,
};
} catch (error) {
return buildDiscordFailureResult('Failed to post Discord message', error);
}
};
@@ -1,52 +0,0 @@
import { type DiscordToolResult } from 'src/logic-functions/types/discord-tool-result.type';
import { type DiscordUpdateMessageInput } from 'src/logic-functions/types/discord-update-message-input.type';
import { buildDiscordFailureResult } from 'src/logic-functions/utils/build-discord-failure-result';
import { discordApiRequest } from 'src/logic-functions/utils/discord-api-request';
import { getDiscordBotToken } from 'src/logic-functions/utils/get-discord-bot-token';
type DiscordMessageResponse = {
id: string;
channel_id: string;
};
export const discordUpdateMessageHandler = async (
parameters: DiscordUpdateMessageInput,
): Promise<DiscordToolResult> => {
const tokenResult = getDiscordBotToken();
if (!tokenResult.success) {
return {
success: false,
message: 'Discord is not configured',
error: tokenResult.error,
};
}
try {
const result = await discordApiRequest<DiscordMessageResponse>({
botToken: tokenResult.botToken,
method: 'PATCH',
path: `/channels/${encodeURIComponent(
parameters.channelId,
)}/messages/${encodeURIComponent(parameters.messageId)}`,
body: { content: parameters.newMessageText },
});
if (!result.ok) {
return {
success: false,
message: 'Failed to update Discord message',
error: result.errorMessage,
};
}
return {
success: true,
message: 'Discord message updated.',
messageId: result.data.id,
channelId: result.data.channel_id,
};
} catch (error) {
return buildDiscordFailureResult('Failed to update Discord message', error);
}
};
@@ -1,26 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const discordAddReactionInputSchema: InputJsonSchema = {
type: 'object',
properties: {
channelId: {
type: 'string',
label: 'Discord channel ID',
description: 'Channel where the message lives (Discord channel ID).',
},
messageId: {
type: 'string',
label: 'Message ID',
description:
'The message to react to: its **message ID** (same value as `messageId` returned when the bot posted it, or right-click the message → "Copy Message ID").',
},
emoji: {
type: 'string',
label: 'Emoji',
description:
'Emoji to add. For a standard unicode emoji, paste the emoji directly (e.g. `👍`, `✅`, `🎉`). For a custom server emoji, use the format `name:id` (e.g. `partyparrot:643452342342342342`). The bot must have **Add Reactions** permission in the channel.',
},
},
required: ['channelId', 'messageId', 'emoji'],
additionalProperties: false,
};
@@ -1,20 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const discordDeleteMessageInputSchema: InputJsonSchema = {
type: 'object',
properties: {
channelId: {
type: 'string',
label: 'Discord channel ID',
description: 'Channel that contains the message (Discord channel ID).',
},
messageId: {
type: 'string',
label: 'Message ID',
description:
'Which message to remove: its **message ID** (same value as `messageId` returned when the bot posted it). The bot can delete its own messages without extra permissions; deleting others\' messages requires the **Manage Messages** permission.',
},
},
required: ['channelId', 'messageId'],
additionalProperties: false,
};
@@ -1,15 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const discordListChannelsInputSchema: InputJsonSchema = {
type: 'object',
properties: {
guildId: {
type: 'string',
label: 'Discord server (guild) ID',
description:
'Optional. The Discord server ID to list channels for (a long number like `1234567890123456789`). In Discord with Developer Mode on: right-click the server icon → "Copy Server ID". If omitted and the bot is in exactly one server, that server is used automatically; if the bot is in multiple servers, the error response lists each server name and ID so you can pick one.',
},
},
required: [],
additionalProperties: false,
};
@@ -1,8 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const discordListGuildsInputSchema: InputJsonSchema = {
type: 'object',
properties: {},
required: [],
additionalProperties: false,
};
@@ -1,28 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const discordPostMessageInputSchema: InputJsonSchema = {
type: 'object',
properties: {
channelId: {
type: 'string',
label: 'Discord channel ID',
description:
'Where to post: the Discord channel ID (a long number like `1234567890123456789`). In Discord with Developer Mode on: right-click the channel → "Copy Channel ID". The bot must be a member of the server and have permission to send messages in this channel.',
},
messageText: {
type: 'string',
label: 'Message',
multiline: true,
description:
'The message text. Supports Discord-flavoured markdown (**bold**, *italics*, ```code blocks```, lists, links). Max 2000 characters — Discord rejects longer payloads.',
},
replyToMessageId: {
type: 'string',
label: 'Reply to message ID',
description:
'Optional. To reply to an existing message, paste its ID (from a previous step\'s `messageId` output, or right-click the message → "Copy Message ID"). Leave empty for a normal new message.',
},
},
required: ['channelId', 'messageText'],
additionalProperties: false,
};
@@ -1,27 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const discordUpdateMessageInputSchema: InputJsonSchema = {
type: 'object',
properties: {
channelId: {
type: 'string',
label: 'Discord channel ID',
description: 'Channel that contains the message (Discord channel ID).',
},
messageId: {
type: 'string',
label: 'Message ID',
description:
'Which message to edit: its **message ID** (same value as `messageId` returned when the bot posted the message). You can only edit messages this bot sent.',
},
newMessageText: {
type: 'string',
label: 'New message',
multiline: true,
description:
'Replacement text shown in Discord instead of the old content. Supports Discord-flavoured markdown. Max 2000 characters.',
},
},
required: ['channelId', 'messageId', 'newMessageText'],
additionalProperties: false,
};
@@ -1,5 +0,0 @@
export type DiscordAddReactionInput = {
channelId: string;
messageId: string;
emoji: string;
};
@@ -1,4 +0,0 @@
export type DiscordDeleteMessageInput = {
channelId: string;
messageId: string;
};
@@ -1,3 +0,0 @@
export type DiscordListChannelsInput = {
guildId?: string;
};
@@ -1,14 +0,0 @@
export type DiscordListChannelsResultChannel = {
id: string;
name: string;
type: number;
parentId: string | null;
position: number;
};
export type DiscordListChannelsResult = {
success: boolean;
channels: DiscordListChannelsResultChannel[];
count: number;
error?: string;
};
@@ -1,11 +0,0 @@
export type DiscordListGuildsResultGuild = {
id: string;
name: string;
};
export type DiscordListGuildsResult = {
success: boolean;
guilds: DiscordListGuildsResultGuild[];
count: number;
error?: string;
};
@@ -1,5 +0,0 @@
export type DiscordPostMessageInput = {
channelId: string;
messageText: string;
replyToMessageId?: string;
};
@@ -1,7 +0,0 @@
export type DiscordToolResult = {
success: boolean;
message: string;
error?: string;
messageId?: string;
channelId?: string;
};

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