Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5da1c5abc6 | |||
| 373215b776 | |||
| c53fc71fdd | |||
| 32c0f27dd4 | |||
| 40d95b76d9 | |||
| 24bc7c2d39 | |||
| adfc39bae8 | |||
| 36ce2df9fc | |||
| e7908892c2 | |||
| 03c16a288e | |||
| 99d4366171 | |||
| 887994cf75 | |||
| a4a737abe4 | |||
| d12eabc1a0 | |||
| 84e5ba8ef3 | |||
| 99ec9e9981 | |||
| f02a75a7e6 | |||
| 527e23f930 | |||
| 15899cbab4 | |||
| 9714f8c1d0 | |||
| 97b75229bc | |||
| 2fa0216f1b | |||
| 2c107ed1df | |||
| 072d00c7cb |
@@ -12,7 +12,7 @@ This directory contains Twenty's development guidelines and best practices in th
|
||||
### Core Guidelines
|
||||
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
|
||||
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
|
||||
- **server-migrations.mdc** - Upgrade command guidelines (instance commands and workspace commands) for `twenty-server` (Auto-attached to server entities and upgrade command files)
|
||||
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
|
||||
- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files)
|
||||
|
||||
### Code Quality
|
||||
@@ -81,8 +81,11 @@ npx nx run twenty-server:typecheck # Type checking
|
||||
npx nx run twenty-server:test # Run unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
|
||||
|
||||
# Upgrade commands (instance + workspace)
|
||||
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
|
||||
# Migrations
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
|
||||
# Workspace
|
||||
npx nx run twenty-server:command workspace:sync-metadata -f # Sync metadata
|
||||
```
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
@@ -55,8 +55,7 @@ If feature descriptions are not provided or need enhancement, research the codeb
|
||||
- Services: Look for `*.service.ts` files
|
||||
|
||||
**For Database/ORM Changes:**
|
||||
- Instance commands (fast/slow): `packages/twenty-server/src/database/commands/upgrade-version-command/`
|
||||
- Legacy TypeORM migrations: `packages/twenty-server/src/database/typeorm/`
|
||||
- Migrations: `packages/twenty-server/src/database/typeorm/`
|
||||
- Entities: `packages/twenty-server/src/entities/`
|
||||
|
||||
### Research Commands
|
||||
@@ -106,35 +105,34 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
|
||||
### 2. Create File Structure
|
||||
|
||||
**Create changelog file:**
|
||||
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
|
||||
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
|
||||
|
||||
**Create image folder:**
|
||||
- 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/`
|
||||
- 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/`
|
||||
|
||||
```bash
|
||||
# Create the image folder
|
||||
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
### 3. Move Illustration Files
|
||||
|
||||
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
|
||||
|
||||
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
|
||||
**Naming Convention:** `{VERSION}-descriptive-name.webp`
|
||||
**Naming Convention:** `{VERSION}-descriptive-name.png`
|
||||
|
||||
Examples:
|
||||
- `1.9.0-feature-name.webp`
|
||||
- `1.9.0-another-feature.webp`
|
||||
- `1.9.0-feature-name.png`
|
||||
- `1.9.0-another-feature.png`
|
||||
|
||||
```bash
|
||||
# Move and rename source files, then convert to webp if needed
|
||||
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
|
||||
# Move and rename files
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
```
|
||||
|
||||
### 4. Research Features (if needed)
|
||||
@@ -159,19 +157,19 @@ Date: {YYYY-MM-DD}
|
||||
|
||||
Short description explaining what the feature does and why it's useful. Keep it user-focused and concise (1-2 sentences).
|
||||
|
||||

|
||||

|
||||
|
||||
# Feature 2 Name
|
||||
|
||||
Another short description of the second feature.
|
||||
|
||||

|
||||

|
||||
|
||||
# Feature 3 Name
|
||||
|
||||
Description of the third feature.
|
||||
|
||||

|
||||

|
||||
```
|
||||
|
||||
**Style Guidelines:**
|
||||
@@ -183,7 +181,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-new/src/content/releases/` for examples
|
||||
- Check `packages/twenty-website/src/content/releases/` for examples
|
||||
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
|
||||
|
||||
### 6. Review
|
||||
@@ -191,10 +189,10 @@ Description of the third feature.
|
||||
Open the changelog file for review:
|
||||
```bash
|
||||
# Open in Cursor
|
||||
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
|
||||
# Open image folder to verify illustrations
|
||||
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
Review checklist:
|
||||
@@ -222,8 +220,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-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
|
||||
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.png
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.png
|
||||
|
||||
Please review the content. Once you approve, I'll commit the changes and create the pull request.
|
||||
```
|
||||
@@ -242,8 +240,8 @@ Possible user responses:
|
||||
git status
|
||||
|
||||
# Add files
|
||||
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
|
||||
# Commit
|
||||
git commit -m "Add {VERSION} release changelog"
|
||||
@@ -266,7 +264,7 @@ This release includes:
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
|
||||
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
|
||||
Release date: {DATE}" \
|
||||
--base main \
|
||||
--head {VERSION}
|
||||
@@ -280,21 +278,21 @@ 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-new/src/content/releases/`
|
||||
- **Location**: `packages/twenty-website/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-new/public/images/releases/`
|
||||
- **Location**: `packages/twenty-website/public/images/releases/`
|
||||
|
||||
### Image Files
|
||||
- **Format**: `{VERSION}-descriptive-name.webp`
|
||||
- **Format**: `{VERSION}-descriptive-name.png`
|
||||
- **Convention**: Kebab-case descriptive names
|
||||
- **Examples**:
|
||||
- `1.8.0-workflow-iterator.webp`
|
||||
- `1.8.0-bulk-select.webp`
|
||||
- `1.9.0-new-feature.webp`
|
||||
- `1.8.0-workflow-iterator.png`
|
||||
- `1.8.0-bulk-select.png`
|
||||
- `1.9.0-new-feature.png`
|
||||
|
||||
## Quick Reference Template
|
||||
|
||||
@@ -311,8 +309,8 @@ Features to document:
|
||||
3. ___________________________
|
||||
|
||||
Branch name: {VERSION}
|
||||
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
@@ -22,7 +22,7 @@ This main guide provides a high-level overview and navigation hub.
|
||||
|
||||
A syncable entity is a metadata entity that:
|
||||
- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications
|
||||
- Has an **`applicationId`**: Links the entity to an application (Standard or Custom applications)
|
||||
- Has an **`applicationId`**: Links the entity to an application (Twenty Standard or Custom applications)
|
||||
- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline
|
||||
- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection
|
||||
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
---
|
||||
description: Guidelines for generating and managing upgrade commands (instance commands and workspace commands) in twenty-server
|
||||
description: Guidelines for generating and managing TypeORM migrations in twenty-server
|
||||
globs: [
|
||||
"packages/twenty-server/src/**/*.entity.ts",
|
||||
"packages/twenty-server/src/database/commands/upgrade-version-command/**/*.ts"
|
||||
"packages/twenty-server/src/database/typeorm/**/*.ts"
|
||||
]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## Upgrade Commands (twenty-server)
|
||||
## Server Migrations (twenty-server)
|
||||
|
||||
The upgrade system uses two types of commands instead of raw TypeORM migrations:
|
||||
- **Instance commands** — schema and data migrations that run once at the instance level.
|
||||
- **Workspace commands** — commands that iterate over all active/suspended workspaces.
|
||||
|
||||
See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation.
|
||||
|
||||
### Instance Commands
|
||||
|
||||
- **When changing a `*.entity.ts` file**, generate an instance command:
|
||||
- **When changing an entity, always generate a migration**
|
||||
- If you modify a `*.entity.ts` file in `packages/twenty-server/src`, you **must** generate a corresponding TypeORM migration instead of manually editing the database schema.
|
||||
- Use the Nx + TypeORM command from the project root:
|
||||
|
||||
```bash
|
||||
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
```
|
||||
|
||||
- **Fast commands** (`--type fast`, default) are for schema-only changes that must run immediately. They implement `FastInstanceCommand` with `up`/`down` methods and use the `@RegisteredInstanceCommand` decorator.
|
||||
- Replace `[name]` with a descriptive, kebab-case migration name that reflects the change (for example, `add-agent-turn-evaluation`).
|
||||
|
||||
- **Slow commands** (`--type slow`) add a `runDataMigration` method for potentially long-running data backfills that execute before `up`. They only run when `--include-slow` is passed. Use the decorator with `{ type: 'slow' }`.
|
||||
- **Prefer generated migrations over manual edits**
|
||||
- Let TypeORM infer schema changes from the updated entities; only adjust the generated migration file manually if absolutely necessary (for example, for data backfills or complex constraints).
|
||||
- Keep schema changes (DDL) in these generated migrations and avoid mixing in heavy data migrations unless there is a strong reason and clear comments.
|
||||
|
||||
- The generator auto-registers the command in `instance-commands.constant.ts` — do not edit that file manually.
|
||||
|
||||
- **Keep commands consistent and reversible**: include both `up` and `down` logic. Do not delete or rewrite existing, committed commands unless on a pre-release branch.
|
||||
|
||||
### Workspace Commands
|
||||
|
||||
- Use the `@RegisteredWorkspaceCommand` decorator alongside nest-commander's `@Command` decorator.
|
||||
- Extend `ActiveOrSuspendedWorkspaceCommandRunner` and implement `runOnWorkspace`.
|
||||
- The base class provides `--dry-run`, `--verbose`, and workspace filter options automatically.
|
||||
|
||||
### Execution Order
|
||||
|
||||
Within a given version, commands run in this order (timestamp-sorted within each group):
|
||||
1. Instance fast commands
|
||||
2. Instance slow commands (only with `--include-slow`)
|
||||
3. Workspace commands
|
||||
- **Keep migrations consistent and reversible**
|
||||
- Ensure the generated migration includes both `up` and `down` logic that correctly applies and reverts the entity change when possible.
|
||||
- Do not delete or rewrite existing, committed migrations unless you are explicitly working on a pre-release branch where history rewrites are allowed by team conventions.
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Deploy Twenty App
|
||||
description: Build and deploy a Twenty app to a remote instance
|
||||
|
||||
inputs:
|
||||
api-url:
|
||||
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
|
||||
required: true
|
||||
api-key:
|
||||
description: API key or access token for the target instance
|
||||
required: true
|
||||
app-path:
|
||||
description: Path to the app directory (relative to repo root). Defaults to repo root.
|
||||
required: false
|
||||
default: '.'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '${{ inputs.app-path }}/.nvmrc'
|
||||
cache: yarn
|
||||
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Configure remote
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ~/.twenty
|
||||
node -e "
|
||||
const fs = require('fs'), path = require('path'), os = require('os');
|
||||
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
|
||||
version: 1,
|
||||
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
|
||||
}, null, 2));
|
||||
"
|
||||
env:
|
||||
API_URL: ${{ inputs.api-url }}
|
||||
API_KEY: ${{ inputs.api-key }}
|
||||
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty deploy --remote target
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Install Twenty App
|
||||
description: Install (or upgrade) a Twenty app on a specific workspace
|
||||
|
||||
inputs:
|
||||
api-url:
|
||||
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
|
||||
required: true
|
||||
api-key:
|
||||
description: API key or access token for the target workspace
|
||||
required: true
|
||||
app-path:
|
||||
description: Path to the app directory (relative to repo root). Defaults to repo root.
|
||||
required: false
|
||||
default: '.'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '${{ inputs.app-path }}/.nvmrc'
|
||||
cache: yarn
|
||||
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Configure remote
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ~/.twenty
|
||||
node -e "
|
||||
const fs = require('fs'), path = require('path'), os = require('os');
|
||||
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
|
||||
version: 1,
|
||||
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
|
||||
}, null, 2));
|
||||
"
|
||||
env:
|
||||
API_URL: ${{ inputs.api-url }}
|
||||
API_KEY: ${{ inputs.api-key }}
|
||||
|
||||
- name: Install
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty install --remote target
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Spawn Twenty App Dev Test
|
||||
description: >
|
||||
Starts a Twenty all-in-one test instance (server, worker, database, redis)
|
||||
using the twentycrm/twenty-app-dev Docker image on port 2021.
|
||||
The server is available at http://localhost:2021 with seeded demo data.
|
||||
|
||||
inputs:
|
||||
twenty-version:
|
||||
description: 'Twenty Docker Hub image tag for twenty-app-dev (e.g., "latest" or "v1.20.0").'
|
||||
required: false
|
||||
default: 'latest'
|
||||
|
||||
outputs:
|
||||
server-url:
|
||||
description: 'URL where the Twenty test server can be reached'
|
||||
value: http://localhost:2021
|
||||
api-key:
|
||||
description: 'API key (type: API_KEY) for the seeded Twenty dev workspace'
|
||||
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start twenty-app-dev-test container
|
||||
shell: bash
|
||||
run: |
|
||||
docker run -d \
|
||||
--name twenty-app-dev-test \
|
||||
-p 2021:2021 \
|
||||
-e NODE_PORT=2021 \
|
||||
-e SERVER_URL=http://localhost:2021 \
|
||||
twentycrm/twenty-app-dev:${{ inputs.twenty-version }}
|
||||
|
||||
echo "Waiting for Twenty test instance to become healthy…"
|
||||
TIMEOUT=180
|
||||
ELAPSED=0
|
||||
until curl -sf http://localhost:2021/healthz > /dev/null 2>&1; do
|
||||
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
|
||||
echo "::error::Twenty did not become healthy within ${TIMEOUT}s"
|
||||
docker logs twenty-app-dev-test 2>&1 | tail -80
|
||||
exit 1
|
||||
fi
|
||||
sleep 3
|
||||
ELAPSED=$((ELAPSED + 3))
|
||||
echo " … waited ${ELAPSED}s"
|
||||
done
|
||||
echo "Twenty test instance is ready at http://localhost:2021 (took ~${ELAPSED}s)"
|
||||
@@ -7,17 +7,6 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Free disk space for install
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
# Default GitHub images ship large SDKs this repo does not use; removing
|
||||
# them avoids ENOSPC when restoring or linking a full Yarn node_modules.
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
df -h
|
||||
- name: Cache primary key builder
|
||||
id: globals
|
||||
shell: bash
|
||||
|
||||
+13
-12
@@ -4,19 +4,20 @@
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
preserve_hierarchy: true
|
||||
base_path: ..
|
||||
"preserve_hierarchy": true
|
||||
"base_path": ".."
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
|
||||
files:
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
- source: packages/twenty-front/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
translation: '%original_path%/%locale%.po'
|
||||
- source: packages/twenty-server/src/engine/core-modules/i18n/locales/en.po
|
||||
translation: '%original_path%/%locale%.po'
|
||||
- source: packages/twenty-emails/src/locales/en.po
|
||||
translation: '%original_path%/%locale%.po'
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Website translations (twenty-website-new)
|
||||
# Project ID: 4
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
project_id: 4
|
||||
preserve_hierarchy: true
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
base_path: ..
|
||||
languages_mapping:
|
||||
locale:
|
||||
fr: fr-FR
|
||||
|
||||
files:
|
||||
#
|
||||
# Source file - PO file for Lingui
|
||||
#
|
||||
- source: packages/twenty-website-new/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
translation: '%original_path%/%locale%.po'
|
||||
@@ -35,10 +35,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -48,10 +53,16 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.8.8
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
@@ -251,14 +262,6 @@ jobs:
|
||||
rm -f /tmp/current-server.pid
|
||||
fi
|
||||
|
||||
- name: Flush Redis between server runs
|
||||
run: |
|
||||
# Clear all Redis caches to prevent stale data from the current branch
|
||||
# server contaminating the main branch server. Both servers share the
|
||||
# same Redis instance, and CoreEntityCacheService/WorkspaceCacheService
|
||||
# persist cached entities across process restarts.
|
||||
redis-cli -h localhost -p 6379 FLUSHALL || echo "::warning::Failed to flush Redis"
|
||||
|
||||
- name: Checkout main branch
|
||||
run: |
|
||||
git stash
|
||||
@@ -418,6 +421,12 @@ jobs:
|
||||
|
||||
echo "valid=$valid" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Install OpenAPI Diff Tool
|
||||
run: |
|
||||
# Using the Java-based OpenAPITools/openapi-diff via Docker
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
name: CI Hello world App E2E
|
||||
|
||||
on:
|
||||
# Temporarily disabled — will be re-enabled when example apps are published.
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
#
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/create-twenty-app/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
create-app-e2e-hello-world:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-4-cores
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Set CI version and prepare packages for publish
|
||||
run: |
|
||||
CI_VERSION="0.0.0-ci.$(date +%s)"
|
||||
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx run $pkg:set-local-version --releaseVersion=$CI_VERSION
|
||||
done
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx build $pkg
|
||||
done
|
||||
|
||||
- name: Install and start Verdaccio
|
||||
run: |
|
||||
npx verdaccio --config .github/verdaccio-config.yaml &
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://localhost:4873 > /dev/null 2>&1; then
|
||||
echo "Verdaccio is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Verdaccio... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Publish packages to local registry
|
||||
run: |
|
||||
yarn config set npmRegistryServer http://localhost:4873
|
||||
yarn config set unsafeHttpWhitelist --json '["localhost"]'
|
||||
yarn config set npmAuthToken ci-auth-token
|
||||
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
cd packages/$pkg
|
||||
yarn npm publish --tag ci
|
||||
cd ../..
|
||||
done
|
||||
|
||||
- name: Scaffold app using published create-twenty-app
|
||||
run: |
|
||||
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --example hello-world --display-name "Test hello-world app" --description "E2E test hello-world app" --skip-local-instance
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
|
||||
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
|
||||
echo "--- Installing last SDK versions ---"
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
|
||||
|
||||
- name: Verify installed app versions
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo "--- Checking package.json references correct SDK version ---"
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const sdkVersion = pkg.dependencies['twenty-sdk'];
|
||||
if (!sdkVersion.startsWith('0.0.0-ci.')) {
|
||||
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('SDK version in scaffolded app:', sdkVersion);
|
||||
"
|
||||
|
||||
- name: Verify SDK CLI is available
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty --version
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
|
||||
- name: Setup database
|
||||
run: npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty deploy
|
||||
|
||||
- name: Install scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty install
|
||||
|
||||
- name: Execute hello-world logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
|
||||
|
||||
- name: Execute create-hello-world-company logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-hello-world-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e-hello-world]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,171 +0,0 @@
|
||||
name: CI Create App E2E minimal
|
||||
|
||||
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: |
|
||||
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-minimal:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-4-cores
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Set CI version and prepare packages for publish
|
||||
run: |
|
||||
CI_VERSION="0.0.0-ci.$(date +%s)"
|
||||
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
|
||||
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx build $pkg
|
||||
done
|
||||
|
||||
- name: Install and start Verdaccio
|
||||
run: |
|
||||
npx verdaccio --config .github/verdaccio-config.yaml &
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://localhost:4873 > /dev/null 2>&1; then
|
||||
echo "Verdaccio is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Verdaccio... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Publish packages to local registry
|
||||
run: |
|
||||
yarn config set npmRegistryServer http://localhost:4873
|
||||
yarn config set unsafeHttpWhitelist --json '["localhost"]'
|
||||
yarn config set npmAuthToken ci-auth-token
|
||||
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
cd packages/$pkg
|
||||
yarn npm publish --tag ci
|
||||
cd ../..
|
||||
done
|
||||
|
||||
- name: Scaffold app using published create-twenty-app
|
||||
run: |
|
||||
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded 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
|
||||
|
||||
- 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: Run scaffolded app integration test (deploys, installs, and verifies the app)
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-minimal-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e-minimal]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,199 +0,0 @@
|
||||
name: CI Postcard App E2E
|
||||
|
||||
on:
|
||||
# Temporarily disabled — will be re-enabled when example apps are published.
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
#
|
||||
# pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/create-twenty-app/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
create-app-e2e-postcard:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-4-cores
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Set CI version and prepare packages for publish
|
||||
run: |
|
||||
CI_VERSION="0.0.0-ci.$(date +%s)"
|
||||
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
|
||||
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
npx nx build $pkg
|
||||
done
|
||||
|
||||
- name: Install and start Verdaccio
|
||||
run: |
|
||||
npx verdaccio --config .github/verdaccio-config.yaml &
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -s http://localhost:4873 > /dev/null 2>&1; then
|
||||
echo "Verdaccio is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Verdaccio... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Publish packages to local registry
|
||||
run: |
|
||||
yarn config set npmRegistryServer http://localhost:4873
|
||||
yarn config set unsafeHttpWhitelist --json '["localhost"]'
|
||||
yarn config set npmAuthToken ci-auth-token
|
||||
|
||||
for pkg in $PUBLISHABLE_PACKAGES; do
|
||||
cd packages/$pkg
|
||||
yarn npm publish --tag ci
|
||||
cd ../..
|
||||
done
|
||||
|
||||
- name: Scaffold app using published create-twenty-app
|
||||
run: |
|
||||
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --example postcard --display-name "Test postcard app" --description "E2E test postcard app" --skip-local-instance
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
|
||||
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
|
||||
echo "--- Installing last SDK versions ---"
|
||||
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
|
||||
|
||||
- name: Verify installed app versions
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
echo "--- Checking package.json references correct SDK version ---"
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
const sdkVersion = pkg.dependencies['twenty-sdk'];
|
||||
if (!sdkVersion.startsWith('0.0.0-ci.')) {
|
||||
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('SDK version in scaffolded app:', sdkVersion);
|
||||
"
|
||||
|
||||
- name: Verify SDK CLI is available
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty --version
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
|
||||
- name: Setup database
|
||||
run: npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty deploy
|
||||
|
||||
- name: Install scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty install
|
||||
|
||||
- name: Execute postcard logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
|
||||
|
||||
- name: Execute create-postcard-company logic function
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
|
||||
echo "$EXEC_OUTPUT"
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-postcard-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e-postcard]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -0,0 +1,208 @@
|
||||
name: CI Create App E2E
|
||||
|
||||
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: |
|
||||
packages/create-twenty-app/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
packages/twenty-server/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
!packages/twenty-server/package.json
|
||||
create-app-e2e:
|
||||
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: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
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 --exhaustive --display-name "Test App" --description "E2E test 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
|
||||
|
||||
- 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.devDependencies['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: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
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";'
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
env:
|
||||
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000
|
||||
|
||||
- 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
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
ci-create-app-e2e-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, create-app-e2e]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,94 +0,0 @@
|
||||
name: CI Example App Hello World
|
||||
|
||||
on:
|
||||
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/twenty-apps/examples/hello-world/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
|
||||
example-app-hello-world:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build SDK packages
|
||||
run: npx nx build twenty-sdk
|
||||
|
||||
- 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: Run integration tests
|
||||
working-directory: packages/twenty-apps/examples/hello-world
|
||||
run: npx vitest run
|
||||
|
||||
ci-example-app-hello-world-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, example-app-hello-world]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,94 +0,0 @@
|
||||
name: CI Example App Postcard
|
||||
|
||||
on:
|
||||
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/twenty-apps/examples/postcard/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
|
||||
example-app-postcard:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build SDK packages
|
||||
run: npx nx build twenty-sdk
|
||||
|
||||
- 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: Run integration tests
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: npx vitest run
|
||||
|
||||
ci-example-app-postcard-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, example-app-postcard]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
TASK_CACHE_KEY: front-task-${{ matrix.task }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -25,10 +25,15 @@ jobs:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -38,6 +43,9 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
|
||||
@@ -19,7 +19,6 @@ jobs:
|
||||
files: |
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-server/**
|
||||
.github/workflows/ci-sdk.yaml
|
||||
!packages/twenty-sdk/package.json
|
||||
sdk-test:
|
||||
needs: changed-files-check
|
||||
@@ -54,10 +53,15 @@ jobs:
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -67,11 +71,13 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
NODE_ENV: test
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
@@ -79,26 +85,16 @@ jobs:
|
||||
fetch-depth: 10
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Build SDK
|
||||
- name: Build
|
||||
run: npx nx build twenty-sdk
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
- name: Create databases
|
||||
- name: Server / Create Test DB
|
||||
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 > /tmp/twenty-server.log 2>&1 &
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
- name: SDK / Run e2e Tests
|
||||
run: NODE_ENV=test npx vitest run --config ./vitest.e2e.config.ts
|
||||
working-directory: packages/twenty-sdk
|
||||
- name: Server / Dump logs on failure
|
||||
if: failure()
|
||||
run: tail -100 /tmp/twenty-server.log || echo "No server log file found"
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:sdk
|
||||
tasks: test:e2e
|
||||
ci-sdk-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -25,7 +25,6 @@ jobs:
|
||||
packages/twenty-server/**
|
||||
packages/twenty-front/src/generated/**
|
||||
packages/twenty-front/src/generated-metadata/**
|
||||
packages/twenty-front/src/generated-admin/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-emails/**
|
||||
packages/twenty-shared/**
|
||||
@@ -78,93 +77,21 @@ jobs:
|
||||
tag: scope:backend
|
||||
tasks: lint,typecheck
|
||||
|
||||
server-previous-version-upgrade-mutation-guard:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Get changed upgrade-version-command files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files: |
|
||||
packages/twenty-server/src/database/commands/upgrade-version-command/**
|
||||
- name: Check upgrade version commands are in current version only
|
||||
if: >
|
||||
steps.changed-files.outputs.any_changed == 'true' &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'ci:allow-previous-version-upgrade-mutation')
|
||||
run: |
|
||||
VERSION_CONSTANT_FILE="packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts"
|
||||
|
||||
CURRENT_VERSION=$(sed -n "s/.*TWENTY_CURRENT_VERSION = '\([0-9.]*\)'.*/\1/p" "$VERSION_CONSTANT_FILE")
|
||||
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo "::error::Could not extract TWENTY_CURRENT_VERSION from $VERSION_CONSTANT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_DIR=$(echo "$CURRENT_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+)\..*/\1-\2/')
|
||||
|
||||
echo "Current version: $CURRENT_VERSION (directory: $CURRENT_DIR)"
|
||||
|
||||
ADDED_OFFENDERS=""
|
||||
MODIFIED_OFFENDERS=""
|
||||
|
||||
check_files() {
|
||||
local category="$1"
|
||||
shift
|
||||
for file in "$@"; do
|
||||
VERSION_DIR=$(echo "$file" | sed -n 's|.*upgrade-version-command/\([0-9]*-[0-9]*\)/.*|\1|p')
|
||||
|
||||
if [ -n "$VERSION_DIR" ] && [ "$VERSION_DIR" != "$CURRENT_DIR" ]; then
|
||||
if [ "$category" = "added" ]; then
|
||||
ADDED_OFFENDERS="$ADDED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
|
||||
else
|
||||
MODIFIED_OFFENDERS="$MODIFIED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_files "added" ${{ steps.changed-files.outputs.added_files }}
|
||||
check_files "modified" ${{ steps.changed-files.outputs.modified_files }}
|
||||
|
||||
if [ -n "$ADDED_OFFENDERS" ] || [ -n "$MODIFIED_OFFENDERS" ]; then
|
||||
echo "This PR touches upgrade command files outside the current version directory ($CURRENT_DIR / $CURRENT_VERSION)."
|
||||
|
||||
if [ -n "$ADDED_OFFENDERS" ]; then
|
||||
echo ""
|
||||
echo "New files added to non-current version directories:"
|
||||
echo -e "$ADDED_OFFENDERS"
|
||||
fi
|
||||
|
||||
if [ -n "$MODIFIED_OFFENDERS" ]; then
|
||||
echo ""
|
||||
echo "Existing files modified in non-current version directories:"
|
||||
echo -e "$MODIFIED_OFFENDERS"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "If this is intentional, add the label 'ci:allow-previous-version-upgrade-mutation' to this PR and re-run CI."
|
||||
echo "Otherwise, please move your changes to the current version directory ($CURRENT_DIR)."
|
||||
|
||||
echo "::error::Upgrade commands were added or modified in non-current version directories."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
server-validation:
|
||||
needs: server-build
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -174,6 +101,9 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
@@ -222,18 +152,15 @@ jobs:
|
||||
exit 1
|
||||
- name: Server / Check for Pending Migrations
|
||||
run: |
|
||||
npx nx database:migrate:generate twenty-server -- --name pending-migration-check || true
|
||||
CORE_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate core-migration-check -d src/database/typeorm/core/core.datasource.ts || true)
|
||||
|
||||
if ! git diff --quiet; then
|
||||
echo "::error::Unexpected migration files were generated. Please run 'npx nx database:migrate:generate twenty-server -- --name <migration-name>' and commit the result."
|
||||
echo ""
|
||||
echo "The following migration changes were detected:"
|
||||
echo "==================================================="
|
||||
git diff
|
||||
echo "==================================================="
|
||||
echo ""
|
||||
CORE_MIGRATION_FILE=$(ls packages/twenty-server/*core-migration-check.ts 2>/dev/null || echo "")
|
||||
|
||||
git checkout -- .
|
||||
if [ -n "$CORE_MIGRATION_FILE" ]; then
|
||||
echo "::error::Unexpected migration files were generated. Please create a proper migration manually."
|
||||
echo "$CORE_MIGRATION_OUTPUT"
|
||||
|
||||
rm -f packages/twenty-server/*core-migration-check.ts
|
||||
|
||||
exit 1
|
||||
fi
|
||||
@@ -243,14 +170,13 @@ jobs:
|
||||
|
||||
npx nx run twenty-front:graphql:generate
|
||||
npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
npx nx run twenty-front:graphql:generate --configuration=admin
|
||||
|
||||
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin; then
|
||||
echo "::error::GraphQL schema changes detected. Please run the three graphql:generate configurations ('data', 'metadata', 'admin') and commit the changes."
|
||||
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
|
||||
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
|
||||
echo ""
|
||||
echo "The following GraphQL schema changes were detected:"
|
||||
echo "==================================================="
|
||||
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin
|
||||
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata
|
||||
echo "==================================================="
|
||||
echo ""
|
||||
HAS_ERRORS=true
|
||||
@@ -305,10 +231,15 @@ jobs:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -318,10 +249,16 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.8.8
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
@@ -388,7 +325,6 @@ jobs:
|
||||
changed-files-check,
|
||||
server-build,
|
||||
server-lint-typecheck,
|
||||
server-previous-version-upgrade-mutation-guard,
|
||||
server-validation,
|
||||
server-test,
|
||||
server-integration-test,
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
- name: Start container
|
||||
run: |
|
||||
docker run -d --name twenty-app-dev \
|
||||
-p 2020:2020 \
|
||||
-p 3000:3000 \
|
||||
twenty-app-dev-ci
|
||||
docker logs twenty-app-dev -f &
|
||||
- name: Wait for server health
|
||||
@@ -129,10 +129,10 @@ jobs:
|
||||
echo "Waiting for twenty-app-dev to become healthy..."
|
||||
count=0
|
||||
while true; do
|
||||
status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:2020/healthz 2>/dev/null || echo "000")
|
||||
status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/healthz 2>/dev/null || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Server is healthy!"
|
||||
curl -s http://localhost:2020/healthz
|
||||
curl -s http://localhost:3000/healthz
|
||||
break
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: CI Website
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
@@ -18,40 +18,58 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
package.json
|
||||
yarn.lock
|
||||
packages/twenty-website-new/**
|
||||
packages/twenty-shared/**
|
||||
website-task:
|
||||
packages/twenty-website/**
|
||||
website-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:website
|
||||
tasks: ${{ matrix.task }}
|
||||
|
||||
- name: Server / Create DB
|
||||
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
|
||||
- name: Website / Run migrations
|
||||
run: npx nx database:migrate twenty-website
|
||||
env:
|
||||
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
|
||||
- name: Website / Build Website
|
||||
run: npx nx build twenty-website
|
||||
env:
|
||||
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
|
||||
KEYSTATIC_GITHUB_CLIENT_ID: xxx
|
||||
KEYSTATIC_GITHUB_CLIENT_SECRET: xxx
|
||||
KEYSTATIC_SECRET: xxx
|
||||
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG: xxx
|
||||
ci-website-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, website-task]
|
||||
needs: [changed-files-check, website-build]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
|
||||
@@ -29,10 +29,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
@@ -42,6 +47,9 @@ jobs:
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
credentials:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
@@ -69,14 +77,13 @@ jobs:
|
||||
|
||||
- name: Server / Start
|
||||
run: |
|
||||
npx nx run twenty-server:start:ci &
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -sf http://localhost:3000/healthz; do sleep 2; done'
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Start worker
|
||||
working-directory: packages/twenty-server
|
||||
run: |
|
||||
NODE_ENV=development node dist/queue-worker/queue-worker.js &
|
||||
npx nx run twenty-server:worker &
|
||||
echo "Worker started"
|
||||
|
||||
- name: Zapier / Build
|
||||
|
||||
@@ -74,6 +74,8 @@ jobs:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: '**/en.po'
|
||||
translation: '%original_path%/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Pull down website translations from Crowdin every two hours or when triggered manually.
|
||||
# When force_pull input is true, translations will be pulled regardless of compilation status.
|
||||
|
||||
name: 'Pull website translations from Crowdin'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # Every two hours.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_pull:
|
||||
description: 'Force pull translations regardless of compilation status'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
force_pull:
|
||||
description: 'Force pull translations regardless of compilation status'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
pull_website_translations:
|
||||
name: Pull website translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
|
||||
- name: Setup website i18n branch
|
||||
run: |
|
||||
git fetch origin i18n-website || true
|
||||
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
# Strict mode fails if there are missing website translations.
|
||||
- name: Compile website translations
|
||||
id: compile_translations_strict
|
||||
run: npx nx run twenty-website-new:lingui:compile --strict
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add .
|
||||
git stash
|
||||
|
||||
- name: Pull website translations from Crowdin
|
||||
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
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'
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
download_sources: false
|
||||
push_sources: false
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: '.github/crowdin-website.yml'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# Website translations project
|
||||
CROWDIN_PROJECT_ID: '4'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
# As the files are extracted from a Docker container, they belong to root:root.
|
||||
# We need to fix this before the next steps.
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
- name: Compile website translations
|
||||
id: compile_translations
|
||||
run: |
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
git status
|
||||
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
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Push changes
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-website
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger i18n automerge
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
|
||||
repository: twentyhq/twenty-infra
|
||||
event-type: i18n-pr-ready
|
||||
@@ -1,111 +0,0 @@
|
||||
name: 'Push website translations to Crowdin'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'packages/twenty-website-new/**'
|
||||
- '.github/crowdin-website.yml'
|
||||
- '.github/workflows/website-i18n-push.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
extract_website_translations:
|
||||
name: Extract and upload website translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: main
|
||||
|
||||
- name: Setup website i18n branch
|
||||
run: |
|
||||
git fetch origin i18n-website || true
|
||||
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Extract website translations
|
||||
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-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: extract website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Compile website translations
|
||||
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-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
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Push changes and create remote branch if needed
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-website
|
||||
|
||||
- name: Upload missing website translations
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: '.github/crowdin-website.yml'
|
||||
env:
|
||||
# Website translations project
|
||||
CROWDIN_PROJECT_ID: '4'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Create a pull request
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger i18n automerge
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
|
||||
repository: twentyhq/twenty-infra
|
||||
event-type: i18n-pr-ready
|
||||
+1
-4
@@ -1,8 +1,7 @@
|
||||
**/**/.env
|
||||
.DS_Store
|
||||
/.idea
|
||||
.claude/
|
||||
.cursor/debug-*.log
|
||||
.claude/settings.json
|
||||
**/**/node_modules/
|
||||
.cache
|
||||
|
||||
@@ -53,5 +52,3 @@ mcp.json
|
||||
/.junie/
|
||||
TRANSLATION_QA_REPORT.md
|
||||
.playwright-mcp/
|
||||
.playwright-cli/
|
||||
output/playwright/
|
||||
|
||||
@@ -71,10 +71,13 @@ npx nx build twenty-server
|
||||
# Database management
|
||||
npx nx database:reset twenty-server # Reset database
|
||||
npx nx run twenty-server:database:init:prod # Initialize database
|
||||
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
|
||||
npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Generate an instance command (fast or slow)
|
||||
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
|
||||
# Generate migration (replace [name] with kebab-case descriptive name)
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
|
||||
# Sync metadata
|
||||
npx nx run twenty-server:command workspace:sync-metadata
|
||||
```
|
||||
|
||||
### Database Inspection (Postgres MCP)
|
||||
@@ -84,7 +87,7 @@ A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
|
||||
- Verify migration results (columns, types, constraints) after running migrations
|
||||
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
|
||||
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
|
||||
- Inspect metadata tables to debug GraphQL schema generation issues
|
||||
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
|
||||
|
||||
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
|
||||
|
||||
@@ -110,8 +113,7 @@ packages/
|
||||
├── twenty-ui/ # Shared UI components library
|
||||
├── twenty-shared/ # Common types and utilities
|
||||
├── twenty-emails/ # Email templates with React Email
|
||||
├── twenty-website-new/ # Next.js marketing website
|
||||
├── twenty-docs/ # Documentation website
|
||||
├── twenty-website/ # Next.js documentation website
|
||||
├── twenty-zapier/ # Zapier integration
|
||||
└── twenty-e2e-testing/ # Playwright E2E tests
|
||||
```
|
||||
@@ -159,17 +161,14 @@ packages/
|
||||
- **Redis** for caching and session management
|
||||
- **BullMQ** for background job processing
|
||||
|
||||
### Database & Upgrade Commands
|
||||
### Database & Migrations
|
||||
- **PostgreSQL** as primary database
|
||||
- **Redis** for caching and sessions
|
||||
- **ClickHouse** for analytics (when enabled)
|
||||
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
|
||||
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
|
||||
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
|
||||
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
|
||||
- Include both `up` and `down` logic in instance commands
|
||||
- Never delete or rewrite committed instance command `up`/`down` logic
|
||||
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
|
||||
- Always generate migrations when changing entity files
|
||||
- Migration names must be kebab-case (e.g. `add-agent-turn-evaluation`)
|
||||
- Include both `up` and `down` logic in migrations
|
||||
- Never delete or rewrite committed migrations
|
||||
|
||||
### Utility Helpers
|
||||
Use existing helpers from `twenty-shared` instead of manual type guards:
|
||||
@@ -182,7 +181,7 @@ IMPORTANT: Use Context7 for code generation, setup or configuration steps, or li
|
||||
### Before Making Changes
|
||||
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
|
||||
2. Test changes with relevant test suites (prefer single-file test runs)
|
||||
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
|
||||
3. Ensure database migrations are generated for entity changes
|
||||
4. Check that GraphQL schema changes are backward compatible
|
||||
5. Run `graphql:generate` after any GraphQL schema changes
|
||||
|
||||
|
||||
@@ -1,164 +1,126 @@
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
<img src="./packages/twenty-website/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">🌐 Website</a> · <a href="https://docs.twenty.com">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-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.png" width="12" height="12"/> Figma</a></p>
|
||||
<br />
|
||||
|
||||
<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.png" 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-new/public/images/readme/github-cover-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.png" alt="Twenty banner" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Cover" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
# Why Twenty
|
||||
|
||||
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/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-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
See:
|
||||
🚀 [Self-hosting](https://docs.twenty.com/developers/self-host/capabilities/docker-compose)
|
||||
🖥️ [Local Setup](https://docs.twenty.com/developers/contribute/capabilities/local-setup)
|
||||
|
||||
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.
|
||||
# Why Twenty
|
||||
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
We built Twenty for three reasons:
|
||||
|
||||
Scaffold a new app with the Twenty CLI:
|
||||
**CRMs are too expensive, and users are trapped.** Companies use locked-in customer data to hike prices. It shouldn't be that way.
|
||||
|
||||
```bash
|
||||
npx create-twenty-app my-app
|
||||
```
|
||||
**A fresh start is required to build a better experience.** We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
|
||||
|
||||
Define objects, fields, and views as code:
|
||||
|
||||
```ts
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
nameSingular: 'deal',
|
||||
namePlural: 'deals',
|
||||
labelSingular: 'Deal',
|
||||
labelPlural: 'Deals',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: FieldType.TEXT },
|
||||
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
|
||||
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Then ship it to your workspace:
|
||||
|
||||
```bash
|
||||
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-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).
|
||||
**We believe in open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
# Everything you need
|
||||
# What You Can Do With 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.
|
||||
Please feel free to flag any specific needs you have by creating an issue.
|
||||
|
||||
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.
|
||||
Below are a few features we have implemented to date:
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
|
||||
</picture>
|
||||
<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-new/public/images/readme/v2-version-control-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" 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-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-new/public/images/readme/v2-all-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" 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-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-new/public/images/readme/v2-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
|
||||
</picture>
|
||||
<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-new/public/images/readme/v2-ai-agents-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
|
||||
</picture>
|
||||
<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-new/public/images/readme/v2-crm-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" 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-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
+ [Personalize layouts with filters, sort, group by, kanban and table views](#personalize-layouts-with-filters-sort-group-by-kanban-and-table-views)
|
||||
+ [Customize your objects and fields](#customize-your-objects-and-fields)
|
||||
+ [Create and manage permissions with custom roles](#create-and-manage-permissions-with-custom-roles)
|
||||
+ [Automate workflow with triggers and actions](#automate-workflow-with-triggers-and-actions)
|
||||
+ [Emails, calendar events, files, and more](#emails-calendar-events-files-and-more)
|
||||
|
||||
|
||||
## Personalize layouts with filters, sort, group by, kanban and table views
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/views-light.png" alt="Companies Kanban Views" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Customize your objects and fields
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/data-model-light.png" alt="Setting Custom Objects" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Create and manage permissions with custom roles
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/permissions-light.png" alt="Permissions" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Automate workflow with triggers and actions
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/workflows-light.png" alt="Workflows" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Emails, calendar events, files, and more
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/plus-other-features-light.png" alt="Other Features" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
# Stack
|
||||
|
||||
- <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>
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Nx](https://nx.dev/)
|
||||
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
|
||||
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Linaria](https://linaria.dev/) and [Lingui](https://lingui.dev/)
|
||||
|
||||
|
||||
|
||||
# Thanks
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
|
||||
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
|
||||
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="30" alt="Chromatic" /></a>
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
|
||||
<a href="https://e2b.dev/"><img src="./packages/twenty-website/public/images/readme/e2b.svg" height="30" alt="E2B" /></a>
|
||||
</p>
|
||||
|
||||
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
@@ -166,4 +128,9 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Join the Community
|
||||
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-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>
|
||||
- Star the repo
|
||||
- Subscribe to releases (watch -> custom -> releases)
|
||||
- Follow us on [Twitter](https://twitter.com/twentycrm) or [LinkedIn](https://www.linkedin.com/company/twenty/)
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
- Improve translations on [Crowdin](https://twenty.crowdin.com/twenty)
|
||||
- [Contributions](https://github.com/twentyhq/twenty/contribute) are, of course, most welcome!
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
# Upgrade Experience V2 -- Design Document
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current upgrade system has several pain points:
|
||||
|
||||
- **No cross-version upgrade**: a workspace must be on exactly the previous minor version to upgrade. Skipping versions requires stepping through each intermediate release.
|
||||
- **Opaque errors**: failures surface as raw stack traces with no structured reporting or actionable diagnostics.
|
||||
- **Misleading abstractions**: the command runner inheritance chain (`MigrationCommandRunner` -> `WorkspacesMigrationCommandRunner` -> `ActiveOrSuspendedWorkspacesMigrationCommandRunner` -> `UpgradeCommandRunner`) conflates global operations with per-workspace operations under a single hierarchy.
|
||||
- **No post-upgrade health check**: after upgrade, there is no validation that the workspace is in a consistent state.
|
||||
- **No workspace status visibility**: end users (self-hosted admins) have no way to see their workspace version or whether it is out of date.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Cross-version upgrade** works across the ordered list of supported versions (a workspace on `1.18.0` targeting `1.20.0` runs `1.19.0` then `1.20.0` bundles sequentially).
|
||||
- **Major.minor bundle identity**: the orchestrator groups upgrade commands by `major.minor`. Patch releases do not create new bundles, but they may patch an existing version line by adding new corrective commands inside the existing `major.minor` bundle.
|
||||
- **Post-upgrade health check** validates workspace consistency after migration.
|
||||
- **Improved developer experience** for twenty-eng: clear command taxonomy, scoped responsibilities, easy to add new version bundles and commands.
|
||||
- **Report-a-problem template** that gathers workspace status including upgrade stack traces.
|
||||
- **Settings page** (follow-up) shows current workspace version; if it differs from the installed server version, prompts the user to contact their administrator.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Sequentiality**: the upgrade processes one version bundle at a time, fully completing it (`instanceCommands`, stamp `instanceVersion`, then `perWorkspaceCommands` for all workspaces) before moving to the next. There is no cross-version interleaving -- the instance must finish upgrading all workspaces to version N before any version N+1 commands run.
|
||||
- **Idempotency**: all upgrade commands -- both `GlobalCommand` (in `instanceCommands`) and `PerWorkspaceCommand` (in `perWorkspaceCommands`) -- must be idempotent. Running the same command twice on the same workspace (or the same global state) produces the same result as running it once. This is critical because re-runs after partial failures must be safe, and single-workspace upgrades re-run global commands that may have already been applied.
|
||||
- **`instanceVersion` advancement guard**: `instanceVersion` can only advance to N+1 if **all workspace versions equal `instanceVersion`** (i.e. all workspaces are at N). If any workspace is behind -- because it failed a previous `perWorkspaceCommands` pass -- the orchestrator refuses to run the next bundle's `instanceCommands`. The operator must fix the failing workspace and re-run before the instance can move forward.
|
||||
- **Forward compatibility of global changes**: global commands (especially `instanceCommands` like TypeORM migrations) must produce a schema that is compatible with workspaces still at `instanceVersion - 1`. Since `instanceVersion` is stamped after `instanceCommands`, workspaces are still at the previous version when the global schema changes land. A global schema change that breaks those workspaces violates the upgrade contract.
|
||||
- **Breaking change backward compatibility**: a breaking change introduced in version N's `instanceCommands` must not invalidate any upgrade command in a version bundle still present in `UPGRADE_COMMAND_SUPPORTED_VERSIONS`. During a cross-version upgrade, older bundles' `perWorkspaceCommands` run against the global schema that all preceding `instanceCommands` (including N's) have already modified. If a breaking change in N makes a command in bundle M (where M < N) fail -- e.g. dropping a column that M's command reads -- then M and every version below it must be removed from `UPGRADE_COMMAND_SUPPORTED_VERSIONS`. This narrows the supported upgrade range, so breaking changes should be avoided or deferred whenever possible.
|
||||
- **No downgrade support**: the upgrade path is forward-only.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Command Golden Rules
|
||||
|
||||
These rules define how upgrade commands evolve over time. They exist to avoid
|
||||
history collisions, make patching safe, and keep command intent readable months
|
||||
after a release shipped.
|
||||
|
||||
1. **A command name is a contract**: once a command has shipped, its name
|
||||
becomes part of the upgrade history contract. A successful history row for
|
||||
`commandName = foo` means "the logical operation `foo` already ran".
|
||||
2. **Do not silently change the meaning of a released command**: if a command
|
||||
already shipped and later turns out to be incomplete, buggy, or wrongly
|
||||
ordered, do not keep the same name and change the implementation in place.
|
||||
3. **New intent requires new identity**: if behavior materially changes, create
|
||||
a new command name (`foo-fix-missing-bar`, `foo-v2`,
|
||||
`rebuild-foo-after-order-fix`, etc.). This lets the new command run once
|
||||
without colliding with prior successful history rows.
|
||||
4. **Ordering fixes are modeled as new commands**: if a command was released in
|
||||
the wrong order, fix the workflow by adding a new compensating command in the
|
||||
correct position, not by trying to reinterpret the old history row.
|
||||
5. **Prefer additive repair over replaying history**: if production data needs
|
||||
correction after release, prefer adding a new corrective command rather than
|
||||
renaming or deleting history rows.
|
||||
6. **Idempotency still applies**: even when introducing corrective commands, the
|
||||
new command must still be safe to re-run after partial failure.
|
||||
|
||||
These rules are especially important for patching an existing version line. If
|
||||
`1.20.1` needs to fix work already attempted by `1.20.0`, the fix should
|
||||
generally be represented as a **new command in the `1.20` bundle**, not as a
|
||||
silent rewrite of the old command's meaning.
|
||||
|
||||
---
|
||||
|
||||
## Instance Version
|
||||
|
||||
The upgrade system introduces a new concept: **`instanceVersion`** -- the version the instance's global state (schema, core data) has been upgraded to. This is distinct from:
|
||||
|
||||
- **`APP_VERSION`**: the version of the deployed Twenty codebase (set at build time, read from env/package).
|
||||
- **Workspace version**: the version each individual workspace has been upgraded to.
|
||||
|
||||
`instanceVersion` tracks how far the shared database has progressed through the upgrade timeline. It is the gate that controls which workspaces are eligible for upgrade.
|
||||
|
||||
### Storage
|
||||
|
||||
Stored in the existing `KeyValuePair` table (`core` schema) as a `CONFIG_VARIABLE` entry with `userId = null` and `workspaceId = null`, key = `INSTANCE_VERSION`. This leverages the existing `ConfigStorageService` / `ConfigVariables` infrastructure -- no new table or schema change required.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. Before any upgrade, `instanceVersion` reflects the last fully-completed version bundle (e.g. `1.18.0`).
|
||||
2. The orchestrator verifies all workspace versions equal `instanceVersion` (advancement guard).
|
||||
3. The orchestrator picks the next version bundle in `UPGRADE_COMMAND_SUPPORTED_VERSIONS` (e.g. `bundle_1190`).
|
||||
4. The `instanceCommands` run (global schema changes).
|
||||
5. `instanceVersion` is stamped to `1.19.0`. The shared database is now at this version's global state.
|
||||
6. The `perWorkspaceCommands` run for all workspaces at `1.18.0` (i.e. `instanceVersion - 1`).
|
||||
7. Once all workspaces succeed, the orchestrator moves to the next version bundle.
|
||||
|
||||
`instanceVersion` advances after `instanceCommands` complete -- it reflects the global schema state. Workspace versions catch up via `perWorkspaceCommands`. During cloud development, `instanceVersion` is **not** bumped on deploys -- only `instanceCommands` are executed (with skip-if-completed via `instance_upgrade_history`). `instanceVersion` stays at the previous stable version until the release owner runs the full orchestrator (see "Deploying a Partial Next Version to Cloud Production").
|
||||
|
||||
### Workspace Eligibility
|
||||
|
||||
A workspace can only be upgraded by a version bundle if it is at exactly `instanceVersion - 1` -- the version immediately before the current instance version in `UPGRADE_COMMAND_SUPPORTED_VERSIONS`. Workspaces that are further behind are not eligible and must be addressed before the instance can proceed to the next version.
|
||||
|
||||
### Initial Value
|
||||
|
||||
On a fresh install, `instanceVersion` is set to `APP_VERSION` (no upgrade needed). On an existing instance that predates this feature, the migration that introduces `instanceVersion` seeds it from the current `APP_VERSION` at the time of deployment.
|
||||
|
||||
### New Workspace Version
|
||||
|
||||
When a new workspace is created, it receives `instanceVersion` as its workspace version -- not `APP_VERSION`. This ensures the workspace is aligned with the last fully-completed upgrade state. During cloud development, `APP_VERSION` may be ahead of `instanceVersion` (e.g. `APP_VERSION = 1.21.0` while `instanceVersion = 1.20.0`), so using `instanceVersion` prevents creating workspaces at a version that hasn't been fully upgraded to yet.
|
||||
|
||||
---
|
||||
|
||||
## Command Taxonomy
|
||||
|
||||
Replace the current deep inheritance chain with explicit base classes and an orchestrator.
|
||||
|
||||
### Terminology
|
||||
|
||||
- **UpgradeCommandOrchestrator**: the top-level orchestrator that resolves versions, processes version bundles sequentially, and manages the upgrade flow.
|
||||
- **UpgradeVersionBundle**: the per-version `{ instanceCommands, perWorkspaceCommands }` object (e.g. `bundle_1200`). Groups all commands needed to upgrade workspaces *to* that version.
|
||||
- **UpgradeCommand**: an individual command within a bundle's `instanceCommands` or `perWorkspaceCommands` array. Base class for all upgrade commands. Subtypes:
|
||||
- **GlobalCommand**: runs once, globally, workspace-agnostic. Used **only in `instanceCommands`**. Example: TypeORM core migrations, breaking schema changes.
|
||||
- **PerWorkspaceCommand**: iterates over all active/suspended workspaces and executes per-workspace logic. Used **only in `perWorkspaceCommands`**. Example: backfilling data, migrating workspace schemas.
|
||||
|
||||
### Command Contract
|
||||
|
||||
Every `UpgradeCommand` (both `GlobalCommand` and `PerWorkspaceCommand`) must follow a strict contract:
|
||||
|
||||
**Return type -- discriminated union**:
|
||||
|
||||
```typescript
|
||||
type UpgradeCommandResult =
|
||||
| { status: 'success' }
|
||||
| { status: 'failure'; error: string };
|
||||
```
|
||||
|
||||
- Commands must **never throw**. They return `{ status: 'failure', error }` instead.
|
||||
- The orchestrator wraps each command execution in a try/catch to handle unexpected exceptions -- these are converted into a `failure` result with the caught error message and stack.
|
||||
|
||||
**Log capture via correlation ID**:
|
||||
|
||||
Commands use the standard NestJS `Logger` (`this.logger`) -- no custom callback or API change. The orchestrator captures all logs emitted during a command's execution using a correlation ID:
|
||||
|
||||
- Before executing a command, the orchestrator sets a correlation ID (e.g. `commandName + workspaceId`) in async local storage.
|
||||
- The global `LoggerService` includes this correlation ID in every log line, regardless of which Logger instance or service emits it (command code, TypeORM, repositories, framework internals).
|
||||
- A per-command buffer captures all correlated log lines during execution. This gives true per-command log isolation -- ORM queries, SQL errors, service calls, and the command's own narrative all appear in one unified stream.
|
||||
- Tail-truncated to last 5,000 lines if exceeded. A `[TRUNCATED]` header is prepended when truncation occurs.
|
||||
- On **failure**: the buffer is stored in the `logs` column of the relevant history table (`instance_upgrade_history` or `workspace_upgrade_history`).
|
||||
- On **success**: the buffer is discarded (already written to stdout).
|
||||
|
||||
This approach avoids the fragility of wrapping individual Logger instances and naturally supports future parallelized workspace upgrades (each command execution has its own correlation ID).
|
||||
|
||||
**Orchestrator error handling**:
|
||||
|
||||
```typescript
|
||||
let result: UpgradeCommandResult;
|
||||
|
||||
try {
|
||||
result = await command.execute(context);
|
||||
} catch (unexpectedError) {
|
||||
result = {
|
||||
status: 'failure',
|
||||
error: `${unexpectedError.message}\n${unexpectedError.stack}`,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Version Bundles
|
||||
|
||||
Each version defines its upgrade bundle as two ordered arrays:
|
||||
|
||||
- **`instanceCommands`**: Global commands that must execute quickly (e.g. breaking schema changes, TypeORM migrations). Contains **only `GlobalCommand` entries**. These run first, before any workspace is touched.
|
||||
- **`perWorkspaceCommands`**: Per-workspace commands that may take longer (backfills, data migrations). Contains **only `PerWorkspaceCommand` entries**. These run after `instanceCommands` completes, iterating over all eligible workspaces.
|
||||
|
||||
```typescript
|
||||
const bundle_1200: UpgradeVersionBundle = {
|
||||
instanceCommands: [
|
||||
{ type: 'global', command: this.typeOrmMigrationCommandA },
|
||||
{ type: 'global', command: this.typeOrmMigrationCommandB },
|
||||
{ type: 'global', command: this.typeOrmMigrationCommandC },
|
||||
],
|
||||
perWorkspaceCommands: [
|
||||
{ type: 'per-workspace', command: this.backfillCommandMenuItemsCommand },
|
||||
{ type: 'per-workspace', command: this.migrateRichTextToTextCommand },
|
||||
{ type: 'per-workspace', command: this.backfillSelectFieldOptionIdsCommand },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### TypeORM Migration Generation
|
||||
|
||||
TypeORM migrations are no longer generated directly through the raw TypeORM
|
||||
CLI. They now go through a dedicated NestJS command that associates each newly
|
||||
generated migration with the **latest known version line**.
|
||||
|
||||
**Why**:
|
||||
|
||||
- TypeORM migrations are part of `instanceCommands`, so they must belong to a
|
||||
specific upgrade bundle.
|
||||
- We want one canonical place to decide which version line a new migration
|
||||
belongs to.
|
||||
- This avoids developers generating a migration and then manually forgetting to
|
||||
associate it with the latest bundle.
|
||||
|
||||
**How it works**:
|
||||
|
||||
1. A developer runs a NestJS command to generate the migration.
|
||||
2. That command resolves the latest known version from
|
||||
`UPGRADE_COMMAND_SUPPORTED_VERSIONS`.
|
||||
3. The generated migration is associated with that latest known version line.
|
||||
4. The matching `TypeOrmMigrationCommand` for that bundle will execute it as
|
||||
part of the bundle's `instanceCommands`.
|
||||
|
||||
### Orchestrator
|
||||
|
||||
`UpgradeCommandOrchestrator` (renamed from `UpgradeCommand`) is responsible for:
|
||||
|
||||
1. Resolving the current `APP_VERSION`, `instanceVersion`, and the supported upgrade range.
|
||||
2. For each version bundle from `instanceVersion + 1` to `APP_VERSION` (sequentially):
|
||||
a. Run the `instanceCommands` array (global commands). Skip commands already recorded as `completed` in `instance_upgrade_history`.
|
||||
b. Stamp `instanceVersion` to this version.
|
||||
c. Run the `perWorkspaceCommands` array for all workspaces at `instanceVersion - 1`. Skip commands already recorded as `completed` in `workspace_upgrade_history` for each workspace. Stamp each workspace's version after its commands complete.
|
||||
d. Verify all workspaces succeeded before moving to the next version bundle.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Version Upgrade
|
||||
|
||||
### How It Works
|
||||
|
||||
`UPGRADE_COMMAND_SUPPORTED_VERSIONS` remains an ordered list of versions (e.g. `['1.18.0', '1.19.0', '1.20.0']`). The orchestrator uses it as a timeline, combined with `instanceVersion` to determine where to start:
|
||||
|
||||
1. Read `instanceVersion` from the database.
|
||||
2. Find the next version after `instanceVersion` in the ordered list.
|
||||
3. Process each version bundle sequentially up to `APP_VERSION`, fully completing one before starting the next.
|
||||
|
||||
No per-version allowlist is needed. The ordered list itself defines the upgrade path. The oldest entry in the list is the oldest supported source version -- anything below it is out of range.
|
||||
|
||||
The `1.20.0` codebase must ship all version bundles back to the oldest supported version. If `UPGRADE_COMMAND_SUPPORTED_VERSIONS` is `['1.17.0', '1.18.0', '1.19.0', '1.20.0']`, then the `1.20.0` release includes `bundle_1180`, `bundle_1190`, and `bundle_1200`.
|
||||
|
||||
### Execution Flow
|
||||
|
||||
The upgrade processes **one version bundle at a time**, fully completing it before moving to the next:
|
||||
|
||||
**For each version bundle** (from `instanceVersion + 1` to `APP_VERSION`):
|
||||
|
||||
1. **Run `instanceCommands` array** (global commands). Commands already recorded as `completed` in `instance_upgrade_history` are skipped. If any command fails, the upgrade aborts immediately. There is no rollback of previously completed commands; each runs in its own transaction. The operator must fix the issue and re-run.
|
||||
|
||||
2. **Stamp `instanceVersion`** to this version. The shared database is now at this version's global state.
|
||||
|
||||
3. **Run `perWorkspaceCommands` array** for all workspaces at `instanceVersion - 1`. The orchestrator queries for workspaces at exactly the previous version and runs each `PerWorkspaceCommand` in order. Commands already recorded as `completed` in `workspace_upgrade_history` for a given workspace are skipped. Each workspace's version is stamped after all its per-workspace commands complete. If a workspace fails, the orchestrator records the failure and **continues to the next workspace** -- it does not stop at the first failure.
|
||||
|
||||
4. **All workspaces must succeed** before the orchestrator moves to the next version bundle. After attempting all workspaces, if any failed, the upgrade halts. The `instanceVersion` advancement guard enforces this: the next bundle cannot start because not all workspace versions equal `instanceVersion`. The operator must fix the failing workspaces and re-run.
|
||||
|
||||
### No Straggler Rescue
|
||||
|
||||
Unlike a model where the orchestrator walks behind-workspaces through multiple intermediate bundles, the sequential model requires workspaces to be at exactly `instanceVersion - 1` to be eligible for upgrade. Workspaces that are further behind (e.g. they failed a previous upgrade cycle) are **not eligible** -- they block the instance from proceeding to the next version.
|
||||
|
||||
This is intentional: the hard-block forces operators to resolve failures before moving forward, preventing a growing tail of broken workspaces across versions.
|
||||
|
||||
### Real-World Example
|
||||
|
||||
**Context**: the server was previously running `1.18.0` (`instanceVersion = 1.18.0`). We are now deploying `1.20.0`.
|
||||
|
||||
**Supported versions**: `['1.17.0', '1.18.0', '1.19.0', '1.20.0']`
|
||||
|
||||
**Workspaces**: all at `1.18.0` (A, B, C).
|
||||
|
||||
**Version bundles shipped with `1.20.0`**:
|
||||
|
||||
```typescript
|
||||
this.allBundles = {
|
||||
'1.18.0': bundle_1180, // for workspaces on 1.17.0
|
||||
'1.19.0': bundle_1190, // for workspaces on 1.18.0
|
||||
'1.20.0': bundle_1200, // for workspaces on 1.19.0
|
||||
};
|
||||
```
|
||||
|
||||
**Version bundle 1.19.0**:
|
||||
|
||||
```
|
||||
> instanceVersion = 1.18.0, target = 1.20.0
|
||||
> Processing bundle_1190...
|
||||
|
||||
instanceCommands:
|
||||
[global] TypeORM migrations for 1.19.0 OK
|
||||
[global] Schema change ABC OK
|
||||
instanceVersion stamped to 1.19.0
|
||||
|
||||
perWorkspaceCommands (workspaces at 1.18.0: A, B, C):
|
||||
> Workspace A:
|
||||
[per-workspace] backfill feature flags OK
|
||||
Workspace A stamped to 1.19.0
|
||||
> Workspace B:
|
||||
[per-workspace] backfill feature flags FAILED
|
||||
Workspace B stays at 1.18.0
|
||||
> Workspace C:
|
||||
[per-workspace] backfill feature flags OK
|
||||
Workspace C stamped to 1.19.0
|
||||
*** 1 workspace failed. Upgrade halts after this bundle. ***
|
||||
```
|
||||
|
||||
**The upgrade halts.** `instanceVersion` is `1.19.0` (stamped after `instanceCommands`). Workspaces A and C are at `1.19.0`, but workspace B is still at `1.18.0`. All three workspaces were attempted -- the orchestrator did not stop at B's failure.
|
||||
|
||||
The operator investigates workspace B's failure (using the `workspace_upgrade_history` table and its captured logs), fixes the underlying issue, and re-runs the upgrade command.
|
||||
|
||||
**Re-run**:
|
||||
|
||||
```
|
||||
> instanceVersion = 1.19.0, target = 1.20.0
|
||||
> Advancement guard: all workspace versions must equal instanceVersion (1.19.0)
|
||||
> Workspace B is at 1.18.0 -- NOT OK
|
||||
> Processing bundle_1190 (perWorkspaceCommands only, instanceCommands already completed)...
|
||||
|
||||
instanceCommands:
|
||||
[global] TypeORM migrations for 1.19.0 SKIP (completed in history)
|
||||
[global] Schema change ABC SKIP (completed in history)
|
||||
instanceVersion already at 1.19.0 (no-op)
|
||||
|
||||
perWorkspaceCommands (workspaces at 1.18.0: B):
|
||||
> Workspace A: already at 1.19.0 (skipped)
|
||||
> Workspace B:
|
||||
[per-workspace] backfill feature flags OK (fixed)
|
||||
Workspace B stamped to 1.19.0
|
||||
> Workspace C: already at 1.19.0 (skipped)
|
||||
|
||||
> Processing bundle_1200...
|
||||
|
||||
instanceCommands:
|
||||
[global] TypeORM migrations for 1.20.0 OK
|
||||
[global] Breaking schema change XYZ OK
|
||||
instanceVersion stamped to 1.20.0
|
||||
|
||||
perWorkspaceCommands (workspaces at 1.19.0: A, B, C):
|
||||
> Workspace A:
|
||||
[per-workspace] backfillCommandMenuItems OK
|
||||
[per-workspace] migrateRichTextToText OK
|
||||
Workspace A stamped to 1.20.0
|
||||
> Workspace B:
|
||||
[per-workspace] backfillCommandMenuItems OK
|
||||
[per-workspace] migrateRichTextToText OK
|
||||
Workspace B stamped to 1.20.0
|
||||
> Workspace C:
|
||||
[per-workspace] backfillCommandMenuItems OK
|
||||
[per-workspace] migrateRichTextToText OK
|
||||
Workspace C stamped to 1.20.0
|
||||
```
|
||||
|
||||
**Key observations**:
|
||||
|
||||
- The upgrade is strictly sequential: `bundle_1190` must fully complete (`instanceCommands` + all workspaces `perWorkspaceCommands`) before `bundle_1200` starts.
|
||||
- `instanceVersion` is stamped after `instanceCommands` complete -- it advanced to `1.19.0` even though workspace B failed. On re-run, the orchestrator sees `instanceVersion = 1.19.0` and skips `instanceCommands` for `bundle_1190`.
|
||||
- All workspaces are attempted even when one fails. Workspace C was upgraded successfully despite workspace B's failure. This maximizes progress per run.
|
||||
- On re-run, instance commands are skipped (recorded as `completed` in `instance_upgrade_history`). Workspaces A and C are already at `1.19.0` so they're skipped. Only workspace B is re-attempted.
|
||||
- The `instanceVersion` advancement guard blocked `bundle_1200` from starting because workspace B was still at `1.18.0` (not equal to `instanceVersion = 1.19.0`). Once B succeeds on re-run, all workspaces are at `1.19.0` and the guard passes.
|
||||
- `perWorkspaceCommands` contains only `PerWorkspaceCommand` entries. Any global work belongs in `instanceCommands`.
|
||||
|
||||
### Failure Behavior
|
||||
|
||||
- **Instance command failure**: the upgrade aborts immediately. `instanceVersion` is not stamped (the failing command's transaction is rolled back, previously completed commands remain applied). No per-workspace commands run.
|
||||
- **Per-workspace command failure (any workspace)**: the orchestrator **continues attempting all remaining workspaces** -- it does not stop at the first failure. `instanceVersion` was already stamped (after `instanceCommands`). Failed workspaces keep their current version. Successfully upgraded workspaces keep their new version stamp. After all workspaces have been attempted, the upgrade halts. The `instanceVersion` advancement guard prevents the next bundle from starting: since the failed workspace's version is still at `instanceVersion - 1` (not equal to `instanceVersion`), the orchestrator will refuse to advance further until all workspaces catch up. The operator must fix the failing workspaces and re-run -- already-completed commands (including successful workspaces) are skipped via the history tables.
|
||||
|
||||
The final report includes per-workspace status (success / failure).
|
||||
|
||||
### Guard Logic
|
||||
|
||||
Before running `instanceCommands` for a version bundle, the orchestrator verifies that **all workspace versions equal `instanceVersion`**. If any workspace is behind (e.g. it failed during the previous bundle's `perWorkspaceCommands`), the orchestrator refuses to advance. This is the primary guard -- `instanceVersion` never moves forward while any workspace is lagging.
|
||||
|
||||
On the very first bundle (or after a fresh deploy), the orchestrator also checks for workspaces below `instanceVersion`. These workspaces are too far behind to be eligible:
|
||||
|
||||
- **Self-hosted (default)**: the upgrade refuses to start. A clear message lists the affected workspaces and the minimum required version.
|
||||
- **Self-hosted (`--force`)**: the upgrade proceeds, but ineligible workspaces are skipped and reported as "refused". The hard-block on workspace failure still applies to eligible workspaces.
|
||||
|
||||
### Single-Workspace Upgrade (`-w`)
|
||||
|
||||
When targeting a single workspace, the orchestrator still runs all `instanceCommands` (global) for the relevant version bundle because they affect the shared database, and stamps `instanceVersion`. Then only the targeted workspace's `perWorkspaceCommands` run. Other workspaces remain at their current version. This means global changes "leak" to all other workspaces. This is acceptable because:
|
||||
|
||||
- All commands are idempotent -- when other workspaces are upgraded later, global commands no-op.
|
||||
- Global schema changes are forward-compatible by design -- workspaces at `instanceVersion - 1` continue to work against the new schema.
|
||||
|
||||
The orchestrator logs a clear warning when running in single-workspace mode: global commands will be applied to the shared database and affect all workspaces.
|
||||
|
||||
Note: after a single-workspace upgrade, the non-targeted workspaces are still at the previous version. The `instanceVersion` advancement guard will block the next bundle until all workspaces are upgraded to `instanceVersion`. The operator must run the full orchestrator (or target each remaining workspace individually) before the instance can advance further.
|
||||
|
||||
### Breaking Changes and Stale Versions
|
||||
|
||||
Breaking changes in the upgrade history are avoided unless they would break the cross-version upgrade path. When a breaking change is unavoidable:
|
||||
|
||||
- The breaking change may make one or more commands in an older version bundle **stale** (e.g. a command that backfills a column that no longer exists after the breaking change).
|
||||
- When any command in a version bundle becomes stale, the **entire version must be dropped** from `UPGRADE_COMMAND_SUPPORTED_VERSIONS` -- not just the individual stale command. A workspace on that version needs all of its bundle's commands to upgrade successfully; if even one command is broken, the full upgrade path from that version is invalid.
|
||||
- The entire version bundle (`instanceCommands` and `perWorkspaceCommands`) is removed from the codebase as a unit.
|
||||
|
||||
Since the supported range is a contiguous upgrade path, invalidating a version also invalidates **every version below it** -- those workspaces would need to pass through the invalidated version bundle to reach the target.
|
||||
|
||||
Example: `bundle_1190` has a command that backfills column `X`. In `1.21.0`, a breaking change drops column `X`. That single stale command invalidates the entire `1.19.0` version bundle. But `1.18.0` and `1.17.0` are also invalidated because they depend on the `1.19.0` bundle to reach the target. All three versions and their bundles are removed from `UPGRADE_COMMAND_SUPPORTED_VERSIONS`. The oldest supported source version becomes `1.20.0`.
|
||||
|
||||
### Workspace Recap Tooling
|
||||
|
||||
A dedicated recap/status command should provide visibility into this:
|
||||
|
||||
- List all workspaces with their current version.
|
||||
- Flag workspaces that are **below the supported range** (too far behind to be upgraded by the current version).
|
||||
- Flag workspaces that are **at risk** of falling out of range if the next version introduces a breaking change.
|
||||
- Warn when a version is about to be or has been dropped from the supported list, and which workspaces are affected.
|
||||
|
||||
This recap is also the foundation for the "report-a-problem" template and the settings page workspace status (follow-up).
|
||||
|
||||
---
|
||||
|
||||
## Upgrade History
|
||||
|
||||
Two tables in the **core schema** (shared, not per-workspace) that record command executions. Splitting mirrors the taxonomy: `instanceCommands` -> `instance_upgrade_history`, `perWorkspaceCommands` -> `workspace_upgrade_history`. This avoids nullable foreign keys and makes each table's schema tight and query-friendly.
|
||||
|
||||
### `instance_upgrade_history` Table
|
||||
|
||||
Tracks execution of `GlobalCommand` entries from `instanceCommands` arrays.
|
||||
|
||||
**Columns**:
|
||||
|
||||
- `id` (uuid, PK)
|
||||
- `version` (varchar -- the version bundle this command belongs to, e.g. `1.20.0`)
|
||||
- `commandName` (varchar -- unique identifier for the command, e.g. `typeOrmMigration`)
|
||||
- `status` (varchar -- `completed` / `failed`)
|
||||
- `retry` (integer, NOT NULL, default `0` -- zero-indexed retry counter. The first execution is `0`, the first re-run is `1`, etc.)
|
||||
- `runByVersion` (varchar -- the `APP_VERSION` of the Twenty instance that executed this command, e.g. `1.20.1`. Useful for debugging: if a command was completed by a buggy version, this tells you which build ran it.)
|
||||
- `error` (text, nullable -- full error string from `UpgradeCommandResult.error` on failure, including stack trace)
|
||||
- `logs` (text, nullable -- all log output correlated to this command execution via correlation ID. Tail-truncated to last 5,000 lines if exceeded; when truncated, a `[TRUNCATED - showing last 5000 of N total lines]` header is prepended. Stored on failure only.)
|
||||
- `createdAt` / `updatedAt` (timestamps)
|
||||
|
||||
**Index**: `(commandName, version)` -- not unique, since multiple rows can exist for the same command across retries.
|
||||
|
||||
### `workspace_upgrade_history` Table
|
||||
|
||||
Tracks execution of `PerWorkspaceCommand` entries from `perWorkspaceCommands` arrays.
|
||||
|
||||
**Columns**:
|
||||
|
||||
- `id` (uuid, PK)
|
||||
- `workspaceId` (uuid, NOT NULL)
|
||||
- `version` (varchar -- the version bundle this command belongs to, e.g. `1.20.0`)
|
||||
- `commandName` (varchar -- unique identifier for the command, e.g. `backfillCommandMenuItems`)
|
||||
- `status` (varchar -- `completed` / `failed`)
|
||||
- `retry` (integer, NOT NULL, default `0` -- zero-indexed retry counter. The first execution is `0`, the first re-run is `1`, etc.)
|
||||
- `runByVersion` (varchar -- the `APP_VERSION` of the Twenty instance that executed this command, e.g. `1.20.1`.)
|
||||
- `error` (text, nullable -- full error string from `UpgradeCommandResult.error` on failure, including stack trace)
|
||||
- `logs` (text, nullable -- all log output correlated to this command execution via correlation ID. Tail-truncated to last 5,000 lines if exceeded; when truncated, a `[TRUNCATED - showing last 5000 of N total lines]` header is prepended. Stored on failure only.)
|
||||
- `createdAt` / `updatedAt` (timestamps)
|
||||
|
||||
**Index**: `(commandName, version, workspaceId)` -- not unique, since multiple rows can exist for the same command across retries.
|
||||
|
||||
### Shared Lifecycle
|
||||
|
||||
Both tables are **append-only and immutable** -- rows are inserted once and never updated:
|
||||
|
||||
- On completion: insert a row with `status: completed`, `retry: 0`.
|
||||
- On failure: insert a row with `status: failed`, `error`, and `logs`, `retry: 0`.
|
||||
- On re-run: insert a **new row** with `retry` incremented. The new row gets its own final `status` (`completed` or `failed`). Previous rows are never touched.
|
||||
|
||||
There is no `started` status. A row is only written once the command finishes (successfully or not). A crash mid-command leaves no row at all -- the orchestrator treats the absence of a `completed` row as "not completed" on re-run.
|
||||
|
||||
Previous failure rows are preserved across retries. This gives operators a full execution history for debugging: they can see every attempt, what error occurred, and which `runByVersion` executed it. The orchestrator determines the current state of a command by looking at the row with the **highest `retry` value** for a given `(commandName, version)` or `(commandName, version, workspaceId)` tuple.
|
||||
|
||||
### Re-Run Behavior and Skip-If-Completed
|
||||
|
||||
The orchestrator uses the history tables as a **control mechanism**: before running a command, it checks whether the **latest row** (highest `retry`) for that command is `completed` in the relevant history table and skips it if so. This is essential for two reasons:
|
||||
|
||||
1. **Partial failure recovery**: on re-run after a failure, already-completed commands are skipped rather than re-executed. This avoids redundant work on expensive commands (e.g. full table scans that would no-op row by row).
|
||||
2. **Cloud development workflow**: during incremental cloud deploys, commands may be run individually before the stable release. When the release owner runs the full orchestrator, the history tables tell it exactly which commands have already been applied. See "Deploying a Partial Next Version to Cloud Production" for the full workflow.
|
||||
|
||||
A `--force-rerun` flag overrides this and re-executes all commands regardless of history. This is useful for debugging or when a command was recorded as `completed` but produced incorrect results (e.g. a buggy version ran it). All commands must still be idempotent -- `--force-rerun` relies on this guarantee.
|
||||
|
||||
### Row Eviction
|
||||
|
||||
Because the tables are append-only, rows accumulate over time. In practice the volume is modest -- a version bundle has ~5-10 commands, retries are rare, and even cloud environments with thousands of workspaces produce manageable row counts. Still, stale rows serve no operational purpose once their version is no longer part of the upgrade path.
|
||||
|
||||
**Policy**: when a version is dropped from `UPGRADE_COMMAND_SUPPORTED_VERSIONS` (see "Breaking Changes and Stale Versions"), all history rows for that version are deleted from both `instance_upgrade_history` and `workspace_upgrade_history`. The commands those rows reference no longer exist in the codebase and can never be re-run -- the history is purely archival at that point.
|
||||
|
||||
**Timing**: eviction runs at the start of the upgrade orchestrator, before processing any version bundle. The orchestrator compares the versions present in the history tables against `UPGRADE_COMMAND_SUPPORTED_VERSIONS` and deletes rows whose `version` is not in the list. This keeps the tables tight without requiring a separate cleanup job or manual intervention.
|
||||
|
||||
**No time-based eviction**: rows are not evicted by age. A version may stay in `UPGRADE_COMMAND_SUPPORTED_VERSIONS` for a long time (e.g. the oldest supported source version), and its history rows must remain queryable for the skip-if-completed mechanism to work. Version-based eviction is the only safe trigger.
|
||||
|
||||
---
|
||||
|
||||
## Deploying a Partial Next Version to Cloud Production
|
||||
|
||||
During the development cycle for a new version (e.g. `1.21.0`), the cloud production environment receives incremental deploys before the stable release. Each deploy ships `APP_VERSION = 1.21.0` (not pre-release tags like `1.21.0-alpha.3`). This means new commands may be added to `bundle_1210` between deploys.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **`instanceCommands` run automatically on each deploy** via a dedicated CLI mode (similar to `database:reset --force`). Each deploy runs the full `instanceCommands` array for the current version bundle. Commands already recorded as `completed` in `instance_upgrade_history` are skipped. New commands added since the last deploy are executed. **`instanceVersion` is not stamped** -- it stays at the previous stable version (e.g. `1.20.0`) throughout the development cycle. The skip-if-completed mechanism (via `instance_upgrade_history`) is what prevents re-running already-applied commands on subsequent deploys, not the version stamp.
|
||||
- **`perWorkspaceCommands` are run manually** by developers via CLI as needed. This gives developers control over when workspace-level migrations run during the development cycle.
|
||||
- **New workspaces** created during the development cycle get `instanceVersion` as their workspace version (e.g. `1.20.0`). Since `instanceVersion` is not stamped during deploys, this is always the last fully-completed version -- not `APP_VERSION`. These workspaces will be upgraded when the release owner runs the full orchestrator.
|
||||
- **At stable release**, the release owner runs the full upgrade orchestrator. The orchestrator processes `bundle_1210`: `instanceCommands` are all skipped (already `completed` in history), `instanceVersion` is then stamped to `1.21.0`, and `perWorkspaceCommands` run for all workspaces still at `1.20.0` (commands already applied manually during the dev cycle are skipped via `workspace_upgrade_history`). This is the first and only time `instanceVersion` advances.
|
||||
|
||||
### Real-World Example
|
||||
|
||||
**Context**: `1.20.0` is the current stable release. `instanceVersion = 1.20.0`. All workspaces at `1.20.0`. The team is developing `1.21.0`.
|
||||
|
||||
**Deploy 1** (ships `APP_VERSION = 1.21.0`, `bundle_1210` has 2 instance commands and 3 per-workspace commands):
|
||||
|
||||
```
|
||||
> APP_VERSION = 1.21.0, instanceVersion = 1.20.0
|
||||
> Running instanceCommands for bundle_1210 (auto-deploy mode)...
|
||||
|
||||
instanceCommands:
|
||||
[global] TypeORM migrations for 1.21.0 OK
|
||||
[global] Add new core index OK
|
||||
instanceVersion not stamped (auto-deploy mode)
|
||||
|
||||
perWorkspaceCommands: not run (auto-deploy mode only runs instanceCommands)
|
||||
```
|
||||
|
||||
**Deploy 2** (a developer adds a new instance command to `bundle_1210`):
|
||||
|
||||
```
|
||||
> APP_VERSION = 1.21.0, instanceVersion = 1.20.0
|
||||
> Running instanceCommands for bundle_1210 (auto-deploy mode)...
|
||||
|
||||
instanceCommands:
|
||||
[global] TypeORM migrations for 1.21.0 SKIP (completed in history)
|
||||
[global] Add new core index SKIP (completed in history)
|
||||
[global] Seed new config variable OK (new command)
|
||||
instanceVersion not stamped (auto-deploy mode)
|
||||
```
|
||||
|
||||
**Developer manually runs perWorkspaceCommands** for a specific workspace during testing:
|
||||
|
||||
```
|
||||
> Running perWorkspaceCommands for bundle_1210 on workspace D (manual CLI)...
|
||||
|
||||
perWorkspaceCommands:
|
||||
> Workspace D:
|
||||
[per-workspace] backfillNewField OK
|
||||
[per-workspace] migrateCalendarEvents OK
|
||||
[per-workspace] updateSearchIndex OK
|
||||
Workspace D stamped to 1.21.0
|
||||
```
|
||||
|
||||
**New workspace E is created** during the dev cycle. It gets workspace version `1.20.0`.
|
||||
|
||||
**Stable release** -- the release owner runs the full upgrade orchestrator:
|
||||
|
||||
```
|
||||
> APP_VERSION = 1.21.0, instanceVersion = 1.20.0
|
||||
> Processing bundle_1210...
|
||||
|
||||
instanceCommands:
|
||||
[global] TypeORM migrations for 1.21.0 SKIP (completed in history)
|
||||
[global] Add new core index SKIP (completed in history)
|
||||
[global] Seed new config variable SKIP (completed in history)
|
||||
instanceVersion stamped to 1.21.0
|
||||
|
||||
perWorkspaceCommands (workspaces at 1.20.0: A, B, C, E):
|
||||
> Workspace A:
|
||||
[per-workspace] backfillNewField OK
|
||||
[per-workspace] migrateCalendarEvents OK
|
||||
[per-workspace] updateSearchIndex OK
|
||||
Workspace A stamped to 1.21.0
|
||||
> Workspace B:
|
||||
[per-workspace] backfillNewField OK
|
||||
[per-workspace] migrateCalendarEvents OK
|
||||
[per-workspace] updateSearchIndex OK
|
||||
Workspace B stamped to 1.21.0
|
||||
> Workspace C:
|
||||
[per-workspace] backfillNewField OK
|
||||
[per-workspace] migrateCalendarEvents OK
|
||||
[per-workspace] updateSearchIndex OK
|
||||
Workspace C stamped to 1.21.0
|
||||
> Workspace D: already at 1.21.0, not eligible (skipped)
|
||||
> Workspace E:
|
||||
[per-workspace] backfillNewField OK
|
||||
[per-workspace] migrateCalendarEvents OK
|
||||
[per-workspace] updateSearchIndex OK
|
||||
Workspace E stamped to 1.21.0
|
||||
```
|
||||
|
||||
**Key observations**:
|
||||
|
||||
- All `instanceCommands` were skipped -- they were already applied across the two deploys during development. The `skipIfCompleted` mechanism (via `instance_upgrade_history`) is what makes this possible, not the version stamp.
|
||||
- `instanceVersion` stayed at `1.20.0` throughout the entire development cycle. It is only stamped to `1.21.0` now, during the full orchestrator run.
|
||||
- Workspace D was manually upgraded during development and is skipped (already at `1.21.0`).
|
||||
- Workspace E was created during the dev cycle with version `1.20.0` and is upgraded now.
|
||||
|
||||
---
|
||||
|
||||
## Post-Upgrade Health Check
|
||||
|
||||
After all version bundles complete, the orchestrator runs a health check:
|
||||
|
||||
- **Instance version**: confirm `instanceVersion` matches `APP_VERSION`.
|
||||
- **Workspace versions**: confirm all workspace versions match `APP_VERSION`.
|
||||
- **Command completion**: verify all expected commands have a `completed` row (highest `retry`) in both `instance_upgrade_history` and `workspace_upgrade_history`. A missing row for an expected command indicates a crash mid-execution.
|
||||
|
||||
Health check results are included in the upgrade report. Failures are warnings (the upgrade itself already succeeded), not rollback triggers.
|
||||
|
||||
Note: broader workspace health (metadata consistency, runtime checks, etc.) is a separate concern from upgrade validation and is out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## Error Reporting and Logging
|
||||
|
||||
### Structured Upgrade Report
|
||||
|
||||
Replace raw stack traces with a structured report, built from the `instance_upgrade_history` and `workspace_upgrade_history` tables:
|
||||
|
||||
- Per-workspace status: success / failure / not-attempted / refused (below supported range, with `--force`).
|
||||
- For failures: the command that failed, a human-readable error message, and the full stack trace captured (not dumped to stdout).
|
||||
- Summary: total workspaces, succeeded, failed, not-attempted.
|
||||
- Instance version before and after the upgrade.
|
||||
|
||||
### Report-a-Problem Template (follow-up)
|
||||
|
||||
A template that gathers:
|
||||
|
||||
- Current workspace version vs installed server version.
|
||||
- Upgrade report (if available).
|
||||
- Stack traces from the last failed upgrade attempt.
|
||||
- Environment info (Postgres version, Redis status, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Frontend (Follow-Up)
|
||||
|
||||
Out of scope for this doc but planned:
|
||||
|
||||
- **General Settings page**: display current workspace version. If it differs from the installed server version, show a banner prompting the user to contact their administrator.
|
||||
- **Report-a-problem**: pre-filled template using the workspace status endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Orchestrator Unit Tests
|
||||
|
||||
The orchestrator motor is covered by unit tests that exercise the full state machine in isolation -- no real database, no real commands. Commands are stubbed to return `completed` or `failed` as needed, and the tests verify the orchestrator's decisions and side effects.
|
||||
|
||||
**Edge cases covered**:
|
||||
|
||||
- **Happy path**: single-version upgrade, multi-version cross-version upgrade, all workspaces succeed.
|
||||
- **Instance command failure**: verify `instanceVersion` is not stamped, no `perWorkspaceCommands` run.
|
||||
- **Per-workspace command failure (single workspace)**: verify the orchestrator continues to all remaining workspaces, records the failure, and halts after the full pass.
|
||||
- **Per-workspace command failure (multiple workspaces)**: verify all failures are collected and reported.
|
||||
- **`instanceVersion` advancement guard**: verify the orchestrator refuses to start the next bundle when any workspace version != `instanceVersion`.
|
||||
- **Skip-if-completed**: verify already-completed commands (both instance and per-workspace) are skipped on re-run.
|
||||
- **Retry counter**: verify re-run inserts a new row with incremented `retry`, previous rows untouched.
|
||||
- **`--force-rerun`**: verify all commands re-execute regardless of history.
|
||||
- **`--force` with straggler workspaces**: verify ineligible workspaces are skipped and reported as "refused".
|
||||
- **Single-workspace mode (`-w`)**: verify `instanceCommands` run, only the targeted workspace's `perWorkspaceCommands` run, other workspaces untouched.
|
||||
- **No-op upgrade**: `instanceVersion` == `APP_VERSION`, nothing to do.
|
||||
- **Log capture**: verify failure logs are stored in the history row, success logs are discarded.
|
||||
- **Row eviction**: verify rows for versions not in `UPGRADE_COMMAND_SUPPORTED_VERSIONS` are deleted at the start of the run.
|
||||
|
||||
### Pre-Release CI: Full Cross-Version Upgrade
|
||||
|
||||
A CI pipeline runs before every release to validate the entire cross-version upgrade path end-to-end. This catches breaking changes that would silently narrow the supported upgrade range.
|
||||
|
||||
**What it does**:
|
||||
|
||||
1. Starts a Twenty instance at the **oldest version in `UPGRADE_COMMAND_SUPPORTED_VERSIONS`** (e.g. `1.17.0`) with a seeded database containing representative workspace data.
|
||||
2. Deploys the **latest release candidate** (`APP_VERSION` = target version).
|
||||
3. Runs the full upgrade orchestrator.
|
||||
4. Verifies: `instanceVersion` matches `APP_VERSION`, all workspace versions match `APP_VERSION`, all commands recorded as `completed` in both history tables, post-upgrade health check passes.
|
||||
|
||||
**Why this matters**: the orchestrator unit tests validate the motor's logic, but they don't catch stale commands -- e.g. a `perWorkspaceCommand` in `bundle_1190` that reads a column dropped by `bundle_1210`'s `instanceCommands`. Only a real end-to-end run against actual version bundles surfaces these. This CI is the safety net for the breaking change backward compatibility principle.
|
||||
|
||||
**Trigger**: runs as a pre-release gate. A failure blocks the release until the breaking change is resolved (either by fixing the command or by dropping the affected versions from `UPGRADE_COMMAND_SUPPORTED_VERSIONS`).
|
||||
|
||||
---
|
||||
|
||||
## Incremental Implementation Roadmap
|
||||
|
||||
The refactor is designed to be shipped incrementally, phase by phase, without requiring a big-bang rewrite.
|
||||
|
||||
### Phase 1: Command Taxonomy Refactor (start here)
|
||||
|
||||
**Goal**: Introduce `GlobalCommand` and `PerWorkspaceCommand` base classes and the `UpgradeVersionBundle` (`instanceCommands`/`perWorkspaceCommands`) format, applied to the current version's upgrade bundle.
|
||||
|
||||
**What changes**:
|
||||
|
||||
- Create `GlobalCommand` and `PerWorkspaceCommand` abstract base classes.
|
||||
- Refactor the current `commands_1200` array into a typed `bundle_1200: UpgradeVersionBundle` with `instanceCommands` (global-only) and `perWorkspaceCommands` (per-workspace-only) arrays.
|
||||
- TypeORM migration becomes a `GlobalCommand` entry in the `instanceCommands` array.
|
||||
- The orchestrator (`UpgradeCommandOrchestrator`) replaces `UpgradeCommandRunner` and walks `instanceCommands` then `perWorkspaceCommands`, dispatching each entry based on its type.
|
||||
- Individual upgrade commands (e.g. `backfillCommandMenuItems`) are migrated to extend `PerWorkspaceCommand`.
|
||||
- The old inheritance chain (`MigrationCommandRunner` -> `WorkspacesMigrationCommandRunner` -> `ActiveOrSuspendedWorkspacesMigrationCommandRunner` -> `UpgradeCommandRunner`) is removed.
|
||||
|
||||
**What stays the same**:
|
||||
|
||||
- `UpgradeCommand` remains the nest-commander entry point, delegating to the orchestrator.
|
||||
- Version comparison still uses major.minor.
|
||||
- Only the current version bundle is refactored; older version entries (e.g. `1.19.0: []`) are left as-is or trivially wrapped.
|
||||
|
||||
**Going forward**: the next version's upgrade commands (e.g. `1.21.0`) should be written directly against the new pattern -- extending `GlobalCommand` or `PerWorkspaceCommand` and registered in an `UpgradeVersionBundle`. This validates the new taxonomy on a real upgrade cycle.
|
||||
|
||||
**Validation**: the upgrade command produces the same outcome as before -- same TypeORM migrations run, same per-workspace commands execute in the same order.
|
||||
|
||||
### Phase 2: Cross-Version Upgrade and Instance Version
|
||||
|
||||
**Goal**: Allow an instance to upgrade across multiple minor versions in a single run, with strict sequential version-by-version processing.
|
||||
|
||||
**What changes**:
|
||||
|
||||
- Introduce `instanceVersion` stored in the `KeyValuePair` table (`CONFIG_VARIABLE`, `userId = null`, `workspaceId = null`, key = `INSTANCE_VERSION`). Seed it from `APP_VERSION` on first deployment.
|
||||
- The orchestrator iterates through `UPGRADE_COMMAND_SUPPORTED_VERSIONS` from `instanceVersion + 1` to `APP_VERSION`, processing one version bundle at a time: `instanceCommands`, stamp `instanceVersion`, `perWorkspaceCommands` for all workspaces at `instanceVersion - 1`, verify all succeeded.
|
||||
- `instanceVersion` advancement guard: before running `instanceCommands` for a bundle, verify all workspace versions equal `instanceVersion`. If any workspace is behind, the orchestrator refuses to advance.
|
||||
- Workspace eligibility: only workspaces at exactly `instanceVersion - 1` are upgraded. No straggler rescue.
|
||||
- Hard-block on failure: if any workspace fails during per-workspace commands, the upgrade stops entirely.
|
||||
- Version comparison remains major.minor at the bundle identity level. Patch releases can patch an existing version line, but corrective work should generally be introduced as **new command identities inside the same major.minor bundle** rather than by mutating previously shipped commands in place.
|
||||
- Guard logic: workspaces below `instanceVersion` block the upgrade (self-hosted default) or are skipped with `--force`.
|
||||
|
||||
### Phase 3: Health Check and Error Reporting
|
||||
|
||||
**Goal**: Structured post-upgrade validation and actionable error output.
|
||||
|
||||
**What changes**:
|
||||
|
||||
- Post-upgrade health check runs after all version bundles complete (instance version, workspace versions, command completion).
|
||||
- Structured upgrade report replaces raw stack traces: per-workspace status, failure details with captured stack traces, summary counts.
|
||||
- Report-a-problem template groundwork (workspace status endpoint).
|
||||
|
||||
### Phase 4: Frontend (Follow-Up)
|
||||
|
||||
- Settings page: workspace version display, version mismatch banner.
|
||||
- Report-a-problem: pre-filled template from workspace status endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Migration Path from Current Architecture
|
||||
|
||||
The current inheritance chain in `command-runners/` is replaced in Phase 1:
|
||||
|
||||
- `MigrationCommandRunner` -- Removed (options like `--dry-run` move to orchestrator)
|
||||
- `WorkspacesMigrationCommandRunner` -- Becomes the `PerWorkspaceCommand` base class
|
||||
- `ActiveOrSuspendedWorkspacesMigrationCommandRunner` -- Folded into `PerWorkspaceCommand` (active/suspended is the default filter)
|
||||
- `UpgradeCommandRunner` -- Becomes `UpgradeCommandOrchestrator`
|
||||
- `UpgradeCommand` -- Stays as the nest-commander entry point, delegates to orchestrator
|
||||
|
||||
Individual commands (e.g. `backfillCommandMenuItems`) keep their current granularity but extend either `GlobalCommand` or `PerWorkspaceCommand` explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Real-World Scenarios
|
||||
|
||||
### 1. Happy Path
|
||||
|
||||
`APP_VERSION = 1.21.0`, `instanceVersion = 1.20.0`, all workspaces at
|
||||
`1.20.0`.
|
||||
|
||||
- The orchestrator runs `bundle_1210.instanceCommands`.
|
||||
- `instanceVersion` is stamped to `1.21.0`.
|
||||
- The orchestrator runs `bundle_1210.perWorkspaceCommands` for every workspace.
|
||||
- All workspaces succeed and are stamped to `1.21.0`.
|
||||
- Final state: `instanceVersion = 1.21.0`, all workspace versions = `1.21.0`.
|
||||
|
||||
### 2. One Command Failed
|
||||
|
||||
`APP_VERSION = 1.21.0`, `instanceVersion = 1.20.0`, workspaces A/B/C at
|
||||
`1.20.0`.
|
||||
|
||||
- `bundle_1210.instanceCommands` succeed and `instanceVersion` is stamped to
|
||||
`1.21.0`.
|
||||
- Workspace A succeeds and is stamped to `1.21.0`.
|
||||
- Workspace B fails on `backfill-new-field` and stays at `1.20.0`.
|
||||
- Workspace C is still attempted and succeeds, ending at `1.21.0`.
|
||||
- The upgrade halts after the full workspace pass.
|
||||
- On re-run, instance commands are skipped by history; only workspace B is
|
||||
retried.
|
||||
- The next bundle cannot start until B also reaches `1.21.0`.
|
||||
|
||||
### 3. `1.20.0` to `1.20.1`: Add a New Command
|
||||
|
||||
`1.20.0` shipped with:
|
||||
|
||||
- `foo`
|
||||
- `bar`
|
||||
|
||||
After release, we realize some data still needs a missing backfill. In
|
||||
`1.20.1`, we keep the existing `1.20` bundle identity and add:
|
||||
|
||||
- `foo`
|
||||
- `bar`
|
||||
- `backfill-missing-baz`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- History already contains successful rows for `foo` and `bar`.
|
||||
- Those commands are skipped.
|
||||
- `backfill-missing-baz` has never run, so it executes once.
|
||||
- History remains readable: the patch added a new corrective operation instead
|
||||
of reinterpreting an old one.
|
||||
|
||||
### 4. `1.20.0` to `1.20.1`: Rename a Previously Released Command
|
||||
|
||||
`1.20.0` shipped with a command named `foo`.
|
||||
|
||||
Later we discover `foo` was semantically wrong: maybe it updated the right data
|
||||
set but missed a required transformation, or maybe it should have run after
|
||||
another command. In `1.20.1`, we **do not** keep the name `foo` and change its
|
||||
meaning silently. Instead we introduce a new command such as:
|
||||
|
||||
- `foo`
|
||||
- `foo-fix-missing-transformation`
|
||||
|
||||
or:
|
||||
|
||||
- `foo`
|
||||
- `rebuild-foo-after-order-fix`
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- The successful `foo` history rows remain valid and untouched.
|
||||
- The new corrective command runs once because it has a new identity.
|
||||
- Operators can clearly see what happened in history: first `foo`, then the
|
||||
later repair command.
|
||||
- This avoids history collisions and makes the patch behavior explicit.
|
||||
@@ -141,7 +141,7 @@
|
||||
"cache": false,
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "node -e \"const fs=require('fs'),p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='{args.releaseVersion}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');\""
|
||||
"command": "npm pkg set version={args.releaseVersion}"
|
||||
}
|
||||
},
|
||||
"storybook:build": {
|
||||
|
||||
+155
-5
@@ -1,20 +1,172 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.0.0",
|
||||
"@floating-ui/react": "^0.24.3",
|
||||
"@linaria/core": "^6.2.0",
|
||||
"@linaria/react": "^6.2.1",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@wyw-in-js/babel-preset": "^1.0.6",
|
||||
"@wyw-in-js/vite": "^0.7.0",
|
||||
"archiver": "^7.0.1",
|
||||
"danger-plugin-todos": "^1.3.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns-tz": "^2.0.0",
|
||||
"deep-equal": "^2.2.2",
|
||||
"file-type": "16.5.4",
|
||||
"framer-motion": "^11.18.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"googleapis": "105",
|
||||
"hex-rgb": "^5.0.0",
|
||||
"immer": "^10.1.1",
|
||||
"jotai": "^2.17.1",
|
||||
"libphonenumber-js": "^1.10.26",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.compact": "^3.0.1",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.groupby": "^4.6.0",
|
||||
"lodash.identity": "^3.0.0",
|
||||
"lodash.isempty": "^4.4.0",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.isobject": "^3.0.2",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.mapvalues": "^4.6.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.omit": "^4.5.0",
|
||||
"lodash.pickby": "^4.6.0",
|
||||
"lodash.snakecase": "^4.1.1",
|
||||
"lodash.upperfirst": "^4.3.1",
|
||||
"microdiff": "^1.3.2",
|
||||
"next-with-linaria": "^1.3.0",
|
||||
"planer": "^1.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-tooltip": "^5.13.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.2.0",
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^5.1.0",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"ts-key-enum": "^2.0.12",
|
||||
"tslib": "^2.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.9.2",
|
||||
"uuid": "^9.0.0",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"xlsx-ugnis": "^0.19.3",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "^7.24.6",
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@graphql-codegen/cli": "^3.3.1",
|
||||
"@graphql-codegen/client-preset": "^4.1.0",
|
||||
"@graphql-codegen/typescript": "^3.0.4",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.4",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@nx/jest": "22.5.4",
|
||||
"@nx/js": "22.5.4",
|
||||
"@nx/react": "22.5.4",
|
||||
"@nx/storybook": "22.5.4",
|
||||
"@nx/vite": "22.5.4",
|
||||
"@nx/web": "22.5.4",
|
||||
"@oxlint/plugins": "^1.51.0",
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.2.13",
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
"@storybook/react-vite": "^10.2.13",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/cli": "^0.7.10",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/helpers": "~0.5.19",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bytes": "^3.1.1",
|
||||
"@types/chrome": "^0.0.267",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/graphql-fields": "^1.3.6",
|
||||
"@types/inquirer": "^9.0.9",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
"@types/lodash.compact": "^3.0.9",
|
||||
"@types/lodash.escaperegexp": "^4.1.9",
|
||||
"@types/lodash.groupby": "^4.6.9",
|
||||
"@types/lodash.identity": "^3.0.9",
|
||||
"@types/lodash.isempty": "^4.4.7",
|
||||
"@types/lodash.isequal": "^4.5.7",
|
||||
"@types/lodash.isobject": "^3.0.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
"@types/lodash.mapvalues": "^4.6.9",
|
||||
"@types/lodash.omit": "^4.5.9",
|
||||
"@types/lodash.pickby": "^4.6.9",
|
||||
"@types/lodash.snakecase": "^4.1.7",
|
||||
"@types/lodash.upperfirst": "^4.3.7",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/passport-microsoft": "^2.1.0",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
|
||||
"@vitejs/plugin-react-swc": "4.2.3",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/coverage-istanbul": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@yarnpkg/types": "^4.0.0",
|
||||
"chromatic": "^6.18.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"danger": "^13.0.4",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"esbuild": "^0.25.10",
|
||||
"http-server": "^14.1.1",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "30.0.0-beta.3",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jsdom": "~22.1.0",
|
||||
"msw": "^2.12.7",
|
||||
"msw-storybook-addon": "^2.0.6",
|
||||
"nx": "22.5.4",
|
||||
"prettier": "^3.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"source-map-support": "^0.5.20",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-addon-mock-date": "2.0.0",
|
||||
"storybook-addon-pseudo-states": "^10.2.13",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.17.0",
|
||||
"verdaccio": "^6.3.1"
|
||||
"verdaccio": "^6.3.1",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -28,14 +180,11 @@
|
||||
"graphql": "16.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.9.2",
|
||||
"nodemailer": "8.0.4",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0",
|
||||
"@lingui/core": "5.1.2",
|
||||
"@types/qs": "6.9.16",
|
||||
"@wyw-in-js/transform@npm:0.6.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
|
||||
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"chokidar": "^3.6.0"
|
||||
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
@@ -53,6 +202,7 @@
|
||||
"packages/twenty-ui",
|
||||
"packages/twenty-utils",
|
||||
"packages/twenty-zapier",
|
||||
"packages/twenty-website",
|
||||
"packages/twenty-website-new",
|
||||
"packages/twenty-docs",
|
||||
"packages/twenty-e2e-testing",
|
||||
|
||||
@@ -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-new/public/images/core/logo.svg" height="128">
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
|
||||
</picture>
|
||||
</a>
|
||||
<h1>Create Twenty App</h1>
|
||||
@@ -12,52 +12,164 @@
|
||||
|
||||
</div>
|
||||
|
||||
The official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). Sets up a ready-to-run project with [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
|
||||
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a ready‑to‑run project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
|
||||
|
||||
- Zero‑config project bootstrap
|
||||
- Preconfigured scripts for auth, dev mode (watch & sync), uninstall, and function management
|
||||
- Strong TypeScript support and typed client generation
|
||||
|
||||
## Documentation
|
||||
|
||||
See Twenty application documentation https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+ (recommended) and Yarn 4
|
||||
- Docker (for the local Twenty dev server)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Scaffold a new app — the CLI will offer to start a local Twenty server
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
cd my-twenty-app
|
||||
|
||||
# The scaffolder can automatically:
|
||||
# 1. Start a local Twenty server (Docker)
|
||||
# 2. Open the browser to log in (tim@apple.dev / tim@apple.dev)
|
||||
# 3. Authenticate your app via OAuth
|
||||
|
||||
# Or do it manually:
|
||||
yarn twenty server start # Start local Twenty server
|
||||
yarn twenty remote add http://localhost:2020 --as local # Authenticate via OAuth
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built — both available via `twenty-client-sdk`)
|
||||
yarn twenty dev
|
||||
|
||||
# Watch your application's function logs
|
||||
yarn twenty logs
|
||||
|
||||
# Execute a function with a JSON payload
|
||||
yarn twenty exec -n my-function -p '{"key": "value"}'
|
||||
|
||||
# Execute the pre-install function
|
||||
yarn twenty exec --preInstall
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty exec --postInstall
|
||||
|
||||
# Build the app for distribution
|
||||
yarn twenty build
|
||||
|
||||
# Publish the app to npm or directly to a Twenty server
|
||||
yarn twenty publish
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty uninstall
|
||||
```
|
||||
|
||||
The scaffolder will:
|
||||
## Scaffolding modes
|
||||
|
||||
1. Create a new project with TypeScript, linting, tests, and a preconfigured `twenty` CLI
|
||||
2. Optionally start a local Twenty server (Docker)
|
||||
3. Open the browser for OAuth authentication
|
||||
Control which example files are included when creating a new app:
|
||||
|
||||
## Options
|
||||
|
||||
| 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:
|
||||
| Flag | Behavior |
|
||||
| ------------------ | ----------------------------------------------------------------------- |
|
||||
| `-e, --exhaustive` | **(default)** Creates all example files |
|
||||
| `-m, --minimal` | Creates only core files (`application-config.ts` and `default-role.ts`) |
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-twenty-app --example hello-world
|
||||
# Default: all examples included
|
||||
npx create-twenty-app@latest my-app
|
||||
|
||||
# Minimal: only core files
|
||||
npx create-twenty-app@latest my-app -m
|
||||
```
|
||||
|
||||
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
|
||||
## What gets scaffolded
|
||||
|
||||
## Documentation
|
||||
**Core files (always created):**
|
||||
|
||||
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started)**:
|
||||
- `application-config.ts` — Application metadata configuration
|
||||
- `roles/default-role.ts` — Default role for logic functions
|
||||
- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation)
|
||||
- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation)
|
||||
- TypeScript configuration, Oxlint, package.json, .gitignore
|
||||
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
|
||||
|
||||
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started) — step-by-step setup, project structure, server management, CI
|
||||
- [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing
|
||||
- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace
|
||||
**Example files (controlled by scaffolding mode):**
|
||||
|
||||
- `objects/example-object.ts` — Example custom object with a text field
|
||||
- `fields/example-field.ts` — Example standalone field extending the example object
|
||||
- `logic-functions/hello-world.ts` — Example logic function with HTTP trigger
|
||||
- `front-components/hello-world.tsx` — Example front component
|
||||
- `views/example-view.ts` — Example saved view for the example object
|
||||
- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link
|
||||
- `skills/example-skill.ts` — Example AI agent skill definition
|
||||
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
|
||||
|
||||
## Local server
|
||||
|
||||
The scaffolder can start a local Twenty dev server for you (all-in-one Docker image with PostgreSQL, Redis, server, and worker on port 2020). These commands only apply to the Docker-based dev server — they do not manage a Twenty instance started from source (e.g. `npx nx start twenty-server` on port 3000). You can also manage it manually:
|
||||
|
||||
```bash
|
||||
yarn twenty server start # Start (pulls image if needed)
|
||||
yarn twenty server status # Check if it's healthy
|
||||
yarn twenty server logs # Stream logs
|
||||
yarn twenty server stop # Stop (data is preserved)
|
||||
yarn twenty server reset # Wipe all data and start fresh
|
||||
```
|
||||
|
||||
The server is pre-seeded with a workspace and user (`tim@apple.dev` / `tim@apple.dev`).
|
||||
|
||||
## Next steps
|
||||
|
||||
- Run `yarn twenty help` to see all available commands.
|
||||
- Use `yarn twenty remote add <url>` to authenticate with your Twenty workspace via OAuth.
|
||||
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
|
||||
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`.
|
||||
|
||||
## Build and publish your application
|
||||
|
||||
Once your app is ready, build and publish it using the CLI:
|
||||
|
||||
```bash
|
||||
# Build the app (output goes to .twenty/output/)
|
||||
yarn twenty build
|
||||
|
||||
# Build and create a tarball (.tgz) for distribution
|
||||
yarn twenty build --tarball
|
||||
|
||||
# Publish to npm (requires npm login)
|
||||
yarn twenty publish
|
||||
|
||||
# Publish with a dist-tag (e.g. beta, next)
|
||||
yarn twenty publish --tag beta
|
||||
|
||||
# Deploy directly to a Twenty server (builds, uploads, and installs in one step)
|
||||
yarn twenty deploy
|
||||
```
|
||||
|
||||
### Publish to the Twenty marketplace
|
||||
|
||||
You can also contribute your application to the curated marketplace:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/twentyhq/twenty.git
|
||||
cd twenty
|
||||
git checkout -b feature/my-awesome-app
|
||||
```
|
||||
|
||||
- Copy your app folder into `twenty/packages/twenty-apps`.
|
||||
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
|
||||
|
||||
Our team reviews contributions for quality, security, and reusability before merging.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
|
||||
- Auth not working: make sure you are logged in to Twenty in the browser, then run `yarn twenty remote add`.
|
||||
- Auth not working: make sure you're logged in to Twenty in the browser first, then run `yarn twenty remote add <url>`.
|
||||
- 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.3.0",
|
||||
"version": "0.8.0-canary.7",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
@@ -40,17 +40,12 @@
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@types/lodash.startcase": "^4",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"twenty-shared": "workspace:*",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.0",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import chalk from 'chalk';
|
||||
import { Command, CommanderError } from 'commander';
|
||||
import { CreateAppCommand } from '@/create-app.command';
|
||||
import { type ScaffoldingMode } from '@/types/scaffolding-options';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const program = new Command(packageJson.name)
|
||||
@@ -12,7 +13,11 @@ const program = new Command(packageJson.name)
|
||||
'Output the current version of create-twenty-app.',
|
||||
)
|
||||
.argument('[directory]')
|
||||
.option('--example <name>', 'Initialize from an example')
|
||||
.option('-e, --exhaustive', 'Create all example entities (default)')
|
||||
.option(
|
||||
'-m, --minimal',
|
||||
'Create only core entities (application-config and default-role)',
|
||||
)
|
||||
.option('-n, --name <name>', 'Application name (skips prompt)')
|
||||
.option(
|
||||
'-d, --display-name <displayName>',
|
||||
@@ -26,20 +31,30 @@ const program = new Command(packageJson.name)
|
||||
'--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;
|
||||
exhaustive?: boolean;
|
||||
minimal?: boolean;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
const modeFlags = [options?.exhaustive, options?.minimal].filter(Boolean);
|
||||
|
||||
if (modeFlags.length > 1) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
'Error: --exhaustive and --minimal are mutually exclusive.',
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
|
||||
console.error(
|
||||
chalk.red(
|
||||
@@ -54,14 +69,15 @@ const program = new Command(packageJson.name)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode: ScaffoldingMode = options?.minimal ? 'minimal' : 'exhaustive';
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
example: options?.example,
|
||||
mode,
|
||||
name: options?.name,
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
skipLocalInstance: options?.skipLocalInstance,
|
||||
yes: options?.yes,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/app-seeds/rich-app
|
||||
|
||||
## UUID requirement
|
||||
|
||||
- All generated UUIDs must be valid UUID v4.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
|
||||
@@ -0,0 +1,11 @@
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/capabilities/apps)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.integration-test.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"ignorePatterns": ["node_modules", "dist"],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/no-explicit-any": "off"
|
||||
},
|
||||
"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,14 +0,0 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
|
||||
|
||||
## UUID requirement
|
||||
|
||||
- All generated UUIDs must be valid UUID v4.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
|
||||
@@ -1,14 +0,0 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
|
||||
|
||||
## UUID requirement
|
||||
|
||||
- All generated UUIDs must be valid UUID v4.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
|
||||
@@ -1,14 +0,0 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
|
||||
|
||||
## UUID requirement
|
||||
|
||||
- All generated UUIDs must be valid UUID v4.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
|
||||
@@ -1,11 +0,0 @@
|
||||
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
Run `yarn twenty help` to list all available commands.
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
|
||||
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
|
||||
- [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
@@ -1,42 +0,0 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TWENTY_DEPLOY_URL: http://localhost:3000
|
||||
|
||||
concurrency:
|
||||
group: cd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-and-install:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Deploy
|
||||
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
|
||||
with:
|
||||
api-url: ${{ env.TWENTY_DEPLOY_URL }}
|
||||
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
|
||||
|
||||
- name: Install
|
||||
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
|
||||
with:
|
||||
api-url: ${{ env.TWENTY_DEPLOY_URL }}
|
||||
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
|
||||
@@ -1,48 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TWENTY_VERSION: latest
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Spawn Twenty test instance
|
||||
id: twenty
|
||||
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
|
||||
with:
|
||||
twenty-version: ${{ env.TWENTY_VERSION }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Run integration tests
|
||||
run: yarn test
|
||||
env:
|
||||
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
|
||||
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
|
||||
@@ -1,38 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.d.ts
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "TO-BE-GENERATED",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"keywords": [],
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-client-sdk": "TO-BE-GENERATED",
|
||||
"twenty-sdk": "TO-BE-GENERATED"
|
||||
},
|
||||
"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,87 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
|
||||
|
||||
const APP_PATH = process.cwd();
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
|
||||
|
||||
function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
const apiUrl = process.env.TWENTY_API_URL;
|
||||
const apiKey = process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
return { apiUrl, apiKey };
|
||||
}
|
||||
|
||||
async function checkServer(apiUrl: string) {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiUrl}/healthz`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Twenty server is not reachable at ${apiUrl}. ` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(apiUrl: string, apiKey: string) {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
remotes: {
|
||||
local: { apiUrl, apiKey, accessToken: apiKey },
|
||||
},
|
||||
defaultRemote: 'local',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
|
||||
}
|
||||
|
||||
export async function setup() {
|
||||
const { apiUrl, apiKey } = validateEnv();
|
||||
|
||||
await checkServer(apiUrl);
|
||||
|
||||
writeConfig(apiUrl, apiKey);
|
||||
|
||||
await appUninstall({ appPath: APP_PATH }).catch(() => {});
|
||||
|
||||
const result = await appDevOnce({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(`[dev] ${message}`),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('App installation', () => {
|
||||
it('should find the installed app in the applications list', async () => {
|
||||
const client = new MetadataApiClient();
|
||||
|
||||
const result = await client.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const app = result.findManyApplications.find(
|
||||
(a: { universalIdentifier: string }) =>
|
||||
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreApiClient', () => {
|
||||
it('should support CRUD on standard objects', async () => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const created = await client.mutation({
|
||||
createNote: {
|
||||
__args: { data: { title: 'Integration test note' } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
expect(created.createNote.id).toBeDefined();
|
||||
|
||||
await client.mutation({
|
||||
destroyNote: {
|
||||
__args: { id: created.createNote.id },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APP_DESCRIPTION,
|
||||
APP_DISPLAY_NAME,
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: APP_DISPLAY_NAME,
|
||||
description: APP_DESCRIPTION,
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
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';
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APP_DISPLAY_NAME,
|
||||
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: `${APP_DISPLAY_NAME} default function role`,
|
||||
description: `${APP_DISPLAY_NAME} default function role`,
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.integration-test.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TWENTY_API_KEY =
|
||||
process.env.TWENTY_API_KEY ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
|
||||
|
||||
// Make env vars available to globalSetup (test.env only applies to workers)
|
||||
process.env.TWENTY_API_URL = TWENTY_API_URL;
|
||||
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
fileParallelism: false,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
globalSetup: ['src/__tests__/global-setup.ts'],
|
||||
env: {
|
||||
TWENTY_API_URL,
|
||||
TWENTY_API_KEY,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -11,25 +10,26 @@ import * as path from 'path';
|
||||
import { basename } from 'path';
|
||||
import {
|
||||
authLoginOAuth,
|
||||
checkDockerRunning,
|
||||
ConfigService,
|
||||
containerExists,
|
||||
detectLocalServer,
|
||||
serverStart,
|
||||
type ServerStartResult,
|
||||
} from 'twenty-sdk/cli';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type ExampleOptions,
|
||||
type ScaffoldingMode,
|
||||
} from '@/types/scaffolding-options';
|
||||
|
||||
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
|
||||
|
||||
type CreateAppOptions = {
|
||||
directory?: string;
|
||||
example?: string;
|
||||
mode?: ScaffoldingMode;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
export class CreateAppCommand {
|
||||
@@ -38,34 +38,23 @@ export class CreateAppCommand {
|
||||
await this.getAppInfos(options);
|
||||
|
||||
try {
|
||||
const exampleOptions = this.resolveExampleOptions(
|
||||
options.mode ?? 'exhaustive',
|
||||
);
|
||||
|
||||
await this.validateDirectory(appDirectory);
|
||||
|
||||
this.logCreationInfo({ appDirectory, appName });
|
||||
|
||||
await fs.ensureDir(appDirectory);
|
||||
|
||||
if (options.example) {
|
||||
const exampleSucceeded = await this.tryDownloadExample(
|
||||
options.example,
|
||||
appDirectory,
|
||||
);
|
||||
|
||||
if (!exampleSucceeded) {
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
}
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
exampleOptions,
|
||||
});
|
||||
|
||||
await install(appDirectory);
|
||||
|
||||
@@ -74,7 +63,7 @@ export class CreateAppCommand {
|
||||
let serverResult: ServerStartResult | undefined;
|
||||
|
||||
if (!options.skipLocalInstance) {
|
||||
const shouldStartServer = await this.shouldStartServer(options.yes);
|
||||
const shouldStartServer = await this.shouldStartServer();
|
||||
|
||||
if (shouldStartServer) {
|
||||
const startResult = await serverStart({
|
||||
@@ -111,14 +100,13 @@ export class CreateAppCommand {
|
||||
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,
|
||||
when: () => !hasName,
|
||||
default: 'my-twenty-app',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
@@ -129,7 +117,7 @@ export class CreateAppCommand {
|
||||
type: 'input',
|
||||
name: 'displayName',
|
||||
message: 'Application display name:',
|
||||
when: () => !hasDisplayName && !hasExample,
|
||||
when: () => !hasDisplayName,
|
||||
default: (answers: { name?: string }) => {
|
||||
return convertToLabel(
|
||||
answers?.name ?? options.name ?? directory ?? '',
|
||||
@@ -140,7 +128,7 @@ export class CreateAppCommand {
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
message: 'Application description (optional):',
|
||||
when: () => !hasDescription && !hasExample,
|
||||
when: () => !hasDescription,
|
||||
default: '',
|
||||
},
|
||||
]);
|
||||
@@ -149,7 +137,6 @@ export class CreateAppCommand {
|
||||
options.name ??
|
||||
name ??
|
||||
directory ??
|
||||
options.example ??
|
||||
'my-twenty-app'
|
||||
).trim();
|
||||
|
||||
@@ -165,6 +152,34 @@ export class CreateAppCommand {
|
||||
return { appName, appDisplayName, appDirectory, appDescription };
|
||||
}
|
||||
|
||||
private resolveExampleOptions(mode: ScaffoldingMode): ExampleOptions {
|
||||
if (mode === 'minimal') {
|
||||
return {
|
||||
includeExampleObject: false,
|
||||
includeExampleField: false,
|
||||
includeExampleLogicFunction: false,
|
||||
includeExampleFrontComponent: false,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeExampleSkill: false,
|
||||
includeExampleAgent: false,
|
||||
includeExampleIntegrationTest: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
includeExampleObject: true,
|
||||
includeExampleField: true,
|
||||
includeExampleLogicFunction: true,
|
||||
includeExampleFrontComponent: true,
|
||||
includeExampleView: true,
|
||||
includeExampleNavigationMenuItem: true,
|
||||
includeExampleSkill: true,
|
||||
includeExampleIntegrationTest: true,
|
||||
includeExampleAgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async validateDirectory(appDirectory: string): Promise<void> {
|
||||
if (!(await fs.pathExists(appDirectory))) {
|
||||
return;
|
||||
@@ -178,41 +193,6 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
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 logCreationInfo({
|
||||
appDirectory,
|
||||
appName,
|
||||
@@ -226,35 +206,13 @@ export class CreateAppCommand {
|
||||
);
|
||||
}
|
||||
|
||||
private async shouldStartServer(autoConfirm?: boolean): Promise<boolean> {
|
||||
private async shouldStartServer(): Promise<boolean> {
|
||||
const existingServerUrl = await detectLocalServer();
|
||||
|
||||
if (existingServerUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -281,7 +239,7 @@ export class CreateAppCommand {
|
||||
if (!shouldAuthenticate) {
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'Authentication skipped. Run `yarn twenty remote add --local` manually.',
|
||||
'Authentication skipped. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -294,14 +252,10 @@ export class CreateAppCommand {
|
||||
remote: 'local',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote('local');
|
||||
} else {
|
||||
if (!result.success) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
'Authentication failed. Run `yarn twenty remote add` manually.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type ScaffoldingMode = 'exhaustive' | 'minimal';
|
||||
|
||||
export type ExampleOptions = {
|
||||
includeExampleObject: boolean;
|
||||
includeExampleField: boolean;
|
||||
includeExampleLogicFunction: boolean;
|
||||
includeExampleFrontComponent: boolean;
|
||||
includeExampleView: boolean;
|
||||
includeExampleNavigationMenuItem: boolean;
|
||||
includeExampleSkill: boolean;
|
||||
includeExampleAgent: boolean;
|
||||
includeExampleIntegrationTest: boolean;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
import { scaffoldIntegrationTest } from '@/utils/test-template';
|
||||
import * as fs from 'fs-extra';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('scaffoldIntegrationTest', () => {
|
||||
let testAppDirectory: string;
|
||||
let sourceFolderPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testAppDirectory = join(
|
||||
tmpdir(),
|
||||
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
sourceFolderPath = join(testAppDirectory, 'src');
|
||||
await fs.ensureDir(sourceFolderPath);
|
||||
|
||||
await fs.writeJson(join(testAppDirectory, 'tsconfig.json'), {
|
||||
compilerOptions: {
|
||||
paths: { 'src/*': ['./src/*'] },
|
||||
},
|
||||
exclude: ['node_modules', 'dist', '**/*.integration-test.ts'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
|
||||
await fs.remove(testAppDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
describe('integration test file', () => {
|
||||
it('should create app-install.integration-test.ts with correct structure', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const testPath = join(
|
||||
sourceFolderPath,
|
||||
'__tests__',
|
||||
'app-install.integration-test.ts',
|
||||
);
|
||||
|
||||
expect(await fs.pathExists(testPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(testPath, 'utf8');
|
||||
|
||||
expect(content).toContain(
|
||||
"import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'",
|
||||
);
|
||||
expect(content).toContain(
|
||||
"import { MetadataApiClient } from 'twenty-client-sdk/metadata'",
|
||||
);
|
||||
expect(content).toContain(
|
||||
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
|
||||
);
|
||||
expect(content).toContain('appBuild');
|
||||
expect(content).toContain('appDeploy');
|
||||
expect(content).toContain('appInstall');
|
||||
expect(content).toContain('appUninstall');
|
||||
expect(content).toContain('new MetadataApiClient()');
|
||||
expect(content).toContain('findManyApplications');
|
||||
expect(content).toContain('APPLICATION_UNIVERSAL_IDENTIFIER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-test file', () => {
|
||||
it('should create setup-test.ts with SDK config bootstrap', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const setupTestPath = join(
|
||||
sourceFolderPath,
|
||||
'__tests__',
|
||||
'setup-test.ts',
|
||||
);
|
||||
|
||||
expect(await fs.pathExists(setupTestPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(setupTestPath, 'utf8');
|
||||
|
||||
expect(content).toContain('.twenty-sdk-test');
|
||||
expect(content).toContain('config.json');
|
||||
expect(content).toContain('process.env.TWENTY_API_URL');
|
||||
expect(content).toContain('process.env.TWENTY_API_KEY');
|
||||
expect(content).toContain('assertServerIsReachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vitest config', () => {
|
||||
it('should create vitest.config.ts with env vars and setup file', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const vitestConfigPath = join(testAppDirectory, 'vitest.config.ts');
|
||||
|
||||
expect(await fs.pathExists(vitestConfigPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(vitestConfigPath, 'utf8');
|
||||
|
||||
expect(content).toContain('TWENTY_API_KEY');
|
||||
expect(content).not.toContain('TWENTY_TEST_API_KEY');
|
||||
expect(content).toContain('setup-test.ts');
|
||||
expect(content).toContain('tsconfig.spec.json');
|
||||
expect(content).toContain('integration-test.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('github workflow', () => {
|
||||
it('should create .github/workflows/ci.yml with correct structure', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const workflowPath = join(
|
||||
testAppDirectory,
|
||||
'.github',
|
||||
'workflows',
|
||||
'ci.yml',
|
||||
);
|
||||
|
||||
expect(await fs.pathExists(workflowPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(workflowPath, 'utf8');
|
||||
|
||||
expect(content).toContain('name: CI');
|
||||
expect(content).toContain('TWENTY_VERSION: latest');
|
||||
expect(content).toContain('twenty-version: ${{ env.TWENTY_VERSION }}');
|
||||
expect(content).toContain('actions/checkout@v4');
|
||||
expect(content).toContain('spawn-twenty-docker-image@main');
|
||||
expect(content).toContain('actions/setup-node@v4');
|
||||
expect(content).toContain('yarn install --immutable');
|
||||
expect(content).toContain('yarn test');
|
||||
expect(content).toContain('TWENTY_API_URL');
|
||||
expect(content).toContain('TWENTY_API_KEY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tsconfig.spec.json', () => {
|
||||
it('should create tsconfig.spec.json extending the base tsconfig', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const tsconfigSpecPath = join(testAppDirectory, 'tsconfig.spec.json');
|
||||
|
||||
expect(await fs.pathExists(tsconfigSpecPath)).toBe(true);
|
||||
|
||||
const tsconfigSpec = await fs.readJson(tsconfigSpecPath);
|
||||
|
||||
expect(tsconfigSpec.extends).toBe('./tsconfig.json');
|
||||
expect(tsconfigSpec.compilerOptions.composite).toBe(true);
|
||||
expect(tsconfigSpec.include).toContain('src/**/*.ts');
|
||||
expect(tsconfigSpec.exclude).not.toContain('**/*.integration-test.ts');
|
||||
});
|
||||
|
||||
it('should add a reference to tsconfig.spec.json in tsconfig.json', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const tsconfig = await fs.readJson(
|
||||
join(testAppDirectory, 'tsconfig.json'),
|
||||
);
|
||||
|
||||
expect(tsconfig.references).toEqual([{ path: './tsconfig.spec.json' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { ASSETS_DIR } from 'twenty-shared/application';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { type ExampleOptions } from '@/types/scaffolding-options';
|
||||
import { scaffoldIntegrationTest } from '@/utils/test-template';
|
||||
import createTwentyAppPackageJson from 'package.json';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const SRC_FOLDER = 'src';
|
||||
|
||||
@@ -12,96 +14,795 @@ export const copyBaseApplicationProject = async ({
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
exampleOptions,
|
||||
}: {
|
||||
appName: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
exampleOptions: ExampleOptions;
|
||||
}) => {
|
||||
console.log(chalk.gray('Generating application project...'));
|
||||
await fs.copy(join(__dirname, './constants/template'), appDirectory);
|
||||
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
|
||||
|
||||
await renameDotfiles({ appDirectory });
|
||||
|
||||
await addEmptyPublicDirectory({ appDirectory });
|
||||
|
||||
await generateUniversalIdentifiers({
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
await createPackageJson({
|
||||
appName,
|
||||
appDirectory,
|
||||
includeExampleIntegrationTest: exampleOptions.includeExampleIntegrationTest,
|
||||
});
|
||||
|
||||
await updatePackageJson({ appName, appDirectory });
|
||||
};
|
||||
await createYarnLock(appDirectory);
|
||||
|
||||
// npm strips dotfiles/dotdirs (.gitignore, .github/) from published packages,
|
||||
// so we store them without the leading dot and rename after copying.
|
||||
const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
const renames = [
|
||||
{ from: 'gitignore', to: '.gitignore' },
|
||||
{ from: 'github', to: '.github' },
|
||||
];
|
||||
await createGitignore(appDirectory);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const sourcePath = join(appDirectory, from);
|
||||
await createNvmrc(appDirectory);
|
||||
|
||||
if (await fs.pathExists(sourcePath)) {
|
||||
await fs.rename(sourcePath, join(appDirectory, to));
|
||||
}
|
||||
await createPublicAssetDirectory(appDirectory);
|
||||
|
||||
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
|
||||
|
||||
await fs.ensureDir(sourceFolderPath);
|
||||
|
||||
await createDefaultRoleConfig({
|
||||
displayName: appDisplayName,
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'roles',
|
||||
fileName: 'default-role.ts',
|
||||
});
|
||||
|
||||
if (exampleOptions.includeExampleObject) {
|
||||
await createExampleObject({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'objects',
|
||||
fileName: 'example-object.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleField) {
|
||||
await createExampleField({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'fields',
|
||||
fileName: 'example-field.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleLogicFunction) {
|
||||
await createDefaultFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'hello-world.ts',
|
||||
});
|
||||
|
||||
await createCreateCompanyFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'create-hello-world-company.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleFrontComponent) {
|
||||
await createDefaultFrontComponent({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'front-components',
|
||||
fileName: 'hello-world.tsx',
|
||||
});
|
||||
|
||||
await createExamplePageLayout({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'page-layouts',
|
||||
fileName: 'example-record-page-layout.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleView) {
|
||||
await createExampleView({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'views',
|
||||
fileName: 'example-view.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleNavigationMenuItem) {
|
||||
await createExampleNavigationMenuItem({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'navigation-menu-items',
|
||||
fileName: 'example-navigation-menu-item.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleSkill) {
|
||||
await createExampleSkill({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'skills',
|
||||
fileName: 'example-skill.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleAgent) {
|
||||
await createExampleAgent({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'agents',
|
||||
fileName: 'example-agent.ts',
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleIntegrationTest) {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
}
|
||||
|
||||
await createDefaultPreInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'pre-install.ts',
|
||||
});
|
||||
|
||||
await createDefaultPostInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'post-install.ts',
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
displayName: appDisplayName,
|
||||
description: appDescription,
|
||||
appDirectory: sourceFolderPath,
|
||||
fileName: 'application-config.ts',
|
||||
});
|
||||
};
|
||||
|
||||
const addEmptyPublicDirectory = async ({
|
||||
const createPublicAssetDirectory = async (appDirectory: string) => {
|
||||
await fs.ensureDir(join(appDirectory, ASSETS_DIR));
|
||||
};
|
||||
|
||||
const createGitignore = async (appDirectory: string) => {
|
||||
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.d.ts
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
|
||||
};
|
||||
|
||||
const createNvmrc = async (appDirectory: string) => {
|
||||
await fs.writeFile(join(appDirectory, '.nvmrc'), '24.5.0\n');
|
||||
};
|
||||
|
||||
const createDefaultRoleConfig = async ({
|
||||
displayName,
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
displayName: string;
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineRole } from 'twenty-sdk';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: '${displayName} default function role',
|
||||
description: '${displayName} default function role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultFrontComponent = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
await fs.ensureDir(join(appDirectory, 'public'));
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export const HelloWorld = () => {
|
||||
const client = new CoreApiClient();
|
||||
const [data, setData] = useState<
|
||||
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const response = await client.query({
|
||||
company: {
|
||||
name: true,
|
||||
id: true,
|
||||
__args: {
|
||||
filter: {
|
||||
position: {
|
||||
eq: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setData(response.company);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||||
<h1>Hello, World!</h1>
|
||||
<p>This is your first front component.</p>
|
||||
{data ? (
|
||||
<div>
|
||||
<p>Company name: {data.name}</p>
|
||||
<p>Company id: {data.id}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Company not found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const generateUniversalIdentifiers = async ({
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'hello-world-front-component',
|
||||
description: 'A sample front component',
|
||||
component: HelloWorld,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createExamplePageLayout = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifiersPath = join(
|
||||
appDirectory,
|
||||
SRC_FOLDER,
|
||||
'constants',
|
||||
'universal-identifiers.ts',
|
||||
);
|
||||
const pageLayoutUniversalIdentifier = v4();
|
||||
const tabUniversalIdentifier = v4();
|
||||
const widgetUniversalIdentifier = v4();
|
||||
|
||||
const universalIdentifiersFileContent = await fs.readFile(
|
||||
universalIdentifiersPath,
|
||||
'utf-8',
|
||||
);
|
||||
const content = `import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
|
||||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
|
||||
|
||||
await fs.writeFile(
|
||||
universalIdentifiersPath,
|
||||
universalIdentifiersFileContent
|
||||
.replace('DISPLAY-NAME-TO-BE-GENERATED', appDisplayName)
|
||||
.replace('DESCRIPTION-TO-BE-GENERATED', appDescription)
|
||||
.replace(/UUID-TO-BE-GENERATED/g, () => v4()),
|
||||
);
|
||||
export default definePageLayout({
|
||||
universalIdentifier: '${pageLayoutUniversalIdentifier}',
|
||||
name: 'Example Record Page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [
|
||||
{
|
||||
universalIdentifier: '${tabUniversalIdentifier}',
|
||||
title: 'Hello World',
|
||||
position: 50,
|
||||
icon: 'IconWorld',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: '${widgetUniversalIdentifier}',
|
||||
title: 'Hello World',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const updatePackageJson = async ({
|
||||
const createDefaultFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
const handler = async (): Promise<{ message: string }> => {
|
||||
return { message: 'Hello, World!' };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'hello-world-logic-function',
|
||||
description: 'A simple logic function',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/hello-world-logic-function',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createCreateCompanyFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
const handler = async (): Promise<{ message: string }> => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const { createCompany } = await client.mutation({
|
||||
createCompany: {
|
||||
__args: {
|
||||
data: {
|
||||
name: 'Hello World',
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createCompany?.id || !createCompany?.name) {
|
||||
throw new Error('Failed to create company: missing id or name in response');
|
||||
}
|
||||
|
||||
return {
|
||||
message: \`Created company "\${createCompany.name}" with id \${createCompany.id}\`,
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'create-hello-world-company',
|
||||
description: 'Creates a company called Hello World',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/create-hello-world-company',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultPreInstallFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Pre install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default definePreInstallLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'pre-install',
|
||||
description: 'Runs before installation to prepare the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultPostInstallFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createExampleObject = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const objectUniversalIdentifier = v4();
|
||||
const nameFieldUniversalIdentifier = v4();
|
||||
|
||||
const content = `import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'${objectUniversalIdentifier}';
|
||||
|
||||
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'${nameFieldUniversalIdentifier}';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'exampleItem',
|
||||
namePlural: 'exampleItems',
|
||||
labelSingular: 'Example item',
|
||||
labelPlural: 'Example items',
|
||||
description: 'A sample custom object',
|
||||
icon: 'IconBox',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
description: 'Name of the example item',
|
||||
icon: 'IconAbc',
|
||||
},
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createExampleField = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineField, FieldType } from 'twenty-sdk';
|
||||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
|
||||
|
||||
export default defineField({
|
||||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
type: FieldType.NUMBER,
|
||||
name: 'priority',
|
||||
label: 'Priority',
|
||||
description: 'Priority level for the example item (1-10)',
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createExampleView = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
const viewFieldUniversalIdentifier = v4();
|
||||
|
||||
const content = `import { defineView, ViewKey } from 'twenty-sdk';
|
||||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
|
||||
|
||||
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All example items',
|
||||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconList',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '${viewFieldUniversalIdentifier}',
|
||||
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 200,
|
||||
},
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createExampleNavigationMenuItem = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
|
||||
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'example-navigation-menu-item',
|
||||
icon: 'IconList',
|
||||
color: 'blue',
|
||||
position: 0,
|
||||
type: 'VIEW',
|
||||
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createExampleSkill = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineSkill } from 'twenty-sdk';
|
||||
|
||||
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'example-skill',
|
||||
label: 'Example Skill',
|
||||
description: 'A sample skill for your application',
|
||||
icon: 'IconBrain',
|
||||
content: 'Add your skill instructions here. Skills provide context and capabilities to AI agents.',
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createExampleAgent = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineAgent } from 'twenty-sdk';
|
||||
|
||||
export const EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineAgent({
|
||||
universalIdentifier: EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'example-agent',
|
||||
label: 'Example Agent',
|
||||
description: 'A sample AI agent for your application',
|
||||
icon: 'IconRobot',
|
||||
prompt: 'You are a helpful assistant. Help users with their questions and tasks.',
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createApplicationConfig = async ({
|
||||
displayName,
|
||||
description,
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createYarnLock = async (appDirectory: string) => {
|
||||
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
|
||||
};
|
||||
|
||||
const createPackageJson = async ({
|
||||
appName,
|
||||
appDirectory,
|
||||
includeExampleIntegrationTest,
|
||||
}: {
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
includeExampleIntegrationTest: boolean;
|
||||
}) => {
|
||||
const packageJson = await fs.readJson(join(appDirectory, 'package.json'));
|
||||
const scripts: Record<string, string> = {
|
||||
twenty: 'twenty',
|
||||
lint: 'oxlint -c .oxlintrc.json .',
|
||||
'lint:fix': 'oxlint --fix -c .oxlintrc.json .',
|
||||
};
|
||||
|
||||
packageJson.name = appName;
|
||||
packageJson.dependencies['twenty-sdk'] = createTwentyAppPackageJson.version;
|
||||
packageJson.dependencies['twenty-client-sdk'] =
|
||||
createTwentyAppPackageJson.version;
|
||||
const devDependencies: Record<string, string> = {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^19.0.0',
|
||||
react: '^19.0.0',
|
||||
'react-dom': '^19.0.0',
|
||||
oxlint: '^0.16.0',
|
||||
'twenty-sdk': createTwentyAppPackageJson.version,
|
||||
'twenty-client-sdk': createTwentyAppPackageJson.version,
|
||||
};
|
||||
|
||||
if (includeExampleIntegrationTest) {
|
||||
scripts.test = 'vitest run';
|
||||
scripts['test:watch'] = 'vitest';
|
||||
devDependencies.vitest = '^3.1.1';
|
||||
devDependencies['vite-tsconfig-paths'] = '^4.2.1';
|
||||
}
|
||||
|
||||
const packageJson = {
|
||||
name: appName,
|
||||
version: '0.1.0',
|
||||
license: 'MIT',
|
||||
engines: {
|
||||
node: '^24.5.0',
|
||||
npm: 'please-use-yarn',
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
keywords: ['twenty-app'],
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts,
|
||||
devDependencies,
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
join(appDirectory, 'package.json'),
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
const SEED_API_KEY =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik';
|
||||
|
||||
export const scaffoldIntegrationTest = async ({
|
||||
appDirectory,
|
||||
sourceFolderPath,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
sourceFolderPath: string;
|
||||
}) => {
|
||||
await createIntegrationTest({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: '__tests__',
|
||||
fileName: 'app-install.integration-test.ts',
|
||||
});
|
||||
|
||||
await createSetupTest({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: '__tests__',
|
||||
fileName: 'setup-test.ts',
|
||||
});
|
||||
|
||||
await createVitestConfig(appDirectory);
|
||||
await createTsconfigSpec(appDirectory);
|
||||
await createGithubWorkflow(appDirectory);
|
||||
};
|
||||
|
||||
const createVitestConfig = async (appDirectory: string) => {
|
||||
const content = `import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
setupFiles: ['src/__tests__/setup-test.ts'],
|
||||
env: {
|
||||
TWENTY_API_KEY:
|
||||
'${SEED_API_KEY}',
|
||||
},
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'vitest.config.ts'), content);
|
||||
};
|
||||
|
||||
const createTsconfigSpec = async (appDirectory: string) => {
|
||||
const tsconfigSpec = {
|
||||
extends: './tsconfig.json',
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
types: ['vitest/globals'],
|
||||
},
|
||||
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
join(appDirectory, 'tsconfig.spec.json'),
|
||||
JSON.stringify(tsconfigSpec, null, 2),
|
||||
);
|
||||
|
||||
const tsconfigPath = join(appDirectory, 'tsconfig.json');
|
||||
const tsconfig = await fs.readJson(tsconfigPath);
|
||||
|
||||
tsconfig.references = [{ path: './tsconfig.spec.json' }];
|
||||
|
||||
await fs.writeFile(tsconfigPath, JSON.stringify(tsconfig, null, 2));
|
||||
};
|
||||
|
||||
const createSetupTest = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const content = `import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(\`Server at \${TWENTY_API_URL} returned \${response.status}\`);
|
||||
}
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await assertServerIsReachable();
|
||||
|
||||
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
|
||||
|
||||
const configFile = {
|
||||
remotes: {
|
||||
local: {
|
||||
apiUrl: process.env.TWENTY_API_URL,
|
||||
apiKey: process.env.TWENTY_API_KEY,
|
||||
},
|
||||
},
|
||||
defaultRemote: 'local',
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(TEST_CONFIG_DIR, 'config.json'),
|
||||
JSON.stringify(configFile, null, 2),
|
||||
);
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createIntegrationTest = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
|
||||
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const APP_PATH = process.cwd();
|
||||
|
||||
describe('App installation', () => {
|
||||
beforeAll(async () => {
|
||||
const buildResult = await appBuild({
|
||||
appPath: APP_PATH,
|
||||
tarball: true,
|
||||
onProgress: (message: string) => console.log(\`[build] \${message}\`),
|
||||
});
|
||||
|
||||
if (!buildResult.success) {
|
||||
throw new Error(
|
||||
\`Build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
|
||||
const deployResult = await appDeploy({
|
||||
tarballPath: buildResult.data.tarballPath!,
|
||||
onProgress: (message: string) => console.log(\`[deploy] \${message}\`),
|
||||
});
|
||||
|
||||
if (!deployResult.success) {
|
||||
throw new Error(
|
||||
\`Deploy failed: \${deployResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
|
||||
const installResult = await appInstall({ appPath: APP_PATH });
|
||||
|
||||
if (!installResult.success) {
|
||||
throw new Error(
|
||||
\`Install failed: \${installResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
\`App uninstall failed: \${uninstallResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should find the installed app in the applications list', async () => {
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const result = await metadataClient.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const installedApp = result.findManyApplications.find(
|
||||
(application: { universalIdentifier: string }) =>
|
||||
application.universalIdentifier ===
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
expect(installedApp).toBeDefined();
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const DEFAULT_TWENTY_VERSION = 'latest';
|
||||
|
||||
const createGithubWorkflow = async (appDirectory: string) => {
|
||||
const content = `name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request: {}
|
||||
|
||||
env:
|
||||
TWENTY_VERSION: ${DEFAULT_TWENTY_VERSION}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Spawn Twenty instance
|
||||
id: twenty
|
||||
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
|
||||
with:
|
||||
twenty-version: \${{ env.TWENTY_VERSION }}
|
||||
github-token: \${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Run integration tests
|
||||
run: yarn test
|
||||
env:
|
||||
TWENTY_API_URL: \${{ steps.twenty.outputs.server-url }}
|
||||
TWENTY_API_KEY: \${{ steps.twenty.outputs.access-token }}
|
||||
`;
|
||||
|
||||
const workflowDir = join(appDirectory, '.github', 'workflows');
|
||||
|
||||
await fs.ensureDir(workflowDir);
|
||||
await fs.writeFile(join(workflowDir, 'ci.yml'), content);
|
||||
};
|
||||
@@ -24,9 +24,5 @@
|
||||
"vite.config.ts",
|
||||
"jest.config.mjs"
|
||||
],
|
||||
"exclude": [
|
||||
"src/constants/template/vitest.config.ts",
|
||||
"src/constants/template/src/**",
|
||||
"src/constants/template/tsconfig.spec.json"
|
||||
]
|
||||
"exclude": ["src/constants/base-application/vitest.config.ts"]
|
||||
}
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/__tests__/**",
|
||||
"src/constants/template/src/**",
|
||||
"src/constants/template/vitest.config.ts",
|
||||
"src/constants/template/tsconfig.spec.json"
|
||||
"**/__tests__/**"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ export default defineConfig(() => {
|
||||
dts({ entryRoot: './src', tsconfigPath: tsConfigPath }),
|
||||
copyAssetPlugin([
|
||||
{
|
||||
src: 'src/constants/template',
|
||||
dest: 'dist/constants/template',
|
||||
src: 'src/constants/base-application',
|
||||
dest: 'dist/constants/base-application',
|
||||
},
|
||||
]),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
TWENTY_API_KEY=<SET_YOUR_TWENTY_API>
|
||||
DAYS_AGO=<SET_YOUR_DAYS_AGO>
|
||||
DISCORD_WEBHOOK_URL=<SET_YOUR_DISCORD_WEBHOOK_URL>
|
||||
FB_GRAPH_TOKEN=<SET_YOUR_FB_GRAPH_TOKEN>
|
||||
WHATSAPP_RECIPIENT_PHONE_NUMBER=<SET_YOUR_WHATSAPP_RECIPIENT_PHONE_NUMBER>
|
||||
SLACK_HOOK_URL=<SET_YOUR_SLACK_HOOK_URL>
|
||||
@@ -0,0 +1,2 @@
|
||||
.yarn/install-state.gz
|
||||
.env
|
||||
@@ -0,0 +1,108 @@
|
||||
# Twenty CRM Activity Reporter ��
|
||||
|
||||
A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!
|
||||
|
||||
## Features
|
||||
|
||||
- 🧑💻 **People & Company Tracking**: Summarizes newly created people and companies
|
||||
- 🎯 **Opportunity Monitoring**: Reports on new opportunities created, broken down by stage
|
||||
- ✅ **Task Analytics**:
|
||||
- Tracks task creation
|
||||
- Calculates on-time completion rates
|
||||
- Identifies team members with the most overdue tasks (the "slackers")
|
||||
- 🔔 **Multi-Platform Notifications**: Send reports to Slack, Discord, and/or WhatsApp
|
||||
- ⏰ **Configurable Time Range**: Look back any number of days
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (v14 or higher recommended)
|
||||
- TypeScript
|
||||
- A [Twenty CRM](https://twenty.com) account with API access
|
||||
- Optional: Slack webhook, Discord webhook, and/or WhatsApp Business API access
|
||||
|
||||
## Installing dependencies
|
||||
```bash
|
||||
# Install dependencies
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `TWENTY_API_KEY` | ✅ Yes | Your Twenty CRM API key |
|
||||
| `DAYS_AGO` | ✅ Yes | Number of days to look back for the report |
|
||||
| `SLACK_HOOK_URL` | ❌ No | Slack incoming webhook URL |
|
||||
| `DISCORD_WEBHOOK_URL` | ❌ No | Discord webhook URL |
|
||||
| `FB_GRAPH_TOKEN` | ❌ No | Facebook Graph API token for WhatsApp |
|
||||
| `WHATSAPP_RECIPIENT_PHONE_NUMBER` | ❌ No | WhatsApp recipient phone number (with country code) |
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
.
|
||||
├── index.ts # Main entry point
|
||||
├── people-creation-summariser.ts # Summarizes people/company creation
|
||||
├── opportunity-creation-summariser.ts # Summarizes opportunity creation
|
||||
├── task-creation-summariser.ts # Summarizes task creation & completion
|
||||
├── senders.ts # Handles sending to Slack/Discord/WhatsApp
|
||||
├── utils.ts # API request utility
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Data Collection**: The bot queries the Twenty CRM API for activities within the specified time range
|
||||
2. **Analysis**:
|
||||
- Counts new people and companies
|
||||
- Categorizes opportunities by stage
|
||||
- Calculates task completion rates and identifies overdue tasks
|
||||
3. **Reporting**: Formats the data into friendly messages
|
||||
4. **Distribution**: Sends reports to configured platforms (Slack, Discord, WhatsApp)
|
||||
|
||||
## Report Format
|
||||
|
||||
Each report includes:
|
||||
```
|
||||
Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last X days:
|
||||
|
||||
🧑💻 People & Companies
|
||||
- X People and Y Companies were added
|
||||
|
||||
🎯 Opportunities
|
||||
- X Opportunities were added: Y in NEW, Z in PROPOSAL
|
||||
|
||||
📋 Tasks
|
||||
- X Tasks were created
|
||||
- Y% Tasks were completed on time
|
||||
- [Name] slacked the most with Z Tasks overdue
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
This bot uses the [Twenty CRM REST API](https://api.twenty.com/rest/). The following endpoints are used:
|
||||
|
||||
- `GET /people` - Fetch people data
|
||||
- `GET /opportunities` - Fetch opportunity data
|
||||
- `GET /tasks` - Fetch task data
|
||||
- `GET /workspaceMembers/{id}` - Fetch workspace member details
|
||||
|
||||
## Notes
|
||||
|
||||
- The "slacker" detection is lighthearted and identifies team members with the most overdue tasks
|
||||
- At least one messaging platform must be configured for the bot to send reports
|
||||
- The bot uses ISO date format (YYYY-MM-DD) for date filtering
|
||||
- Task completion percentage only considers incomplete tasks (excludes already completed tasks from the calculation)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Issue**: No messages being sent
|
||||
- **Solution**: Ensure at least one messaging platform is configured with valid credentials
|
||||
|
||||
**Issue**: API authentication errors
|
||||
- **Solution**: Verify your `TWENTY_API_KEY` is correct and has necessary permissions
|
||||
|
||||
**Issue**: WhatsApp messages not sending
|
||||
- **Solution**: Ensure both `FB_GRAPH_TOKEN` and `WHATSAPP_RECIPIENT_PHONE_NUMBER` are set correctly
|
||||
|
||||
## Contributing
|
||||
Built with ❤️ and 🥖 by Azmat, Ali and Mike from 9dots
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: 'b53627f5-ca60-478c-bc43-c7ab4904e34a',
|
||||
displayName: 'Activity Summary',
|
||||
description:
|
||||
'A TypeScript-based reporting bot that summarizes activity from your Twenty CRM workspace and sends daily/periodic reports to Slack, Discord, and WhatsApp. Meet Kylian Mbaguette, your friendly CRM activity reporter!',
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '304b7d5d-e2bb-4444-9b04-6b3ae8b73730',
|
||||
description: 'Twenty API Key',
|
||||
isSecret: true,
|
||||
},
|
||||
DAYS_AGO: {
|
||||
universalIdentifier: '040a3097-9cee-4f74-b957-c2f9bf636c3f',
|
||||
description:
|
||||
'How far back into the past we want to summarise – defaults to the past 7 days',
|
||||
value: '7',
|
||||
isSecret: false,
|
||||
},
|
||||
SLACK_HOOK_URL: {
|
||||
universalIdentifier: 'fd16e370-934c-4267-83b4-7d88259bf7e1',
|
||||
description: 'Slack hook URL for sending message to channel',
|
||||
isSecret: true,
|
||||
},
|
||||
DISCORD_WEBHOOK_URL: {
|
||||
universalIdentifier: 'f3741075-d525-4988-ba42-55d519c6fd76',
|
||||
description:
|
||||
'Discord webhook URL for sending message to channel of a server',
|
||||
isSecret: true,
|
||||
},
|
||||
FB_GRAPH_TOKEN: {
|
||||
universalIdentifier: 'fb907f49-74ac-4aa5-ba45-cfc9250ecc44',
|
||||
description: 'For Facebook auth',
|
||||
isSecret: true,
|
||||
},
|
||||
WHATSAPP_RECIPIENT_PHONE_NUMBER: {
|
||||
universalIdentifier: 'c856ee5d-44bf-42f4-9a39-2553a94af518',
|
||||
description: 'Phone number for receiving WhatsApp message',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "activity-summary",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"twenty-sdk": "0.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { summariseOpportunityCreation } from './opportunity-creation-summariser';
|
||||
import { summarisePeopleCreation } from './people-creation-summariser';
|
||||
import { sendToDiscord, sendToSlack, sendToWhatsApp } from './senders';
|
||||
import { summariseTaskCreation } from './task-creation-summariser';
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
|
||||
export const main = async (): Promise<object> => {
|
||||
let date: string | Date = new Date();
|
||||
date.setDate(new Date().getDate() - Number(process.env.DAYS_AGO));
|
||||
date = date.toISOString().substring(0, 10);
|
||||
const peopleCreationSummary = await summarisePeopleCreation(date);
|
||||
const opportunityCreationSummary = await summariseOpportunityCreation(date);
|
||||
const taskCreationSummary = await summariseTaskCreation(date);
|
||||
|
||||
let body = {
|
||||
daysAgo: Number(process.env.DAYS_AGO),
|
||||
peopleCreationSummary,
|
||||
opportunityCreationSummary,
|
||||
taskCreationSummary,
|
||||
discord: {},
|
||||
whatsapp: {},
|
||||
slack: {},
|
||||
};
|
||||
|
||||
if (process.env.SLACK_HOOK_URL) {
|
||||
const slackBody = await sendToSlack({
|
||||
peopleCreationSummary,
|
||||
opportunityCreationSummary,
|
||||
taskCreationSummary,
|
||||
});
|
||||
|
||||
body = {
|
||||
...body,
|
||||
slack: slackBody,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.DISCORD_WEBHOOK_URL) {
|
||||
const discordBody = await sendToDiscord({
|
||||
peopleCreationSummary,
|
||||
opportunityCreationSummary,
|
||||
taskCreationSummary,
|
||||
});
|
||||
|
||||
body = {
|
||||
...body,
|
||||
discord: discordBody,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.FB_GRAPH_TOKEN &&
|
||||
process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER
|
||||
) {
|
||||
const whatsappBody = await sendToWhatsApp({
|
||||
peopleCreationSummary,
|
||||
opportunityCreationSummary,
|
||||
taskCreationSummary,
|
||||
});
|
||||
|
||||
body = {
|
||||
...body,
|
||||
whatsapp: whatsappBody,
|
||||
};
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
export const config: ServerlessFunctionConfig = {
|
||||
universalIdentifier: 'c5b0e3f7-cbbd-4bd6-b01c-150d52cf2ce9',
|
||||
name: 'summarise-and-send',
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: '36e1c4c7-8664-4d6d-a88f-ac56f1bd0651',
|
||||
type: 'cron',
|
||||
pattern: '0 9 * * *',
|
||||
},
|
||||
],
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { request } from "./utils"
|
||||
|
||||
type Opportunity = {
|
||||
stage: 'NEW' | 'PROPOSAL'
|
||||
}
|
||||
|
||||
export const summariseOpportunityCreation = async (date: string) => {
|
||||
const { opportunities }: { opportunities: Opportunity[] } = await request(
|
||||
`opportunities?filter=createdAt[gte]:${date}`,
|
||||
)
|
||||
|
||||
if (opportunities.length === 0) {
|
||||
return `- No Opportunities were added`
|
||||
}
|
||||
|
||||
const stageSummary = Object.entries(
|
||||
opportunities.reduce((hash: Record<string,number>, opportunity) => {
|
||||
if (!hash[opportunity.stage]) {
|
||||
hash[opportunity.stage] = 0
|
||||
}
|
||||
|
||||
hash[opportunity.stage] += 1
|
||||
return hash
|
||||
}, {})
|
||||
).map(([stage, value]) => `${value} in ${stage}`).join(', ')
|
||||
|
||||
return `- ${opportunities.length} Opportunities were added: ${stageSummary}`
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { request } from "./utils"
|
||||
|
||||
type Person = {
|
||||
companyId: string
|
||||
company: Company
|
||||
}
|
||||
|
||||
type Company = {
|
||||
id: string
|
||||
accountOwnerId: string
|
||||
}
|
||||
|
||||
export const summarisePeopleCreation = async (date: string) => {
|
||||
const { people }: { people: Person[] } = await request(
|
||||
`people?depth=1&filter=createdAt[gte]:${date}`,
|
||||
)
|
||||
|
||||
if (people.length === 0) {
|
||||
return '- No People were added'
|
||||
}
|
||||
|
||||
let createdForCompanies: Record<string, Company> = {}
|
||||
let numberOfAccountOwnerlessCompanies = 0
|
||||
|
||||
for (const person of people) {
|
||||
const isCompanyTracked = createdForCompanies[person.companyId]
|
||||
if (person.companyId && !isCompanyTracked) {
|
||||
createdForCompanies[person.company.id] = person.company
|
||||
|
||||
if (!person?.company?.accountOwnerId) {
|
||||
numberOfAccountOwnerlessCompanies += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `- ${people.length} People were added for ${Object.keys(createdForCompanies).length} Companies
|
||||
- Out of those, ${numberOfAccountOwnerlessCompanies} Companies don't have account owners yet`
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
export const sendToSlack = async (params: {
|
||||
peopleCreationSummary: string;
|
||||
opportunityCreationSummary: string;
|
||||
taskCreationSummary: string;
|
||||
}) => {
|
||||
const {
|
||||
peopleCreationSummary,
|
||||
opportunityCreationSummary,
|
||||
taskCreationSummary,
|
||||
} = params;
|
||||
|
||||
const slackMessage = {
|
||||
blocks: [
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days`,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: '🧑💻 People & Companies',
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: peopleCreationSummary,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: '🎯 Opportunities',
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: opportunityCreationSummary,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: '📋 Tasks',
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'plain_text',
|
||||
text: taskCreationSummary,
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetch(process.env.SLACK_HOOK_URL ?? '', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(slackMessage),
|
||||
});
|
||||
|
||||
return {
|
||||
formattedMessage: slackMessage,
|
||||
webhookStatus: response.status,
|
||||
};
|
||||
};
|
||||
|
||||
export const sendToDiscord = async (params: {
|
||||
peopleCreationSummary: string;
|
||||
opportunityCreationSummary: string;
|
||||
taskCreationSummary: string;
|
||||
}) => {
|
||||
const {
|
||||
peopleCreationSummary,
|
||||
opportunityCreationSummary,
|
||||
taskCreationSummary,
|
||||
} = params;
|
||||
const formattedMessage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
|
||||
|
||||
**🧑💻 People & Companies**
|
||||
${peopleCreationSummary}
|
||||
|
||||
**🎯 Opportunities**
|
||||
${opportunityCreationSummary}
|
||||
|
||||
**📋 Tasks**
|
||||
${taskCreationSummary}`;
|
||||
|
||||
const body = {
|
||||
username: 'Twenty Bot',
|
||||
content: formattedMessage,
|
||||
};
|
||||
|
||||
const response = await fetch(process.env.DISCORD_WEBHOOK_URL ?? '', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return {
|
||||
formattedMessage,
|
||||
webhookStatus: response.status,
|
||||
};
|
||||
};
|
||||
|
||||
export const sendToWhatsApp = async (params: {
|
||||
peopleCreationSummary: string;
|
||||
opportunityCreationSummary: string;
|
||||
taskCreationSummary: string;
|
||||
}): Promise<object> => {
|
||||
const {
|
||||
peopleCreationSummary,
|
||||
opportunityCreationSummary,
|
||||
taskCreationSummary,
|
||||
} = params;
|
||||
const formattedMessage = `Bonjour! 🥖 Je m'appelle Kylian Mbaguette. Over the last ${process.env.DAYS_AGO} days:
|
||||
|
||||
*🧑💻 People & Companies*
|
||||
${peopleCreationSummary}
|
||||
|
||||
*🎯 Opportunities*
|
||||
${opportunityCreationSummary}
|
||||
|
||||
*📋 Tasks*
|
||||
${taskCreationSummary}`;
|
||||
|
||||
const response = await fetch(
|
||||
'https://graph.facebook.com/v22.0/828771160324576/messages',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.FB_GRAPH_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to: process.env.WHATSAPP_RECIPIENT_PHONE_NUMBER,
|
||||
type: 'text',
|
||||
text: {
|
||||
preview_url: true,
|
||||
body: formattedMessage,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
return {
|
||||
formattedMessage,
|
||||
webhookStatus: response.status,
|
||||
webhookResponse: responseBody,
|
||||
};
|
||||
};
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { request } from "./utils"
|
||||
|
||||
type Task = {
|
||||
status: string
|
||||
dueAt: string
|
||||
createdBy: {
|
||||
workspaceMemberId: string
|
||||
}
|
||||
}
|
||||
|
||||
export const summariseTaskCreation = async (date: string) => {
|
||||
const { tasks }: { tasks: Task[] } = await request(
|
||||
`tasks?filter=createdAt[gte]:${date}`,
|
||||
)
|
||||
if (tasks.length === 0) {
|
||||
return '- No Tasks were added'
|
||||
}
|
||||
|
||||
const presentDateISOString = new Date().toISOString()
|
||||
const workspaceMemberIdsDueDateCounter: Record<string, number> = {}
|
||||
let workspaceMembers = []
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (task.status === 'DONE') {
|
||||
return
|
||||
}
|
||||
|
||||
if (presentDateISOString >= task.dueAt) {
|
||||
if (!workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId]) {
|
||||
workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId] = 0
|
||||
}
|
||||
|
||||
workspaceMemberIdsDueDateCounter[task.createdBy.workspaceMemberId] += 1
|
||||
}
|
||||
})
|
||||
|
||||
for (const { userId, count } of findMaxIncompleteKeys(workspaceMemberIdsDueDateCounter)) {
|
||||
const data = await request(`workspaceMembers/${userId}`)
|
||||
if (data.workspaceMember) {
|
||||
workspaceMembers.push({
|
||||
...data.workspaceMember,
|
||||
slackCount: count
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let slackerMessage = workspaceMembers.length > 0 ? workspaceMembers.reduce((text, member, index) => {
|
||||
if (index === 0 && workspaceMembers.length === 1) {
|
||||
return `${member.name.firstName} slacked the most with ${member.slackCount} Tasks overdue`
|
||||
}
|
||||
|
||||
if (index !== workspaceMembers.length - 1) {
|
||||
return text.concat(`, ${member.name.firstName}`)
|
||||
}
|
||||
|
||||
if (index === workspaceMembers.length - 1) {
|
||||
return text.concat(`, and ${member.name.firstName} slacked the most with ${member.slackCount} Tasks overdue`)
|
||||
}
|
||||
}, '') : 'No one was caught slacking!'
|
||||
|
||||
const tasksCompletedOnTime = tasks.filter(
|
||||
task => task.status === 'DONE' && task.dueAt >= presentDateISOString
|
||||
)
|
||||
|
||||
const taskCompletionPercentage = tasksCompletedOnTime.length > 0
|
||||
? (tasksCompletedOnTime.length / tasks.length) * 100
|
||||
: NaN
|
||||
|
||||
const taskCompletionMessage = isNaN(taskCompletionPercentage)
|
||||
? 'No completed Tasks yet'
|
||||
: `${taskCompletionPercentage.toFixed(2)}% of Tasks were completed on time`
|
||||
|
||||
return `- ${tasks.length} Tasks were created
|
||||
- ${taskCompletionMessage}
|
||||
- ${slackerMessage}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This is generated by AI
|
||||
*/
|
||||
const findMaxIncompleteKeys = (workspaceMemberIdsDueDateCounter: Record<string,number>) => {
|
||||
// Get the maximum incomplete count
|
||||
const max = Math.max(...Object.values(workspaceMemberIdsDueDateCounter));
|
||||
|
||||
// Filter keys that match the maximum value
|
||||
return Object.entries(workspaceMemberIdsDueDateCounter)
|
||||
.filter(([_, count]) => count === max)
|
||||
.map(([userId, count]) => ({ userId, count }))
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export const request = async (route: string, authToken?: string) => {
|
||||
const response = await fetch(
|
||||
`https://api.twenty.com/rest/${route}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const json = await response.json()
|
||||
return json.data
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"strict": true,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# This file is generated by running "yarn install" inside your project.
|
||||
# Manual changes might be lost - proceed with caution!
|
||||
|
||||
__metadata:
|
||||
version: 8
|
||||
cacheKey: 10c0
|
||||
|
||||
"@types/node@npm:^24.7.2":
|
||||
version: 24.9.2
|
||||
resolution: "@types/node@npm:24.9.2"
|
||||
dependencies:
|
||||
undici-types: "npm:~7.16.0"
|
||||
checksum: 10c0/7905d43f65cee72ef475fe76316e10bbf6ac5d08a7f0f6c38f2b6285d7ca3009e8fcafc8f8a1d2bf3f55889c9c278dbb203a9081fd0cf2d6d62161703924c6fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"activity-summary@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "activity-summary@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
twenty-sdk: "npm:0.0.3"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"twenty-sdk@npm:0.0.3":
|
||||
version: 0.0.3
|
||||
resolution: "twenty-sdk@npm:0.0.3"
|
||||
checksum: 10c0/0a3c85c27edb22fb50f7eb0da4f9770e85729fce05e9e0118ad0cdfc36e42425c93340a6cd1c276daf30aeeaa612db0cd905831c0a8287a31bff3da5be9b0562
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~7.16.0":
|
||||
version: 7.16.0
|
||||
resolution: "undici-types@npm:7.16.0"
|
||||
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -0,0 +1,19 @@
|
||||
# Set environment values for your application here.
|
||||
# Use the format: KEY=value
|
||||
#
|
||||
# These variables are automatically loaded when running your serverless functions.
|
||||
# You can access them directly in your code using:
|
||||
# const myValue = process.env.KEY;
|
||||
#
|
||||
# To make these variables available to your application,
|
||||
# add them to package.json "env" key. This "env" key defines all
|
||||
# environment variables that will be provided to your serverless
|
||||
# functions at runtime.
|
||||
#
|
||||
# Example:
|
||||
# API_TOKEN=your-api-token
|
||||
# TIMEOUT_MS=3000
|
||||
|
||||
TWENTY_API_URL=
|
||||
TWENTY_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
@@ -0,0 +1,2 @@
|
||||
.yarn/install-state.gz
|
||||
.env
|
||||
@@ -0,0 +1,159 @@
|
||||
# AI Meeting Transcript
|
||||
|
||||
Automatically process meeting transcripts to extract insights, action items, and follow-ups using AI.
|
||||
|
||||
## Features
|
||||
|
||||
- **Automatic Transcript Processing**: Receives meeting transcripts via webhook from Granola or similar transcription tools
|
||||
- **AI-Powered Analysis**: Uses OpenAI to extract:
|
||||
- Meeting summary
|
||||
- Key discussion points
|
||||
- Action items with assignees and due dates
|
||||
- Commitments made by participants
|
||||
- **Rich Note Creation**: Creates formatted notes in Twenty with summary and key points
|
||||
- **Task Generation**: Automatically creates tasks for action items and commitments, linked to the meeting note
|
||||
|
||||
## Requirements
|
||||
|
||||
- `twenty-cli` - Install globally: `npm install -g twenty-cli`
|
||||
- `apiKey` - Go to `https://twenty.com/settings/api-webhooks` to generate one
|
||||
- `OpenAI API Key` - Get your API key from [OpenAI](https://platform.openai.com/api-keys)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Edit `.env` and replace the placeholders:
|
||||
- `<SET_YOUR_TWENTY_API>` with your Twenty API key
|
||||
- `<SET_YOUR_OPENAI_API_KEY>` with your OpenAI API key
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
4. Sync the app to your Twenty workspace:
|
||||
|
||||
```bash
|
||||
twenty auth login
|
||||
twenty app sync
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
After syncing, configure the environment variables in your Twenty workspace:
|
||||
1. Go to Settings → Apps → AI Meeting Transcript
|
||||
2. Set the following environment variables:
|
||||
- `TWENTY_API_KEY` - Your Twenty API key
|
||||
- `TWENTY_API_URL` - Your Twenty instance URL (e.g., `https://api.twenty.com` or `http://localhost:3000` for local development)
|
||||
- `OPENAI_API_KEY` - Your OpenAI API key
|
||||
|
||||
**Important**: `TWENTY_API_URL` is required and must be set to your Twenty instance URL. For local development, use `http://localhost:3000`. For production, use your actual Twenty instance URL.
|
||||
|
||||
## Usage
|
||||
|
||||
### Webhook Endpoint
|
||||
|
||||
The app exposes a public serverless route trigger.
|
||||
```
|
||||
POST /s/webhook/transcript
|
||||
```
|
||||
|
||||
Examples:
|
||||
- Local: `POST http://localhost:3000/s/webhook/transcript`
|
||||
- Hosted: `POST https://your-twenty-instance.com/s/webhook/transcript`
|
||||
|
||||
### Webhook Payload Format
|
||||
|
||||
Send a POST request with the following JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"transcript": "Full meeting transcript text here...",
|
||||
"meetingTitle": "Q4 Planning Meeting",
|
||||
"meetingDate": "2024-01-15",
|
||||
"participants": ["John Doe", "Jane Smith"],
|
||||
"metadata": {
|
||||
"duration": "45 minutes",
|
||||
"location": "Conference Room A"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `transcript` (string): The full meeting transcript text
|
||||
|
||||
**Optional Fields:**
|
||||
- `meetingTitle` (string): Title of the meeting
|
||||
- `meetingDate` (string): Date of the meeting (ISO format or readable date)
|
||||
- `participants` (string[]): List of meeting participants
|
||||
- `metadata` (object): Additional metadata about the meeting
|
||||
|
||||
### Example Webhook Call
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-twenty-instance.com/s/webhook/transcript \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"transcript": "John: Let'\''s start the meeting. Today we need to discuss Q4 goals. Jane: I agree. We should focus on customer retention. John: Great point. Can you prepare a report by Friday? Jane: Yes, I will have it ready.",
|
||||
"meetingTitle": "Q4 Planning Meeting",
|
||||
"meetingDate": "2024-01-15"
|
||||
}'
|
||||
```
|
||||
|
||||
### What Happens
|
||||
|
||||
1. **Transcript Analysis**: The transcript is sent to OpenAI for analysis
|
||||
2. **Note Creation**: A formatted note is created in Twenty with:
|
||||
- Meeting summary
|
||||
- Key discussion points
|
||||
- Reference to the transcript source
|
||||
3. **Task Creation**: Tasks are automatically created for:
|
||||
- Each action item identified
|
||||
- Each commitment made by participants
|
||||
- Tasks include a reference to the meeting note ID in their description
|
||||
|
||||
## Development
|
||||
|
||||
Run dev mode to see application updates on your workspace instantly:
|
||||
|
||||
```bash
|
||||
twenty app dev
|
||||
```
|
||||
|
||||
## Integration with Granola
|
||||
|
||||
To integrate with Granola or similar transcription tools:
|
||||
|
||||
1. Set up a webhook in your transcription service
|
||||
2. Configure it to POST to: `https://your-twenty-instance.com/s/webhook/transcript`
|
||||
3. Map the transcription service's payload format to the expected format above
|
||||
|
||||
### Granola Webhook Setup
|
||||
|
||||
If using Granola, configure the webhook to send:
|
||||
- `transcript` field with the transcript text
|
||||
- Optionally include meeting metadata fields
|
||||
|
||||
## API Response
|
||||
|
||||
The webhook returns a JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"noteId": "uuid-of-created-note",
|
||||
"taskIds": ["uuid-of-task-1", "uuid-of-task-2"],
|
||||
"summary": {
|
||||
"noteCreated": true,
|
||||
"tasksCreated": 2,
|
||||
"actionItemsProcessed": 1,
|
||||
"commitmentsProcessed": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: '028754f1-3235-43b9-9427-fa6a62dbd473',
|
||||
displayName: 'AI Meeting Transcript',
|
||||
description:
|
||||
'Automatically process meeting transcripts to extract insights, action items, and follow-ups',
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '1359d05c-4947-4673-809f-abd55bede365',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'Twenty API key',
|
||||
},
|
||||
TWENTY_API_URL: {
|
||||
universalIdentifier: 'dbe83355-b574-445c-92c0-5c2b94a61ddb',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'Twenty API URL',
|
||||
},
|
||||
OPENAI_API_KEY: {
|
||||
universalIdentifier: '9559470d-15eb-4bc2-9cbc-3bc5c869d1fd',
|
||||
isSecret: true,
|
||||
value: '',
|
||||
description: 'OpenAI API key for transcript analysis',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "ai-meeting-transcript",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.2",
|
||||
"openai": "^4.28.0",
|
||||
"twenty-sdk": "0.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
}
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
import axios from 'axios';
|
||||
import OpenAI from 'openai';
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
|
||||
type TranscriptWebhookPayload = {
|
||||
transcript: string;
|
||||
meetingTitle?: string;
|
||||
meetingDate?: string;
|
||||
participants?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ActionItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
assignee?: string;
|
||||
dueDate?: string;
|
||||
};
|
||||
|
||||
type Commitment = {
|
||||
person: string;
|
||||
commitment: string;
|
||||
dueDate?: string;
|
||||
};
|
||||
|
||||
type AnalysisResult = {
|
||||
summary: string;
|
||||
keyPoints: string[];
|
||||
actionItems: ActionItem[];
|
||||
commitments: Commitment[];
|
||||
};
|
||||
|
||||
type RichTextData = {
|
||||
markdown: string;
|
||||
blocknote: null;
|
||||
};
|
||||
|
||||
type TwentyApiResponse = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const OPENAI_MODEL = 'gpt-4o-mini';
|
||||
const OPENAI_TEMPERATURE = 0.3;
|
||||
|
||||
const analyzeTranscript = async (
|
||||
transcript: string,
|
||||
openaiApiKey: string,
|
||||
): Promise<AnalysisResult> => {
|
||||
const openai = new OpenAI({ apiKey: openaiApiKey });
|
||||
|
||||
const prompt = `Analyze the following meeting transcript and extract:
|
||||
1. A concise summary (2-3 sentences)
|
||||
2. Key discussion points (bullet list)
|
||||
3. Action items with titles, descriptions, and any mentioned assignees or due dates
|
||||
4. Commitments made by participants with names and any mentioned due dates
|
||||
|
||||
Return the response as a JSON object with this structure:
|
||||
{
|
||||
"summary": "string",
|
||||
"keyPoints": ["string"],
|
||||
"actionItems": [{"title": "string", "description": "string", "assignee": "string (optional)", "dueDate": "string (optional)"}],
|
||||
"commitments": [{"person": "string", "commitment": "string", "dueDate": "string (optional)"}]
|
||||
}
|
||||
|
||||
Transcript:
|
||||
${transcript}`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: OPENAI_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a meeting analysis assistant. Extract key insights, action items, and commitments from meeting transcripts. Always return valid JSON.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: OPENAI_TEMPERATURE,
|
||||
});
|
||||
|
||||
const content = completion.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('No response from OpenAI');
|
||||
}
|
||||
|
||||
return JSON.parse(content) as AnalysisResult;
|
||||
};
|
||||
|
||||
const getTwentyApiConfig = () => {
|
||||
const apiKey = process.env.TWENTY_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('TWENTY_API_KEY environment variable is not set');
|
||||
}
|
||||
|
||||
const baseUrl = process.env.TWENTY_API_URL;
|
||||
|
||||
return { apiKey, baseUrl };
|
||||
};
|
||||
|
||||
const formatNoteBody = (summary: string, keyPoints: string[]): string => {
|
||||
const keyPointsList = keyPoints.map((point) => `- ${point}`).join('\n');
|
||||
return `## Summary\n\n${summary}\n\n## Key Points\n\n${keyPointsList}\n\n*Generated from meeting transcript*`;
|
||||
};
|
||||
|
||||
const createNoteInTwenty = async (
|
||||
summary: string,
|
||||
keyPoints: string[],
|
||||
meetingTitle?: string,
|
||||
meetingDate?: string,
|
||||
): Promise<TwentyApiResponse> => {
|
||||
const { apiKey, baseUrl } = getTwentyApiConfig();
|
||||
const noteTitle =
|
||||
meetingTitle ||
|
||||
`Meeting Notes - ${meetingDate || new Date().toLocaleDateString()}`;
|
||||
const noteBodyMarkdown = formatNoteBody(summary, keyPoints);
|
||||
|
||||
const requestData = {
|
||||
title: noteTitle,
|
||||
bodyV2: {
|
||||
markdown: noteBodyMarkdown,
|
||||
blocknote: null,
|
||||
} satisfies RichTextData,
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<TwentyApiResponse>(
|
||||
`${baseUrl}/rest/notes`,
|
||||
requestData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const errorMessage = error.response?.data
|
||||
? JSON.stringify(error.response.data, null, 2)
|
||||
: error.message;
|
||||
const status = error.response?.status;
|
||||
throw new Error(
|
||||
`Failed to create note: ${errorMessage}. Status: ${status}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const createTaskInTwenty = async (
|
||||
actionItem: ActionItem,
|
||||
): Promise<TwentyApiResponse> => {
|
||||
const { apiKey, baseUrl } = getTwentyApiConfig();
|
||||
|
||||
const taskData: {
|
||||
title: string;
|
||||
bodyV2: RichTextData;
|
||||
dueAt?: string;
|
||||
} = {
|
||||
title: actionItem.title,
|
||||
bodyV2: {
|
||||
markdown: actionItem.description,
|
||||
blocknote: null,
|
||||
},
|
||||
};
|
||||
|
||||
if (actionItem.dueDate) {
|
||||
taskData.dueAt = new Date(actionItem.dueDate).toISOString();
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<TwentyApiResponse>(
|
||||
`${baseUrl}/rest/tasks`,
|
||||
taskData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const errorMessage = error.response?.data
|
||||
? JSON.stringify(error.response.data, null, 2)
|
||||
: error.message;
|
||||
const status = error.response?.status;
|
||||
throw new Error(
|
||||
`Failed to create task "${actionItem.title}": ${errorMessage}. Status: ${status}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const createTasksFromActionItems = async (
|
||||
actionItems: ActionItem[],
|
||||
noteId: string,
|
||||
): Promise<string[]> => {
|
||||
const taskIds: string[] = [];
|
||||
|
||||
for (const actionItem of actionItems) {
|
||||
try {
|
||||
const taskDescription = `${actionItem.description}\n\n*Related to meeting note: ${noteId}*`;
|
||||
const task = await createTaskInTwenty({
|
||||
...actionItem,
|
||||
description: taskDescription,
|
||||
});
|
||||
taskIds.push(task.id);
|
||||
} catch (error) {
|
||||
// Task creation failed, continue with next task
|
||||
}
|
||||
}
|
||||
|
||||
return taskIds;
|
||||
};
|
||||
|
||||
const createTasksFromCommitments = async (
|
||||
commitments: Commitment[],
|
||||
noteId: string,
|
||||
): Promise<string[]> => {
|
||||
const taskIds: string[] = [];
|
||||
|
||||
for (const commitment of commitments) {
|
||||
try {
|
||||
const taskDescription = `Commitment from ${commitment.person}: ${commitment.commitment}\n\n*Related to meeting note: ${noteId}*`;
|
||||
const task = await createTaskInTwenty({
|
||||
title: `Follow up: ${commitment.commitment}`,
|
||||
description: taskDescription,
|
||||
dueDate: commitment.dueDate,
|
||||
});
|
||||
taskIds.push(task.id);
|
||||
} catch (error) {
|
||||
// Commitment task creation failed, continue with next commitment
|
||||
}
|
||||
}
|
||||
|
||||
return taskIds;
|
||||
};
|
||||
|
||||
export const main = async (
|
||||
params: TranscriptWebhookPayload,
|
||||
): Promise<object> => {
|
||||
const { transcript, meetingTitle, meetingDate } = params;
|
||||
|
||||
if (!transcript || typeof transcript !== 'string') {
|
||||
throw new Error('Transcript is required and must be a string');
|
||||
}
|
||||
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OPENAI_API_KEY environment variable is not set');
|
||||
}
|
||||
|
||||
const analysis = await analyzeTranscript(transcript, openaiApiKey);
|
||||
|
||||
const note = await createNoteInTwenty(
|
||||
analysis.summary,
|
||||
analysis.keyPoints,
|
||||
meetingTitle,
|
||||
meetingDate,
|
||||
);
|
||||
|
||||
const actionItemTaskIds = await createTasksFromActionItems(
|
||||
analysis.actionItems,
|
||||
note.id,
|
||||
);
|
||||
const commitmentTaskIds = await createTasksFromCommitments(
|
||||
analysis.commitments,
|
||||
note.id,
|
||||
);
|
||||
|
||||
const allTaskIds = [...actionItemTaskIds, ...commitmentTaskIds];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.id,
|
||||
taskIds: allTaskIds,
|
||||
summary: {
|
||||
noteCreated: true,
|
||||
tasksCreated: allTaskIds.length,
|
||||
actionItemsProcessed: analysis.actionItems.length,
|
||||
commitmentsProcessed: analysis.commitments.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const config: ServerlessFunctionConfig = {
|
||||
universalIdentifier: 'dae52ab2-174f-4f81-a031-604ee2e81eba',
|
||||
name: 'ai-meeting-transcriptor',
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'b011303d-2c24-44d4-9923-55eb060a1ff6',
|
||||
type: 'route',
|
||||
path: '/webhook/transcript',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"strict": true,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
# This file is generated by running "yarn install" inside your project.
|
||||
# Manual changes might be lost - proceed with caution!
|
||||
|
||||
__metadata:
|
||||
version: 8
|
||||
cacheKey: 10c0
|
||||
|
||||
"@types/node-fetch@npm:^2.6.4":
|
||||
version: 2.6.13
|
||||
resolution: "@types/node-fetch@npm:2.6.13"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
form-data: "npm:^4.0.4"
|
||||
checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:*, @types/node@npm:^24.7.2":
|
||||
version: 24.9.2
|
||||
resolution: "@types/node@npm:24.9.2"
|
||||
dependencies:
|
||||
undici-types: "npm:~7.16.0"
|
||||
checksum: 10c0/7905d43f65cee72ef475fe76316e10bbf6ac5d08a7f0f6c38f2b6285d7ca3009e8fcafc8f8a1d2bf3f55889c9c278dbb203a9081fd0cf2d6d62161703924c6fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^18.11.18":
|
||||
version: 18.19.130
|
||||
resolution: "@types/node@npm:18.19.130"
|
||||
dependencies:
|
||||
undici-types: "npm:~5.26.4"
|
||||
checksum: 10c0/22ba2bc9f8863101a7e90a56aaeba1eb3ebdc51e847cef4a6d188967ab1acbce9b4f92251372fd0329ecb924bbf610509e122c3dfe346c04dbad04013d4ad7d0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"abort-controller@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "abort-controller@npm:3.0.0"
|
||||
dependencies:
|
||||
event-target-shim: "npm:^5.0.0"
|
||||
checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"agentkeepalive@npm:^4.2.1":
|
||||
version: 4.6.0
|
||||
resolution: "agentkeepalive@npm:4.6.0"
|
||||
dependencies:
|
||||
humanize-ms: "npm:^1.2.1"
|
||||
checksum: 10c0/235c182432f75046835b05f239708107138a40103deee23b6a08caee5136873709155753b394ec212e49e60e94a378189562cb01347765515cff61b692c69187
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ai-meeting-transcript@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "ai-meeting-transcript@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
axios: "npm:^1.12.2"
|
||||
openai: "npm:^4.28.0"
|
||||
twenty-sdk: "npm:^0.0.2"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"async-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-function@npm:1.0.0"
|
||||
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"async-generator-function@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "async-generator-function@npm:1.0.0"
|
||||
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"asynckit@npm:^0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "asynckit@npm:0.4.0"
|
||||
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.12.2":
|
||||
version: 1.13.1
|
||||
resolution: "axios@npm:1.13.1"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.4"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "call-bind-apply-helpers@npm:1.0.2"
|
||||
dependencies:
|
||||
es-errors: "npm:^1.3.0"
|
||||
function-bind: "npm:^1.1.2"
|
||||
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"combined-stream@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "combined-stream@npm:1.0.8"
|
||||
dependencies:
|
||||
delayed-stream: "npm:~1.0.0"
|
||||
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"delayed-stream@npm:~1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "delayed-stream@npm:1.0.0"
|
||||
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dunder-proto@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "dunder-proto@npm:1.0.1"
|
||||
dependencies:
|
||||
call-bind-apply-helpers: "npm:^1.0.1"
|
||||
es-errors: "npm:^1.3.0"
|
||||
gopd: "npm:^1.2.0"
|
||||
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-define-property@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "es-define-property@npm:1.0.1"
|
||||
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-errors@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "es-errors@npm:1.3.0"
|
||||
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "es-object-atoms@npm:1.1.1"
|
||||
dependencies:
|
||||
es-errors: "npm:^1.3.0"
|
||||
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-set-tostringtag@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "es-set-tostringtag@npm:2.1.0"
|
||||
dependencies:
|
||||
es-errors: "npm:^1.3.0"
|
||||
get-intrinsic: "npm:^1.2.6"
|
||||
has-tostringtag: "npm:^1.0.2"
|
||||
hasown: "npm:^2.0.2"
|
||||
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"event-target-shim@npm:^5.0.0":
|
||||
version: 5.0.1
|
||||
resolution: "event-target-shim@npm:5.0.1"
|
||||
checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"follow-redirects@npm:^1.15.6":
|
||||
version: 1.15.11
|
||||
resolution: "follow-redirects@npm:1.15.11"
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data-encoder@npm:1.7.2":
|
||||
version: 1.7.2
|
||||
resolution: "form-data-encoder@npm:1.7.2"
|
||||
checksum: 10c0/56553768037b6d55d9de524f97fe70555f0e415e781cb56fc457a68263de3d40fadea2304d4beef2d40b1a851269bd7854e42c362107071892cb5238debe9464
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "form-data@npm:4.0.4"
|
||||
dependencies:
|
||||
asynckit: "npm:^0.4.0"
|
||||
combined-stream: "npm:^1.0.8"
|
||||
es-set-tostringtag: "npm:^2.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
mime-types: "npm:^2.1.12"
|
||||
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"formdata-node@npm:^4.3.2":
|
||||
version: 4.4.1
|
||||
resolution: "formdata-node@npm:4.4.1"
|
||||
dependencies:
|
||||
node-domexception: "npm:1.0.0"
|
||||
web-streams-polyfill: "npm:4.0.0-beta.3"
|
||||
checksum: 10c0/74151e7b228ffb33b565cec69182694ad07cc3fdd9126a8240468bb70a8ba66e97e097072b60bcb08729b24c7ce3fd3e0bd7f1f80df6f9f662b9656786e76f6a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"function-bind@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "function-bind@npm:1.1.2"
|
||||
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"generator-function@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "generator-function@npm:2.0.1"
|
||||
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-intrinsic@npm:^1.2.6":
|
||||
version: 1.3.1
|
||||
resolution: "get-intrinsic@npm:1.3.1"
|
||||
dependencies:
|
||||
async-function: "npm:^1.0.0"
|
||||
async-generator-function: "npm:^1.0.0"
|
||||
call-bind-apply-helpers: "npm:^1.0.2"
|
||||
es-define-property: "npm:^1.0.1"
|
||||
es-errors: "npm:^1.3.0"
|
||||
es-object-atoms: "npm:^1.1.1"
|
||||
function-bind: "npm:^1.1.2"
|
||||
generator-function: "npm:^2.0.0"
|
||||
get-proto: "npm:^1.0.1"
|
||||
gopd: "npm:^1.2.0"
|
||||
has-symbols: "npm:^1.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
math-intrinsics: "npm:^1.1.0"
|
||||
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-proto@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "get-proto@npm:1.0.1"
|
||||
dependencies:
|
||||
dunder-proto: "npm:^1.0.1"
|
||||
es-object-atoms: "npm:^1.0.0"
|
||||
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gopd@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "gopd@npm:1.2.0"
|
||||
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "has-symbols@npm:1.1.0"
|
||||
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"has-tostringtag@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "has-tostringtag@npm:1.0.2"
|
||||
dependencies:
|
||||
has-symbols: "npm:^1.0.3"
|
||||
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hasown@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "hasown@npm:2.0.2"
|
||||
dependencies:
|
||||
function-bind: "npm:^1.1.2"
|
||||
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"humanize-ms@npm:^1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "humanize-ms@npm:1.2.1"
|
||||
dependencies:
|
||||
ms: "npm:^2.0.0"
|
||||
checksum: 10c0/f34a2c20161d02303c2807badec2f3b49cbfbbb409abd4f95a07377ae01cfe6b59e3d15ac609cffcd8f2521f0eb37b7e1091acf65da99aa2a4f1ad63c21e7e7a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"math-intrinsics@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "math-intrinsics@npm:1.1.0"
|
||||
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mime-db@npm:1.52.0":
|
||||
version: 1.52.0
|
||||
resolution: "mime-db@npm:1.52.0"
|
||||
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mime-types@npm:^2.1.12":
|
||||
version: 2.1.35
|
||||
resolution: "mime-types@npm:2.1.35"
|
||||
dependencies:
|
||||
mime-db: "npm:1.52.0"
|
||||
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ms@npm:^2.0.0":
|
||||
version: 2.1.3
|
||||
resolution: "ms@npm:2.1.3"
|
||||
checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-domexception@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "node-domexception@npm:1.0.0"
|
||||
checksum: 10c0/5e5d63cda29856402df9472335af4bb13875e1927ad3be861dc5ebde38917aecbf9ae337923777af52a48c426b70148815e890a5d72760f1b4d758cc671b1a2b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-fetch@npm:^2.6.7":
|
||||
version: 2.7.0
|
||||
resolution: "node-fetch@npm:2.7.0"
|
||||
dependencies:
|
||||
whatwg-url: "npm:^5.0.0"
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"openai@npm:^4.28.0":
|
||||
version: 4.104.0
|
||||
resolution: "openai@npm:4.104.0"
|
||||
dependencies:
|
||||
"@types/node": "npm:^18.11.18"
|
||||
"@types/node-fetch": "npm:^2.6.4"
|
||||
abort-controller: "npm:^3.0.0"
|
||||
agentkeepalive: "npm:^4.2.1"
|
||||
form-data-encoder: "npm:1.7.2"
|
||||
formdata-node: "npm:^4.3.2"
|
||||
node-fetch: "npm:^2.6.7"
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.23.8
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
bin:
|
||||
openai: bin/cli
|
||||
checksum: 10c0/c4f2e837684ed96b8cec58c65a584646d667c69918f29052775e2e8c05ff5c860d8b58214a7770bc6895ca8602480420c1db6a5392dd250179eb0b91c2b19a2f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"proxy-from-env@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "proxy-from-env@npm:1.1.0"
|
||||
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tr46@npm:~0.0.3":
|
||||
version: 0.0.3
|
||||
resolution: "tr46@npm:0.0.3"
|
||||
checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"twenty-sdk@npm:^0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "twenty-sdk@npm:0.0.2"
|
||||
checksum: 10c0/99e6fe86059d847b548c1f03e0f0c59a4d540caf1d28dd4500f1f5f0094196985ded955801274de9e72ff03e3d1f41e9a509b4c2c5a02ffc8a027277b1e35d8e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~5.26.4":
|
||||
version: 5.26.5
|
||||
resolution: "undici-types@npm:5.26.5"
|
||||
checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undici-types@npm:~7.16.0":
|
||||
version: 7.16.0
|
||||
resolution: "undici-types@npm:7.16.0"
|
||||
checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"web-streams-polyfill@npm:4.0.0-beta.3":
|
||||
version: 4.0.0-beta.3
|
||||
resolution: "web-streams-polyfill@npm:4.0.0-beta.3"
|
||||
checksum: 10c0/a9596779db2766990117ed3a158e0b0e9f69b887a6d6ba0779940259e95f99dc3922e534acc3e5a117b5f5905300f527d6fbf8a9f0957faf1d8e585ce3452e8e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webidl-conversions@npm:^3.0.0":
|
||||
version: 3.0.1
|
||||
resolution: "webidl-conversions@npm:3.0.1"
|
||||
checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"whatwg-url@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "whatwg-url@npm:5.0.0"
|
||||
dependencies:
|
||||
tr46: "npm:~0.0.3"
|
||||
webidl-conversions: "npm:^3.0.0"
|
||||
checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty/*
|
||||
!.twenty/output/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "import", "unicorn"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"ignorePatterns": ["node_modules"],
|
||||
"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-explicit-any": "off",
|
||||
"typescript/no-unused-vars": ["warn", {
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/app-seeds/rich-app
|
||||
|
||||
## UUID requirement
|
||||
- All generated UUIDs must be valid UUID v4.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
|
||||
@@ -0,0 +1,51 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote add http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
|
||||
```bash
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Available Commands
|
||||
|
||||
Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote add 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 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
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "apollo-enrich",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^18.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user