Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7b33fe18 | ||
|
|
f1125d0894 |
@@ -106,24 +106,24 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
|
||||
### 2. Create File Structure
|
||||
|
||||
**Create changelog file:**
|
||||
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
|
||||
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
|
||||
|
||||
**Create image folder:**
|
||||
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
|
||||
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
|
||||
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
|
||||
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
|
||||
|
||||
```bash
|
||||
# Create the image folder
|
||||
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
### 3. Move Illustration Files
|
||||
|
||||
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
|
||||
|
||||
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
|
||||
**Naming Convention:** `{VERSION}-descriptive-name.webp`
|
||||
|
||||
@@ -133,8 +133,8 @@ Examples:
|
||||
|
||||
```bash
|
||||
# Move and rename source files, then convert to webp if needed
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
|
||||
```
|
||||
|
||||
### 4. Research Features (if needed)
|
||||
@@ -183,7 +183,7 @@ Description of the third feature.
|
||||
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
|
||||
|
||||
**Reference Previous Changelogs:**
|
||||
- Check `packages/twenty-website/src/content/releases/` for examples
|
||||
- Check `packages/twenty-website-new/src/content/releases/` for examples
|
||||
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
|
||||
|
||||
### 6. Review
|
||||
@@ -191,10 +191,10 @@ Description of the third feature.
|
||||
Open the changelog file for review:
|
||||
```bash
|
||||
# Open in Cursor
|
||||
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
|
||||
# Open image folder to verify illustrations
|
||||
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
Review checklist:
|
||||
@@ -222,8 +222,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
|
||||
[Show full MDX content]
|
||||
|
||||
Images moved to:
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
|
||||
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
|
||||
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
|
||||
|
||||
Please review the content. Once you approve, I'll commit the changes and create the pull request.
|
||||
```
|
||||
@@ -242,8 +242,8 @@ Possible user responses:
|
||||
git status
|
||||
|
||||
# Add files
|
||||
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
|
||||
# Commit
|
||||
git commit -m "Add {VERSION} release changelog"
|
||||
@@ -266,7 +266,7 @@ This release includes:
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
|
||||
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
|
||||
Release date: {DATE}" \
|
||||
--base main \
|
||||
--head {VERSION}
|
||||
@@ -280,13 +280,13 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
|
||||
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
|
||||
- **Convention**: One file per complete version
|
||||
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
|
||||
- **Location**: `packages/twenty-website/src/content/releases/`
|
||||
- **Location**: `packages/twenty-website-new/src/content/releases/`
|
||||
|
||||
### Image Folders
|
||||
- **Format**: `{MAJOR}.{MINOR}/`
|
||||
- **Convention**: One folder per minor version (shared across patches)
|
||||
- **Examples**: `1.6/`, `1.7/`, `2.0/`
|
||||
- **Location**: `packages/twenty-website/public/images/releases/`
|
||||
- **Location**: `packages/twenty-website-new/public/images/releases/`
|
||||
|
||||
### Image Files
|
||||
- **Format**: `{VERSION}-descriptive-name.webp`
|
||||
@@ -311,8 +311,8 @@ Features to document:
|
||||
3. ___________________________
|
||||
|
||||
Branch name: {VERSION}
|
||||
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty app:publish --private --remote target
|
||||
run: yarn twenty deploy --remote target
|
||||
|
||||
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Install
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty app:install --remote target
|
||||
run: yarn twenty install --remote target
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Website translations (twenty-website)
|
||||
# Crowdin CLI configuration for Website translations (twenty-website-new)
|
||||
# Project ID: 4
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
@@ -16,7 +16,7 @@ files:
|
||||
#
|
||||
# Source file - PO file for Lingui
|
||||
#
|
||||
- source: packages/twenty-website/src/locales/en.po
|
||||
- source: packages/twenty-website-new/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
|
||||
@@ -144,9 +144,6 @@ jobs:
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
|
||||
- name: Flush cache before seeding current branch
|
||||
run: npx nx command-no-deps twenty-server -- cache:flush
|
||||
|
||||
- name: Seed current branch database with test data
|
||||
run: |
|
||||
npx nx command-no-deps twenty-server -- workspace:seed:dev
|
||||
@@ -163,7 +160,7 @@ jobs:
|
||||
- name: Wait for current branch server to be ready
|
||||
run: |
|
||||
echo "Waiting for current branch server to start..."
|
||||
timeout=60
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
@@ -188,10 +185,9 @@ jobs:
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "❌ Timed out waiting for current branch server to serve a valid schema."
|
||||
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
|
||||
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
|
||||
@@ -315,9 +311,6 @@ jobs:
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
|
||||
- name: Flush cache before seeding main branch
|
||||
run: npx nx command-no-deps twenty-server -- cache:flush
|
||||
|
||||
- name: Seed main branch database with test data
|
||||
run: |
|
||||
npx nx command-no-deps twenty-server -- workspace:seed:dev
|
||||
@@ -359,10 +352,9 @@ jobs:
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "❌ Timed out waiting for main branch server to serve a valid schema."
|
||||
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
|
||||
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
|
||||
@@ -458,7 +450,6 @@ jobs:
|
||||
echo "Using OpenAPITools/openapi-diff via Docker"
|
||||
|
||||
- name: Generate GraphQL Schema Diff Reports
|
||||
id: graphql-diff
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
|
||||
@@ -472,7 +463,6 @@ jobs:
|
||||
echo "✅ No changes in GraphQL schema"
|
||||
else
|
||||
echo "⚠️ Changes detected in GraphQL schema, generating report..."
|
||||
echo "core_breaking=true" >> $GITHUB_OUTPUT
|
||||
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
|
||||
echo "" >> graphql-schema-diff.md
|
||||
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
|
||||
@@ -490,7 +480,6 @@ jobs:
|
||||
echo "✅ No changes in GraphQL metadata schema"
|
||||
else
|
||||
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
|
||||
echo "metadata_breaking=true" >> $GITHUB_OUTPUT
|
||||
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
|
||||
echo "" >> graphql-metadata-diff.md
|
||||
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
|
||||
@@ -507,7 +496,6 @@ jobs:
|
||||
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
|
||||
|
||||
- name: Check REST API Breaking Changes
|
||||
id: rest-diff
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
|
||||
@@ -530,7 +518,6 @@ jobs:
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST API"
|
||||
echo "breaking=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST API Breaking Changes" > rest-api-diff.md
|
||||
@@ -578,7 +565,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check REST Metadata API Breaking Changes
|
||||
id: rest-metadata-diff
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
|
||||
@@ -601,7 +587,6 @@ jobs:
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST Metadata API"
|
||||
echo "breaking=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Generate breaking changes report (only for breaking changes)
|
||||
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
|
||||
@@ -647,79 +632,6 @@ jobs:
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
||||
- name: Fail on breaking changes
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
breaking=false
|
||||
|
||||
if [ "${{ steps.graphql-diff.outputs.core_breaking }}" = "true" ]; then
|
||||
echo "❌ GraphQL core schema has breaking changes"
|
||||
breaking=true
|
||||
if [ -f graphql-schema-diff.md ]; then
|
||||
echo ""
|
||||
cat graphql-schema-diff.md
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${{ steps.graphql-diff.outputs.metadata_breaking }}" = "true" ]; then
|
||||
echo "❌ GraphQL metadata schema has breaking changes"
|
||||
breaking=true
|
||||
if [ -f graphql-metadata-diff.md ]; then
|
||||
echo ""
|
||||
cat graphql-metadata-diff.md
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${{ steps.rest-diff.outputs.breaking }}" = "true" ]; then
|
||||
echo "❌ REST core API has breaking changes"
|
||||
breaking=true
|
||||
if [ -f rest-api-diff.json ]; then
|
||||
echo ""
|
||||
jq -r '
|
||||
(if (.missingEndpoints | length) > 0 then
|
||||
" Removed endpoints:\n" +
|
||||
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end),
|
||||
(if (.changedOperations | length) > 0 then
|
||||
" Changed operations:\n" +
|
||||
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end)
|
||||
' rest-api-diff.json | sed '/^$/d'
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${{ steps.rest-metadata-diff.outputs.breaking }}" = "true" ]; then
|
||||
echo "❌ REST metadata API has breaking changes"
|
||||
breaking=true
|
||||
if [ -f rest-metadata-api-diff.json ]; then
|
||||
echo ""
|
||||
jq -r '
|
||||
(if (.missingEndpoints | length) > 0 then
|
||||
" Removed endpoints:\n" +
|
||||
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end),
|
||||
(if (.changedOperations | length) > 0 then
|
||||
" Changed operations:\n" +
|
||||
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end)
|
||||
' rest-metadata-api-diff.json | sed '/^$/d'
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$breaking" = "true" ]; then
|
||||
echo ""
|
||||
echo "This PR introduces breaking changes to the public API."
|
||||
echo "If intentional, deprecate the old endpoint and introduce a new one."
|
||||
echo "See the breaking changes report artifact and PR comment for details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No breaking API changes detected"
|
||||
|
||||
- name: Upload breaking changes report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -740,3 +652,5 @@ jobs:
|
||||
if [ -f /tmp/main-server.pid ]; then
|
||||
kill $(cat /tmp/main-server.pid) || true
|
||||
fi
|
||||
|
||||
|
||||
|
||||
@@ -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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
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" --api-url http://localhost:3000
|
||||
|
||||
- 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
|
||||
@@ -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" --url http://localhost:3000
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --api-url http://localhost:3000
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
- 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 }} --url ${{ env.TWENTY_API_URL }}
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
|
||||
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
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" --api-url http://localhost:3000
|
||||
|
||||
- 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
|
||||
@@ -80,20 +80,10 @@ jobs:
|
||||
with:
|
||||
name: storybook-twenty-front-component-renderer
|
||||
path: packages/twenty-front-component-renderer/storybook-static
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
- name: Install Playwright
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd packages/twenty-front-component-renderer
|
||||
npx playwright install chromium
|
||||
npx playwright install
|
||||
- name: Serve storybook & run tests
|
||||
run: |
|
||||
npx http-server packages/twenty-front-component-renderer/storybook-static --port 6008 --silent &
|
||||
|
||||
@@ -96,20 +96,10 @@ jobs:
|
||||
with:
|
||||
name: storybook-static
|
||||
path: packages/twenty-front/storybook-static
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
|
||||
- name: Install Playwright
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
cd packages/twenty-front
|
||||
npx playwright install chromium
|
||||
npx playwright install
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Serve storybook & run tests
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
files: |
|
||||
package.json
|
||||
yarn.lock
|
||||
packages/twenty-website/**
|
||||
packages/twenty-website-new/**
|
||||
packages/twenty-shared/**
|
||||
website-task:
|
||||
needs: changed-files-check
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 'Preview Environment Dispatch'
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -10,6 +11,7 @@ on:
|
||||
- packages/twenty-server/**
|
||||
- packages/twenty-front/**
|
||||
- .github/workflows/preview-env-dispatch.yaml
|
||||
- .github/workflows/preview-env-keepalive.yaml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -33,15 +35,28 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch preview-env to ci-privileged
|
||||
- name: Trigger preview environment workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
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/twentyhq/ci-privileged/dispatches \
|
||||
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"
|
||||
|
||||
- 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"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
name: 'Preview Environment Keep Alive'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [preview-environment]
|
||||
|
||||
jobs:
|
||||
preview-environment:
|
||||
timeout-minutes: 310
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.client_payload.pr_head_sha }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Run compose setup
|
||||
run: |
|
||||
echo "Patching docker-compose.yml..."
|
||||
# change image to localbuild using yq
|
||||
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
|
||||
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
echo "Setting up .env file..."
|
||||
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
|
||||
|
||||
echo "Generating secrets..."
|
||||
echo "" >> packages/twenty-docker/.env
|
||||
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
|
||||
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
|
||||
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
|
||||
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
|
||||
echo "Docker compose build..."
|
||||
cd packages/twenty-docker/
|
||||
docker compose build
|
||||
working-directory: ./
|
||||
|
||||
- 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"
|
||||
|
||||
- name: Start services with correct SERVER_URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
|
||||
echo "Setting SERVER_URL to $TUNNEL_URL"
|
||||
sed -i '/SERVER_URL=/d' .env
|
||||
echo "" >> .env
|
||||
echo "SERVER_URL=$TUNNEL_URL" >> .env
|
||||
|
||||
# Start the services
|
||||
echo "Docker compose up..."
|
||||
docker compose up -d || {
|
||||
echo "Docker compose failed to start"
|
||||
docker compose logs
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Waiting for services to be ready..."
|
||||
count=0
|
||||
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
|
||||
sleep 5
|
||||
count=$((count+1))
|
||||
if [ $count -gt 60 ]; then
|
||||
echo "Timeout waiting for services to be ready"
|
||||
docker compose logs
|
||||
exit 1
|
||||
fi
|
||||
echo "Still waiting for services... ($count/60)"
|
||||
done
|
||||
|
||||
echo "All services are up and running!"
|
||||
working-directory: ./
|
||||
|
||||
- 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..."
|
||||
docker compose logs server
|
||||
exit 1
|
||||
fi
|
||||
working-directory: ./
|
||||
|
||||
- name: Output tunnel URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
echo "✅ Preview Environment Ready!"
|
||||
echo "🔗 Preview URL: $TUNNEL_URL"
|
||||
echo "⏱️ This environment will be available for 5 hours"
|
||||
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "$TUNNEL_URL" > tunnel-url.txt
|
||||
|
||||
- name: Upload tunnel URL artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: tunnel-url
|
||||
path: tunnel-url.txt
|
||||
retention-days: 1
|
||||
|
||||
- name: Keep tunnel alive for 5 hours
|
||||
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
|
||||
|
||||
- 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: ./
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# Strict mode fails if there are missing website translations.
|
||||
- name: Compile website translations
|
||||
id: compile_translations_strict
|
||||
run: npx nx run twenty-website:lingui:compile --strict
|
||||
run: npx nx run twenty-website-new:lingui:compile --strict
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
@@ -71,8 +71,8 @@ jobs:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: 'packages/twenty-website/src/locales/en.po'
|
||||
translation: 'packages/twenty-website/src/locales/%locale%.po'
|
||||
source: 'packages/twenty-website-new/src/locales/en.po'
|
||||
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
@@ -101,9 +101,9 @@ jobs:
|
||||
- name: Compile website translations
|
||||
id: compile_translations
|
||||
run: |
|
||||
npx nx run twenty-website:lingui:compile
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
git status
|
||||
git add packages/twenty-website/src/locales
|
||||
git add packages/twenty-website-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'packages/twenty-website/**'
|
||||
- 'packages/twenty-website-new/**'
|
||||
- '.github/crowdin-website.yml'
|
||||
- '.github/workflows/website-i18n-push.yaml'
|
||||
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Extract website translations
|
||||
run: npx nx run twenty-website:lingui:extract
|
||||
run: npx nx run twenty-website-new:lingui:extract
|
||||
|
||||
- name: Check and commit extracted files
|
||||
id: check_extract_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website/src/locales
|
||||
git add packages/twenty-website-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: extract website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
@@ -57,14 +57,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Compile website translations
|
||||
run: npx nx run twenty-website:lingui:compile
|
||||
run: npx nx run twenty-website-new:lingui:compile
|
||||
|
||||
- name: Check and commit compiled files
|
||||
id: check_compile_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website/src/locales/generated
|
||||
git add packages/twenty-website-new/src/locales/generated
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: 'Website Preview Dispatch'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed, labeled]
|
||||
paths:
|
||||
- packages/twenty-website/**
|
||||
- .github/workflows/website-preview-dispatch.yaml
|
||||
|
||||
concurrency:
|
||||
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
|
||||
# would resolve to the base branch under pull_request and collide.
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-build:
|
||||
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
|
||||
# call would fail anyway — skip explicitly to avoid noise.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action != 'closed' && (
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
|
||||
(
|
||||
(
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR'
|
||||
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-build to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-build \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
|
||||
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
|
||||
|
||||
trigger-cleanup:
|
||||
# Covers both merge and close-without-merge — pull_request `closed` fires
|
||||
# for both. PRs left open forever are covered by OpenNext's
|
||||
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
|
||||
# open-next.config.ts, so nothing leaks even if cleanup never runs.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-cleanup to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-cleanup \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 80,
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": [
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/lib/**",
|
||||
"**/.next/**",
|
||||
"**/coverage/**",
|
||||
"**/generated/**",
|
||||
"**/generated-admin/**",
|
||||
"**/generated-metadata/**",
|
||||
"**/.cache/**",
|
||||
"**/node_modules/**",
|
||||
"**/*.min.js",
|
||||
"**/*.snap",
|
||||
"**/*.md",
|
||||
"**/*.mdx",
|
||||
"**/seed-project/**/*.mjs",
|
||||
"packages/twenty-zapier/build/**",
|
||||
"**/upgrade-version-command/**"
|
||||
]
|
||||
}
|
||||
@@ -81,8 +81,8 @@
|
||||
"path": "../packages/twenty-sdk"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-website",
|
||||
"path": "../packages/twenty-website"
|
||||
"name": "packages/twenty-website-new",
|
||||
"path": "../packages/twenty-website-new"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -10,8 +10,4 @@ nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- twenty-sdk
|
||||
- twenty-client-sdk
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
|
||||
@@ -110,7 +110,7 @@ packages/
|
||||
├── twenty-ui/ # Shared UI components library
|
||||
├── twenty-shared/ # Common types and utilities
|
||||
├── twenty-emails/ # Email templates with React Email
|
||||
├── twenty-website/ # Next.js marketing website
|
||||
├── twenty-website-new/ # Next.js marketing website
|
||||
├── twenty-docs/ # Documentation website
|
||||
├── twenty-zapier/ # Zapier integration
|
||||
└── twenty-e2e-testing/ # Playwright E2E tests
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 align="center" >The #1 Open-Source CRM</h2>
|
||||
|
||||
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
|
||||
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
@@ -24,17 +24,17 @@
|
||||
|
||||
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
|
||||
|
||||
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||
|
||||
<br />
|
||||
|
||||
# Installation
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
|
||||
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
|
||||
Scaffold a new app with the Twenty CLI:
|
||||
|
||||
@@ -63,12 +63,12 @@ export default defineObject({
|
||||
Then ship it to your workspace:
|
||||
|
||||
```bash
|
||||
npx twenty app:publish --private
|
||||
npx twenty deploy
|
||||
```
|
||||
|
||||
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
|
||||
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
|
||||
|
||||
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
|
||||
|
||||
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
|
||||
|
||||
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
|
||||
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Stack
|
||||
|
||||
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
|
||||
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
|
||||
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
|
||||
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
|
||||
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
|
||||
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
|
||||
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
|
||||
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
|
||||
|
||||
|
||||
|
||||
# Thanks
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
|
||||
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
|
||||
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
|
||||
</p>
|
||||
|
||||
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Join the Community
|
||||
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
|
||||
|
||||
|
Before Width: | Height: | Size: 173 KiB |
@@ -44,12 +44,12 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
|
||||
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {},
|
||||
"fix": {
|
||||
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
|
||||
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
|
||||
@@ -59,12 +59,12 @@
|
||||
"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 && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
|
||||
"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)$"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
|
||||
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -73,14 +73,18 @@
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx oxfmt --check {args.files} {args.write}",
|
||||
"files": ".",
|
||||
"write": ""
|
||||
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
|
||||
"cache": true,
|
||||
"cacheLocation": "../../.cache/prettier/{projectRoot}",
|
||||
"cacheStrategy": "metadata",
|
||||
"write": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {},
|
||||
"ci": {
|
||||
"cacheStrategy": "content"
|
||||
},
|
||||
"fix": {
|
||||
"command": "npx oxfmt {args.files}"
|
||||
"write": true
|
||||
}
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"concurrently": "^8.2.2",
|
||||
"http-server": "^14.1.1",
|
||||
"nx": "22.5.4",
|
||||
"oxfmt": "0.50.0",
|
||||
"tsx": "^4.17.0",
|
||||
"verdaccio": "^6.3.1"
|
||||
},
|
||||
@@ -28,7 +27,7 @@
|
||||
"resolutions": {
|
||||
"graphql": "16.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "5.9.2",
|
||||
"nodemailer": "8.0.4",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0",
|
||||
"@lingui/core": "5.1.2",
|
||||
@@ -54,7 +53,7 @@
|
||||
"packages/twenty-ui",
|
||||
"packages/twenty-utils",
|
||||
"packages/twenty-zapier",
|
||||
"packages/twenty-website",
|
||||
"packages/twenty-website-new",
|
||||
"packages/twenty-docs",
|
||||
"packages/twenty-e2e-testing",
|
||||
"packages/twenty-shared",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<a href="https://twenty.com">
|
||||
<picture>
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
|
||||
</picture>
|
||||
</a>
|
||||
<h1>Create Twenty App</h1>
|
||||
@@ -32,12 +32,21 @@ The scaffolder will:
|
||||
|
||||
| Flag | Description |
|
||||
| ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| `--example <name>` | Initialize from an example |
|
||||
| `--name <name>` | Set the app name |
|
||||
| `--display-name <displayName>` | Set the display name |
|
||||
| `--description <description>` | Set the description |
|
||||
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--api-url <url>` | Twenty instance URL (default: `http://localhost:2020`) |
|
||||
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
|
||||
|
||||
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
|
||||
|
||||
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)**:
|
||||
@@ -48,8 +57,8 @@ 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 docker:logs`.
|
||||
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
|
||||
- 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.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.9.0",
|
||||
"version": "2.5.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
@@ -50,7 +50,7 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"twenty-shared": "workspace:*",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
|
||||
@@ -15,11 +15,14 @@ const program = new Command(packageJson.name)
|
||||
'Output the current version of create-twenty-app.',
|
||||
)
|
||||
.argument('[directory]')
|
||||
.option('--example <name>', 'Initialize from an example')
|
||||
.option('-n, --name <name>', 'Application name')
|
||||
.option('-d, --display-name <displayName>', 'Application display name')
|
||||
.option('--description <description>', 'Application description')
|
||||
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --url]')
|
||||
.option(
|
||||
'--api-url <apiUrl>',
|
||||
'Twenty instance URL (default: http://localhost:2020)',
|
||||
)
|
||||
.option(
|
||||
'--authentication-method <method>',
|
||||
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
|
||||
@@ -29,10 +32,10 @@ const program = new Command(packageJson.name)
|
||||
async (
|
||||
directory?: string,
|
||||
options?: {
|
||||
example?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
apiUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
},
|
||||
@@ -63,20 +66,13 @@ const program = new Command(packageJson.name)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options?.apiUrl) {
|
||||
console.warn(
|
||||
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
|
||||
);
|
||||
}
|
||||
|
||||
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
example: options?.example,
|
||||
name: options?.name,
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
serverUrl,
|
||||
apiUrl: options?.apiUrl,
|
||||
authenticationMethod: options?.authenticationMethod,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -49,19 +49,19 @@
|
||||
|
||||
## Best practice
|
||||
|
||||
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
|
||||
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
|
||||
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------- |
|
||||
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ------------------------------------ | ------------------------------------- |
|
||||
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
|
||||
This helps automatically generate required IDs etc.
|
||||
|
||||
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
|
||||
## Useful Commands
|
||||
|
||||
- `yarn twenty dev` - Start the development server and sync your app
|
||||
- `yarn twenty docker:status` - Check the local Twenty server status
|
||||
- `yarn twenty docker:start` - Start the local Twenty server
|
||||
- `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
|
||||
|
||||
@@ -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 |
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,291 +0,0 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
Avatar,
|
||||
IconBox,
|
||||
IconHierarchy,
|
||||
IconLayout,
|
||||
IconSettingsAutomation,
|
||||
} from 'twenty-sdk/ui';
|
||||
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: 'LOGIC FUNCTION',
|
||||
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 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);
|
||||
|
||||
const CategoryIcon = () => {
|
||||
if (title === 'Data model') {
|
||||
return <IconHierarchy color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Logic') {
|
||||
return <IconSettingsAutomation color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Layout') {
|
||||
return <IconLayout color={color} size={'20px'} />;
|
||||
}
|
||||
};
|
||||
|
||||
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`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<CategoryIcon />
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<IconBox color={color} size={'20px'} />
|
||||
<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
|
||||
placeholder={APP_DISPLAY_NAME}
|
||||
placeholderColorSeed={APP_DISPLAY_NAME}
|
||||
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#installed"
|
||||
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 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
DEV_API_URL,
|
||||
serverStart,
|
||||
} from 'twenty-sdk/cli';
|
||||
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
getDockerInstallInstructions,
|
||||
isDockerInstalled,
|
||||
@@ -30,10 +31,11 @@ export type AuthenticationMethod = 'oauth' | 'apiKey';
|
||||
|
||||
type CreateAppOptions = {
|
||||
directory?: string;
|
||||
example?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
serverUrl?: string;
|
||||
apiUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
};
|
||||
|
||||
@@ -45,9 +47,9 @@ export class CreateAppCommand {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
this.getAppInfos(options);
|
||||
|
||||
const serverUrl = options.serverUrl ?? DEV_API_URL;
|
||||
const apiUrl = options.apiUrl ?? DEV_API_URL;
|
||||
|
||||
const skipLocalInstance = serverUrl !== DEV_API_URL;
|
||||
const skipLocalInstance = apiUrl !== DEV_API_URL;
|
||||
|
||||
if (!skipLocalInstance && !isDockerInstalled()) {
|
||||
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
|
||||
@@ -90,13 +92,30 @@ export class CreateAppCommand {
|
||||
|
||||
this.logNextStep('Scaffolding project files');
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
onProgress: (message) => this.logDetail(message),
|
||||
});
|
||||
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.logNextStep('Installing dependencies');
|
||||
|
||||
@@ -118,7 +137,7 @@ export class CreateAppCommand {
|
||||
console.log('');
|
||||
|
||||
let authSucceeded = false;
|
||||
let resolvedServerUrl = serverUrl;
|
||||
let resolvedApiUrl = apiUrl;
|
||||
let serverReady = skipLocalInstance;
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
@@ -126,47 +145,20 @@ export class CreateAppCommand {
|
||||
const serverResult = await this.ensureDockerServer(dockerPullPromise);
|
||||
|
||||
if (isDefined(serverResult.url)) {
|
||||
resolvedServerUrl = serverResult.url;
|
||||
resolvedApiUrl = serverResult.url;
|
||||
serverReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (serverReady) {
|
||||
this.logNextStep('Authenticating');
|
||||
|
||||
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
|
||||
|
||||
if (authSucceeded) {
|
||||
this.logDetail('Reusing existing credentials');
|
||||
} else if (authenticationMethod === 'oauth') {
|
||||
this.logDetail('Starting OAuth flow');
|
||||
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
|
||||
} else {
|
||||
this.logDetail('Using development API key');
|
||||
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
|
||||
}
|
||||
if (serverReady && authenticationMethod === 'oauth') {
|
||||
this.logNextStep('Authenticating via OAuth');
|
||||
authSucceeded = await this.authenticateWithOAuth(resolvedApiUrl);
|
||||
} else if (serverReady && authenticationMethod === 'apiKey') {
|
||||
this.logNextStep('Authenticating via API key');
|
||||
authSucceeded = await this.authenticateWithDevKey(resolvedApiUrl);
|
||||
}
|
||||
|
||||
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, resolvedServerUrl);
|
||||
}
|
||||
|
||||
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
|
||||
this.logSuccess(appDirectory, resolvedApiUrl, authSucceeded);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('\nCreate application failed:'),
|
||||
@@ -188,7 +180,6 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
steps += 1; // authenticate (oauth or apiKey)
|
||||
steps += 1; // sync application
|
||||
|
||||
return steps;
|
||||
}
|
||||
@@ -202,6 +193,7 @@ export class CreateAppCommand {
|
||||
const appName = (
|
||||
options.name ??
|
||||
options.directory ??
|
||||
options.example ??
|
||||
'my-twenty-app'
|
||||
).trim();
|
||||
|
||||
@@ -230,6 +222,28 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async tryDownloadExample(
|
||||
example: string,
|
||||
appDirectory: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await downloadExample(example, appDirectory);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
|
||||
),
|
||||
);
|
||||
this.logDetail('Falling back to default template...');
|
||||
|
||||
await fs.emptyDir(appDirectory);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private logPlan({
|
||||
appName,
|
||||
appDisplayName,
|
||||
@@ -311,253 +325,11 @@ export class CreateAppCommand {
|
||||
return {};
|
||||
}
|
||||
|
||||
private async openMainPage(
|
||||
appDirectory: string,
|
||||
serverUrl: 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(serverUrl, token),
|
||||
]);
|
||||
|
||||
if (!universalIdentifier || !frontUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageLayoutId = await this.resolvePageLayoutId(
|
||||
serverUrl,
|
||||
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(
|
||||
serverUrl: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
|
||||
|
||||
const response = await fetch(`${serverUrl}/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 ? normalizeUrl(frontUrl) : 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(
|
||||
serverUrl: string,
|
||||
universalIdentifier: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ getPageLayouts { id universalIdentifier } }`;
|
||||
|
||||
const response = await fetch(`${serverUrl}/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(serverUrl: 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 !== serverUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/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(serverUrl: string): Promise<boolean> {
|
||||
private async authenticateWithDevKey(apiUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await authLogin({
|
||||
apiKey: DEV_API_KEY,
|
||||
apiUrl: serverUrl,
|
||||
apiUrl,
|
||||
remote: 'local',
|
||||
});
|
||||
|
||||
@@ -572,7 +344,7 @@ export class CreateAppCommand {
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -580,7 +352,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -596,21 +368,21 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
|
||||
private async authenticateWithOAuth(apiUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const remoteName = this.deriveRemoteName(serverUrl);
|
||||
const remoteName = this.deriveRemoteName(apiUrl);
|
||||
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
|
||||
this.logDetail('Opening browser for OAuth...');
|
||||
|
||||
const result = await authLoginOAuth({ apiUrl: serverUrl });
|
||||
const result = await authLoginOAuth({ apiUrl });
|
||||
|
||||
if (result.success) {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote(remoteName);
|
||||
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
|
||||
this.logDetail(`Authenticated via OAuth to ${apiUrl}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -618,7 +390,7 @@ export class CreateAppCommand {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` OAuth failed: ${result.error.message}\n` +
|
||||
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
` Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -626,7 +398,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
` Authentication failed. Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -636,7 +408,7 @@ export class CreateAppCommand {
|
||||
|
||||
private logSuccess(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
apiUrl: string,
|
||||
authSucceeded: boolean,
|
||||
): void {
|
||||
const dirName = basename(appDirectory);
|
||||
@@ -654,7 +426,9 @@ export class CreateAppCommand {
|
||||
if (!authSucceeded) {
|
||||
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
|
||||
console.log(
|
||||
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
|
||||
chalk.cyan(
|
||||
' yarn twenty remote add --api-url <your-instance-url>\n',
|
||||
),
|
||||
);
|
||||
stepNumber++;
|
||||
}
|
||||
@@ -664,7 +438,7 @@ export class CreateAppCommand {
|
||||
stepNumber++;
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
|
||||
console.log(chalk.cyan(` ${serverUrl}\n`));
|
||||
console.log(chalk.cyan(` ${apiUrl}\n`));
|
||||
|
||||
console.log(
|
||||
chalk.gray(
|
||||
|
||||
@@ -17,7 +17,6 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
|
||||
'constants',
|
||||
'universal-identifiers.ts',
|
||||
);
|
||||
const YARNRC_PATH = 'yarnrc.yml';
|
||||
|
||||
// Template content matching template/src/constants/universal-identifiers.ts
|
||||
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
|
||||
@@ -174,27 +173,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
expect(publicDirectoryContents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
|
||||
await fs.writeFile(
|
||||
join(testAppDirectory, YARNRC_PATH),
|
||||
'nodeLinker: node-modules',
|
||||
);
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
|
||||
onProgress?.('Copying base template');
|
||||
await fs.copy(join(__dirname, './constants/template'), appDirectory);
|
||||
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github)');
|
||||
await renameDotfiles({ appDirectory });
|
||||
|
||||
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
|
||||
@@ -47,7 +47,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
const renames = [
|
||||
{ from: 'gitignore', to: '.gitignore' },
|
||||
{ from: 'github', to: '.github' },
|
||||
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
|
||||
];
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
|
||||
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
|
||||
' Then run this command again.',
|
||||
'',
|
||||
' Alternatively, connect to an existing Twenty instance:',
|
||||
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
|
||||
' npx create-twenty-app@latest my-twenty-app --api-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);
|
||||
}
|
||||
};
|
||||
@@ -93,7 +93,7 @@ yarn install
|
||||
# Register your local Twenty server as a remote (interactive prompt).
|
||||
# When asked for the URL use http://localhost:2021 and paste an API key
|
||||
# from Settings -> Developers in the Twenty UI.
|
||||
yarn twenty remote:add
|
||||
yarn twenty remote add
|
||||
|
||||
# Build, install, and watch for changes.
|
||||
yarn twenty dev
|
||||
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
|
||||
```bash
|
||||
cd packages/twenty-apps/community/github-connector
|
||||
yarn install
|
||||
yarn twenty remote:add # same prompts as above
|
||||
yarn twenty app:install # builds and installs once
|
||||
yarn twenty remote add # same prompts as above
|
||||
yarn twenty install # builds and installs once
|
||||
```
|
||||
|
||||
## Configure authentication
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
@@ -80,8 +80,8 @@ export async function teardown() {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
throw new Error(
|
||||
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import {
|
||||
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
@@ -36,5 +36,5 @@ export default defineRole({
|
||||
canUpdateFieldValue: true,
|
||||
},
|
||||
],
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"dev": "twenty dev",
|
||||
"exec": "twenty dev:fn-exec",
|
||||
"uninstall": "twenty app:uninstall",
|
||||
"exec": "twenty exec",
|
||||
"uninstall": "twenty uninstall",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
},
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import {
|
||||
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
@@ -36,5 +36,5 @@ export default defineRole({
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,267 +0,0 @@
|
||||
# Fireflies for Twenty
|
||||
|
||||
Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries onto
|
||||
the matching `CalendarEvent` in your Twenty CRM — searchable, in context, and
|
||||
ready for AI agents and workflows to act on. Plus on-demand workflow tools to
|
||||
sync, list, and search Fireflies calls from the AI chat or workflow builder.
|
||||
|
||||
## What this app does
|
||||
|
||||
1. Fireflies records and transcribes your Zoom / Meet / Teams / phone call.
|
||||
2. When the transcript is ready, Fireflies fires a `meeting.transcribed`
|
||||
webhook; once Fireflies finishes its AI summary, it fires a separate
|
||||
`meeting.summarized` webhook.
|
||||
3. For each event, this app fetches the relevant data via the Fireflies
|
||||
GraphQL API.
|
||||
4. It finds the matching `CalendarEvent` in Twenty and writes the content
|
||||
into either the **Transcript** or **Summary** field on that event.
|
||||
|
||||
Alongside the webhook, three [Workflow tools](#workflow-tools) let you
|
||||
trigger Fireflies actions from the AI chat or as steps inside a workflow,
|
||||
without waiting for Fireflies to push.
|
||||
|
||||
### How a transcript is matched to a CalendarEvent
|
||||
|
||||
The matcher tries two provider-ID strategies in priority order and stops at
|
||||
the first hit:
|
||||
|
||||
1. **Provider-native event ID** — Fireflies' `calendar_id` / `cal_id` is
|
||||
matched against `CalendarChannelEventAssociation.eventExternalId`. Covers
|
||||
events synced into Twenty from Google Calendar (including individual
|
||||
instances of recurring events, where Fireflies returns the per-instance
|
||||
id with timestamp on `cal_id`).
|
||||
2. **iCalUID** — Fireflies' `calendar_id` is matched against
|
||||
`CalendarEvent.iCalUid`. Covers events synced from Outlook / CalDAV,
|
||||
where Fireflies returns the RFC 5545 iCalUID directly.
|
||||
|
||||
Both identifiers are populated by Twenty's calendar drivers on every synced
|
||||
CalendarEvent, so any meeting that's been pulled in via Google / Outlook /
|
||||
CalDAV calendar sync will match exactly. The matcher does **not** fall back
|
||||
to fuzzy URL matching — if the transcript can't be tied to a synced calendar
|
||||
event, the call is treated as an orphan and skipped (see
|
||||
[Limitations](#limitations) below). This avoids silently writing transcripts
|
||||
to the wrong event.
|
||||
|
||||
## What gets added to your Twenty workspace
|
||||
|
||||
Two new fields on the standard **CalendarEvent** object:
|
||||
|
||||
- **Transcript** — rich-text field, speaker-attributed (e.g. *"**Sarah:**
|
||||
Hi there"*, then *"**John:** Doing well, thanks."*).
|
||||
- **Summary** — rich-text field with the Fireflies AI summary: a bullet-list
|
||||
overview, action items grouped by speaker, topics discussed, and keywords.
|
||||
|
||||
Plus three workflow tools — see [Workflow tools](#workflow-tools) below.
|
||||
|
||||
## Workflow tools
|
||||
|
||||
Once the API key is configured, three tools become available in the workflow
|
||||
builder and the AI chat — covering the cases the webhook can't:
|
||||
|
||||
- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` onto its
|
||||
CalendarEvent now"*. As a workflow step: provide `transcriptId`. Runs the
|
||||
same pipeline as the webhook (fetch transcript + AI summary, find matching
|
||||
CalendarEvent, write Transcript + Summary fields) on demand. Use cases:
|
||||
**backfilling** historical calls that happened before the app was
|
||||
installed; **recovering** from a missed webhook (e.g. the calendar event
|
||||
hadn't synced yet when Fireflies pushed); or triggering a sync from a
|
||||
workflow instead of waiting for Fireflies. Output includes
|
||||
`calendarEventId`, `updatedFields`, and a per-field outcome breakdown so
|
||||
partial successes are visible.
|
||||
- **List Fireflies Calls By Participant** — *"show me my last 5 calls with
|
||||
john@acme.com"*. As a workflow step: provide `participantEmail` (and
|
||||
optional `limit`, max 50). Returns recent Fireflies calls — newest first —
|
||||
where that email was an attendee, with title, date, duration, host, and
|
||||
transcript URL. The natural first step in workflows triggered on
|
||||
`Person.created` — *"find what we've talked about with this contact"*.
|
||||
- **Search Fireflies Calls** — *"find any call where we discussed pricing"*.
|
||||
As a workflow step: provide `keyword` (and optional `limit`, max 50).
|
||||
Matches the keyword against both meeting titles and the words actually
|
||||
spoken in meetings. Returns the same call-summary shape as the
|
||||
participant tool. Best for AI-chat-driven research.
|
||||
|
||||
The list-by-participant and search tools return the same compact call shape:
|
||||
`id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`,
|
||||
`transcriptUrl`, `meetingLink`. To then sync any of those calls onto its
|
||||
CalendarEvent, pass the `id` from a list result into **Sync Fireflies Call**.
|
||||
|
||||
## Installing
|
||||
|
||||
1. Open **Settings → Applications** in your Twenty workspace.
|
||||
2. Find **Fireflies** in the available apps and click **Install**.
|
||||
3. Follow [Self-hosting setup](#self-hosting-setup-admin-only) below to wire
|
||||
up the API key and webhook (admin-only, one-time).
|
||||
|
||||
> **Heads up:** if you see *"Fireflies is not configured"* on the first
|
||||
> webhook, your Twenty admin needs to follow the
|
||||
> [Self-hosting setup](#self-hosting-setup-admin-only) section.
|
||||
|
||||
## Limitations
|
||||
|
||||
What this connector intentionally does **not** support in v1:
|
||||
|
||||
- **Calls without a matching CalendarEvent (orphan calls).** Ad-hoc calls
|
||||
that were never on anyone's synced calendar are skipped. The webhook logs
|
||||
the skip reason; the transcript still lives in Fireflies. Synthetic event
|
||||
creation for orphans is planned for v2.
|
||||
- **Fireflies sentiment, speaker analytics, transcript chapters.** Only
|
||||
the raw transcript and the AI summary (overview, action items, topics,
|
||||
keywords) are synced today.
|
||||
- **Per-user Fireflies accounts.** All transcripts come through one
|
||||
workspace-shared API key (set by the admin). Per-user OAuth-style
|
||||
connections require extending Twenty's connection provider system and are
|
||||
planned once we have evidence that workspace-shared is too coarse.
|
||||
- **Editing transcripts or summaries in Twenty.** The fields are writable
|
||||
but the next Fireflies sync overwrites any manual edits — treat them as
|
||||
read-only.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Webhook returns `Fireflies is not configured` | `FIREFLIES_API_KEY` not set | Admin: paste the API key in **Settings → Applications → Fireflies → Settings** |
|
||||
| Webhook returns `Invalid webhook signature` | `FIREFLIES_WEBHOOK_SECRET` mismatch between Fireflies and Twenty | Re-copy the signing secret from the Fireflies webhook configuration and paste it into the Twenty app settings |
|
||||
| Webhook returns `skipped: No CalendarEvent matched the transcript by external ID or iCalUid` | The meeting was never on a synced calendar in Twenty, or the workspace has no Google/Outlook/CalDAV calendar connection set up | Connect the relevant calendar provider in **Settings → Accounts** so the calendar event lands in Twenty with `eventExternalId` and `iCalUid` populated. Manually-created CalendarEvents are intentionally not matched in v1 |
|
||||
| Transcript appears empty | Fireflies returned no sentences (call too short, audio failed) | Check the call in the Fireflies dashboard; nothing this app can do |
|
||||
| Summary appears empty | Fireflies hasn't summarized the call yet, or the call was too short to summarize | Fireflies sends `meeting.summarized` separately from `meeting.transcribed` (typically a minute or two later); ensure that event is subscribed to in your Webhooks V2 config |
|
||||
| Summary is populated but Transcript isn't (or vice versa) | Only one of the two Fireflies events is subscribed to | Subscribe to both `meeting.transcribed` and `meeting.summarized` in your Fireflies Webhooks V2 configuration |
|
||||
| Fireflies API errors with `401` | API key wrong, rotated, or revoked | Generate a new key in Fireflies → Integrations → Fireflies API → Regenerate, then update `FIREFLIES_API_KEY` |
|
||||
| **Sync Fireflies Call** reports `No fields were updated` | The Fireflies call's `calendar_id` / `cal_id` doesn't match any CalendarEvent's `iCalUid` or `eventExternalId` (orphan call), or the per-field outcomes show transient Fireflies API failures | Check the `fieldOutcomes` array in the result — `skipped` means orphan call (same limitation as the webhook); `error` means Fireflies-side failure (retry, or inspect the error message) |
|
||||
| **List / Search** tools return `count: 0` for a contact you've definitely talked to | Email mismatch — Fireflies stores the address as the participant joined the meeting with, which may differ from the contact's primary address in Twenty (aliases, plus-addressing, work vs. personal) | Try the contact's other known email addresses; cross-check the `participants` list on a known matching call |
|
||||
|
||||
---
|
||||
|
||||
## Self-hosting setup (admin-only)
|
||||
|
||||
This section is for Twenty server admins. If you're on Twenty Cloud, skip
|
||||
this — the credentials may already be configured.
|
||||
|
||||
### 1. Generate a Fireflies API key
|
||||
|
||||
1. Visit https://app.fireflies.ai and sign in.
|
||||
2. Go to **Integrations → Fireflies API**.
|
||||
3. Click **Generate API key** and copy the value (it's only shown once).
|
||||
|
||||
### 2. Configure a Webhooks V2 endpoint in Fireflies
|
||||
|
||||
This integration targets [Fireflies Webhooks V2](https://docs.fireflies.ai/graphql-api/webhooks-v2)
|
||||
(snake_case payload, granular event subscriptions). The legacy V1 webhook
|
||||
format (`meetingId` / `eventType: "Transcription completed"`) is **not**
|
||||
supported.
|
||||
|
||||
1. Open the Webhooks V2 page: https://app.fireflies.ai/integrations/api/webhook
|
||||
2. Set the **Webhook URL** to your Twenty deployment's webhook endpoint:
|
||||
`https://<your-twenty-domain>/webhook/fireflies`. Twenty resolves the
|
||||
target workspace from the request's `Host` header, so the URL must match
|
||||
the workspace's public domain — `localhost` is not valid in the
|
||||
Fireflies UI. For local development, expose your dev server with a
|
||||
tunnel like `ngrok http 3000` and paste the HTTPS forwarding URL here,
|
||||
or skip the Fireflies UI entirely and POST a signed payload directly to
|
||||
your local endpoint (see [Local webhook testing](#local-webhook-testing)
|
||||
in the developer section below).
|
||||
3. Set a **Signing Secret** (a long random string — generate one with
|
||||
`openssl rand -hex 32`). Save it; you'll paste it into Twenty next.
|
||||
4. Under **Events**, subscribe to **both**:
|
||||
- **`meeting.transcribed`** — fires when the transcript is ready and
|
||||
writes it to the **Transcript** field.
|
||||
- **`meeting.summarized`** — fires once Fireflies finishes its AI summary
|
||||
and writes it to the **Summary** field.
|
||||
Subscribing to only one is fine if you don't want the other field
|
||||
populated; the app dispatches per event.
|
||||
5. **Save** the configuration.
|
||||
|
||||
### 3. Wire the credentials into Twenty
|
||||
|
||||
1. In Twenty: **Settings → Applications → Fireflies → Settings tab**.
|
||||
2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row.
|
||||
3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row.
|
||||
|
||||
After saving, the next time Fireflies finishes processing a recording, the
|
||||
transcript will land on the matching CalendarEvent within a few seconds;
|
||||
the summary follows once Fireflies finishes the AI summarization step
|
||||
(typically a minute or two later — Fireflies sends two separate webhooks).
|
||||
|
||||
---
|
||||
|
||||
## Why `transcript` / `summary` fields on `CalendarEvent` instead of a new object?
|
||||
|
||||
Storing the transcript and AI summary as rich-text fields directly on the
|
||||
existing `CalendarEvent`:
|
||||
|
||||
- Keeps everything about a meeting in one place (no joins)
|
||||
- Avoids inventing a new object that other call-recording apps would each
|
||||
need to coordinate on
|
||||
- Works today without lookup fields
|
||||
|
||||
If later integrations (Gong, Otter, Zoom AI, etc.) make one pair of fields
|
||||
too restrictive — for example, needing to distinguish *which* tool produced
|
||||
the transcript — we'll promote the fields to a platform-level concept rather
|
||||
than keep extending this app.
|
||||
|
||||
---
|
||||
|
||||
## Developers only
|
||||
|
||||
If you're working on this app rather than installing the published version:
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-fireflies
|
||||
|
||||
# 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 Fireflies GraphQL API is called directly via `fetch` — no `fireflies` SDK
|
||||
dependency. See `src/logic-functions/utils/fireflies-api-request.ts` for the
|
||||
auth + error-handling wrapper that all queries go through.
|
||||
|
||||
### Local webhook testing
|
||||
|
||||
Fireflies' Webhooks V2 UI only accepts a publicly reachable HTTPS URL, so
|
||||
pointing it at `http://localhost:*` directly is not possible. Two paths:
|
||||
|
||||
**End-to-end via tunnel.** Run a tunnel that fronts your local server with
|
||||
a public HTTPS URL (`ngrok http 3000`, `cloudflared tunnel`, etc.), paste
|
||||
the HTTPS forwarding URL into the Fireflies webhook UI as the **Webhook
|
||||
URL**, and exercise the integration by ending a real Fireflies meeting.
|
||||
|
||||
**Backend-only via signed `curl`.** Skip the Fireflies UI entirely and POST
|
||||
a signed payload straight to the local endpoint. The signature must be
|
||||
HMAC-SHA256 over the **raw** request body, keyed by your
|
||||
`FIREFLIES_WEBHOOK_SECRET`, prefixed with `sha256=`:
|
||||
|
||||
```bash
|
||||
export FIREFLIES_WEBHOOK_SECRET='<the secret you set in Twenty app settings>'
|
||||
|
||||
BODY='{"event":"meeting.transcribed","meeting_id":"<a-real-fireflies-transcript-id>"}'
|
||||
SIG=$(printf '%s' "$BODY" \
|
||||
| openssl dgst -sha256 -hmac "$FIREFLIES_WEBHOOK_SECRET" \
|
||||
| awk '{print $NF}')
|
||||
|
||||
curl -X POST http://localhost:3000/webhook/fireflies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "x-hub-signature: sha256=$SIG" \
|
||||
--data-binary "$BODY"
|
||||
```
|
||||
|
||||
`--data-binary` (not `--data`) is important: it preserves the bytes
|
||||
verbatim so the HMAC the server computes matches the one `openssl`
|
||||
computed above. Twenty resolves the workspace from the `Host` header, so
|
||||
the default dev workspace (mapped to `localhost:3000` in a standard
|
||||
`yarn start` setup) receives the request.
|
||||
|
||||
To match a real `CalendarEvent`, the transcript ID you pass must belong to
|
||||
a Fireflies call whose `calendar_id` / `cal_id` matches an existing
|
||||
`CalendarEvent.iCalUid` or `CalendarChannelEventAssociation.eventExternalId`
|
||||
in your local Twenty workspace. The easiest local seed is to manually
|
||||
insert a row with one of those identifiers and use a Fireflies transcript
|
||||
whose calendar fields point at it.
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "twenty-fireflies",
|
||||
"version": "0.1.0",
|
||||
"description": "Fireflies call-transcript 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-client-sdk": "2.4.0",
|
||||
"twenty-sdk": "2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,44 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
>
|
||||
<title>Fireflies</title>
|
||||
<defs>
|
||||
<linearGradient id="fireflies-tl" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#8E2A8B" />
|
||||
<stop offset="100%" stop-color="#D02D7E" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fireflies-tr" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#C2287D" />
|
||||
<stop offset="100%" stop-color="#E83C8E" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fireflies-mid" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#B0297B" />
|
||||
<stop offset="100%" stop-color="#D9357F" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fireflies-bl" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7C1F6E" />
|
||||
<stop offset="100%" stop-color="#C8327F" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#fireflies-tl)"
|
||||
d="M4 12a8 8 0 0 1 8-8h14v22H4V12z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#fireflies-tr)"
|
||||
d="M30 4h22a8 8 0 0 1 8 8v14H30V4z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#fireflies-mid)"
|
||||
d="M30 30h18v18H30V30z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#fireflies-bl)"
|
||||
d="M4 30h22v22a8 8 0 0 1-8 8H4V30z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,43 +0,0 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import { ABOUT_DESCRIPTION } from 'src/constants/ABOUT_DESCRIPTION.md';
|
||||
import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Twenty Fireflies',
|
||||
description:
|
||||
'Sync Fireflies call transcripts and AI summaries onto matching CalendarEvent records in Twenty, and trigger sync / list / search of Fireflies calls from workflows and the AI chat.',
|
||||
logoUrl: 'public/twenty-fireflies.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
aboutDescription: ABOUT_DESCRIPTION,
|
||||
screenshots: [
|
||||
'public/gallery/transcript-on-calendar-event.png',
|
||||
'public/gallery/summary-on-calendar-event.png',
|
||||
'public/gallery/workflow-builder-actions.png',
|
||||
'public/gallery/app-settings.png',
|
||||
],
|
||||
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: {
|
||||
FIREFLIES_API_KEY: {
|
||||
universalIdentifier: FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
description:
|
||||
'API key from Fireflies (Integrations → Fireflies API → Generate). Used as a Bearer token against https://api.fireflies.ai/graphql to fetch full transcript content after a webhook fires.',
|
||||
isSecret: true,
|
||||
},
|
||||
FIREFLIES_WEBHOOK_SECRET: {
|
||||
universalIdentifier: FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
|
||||
description:
|
||||
'Signing secret for verifying Fireflies Webhooks V2 payloads (sent in the X-Hub-Signature header as sha256=<hex-hmac-sha256-of-body>). Configure the same value on the Fireflies V2 webhook setup page.',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
export const ABOUT_DESCRIPTION = `Bring your Fireflies meeting recordings into Twenty. When Fireflies finishes processing a call, the transcript and AI summary land on the matching CalendarEvent automatically — no copy-pasting, no extra tabs.
|
||||
|
||||
## What gets added to your workspace
|
||||
|
||||
Two new fields appear on the standard **CalendarEvent** object:
|
||||
|
||||
- **Transcript** — speaker-attributed rich text, e.g. *"**Sarah:** Hi there"* followed by *"**John:** Doing well, thanks"*.
|
||||
- **Summary** — Fireflies' AI-generated overview of the meeting (key points, action items, decisions).
|
||||
|
||||
Both update in real time through a Fireflies Webhooks V2 subscription.
|
||||
|
||||
## How it syncs
|
||||
|
||||
The connector subscribes to two Fireflies V2 events:
|
||||
|
||||
- \`meeting.transcribed\` writes the **Transcript** field.
|
||||
- \`meeting.summarized\` writes the **Summary** field.
|
||||
|
||||
Each webhook delivery is HMAC-SHA256 verified against your signing secret before anything touches your data.
|
||||
|
||||
## How calls are matched to CalendarEvents
|
||||
|
||||
The matcher uses provider-native identifiers — never fuzzy URL matching — so transcripts always land on the right event:
|
||||
|
||||
1. **Provider event ID** — Fireflies' \`calendar_id\` / \`calendar_event_uid\` against \`CalendarChannelEventAssociation.eventExternalId\`. Covers events synced from Google Calendar, including individual instances of recurring meetings.
|
||||
2. **iCalUID** — Fireflies' \`calendar_id\` against \`CalendarEvent.iCalUid\`. Covers events synced from Outlook / CalDAV.
|
||||
|
||||
Both identifiers are populated automatically when calendars are synced into Twenty. If a recording can't be matched (orphan recording, no calendar sync configured), the webhook reports a clear skip reason and writes nothing.
|
||||
|
||||
## Tools for workflows and the AI chat
|
||||
|
||||
Beyond the automatic sync, three Fireflies tools become available in **workflows** and the **AI chat**:
|
||||
|
||||
- **Sync Fireflies Call** — Pull a single Fireflies call onto its CalendarEvent on demand. Useful for backfilling history or recovering from a missed webhook. Same matching rules as the webhook.
|
||||
- **Search Fireflies Calls** — Keyword search across **both** meeting titles and the words spoken during meetings. Ask the AI chat *"find any call where we discussed pricing"* and it returns matching calls with titles, dates, participants, and transcript links.
|
||||
- **List Fireflies Calls By Participant** — List every call a given email address attended. Great as the first step of a workflow triggered when a Person record is created, or to answer *"what calls have we had with this contact?"* from the AI chat.
|
||||
|
||||
## Installing
|
||||
|
||||
1. Open **Settings → Applications** in your Twenty workspace.
|
||||
2. Find **Twenty Fireflies** in the available apps and click **Install**.
|
||||
|
||||
Then your admin completes the one-time wiring (see below).
|
||||
|
||||
## One-time setup (admin)
|
||||
|
||||
1. Generate an API key at [Fireflies → Integrations → Fireflies API](https://app.fireflies.ai/settings/developer-settings) and paste it into the **FIREFLIES_API_KEY** application variable.
|
||||
2. Generate a long random string (\`openssl rand -hex 32\`). Paste it into the **FIREFLIES_WEBHOOK_SECRET** application variable.
|
||||
3. Configure a Webhooks V2 endpoint at [Fireflies → Integrations → Webhooks V2](https://app.fireflies.ai/integrations/api/webhook):
|
||||
- **Webhook URL**: \`https://<your-twenty-domain>/webhook/fireflies\`
|
||||
- **Signing Secret**: the same value as \`FIREFLIES_WEBHOOK_SECRET\`
|
||||
- **Events**: subscribe to \`meeting.transcribed\` (required) and \`meeting.summarized\` (optional, for AI summaries)
|
||||
|
||||
That's it — the next call Fireflies processes will start syncing automatically.
|
||||
|
||||
## Limitations
|
||||
|
||||
What this connector intentionally does **not** support in v1:
|
||||
|
||||
- **Orphan calls** (recordings with no matching CalendarEvent in Twenty) are skipped — fuzzy URL matching is avoided so transcripts never land on the wrong event.
|
||||
- **Per-user Fireflies accounts** — all sync goes through one workspace-shared API key set by the admin.
|
||||
- **Editing transcripts in Twenty** — the field is writable in principle, but future Fireflies syncs will overwrite manual edits.
|
||||
- **Speaker analytics, sentiment, action items as structured fields** — only raw transcript and summary text are synced; structured insights stay in the Fireflies dashboard.
|
||||
`;
|
||||
@@ -1,29 +0,0 @@
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'97d24431-ebc7-4156-9705-b6900e73edc8';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'ed0a61a4-7638-4fd8-a2c1-982b50aca4ff';
|
||||
|
||||
export const FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER =
|
||||
'876ebb9f-0fb1-48b3-bd6f-f4138f999ba7';
|
||||
|
||||
export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER =
|
||||
'a1ef34d9-f0e7-483e-9909-fce2757bdd23';
|
||||
|
||||
export const TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'e444c83b-0d28-41e2-8c3f-4ede9eb88a75';
|
||||
|
||||
export const SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'c5f8a3e2-7b91-4d2e-b6a4-9f3e1d5c8a02';
|
||||
|
||||
export const FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
|
||||
'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e';
|
||||
|
||||
export const FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER =
|
||||
'c715a476-929e-4e5b-b692-6350fc0adb73';
|
||||
|
||||
export const FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER =
|
||||
'3148f5c7-8bcb-48d3-a7b9-aa811608e256';
|
||||
|
||||
export const FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER =
|
||||
'cb2dd01d-8dca-4222-acce-1d1dbdef9146';
|
||||
@@ -1,53 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { firefliesListCallsByParticipantHandler } from 'src/logic-functions/handlers/fireflies-list-calls-by-participant-handler';
|
||||
import { firefliesListCallsByParticipantInputSchema } from 'src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema';
|
||||
|
||||
const callSummaryProperties = {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
durationMinutes: { type: 'number' },
|
||||
participants: { type: 'array', items: { type: 'string' } },
|
||||
hostEmail: { type: 'string' },
|
||||
transcriptUrl: { type: 'string' },
|
||||
meetingLink: { type: 'string' },
|
||||
} as const;
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'fireflies-list-calls-by-participant',
|
||||
description:
|
||||
'List Fireflies calls that include a given participant email. Returns each call\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and original meeting link. Use this to answer "what calls have we had with this contact?" — for example as the first step of a workflow triggered when a Person record is created.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: firefliesListCallsByParticipantInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'List Fireflies Calls By Participant',
|
||||
inputSchema: jsonSchemaToInputSchema(
|
||||
firefliesListCallsByParticipantInputSchema,
|
||||
),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
calls: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: callSummaryProperties,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: firefliesListCallsByParticipantHandler,
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { firefliesSearchCallsHandler } from 'src/logic-functions/handlers/fireflies-search-calls-handler';
|
||||
import { firefliesSearchCallsInputSchema } from 'src/logic-functions/schemas/fireflies-search-calls-input.schema';
|
||||
|
||||
const callSummaryProperties = {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
date: { type: 'string' },
|
||||
durationMinutes: { type: 'number' },
|
||||
participants: { type: 'array', items: { type: 'string' } },
|
||||
hostEmail: { type: 'string' },
|
||||
transcriptUrl: { type: 'string' },
|
||||
meetingLink: { type: 'string' },
|
||||
} as const;
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'fireflies-search-calls',
|
||||
description:
|
||||
'Search Fireflies calls by keyword. Matches the keyword against both meeting titles and the words spoken during meetings (full transcript content). Returns each match\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and meeting link. Use this for AI-chat questions like "find any call where we discussed pricing".',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: firefliesSearchCallsInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Search Fireflies Calls',
|
||||
inputSchema: jsonSchemaToInputSchema(firefliesSearchCallsInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
calls: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: callSummaryProperties,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: firefliesSearchCallsHandler,
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { firefliesSyncCallHandler } from 'src/logic-functions/handlers/fireflies-sync-call-handler';
|
||||
import { firefliesSyncCallInputSchema } from 'src/logic-functions/schemas/fireflies-sync-call-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'fireflies-sync-call',
|
||||
description:
|
||||
'Sync a single Fireflies call onto its matching CalendarEvent on demand: fetches both transcript and AI summary from Fireflies and writes them to the Transcript and Summary fields. Same matching rules as the webhook (Fireflies calendar_id / cal_id ↔ Twenty eventExternalId or iCalUid). Useful for backfilling history, recovering from a missed webhook, or syncing on a workflow trigger instead of waiting for Fireflies to push.',
|
||||
timeoutSeconds: 60,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: firefliesSyncCallInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Sync Fireflies Call',
|
||||
inputSchema: jsonSchemaToInputSchema(firefliesSyncCallInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
transcriptId: { type: 'string' },
|
||||
calendarEventId: { type: 'string' },
|
||||
updatedFields: { type: 'array', items: { type: 'string' } },
|
||||
fieldOutcomes: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field: { type: 'string' },
|
||||
status: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: firefliesSyncCallHandler,
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { firefliesWebhookHandler } from 'src/logic-functions/handlers/fireflies-webhook-handler';
|
||||
import {
|
||||
type FirefliesWebhookPayload,
|
||||
type FirefliesWebhookResult,
|
||||
} from 'src/logic-functions/types/fireflies-webhook-payload.type';
|
||||
import { getFirefliesWebhookSecret } from 'src/logic-functions/utils/get-fireflies-webhook-secret';
|
||||
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
|
||||
|
||||
const firefliesWebhookRouteHandler = async (
|
||||
routePayload: RoutePayload<FirefliesWebhookPayload>,
|
||||
): Promise<FirefliesWebhookResult> => {
|
||||
const secretResult = getFirefliesWebhookSecret();
|
||||
|
||||
if (!secretResult.success) {
|
||||
return { error: secretResult.error };
|
||||
}
|
||||
|
||||
const { rawBody } = routePayload;
|
||||
|
||||
if (!isDefined(rawBody)) {
|
||||
return {
|
||||
error:
|
||||
'Invalid webhook signature: raw request body was not forwarded by the server, cannot verify HMAC',
|
||||
};
|
||||
}
|
||||
|
||||
const signatureHeader = routePayload.headers['x-hub-signature'];
|
||||
|
||||
const signatureCheck = verifyFirefliesWebhookSignature({
|
||||
rawBody,
|
||||
signatureHeader,
|
||||
secret: secretResult.secret,
|
||||
});
|
||||
|
||||
if (!signatureCheck.valid) {
|
||||
return { error: `Invalid webhook signature: ${signatureCheck.error}` };
|
||||
}
|
||||
|
||||
const body = routePayload.body;
|
||||
|
||||
if (!isDefined(body)) {
|
||||
return { error: 'Webhook payload was empty' };
|
||||
}
|
||||
|
||||
return firefliesWebhookHandler({
|
||||
meetingId: body.meeting_id,
|
||||
eventType: body.event,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'fireflies-webhook',
|
||||
description:
|
||||
'Receives Fireflies webhook events when a transcript is ready, then writes the transcript onto the matching CalendarEvent.',
|
||||
timeoutSeconds: 60,
|
||||
handler: firefliesWebhookRouteHandler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/webhook/fireflies',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: ['x-hub-signature'],
|
||||
},
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
|
||||
import { type FirefliesListCallsByParticipantInput } from 'src/logic-functions/types/fireflies-list-calls-by-participant-input.type';
|
||||
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
|
||||
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
const clampLimit = (limit: number | undefined): number => {
|
||||
if (!Number.isFinite(limit) || limit === undefined) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
|
||||
};
|
||||
|
||||
export const firefliesListCallsByParticipantHandler = async (
|
||||
parameters: FirefliesListCallsByParticipantInput,
|
||||
): Promise<FirefliesCallListResult> => {
|
||||
const participantEmail = parameters.participantEmail?.trim();
|
||||
|
||||
if (!isNonEmptyString(participantEmail)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to list Fireflies calls',
|
||||
error: '`participantEmail` is required.',
|
||||
};
|
||||
}
|
||||
|
||||
const apiKeyResult = getFirefliesApiKey();
|
||||
|
||||
if (!apiKeyResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Fireflies is not configured',
|
||||
error: apiKeyResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await listFirefliesTranscripts({
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
participants: [participantEmail],
|
||||
limit: clampLimit(parameters.limit),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to list Fireflies calls',
|
||||
error: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${result.data.length} Fireflies call(s) with ${participantEmail}.`,
|
||||
calls: result.data,
|
||||
count: result.data.length,
|
||||
};
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
|
||||
import { type FirefliesSearchCallsInput } from 'src/logic-functions/types/fireflies-search-calls-input.type';
|
||||
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
|
||||
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
const clampLimit = (limit: number | undefined): number => {
|
||||
if (!Number.isFinite(limit) || limit === undefined) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
|
||||
};
|
||||
|
||||
export const firefliesSearchCallsHandler = async (
|
||||
parameters: FirefliesSearchCallsInput,
|
||||
): Promise<FirefliesCallListResult> => {
|
||||
const keyword = parameters.keyword?.trim();
|
||||
|
||||
if (!isNonEmptyString(keyword)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to search Fireflies calls',
|
||||
error: '`keyword` is required.',
|
||||
};
|
||||
}
|
||||
|
||||
const apiKeyResult = getFirefliesApiKey();
|
||||
|
||||
if (!apiKeyResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Fireflies is not configured',
|
||||
error: apiKeyResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await listFirefliesTranscripts({
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
keyword,
|
||||
keywordScope: 'all',
|
||||
limit: clampLimit(parameters.limit),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to search Fireflies calls',
|
||||
error: result.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Found ${result.data.length} Fireflies call(s) matching "${keyword}".`,
|
||||
calls: result.data,
|
||||
count: result.data.length,
|
||||
};
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { type FirefliesSyncCallInput } from 'src/logic-functions/types/fireflies-sync-call-input.type';
|
||||
import {
|
||||
type FirefliesSyncCallFieldOutcome,
|
||||
type FirefliesSyncCallResult,
|
||||
} from 'src/logic-functions/types/fireflies-sync-call-result.type';
|
||||
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
|
||||
import {
|
||||
type FirefliesSyncableField,
|
||||
syncFirefliesFieldToCalendarEvent,
|
||||
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
|
||||
|
||||
const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary'];
|
||||
|
||||
const buildFailure = (
|
||||
message: string,
|
||||
error: string,
|
||||
transcriptId?: string,
|
||||
): FirefliesSyncCallResult => ({
|
||||
success: false,
|
||||
message,
|
||||
error,
|
||||
transcriptId,
|
||||
});
|
||||
|
||||
export const firefliesSyncCallHandler = async (
|
||||
parameters: FirefliesSyncCallInput,
|
||||
): Promise<FirefliesSyncCallResult> => {
|
||||
const transcriptId = parameters.transcriptId?.trim();
|
||||
|
||||
if (!isNonEmptyString(transcriptId)) {
|
||||
return buildFailure(
|
||||
'Failed to sync Fireflies call',
|
||||
'`transcriptId` is required.',
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeyResult = getFirefliesApiKey();
|
||||
|
||||
if (!apiKeyResult.success) {
|
||||
return buildFailure(
|
||||
'Fireflies is not configured',
|
||||
apiKeyResult.error,
|
||||
transcriptId,
|
||||
);
|
||||
}
|
||||
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const results = await Promise.all(
|
||||
ALL_FIELDS.map((field) =>
|
||||
syncFirefliesFieldToCalendarEvent({
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
client,
|
||||
transcriptId,
|
||||
field,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map(
|
||||
(result) => {
|
||||
if (result.status === 'skipped') {
|
||||
return {
|
||||
field: result.field,
|
||||
status: 'skipped',
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
if (result.status === 'error') {
|
||||
return { field: result.field, status: 'error', error: result.error };
|
||||
}
|
||||
return { field: result.field, status: 'updated' };
|
||||
},
|
||||
);
|
||||
|
||||
const updatedFields = fieldOutcomes
|
||||
.filter((outcome) => outcome.status === 'updated')
|
||||
.map((outcome) => outcome.field);
|
||||
|
||||
const calendarEventId = results.find(
|
||||
(result) => result.status === 'updated',
|
||||
)?.calendarEventId;
|
||||
|
||||
if (updatedFields.length === 0) {
|
||||
const skipReasons = fieldOutcomes
|
||||
.filter((outcome) => outcome.status === 'skipped')
|
||||
.map((outcome) => `${outcome.field}: ${outcome.reason}`);
|
||||
const errors = fieldOutcomes
|
||||
.filter((outcome) => outcome.status === 'error')
|
||||
.map((outcome) => `${outcome.field}: ${outcome.error}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `No fields were updated on the matching CalendarEvent for Fireflies transcript ${transcriptId}.`,
|
||||
error: [...errors, ...skipReasons].join(' | ') || 'No fields updated.',
|
||||
transcriptId,
|
||||
fieldOutcomes,
|
||||
};
|
||||
}
|
||||
|
||||
const partialFailures = fieldOutcomes.filter(
|
||||
(outcome) => outcome.status === 'error',
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
partialFailures.length > 0
|
||||
? `Synced ${updatedFields.join(
|
||||
' + ',
|
||||
)} for Fireflies transcript ${transcriptId} (with errors on ${partialFailures
|
||||
.map((outcome) => outcome.field)
|
||||
.join(', ')}).`
|
||||
: `Synced ${updatedFields.join(
|
||||
' + ',
|
||||
)} for Fireflies transcript ${transcriptId}.`,
|
||||
transcriptId,
|
||||
calendarEventId,
|
||||
updatedFields,
|
||||
fieldOutcomes,
|
||||
};
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { type FirefliesWebhookResult } from 'src/logic-functions/types/fireflies-webhook-payload.type';
|
||||
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
|
||||
import {
|
||||
type FirefliesSyncableField,
|
||||
syncFirefliesFieldToCalendarEvent,
|
||||
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
|
||||
|
||||
const TRANSCRIPT_READY_EVENT = 'meeting.transcribed';
|
||||
const SUMMARY_READY_EVENT = 'meeting.summarized';
|
||||
|
||||
const FIELD_BY_EVENT: Record<string, FirefliesSyncableField> = {
|
||||
[TRANSCRIPT_READY_EVENT]: 'transcript',
|
||||
[SUMMARY_READY_EVENT]: 'summary',
|
||||
};
|
||||
|
||||
export const firefliesWebhookHandler = async ({
|
||||
meetingId,
|
||||
eventType,
|
||||
}: {
|
||||
meetingId?: string | null;
|
||||
eventType?: string | null;
|
||||
}): Promise<FirefliesWebhookResult> => {
|
||||
if (!isNonEmptyString(meetingId)) {
|
||||
return { error: 'Webhook payload is missing meetingId' };
|
||||
}
|
||||
|
||||
const field = isNonEmptyString(eventType)
|
||||
? FIELD_BY_EVENT[eventType]
|
||||
: undefined;
|
||||
|
||||
if (field === undefined) {
|
||||
return {
|
||||
skipped: true,
|
||||
reason: `Unsupported Fireflies Webhooks V2 event "${
|
||||
eventType ?? '<missing>'
|
||||
}"; expected "${TRANSCRIPT_READY_EVENT}" or "${SUMMARY_READY_EVENT}"`,
|
||||
meetingId,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKeyResult = getFirefliesApiKey();
|
||||
|
||||
if (!apiKeyResult.success) {
|
||||
return { error: apiKeyResult.error, meetingId };
|
||||
}
|
||||
|
||||
const syncResult = await syncFirefliesFieldToCalendarEvent({
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
client: new CoreApiClient(),
|
||||
transcriptId: meetingId,
|
||||
field,
|
||||
});
|
||||
|
||||
if (syncResult.status === 'error') {
|
||||
return { error: syncResult.error, meetingId };
|
||||
}
|
||||
|
||||
if (syncResult.status === 'skipped') {
|
||||
return { skipped: true, reason: syncResult.reason, meetingId };
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'updated',
|
||||
field: syncResult.field,
|
||||
calendarEventId: syncResult.calendarEventId,
|
||||
meetingId,
|
||||
};
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const firefliesListCallsByParticipantInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
participantEmail: {
|
||||
type: 'string',
|
||||
label: 'Participant email',
|
||||
description:
|
||||
'Email address of a meeting attendee. Returns Fireflies calls where this email appears in the participants list (case-insensitive match performed by Fireflies). Useful to answer "what calls have we had with this contact?" before reaching out to them.',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
label: 'Maximum number of calls',
|
||||
description:
|
||||
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
},
|
||||
},
|
||||
required: ['participantEmail'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const firefliesSearchCallsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
label: 'Keyword to search for',
|
||||
description:
|
||||
'Keyword or phrase to search across Fireflies meetings. Matches against both meeting titles and the words spoken during meetings. Useful for finding "the call where we discussed pricing" or "any meeting that mentioned the new integration".',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
label: 'Maximum number of calls',
|
||||
description:
|
||||
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
},
|
||||
},
|
||||
required: ['keyword'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const firefliesSyncCallInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
transcriptId: {
|
||||
type: 'string',
|
||||
label: 'Fireflies call ID',
|
||||
description:
|
||||
'The ID of the Fireflies call to sync (also referred to as the "transcript ID" in Fireflies\' API and docs). Found at the end of the Fireflies meeting URL (`https://app.fireflies.ai/view/<id>`) or in the `meeting_id` field of a Fireflies webhook payload. Runs the same pipeline as the webhook: fetches transcript + AI summary from Fireflies and writes both onto the matching CalendarEvent.',
|
||||
},
|
||||
},
|
||||
required: ['transcriptId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
export type FirefliesCallSummary = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
date: string | null;
|
||||
durationMinutes: number | null;
|
||||
participants: string[];
|
||||
hostEmail: string | null;
|
||||
transcriptUrl: string | null;
|
||||
meetingLink: string | null;
|
||||
};
|
||||
|
||||
export type FirefliesCallListResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
calls?: FirefliesCallSummary[];
|
||||
count?: number;
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export type FirefliesListCallsByParticipantInput = {
|
||||
participantEmail: string;
|
||||
limit?: number;
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export type FirefliesSearchCallsInput = {
|
||||
keyword: string;
|
||||
limit?: number;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export type FirefliesSyncCallInput = {
|
||||
transcriptId: string;
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { type FirefliesSyncableField } from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
|
||||
|
||||
export type FirefliesSyncCallFieldOutcome =
|
||||
| { field: FirefliesSyncableField; status: 'updated' }
|
||||
| { field: FirefliesSyncableField; status: 'skipped'; reason: string }
|
||||
| { field: FirefliesSyncableField; status: 'error'; error: string };
|
||||
|
||||
export type FirefliesSyncCallResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
transcriptId?: string;
|
||||
calendarEventId?: string;
|
||||
updatedFields?: FirefliesSyncableField[];
|
||||
fieldOutcomes?: FirefliesSyncCallFieldOutcome[];
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
export type FirefliesTranscriptSentence = {
|
||||
speaker_name: string | null;
|
||||
text: string;
|
||||
start_time: number | null;
|
||||
};
|
||||
|
||||
export type FirefliesSummary = {
|
||||
overview: string | null;
|
||||
action_items: string | null;
|
||||
keywords: string[] | null;
|
||||
topics_discussed: string[] | null;
|
||||
short_summary: string | null;
|
||||
};
|
||||
|
||||
export type FirefliesTranscript = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
duration: number | null;
|
||||
meeting_link: string | null;
|
||||
participants: string[];
|
||||
organizer_email: string | null;
|
||||
host_email?: string | null;
|
||||
date?: number | null;
|
||||
transcript_url?: string | null;
|
||||
sentences?: FirefliesTranscriptSentence[] | null;
|
||||
summary?: FirefliesSummary | null;
|
||||
calendar_id?: string | null;
|
||||
cal_id?: string | null;
|
||||
calendar_type?: string | null;
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
export type FirefliesWebhookPayload = {
|
||||
event: string;
|
||||
meeting_id: string;
|
||||
timestamp?: number;
|
||||
client_reference_id?: string | null;
|
||||
};
|
||||
|
||||
export type FirefliesSyncedField = 'transcript' | 'summary';
|
||||
|
||||
export type FirefliesWebhookResult =
|
||||
| {
|
||||
action: 'updated';
|
||||
field: FirefliesSyncedField;
|
||||
calendarEventId: string;
|
||||
meetingId: string;
|
||||
}
|
||||
| { skipped: true; reason: string; meetingId?: string }
|
||||
| { error: string; meetingId?: string };
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type FirefliesSummary,
|
||||
type FirefliesTranscript,
|
||||
} from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
|
||||
|
||||
const buildTranscript = (
|
||||
summary: FirefliesSummary | null | undefined,
|
||||
): FirefliesTranscript => ({
|
||||
id: 'abc',
|
||||
title: 'Test call',
|
||||
duration: 30,
|
||||
meeting_link: 'https://zoom.us/j/1234',
|
||||
participants: ['a@example.com'],
|
||||
organizer_email: 'a@example.com',
|
||||
summary,
|
||||
});
|
||||
|
||||
describe('formatSummaryAsMarkdown', () => {
|
||||
it('returns a placeholder when summary is undefined', () => {
|
||||
const result = formatSummaryAsMarkdown(buildTranscript(undefined));
|
||||
|
||||
expect(result).toContain('Fireflies returned no summary content');
|
||||
});
|
||||
|
||||
it('returns a placeholder when summary is null', () => {
|
||||
const result = formatSummaryAsMarkdown(buildTranscript(null));
|
||||
|
||||
expect(result).toContain('Fireflies returned no summary content');
|
||||
});
|
||||
|
||||
it('returns a placeholder when every summary section is empty', () => {
|
||||
const result = formatSummaryAsMarkdown(
|
||||
buildTranscript({
|
||||
overview: '',
|
||||
action_items: ' ',
|
||||
keywords: null,
|
||||
topics_discussed: null,
|
||||
short_summary: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toContain('Fireflies returned no summary content');
|
||||
});
|
||||
|
||||
it('renders overview, action items, topics and keywords in order with headers', () => {
|
||||
const result = formatSummaryAsMarkdown(
|
||||
buildTranscript({
|
||||
overview: '- **Item 1:** First point',
|
||||
action_items: '**Abdul**\nDo something (00:10)',
|
||||
keywords: ['Twenty', 'Fireflies'],
|
||||
topics_discussed: ['Integration', 'Roadmap'],
|
||||
short_summary: 'irrelevant',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
[
|
||||
'## Overview',
|
||||
'',
|
||||
'- **Item 1:** First point',
|
||||
'',
|
||||
'## Action items',
|
||||
'',
|
||||
'**Abdul**\nDo something (00:10)',
|
||||
'',
|
||||
'## Topics discussed',
|
||||
'',
|
||||
'Integration, Roadmap',
|
||||
'',
|
||||
'## Keywords',
|
||||
'',
|
||||
'Twenty, Fireflies',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits sections that are empty', () => {
|
||||
const result = formatSummaryAsMarkdown(
|
||||
buildTranscript({
|
||||
overview: 'Some overview',
|
||||
action_items: null,
|
||||
keywords: [],
|
||||
topics_discussed: null,
|
||||
short_summary: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe('## Overview\n\nSome overview');
|
||||
});
|
||||
|
||||
it('trims overview and action_items whitespace', () => {
|
||||
const result = formatSummaryAsMarkdown(
|
||||
buildTranscript({
|
||||
overview: ' \n Overview body \n ',
|
||||
action_items: '\n**A**\nDo it\n',
|
||||
keywords: null,
|
||||
topics_discussed: null,
|
||||
short_summary: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
'## Overview\n\nOverview body\n\n## Action items\n\n**A**\nDo it',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
|
||||
|
||||
const buildTranscript = (
|
||||
sentences: FirefliesTranscript['sentences'],
|
||||
): FirefliesTranscript => ({
|
||||
id: 'abc',
|
||||
title: 'Test call',
|
||||
date: 1700000000000,
|
||||
duration: 30,
|
||||
meeting_link: 'https://zoom.us/j/1234',
|
||||
participants: ['a@example.com'],
|
||||
organizer_email: 'a@example.com',
|
||||
sentences,
|
||||
});
|
||||
|
||||
describe('formatTranscriptAsMarkdown', () => {
|
||||
it('returns a placeholder when there are no sentences', () => {
|
||||
const result = formatTranscriptAsMarkdown(buildTranscript([]));
|
||||
|
||||
expect(result).toContain('Fireflies returned no transcript content');
|
||||
});
|
||||
|
||||
it('returns a placeholder when sentences is null', () => {
|
||||
const result = formatTranscriptAsMarkdown(buildTranscript(null));
|
||||
|
||||
expect(result).toContain('Fireflies returned no transcript content');
|
||||
});
|
||||
|
||||
it('groups consecutive sentences from the same speaker into one paragraph', () => {
|
||||
const result = formatTranscriptAsMarkdown(
|
||||
buildTranscript([
|
||||
{ speaker_name: 'Sarah', text: 'Hi there', start_time: 0 },
|
||||
{ speaker_name: 'Sarah', text: 'How are you?', start_time: 1 },
|
||||
{ speaker_name: 'John', text: 'Doing well, thanks.', start_time: 2 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
'**Sarah:** Hi there How are you?\n\n**John:** Doing well, thanks.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to "Speaker" when the speaker name is missing', () => {
|
||||
const result = formatTranscriptAsMarkdown(
|
||||
buildTranscript([{ speaker_name: null, text: 'Hello', start_time: 0 }]),
|
||||
);
|
||||
|
||||
expect(result).toBe('**Speaker:** Hello');
|
||||
});
|
||||
|
||||
it('falls back to "Speaker" when the speaker name is whitespace-only', () => {
|
||||
const result = formatTranscriptAsMarkdown(
|
||||
buildTranscript([
|
||||
{ speaker_name: ' ', text: 'Hello', start_time: 0 },
|
||||
{ speaker_name: '\n\t', text: 'World', start_time: 1 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toBe('**Speaker:** Hello World');
|
||||
});
|
||||
|
||||
it('skips empty sentence text', () => {
|
||||
const result = formatTranscriptAsMarkdown(
|
||||
buildTranscript([
|
||||
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
|
||||
{ speaker_name: 'Sarah', text: 'Hello', start_time: 1 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toBe('**Sarah:** Hello');
|
||||
});
|
||||
|
||||
it('returns the placeholder when every sentence is whitespace-only', () => {
|
||||
const result = formatTranscriptAsMarkdown(
|
||||
buildTranscript([
|
||||
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
|
||||
{ speaker_name: 'John', text: '\n\t', start_time: 1 },
|
||||
{ speaker_name: null, text: '', start_time: 2 },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result).toContain('Fireflies returned no transcript content');
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
|
||||
|
||||
const SECRET = 'test-secret-abc123';
|
||||
|
||||
const sign = (body: string): string =>
|
||||
createHmac('sha256', SECRET).update(body, 'utf8').digest('hex');
|
||||
|
||||
describe('verifyFirefliesWebhookSignature', () => {
|
||||
it('accepts a valid bare-hex signature', () => {
|
||||
const body = JSON.stringify({ meetingId: 'm1' });
|
||||
const signature = sign(body);
|
||||
|
||||
const result = verifyFirefliesWebhookSignature({
|
||||
rawBody: body,
|
||||
signatureHeader: signature,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('accepts a valid signature with sha256= prefix', () => {
|
||||
const body = JSON.stringify({ meetingId: 'm1' });
|
||||
const signature = `sha256=${sign(body)}`;
|
||||
|
||||
const result = verifyFirefliesWebhookSignature({
|
||||
rawBody: body,
|
||||
signatureHeader: signature,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('rejects when signature header is missing', () => {
|
||||
const result = verifyFirefliesWebhookSignature({
|
||||
rawBody: '{}',
|
||||
signatureHeader: undefined,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
error: 'Missing x-hub-signature header',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when signature header is empty', () => {
|
||||
const result = verifyFirefliesWebhookSignature({
|
||||
rawBody: '{}',
|
||||
signatureHeader: '',
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: false,
|
||||
error: 'Missing x-hub-signature header',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the signature was computed from a different body', () => {
|
||||
const signature = sign(JSON.stringify({ meetingId: 'm1' }));
|
||||
|
||||
const result = verifyFirefliesWebhookSignature({
|
||||
rawBody: JSON.stringify({ meetingId: 'tampered' }),
|
||||
signatureHeader: signature,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the signature was computed with a different secret', () => {
|
||||
const body = JSON.stringify({ meetingId: 'm1' });
|
||||
const signature = createHmac('sha256', 'other-secret')
|
||||
.update(body, 'utf8')
|
||||
.digest('hex');
|
||||
|
||||
const result = verifyFirefliesWebhookSignature({
|
||||
rawBody: body,
|
||||
signatureHeader: signature,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed signature strings', () => {
|
||||
const result = verifyFirefliesWebhookSignature({
|
||||
rawBody: '{}',
|
||||
signatureHeader: 'not-a-real-signature',
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
import {
|
||||
firefliesApiRequest,
|
||||
type FirefliesApiResult,
|
||||
} from 'src/logic-functions/utils/fireflies-api-request';
|
||||
|
||||
const SUMMARY_QUERY = `
|
||||
query Transcript($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
duration
|
||||
meeting_link
|
||||
participants
|
||||
organizer_email
|
||||
calendar_id
|
||||
cal_id
|
||||
calendar_type
|
||||
summary {
|
||||
overview
|
||||
action_items
|
||||
keywords
|
||||
topics_discussed
|
||||
short_summary
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type FirefliesSummaryResponse = {
|
||||
transcript: FirefliesTranscript | null;
|
||||
};
|
||||
|
||||
export const fetchFirefliesSummary = async ({
|
||||
apiKey,
|
||||
transcriptId,
|
||||
}: {
|
||||
apiKey: string;
|
||||
transcriptId: string;
|
||||
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
|
||||
const result = await firefliesApiRequest<FirefliesSummaryResponse>({
|
||||
apiKey,
|
||||
query: SUMMARY_QUERY,
|
||||
variables: { transcriptId },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.data.transcript === null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, status: result.status, data: result.data.transcript };
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
import {
|
||||
firefliesApiRequest,
|
||||
type FirefliesApiResult,
|
||||
} from 'src/logic-functions/utils/fireflies-api-request';
|
||||
|
||||
const TRANSCRIPT_QUERY = `
|
||||
query Transcript($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
duration
|
||||
meeting_link
|
||||
participants
|
||||
organizer_email
|
||||
calendar_id
|
||||
cal_id
|
||||
calendar_type
|
||||
sentences {
|
||||
speaker_name
|
||||
text
|
||||
start_time
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type FirefliesTranscriptResponse = {
|
||||
transcript: FirefliesTranscript | null;
|
||||
};
|
||||
|
||||
export const fetchFirefliesTranscript = async ({
|
||||
apiKey,
|
||||
transcriptId,
|
||||
}: {
|
||||
apiKey: string;
|
||||
transcriptId: string;
|
||||
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
|
||||
const result = await firefliesApiRequest<FirefliesTranscriptResponse>({
|
||||
apiKey,
|
||||
query: TRANSCRIPT_QUERY,
|
||||
variables: { transcriptId },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.data.transcript === null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, status: result.status, data: result.data.transcript };
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
|
||||
type CalendarChannelEventAssociationsConnection = {
|
||||
edges: Array<{ node: { calendarEventId: string } }>;
|
||||
};
|
||||
|
||||
type CalendarEventsConnection = {
|
||||
edges: Array<{ node: { id: string } }>;
|
||||
};
|
||||
|
||||
const findByEventExternalId = async (
|
||||
client: CoreApiClient,
|
||||
externalIds: string[],
|
||||
): Promise<string | null> => {
|
||||
if (externalIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { calendarChannelEventAssociations } = await client.query({
|
||||
calendarChannelEventAssociations: {
|
||||
__args: {
|
||||
filter: {
|
||||
eventExternalId: { in: externalIds },
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
calendarEventId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const node = (
|
||||
calendarChannelEventAssociations as
|
||||
| CalendarChannelEventAssociationsConnection
|
||||
| undefined
|
||||
)?.edges?.[0]?.node;
|
||||
|
||||
return node?.calendarEventId ?? null;
|
||||
};
|
||||
|
||||
const findByICalUid = async (
|
||||
client: CoreApiClient,
|
||||
iCalUid: string,
|
||||
): Promise<string | null> => {
|
||||
const { calendarEvents } = await client.query({
|
||||
calendarEvents: {
|
||||
__args: {
|
||||
filter: {
|
||||
iCalUid: { eq: iCalUid },
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const node = (calendarEvents as CalendarEventsConnection | undefined)
|
||||
?.edges?.[0]?.node;
|
||||
|
||||
return node?.id ?? null;
|
||||
};
|
||||
|
||||
export const findMatchingCalendarEvent = async ({
|
||||
client,
|
||||
transcript,
|
||||
}: {
|
||||
client: CoreApiClient;
|
||||
transcript: FirefliesTranscript;
|
||||
}): Promise<
|
||||
| {
|
||||
matched: true;
|
||||
calendarEventId: string;
|
||||
matchedBy: 'externalId' | 'iCalUid';
|
||||
}
|
||||
| { matched: false; reason: string }
|
||||
> => {
|
||||
const externalIdCandidates = [
|
||||
transcript.calendar_id,
|
||||
transcript.cal_id,
|
||||
].filter(isNonEmptyString);
|
||||
|
||||
if (externalIdCandidates.length > 0) {
|
||||
const calendarEventId = await findByEventExternalId(
|
||||
client,
|
||||
externalIdCandidates,
|
||||
);
|
||||
|
||||
if (isDefined(calendarEventId)) {
|
||||
return { matched: true, calendarEventId, matchedBy: 'externalId' };
|
||||
}
|
||||
}
|
||||
|
||||
if (isNonEmptyString(transcript.calendar_id)) {
|
||||
const calendarEventId = await findByICalUid(client, transcript.calendar_id);
|
||||
|
||||
if (isDefined(calendarEventId)) {
|
||||
return { matched: true, calendarEventId, matchedBy: 'iCalUid' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matched: false,
|
||||
reason:
|
||||
'No CalendarEvent matched the transcript by external ID or iCalUid. Either the meeting was never on a synced calendar, or its calendar sync (Google/Outlook/CalDAV) is not configured in Twenty. Orphan calls are skipped in v1.',
|
||||
};
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const FIREFLIES_API_URL = 'https://api.fireflies.ai/graphql';
|
||||
|
||||
type FirefliesApiSuccess<TData> = {
|
||||
ok: true;
|
||||
status: number;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
type FirefliesApiFailure = {
|
||||
ok: false;
|
||||
status: number;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type FirefliesApiResult<TData> =
|
||||
| FirefliesApiSuccess<TData>
|
||||
| FirefliesApiFailure;
|
||||
|
||||
type FirefliesGraphqlError = {
|
||||
message?: string;
|
||||
extensions?: {
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type FirefliesGraphqlEnvelope<TData> = {
|
||||
data?: TData;
|
||||
errors?: FirefliesGraphqlError[];
|
||||
};
|
||||
|
||||
type FirefliesApiRequestParams = {
|
||||
apiKey: string;
|
||||
query: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const firefliesApiRequest = async <TData = unknown>({
|
||||
apiKey,
|
||||
query,
|
||||
variables,
|
||||
}: FirefliesApiRequestParams): Promise<FirefliesApiResult<TData>> => {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(FIREFLIES_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
errorMessage: `Fireflies API request failed: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
|
||||
let envelope: FirefliesGraphqlEnvelope<TData> | null = null;
|
||||
let parseError: Error | null = null;
|
||||
|
||||
try {
|
||||
envelope = (await response.json()) as FirefliesGraphqlEnvelope<TData>;
|
||||
} catch (error) {
|
||||
parseError = error as Error;
|
||||
}
|
||||
|
||||
if (
|
||||
envelope !== null &&
|
||||
isDefined(envelope.errors) &&
|
||||
envelope.errors.length > 0
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage: `Fireflies GraphQL error: ${formatFirefliesGraphqlError(
|
||||
envelope.errors[0],
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage: `Fireflies API responded with HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseError !== null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage: `Fireflies API returned a non-JSON response: ${parseError.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (envelope === null || !isDefined(envelope.data)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage: 'Fireflies GraphQL response was missing a `data` field',
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, status: response.status, data: envelope.data };
|
||||
};
|
||||
|
||||
const formatFirefliesGraphqlError = (
|
||||
error: FirefliesGraphqlError | undefined,
|
||||
): string => {
|
||||
const message = error?.message ?? 'Unknown Fireflies GraphQL error';
|
||||
const code = error?.extensions?.code;
|
||||
|
||||
return isDefined(code)
|
||||
? `${message} (Fireflies error code ${code})`
|
||||
: message;
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
|
||||
const EMPTY_SUMMARY_MESSAGE =
|
||||
'_Fireflies returned no summary content for this meeting._';
|
||||
|
||||
export const formatSummaryAsMarkdown = (
|
||||
transcript: FirefliesTranscript,
|
||||
): string => {
|
||||
const summary = transcript.summary;
|
||||
|
||||
if (!isDefined(summary)) {
|
||||
return EMPTY_SUMMARY_MESSAGE;
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (isNonEmptyString(summary.overview?.trim())) {
|
||||
sections.push(`## Overview\n\n${summary.overview.trim()}`);
|
||||
}
|
||||
|
||||
if (isNonEmptyString(summary.action_items?.trim())) {
|
||||
sections.push(`## Action items\n\n${summary.action_items.trim()}`);
|
||||
}
|
||||
|
||||
if (isNonEmptyArray(summary.topics_discussed)) {
|
||||
sections.push(
|
||||
`## Topics discussed\n\n${summary.topics_discussed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNonEmptyArray(summary.keywords)) {
|
||||
sections.push(`## Keywords\n\n${summary.keywords.join(', ')}`);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
return EMPTY_SUMMARY_MESSAGE;
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
|
||||
const UNKNOWN_SPEAKER_LABEL = 'Speaker';
|
||||
const EMPTY_TRANSCRIPT_MESSAGE =
|
||||
'_Fireflies returned no transcript content for this meeting._';
|
||||
|
||||
export const formatTranscriptAsMarkdown = (
|
||||
transcript: FirefliesTranscript,
|
||||
): string => {
|
||||
const sentences = transcript.sentences ?? [];
|
||||
|
||||
const lines: string[] = [];
|
||||
let currentSpeaker: string | null = null;
|
||||
let currentLines: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (!isNonEmptyArray(currentLines)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const speakerLabel = isDefined(currentSpeaker)
|
||||
? currentSpeaker
|
||||
: UNKNOWN_SPEAKER_LABEL;
|
||||
|
||||
lines.push(`**${speakerLabel}:** ${currentLines.join(' ')}`);
|
||||
currentLines = [];
|
||||
};
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const text = sentence.text.trim();
|
||||
|
||||
if (!isNonEmptyString(text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmedSpeaker = sentence.speaker_name?.trim();
|
||||
const speaker = isNonEmptyString(trimmedSpeaker) ? trimmedSpeaker : null;
|
||||
|
||||
if (speaker !== currentSpeaker) {
|
||||
flush();
|
||||
currentSpeaker = speaker;
|
||||
}
|
||||
|
||||
currentLines.push(text);
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
if (!isNonEmptyArray(lines)) {
|
||||
return EMPTY_TRANSCRIPT_MESSAGE;
|
||||
}
|
||||
|
||||
return lines.join('\n\n');
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const FIREFLIES_API_KEY_ENV_VAR = 'FIREFLIES_API_KEY';
|
||||
|
||||
export const getFirefliesApiKey = ():
|
||||
| { success: true; apiKey: string }
|
||||
| { success: false; error: string } => {
|
||||
const apiKey = process.env[FIREFLIES_API_KEY_ENV_VAR];
|
||||
|
||||
if (!isNonEmptyString(apiKey)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Fireflies is not configured. Open the Twenty Fireflies app settings and set the FIREFLIES_API_KEY application variable (Fireflies → Integrations → Fireflies API → Generate API key).',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, apiKey };
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const FIREFLIES_WEBHOOK_SECRET_ENV_VAR = 'FIREFLIES_WEBHOOK_SECRET';
|
||||
|
||||
export const getFirefliesWebhookSecret = ():
|
||||
| { success: true; secret: string }
|
||||
| { success: false; error: string } => {
|
||||
const secret = process.env[FIREFLIES_WEBHOOK_SECRET_ENV_VAR];
|
||||
|
||||
if (!isNonEmptyString(secret)) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'FIREFLIES_WEBHOOK_SECRET application variable is not set. Set it in Twenty Fireflies app settings, then configure the same value on the Fireflies side when registering the webhook URL.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, secret };
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FirefliesCallSummary } from 'src/logic-functions/types/fireflies-call-list-result.type';
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
import {
|
||||
firefliesApiRequest,
|
||||
type FirefliesApiResult,
|
||||
} from 'src/logic-functions/utils/fireflies-api-request';
|
||||
|
||||
const TRANSCRIPTS_QUERY = `
|
||||
query Transcripts(
|
||||
$keyword: String
|
||||
$scope: String
|
||||
$participants: [String!]
|
||||
$fromDate: DateTime
|
||||
$toDate: DateTime
|
||||
$limit: Int
|
||||
) {
|
||||
transcripts(
|
||||
keyword: $keyword
|
||||
scope: $scope
|
||||
participants: $participants
|
||||
fromDate: $fromDate
|
||||
toDate: $toDate
|
||||
limit: $limit
|
||||
) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
host_email
|
||||
transcript_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type TranscriptsResponse = {
|
||||
transcripts: FirefliesTranscript[] | null;
|
||||
};
|
||||
|
||||
export type FirefliesKeywordScope = 'title' | 'sentences' | 'all';
|
||||
|
||||
export type ListFirefliesTranscriptsArgs = {
|
||||
apiKey: string;
|
||||
keyword?: string;
|
||||
keywordScope?: FirefliesKeywordScope;
|
||||
participants?: string[];
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const toCallSummary = (transcript: FirefliesTranscript): FirefliesCallSummary => ({
|
||||
id: transcript.id,
|
||||
title: transcript.title,
|
||||
date: isDefined(transcript.date) ? new Date(transcript.date).toISOString() : null,
|
||||
durationMinutes: transcript.duration,
|
||||
participants: transcript.participants,
|
||||
hostEmail: transcript.host_email ?? transcript.organizer_email ?? null,
|
||||
transcriptUrl: transcript.transcript_url ?? null,
|
||||
meetingLink: transcript.meeting_link,
|
||||
});
|
||||
|
||||
export const listFirefliesTranscripts = async ({
|
||||
apiKey,
|
||||
keyword,
|
||||
keywordScope,
|
||||
participants,
|
||||
fromDate,
|
||||
toDate,
|
||||
limit,
|
||||
}: ListFirefliesTranscriptsArgs): Promise<
|
||||
FirefliesApiResult<FirefliesCallSummary[]>
|
||||
> => {
|
||||
const result = await firefliesApiRequest<TranscriptsResponse>({
|
||||
apiKey,
|
||||
query: TRANSCRIPTS_QUERY,
|
||||
variables: {
|
||||
keyword,
|
||||
scope: keywordScope,
|
||||
participants,
|
||||
fromDate,
|
||||
toDate,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const transcripts = result.data.transcripts ?? [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: result.status,
|
||||
data: transcripts.map(toCallSummary),
|
||||
};
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
|
||||
import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary';
|
||||
import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript';
|
||||
import { type FirefliesApiResult } from 'src/logic-functions/utils/fireflies-api-request';
|
||||
import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event';
|
||||
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
|
||||
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
|
||||
import { updateCalendarEventSummary } from 'src/logic-functions/utils/update-calendar-event-summary';
|
||||
import { updateCalendarEventTranscript } from 'src/logic-functions/utils/update-calendar-event-transcript';
|
||||
|
||||
export type FirefliesSyncableField = 'transcript' | 'summary';
|
||||
|
||||
export type SyncFirefliesFieldResult =
|
||||
| {
|
||||
status: 'updated';
|
||||
field: FirefliesSyncableField;
|
||||
calendarEventId: string;
|
||||
}
|
||||
| { status: 'skipped'; field: FirefliesSyncableField; reason: string }
|
||||
| { status: 'error'; field: FirefliesSyncableField; error: string };
|
||||
|
||||
type FieldSyncStrategy = {
|
||||
fetch: (args: {
|
||||
apiKey: string;
|
||||
transcriptId: string;
|
||||
}) => Promise<FirefliesApiResult<FirefliesTranscript>>;
|
||||
format: (transcript: FirefliesTranscript) => string;
|
||||
update: (args: {
|
||||
client: CoreApiClient;
|
||||
calendarEventId: string;
|
||||
markdown: string;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
const FIELD_SYNC_STRATEGIES: Record<FirefliesSyncableField, FieldSyncStrategy> =
|
||||
{
|
||||
transcript: {
|
||||
fetch: fetchFirefliesTranscript,
|
||||
format: formatTranscriptAsMarkdown,
|
||||
update: updateCalendarEventTranscript,
|
||||
},
|
||||
summary: {
|
||||
fetch: fetchFirefliesSummary,
|
||||
format: formatSummaryAsMarkdown,
|
||||
update: updateCalendarEventSummary,
|
||||
},
|
||||
};
|
||||
|
||||
export const syncFirefliesFieldToCalendarEvent = async ({
|
||||
apiKey,
|
||||
client,
|
||||
transcriptId,
|
||||
field,
|
||||
}: {
|
||||
apiKey: string;
|
||||
client: CoreApiClient;
|
||||
transcriptId: string;
|
||||
field: FirefliesSyncableField;
|
||||
}): Promise<SyncFirefliesFieldResult> => {
|
||||
const strategy = FIELD_SYNC_STRATEGIES[field];
|
||||
|
||||
const fetchResult = await strategy.fetch({ apiKey, transcriptId });
|
||||
|
||||
if (!fetchResult.ok) {
|
||||
return { status: 'error', field, error: fetchResult.errorMessage };
|
||||
}
|
||||
|
||||
const match = await findMatchingCalendarEvent({
|
||||
client,
|
||||
transcript: fetchResult.data,
|
||||
});
|
||||
|
||||
if (!match.matched) {
|
||||
return { status: 'skipped', field, reason: match.reason };
|
||||
}
|
||||
|
||||
const markdown = strategy.format(fetchResult.data);
|
||||
|
||||
try {
|
||||
await strategy.update({
|
||||
client,
|
||||
calendarEventId: match.calendarEventId,
|
||||
markdown,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
field,
|
||||
error: `Failed to update CalendarEvent ${match.calendarEventId} ${field}: ${
|
||||
(error as Error).message
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'updated',
|
||||
field,
|
||||
calendarEventId: match.calendarEventId,
|
||||
};
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export const updateCalendarEventSummary = async ({
|
||||
client,
|
||||
calendarEventId,
|
||||
markdown,
|
||||
}: {
|
||||
client: CoreApiClient;
|
||||
calendarEventId: string;
|
||||
markdown: string;
|
||||
}): Promise<void> => {
|
||||
await client.mutation({
|
||||
updateCalendarEvent: {
|
||||
__args: {
|
||||
id: calendarEventId,
|
||||
data: {
|
||||
summary: {
|
||||
markdown,
|
||||
blocknote: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
export const updateCalendarEventTranscript = async ({
|
||||
client,
|
||||
calendarEventId,
|
||||
markdown,
|
||||
}: {
|
||||
client: CoreApiClient;
|
||||
calendarEventId: string;
|
||||
markdown: string;
|
||||
}): Promise<void> => {
|
||||
await client.mutation({
|
||||
updateCalendarEvent: {
|
||||
__args: {
|
||||
id: calendarEventId,
|
||||
data: {
|
||||
transcript: {
|
||||
markdown,
|
||||
blocknote: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
const SIGNATURE_PREFIX = 'sha256=';
|
||||
|
||||
const stripPrefix = (signature: string): string =>
|
||||
signature.startsWith(SIGNATURE_PREFIX)
|
||||
? signature.slice(SIGNATURE_PREFIX.length)
|
||||
: signature;
|
||||
|
||||
export const verifyFirefliesWebhookSignature = ({
|
||||
rawBody,
|
||||
signatureHeader,
|
||||
secret,
|
||||
}: {
|
||||
rawBody: string;
|
||||
signatureHeader: string | undefined;
|
||||
secret: string;
|
||||
}): { valid: true } | { valid: false; error: string } => {
|
||||
if (!isNonEmptyString(signatureHeader)) {
|
||||
return { valid: false, error: 'Missing x-hub-signature header' };
|
||||
}
|
||||
|
||||
const provided = stripPrefix(signatureHeader.trim()).toLowerCase();
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(rawBody, 'utf8')
|
||||
.digest('hex');
|
||||
|
||||
if (provided.length !== expected.length) {
|
||||
return { valid: false, error: 'Signature length mismatch' };
|
||||
}
|
||||
|
||||
const providedBuffer = Buffer.from(provided, 'hex');
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
|
||||
if (
|
||||
providedBuffer.length === 0 ||
|
||||
providedBuffer.length !== expectedBuffer.length
|
||||
) {
|
||||
return { valid: false, error: 'Malformed signature' };
|
||||
}
|
||||
|
||||
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
|
||||
return { valid: false, error: 'Signature verification failed' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import {
|
||||
defineApplicationRole,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplicationRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Twenty Fireflies sync role',
|
||||
description:
|
||||
'Reads CalendarEvent and CalendarChannelEventAssociation to locate the meeting matching a Fireflies call (from an incoming webhook or from the on-demand Sync Fireflies Call workflow tool), and updates that CalendarEvent to write the synced transcript and summary fields. The list / search workflow tools only call the Fireflies API and do not require any Twenty object permissions.',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
|
||||
.universalIdentifier,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: false,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: [],
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
|
||||
type: FieldType.RICH_TEXT,
|
||||
name: 'summary',
|
||||
label: 'Summary',
|
||||
description:
|
||||
'AI-generated meeting summary synced from Fireflies. Includes overview, action items, and keywords. Populated automatically when Fireflies finishes summarizing a recording that matches this calendar event.',
|
||||
icon: 'IconNotes',
|
||||
isNullable: true,
|
||||
});
|
||||