Compare commits

..

17 Commits

Author SHA1 Message Date
prastoin 396efd2d96 generate typeorm migration cli 2026-04-02 16:06:10 +02:00
prastoin d7d279a4b4 wip 2026-04-02 14:46:58 +02:00
prastoin 86a9d3832b remove finally from runners 2026-04-02 13:54:06 +02:00
prastoin dc298bf548 fix merge 2026-04-02 13:46:15 +02:00
prastoin 17ce7ed781 Merge remote-tracking branch 'origin/main' into upgrade-command-refactor 2026-04-02 13:41:59 +02:00
prastoin c2872f8e7d refactor(server): commander options 2026-04-02 13:38:07 +02:00
prastoin ac02aa3ce6 deprecated report 2026-04-02 13:35:05 +02:00
prastoin 3ea466755a naming 2026-04-02 13:22:21 +02:00
prastoin d219c83815 workspace migration runner extends commandRunner directly 2026-04-02 13:04:25 +02:00
prastoin ab16ae6995 review 2026-04-02 12:00:57 +02:00
prastoin 49d19494b9 avoid mutations 2026-04-02 11:45:06 +02:00
prastoin 5172572d15 refactor(server): remove last upgrade command runner extends 2026-04-02 11:39:31 +02:00
prastoin b5492cff64 workspace iterator callback 2026-04-02 11:12:55 +02:00
prastoin 0cb231a407 revert instance commands stuff 2026-04-02 10:49:33 +02:00
prastoin 5484d0b776 feat(server): factorize workspace iterator usage 2026-04-02 10:36:43 +02:00
prastoin bbc4d50d4f feat(server): workspace iterator service 2026-04-02 10:33:18 +02:00
prastoin 10b37a6b39 refactor(server): upgrade and typeorm migrations nesting 2026-04-01 18:39:48 +02:00
5445 changed files with 146907 additions and 304926 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {}
}
}
}
-223
View File
@@ -1,223 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages.
## Key Commands
### Development
```bash
# Start development environment (frontend + backend + worker)
yarn start
# Individual package development
npx nx start twenty-front # Start frontend dev server
npx nx start twenty-server # Start backend server
npx nx run twenty-server:worker # Start background worker
```
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Type checking
npx nx typecheck twenty-front
npx nx typecheck twenty-server
# Format code
npx nx fmt twenty-front
npx nx fmt twenty-server
```
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
npx nx build twenty-front
npx nx build twenty-server
```
### Database Operations
```bash
# 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)
# Generate an instance command (fast or slow)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- 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
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
### Package Structure
```
packages/
├── twenty-front/ # React frontend application
├── twenty-server/ # NestJS backend API
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
### Key Development Principles
- **Functional components only** (no class components)
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
### Backend Architecture
- **NestJS modules** for feature organization
- **TypeORM** for database ORM with PostgreSQL
- **GraphQL** API with code-first approach
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Upgrade Commands
- **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
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## Dev Environment Setup
All dev environments (Claude Code web, Cursor, local) use one script:
```bash
bash packages/twenty-utils/setup-dev-env.sh
```
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
- `--down` — stop services
- `--reset` — wipe data and restart fresh
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
+6 -3
View File
@@ -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
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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
+14 -31
View File
@@ -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)"
@@ -251,14 +251,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
@@ -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
@@ -1,13 +1,11 @@
name: CI Hello world App E2E
name: CI Create App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
push:
branches:
- main
pull_request:
permissions:
contents: read
@@ -25,11 +23,13 @@ jobs:
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
create-app-e2e-hello-world:
!packages/twenty-server/package.json
create-app-e2e:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
@@ -53,8 +53,6 @@ jobs:
- 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
@@ -68,9 +66,7 @@ jobs:
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
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
@@ -109,7 +105,7 @@ jobs:
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
create-twenty-app test-app --exhaustive --display-name "Test App" --description "E2E test app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
@@ -117,8 +113,6 @@ jobs:
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: |
@@ -126,7 +120,7 @@ jobs:
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
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);
@@ -142,24 +136,27 @@ jobs:
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
- 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";'
- name: Setup database
run: npx nx run twenty-server:database:reset
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
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 --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
- name: Deploy scaffolded app
run: |
@@ -186,15 +183,17 @@ jobs:
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-hello-world-status-check:
ci-create-app-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-hello-world]
needs: [changed-files-check, create-app-e2e]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
@@ -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
+1 -1
View File
@@ -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:
+7 -19
View File
@@ -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
@@ -70,8 +69,7 @@ jobs:
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 +77,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
+10 -15
View File
@@ -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/**
@@ -145,18 +144,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
@@ -166,14 +162,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
+3 -4
View File
@@ -69,14 +69,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
+1 -4
View File
@@ -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/
+13 -13
View File
@@ -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.
@@ -158,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:
@@ -181,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
+89 -122
View File
@@ -4,161 +4,123 @@
</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/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.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/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/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/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
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/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/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/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
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/public/images/readme/v2-build-apps-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" />
<img src="./packages/twenty-website/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/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.png" />
<img src="./packages/twenty-website/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/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" />
<img src="./packages/twenty-website/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/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.png" />
<img src="./packages/twenty-website/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/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" />
<img src="./packages/twenty-website/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/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" />
<img src="./packages/twenty-website/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/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/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- [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/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website/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/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
- 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!
+1 -1
View File
@@ -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": {
+12 -15
View File
@@ -24,27 +24,24 @@ yarn twenty dev
The scaffolder will:
1. Create a new project with TypeScript, linting, tests, and a preconfigured `twenty` CLI
1. Create a new project with TypeScript, linting, and a preconfigured `twenty` CLI
2. Optionally start a local Twenty server (Docker)
3. Open the browser for OAuth authentication
4. Scaffold example entities and an integration test
## Options
## Scaffolding modes
| 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 |
| Flag | Behavior |
| -------------- | -------------------------------------------------------------------------------------------------------------- |
| `--minimal` | **(default)** Creates only core files (`application-config.ts`, `default-role.ts`, pre/post-install functions) |
| `--exhaustive` | Creates all example entities |
By default (no flags), a minimal app is generated with core files and an integration test. Use `--example` to start from a richer example:
Other flags:
```bash
npx create-twenty-app@latest my-twenty-app --example hello-world
```
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
- `--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
## Documentation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.0.0",
"version": "0.8.0-canary.8",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+24 -3
View File
@@ -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')
.option(
'-m, --minimal',
'Create only core entities (application-config and default-role) (default)',
)
.option('-n, --name <name>', 'Application name (skips prompt)')
.option(
'-d, --display-name <displayName>',
@@ -31,13 +36,25 @@ const program = new Command(packageJson.name)
async (
directory?: string,
options?: {
example?: string;
exhaustive?: boolean;
minimal?: boolean;
name?: string;
displayName?: string;
description?: string;
skipLocalInstance?: 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(
@@ -52,9 +69,13 @@ const program = new Command(packageJson.name)
process.exit(1);
}
const mode: ScaffoldingMode = options?.exhaustive
? 'exhaustive'
: 'minimal';
await new CreateAppCommand().execute({
directory,
example: options?.example,
mode,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
@@ -1,7 +1,7 @@
## 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/fixtures/rich-app
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/postcard-app
## UUID requirement
@@ -6,6 +6,6 @@ Run `yarn twenty help` to list all available commands.
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [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)
@@ -33,10 +33,5 @@
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -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,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,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'}`,
);
}
}
@@ -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,
});
@@ -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,18 +10,22 @@ import * as path from 'path';
import { basename } from 'path';
import {
authLoginOAuth,
ConfigService,
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;
@@ -35,34 +38,23 @@ export class CreateAppCommand {
await this.getAppInfos(options);
try {
const exampleOptions = this.resolveExampleOptions(
options.mode ?? 'minimal',
);
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);
@@ -108,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';
@@ -126,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 ?? '',
@@ -137,7 +128,7 @@ export class CreateAppCommand {
type: 'input',
name: 'description',
message: 'Application description (optional):',
when: () => !hasDescription && !hasExample,
when: () => !hasDescription,
default: '',
},
]);
@@ -146,7 +137,6 @@ export class CreateAppCommand {
options.name ??
name ??
directory ??
options.example ??
'my-twenty-app'
).trim();
@@ -162,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: true,
};
}
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;
@@ -175,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,
@@ -256,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.',
),
);
@@ -269,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);
};
+1 -5
View File
@@ -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"]
}
+1 -4
View File
@@ -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__/**"
]
}
+2 -2
View File
@@ -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',
},
]),
],
@@ -1,7 +1,7 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/rich-app
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/fixtures/postcard-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
@@ -1,5 +1,5 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk/define';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
import { defineFrontComponent } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk';
const StyledContainer = styled.div`
display: flex;
@@ -1,4 +1,4 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk/define";
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
import { MetadataApiClient } from 'twenty-sdk/clients';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
@@ -1,4 +1,4 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk';
export type OAuthApplicationVariables = {
apolloClientId: string;
@@ -1,4 +1,4 @@
import { defineLogicFunction } from "twenty-sdk/define";
import { defineLogicFunction } from "twenty-sdk";
export const VERIFY_PAGE_PATH = '/oauth/verify';
@@ -1,4 +1,8 @@
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/clients';
type CompanyRecord = {
@@ -1,4 +1,4 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
@@ -1,4 +1,4 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
@@ -1,4 +1,4 @@
import { defineRole } from 'twenty-sdk/define';
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b8faae3f-e174-43fa-ab94-715712ae26cb';
@@ -1,4 +1,4 @@
import { type ApplicationConfig } from 'twenty-sdk/define';
import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
@@ -1,4 +1,4 @@
import { Object } from 'twenty-sdk/define';
import { Object } from 'twenty-sdk';
@Object({
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
@@ -1,4 +1,4 @@
import { type FunctionConfig } from 'twenty-sdk/define';
import { type FunctionConfig } from 'twenty-sdk';
import type { ProcessResult } from './types';
import { WebhookHandler } from './webhook-handler';
@@ -1,32 +0,0 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# typescript
*.tsbuildinfo
*.d.ts
@@ -1,254 +0,0 @@
# GitHub Connector
Sync pull requests, issues, contributors and project items from GitHub into
Twenty, and react to GitHub webhook events in real time.
This app showcases how to build a non-trivial third-party connector with the
Twenty SDK: custom objects with rich relationships, navigation menu items,
table views, a dashboard page layout, logic functions for periodic syncs, an
HTTP webhook handler, and authenticated GraphQL/REST calls against an
external provider.
![GitHub Connector marketplace listing in Settings → Apps with About / Content / Permissions / Settings tabs](public/screenshots/app-listing.png)
![GitHub Dashboard with PR / review counters, weekly histograms, and top-contributor leaderboards](public/screenshots/github-dashboard.png)
![Pull Requests view with the Fetch Pull Requests command](public/screenshots/pull-requests-view.png)
![Contributor detail page with the Contributor Stats panel showing PRs authored, merged and reviewed over time](public/screenshots/contributor-stats.png)
## What it adds to your workspace
### Custom objects
Six custom objects, each with fields, relationships and table views:
- `pullRequest`
- `pullRequestReview`
- `pullRequestReviewEvent`
- `issue`
- `projectItem`
- `contributor`
### Navigation
A top-level **GitHub** folder in the left sidebar with:
- Pull Requests
- Issues
- Project Items
- Contributors
- Pull Request Reviews
- Pull Request Review Events
- GitHub Dashboard (a page layout that aggregates PR activity over time and
surfaces top contributors)
### Logic functions
| Function | Trigger |
| --------------------------------- | ------------------------------------------------ |
| `count-prs` | HTTP `POST /github/count-prs` |
| `fetch-prs` | HTTP `POST /github/fetch-prs` |
| `count-issues` | HTTP `POST /github/count-issues` |
| `fetch-issues` | HTTP `POST /github/fetch-issues` |
| `count-contributors` | HTTP `POST /github/count-contributors` |
| `fetch-contributors` | HTTP `POST /github/fetch-contributors` |
| `count-project-items` | HTTP `POST /github/count-project-items` |
| `fetch-project-items` | HTTP `POST /github/fetch-project-items` |
| `handle-github-webhook` | HTTP `POST /github/webhook` (no auth, signed) |
| `search-contributors` | HTTP `POST /contributors/search` |
| `contributor-stats` | HTTP `POST /contributors/stats` |
| `top-contributors` | HTTP `POST /contributors/top` |
| `recompute-pull-request-reviews` | HTTP `POST /pull-request-reviews/recompute` |
### Front components
Seven front components surface the connector inside the Twenty UI:
- **Fetch Pull Requests** — command on the Pull Request object
- **Fetch Issues** — command on the Issue object
- **Fetch Contributors** — command on the Contributor object
- **Fetch Project Items** — command on the Project Item object
- **Contributor Stats** — panel on the Contributor object that renders a
bar chart of PRs authored / merged / reviewed over the selected period
- **Top PR Authors** — dashboard widget that ranks the top 20 PR authors
over the last 90 days
- **Top Reviewers** — dashboard widget that ranks the top 20 PR reviewers
over the last 90 days
## Install
You have two options. Use **dev mode** for a tight edit/test loop while
iterating on the app, or **install** for a one-shot deploy.
### Option A — Live development (`yarn twenty dev`)
Use this when you want every code change to be re-synced into your local
Twenty server automatically.
```bash
cd packages/twenty-apps/community/github-connector
yarn install
# Register your local Twenty server as a remote (interactive prompt).
# When asked for the URL use http://localhost:2021 and paste an API key
# from Settings -> Developers in the Twenty UI.
yarn twenty remote add
# Build, install, and watch for changes.
yarn twenty dev
```
The first `yarn twenty dev` run installs the app on the remote and starts
watching `src/`. Edit any file and the change is re-synced within seconds.
### Option B — One-shot install
```bash
cd packages/twenty-apps/community/github-connector
yarn install
yarn twenty remote add # same prompts as above
yarn twenty install # builds and installs once
```
## Configure authentication
Once the app is installed, open the Twenty UI and go to
**Settings → Apps → GitHub Connector**. You only need one of the two auth
methods.
### Option 1 — Personal Access Token (recommended for trying it out)
| Variable | Required | Notes |
| --------------- | -------- | -------------------------------------------------------------------- |
| `GITHUB_TOKEN` | yes | Fine-grained PAT (`github_pat_…`). See permissions below. |
Create a fine-grained PAT at
<https://github.com/settings/personal-access-tokens>:
1. **Resource owner**: the org (or user) that owns the repos in
`GITHUB_REPOS` and the projects in `GITHUB_PROJECTS`. Org-owned tokens
must be approved by an org admin before they can read org resources.
2. **Repository access**: pick the specific repos (or "All repositories").
3. **Repository permissions** — set to **Read-only**:
- `Contents`
- `Issues`
- `Pull requests`
- `Metadata` (selected automatically)
4. **Organization permissions** — only if you want to sync GitHub Projects
(v2): set `Projects` to **Read-only**.
5. Generate, then copy the `github_pat_…` value.
Classic PATs are intentionally not supported — fine-grained tokens are
scoped per-repo/per-org and avoid the all-or-nothing `repo` scope.
When `GITHUB_TOKEN` is set, it always wins regardless of any GitHub App
config below.
### Option 2 — GitHub App (recommended for production / org-wide installs)
| Variable | Required | Notes |
| ---------------------------- | -------- | ---------------------------------------------------------------------- |
| `GITHUB_APP_ID` | yes | Numeric App ID from the GitHub App settings page. |
| `GITHUB_APP_PRIVATE_KEY` | yes | PEM private key (BEGIN/END PRIVATE KEY block). Newlines are tolerant. |
| `GITHUB_APP_INSTALLATION_ID` | yes | The installation id of the App on your org/user. |
To create one:
1. <https://github.com/settings/apps/new> (or
`https://github.com/organizations/<org>/settings/apps/new`).
2. Grant the App these **repository permissions**: `Contents: Read`,
`Issues: Read`, `Pull Requests: Read`, `Metadata: Read`. For Projects v2
add `Organization → Projects: Read`.
3. Generate a private key (downloads a `.pem`).
4. Install the App on your org/user — the URL bar of the post-install page
contains the installation id, e.g. `.../installations/12345678`.
5. Paste the App ID, the PEM contents, and the installation ID into the
variables above.
The connector exchanges the App credentials for a short-lived installation
token (cached in-memory until shortly before expiry) and uses it for all
GitHub calls.
### Common variables
| Variable | Required | Notes |
| ------------------------- | -------- | --------------------------------------------------------------------------------------- |
| `GITHUB_REPOS` | yes | Comma-separated `owner/repo` list, e.g. `octocat/hello-world,octo-org/octo-repo`. |
| `GITHUB_PROJECTS` | no | Comma-separated GitHub Projects (v2). See format below. |
| `GITHUB_WEBHOOK_SECRET` | no | Shared secret to verify `X-Hub-Signature-256`. When unset, signatures are not verified. |
`GITHUB_PROJECTS` accepts entries in either of these forms:
- `owner/number` — e.g. `twentyhq/24,octo/3`. Owner can be an org or a user.
- Full project URL — e.g. `https://github.com/orgs/twentyhq/projects/24` or
`https://github.com/users/octocat/projects/3`.
## Running a sync
In the Twenty UI, open any of the GitHub objects (e.g. **Pull Requests**) and
trigger the matching command from the command palette (`Cmd/Ctrl+K`):
| View | Command | Reads from |
| ----------------- | --------------------- | ----------------- |
| Pull Requests | Fetch Pull Requests | `GITHUB_REPOS` |
| Issues | Fetch Issues | `GITHUB_REPOS` |
| Contributors | Fetch Contributors | `GITHUB_REPOS` |
| Project Items | Fetch Project Items | `GITHUB_PROJECTS` |
Each command iterates over every entry in the relevant variable and shows a
progress bar.
## Webhooks
Point a GitHub repo or App webhook at the public URL of your Twenty server,
path `POST /github/webhook`. Recommended event subscriptions:
- Pull requests
- Pull request reviews
- Issues
- Project (v2) items
Set the same value as `GITHUB_WEBHOOK_SECRET` on both sides to enable HMAC
verification. For local testing, expose your dev server with
[smee.io](https://smee.io/) or `ngrok` and use that URL as the webhook URL on
GitHub.
> **Heads up — raw body required for signatures.** HMAC verification needs
> the original request body bytes. The Twenty SDK currently parses JSON
> requests before handing them to logic functions, so when the runtime
> delivers an already-parsed body the connector logs a warning and rejects
> the delivery instead of silently accepting it. Until the SDK exposes the
> raw bytes for HTTP routes, either leave `GITHUB_WEBHOOK_SECRET` unset (and
> rely on a hard-to-guess `/github/webhook` URL plus IP allow-listing), or
> terminate signature verification at a reverse proxy in front of Twenty.
## How auth resolution works
`src/modules/github/connector/auth.ts` returns a token using the following
order:
1. `GITHUB_TOKEN` (fine-grained PAT, `github_pat_…`) if present. Classic
PATs are rejected at startup.
2. Cached installation token, if still valid.
3. Fresh installation token minted from the GitHub App credentials.
This makes the example easy to try in 30 seconds with a PAT, while still
demonstrating the production-grade GitHub App flow.
## Tests
The app ships with a small integration test suite that runs against a local
`twenty-app-dev-test` container.
```bash
docker run -d --name twenty-app-dev-test \
-p 2021:2021 twentycrm/twenty-app-dev:v2.0.3
cd packages/twenty-apps/community/github-connector
yarn test
```
The suite installs the app into the container, then asserts that every
object/field/logic-function is wired up and that webhook signature
verification behaves correctly.
@@ -1,35 +0,0 @@
{
"name": "github-connector",
"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": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "2.0.0",
"twenty-sdk": "2.0.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

@@ -1,54 +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 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 = process.env.TWENTY_API_URL!;
const apiKey = process.env.TWENTY_API_KEY!;
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'}`,
);
}
}
@@ -1,128 +0,0 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadata = () =>
new MetadataApiClient({
headers: {
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
export async function findObjectByName(name: string) {
const client = metadata();
const result = await client.query({
objects: {
__args: {
filter: { isCustom: { is: true } },
paging: { first: 50 },
},
edges: {
node: {
nameSingular: true,
fields: {
__args: { paging: { first: 500 } },
edges: { node: { name: true, type: true } },
},
},
},
},
});
return result.objects?.edges?.find((e) => e.node.nameSingular === name)?.node;
}
type ExecutionResult = {
data: unknown;
status: string;
duration: number;
error: unknown;
};
export async function findLogicFunctionId(
universalIdentifier: string,
): Promise<string> {
const client = metadata();
const result = await client.query({
findManyLogicFunctions: {
id: true,
universalIdentifier: true,
},
});
const fn = result.findManyLogicFunctions?.find(
(f) => f.universalIdentifier === universalIdentifier,
);
if (!fn) {
throw new Error(`Logic function ${universalIdentifier} not found`);
}
return fn.id;
}
export async function executeLogicFunction(
id: string,
payload: Record<string, unknown>,
): Promise<ExecutionResult> {
const client = metadata();
const result = await client.mutation({
executeOneLogicFunction: {
__args: { input: { id, payload } },
data: true,
status: true,
duration: true,
error: true,
},
});
return result.executeOneLogicFunction as ExecutionResult;
}
const BASE_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2021';
export async function callRoute(
path: string,
body: Record<string, unknown> | string,
options: {
method?: string;
auth?: boolean;
headers?: Record<string, string>;
} = {},
): Promise<{ status: number; data: unknown }> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers ?? {}),
};
if (options.auth) {
headers['Authorization'] = `Bearer ${process.env.TWENTY_API_KEY}`;
}
const res = await fetch(`${BASE_URL}/s${path}`, {
method: options.method ?? 'POST',
headers,
body: typeof body === 'string' ? body : JSON.stringify(body),
});
let data: unknown = null;
try {
data = await res.json();
} catch {
data = await res.text().catch(() => null);
}
return { status: res.status, data };
}
export async function findInstalledApp(universalIdentifier: string) {
const client = metadata();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
return result.findManyApplications?.find(
(app) => app.universalIdentifier === universalIdentifier,
);
}
@@ -1,73 +0,0 @@
import { beforeAll, describe, expect, it } from 'vitest';
import {
executeLogicFunction,
findLogicFunctionId,
} from './helpers/metadata';
const COUNT_PRS_FN_UI = '082227ae-2acc-4320-8d31-62ad6c443da6';
const COUNT_ISSUES_FN_UI = 'd8cc32bf-6be9-44fc-920a-8bba510f045f';
const COUNT_PROJECT_ITEMS_FN_UI = 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b';
const COUNT_CONTRIBUTORS_FN_UI = 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830';
const HANDLE_WEBHOOK_FN_UI = '22b199b3-2851-4a4f-99fd-4e79c188fe7d';
const fnIds: Record<string, string> = {};
beforeAll(async () => {
fnIds.prs = await findLogicFunctionId(COUNT_PRS_FN_UI);
fnIds.issues = await findLogicFunctionId(COUNT_ISSUES_FN_UI);
fnIds.projectItems = await findLogicFunctionId(COUNT_PROJECT_ITEMS_FN_UI);
fnIds.contributors = await findLogicFunctionId(COUNT_CONTRIBUTORS_FN_UI);
fnIds.webhook = await findLogicFunctionId(HANDLE_WEBHOOK_FN_UI);
});
describe('logic functions are wired up', () => {
it('count-prs is reachable and returns the expected payload shape', async () => {
const result = await executeLogicFunction(fnIds.prs, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
if (result.status === 'SUCCESS') {
const data = result.data as {
totalPages: number;
repos: Array<{ owner: string; repo: string; pages: number }>;
};
expect(typeof data.totalPages).toBe('number');
expect(Array.isArray(data.repos)).toBe(true);
}
});
it('count-issues is reachable', async () => {
const result = await executeLogicFunction(fnIds.issues, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
});
it('count-project-items is reachable', async () => {
const result = await executeLogicFunction(fnIds.projectItems, {
body: { projects: [{ owner: 'fixture-org', number: 9999999 }] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
});
it('count-contributors iterates configured repos and returns the per-repo split', async () => {
const result = await executeLogicFunction(fnIds.contributors, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
if (result.status === 'SUCCESS') {
const data = result.data as {
totalPages: number;
repos: Array<{ owner: string; repo: string; pages: number }>;
};
expect(typeof data.totalPages).toBe('number');
expect(Array.isArray(data.repos)).toBe(true);
expect('orgMembers' in (data as Record<string, unknown>)).toBe(false);
}
});
it('handle-github-webhook is registered (but rejects unsigned requests gracefully)', async () => {
expect(fnIds.webhook).toBeDefined();
});
});
@@ -1,124 +0,0 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/universal-identifiers';
import { describe, expect, it } from 'vitest';
import { findInstalledApp, findObjectByName } from './helpers/metadata';
describe('App installation', () => {
it('finds the installed GitHub Connector app', async () => {
const app = await findInstalledApp(APPLICATION_UNIVERSAL_IDENTIFIER);
expect(app).toBeDefined();
expect(app?.name).toMatch(/github/i);
});
});
describe('Contributor object', () => {
it('exists with the expected GitHub-only fields', async () => {
const obj = await findObjectByName('contributor');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('ghLogin');
expect(names).toContain('githubId');
expect(names).toContain('avatarUrl');
expect(names).toContain('contributions');
expect(names).not.toContain('isCoreTeam');
expect(names).not.toContain('discordId');
});
});
describe('PullRequest object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequest');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('githubNumber');
expect(names).toContain('uniqueIdentifier');
expect(names).toContain('url');
expect(names).toContain('state');
expect(names).toContain('mergedAt');
expect(names).toContain('closedAt');
expect(names).toContain('githubCreatedAt');
expect(names).toContain('author');
expect(names).toContain('merger');
expect(names).toContain('reviews');
expect(names).toContain('projectItems');
});
});
describe('PullRequestReviewEvent object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequestReviewEvent');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('githubReviewId');
expect(names).toContain('state');
expect(names).toContain('submittedAt');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('review');
});
});
describe('PullRequestReview object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequestReview');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('reviewKey');
expect(names).toContain('state');
expect(names).toContain('firstSubmittedAt');
expect(names).toContain('lastSubmittedAt');
expect(names).toContain('eventCount');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('reviewEvents');
});
});
describe('Issue object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('issue');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('githubNumber');
expect(names).toContain('uniqueIdentifier');
expect(names).toContain('githubUrl');
expect(names).toContain('state');
expect(names).toContain('labels');
expect(names).toContain('githubCreatedAt');
expect(names).toContain('closedAt');
expect(names).toContain('repo');
expect(names).toContain('author');
expect(names).toContain('projectItems');
});
});
describe('ProjectItem object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('projectItem');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('githubProjectItemId');
expect(names).toContain('status');
expect(names).toContain('sprint');
expect(names).toContain('assignees');
expect(names).toContain('priority');
expect(names).toContain('mainAssignee');
expect(names).toContain('linkedIssue');
expect(names).toContain('linkedPullRequest');
expect(names).toContain('githubUrl');
expect(names).toContain('repo');
});
});
@@ -1,121 +0,0 @@
import { describe, expect, it } from 'vitest';
import { createHmac } from 'crypto';
import {
getRawBodyForSignature,
verifyGitHubSignature,
} from 'src/modules/github/connector/webhook-signature';
const SECRET = 'super-secret-shared-string';
function sign(body: string): string {
return `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`;
}
describe('verifyGitHubSignature', () => {
it('accepts a valid signature', () => {
const body = '{"action":"opened","number":42}';
const result = verifyGitHubSignature({
rawBody: body,
signatureHeader: sign(body),
secret: SECRET,
});
expect(result.ok).toBe(true);
});
it('rejects a tampered body', () => {
const body = '{"action":"opened","number":42}';
const signature = sign(body);
const result = verifyGitHubSignature({
rawBody: body.replace('42', '43'),
signatureHeader: signature,
secret: SECRET,
});
expect(result.ok).toBe(false);
});
it('rejects a wrong secret', () => {
const body = '{"action":"opened","number":42}';
const result = verifyGitHubSignature({
rawBody: body,
signatureHeader: sign(body),
secret: 'other-secret',
});
expect(result.ok).toBe(false);
});
it('rejects a missing header', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: undefined,
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'missing X-Hub-Signature-256 header',
});
});
it('rejects a header without sha256= prefix', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: 'sha1=deadbeef',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'malformed signature header',
});
});
it('rejects when length differs', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: 'sha256=tooshort',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'signature length mismatch',
});
});
});
describe('getRawBodyForSignature', () => {
it('returns the string as-is for string body', () => {
expect(
getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }),
).toBe('{"a":1}');
});
it('decodes base64 bodies', () => {
const original = '{"a":1}';
const b64 = Buffer.from(original, 'utf8').toString('base64');
expect(getRawBodyForSignature({ body: b64, isBase64Encoded: true })).toBe(
original,
);
});
it('returns null for parsed object bodies (raw bytes lost)', () => {
expect(getRawBodyForSignature({ body: { a: 1 } })).toBeNull();
});
it('returns empty string for null/undefined', () => {
expect(getRawBodyForSignature({ body: null })).toBe('');
});
});
describe('verifyGitHubSignature with parsed body', () => {
it('rejects with a clear reason when the runtime parsed the JSON', () => {
const result = verifyGitHubSignature({
rawBody: null,
signatureHeader: 'sha256=deadbeef',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason:
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
});
});
});
@@ -1,66 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_ABOUT_DESCRIPTION,
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/modules/shared/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
aboutDescription: APP_ABOUT_DESCRIPTION,
icon: 'IconBrandGithub',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
screenshots: [
'public/screenshots/app-listing.png',
'public/screenshots/github-dashboard.png',
'public/screenshots/pull-requests-view.png',
'public/screenshots/contributor-stats.png',
],
applicationVariables: {
GITHUB_TOKEN: {
universalIdentifier: 'fb1d2e91-3a75-4c89-9d6b-1e2f7a4c5d8e',
description:
'Fine-grained Personal Access Token (github_pat_…) from https://github.com/settings/personal-access-tokens — needs Read-only access to Contents, Issues, Pull requests and Metadata (and Organization → Projects for Projects v2). Classic PATs are not supported. When set, takes precedence over the GitHub App credentials below.',
isSecret: true,
},
GITHUB_APP_ID: {
universalIdentifier: '2a196d91-4c1b-4f8d-bc34-6693fcdaa771',
description:
'GitHub App ID. Used together with GITHUB_APP_PRIVATE_KEY and GITHUB_APP_INSTALLATION_ID when GITHUB_TOKEN is unset.',
isSecret: false,
},
GITHUB_APP_PRIVATE_KEY: {
universalIdentifier: 'c5591dd4-f653-4b92-bec9-970ddc8e10cc',
description: 'GitHub App PEM private key (BEGIN/END PRIVATE KEY block).',
isSecret: true,
},
GITHUB_APP_INSTALLATION_ID: {
universalIdentifier: '0c39a59a-ee2e-49a9-88c7-3fbe6cb04ad0',
description: 'GitHub App installation ID for your org.',
isSecret: false,
},
GITHUB_WEBHOOK_SECRET: {
universalIdentifier: 'b9f3c2d8-1e7a-4d56-9c8b-3a2f1e5d7c9a',
description:
'Shared secret used to verify the X-Hub-Signature-256 HMAC on incoming GitHub webhooks. When unset, signature verification is skipped (use only in dev/test).',
isSecret: true,
},
GITHUB_REPOS: {
universalIdentifier: '7d1e9c84-2f63-4a58-9b0d-5e8a3c1f7b29',
description:
'Comma-separated list of `owner/repo` to sync (e.g. `twentyhq/twenty,octo/hello`). Used by the manual fetch routes.',
isSecret: false,
},
GITHUB_PROJECTS: {
universalIdentifier: 'e3a8c7d2-4b95-4e1f-8a6c-9d2b5f7e1c84',
description:
'Comma-separated list of GitHub Projects (v2) to sync. Each entry is `owner/number` (e.g. `twentyhq/24,octo/3`). Owner can be an organization or a user. Full URLs like `https://github.com/orgs/twentyhq/projects/24` or `https://github.com/users/octo/projects/3` are also accepted.',
isSecret: false,
},
},
});
@@ -1,16 +0,0 @@
import { defineRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/modules/shared/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,122 +0,0 @@
import { createSign } from 'crypto';
export function normalizePem(raw: string): string {
let pem = raw.replace(/\\n/g, '\n').trim();
if (!pem.includes('\n')) {
const beginMatch = pem.match(/^(-----BEGIN [A-Z ]+-----)/);
const endMatch = pem.match(/(-----END [A-Z ]+-----)$/);
if (beginMatch && endMatch) {
const header = beginMatch[1];
const footer = endMatch[1];
const body = pem.slice(header.length, pem.length - footer.length);
const lines = body.match(/.{1,64}/g) ?? [];
pem = [header, ...lines, footer].join('\n');
}
}
return pem;
}
function base64url(input: Buffer | string): string {
const buf = typeof input === 'string' ? Buffer.from(input) : input;
return buf.toString('base64url');
}
function signJwt(appId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = base64url(
JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }),
);
const signature = createSign('RSA-SHA256')
.update(`${header}.${payload}`)
.sign(privateKey, 'base64url');
return `${header}.${payload}.${signature}`;
}
type CachedToken = { token: string; expiresAt: number };
let cached: CachedToken | null = null;
const TOKEN_MARGIN_MS = 5 * 60 * 1000;
async function requestInstallationToken(
appId: string,
privateKey: string,
installationId: string,
): Promise<CachedToken> {
const jwt = signJwt(appId, privateKey);
const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
},
);
if (!res.ok) {
const body = await res.text();
throw new Error(
`GitHub App token exchange failed (${res.status}): ${body}`,
);
}
const data = (await res.json()) as {
token: string;
expires_at: string;
};
return {
token: data.token,
expiresAt: new Date(data.expires_at).getTime(),
};
}
function assertFineGrainedPat(token: string): void {
if (token.startsWith('github_pat_')) return;
throw new Error(
'GITHUB_TOKEN must be a fine-grained Personal Access Token (starts with `github_pat_`). Classic PATs are not supported — create one at https://github.com/settings/personal-access-tokens.',
);
}
export async function getGitHubToken(): Promise<string> {
const pat = process.env.GITHUB_TOKEN?.trim();
if (pat) {
assertFineGrainedPat(pat);
return pat;
}
if (cached && Date.now() < cached.expiresAt - TOKEN_MARGIN_MS) {
return cached.token;
}
const appId = process.env.GITHUB_APP_ID;
const rawKey = process.env.GITHUB_APP_PRIVATE_KEY;
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
if (!appId || !rawKey || !installationId) {
const missing = [
!appId && 'GITHUB_APP_ID',
!rawKey && 'GITHUB_APP_PRIVATE_KEY',
!installationId && 'GITHUB_APP_INSTALLATION_ID',
]
.filter(Boolean)
.join(', ');
throw new Error(
`Missing GitHub credentials. Set GITHUB_TOKEN (fine-grained PAT) or all of: ${missing}.`,
);
}
const privateKey = normalizePem(rawKey);
cached = await requestInstallationToken(appId, privateKey, installationId);
return cached.token;
}
@@ -1,55 +0,0 @@
export type GithubRepo = { owner: string; repo: string };
export function parseGithubRepo(entry: string): GithubRepo | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const match = trimmed.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
if (!match) return null;
return { owner: match[1], repo: match[2] };
}
export function getGithubRepos(): string[] {
const raw = process.env.GITHUB_REPOS ?? '';
return raw
.split(',')
.map(parseGithubRepo)
.filter((r): r is GithubRepo => r !== null)
.map((r) => `${r.owner}/${r.repo}`);
}
export type GithubProject = { owner: string; number: number };
export function parseGithubProject(entry: string): GithubProject | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(
/github\.com\/(?:orgs|users)\/([^/]+)\/projects\/(\d+)/i,
);
if (urlMatch) {
const owner = urlMatch[1];
const number = Number.parseInt(urlMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
const shortMatch = trimmed.match(/^([^/\s]+)\/(\d+)$/);
if (shortMatch) {
const owner = shortMatch[1];
const number = Number.parseInt(shortMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
return null;
}
export function getGithubProjects(): GithubProject[] {
const raw = process.env.GITHUB_PROJECTS ?? '';
return raw
.split(',')
.map(parseGithubProject)
.filter((p): p is GithubProject => p !== null);
}
@@ -1,45 +0,0 @@
import {
getGithubRepos,
parseGithubRepo,
} from 'src/modules/github/connector/config';
export type RepoCount = {
owner: string;
repo: string;
totalCount: number;
pages: number;
};
export type CountAcrossReposResult = {
totalPages: number;
repos: RepoCount[];
};
const PAGE_SIZE = 100;
export async function countAcrossRepos(
bodyRepos: string[] | undefined,
count: (owner: string, repo: string) => Promise<number>,
logTag: string,
): Promise<CountAcrossReposResult> {
const repos =
bodyRepos && bodyRepos.length > 0 ? bodyRepos : getGithubRepos();
const results: RepoCount[] = [];
let totalPages = 0;
for (const fullRepo of repos) {
const parsed = parseGithubRepo(fullRepo);
if (!parsed) {
console.warn(`[${logTag}] Skipping malformed repo entry: ${fullRepo}`);
continue;
}
const { owner, repo } = parsed;
const totalCount = await count(owner, repo);
const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
results.push({ owner, repo, totalCount, pages });
totalPages += pages;
}
return { totalPages, repos: results };
}
@@ -1,157 +0,0 @@
import { getGitHubToken } from 'src/modules/github/connector/auth';
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
const LOW_REMAINING_THRESHOLD = 200;
export class GitHubRateLimitError extends Error {
constructor(
message: string,
public readonly kind: 'primary' | 'secondary',
public readonly remaining: number | null,
public readonly limit: number | null,
public readonly resetAt: Date | null,
public readonly retryAfterSeconds: number | null,
) {
super(message);
this.name = 'GitHubRateLimitError';
}
}
function readRateLimitHeaders(res: Response) {
const num = (h: string) => {
const v = res.headers.get(h);
return v === null ? null : Number(v);
};
const reset = num('x-ratelimit-reset');
return {
limit: num('x-ratelimit-limit'),
remaining: num('x-ratelimit-remaining'),
used: num('x-ratelimit-used'),
resource: res.headers.get('x-ratelimit-resource'),
resetAt:
reset === null || Number.isNaN(reset) ? null : new Date(reset * 1000),
retryAfter: num('retry-after'),
};
}
export async function githubGraphql<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
const queryName = query.match(/query\s*\(/)
? query.slice(0, 60).replace(/\s+/g, ' ').trim()
: 'mutation';
console.log(
`[github-gql] Executing: ${queryName} vars=${JSON.stringify(Object.keys(variables))}`,
);
const token = await getGitHubToken();
const res = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
const rl = readRateLimitHeaders(res);
if (rl.remaining !== null && rl.limit !== null) {
const resetIn = rl.resetAt
? Math.max(0, Math.round((rl.resetAt.getTime() - Date.now()) / 1000))
: null;
const isLow =
rl.remaining <= LOW_REMAINING_THRESHOLD ||
rl.remaining / rl.limit < 0.1;
const tag = isLow
? '[github-gql][rate-limit-low]'
: '[github-gql][rate-limit]';
console.log(
`${tag} resource=${rl.resource ?? 'graphql'} remaining=${rl.remaining}/${rl.limit} used=${rl.used ?? '?'} resetIn=${resetIn ?? '?'}s`,
);
}
if (!res.ok) {
const body = await res.text();
const lower = body.toLowerCase();
const isSecondary =
res.status === 403 &&
(lower.includes('secondary rate limit') ||
lower.includes('abuse detection'));
const isPrimary =
(res.status === 403 || res.status === 429) &&
lower.includes('rate limit') &&
!isSecondary;
if (isPrimary || isSecondary) {
throw new GitHubRateLimitError(
`GitHub ${isSecondary ? 'secondary' : 'primary'} rate limit hit (${res.status}): ${body.slice(0, 200)}`,
isSecondary ? 'secondary' : 'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GitHub GraphQL ${res.status} ${res.statusText}: ${body.slice(0, 500)}`,
);
}
const json = (await res.json()) as {
data: T;
errors?: Array<{ type?: string; message: string }>;
};
if (json.errors?.length) {
const isRateLimited = json.errors.some(
(e) => e.type === 'RATE_LIMITED' || /rate limit/i.test(e.message),
);
if (isRateLimited) {
throw new GitHubRateLimitError(
`GitHub GraphQL RATE_LIMITED: ${json.errors.map((e) => e.message).join(', ')}`,
'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GraphQL errors: ${json.errors.map((e) => e.message).join(', ')}`,
);
}
return json.data;
}
export async function githubGraphqlOptional<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T | null> {
try {
return await githubGraphql<T>(query, variables);
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (/Could not resolve to a (User|Organization)/i.test(msg)) return null;
throw err;
}
}
export type GithubPage<T> = {
totalCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: T[];
};
export type Paged<TItems extends Record<string, unknown[]>> = TItems & {
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
export const EMPTY_PAGE = {
totalCount: 0,
hasMore: false,
endCursor: null,
} as const;
@@ -1,5 +0,0 @@
export type GitHubUser = {
login: string;
id: number;
avatar_url?: string | null;
};
@@ -1,15 +0,0 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
import type { GitHubPullRequest } from 'src/modules/github/pull-request/types/github-pull-request';
import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review';
import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue';
import type { GitHubProjectV2Item } from 'src/modules/github/project-item/types/github-project-v2-item';
export type GitHubWebhookPayload = {
action: string;
pull_request?: GitHubPullRequest;
review?: GitHubReview;
issue?: GitHubIssue;
projects_v2_item?: GitHubProjectV2Item;
sender: GitHubUser;
repository?: { full_name: string };
};
@@ -1,54 +0,0 @@
import { createHmac, timingSafeEqual } from 'crypto';
export type SignatureVerificationResult =
| { ok: true }
| { ok: false; reason: string };
export function getRawBodyForSignature(event: {
body: unknown;
isBase64Encoded?: boolean;
}): string | null {
const raw = event.body;
if (raw == null) return '';
if (typeof raw === 'string') {
return event.isBase64Encoded
? Buffer.from(raw, 'base64').toString('utf8')
: raw;
}
return null;
}
export function verifyGitHubSignature({
rawBody,
signatureHeader,
secret,
}: {
rawBody: string | null;
signatureHeader: string | undefined;
secret: string;
}): SignatureVerificationResult {
if (rawBody === null) {
return {
ok: false,
reason:
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
};
}
if (!signatureHeader) {
return { ok: false, reason: 'missing X-Hub-Signature-256 header' };
}
const prefix = 'sha256=';
if (!signatureHeader.startsWith(prefix)) {
return { ok: false, reason: 'malformed signature header' };
}
const provided = signatureHeader.slice(prefix.length);
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
if (provided.length !== expected.length) {
return { ok: false, reason: 'signature length mismatch' };
}
const ok = timingSafeEqual(
Buffer.from(provided, 'utf8'),
Buffer.from(expected, 'utf8'),
);
return ok ? { ok: true } : { ok: false, reason: 'signature mismatch' };
}
@@ -1,282 +0,0 @@
import { type ReactNode } from 'react';
import { THEME } from 'src/modules/github/contributor/components/theme';
export type LeaderboardEntry = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
count: number;
};
const ROW_HEIGHT = 32;
const CELL_PADDING_X = 8;
const RANK_COL_WIDTH = 36;
const COUNT_COL_WIDTH = 72;
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
fontFamily: THEME.fontFamily,
color: THEME.fontPrimary,
background: THEME.bgPrimary,
boxSizing: 'border-box',
overflow: 'hidden',
} as const,
table: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
width: '100%',
} as const,
headerRow: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
borderBottom: `1px solid ${THEME.borderLight}`,
background: THEME.bgPrimary,
} as const,
headerCell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderRight: `1px solid ${THEME.borderLight}`,
color: THEME.fontTertiary,
fontSize: 12,
fontWeight: 500,
boxSizing: 'border-box',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
userSelect: 'none',
} as const,
headerCellLast: {
borderRight: 'none',
} as const,
row: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
width: '100%',
background: THEME.bgPrimary,
color: THEME.fontPrimary,
textDecoration: 'none',
boxSizing: 'border-box',
} as const,
cell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderBottom: `1px solid ${THEME.borderLight}`,
borderRight: `1px solid ${THEME.borderLight}`,
fontSize: 13,
boxSizing: 'border-box',
minWidth: 0,
overflow: 'hidden',
transition: 'background-color 80ms ease',
} as const,
cellLast: {
borderRight: 'none',
} as const,
rankCell: {
width: RANK_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontTertiary,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
} as const,
contributorCell: {
flex: 1,
minWidth: 0,
gap: 6,
} as const,
countCell: {
width: COUNT_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontSecondary,
fontVariantNumeric: 'tabular-nums',
fontWeight: 500,
} as const,
avatar: {
width: 16,
height: 16,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
nameWrapper: {
display: 'flex',
alignItems: 'baseline',
gap: 6,
minWidth: 0,
flex: 1,
lineHeight: 1.2,
} as const,
name: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
login: {
fontSize: 12,
fontWeight: 400,
color: THEME.fontTertiary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
empty: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: THEME.fontTertiary,
fontSize: 13,
padding: 16,
textAlign: 'center',
} as const,
};
type LeaderboardProps = {
contributorColumnLabel: string;
countColumnLabel: string;
entries: LeaderboardEntry[];
loading: boolean;
error: string | null;
emptyMessage?: string;
};
const renderState = (content: ReactNode) => (
<div style={styles.empty}>{content}</div>
);
const Row = ({
rank,
entry,
countLabel,
}: {
rank: number;
entry: LeaderboardEntry;
countLabel: string;
}) => {
const label = entry.name ?? entry.ghLogin ?? 'Unknown';
const profileHref = entry.ghLogin
? `https://github.com/${entry.ghLogin}`
: undefined;
const onEnter = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgSecondary;
};
const onLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgPrimary;
};
const cells = (
<>
<div style={{ ...styles.cell, ...styles.rankCell }}>{rank}</div>
<div style={{ ...styles.cell, ...styles.contributorCell }}>
{entry.avatarUrl ? (
<img src={entry.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameWrapper}>
<span style={styles.name}>{label}</span>
{entry.ghLogin && entry.ghLogin !== label && (
<span style={styles.login}>@{entry.ghLogin}</span>
)}
</div>
</div>
<div
style={{ ...styles.cell, ...styles.countCell, ...styles.cellLast }}
title={countLabel}
>
{entry.count}
</div>
</>
);
if (profileHref) {
return (
<a
href={profileHref}
target="_blank"
rel="noopener noreferrer"
style={{ ...styles.row, cursor: 'pointer' }}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{cells}
</a>
);
}
return (
<div style={styles.row} onMouseEnter={onEnter} onMouseLeave={onLeave}>
{cells}
</div>
);
};
export const Leaderboard = ({
contributorColumnLabel,
countColumnLabel,
entries,
loading,
error,
emptyMessage = 'No activity in the last 90 days',
}: LeaderboardProps) => (
<div style={styles.root}>
<div style={styles.headerRow}>
<div style={{ ...styles.headerCell, ...styles.rankCell }}>#</div>
<div style={{ ...styles.headerCell, ...styles.contributorCell }}>
{contributorColumnLabel}
</div>
<div
style={{
...styles.headerCell,
...styles.countCell,
...styles.headerCellLast,
}}
>
{countColumnLabel}
</div>
</div>
{error ? (
renderState(error)
) : loading && entries.length === 0 ? (
renderState('Loading...')
) : entries.length === 0 ? (
renderState(emptyMessage)
) : (
<div style={styles.table}>
{entries.map((entry, i) => (
<Row
key={entry.id}
rank={i + 1}
entry={entry}
countLabel={countColumnLabel}
/>
))}
</div>
)}
</div>
);
@@ -1,23 +0,0 @@
export const THEME = {
fontFamily:
'var(--t-font-family, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
borderRadius: 'var(--t-border-radius-sm, 4px)',
fontPrimary: 'var(--t-font-color-primary)',
fontSecondary: 'var(--t-font-color-secondary)',
fontTertiary: 'var(--t-font-color-tertiary)',
fontLight: 'var(--t-font-color-light)',
borderLight: 'var(--t-border-color-light)',
borderMedium: 'var(--t-border-color-medium)',
bgPrimary: 'var(--t-background-primary)',
bgSecondary: 'var(--t-background-secondary)',
bgTertiary: 'var(--t-background-tertiary)',
bgTransparentLight: 'var(--t-background-transparent-light)',
bgTransparentLighter: 'var(--t-background-transparent-lighter)',
chartAuthored: 'var(--t-color-purple)',
chartMerged: 'var(--t-color-green)',
chartReviewed: 'var(--t-color-blue)',
} as const;
@@ -1,24 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/main-assignee-on-project-item.field';
export const ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'b35aed82-2c01-4f37-b3f9-cf67dc269d2c';
export default defineField({
universalIdentifier:
ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'assignedProjectItems',
label: 'Assigned Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,22 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field';
export const AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'31f185c0-d2a2-47e2-9132-8cbac05f174a';
export default defineField({
universalIdentifier: AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredIssues',
label: 'Authored Issues',
icon: 'IconBug',
relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,23 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/author-on-pull-request.field';
export const AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'c3d4e5f6-2b3c-4d4e-9f6a-7b8c9d0e1f2a';
export default defineField({
universalIdentifier: AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredPrs',
label: 'Authored PRs',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

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