Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3c45aff3 | ||
|
|
803e3ece5a | ||
|
|
02e7c54043 | ||
|
|
66d088a89d |
@@ -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
|
||||
#
|
||||
|
||||
@@ -6,43 +6,18 @@ permissions:
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'twenty/v*'
|
||||
- 'sdk/v*'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash --noprofile --norc -euo pipefail {0}
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
dispatch-tag:
|
||||
deploy-tag:
|
||||
timeout-minutes: 3
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve dispatch event from tag family
|
||||
id: target
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
case "$REF_NAME" in
|
||||
twenty/v*)
|
||||
event_type=auto-deploy-twenty
|
||||
;;
|
||||
sdk/v*)
|
||||
event_type=auto-publish-npm
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported tag '$REF_NAME', expected 'twenty/v*' or 'sdk/v*'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
printf 'event_type=%s\n' "$event_type" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Repository Dispatch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.TWENTY_INFRA_TOKEN }}
|
||||
EVENT_TYPE: ${{ steps.target.outputs.event_type }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh api repos/twentyhq/twenty-infra/dispatches \
|
||||
-f "event_type=$EVENT_TYPE" \
|
||||
-f event_type=auto-deploy-tag \
|
||||
-f "client_payload[github][ref_name]=$REF_NAME"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: GraphQL and OpenAPI Breaking Changes Detection
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request:
|
||||
types: [opened, synchronize, edited]
|
||||
branches:
|
||||
- main
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
image: clickhouse/clickhouse-server:25.8.8
|
||||
env:
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_URL: 'http://default:clickhousePassword@localhost:8123/twenty'
|
||||
ports:
|
||||
- 8123:8123
|
||||
- 9000:9000
|
||||
@@ -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
|
||||
@@ -239,7 +235,6 @@ jobs:
|
||||
echo "Current branch files downloaded:"
|
||||
ls -la current-*
|
||||
|
||||
|
||||
- name: Preserve current branch files
|
||||
run: |
|
||||
# Create a temp directory to store current branch files
|
||||
@@ -309,15 +304,12 @@ jobs:
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379/1"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
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 +351,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
|
||||
@@ -410,7 +401,6 @@ jobs:
|
||||
echo "Main branch files downloaded:"
|
||||
ls -la main-*
|
||||
|
||||
|
||||
- name: Restore current branch files
|
||||
run: |
|
||||
# Move current branch files back to working directory
|
||||
@@ -430,9 +420,8 @@ jobs:
|
||||
|
||||
for file in main-schema-introspection.json current-schema-introspection.json \
|
||||
main-metadata-schema-introspection.json current-metadata-schema-introspection.json; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "::warning::Missing GraphQL schema file: $file"
|
||||
valid=false
|
||||
if [ ! -f "$file" ] || ! jq empty "$file" 2>/dev/null; then
|
||||
echo "::warning::Invalid or missing schema file: $file"
|
||||
elif ! jq -e '.data.__schema' "$file" >/dev/null 2>&1; then
|
||||
echo "::warning::File $file is not a valid GraphQL introspection result. First 200 bytes: $(head -c 200 "$file")"
|
||||
valid=false
|
||||
@@ -447,6 +436,17 @@ jobs:
|
||||
elif ! jq -e '.openapi // .swagger' "$file" >/dev/null 2>&1; then
|
||||
echo "::warning::File $file is not a valid OpenAPI spec. First 200 bytes: $(head -c 200 "$file")"
|
||||
valid=false
|
||||
elif ! jq -e '.data.__schema' "$file" > /dev/null 2>&1; then
|
||||
echo "::warning::Schema file is not a valid GraphQL introspection response: $file"
|
||||
valid=false
|
||||
fi
|
||||
done
|
||||
|
||||
for file in main-rest-api.json current-rest-api.json \
|
||||
main-rest-metadata-api.json current-rest-metadata-api.json; do
|
||||
if [ ! -f "$file" ] || ! jq empty "$file" 2>/dev/null; then
|
||||
echo "::warning::Invalid or missing REST API file: $file"
|
||||
valid=false
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -458,7 +458,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 +471,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 +488,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 +504,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 +526,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 +573,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 +595,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 +640,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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: CI Codex Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
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: |
|
||||
package.json
|
||||
packages/twenty-codex-plugin/**
|
||||
.github/workflows/ci-codex-plugin.yaml
|
||||
|
||||
codex-plugin-validate:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch local actions
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Codex Plugin / Validate
|
||||
run: npx nx run twenty-codex-plugin:validate
|
||||
|
||||
- name: Codex Plugin / Test
|
||||
run: npx nx run twenty-codex-plugin:test
|
||||
@@ -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" --skip-local-instance
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
|
||||
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
|
||||
echo "--- Installing last SDK versions ---"
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
|
||||
|
||||
- name: Verify installed app versions
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo "--- Checking package.json references correct SDK version ---"
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const sdkVersion = pkg.dependencies['twenty-sdk'];
|
||||
if (!sdkVersion.startsWith('0.0.0-ci.')) {
|
||||
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('SDK version in scaffolded app:', sdkVersion);
|
||||
"
|
||||
|
||||
- name: Verify SDK CLI is available
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty --version
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
|
||||
- name: Setup database
|
||||
run: npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty deploy
|
||||
|
||||
- name: Install scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty install
|
||||
|
||||
- name: Execute hello-world logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
|
||||
|
||||
- name: Execute create-hello-world-company logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-hello-world-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e-hello-world]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -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" --skip-local-instance --yes
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
echo "--- Checking package.json references correct SDK version ---"
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const sdkVersion = pkg.devDependencies['twenty-sdk'];
|
||||
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);
|
||||
@@ -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" --skip-local-instance
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
|
||||
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
|
||||
echo "--- Installing last SDK versions ---"
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
|
||||
|
||||
- name: Verify installed app versions
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo "--- Checking package.json references correct SDK version ---"
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const sdkVersion = pkg.dependencies['twenty-sdk'];
|
||||
if (!sdkVersion.startsWith('0.0.0-ci.')) {
|
||||
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('SDK version in scaffolded app:', sdkVersion);
|
||||
"
|
||||
|
||||
- name: Verify SDK CLI is available
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty --version
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
|
||||
- name: Setup database
|
||||
run: npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty deploy
|
||||
|
||||
- name: Install scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty install
|
||||
|
||||
- name: Execute postcard logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
|
||||
|
||||
- name: Execute create-postcard-company logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-postcard-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e-postcard]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -23,11 +23,9 @@ jobs:
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
packages/twenty-server/**
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
!packages/twenty-server/package.json
|
||||
|
||||
example-app-postcard:
|
||||
needs: changed-files-check
|
||||
@@ -85,31 +83,6 @@ jobs:
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: npx vitest run
|
||||
|
||||
- name: Configure remote for SDK CLI
|
||||
run: |
|
||||
mkdir -p ~/.twenty
|
||||
cat > ~/.twenty/config.json <<EOF
|
||||
{
|
||||
"version": 1,
|
||||
"remotes": {
|
||||
"target": {
|
||||
"apiUrl": "${TWENTY_API_URL}",
|
||||
"apiKey": "${TWENTY_API_KEY}",
|
||||
"accessToken": "${TWENTY_API_KEY}"
|
||||
}
|
||||
},
|
||||
"defaultRemote": "target"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Deploy postcard app (registry install path)
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs deploy --remote target
|
||||
|
||||
- name: Install postcard app (registry install path)
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs install --remote target
|
||||
|
||||
ci-example-app-postcard-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: "Release: on merge"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash --noprofile --norc -euo pipefail {0}
|
||||
|
||||
jobs:
|
||||
tag_and_release:
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
|
||||
steps:
|
||||
- name: Check PR Author
|
||||
id: check_author
|
||||
env:
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$PR_AUTHOR" != "github-actions[bot]" ]]; then
|
||||
echo "PR author ($PR_AUTHOR) is not trusted. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Get version from PR title
|
||||
id: extract_version
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(printf '%s' "$PR_TITLE" | sed -n 's/.*Release v\([0-9][0-9.]*\).*/\1/p')
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "No valid version found in PR title. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
printf 'VERSION=%s\n' "$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Push new tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global user.name 'Github Action Deploy'
|
||||
git config --global user.email 'github-action-deploy@twenty.com'
|
||||
git tag "v${{ env.VERSION }}"
|
||||
git push origin "v${{ env.VERSION }}"
|
||||
|
||||
- uses: release-drafter/release-drafter@09c613e259eb8d4e7c81c2cb00618eb5fc4575a7 # v5
|
||||
if: contains(github.event.pull_request.labels.*.name, 'create_release')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag: v${{ env.VERSION }}
|
||||
@@ -27,12 +27,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
# Pull base images through Google's Docker Hub mirror — avoids Docker Hub
|
||||
# rate limits and needs no credentials (this repo is public).
|
||||
- name: Configure Docker Hub mirror
|
||||
run: |
|
||||
echo '{"registry-mirrors":["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
- 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
|
||||
run: |
|
||||
echo "Patching docker-compose.yml..."
|
||||
@@ -103,12 +102,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
# Pull base images through Google's Docker Hub mirror — avoids Docker Hub
|
||||
# rate limits and needs no credentials (this repo is public).
|
||||
- name: Configure Docker Hub mirror
|
||||
run: |
|
||||
echo '{"registry-mirrors":["https://mirror.gcr.io"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
- name: Create frontend placeholder
|
||||
run: |
|
||||
mkdir -p packages/twenty-front/build
|
||||
|
||||
@@ -3,8 +3,6 @@ name: CI UI
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,7 +13,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name != 'merge_group'
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
@@ -42,10 +40,7 @@ jobs:
|
||||
run: npx nx ${{ matrix.task }} twenty-ui
|
||||
ui-sb-build:
|
||||
needs: changed-files-check
|
||||
if: >-
|
||||
always() &&
|
||||
(github.event_name == 'push' ||
|
||||
needs.changed-files-check.outputs.any_changed == 'true')
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -67,7 +62,8 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
needs: ui-sb-build
|
||||
if: always() && needs.ui-sb-build.result == 'success'
|
||||
env:
|
||||
STORYBOOK_URL: http://localhost:6007
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
@@ -86,20 +82,22 @@ jobs:
|
||||
run: |
|
||||
cd packages/twenty-ui
|
||||
npx playwright install
|
||||
- name: Run storybook tests
|
||||
run: npx nx storybook:test twenty-ui
|
||||
- name: Upload screenshots for visual regression
|
||||
if: always() && !cancelled()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: argos-screenshots-twenty-ui
|
||||
path: packages/twenty-ui/screenshots
|
||||
retention-days: 1
|
||||
- name: Serve storybook & run tests
|
||||
run: |
|
||||
npx http-server packages/twenty-ui/storybook-static --port 6007 --silent &
|
||||
timeout 30 bash -c 'until curl -sf http://localhost:6007 > /dev/null 2>&1; do sleep 1; done'
|
||||
npx nx storybook:test twenty-ui
|
||||
ci-ui-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, ui-task, ui-sb-build, ui-sb-test]
|
||||
needs:
|
||||
[
|
||||
changed-files-check,
|
||||
ui-task,
|
||||
ui-sb-build,
|
||||
ui-sb-test,
|
||||
]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
@@ -124,7 +123,6 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
|
||||
@@ -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: ./
|
||||
@@ -1,33 +1,52 @@
|
||||
name: Visual Regression Dispatch
|
||||
|
||||
# Dispatches visual regression processing to ci-privileged after CI completes.
|
||||
# Runs in the context of the base repo (not the fork) so it has access to secrets,
|
||||
# making it work for external contributor PRs.
|
||||
# Uses workflow_run to dispatch visual regression to ci-privileged.
|
||||
# This runs in the context of the base repo (not the fork), so it has
|
||||
# access to secrets — making it work for external contributor PRs.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI UI']
|
||||
workflows: ['CI Front', 'CI UI']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
dispatch-pr:
|
||||
dispatch:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check if screenshots artifact exists
|
||||
- name: Determine project and artifact name
|
||||
id: project
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const workflowName = context.payload.workflow_run.name;
|
||||
if (workflowName === 'CI Front') {
|
||||
core.setOutput('project', 'twenty-front');
|
||||
core.setOutput('artifact_name', 'storybook-static');
|
||||
core.setOutput('tarball_name', 'storybook-twenty-front-tarball');
|
||||
core.setOutput('tarball_file', 'storybook-twenty-front.tar.gz');
|
||||
} else if (workflowName === 'CI UI') {
|
||||
core.setOutput('project', 'twenty-ui');
|
||||
core.setOutput('artifact_name', 'storybook-twenty-ui');
|
||||
core.setOutput('tarball_name', 'storybook-twenty-ui-tarball');
|
||||
core.setOutput('tarball_file', 'storybook-twenty-ui.tar.gz');
|
||||
} else {
|
||||
core.setFailed(`Unexpected workflow: ${workflowName}`);
|
||||
}
|
||||
|
||||
- name: Check if storybook artifact exists
|
||||
id: check-artifact
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const artifactName = 'argos-screenshots-twenty-ui';
|
||||
const artifactName = '${{ steps.project.outputs.artifact_name }}';
|
||||
const runId = context.payload.workflow_run.id;
|
||||
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -40,7 +59,7 @@ jobs:
|
||||
core.setOutput('exists', found ? 'true' : 'false');
|
||||
|
||||
if (!found) {
|
||||
core.info(`Artifact "${artifactName}" not found in run ${runId} — skipping`);
|
||||
core.info(`Artifact "${artifactName}" not found in run ${runId} — storybook build was likely skipped`);
|
||||
}
|
||||
|
||||
- name: Get PR number
|
||||
@@ -52,6 +71,8 @@ jobs:
|
||||
const headBranch = context.payload.workflow_run.head_branch;
|
||||
const headRepo = context.payload.workflow_run.head_repository;
|
||||
|
||||
// workflow_run.pull_requests is empty for fork PRs,
|
||||
// so fall back to searching by head label (owner:branch)
|
||||
let pullRequests = context.payload.workflow_run.pull_requests;
|
||||
let prNumber;
|
||||
|
||||
@@ -59,7 +80,7 @@ jobs:
|
||||
prNumber = pullRequests[0].number;
|
||||
} else {
|
||||
const headLabel = `${headRepo.owner.login}:${headBranch}`;
|
||||
core.info(`Searching for PR by head label: ${headLabel}`);
|
||||
core.info(`pull_requests is empty (likely a fork PR), searching by head label: ${headLabel}`);
|
||||
|
||||
const { data: prs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
@@ -75,7 +96,7 @@ jobs:
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
core.info('No pull request found — skipping');
|
||||
core.info('No pull request found for this workflow run — skipping');
|
||||
core.setOutput('has_pr', 'false');
|
||||
return;
|
||||
}
|
||||
@@ -84,100 +105,43 @@ jobs:
|
||||
core.setOutput('has_pr', 'true');
|
||||
core.info(`PR #${prNumber}`);
|
||||
|
||||
- name: Compute merge-base for Argos reference
|
||||
- name: Download storybook artifact from triggering run
|
||||
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
|
||||
id: merge-base
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
script: |
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
name: ${{ steps.project.outputs.artifact_name }}
|
||||
path: storybook-static
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
try {
|
||||
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
basehead: `main...${headSha}`,
|
||||
});
|
||||
- name: Package storybook
|
||||
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
|
||||
run: tar -czf /tmp/${{ steps.project.outputs.tarball_file }} -C storybook-static .
|
||||
|
||||
if (comparison.merge_base_commit?.sha) {
|
||||
core.setOutput('sha', comparison.merge_base_commit.sha);
|
||||
core.info(`Merge base: ${comparison.merge_base_commit.sha}`);
|
||||
} else {
|
||||
core.info('Could not determine merge base — will skip reference_commit');
|
||||
core.setOutput('sha', '');
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to compute merge base: ${error instanceof Error ? error.message : String(error)}`);
|
||||
core.setOutput('sha', '');
|
||||
}
|
||||
- name: Upload storybook tarball
|
||||
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ${{ steps.project.outputs.tarball_name }}
|
||||
path: /tmp/${{ steps.project.outputs.tarball_file }}
|
||||
retention-days: 1
|
||||
|
||||
- name: Dispatch to ci-privileged
|
||||
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
REFERENCE_COMMIT: ${{ steps.merge-base.outputs.sha }}
|
||||
run: |
|
||||
ARGS=(
|
||||
--method POST
|
||||
-f event_type=visual-regression
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID"
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
-f "client_payload[branch]=$BRANCH"
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
)
|
||||
|
||||
if [ -n "$REFERENCE_COMMIT" ]; then
|
||||
ARGS+=(-f "client_payload[reference_commit]=$REFERENCE_COMMIT")
|
||||
fi
|
||||
|
||||
gh api repos/twentyhq/ci-privileged/dispatches "${ARGS[@]}"
|
||||
|
||||
dispatch-main:
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check if screenshots artifact exists
|
||||
id: check-artifact
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const runId = context.payload.workflow_run.id;
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: runId,
|
||||
});
|
||||
|
||||
const found = artifacts.artifacts.some(a => a.name === 'argos-screenshots-twenty-ui');
|
||||
core.setOutput('exists', found ? 'true' : 'false');
|
||||
|
||||
if (!found) {
|
||||
core.info(`Artifact not found in run ${runId} — skipping`);
|
||||
}
|
||||
|
||||
- name: Dispatch to ci-privileged
|
||||
if: steps.check-artifact.outputs.exists == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PROJECT: ${{ steps.project.outputs.project }}
|
||||
BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
COMMIT: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
--method POST \
|
||||
-f event_type=visual-regression \
|
||||
-f "client_payload[run_id]=$WORKFLOW_RUN_ID" \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[run_id]=$RUN_ID" \
|
||||
-f "client_payload[repo]=$REPOSITORY" \
|
||||
-f "client_payload[project]=$PROJECT" \
|
||||
-f "client_payload[branch]=$BRANCH" \
|
||||
-f "client_payload[commit]=$COMMIT"
|
||||
|
||||
@@ -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"
|
||||
@@ -51,9 +51,7 @@ dump.rdb
|
||||
|
||||
mcp.json
|
||||
/.junie/
|
||||
/.agents/plugins/marketplace.json
|
||||
TRANSLATION_QA_REPORT.md
|
||||
.playwright-mcp/
|
||||
.playwright-cli/
|
||||
output/playwright/
|
||||
screenshots/
|
||||
|
||||
@@ -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/**"
|
||||
]
|
||||
}
|
||||
Vendored
-40
@@ -43,46 +43,6 @@
|
||||
{
|
||||
"name": "packages/twenty-e2e-testing",
|
||||
"path": "../packages/twenty-e2e-testing"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-docs",
|
||||
"path": "../packages/twenty-docs"
|
||||
},
|
||||
{
|
||||
"name": "packages/create-twenty-app",
|
||||
"path": "../packages/create-twenty-app"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-apps",
|
||||
"path": "../packages/twenty-apps"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-claude-skills",
|
||||
"path": "../packages/twenty-claude-skills"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-cli",
|
||||
"path": "../packages/twenty-cli"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-client-sdk",
|
||||
"path": "../packages/twenty-client-sdk"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-companion",
|
||||
"path": "../packages/twenty-companion"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-front-component-renderer",
|
||||
"path": "../packages/twenty-front-component-renderer"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-sdk",
|
||||
"path": "../packages/twenty-sdk"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-website",
|
||||
"path": "../packages/twenty-website"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
+1
-7
@@ -4,14 +4,8 @@ enableHardenedMode: true
|
||||
|
||||
enableInlineHunks: true
|
||||
|
||||
enableScripts: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- twenty-sdk
|
||||
- twenty-client-sdk
|
||||
npmMinimalAgeGate: "3d"
|
||||
|
||||
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,159 +0,0 @@
|
||||
# Twenty Website — DESIGN.md
|
||||
|
||||
> Visual system for the Twenty marketing site. Distilled from `packages/twenty-website/src/theme/`. Loaded by every `impeccable` invocation alongside PRODUCT.md.
|
||||
|
||||
## Theme
|
||||
|
||||
**Light by default.** A founder browsing a partner profile in daylight on a 14–27 inch monitor is the default scene. The site does ship a `data-scheme="dark"` override (see `css-variables.ts`), but no current public page opts into it. Treat dark as a deferred surface.
|
||||
|
||||
## Color
|
||||
|
||||
Palette is OKLCH-equivalent neutrals at the surface level. The brand accents (blue, pink, yellow, green) are present in the token system but used sparingly — none of them appear on the partner pages.
|
||||
|
||||
### Strategy: Restrained
|
||||
|
||||
Tinted neutrals + one accent ≤10%. The accent for partner pages is the deep ink black (`var(--color-black-100)`) used in CTAs and hover states. Anything beyond a hairline border, an icon glyph, or a primary CTA should question whether it needs color at all.
|
||||
|
||||
### Tokens (from `src/theme/colors.ts` + `css-variables.ts`)
|
||||
|
||||
Neutrals (the workhorses):
|
||||
|
||||
| Token | Hex (computed) | Role |
|
||||
| --- | --- | --- |
|
||||
| `colors.primary.background[100]` | `#ffffff` | Page + card surface |
|
||||
| `colors.primary.text[100]` | `#1c1c1c` | Headlines, primary text |
|
||||
| `colors.primary.text[80]` | `#1c1c1ccc` | Body text |
|
||||
| `colors.primary.text[60]` | `#1c1c1c99` | Eyebrows, meta, captions |
|
||||
| `colors.primary.text[40]` | `#1c1c1c66` | Disabled / placeholder |
|
||||
| `colors.primary.text[20]` | `#1c1c1c33` | Subtle separators |
|
||||
| `colors.primary.text[10]` | `#1c1c1c1a` | Hairline borders |
|
||||
| `colors.primary.text[5]` | `#1c1c1c0d` | Subtle fills (rates panel, skill chips) |
|
||||
| `colors.primary.border[10]` | `#1c1c1c1a` | Default border |
|
||||
| `colors.primary.border[20]` | `#1c1c1c33` | Hover border |
|
||||
|
||||
Reverse palette (for dark CTAs):
|
||||
|
||||
| Token | Role |
|
||||
| --- | --- |
|
||||
| `colors.secondary.background[100]` | Filled CTA background (deep ink) |
|
||||
| `colors.secondary.text[100]` | Filled CTA text (white) |
|
||||
|
||||
Brand accents (currently absent from partner pages; available if needed):
|
||||
|
||||
- `colors.accent.blue` — `#4a38f5` / `#8174f8`
|
||||
- `colors.accent.pink` — `#ed87fc` / `#f3abfd`
|
||||
- `colors.accent.yellow` — `#feffb7` / `#feffd9`
|
||||
- `colors.accent.green` — `#89fc9a` / `#b0fdbe`
|
||||
- `colors.highlight` — same hue as blue accent
|
||||
|
||||
**Do not introduce gradients, glass blurs, or saturated fills on partner pages.** Color is conviction here, not decoration.
|
||||
|
||||
## Typography
|
||||
|
||||
Three families, each load-balanced via CSS variables:
|
||||
|
||||
| Family | Var | Use |
|
||||
| --- | --- | --- |
|
||||
| `theme.font.family.serif` | `--font-serif` | Headlines, partner names, headline values |
|
||||
| `theme.font.family.sans` | `--font-sans` | Body, prose, interactive labels |
|
||||
| `theme.font.family.mono` | `--font-mono` | Eyebrows, meta, currency labels, tabular numerics |
|
||||
| `theme.font.family.retro` | `--font-retro` | Reserved (not used on partner pages) |
|
||||
|
||||
### Weight + Size Contrast
|
||||
|
||||
Weights: `light: 300`, `regular: 400`, `medium: 500`. No bold. Hierarchy is driven by scale and family contrast, never by weight alone.
|
||||
|
||||
Scale (`theme.font.size(n)` → `calc(var(--font-base) * n)`, where `--font-base: 0.25rem` ≈ 4px):
|
||||
|
||||
- Display / h1: size 9–12 (36–48px)
|
||||
- h2 / section heads: size 7–8 (28–32px)
|
||||
- h3 / card heads: size 5–6 (20–24px)
|
||||
- Body / prose: size 4–5 (16–20px)
|
||||
- Eyebrow / meta: size 3 (12px) with `letter-spacing: 0.06–0.08em` and `text-transform: uppercase`
|
||||
|
||||
Body line length: cap at 65–75ch (the existing `PartnerProfileIntro` uses `max-width: 62ch` — keep that order of magnitude).
|
||||
|
||||
### Hierarchy contract
|
||||
|
||||
- A serif `<h1>` at size 9 light reads as a partner's name on the detail page.
|
||||
- A mono eyebrow above or below it locates the partner (region · city · country).
|
||||
- A serif size 6 light reads as a section head.
|
||||
- Body prose is sans regular.
|
||||
- Currency values are serif (they read as headline numbers, not stats).
|
||||
- Currency labels and meta are mono.
|
||||
|
||||
## Spacing & Layout
|
||||
|
||||
Base unit `4px`. Spacing helper `theme.spacing(n)` returns `n * 4px`. Common rhythms on the partner pages:
|
||||
|
||||
- Inter-section gap on the detail page: `theme.spacing(10–14)` — generous, editorial breathing room.
|
||||
- Inter-element gap inside a section: `theme.spacing(3–5)`.
|
||||
- Card padding: `theme.spacing(6)`.
|
||||
- Page horizontal padding: `theme.spacing(4)` mobile, `theme.spacing(10)` ≥ md breakpoint.
|
||||
|
||||
### Radius
|
||||
|
||||
`theme.radius(n)` returns `n * 2px`. The default card radius is `theme.radius(2)` = 4px. Pills use `999px`. No softer rounding than that.
|
||||
|
||||
### Borders
|
||||
|
||||
Borders are hairline (`1px solid theme.colors.primary.border[10]`). They define edges quietly. On hover they step to `border[20]`. Never use a chunky border as decoration.
|
||||
|
||||
## Components
|
||||
|
||||
### Card (PartnerCard, RatesPanel)
|
||||
|
||||
White surface, hairline border, 4px radius, 24px padding, soft shadow on hover only:
|
||||
|
||||
```css
|
||||
background-color: ${theme.colors.primary.background[100]};
|
||||
border: 1px solid ${theme.colors.primary.border[10]};
|
||||
border-radius: ${theme.radius(2)};
|
||||
padding: ${theme.spacing(6)};
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.colors.primary.border[20]};
|
||||
box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Chip / Pill
|
||||
|
||||
Rounded `999px`, 1px border, subtle background fill (`primary.text[5]` for filter pills, transparent for chip rows), `text[80]` color, mono or sans font.
|
||||
|
||||
### Button / LinkButton
|
||||
|
||||
Lives in `@/design-system/components`. Two color modes: `primary` (deep ink fill, white text) and `secondary` (transparent fill, ink text + 1px border). `variant="contained"` is what partner pages use.
|
||||
|
||||
### Avatar
|
||||
|
||||
`PartnerAvatar` is a deterministic generated mark from name + slug. Used as fallback when `profilePictureUrl` is missing. The real photo overlays it at 120px circle on the detail page, 56px on the list card.
|
||||
|
||||
## Motion
|
||||
|
||||
- Hover transitions: 250ms, ease-out (cubic-bezier curve in `PartnerCard`: `0.25s ease`).
|
||||
- Card entrance: 700ms cubic-bezier `0.22, 1, 0.36, 1` (ease-out-quart), 90ms stagger per index.
|
||||
- All motion respects `@media (prefers-reduced-motion: reduce)` — animations stop, hover translate disabled.
|
||||
- **No bounce, no elastic, no parallax.** Editorial restraint.
|
||||
|
||||
## Iconography
|
||||
|
||||
`@tabler/icons-react`, 14–16px on body-level chips, 18–24px on buttons. Always `aria-hidden="true"` when decorative. Stroke width `2` (default).
|
||||
|
||||
## Accessibility Defaults
|
||||
|
||||
- Focus ring: `outline: 2px solid theme.colors.primary.text[100]; outline-offset: 4px` (already used on the card link).
|
||||
- Touch target ≥ 40×40px on mobile.
|
||||
- `aria-label` on icon-only buttons, `aria-labelledby` on sectioned regions.
|
||||
- All `<a target="_blank">` includes `rel="noopener noreferrer"`.
|
||||
- Color is never the sole carrier of meaning. The money pills carry both an icon and a text label.
|
||||
|
||||
## Anti-patterns (project-specific)
|
||||
|
||||
In addition to the impeccable shared absolute bans:
|
||||
|
||||
- **Do not use the brand accent colors (blue/pink/yellow/green) on partner pages** unless we have a stronger reason than "to add color".
|
||||
- **No skeuomorphic shadows on cards.** The hover shadow is `0 12px 32px -16px rgba(0,0,0,0.18)` — that's the ceiling.
|
||||
- **No gradients on anything.** Including text, borders, and backgrounds.
|
||||
- **No floating "Trusted by" logo bars** on partner pages.
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
# Twenty Website — Product & Brand Context
|
||||
|
||||
> Strategic context for design work on the Twenty marketing site (`packages/twenty-website`). Loaded by every `impeccable` invocation.
|
||||
|
||||
## Register
|
||||
|
||||
**Brand.** The marketing site is a public-facing surface where the design itself is part of the credibility argument. Prospects evaluate Twenty partly by how the site feels. The product app (`packages/twenty-front`) is a separate product-register surface, governed elsewhere.
|
||||
|
||||
## Users & Purpose
|
||||
|
||||
The primary audience varies by route, but the working assumption for partner-related pages is:
|
||||
|
||||
- **Who:** A budget-holding decision maker (founder, RevOps lead, or COO) shopping for a CRM implementation partner. Already on Twenty's site, evaluating a shortlist of partners.
|
||||
- **Context:** Doing a side-by-side comparison across 2–5 candidates over a single browsing session. Will spend 30–90 seconds on each profile before deciding whether to book a call.
|
||||
- **Decision being made:** "Is this partner credible, the right size, the right specialty, and within budget? Do I trust them enough to commit 30 minutes to a discovery call?"
|
||||
|
||||
What the partner pages must do, in priority order:
|
||||
1. Communicate credibility (real firm, real person, real work).
|
||||
2. Surface fit signals fast (skills, region, languages, deployment expertise, budget range).
|
||||
3. Give the visitor a confident "next step" affordance (book a call or vet via LinkedIn) without pressure.
|
||||
|
||||
## Desired Outcome
|
||||
|
||||
The redesign should make `/partners/profile/[slug]` feel like a *thoughtfully curated profile of a top-tier partner*, not a generic templated card. A visitor should leave thinking "this firm is serious" even if they don't book a call this session.
|
||||
|
||||
Specifically:
|
||||
- **Confidence over information density.** A short, well-typeset profile beats a packed-but-busy one.
|
||||
- **Editorial restraint.** White space, deliberate type hierarchy, and a few well-chosen details say more than dozens of small components.
|
||||
- **Quiet conviction.** No hype copy, no growth-hack patterns, no "Trusted by" logo strips. The partner's own work and intro speak for themselves.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
**Editorial · Founder-led · Considered.**
|
||||
|
||||
The site reads like a thoughtful indie publication, not a SaaS landing page. Serif headlines, plenty of whitespace, deliberate typographic rhythm. Quietly opinionated — Twenty has a point of view about CRM (open-source, customizable, well-designed) and the site reflects that without shouting.
|
||||
|
||||
Tonal anchors:
|
||||
- Stripe's documentation for clarity, Linear's marketing for restraint, an editorial print magazine for typography choices.
|
||||
|
||||
## Anti-references
|
||||
|
||||
**Reject these patterns. They make the work read as generic AI / generic SaaS:**
|
||||
|
||||
- **Generic SaaS landing.** Big-number heroes, identical icon-grid cards, gradient text, navy + lime accent color schemes, "supercharge your workflow" language.
|
||||
- **Corporate enterprise tone.** Stock photos of diverse handshakes. "Trusted by Fortune 500" logo strips as the primary credibility move. Trust-badge bars.
|
||||
- **Bento templates.** Repetitive same-size cards. Vercel-style scroll-pin animations on every section.
|
||||
- **Side-stripe borders, gradient text, glassmorphism, hero-metric templates, identical card grids** — see impeccable's shared absolute bans.
|
||||
|
||||
## Strategic Design Principles
|
||||
|
||||
1. **Typography carries the design.** The brand has a serif/sans/mono trio. Hierarchy is set by scale + weight contrast, not by color or borders.
|
||||
2. **Restrained palette.** Tinted neutrals (black/white via CSS variables, with alpha-tone variants for text and borders) carry 90%+ of the surface. Accent color used sparingly when it appears at all.
|
||||
3. **Whitespace is a feature.** Tight cards feel cheap. Pages should breathe.
|
||||
4. **Asymmetry over grid.** A 12-col bento is the wrong shape for a profile page. Use asymmetric two-column layouts where one column does heavy lifting.
|
||||
5. **One opinionated detail per page.** Each surface should have one moment of editorial conviction (a typographic flourish, a precise micro-interaction, a deliberate space) rather than five generic flourishes.
|
||||
|
||||
## Accessibility
|
||||
|
||||
**WCAG AA + keyboard + screen reader baseline:**
|
||||
|
||||
- All interactive elements reachable by keyboard, focus visible (`outline: 2px solid`, not just color shift).
|
||||
- Semantic landmarks: `<header>`, `<main>`, `<nav>`, `<section aria-labelledby=…>`, headings in order.
|
||||
- All images with informational content have alt text. Decorative icons have `aria-hidden="true"`.
|
||||
- Body text ≥ 4.5:1 contrast; large text (≥18pt or 14pt bold) ≥ 3:1.
|
||||
- Respect `prefers-reduced-motion`. Animations stop, don't slow.
|
||||
- Forms have explicit labels. Errors are announced.
|
||||
|
||||
## Tech & Constraints
|
||||
|
||||
- Next.js 16 app router (Server Components by default, `'use client'` for interactivity).
|
||||
- Linaria styled-components (`@linaria/react`) for zero-runtime CSS-in-JS.
|
||||
- Lingui (`@lingui/react`) for i18n; never hardcode user-visible strings.
|
||||
- Theme tokens in `packages/twenty-website/src/theme/`. Colors are CSS variables resolved to OKLCH-tinted neutrals.
|
||||
- `@tabler/icons-react` for iconography (no Heroicons, no custom SVGs unless purposeful).
|
||||
- `@radix-ui/react-*` for primitives (Popover etc) where headless behavior is needed.
|
||||
|
||||
## Out of Scope for This File
|
||||
|
||||
- Detailed visual tokens (colors, type scale, motion specs) live in `DESIGN.md`.
|
||||
- Per-page IA decisions live in shape briefs (`docs/superpowers/specs/`).
|
||||
@@ -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>
|
||||
<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,28 @@ 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://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></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://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></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://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></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-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
|
||||
</p>
|
||||
|
||||
Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
|
||||
|
||||
# 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>
|
||||
|
||||
Binary file not shown.
|
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"]
|
||||
@@ -220,6 +224,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chromatic": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
{
|
||||
"command": "nx storybook:build {projectName}",
|
||||
"forwardAllArgs": false
|
||||
},
|
||||
"chromatic --storybook-build-dir=storybook-static {args.ci}"
|
||||
],
|
||||
"parallel": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": "--exit-zero-on-changes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nx/jest:jest": {
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
|
||||
+2
-5
@@ -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",
|
||||
@@ -52,10 +51,9 @@
|
||||
"packages/twenty-server",
|
||||
"packages/twenty-emails",
|
||||
"packages/twenty-ui",
|
||||
"packages/twenty-new-ui",
|
||||
"packages/twenty-utils",
|
||||
"packages/twenty-zapier",
|
||||
"packages/twenty-website",
|
||||
"packages/twenty-website-new",
|
||||
"packages/twenty-docs",
|
||||
"packages/twenty-e2e-testing",
|
||||
"packages/twenty-shared",
|
||||
@@ -64,7 +62,6 @@
|
||||
"packages/twenty-client-sdk",
|
||||
"packages/twenty-cli",
|
||||
"packages/create-twenty-app",
|
||||
"packages/twenty-codex-plugin",
|
||||
"packages/twenty-oxlint-rules",
|
||||
"packages/twenty-companion",
|
||||
"packages/twenty-claude-skills"
|
||||
|
||||
@@ -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>
|
||||
@@ -25,18 +25,26 @@ yarn twenty dev
|
||||
The scaffolder will:
|
||||
|
||||
1. Create a new project with TypeScript, linting, tests, and a preconfigured `twenty` CLI
|
||||
2. Start a local Twenty server via Docker (pulls the latest image automatically)
|
||||
3. Authenticate with the development API key
|
||||
2. Optionally start a local Twenty server (Docker)
|
||||
3. Open the browser for OAuth authentication
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| `--name <name>` | Set the app name |
|
||||
| `--display-name <displayName>` | Set the display name |
|
||||
| `--description <description>` | Set the description |
|
||||
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
|
||||
| Flag | Description |
|
||||
| ------------------------------ | --------------------------------------- |
|
||||
| `--example <name>` | Initialize from an example |
|
||||
| `--name <name>` | Set the app name (skips the prompt) |
|
||||
| `--display-name <displayName>` | Set the display name (skips the prompt) |
|
||||
| `--description <description>` | Set the description (skips the prompt) |
|
||||
| `--skip-local-instance` | Skip the local server setup prompt |
|
||||
|
||||
By default (no flags), a minimal app is generated with core files and an integration test. Use `--example` to start from a richer example:
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-twenty-app --example hello-world
|
||||
```
|
||||
|
||||
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -48,8 +56,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: make sure you are logged in to Twenty in the browser, then run `yarn twenty remote add`.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.11.0",
|
||||
"version": "2.4.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
@@ -32,6 +32,7 @@
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"inquirer": "^10.0.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.startcase": "^4.4.0",
|
||||
@@ -42,6 +43,7 @@
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/inquirer": "^9.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
@@ -50,7 +52,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"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommanderError } from 'commander';
|
||||
import {
|
||||
type AuthenticationMethod,
|
||||
CreateAppCommand,
|
||||
} from '@/create-app.command';
|
||||
import { CreateAppCommand } from '@/create-app.command';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const program = new Command(packageJson.name)
|
||||
@@ -15,26 +12,32 @@ const program = new Command(packageJson.name)
|
||||
'Output the current version of create-twenty-app.',
|
||||
)
|
||||
.argument('[directory]')
|
||||
.option('-n, --name <name>', 'Application name')
|
||||
.option('-d, --display-name <displayName>', 'Application display name')
|
||||
.option('--description <description>', 'Application description')
|
||||
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --url]')
|
||||
.option('--example <name>', 'Initialize from an example')
|
||||
.option('-n, --name <name>', 'Application name (skips prompt)')
|
||||
.option(
|
||||
'--authentication-method <method>',
|
||||
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
|
||||
'-d, --display-name <displayName>',
|
||||
'Application display name (skips prompt)',
|
||||
)
|
||||
.option(
|
||||
'--description <description>',
|
||||
'Application description (skips prompt)',
|
||||
)
|
||||
.option(
|
||||
'--skip-local-instance',
|
||||
'Skip the local Twenty instance setup prompt',
|
||||
)
|
||||
.option('-y, --yes', 'Auto-confirm prompts (e.g. start existing container)')
|
||||
.helpOption('-h, --help', 'Display this help message.')
|
||||
.action(
|
||||
async (
|
||||
directory?: string,
|
||||
options?: {
|
||||
example?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
apiUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
|
||||
@@ -51,33 +54,14 @@ const program = new Command(packageJson.name)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (
|
||||
options?.authenticationMethod &&
|
||||
!['oauth', 'apiKey'].includes(options.authenticationMethod)
|
||||
) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Error: --authentication-method must be "oauth" or "apiKey".',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options?.apiUrl) {
|
||||
console.warn(
|
||||
chalk.yellow('Warning: --api-url is deprecated. Use --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,
|
||||
authenticationMethod: options?.authenticationMethod,
|
||||
skipLocalInstance: options?.skipLocalInstance,
|
||||
yes: options?.yes,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,19 +2,8 @@ This is a [Twenty](https://twenty.com) application bootstrapped with [`create-tw
|
||||
|
||||
## Getting Started
|
||||
|
||||
This app was scaffolded with a local Twenty server running at [http://localhost:2020](http://localhost:2020).
|
||||
|
||||
Login with the default development credentials: `tim@apple.dev` / `tim@apple.dev`.
|
||||
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
- `yarn twenty dev` - Start the development server and sync your app
|
||||
- `yarn twenty docker:status` - Check the local Twenty server status
|
||||
- `yarn twenty docker:start` - Start the local Twenty server
|
||||
- `yarn test` - Run integration tests
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
|
||||
|
||||
@@ -11,7 +11,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TWENTY_DEPLOY_URL: http://localhost:2020
|
||||
TWENTY_DEPLOY_URL: http://localhost:3000
|
||||
|
||||
concurrency:
|
||||
group: cd-${{ github.ref }}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-client-sdk": "TO-BE-GENERATED"
|
||||
"twenty-client-sdk": "TO-BE-GENERATED",
|
||||
"twenty-sdk": "TO-BE-GENERATED"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
@@ -25,7 +26,6 @@
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"twenty-sdk": "TO-BE-GENERATED",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^3.1.1"
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
-7
@@ -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,
|
||||
});
|
||||
-19
@@ -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,
|
||||
});
|
||||
-37
@@ -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,109 +1,96 @@
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import { downloadExample } from '@/utils/download-example';
|
||||
import { convertToLabel } from '@/utils/convert-to-label';
|
||||
import { install } from '@/utils/install';
|
||||
import { tryGitInit } from '@/utils/try-git-init';
|
||||
import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import inquirer from 'inquirer';
|
||||
import kebabCase from 'lodash.kebabcase';
|
||||
import * as path from 'path';
|
||||
import { basename } from 'path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
authLogin,
|
||||
authLoginOAuth,
|
||||
checkDockerRunning,
|
||||
ConfigService,
|
||||
DEV_API_KEY,
|
||||
DEV_API_URL,
|
||||
containerExists,
|
||||
detectLocalServer,
|
||||
serverStart,
|
||||
} from 'twenty-sdk/cli';
|
||||
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
|
||||
import {
|
||||
getDockerInstallInstructions,
|
||||
isDockerInstalled,
|
||||
} from '@/utils/docker-install';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
|
||||
const IMAGE = 'twentycrm/twenty-app-dev:latest';
|
||||
|
||||
export type AuthenticationMethod = 'oauth' | 'apiKey';
|
||||
|
||||
type CreateAppOptions = {
|
||||
directory?: string;
|
||||
example?: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
serverUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
export class CreateAppCommand {
|
||||
private stepCounter = 0;
|
||||
private totalSteps = 0;
|
||||
private static TOTAL_STEPS = 4;
|
||||
|
||||
async execute(options: CreateAppOptions = {}): Promise<void> {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
this.getAppInfos(options);
|
||||
|
||||
const serverUrl = options.serverUrl ?? DEV_API_URL;
|
||||
|
||||
const skipLocalInstance = serverUrl !== DEV_API_URL;
|
||||
|
||||
if (!skipLocalInstance && !isDockerInstalled()) {
|
||||
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (skipLocalInstance && options.authenticationMethod === 'apiKey') {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'API key authentication is only supported on a local Docker instance. Ignoring and switching to OAuth authentication.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const authenticationMethod = skipLocalInstance
|
||||
? 'oauth'
|
||||
: (options.authenticationMethod ?? 'apiKey');
|
||||
await this.getAppInfos(options);
|
||||
|
||||
try {
|
||||
await this.validateDirectory(appDirectory);
|
||||
|
||||
this.totalSteps = this.computeTotalSteps({
|
||||
skipLocalInstance,
|
||||
});
|
||||
|
||||
this.stepCounter = 0;
|
||||
|
||||
const dockerPullPromise =
|
||||
!skipLocalInstance && checkDockerRunning()
|
||||
? this.pullImageInBackground()
|
||||
: Promise.resolve(false);
|
||||
|
||||
this.logPlan({ appName, appDisplayName, appDescription, appDirectory });
|
||||
|
||||
this.logNextStep('Creating project directory');
|
||||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
this.logDetail(appDirectory);
|
||||
|
||||
this.logNextStep('Scaffolding project files');
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
const confirmed = await this.promptScaffoldConfirmation({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
onProgress: (message) => this.logDetail(message),
|
||||
autoConfirm: options.yes,
|
||||
});
|
||||
|
||||
this.logNextStep('Installing dependencies');
|
||||
if (!confirmed) {
|
||||
console.log(chalk.gray('\nScaffolding cancelled.'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
this.logStep(1, 'Creating project directory');
|
||||
await fs.ensureDir(appDirectory);
|
||||
this.logDetail(appDirectory);
|
||||
|
||||
this.logStep(2, 'Scaffolding project files');
|
||||
|
||||
if (options.example) {
|
||||
const exampleSucceeded = await this.tryDownloadExample(
|
||||
options.example,
|
||||
appDirectory,
|
||||
);
|
||||
|
||||
if (!exampleSucceeded) {
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
onProgress: (message) => this.logDetail(message),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
onProgress: (message) => this.logDetail(message),
|
||||
});
|
||||
}
|
||||
|
||||
this.logStep(3, 'Installing dependencies');
|
||||
await install(appDirectory, (message) => this.logDetail(message));
|
||||
|
||||
this.logNextStep('Initializing Git repository');
|
||||
|
||||
this.logStep(4, 'Initializing Git repository');
|
||||
const gitInitialized = await tryGitInit(appDirectory);
|
||||
|
||||
if (gitInitialized) {
|
||||
@@ -117,56 +104,38 @@ export class CreateAppCommand {
|
||||
|
||||
console.log('');
|
||||
|
||||
let hasLocalServer = false;
|
||||
let authSucceeded = false;
|
||||
let resolvedServerUrl = serverUrl;
|
||||
let serverReady = skipLocalInstance;
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
this.logNextStep('Starting Twenty server');
|
||||
const serverResult = await this.ensureDockerServer(dockerPullPromise);
|
||||
if (!options.skipLocalInstance) {
|
||||
const existingServerUrl = await detectLocalServer();
|
||||
|
||||
if (isDefined(serverResult.url)) {
|
||||
resolvedServerUrl = 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);
|
||||
if (existingServerUrl) {
|
||||
hasLocalServer = true;
|
||||
authSucceeded = await this.promptConnectToLocal(existingServerUrl);
|
||||
} else {
|
||||
this.logDetail('Using development API key');
|
||||
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
|
||||
const shouldStart = await this.shouldStartServer(options.yes);
|
||||
|
||||
if (shouldStart) {
|
||||
const startResult = await serverStart({
|
||||
onProgress: (message: string) => console.log(chalk.gray(message)),
|
||||
});
|
||||
|
||||
if (startResult.success) {
|
||||
hasLocalServer = true;
|
||||
authSucceeded = await this.promptConnectToLocal(
|
||||
startResult.data.url,
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.yellow(`\n${startResult.error.message}`));
|
||||
}
|
||||
} else {
|
||||
this.logServerSkipped();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logNextStep('Installing application');
|
||||
|
||||
let syncSucceeded = false;
|
||||
|
||||
if (serverReady && authSucceeded) {
|
||||
syncSucceeded = await this.syncApplication(appDirectory);
|
||||
|
||||
if (!syncSucceeded) {
|
||||
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this.logDetail('Skipped (server or authentication not available)');
|
||||
}
|
||||
|
||||
if (syncSucceeded) {
|
||||
await this.openMainPage(appDirectory, resolvedServerUrl);
|
||||
}
|
||||
|
||||
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
|
||||
this.logSuccess(appDirectory, hasLocalServer, authSucceeded);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('\nCreate application failed:'),
|
||||
@@ -176,42 +145,66 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private computeTotalSteps({
|
||||
skipLocalInstance,
|
||||
}: {
|
||||
skipLocalInstance: boolean;
|
||||
}): number {
|
||||
let steps = 4; // directory, scaffold, install, git
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
steps += 1; // start server
|
||||
}
|
||||
|
||||
steps += 1; // authenticate (oauth or apiKey)
|
||||
steps += 1; // sync application
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private getAppInfos(options: CreateAppOptions): {
|
||||
private async getAppInfos(options: CreateAppOptions): Promise<{
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
} {
|
||||
}> {
|
||||
const { directory } = options;
|
||||
|
||||
const hasName = isDefined(options.name) || isDefined(directory);
|
||||
const hasDisplayName = isDefined(options.displayName);
|
||||
const hasDescription = isDefined(options.description);
|
||||
const hasExample = isDefined(options.example);
|
||||
|
||||
const { name, displayName, description } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Application name:',
|
||||
when: () => !hasName && !hasExample,
|
||||
default: 'my-twenty-app',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'displayName',
|
||||
message: 'Application display name:',
|
||||
when: () => !hasDisplayName && !hasExample,
|
||||
default: (answers: { name?: string }) => {
|
||||
return convertToLabel(
|
||||
answers?.name ?? options.name ?? directory ?? '',
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
message: 'Application description (optional):',
|
||||
when: () => !hasDescription && !hasExample,
|
||||
default: '',
|
||||
},
|
||||
]);
|
||||
|
||||
const appName = (
|
||||
options.name ??
|
||||
options.directory ??
|
||||
name ??
|
||||
directory ??
|
||||
options.example ??
|
||||
'my-twenty-app'
|
||||
).trim();
|
||||
|
||||
const appDisplayName =
|
||||
options.displayName?.trim() || convertToLabel(appName);
|
||||
(options.displayName ?? displayName)?.trim() || convertToLabel(appName);
|
||||
|
||||
const appDescription = (options.description ?? '').trim();
|
||||
const appDescription = (options.description ?? description ?? '').trim();
|
||||
|
||||
const appDirectory = options.directory
|
||||
? path.join(CURRENT_EXECUTION_DIRECTORY, options.directory)
|
||||
const appDirectory = directory
|
||||
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
|
||||
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
|
||||
|
||||
return { appName, appDisplayName, appDirectory, appDescription };
|
||||
@@ -230,17 +223,54 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private logPlan({
|
||||
private async tryDownloadExample(
|
||||
example: string,
|
||||
appDirectory: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await downloadExample(example, appDirectory);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
|
||||
),
|
||||
);
|
||||
|
||||
const { useTemplate } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'useTemplate',
|
||||
message: 'Would you like to create a default template app instead?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!useTemplate) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Clean up any partial files from the failed download
|
||||
await fs.emptyDir(appDirectory);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async promptScaffoldConfirmation({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
autoConfirm,
|
||||
}: {
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}): void {
|
||||
autoConfirm?: boolean;
|
||||
}): Promise<boolean> {
|
||||
console.log(chalk.blue('\nCreating Twenty Application\n'));
|
||||
console.log(chalk.white(` Name: ${appName}`));
|
||||
console.log(chalk.white(` Display name: ${appDisplayName}`));
|
||||
@@ -250,13 +280,43 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
console.log(chalk.white(` Directory: ${appDirectory}`));
|
||||
|
||||
console.log(chalk.white('\nThe following steps will be performed:\n'));
|
||||
console.log(chalk.gray(' 1. Create project directory'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
' 2. Scaffold project files from base template\n' +
|
||||
' - Copy template files\n' +
|
||||
' - Configure dotfiles (.gitignore, .github)\n' +
|
||||
' - Generate unique application identifiers\n' +
|
||||
' - Update package.json with app name and SDK versions',
|
||||
),
|
||||
);
|
||||
console.log(chalk.gray(' 3. Install dependencies (yarn)'));
|
||||
console.log(
|
||||
chalk.gray(' 4. Initialize Git repository with initial commit'),
|
||||
);
|
||||
console.log('');
|
||||
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { proceed } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'proceed',
|
||||
message: 'Proceed?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return proceed;
|
||||
}
|
||||
|
||||
private logNextStep(title: string): void {
|
||||
this.stepCounter++;
|
||||
private logStep(step: number, title: string): void {
|
||||
console.log(
|
||||
chalk.blue(`\n[${this.stepCounter}/${this.totalSteps}]`) +
|
||||
chalk.blue(`\n[${step}/${CreateAppCommand.TOTAL_STEPS}]`) +
|
||||
chalk.white(` ${title}...`),
|
||||
);
|
||||
}
|
||||
@@ -265,298 +325,99 @@ export class CreateAppCommand {
|
||||
console.log(chalk.gray(` → ${message}`));
|
||||
}
|
||||
|
||||
private pullImageInBackground(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('docker', ['pull', IMAGE], { stdio: 'ignore' });
|
||||
private async shouldStartServer(autoConfirm?: boolean): Promise<boolean> {
|
||||
console.log(
|
||||
chalk.white(
|
||||
'\n A local Twenty instance is required for app development.\n' +
|
||||
' It provides the API and schema your application connects to.\n',
|
||||
),
|
||||
);
|
||||
|
||||
child.on('close', (code) => resolve(code === 0));
|
||||
child.on('error', () => resolve(false));
|
||||
});
|
||||
if (checkDockerRunning() && containerExists()) {
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { startExisting } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'startExisting',
|
||||
message:
|
||||
'An existing Twenty server container was found. Would you like to start it?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return startExisting;
|
||||
}
|
||||
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { startDocker } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'startDocker',
|
||||
message:
|
||||
'No running Twenty instance found. Would you like to start one using Docker?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return startDocker;
|
||||
}
|
||||
|
||||
private async ensureDockerServer(
|
||||
dockerPullPromise: Promise<boolean>,
|
||||
): Promise<{ url?: string }> {
|
||||
if (!checkDockerRunning()) {
|
||||
private logServerSkipped(): void {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'\n To start a Twenty instance later:\n' +
|
||||
' yarn twenty server start\n\n' +
|
||||
' To connect to a remote instance instead:\n' +
|
||||
' yarn twenty remote add\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async promptConnectToLocal(serverUrl: string): Promise<boolean> {
|
||||
console.log(
|
||||
chalk.white(
|
||||
'\n Authentication links your app to a Twenty instance so you can\n' +
|
||||
' sync custom objects, fields, and roles during development.\n' +
|
||||
' This will open a browser window to complete the OAuth flow.\n',
|
||||
),
|
||||
);
|
||||
|
||||
const { shouldAuthenticate } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'shouldAuthenticate',
|
||||
message: `Authenticate to the local Twenty instance (${serverUrl})?`,
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!shouldAuthenticate) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\n Docker is installed but not running.\n' +
|
||||
' Please start Docker and run this command again.\n',
|
||||
chalk.gray(
|
||||
'\n Authentication skipped. To authenticate later:\n' +
|
||||
` yarn twenty remote add --local\n`,
|
||||
),
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
this.logDetail('Ensuring latest Twenty server image...');
|
||||
|
||||
const pullSucceeded = await dockerPullPromise;
|
||||
|
||||
if (!pullSucceeded) {
|
||||
this.logDetail(
|
||||
'Image pull failed, continuing with cached image if available...',
|
||||
);
|
||||
}
|
||||
|
||||
const startResult = await serverStart({
|
||||
onProgress: (message: string) => this.logDetail(message),
|
||||
});
|
||||
|
||||
if (startResult.success) {
|
||||
return { url: startResult.data.url };
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`\n ${startResult.error.message}`));
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private async openMainPage(
|
||||
appDirectory: string,
|
||||
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> {
|
||||
await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirm',
|
||||
message: 'Press Enter to open the browser for authentication...',
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const result = await authLogin({
|
||||
apiKey: DEV_API_KEY,
|
||||
const result = await authLoginOAuth({
|
||||
apiUrl: serverUrl,
|
||||
remote: 'local',
|
||||
});
|
||||
@@ -565,68 +426,21 @@ export class CreateAppCommand {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote('local');
|
||||
this.logDetail('Authenticated as tim@apple.dev (development API key)');
|
||||
|
||||
return true;
|
||||
} else {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private deriveRemoteName(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/\./g, '-');
|
||||
} catch {
|
||||
return 'remote';
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const remoteName = this.deriveRemoteName(serverUrl);
|
||||
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
|
||||
this.logDetail('Opening browser for OAuth...');
|
||||
|
||||
const result = await authLoginOAuth({ apiUrl: serverUrl });
|
||||
|
||||
if (result.success) {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote(remoteName);
|
||||
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` OAuth failed: ${result.error.message}\n` +
|
||||
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
'Authentication failed. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -636,13 +450,12 @@ export class CreateAppCommand {
|
||||
|
||||
private logSuccess(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
hasLocalServer: boolean,
|
||||
authSucceeded: boolean,
|
||||
): void {
|
||||
const dirName = basename(appDirectory);
|
||||
|
||||
console.log(chalk.green('\n✔ Application created successfully!\n'));
|
||||
|
||||
console.log(chalk.white(' Next steps:\n'));
|
||||
|
||||
let stepNumber = 1;
|
||||
@@ -652,19 +465,17 @@ export class CreateAppCommand {
|
||||
stepNumber++;
|
||||
|
||||
if (!authSucceeded) {
|
||||
const remoteCommand = hasLocalServer
|
||||
? 'yarn twenty remote add --local'
|
||||
: 'yarn twenty remote add';
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
|
||||
console.log(
|
||||
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
|
||||
);
|
||||
console.log(chalk.cyan(` ${remoteCommand}\n`));
|
||||
stepNumber++;
|
||||
}
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Start developing`));
|
||||
console.log(chalk.cyan(' yarn twenty dev\n'));
|
||||
stepNumber++;
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
|
||||
console.log(chalk.cyan(` ${serverUrl}\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';
|
||||
@@ -33,10 +32,8 @@ const TEMPLATE_PACKAGE_JSON = {
|
||||
license: 'MIT',
|
||||
scripts: { twenty: 'twenty' },
|
||||
dependencies: {
|
||||
'twenty-client-sdk': '0.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'twenty-sdk': '0.0.0',
|
||||
'twenty-client-sdk': '0.0.0',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,7 +146,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
join(testAppDirectory, 'package.json'),
|
||||
);
|
||||
expect(packageJson.name).toBe('my-test-app');
|
||||
expect(packageJson.devDependencies['twenty-sdk']).toBe(
|
||||
expect(packageJson.dependencies['twenty-sdk']).toBe(
|
||||
createTwentyAppPackageJson.version,
|
||||
);
|
||||
expect(packageJson.dependencies['twenty-client-sdk']).toBe(
|
||||
@@ -176,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) {
|
||||
@@ -120,8 +119,7 @@ const updatePackageJson = async ({
|
||||
const packageJson = await fs.readJson(join(appDirectory, 'package.json'));
|
||||
|
||||
packageJson.name = appName;
|
||||
packageJson.devDependencies['twenty-sdk'] =
|
||||
createTwentyAppPackageJson.version;
|
||||
packageJson.dependencies['twenty-sdk'] = createTwentyAppPackageJson.version;
|
||||
packageJson.dependencies['twenty-client-sdk'] =
|
||||
createTwentyAppPackageJson.version;
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const DOCKER_DOWNLOAD_URLS: Record<string, string> = {
|
||||
darwin: 'https://docs.docker.com/desktop/setup/install/mac-install/',
|
||||
win32: 'https://docs.docker.com/desktop/setup/install/windows-install/',
|
||||
linux: 'https://docs.docker.com/engine/install/',
|
||||
};
|
||||
|
||||
export const isDockerInstalled = (): boolean => {
|
||||
try {
|
||||
execSync('docker --version', { stdio: 'ignore' });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDockerInstallInstructions = (): string => {
|
||||
const url =
|
||||
DOCKER_DOWNLOAD_URLS[process.platform] ?? DOCKER_DOWNLOAD_URLS.linux;
|
||||
|
||||
return [
|
||||
' Docker is required but not installed.',
|
||||
'',
|
||||
` Install Docker: ${url}`,
|
||||
'',
|
||||
' Then run this command again.',
|
||||
'',
|
||||
' Alternatively, connect to an existing Twenty instance:',
|
||||
' npx create-twenty-app@latest my-twenty-app --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
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
|
||||
frontComponentUniversalIdentifier: '6c2f1a8d-4b9e-4f7a-9c5d-1e8b2a3d4c5f',
|
||||
label: 'Contributor Stats',
|
||||
icon: 'IconChartBar',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'contributor',
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
|
||||
frontComponentUniversalIdentifier: '08f40f82-24ed-4f3e-8c99-695151e90e38',
|
||||
label: 'Fetch Contributors',
|
||||
icon: 'IconUsers',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'contributor',
|
||||
});
|
||||
+13
-1
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useRecordId } from 'twenty-sdk/front-component';
|
||||
import {
|
||||
enqueueSnackbar,
|
||||
objectMetadataItem,
|
||||
useRecordId,
|
||||
} from 'twenty-sdk/front-component';
|
||||
|
||||
import { THEME } from 'src/modules/github/contributor/components/theme';
|
||||
import { callAppRoute } from 'src/modules/shared/call-app-route';
|
||||
@@ -678,4 +682,12 @@ export default defineFrontComponent({
|
||||
description:
|
||||
'Displays time-bucketed charts of PRs authored (merged only), merged and reviewed by a contributor over the last week, month, 3 months or year.',
|
||||
component: ContributorStats,
|
||||
command: {
|
||||
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
|
||||
label: 'Contributor Stats',
|
||||
icon: 'IconChartBar',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'contributor',
|
||||
},
|
||||
});
|
||||
|
||||
+9
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
enqueueSnackbar,
|
||||
objectMetadataItem,
|
||||
unmountFrontComponent,
|
||||
updateProgress,
|
||||
} from 'twenty-sdk/front-component';
|
||||
@@ -94,4 +95,12 @@ export default defineFrontComponent({
|
||||
'Fetches contributors from every configured GitHub repo (GITHUB_REPOS) and upserts them as Contributor records.',
|
||||
isHeadless: true,
|
||||
component: FetchContributors,
|
||||
command: {
|
||||
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
|
||||
label: 'Fetch Contributors',
|
||||
icon: 'IconUsers',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'contributor',
|
||||
},
|
||||
});
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
|
||||
frontComponentUniversalIdentifier: '9430e4fc-9ecb-428d-9bde-2babeb1f452f',
|
||||
label: 'Fetch Issues',
|
||||
icon: 'IconBug',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'issue',
|
||||
});
|
||||
+9
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
enqueueSnackbar,
|
||||
objectMetadataItem,
|
||||
unmountFrontComponent,
|
||||
updateProgress,
|
||||
} from 'twenty-sdk/front-component';
|
||||
@@ -97,4 +98,12 @@ export default defineFrontComponent({
|
||||
description: 'Fetches issues from GitHub repos',
|
||||
isHeadless: true,
|
||||
component: FetchIssues,
|
||||
command: {
|
||||
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
|
||||
label: 'Fetch Issues',
|
||||
icon: 'IconBug',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'issue',
|
||||
},
|
||||
});
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
|
||||
frontComponentUniversalIdentifier: '7c397b0c-8b19-4fac-924a-8f6aa1dece78',
|
||||
label: 'Fetch Project Items',
|
||||
icon: 'IconLayoutKanban',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'projectItem',
|
||||
});
|
||||
+9
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
enqueueSnackbar,
|
||||
objectMetadataItem,
|
||||
unmountFrontComponent,
|
||||
updateProgress,
|
||||
} from 'twenty-sdk/front-component';
|
||||
@@ -96,4 +97,12 @@ export default defineFrontComponent({
|
||||
description: 'Fetches project items from GitHub Projects V2',
|
||||
isHeadless: true,
|
||||
component: FetchProjectItems,
|
||||
command: {
|
||||
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
|
||||
label: 'Fetch Project Items',
|
||||
icon: 'IconLayoutKanban',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'projectItem',
|
||||
},
|
||||
});
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: '0b24e6d6-da0c-4c0e-8d88-5bfd7f3cd75a',
|
||||
frontComponentUniversalIdentifier: '5e2ba8df-1db5-4964-94d3-44cccfd791a0',
|
||||
label: 'Fetch Pull Requests',
|
||||
icon: 'IconGitPullRequest',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'pullRequest',
|
||||
});
|
||||
+9
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
enqueueSnackbar,
|
||||
objectMetadataItem,
|
||||
unmountFrontComponent,
|
||||
updateProgress,
|
||||
} from 'twenty-sdk/front-component';
|
||||
@@ -100,4 +101,12 @@ export default defineFrontComponent({
|
||||
description: 'Fetches pull requests and reviews from GitHub repos',
|
||||
isHeadless: true,
|
||||
component: FetchPrs,
|
||||
command: {
|
||||
universalIdentifier: '0b24e6d6-da0c-4c0e-8d88-5bfd7f3cd75a',
|
||||
label: 'Fetch Pull Requests',
|
||||
icon: 'IconGitPullRequest',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'pullRequest',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
-1
@@ -3,7 +3,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'e8031eca-d6ea-4a4b-b828-38227dba896a',
|
||||
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
position: 0,
|
||||
type: NavigationMenuItemType.OBJECT,
|
||||
targetObjectUniversalIdentifier: POST_CARD_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],
|
||||
});
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { defineViewField } from 'twenty-sdk/define';
|
||||
import { ALL_POST_CARDS_VIEW_ID } from '../views/all-post-cards.view';
|
||||
|
||||
export default defineViewField({
|
||||
fieldMetadataUniversalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
|
||||
position: 5,
|
||||
universalIdentifier: 'cd582d11-ea21-4dc3-b9c1-0298ce3b6b54',
|
||||
viewUniversalIdentifier: ALL_POST_CARDS_VIEW_ID,
|
||||
isVisible: true,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ A brief note on the overall tone of the conversation (collaborative, tense, expl
|
||||
- If the transcript is very short (under 200 words), provide a brief 2-3 sentence summary instead of the full structure.
|
||||
|
||||
## Saving the Summary
|
||||
After generating the summary, use \`update_one_call_recording\` to save it in the \`summary\` field with the format:
|
||||
After generating the summary, use \`update_callRecording\` to save it in the \`summary\` field with the format:
|
||||
\`\`\`json
|
||||
{ "summary": { "blocknote": null, "markdown": "<your summary>" } }
|
||||
\`\`\`
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
.twenty
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.d.ts
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "import", "unicorn"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"ignorePatterns": ["node_modules", "dist"],
|
||||
"rules": {
|
||||
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
|
||||
"no-console": [
|
||||
"warn",
|
||||
{ "allow": ["group", "groupCollapsed", "groupEnd"] }
|
||||
],
|
||||
"no-control-regex": "off",
|
||||
"no-debugger": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-undef": "off",
|
||||
"no-unused-vars": "off",
|
||||
"no-redeclare": "off",
|
||||
|
||||
"import/no-duplicates": "error",
|
||||
|
||||
"typescript/no-redeclare": "error",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/explicit-function-return-type": "off",
|
||||
"typescript/explicit-module-boundary-types": "off",
|
||||
"typescript/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
"allowInterfaces": "with-single-extends"
|
||||
}
|
||||
],
|
||||
"typescript/no-empty-function": "off",
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/no-explicit-any": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["twenty-shared", "twenty-shared/*"],
|
||||
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
# People Data Labs enrichment app
|
||||
|
||||
Enriches **Person** and **Company** records with [People Data Labs](https://www.peopledatalabs.com/) (PDL) data.
|
||||
|
||||
> **Status: data-model scaffold.** This package defines the fields, relation, indexes,
|
||||
> views, role, and manifest. The enrichment **logic function (the "mapper") is not yet
|
||||
> implemented** — see [What the mapper must do](#what-the-mapper-must-do).
|
||||
|
||||
---
|
||||
|
||||
## Data-model decisions
|
||||
|
||||
### Bundle scope
|
||||
|
||||
Only the core PDL company fields are defined. Premium / Comprehensive / specialized fields
|
||||
(`inferred_revenue`, `linkedin_follower_count`, employee growth/churn/tenure, parent /
|
||||
subsidiary, exec movement, top employers, `funding_details`, …) are **out of scope** for this app.
|
||||
|
||||
### Enums → SELECT / MULTI_SELECT
|
||||
|
||||
Every PDL enum that has a canonical file is a SELECT, **validated 0-missing/0-extra against
|
||||
PDL schema v34.1**:
|
||||
|
||||
| Field | Type | Options |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------ |
|
||||
| `pdlSeniority` (`job_title_levels`, array) | MULTI_SELECT | 10 |
|
||||
| `pdlFundingStages` (`funding_stages`, array) | MULTI_SELECT | 29 |
|
||||
| `pdlIndustry` / `pdlJobCompanyIndustry` (`industry`) | SELECT | 147 |
|
||||
| `pdlJobTitleSubRole` (`job_title_sub_role`) | SELECT | 106 |
|
||||
| `pdlJobTitleClass`, `pdlInferredSalary`, `pdlSex`, `pdlCompanyType`, `pdlSizeRange`, `pdlJobCompanySize`, `pdlLatestFundingStage`, `pdlLocationContinent`, `pdlLocationMetro`, `pdlMicExchange` | SELECT | 5 / 11 / 2 / 6 / 8 / 8 / 29 / 7 / 384 / 70 |
|
||||
|
||||
- Option `value`s are normalized to **GraphQL enum names** (`united states` → `UNITED_STATES`):
|
||||
uppercase, accents stripped, non-alphanumeric → `_`, digit-leading prefixed.
|
||||
- Option `universalIdentifier`s are **unique per field** (shared enums like industry get a
|
||||
separate id-set per field).
|
||||
- **Stays `TEXT`** (no canonical PDL enum file exists): `pdlIndustryDetail` (`industry_v2`),
|
||||
`pdlJobOnetCode`, `pdlLocationRegion`.
|
||||
|
||||
### Standard-field mapping
|
||||
|
||||
`pdl*` shadows are **removed** where an equivalent standard field exists; the mapper writes
|
||||
the standard field instead:
|
||||
|
||||
| Object | Removed shadow → standard target |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Person | `pdlLinkedinUrl`→`linkedinLink`, `pdlJobTitle`→`jobTitle`, `pdlFullName`→`name`, `pdlWorkEmail`/`pdlPersonalEmails`→`emails`, `pdlMobilePhone`/`pdlPhoneNumbers`→`phones` |
|
||||
| Company | `pdlLinkedinUrl`→`linkedinLink`, `pdlWebsite`→`domainName`, `pdlDisplayName`→`name` |
|
||||
|
||||
Shadows are **kept** where no reliable standard field is available: `pdlEmployeeCount`,
|
||||
`pdlTwitterUrl`.
|
||||
_Trade-off:_ PDL's work/personal-email and mobile/other-phone distinction is dropped (folded
|
||||
into the standard bags).
|
||||
|
||||
### Location → ADDRESS composite
|
||||
|
||||
- **Company** location → the **standard `address`** composite (street/city/state/postcode/country/geo).
|
||||
- **Person** has no standard address field → dedicated **`pdlLocation` (ADDRESS)**.
|
||||
- `pdlLocationMetro` (both) and `pdlLocationContinent` (company) stay SELECT — ADDRESS has no slot.
|
||||
_Trade-off:_ ADDRESS `country` is free text, so the country SELECT was dropped.
|
||||
|
||||
### Relation
|
||||
|
||||
Dedicated **`pdlCurrentCompany`** (Person `MANY_TO_ONE` → Company) ↔ inverse
|
||||
**`pdlCurrentEmployees`** (Company `ONE_TO_MANY` → Person). Deliberately **not** the standard
|
||||
`company` relation, so PDL's detected employer can't overwrite the user's CRM account link.
|
||||
|
||||
### Enrichment metadata
|
||||
|
||||
- `pdlId` — PDL record id (re-enrich by id: more precise than by email).
|
||||
- `pdlLikelihood` (Person, NUMBER) — PDL match confidence 1–10.
|
||||
- `pdlEnrichmentStatus` (SELECT: `MATCHED` / `NOT_FOUND` / `ERROR`) — distinguishes
|
||||
"no match" from "never tried" (drives re-enrichment scheduling).
|
||||
- `pdlLastEnrichedAt` (DATE_TIME), `pdlRawPayload` (RAW_JSON, full response).
|
||||
|
||||
### Other
|
||||
|
||||
- `pdlTotalFunding` is `CURRENCY` (mapper must convert the bare USD float → micros).
|
||||
- **Indexes**: `pdlId` and `pdlLastEnrichedAt` on both objects.
|
||||
- **Views**: a curated "People Data Labs" TABLE view per object.
|
||||
- **Role**: read/update on Person & Company (object-level; tighten to field-scoped later).
|
||||
|
||||
---
|
||||
|
||||
## What the mapper must do
|
||||
|
||||
The logic function (to be built) must:
|
||||
|
||||
**Orchestration**
|
||||
|
||||
1. Trigger via manual command-menu action / record create / batch (TBD).
|
||||
2. Call PDL Person and/or Company Enrichment with `PDL_API_KEY`; pass `min_likelihood` /
|
||||
`required_fields` to control match quality.
|
||||
3. On `200` → write fields + set `pdlEnrichmentStatus = MATCHED`; on `404` →
|
||||
`NOT_FOUND`; on error → `ERROR`.
|
||||
4. Respect PDL rate limits (queue / throttle on `429`).
|
||||
5. **TTL guard**: skip re-enrichment if `pdlLastEnrichedAt` is recent; prefer re-enriching by `pdlId`.
|
||||
|
||||
**Field writing**
|
||||
|
||||
6. Write **standard fields** (fill-only-if-empty to avoid overwriting user data):
|
||||
Person `name`, `emails`, `phones`, `linkedinLink`, `jobTitle`; Company `name`,
|
||||
`domainName`, `linkedinLink`, `address`.
|
||||
7. Write `pdl*` fields for everything else.
|
||||
8. **SELECT guard**: only write a SELECT/MULTI_SELECT value if the normalized value exists in
|
||||
the field's option set; otherwise skip and keep it in `pdlRawPayload` (handles PDL schema
|
||||
versions newer than v34.1). Use the same normalization as the option `value`s.
|
||||
9. **MULTI_SELECT** arrays: `job_title_levels` → `pdlSeniority`; `funding_stages` → `pdlFundingStages`.
|
||||
10. **CURRENCY**: `total_funding_raised` (USD float) → `{ amountMicros: value × 1_000_000, currencyCode: 'USD' }`.
|
||||
11. **ADDRESS**: split PDL `location.*` into the composite — Company → standard `address`,
|
||||
Person → `pdlLocation`.
|
||||
12. **Relation**: resolve `job_company_id` → find/upsert a Company record → link `pdlCurrentCompany`.
|
||||
13. **Dates**: handle partial PDL dates (`YYYY`, `YYYY-MM`) for `job_start_date`,
|
||||
`last_funding_date`, `birth_date`.
|
||||
14. Always set `pdlId`, `pdlLastEnrichedAt`, `pdlRawPayload`, `pdlLikelihood` (person).
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "people-data-labs",
|
||||
"version": "0.1.0",
|
||||
"description": "People Data Labs enrichment app 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 --passWithNoTests",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-client-sdk": "2.4.0",
|
||||
"twenty-sdk": "2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'People Data Labs',
|
||||
description: 'Enrich People and Companies with People Data Labs data.',
|
||||
serverVariables: {
|
||||
PDL_API_KEY: {
|
||||
description: 'People Data Labs API key',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import { type SelectOptionMeta } from 'src/types/select-option-meta.type';
|
||||
|
||||
export const ENRICHMENT_STATUS_OPTIONS: readonly SelectOptionMeta[] = [
|
||||
{
|
||||
key: 'matched',
|
||||
value: 'MATCHED',
|
||||
label: 'Matched',
|
||||
color: 'green',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
key: 'notFound',
|
||||
value: 'NOT_FOUND',
|
||||
label: 'No Match',
|
||||
color: 'gray',
|
||||
position: 1,
|
||||
},
|
||||
{ key: 'error', value: 'ERROR', label: 'Error', color: 'red', position: 2 },
|
||||
];
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
import { type SelectOptionMeta } from 'src/types/select-option-meta.type';
|
||||
|
||||
export const FUNDING_STAGE_OPTIONS: readonly SelectOptionMeta[] = [
|
||||
{
|
||||
key: 'angel',
|
||||
value: 'ANGEL',
|
||||
label: 'Angel',
|
||||
color: 'blue',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
key: 'convertibleNote',
|
||||
value: 'CONVERTIBLE_NOTE',
|
||||
label: 'Convertible Note',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
key: 'corporateRound',
|
||||
value: 'CORPORATE_ROUND',
|
||||
label: 'Corporate Round',
|
||||
color: 'green',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
key: 'debtFinancing',
|
||||
value: 'DEBT_FINANCING',
|
||||
label: 'Debt Financing',
|
||||
color: 'orange',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
key: 'equityCrowdfunding',
|
||||
value: 'EQUITY_CROWDFUNDING',
|
||||
label: 'Equity Crowdfunding',
|
||||
color: 'purple',
|
||||
position: 4,
|
||||
},
|
||||
{
|
||||
key: 'fundingRound',
|
||||
value: 'FUNDING_ROUND',
|
||||
label: 'Funding Round',
|
||||
color: 'yellow',
|
||||
position: 5,
|
||||
},
|
||||
{ key: 'grant', value: 'GRANT', label: 'Grant', color: 'pink', position: 6 },
|
||||
{
|
||||
key: 'initialCoinOffering',
|
||||
value: 'INITIAL_COIN_OFFERING',
|
||||
label: 'Initial Coin Offering',
|
||||
color: 'cyan',
|
||||
position: 7,
|
||||
},
|
||||
{
|
||||
key: 'nonEquityAssistance',
|
||||
value: 'NON_EQUITY_ASSISTANCE',
|
||||
label: 'Non-Equity Assistance',
|
||||
color: 'brown',
|
||||
position: 8,
|
||||
},
|
||||
{
|
||||
key: 'postIpoDebt',
|
||||
value: 'POST_IPO_DEBT',
|
||||
label: 'Post-IPO Debt',
|
||||
color: 'lime',
|
||||
position: 9,
|
||||
},
|
||||
{
|
||||
key: 'postIpoEquity',
|
||||
value: 'POST_IPO_EQUITY',
|
||||
label: 'Post-IPO Equity',
|
||||
color: 'violet',
|
||||
position: 10,
|
||||
},
|
||||
{
|
||||
key: 'postIpoSecondary',
|
||||
value: 'POST_IPO_SECONDARY',
|
||||
label: 'Post-IPO Secondary',
|
||||
color: 'gold',
|
||||
position: 11,
|
||||
},
|
||||
{
|
||||
key: 'preSeed',
|
||||
value: 'PRE_SEED',
|
||||
label: 'Pre-Seed',
|
||||
color: 'turquoise',
|
||||
position: 12,
|
||||
},
|
||||
{
|
||||
key: 'privateEquity',
|
||||
value: 'PRIVATE_EQUITY',
|
||||
label: 'Private Equity',
|
||||
color: 'crimson',
|
||||
position: 13,
|
||||
},
|
||||
{
|
||||
key: 'productCrowdfunding',
|
||||
value: 'PRODUCT_CROWDFUNDING',
|
||||
label: 'Product Crowdfunding',
|
||||
color: 'sky',
|
||||
position: 14,
|
||||
},
|
||||
{
|
||||
key: 'secondaryMarket',
|
||||
value: 'SECONDARY_MARKET',
|
||||
label: 'Secondary Market',
|
||||
color: 'amber',
|
||||
position: 15,
|
||||
},
|
||||
{ key: 'seed', value: 'SEED', label: 'Seed', color: 'plum', position: 16 },
|
||||
{
|
||||
key: 'seriesA',
|
||||
value: 'SERIES_A',
|
||||
label: 'Series A',
|
||||
color: 'grass',
|
||||
position: 17,
|
||||
},
|
||||
{
|
||||
key: 'seriesB',
|
||||
value: 'SERIES_B',
|
||||
label: 'Series B',
|
||||
color: 'tomato',
|
||||
position: 18,
|
||||
},
|
||||
{
|
||||
key: 'seriesC',
|
||||
value: 'SERIES_C',
|
||||
label: 'Series C',
|
||||
color: 'iris',
|
||||
position: 19,
|
||||
},
|
||||
{
|
||||
key: 'seriesD',
|
||||
value: 'SERIES_D',
|
||||
label: 'Series D',
|
||||
color: 'mint',
|
||||
position: 20,
|
||||
},
|
||||
{
|
||||
key: 'seriesE',
|
||||
value: 'SERIES_E',
|
||||
label: 'Series E',
|
||||
color: 'ruby',
|
||||
position: 21,
|
||||
},
|
||||
{
|
||||
key: 'seriesF',
|
||||
value: 'SERIES_F',
|
||||
label: 'Series F',
|
||||
color: 'bronze',
|
||||
position: 22,
|
||||
},
|
||||
{
|
||||
key: 'seriesG',
|
||||
value: 'SERIES_G',
|
||||
label: 'Series G',
|
||||
color: 'jade',
|
||||
position: 23,
|
||||
},
|
||||
{
|
||||
key: 'seriesH',
|
||||
value: 'SERIES_H',
|
||||
label: 'Series H',
|
||||
color: 'gray',
|
||||
position: 24,
|
||||
},
|
||||
{
|
||||
key: 'seriesI',
|
||||
value: 'SERIES_I',
|
||||
label: 'Series I',
|
||||
color: 'blue',
|
||||
position: 25,
|
||||
},
|
||||
{
|
||||
key: 'seriesJ',
|
||||
value: 'SERIES_J',
|
||||
label: 'Series J',
|
||||
color: 'red',
|
||||
position: 26,
|
||||
},
|
||||
{
|
||||
key: 'seriesUnknown',
|
||||
value: 'SERIES_UNKNOWN',
|
||||
label: 'Series Unknown',
|
||||
color: 'green',
|
||||
position: 27,
|
||||
},
|
||||
{
|
||||
key: 'undisclosed',
|
||||
value: 'UNDISCLOSED',
|
||||
label: 'Undisclosed',
|
||||
color: 'orange',
|
||||
position: 28,
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
import { type SelectOptionMeta } from 'src/types/select-option-meta.type';
|
||||
|
||||
export const SIZE_OPTIONS: readonly SelectOptionMeta[] = [
|
||||
{
|
||||
key: 'oneToTen',
|
||||
value: 'ONE_TO_TEN',
|
||||
label: '1-10',
|
||||
color: 'blue',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
key: 'elevenToFifty',
|
||||
value: 'ELEVEN_TO_FIFTY',
|
||||
label: '11-50',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
key: 'fiftyOneToTwoHundred',
|
||||
value: 'FIFTY_ONE_TO_TWO_HUNDRED',
|
||||
label: '51-200',
|
||||
color: 'green',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
key: 'twoHundredOneToFiveHundred',
|
||||
value: 'TWO_HUNDRED_ONE_TO_FIVE_HUNDRED',
|
||||
label: '201-500',
|
||||
color: 'orange',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
key: 'fiveHundredOneToOneThousand',
|
||||
value: 'FIVE_HUNDRED_ONE_TO_ONE_THOUSAND',
|
||||
label: '501-1000',
|
||||
color: 'purple',
|
||||
position: 4,
|
||||
},
|
||||
{
|
||||
key: 'oneThousandOneToFiveThousand',
|
||||
value: 'ONE_THOUSAND_ONE_TO_FIVE_THOUSAND',
|
||||
label: '1001-5000',
|
||||
color: 'yellow',
|
||||
position: 5,
|
||||
},
|
||||
{
|
||||
key: 'fiveThousandOneToTenThousand',
|
||||
value: 'FIVE_THOUSAND_ONE_TO_TEN_THOUSAND',
|
||||
label: '5001-10000',
|
||||
color: 'pink',
|
||||
position: 6,
|
||||
},
|
||||
{
|
||||
key: 'tenThousandOnePlus',
|
||||
value: 'TEN_THOUSAND_ONE_PLUS',
|
||||
label: '10001+',
|
||||
color: 'cyan',
|
||||
position: 7,
|
||||
},
|
||||
];
|
||||
-1629
File diff suppressed because it is too large
Load Diff
-19
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlAffiliatedProfiles,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.ARRAY,
|
||||
name: 'pdlAffiliatedProfiles',
|
||||
label: 'Affiliated Profiles',
|
||||
description: 'Affiliated company profiles returned by People Data Labs.',
|
||||
isNullable: true,
|
||||
});
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlAlternativeDomains,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.ARRAY,
|
||||
name: 'pdlAlternativeDomains',
|
||||
label: 'Alternative Domains',
|
||||
description: 'Alternative domains returned by People Data Labs.',
|
||||
isNullable: true,
|
||||
});
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlAlternativeNames,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.ARRAY,
|
||||
name: 'pdlAlternativeNames',
|
||||
label: 'Alternative Names',
|
||||
description: 'Alternative names returned by People Data Labs.',
|
||||
isNullable: true,
|
||||
});
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS,
|
||||
PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlCompanyType,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.SELECT,
|
||||
name: 'pdlCompanyType',
|
||||
label: 'Company Type',
|
||||
description: 'People Data Labs canonical company type.',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{
|
||||
id: PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.companyType.public,
|
||||
value: 'PUBLIC',
|
||||
label: 'Public',
|
||||
color: 'blue',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.companyType.private,
|
||||
value: 'PRIVATE',
|
||||
label: 'Private',
|
||||
color: 'red',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.companyType.publicSubsidiary,
|
||||
value: 'PUBLIC_SUBSIDIARY',
|
||||
label: 'Public Subsidiary',
|
||||
color: 'green',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
id: PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.companyType.educational,
|
||||
value: 'EDUCATIONAL',
|
||||
label: 'Educational',
|
||||
color: 'orange',
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
id: PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.companyType.government,
|
||||
value: 'GOVERNMENT',
|
||||
label: 'Government',
|
||||
color: 'purple',
|
||||
position: 4,
|
||||
},
|
||||
{
|
||||
id: PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.companyType.nonprofit,
|
||||
value: 'NONPROFIT',
|
||||
label: 'Nonprofit',
|
||||
color: 'yellow',
|
||||
position: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlCurrentEmployees,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'pdlCurrentEmployees',
|
||||
label: 'PDL Current Employees',
|
||||
description:
|
||||
'People whose current employer is this company, per People Data Labs.',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS.person.pdlCurrentCompany,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
icon: 'IconUsers',
|
||||
});
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlEmployeeCountByCountry,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.RAW_JSON,
|
||||
name: 'pdlEmployeeCountByCountry',
|
||||
label: 'Employee Count by Country',
|
||||
description:
|
||||
'Employee count broken down by country returned by People Data Labs.',
|
||||
isNullable: true,
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlEmployeeCount,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'pdlEmployeeCount',
|
||||
label: 'Employees',
|
||||
description: 'Exact employee count returned by People Data Labs.',
|
||||
isNullable: true,
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { ENRICHMENT_STATUS_OPTIONS } from 'src/constants/enrichment-status-options';
|
||||
import {
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS,
|
||||
PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
import { buildSelectOptions } from 'src/utils/build-select-options';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier:
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlEnrichmentStatus,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.SELECT,
|
||||
name: 'pdlEnrichmentStatus',
|
||||
label: 'Enrichment Status',
|
||||
description: 'Outcome of the latest People Data Labs enrichment attempt.',
|
||||
isNullable: true,
|
||||
options: buildSelectOptions(
|
||||
ENRICHMENT_STATUS_OPTIONS,
|
||||
PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.companyEnrichmentStatus,
|
||||
),
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlFacebookUrl,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.LINKS,
|
||||
name: 'pdlFacebookUrl',
|
||||
label: 'Facebook',
|
||||
description: 'Facebook page returned by People Data Labs.',
|
||||
isNullable: true,
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { PDL_FIELD_UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlFoundedYear,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.NUMBER,
|
||||
name: 'pdlFoundedYear',
|
||||
label: 'Founded Year',
|
||||
description: 'Founding year returned by People Data Labs.',
|
||||
isNullable: true,
|
||||
});
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
import { FUNDING_STAGE_OPTIONS } from 'src/constants/funding-stage-options';
|
||||
import {
|
||||
PDL_FIELD_UNIVERSAL_IDENTIFIERS,
|
||||
PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
import { buildSelectOptions } from 'src/utils/build-select-options';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PDL_FIELD_UNIVERSAL_IDENTIFIERS.company.pdlFundingStages,
|
||||
objectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'pdlFundingStages',
|
||||
label: 'Funding Stages',
|
||||
description: 'People Data Labs canonical funding stages.',
|
||||
isNullable: true,
|
||||
options: buildSelectOptions(
|
||||
FUNDING_STAGE_OPTIONS,
|
||||
PDL_SELECT_OPTION_UNIVERSAL_IDENTIFIERS.fundingStage,
|
||||
),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user