Compare commits

..
Author SHA1 Message Date
Paul Rastoinandprastoin e704ddc1ca Protect serverless migration metadata operations interacting with metadata (#12739)
# Introduction

This migration has been introduced in 0.54 we should determine how we
wanna handle retro-compatibility with this, might not wanna merge this
one latest main 🤔 but only a new 0.54 patch

related to
https://github.com/twentyhq/twenty/issues/12651#issuecomment-2988164122

## Concerns
If a workspace fails this migration that's not a good sign, the metadata
schema should be completely empty since 0.54 `metadata` merge into
`core` migration.
Please review you existing entries in the schema and verify they exists
in the dest `core` one
2025-06-19 17:18:59 +02:00
17700 changed files with 392712 additions and 1551515 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"install": "curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs && node --version && yarn install && echo 'Setting up Docker Compose environment...' && cd packages/twenty-docker && cp -n docker-compose.yml docker-compose.dev.yml || true && echo 'Dependencies installed and docker-compose prepared'",
"install": "yarn install && echo 'Setting up Docker Compose environment...' && cd packages/twenty-docker && cp -n docker-compose.yml docker-compose.dev.yml || true && echo 'Dependencies installed and docker-compose prepared'",
"start": "sudo service docker start && echo 'Docker service started' && cd packages/twenty-docker && echo 'Installing yq for YAML processing...' && sudo apt-get update -qq && sudo apt-get install -y wget && wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq && echo 'Patching docker-compose for local development...' && yq eval 'del(.services.server.image)' -i docker-compose.dev.yml && yq eval '.services.server.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.server.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && yq eval 'del(.services.worker.image)' -i docker-compose.dev.yml && yq eval '.services.worker.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.worker.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && echo 'Setting up .env file with database configuration...' && echo 'SERVER_URL=http://localhost:3000' > .env && echo 'APP_SECRET='$(openssl rand -base64 32) >> .env && echo 'PG_DATABASE_PASSWORD='$(openssl rand -hex 16) >> .env && echo 'PG_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres' >> .env && echo 'SIGN_IN_PREFILLED=true' >> .env && echo 'Building and starting services...' && docker-compose -f docker-compose.dev.yml up -d --build && echo 'Waiting for services to initialize...' && sleep 30 && echo 'Checking service health...' && docker-compose -f docker-compose.dev.yml ps && echo 'Environment setup complete!'",
"terminals": [
{
+3 -3
View File
@@ -1,10 +1,10 @@
{
"install": "curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs && node --version && yarn install && echo 'Installing dependencies complete'",
"start": "sudo service docker start && echo 'Docker service started' && sleep 3 && echo 'Starting PostgreSQL and Redis containers...' && make postgres-on-docker && make redis-on-docker && echo 'Waiting for containers to initialize...' && sleep 20 && echo 'Checking container status...' && docker ps --filter name=twenty_ && echo 'Waiting for PostgreSQL to be ready...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'PostgreSQL not ready yet, waiting...'; sleep 3; done && echo 'PostgreSQL is ready!' && echo 'Setting up database...' && cd packages/twenty-server && npx nx database:reset twenty-server || echo 'Database already initialized' && echo 'Environment setup complete!'",
"install": "yarn install && echo 'Installing dependencies complete'",
"start": "sudo service docker start && echo 'Docker service started' && sleep 3 && echo 'Starting PostgreSQL and Redis containers...' && make postgres-on-docker && make redis-on-docker && echo 'Waiting for containers to initialize...' && sleep 20 && echo 'Checking container status...' && docker ps --filter name=twenty_ && echo 'Waiting for PostgreSQL to be ready...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'PostgreSQL not ready yet, waiting...'; sleep 3; done && echo 'PostgreSQL is ready!' && echo 'Setting up database...' && cd packages/twenty-server && npm run database:init || echo 'Database already initialized' && echo 'Environment setup complete!'",
"terminals": [
{
"name": "Development Server",
"command": "echo 'Waiting for database to be fully ready...' && sleep 30 && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'Starting Twenty development server...' && export SERVER_URL=http://localhost:3000 && export PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres && yarn start"
"command": "echo 'Waiting for database to be fully ready...' && sleep 30 && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'Starting Twenty development server...' && export SERVER_URL=http://localhost:3000 && export PG_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres && yarn start"
},
{
"name": "Database Management",
+7 -14
View File
@@ -1,7 +1,7 @@
---
description: Twenty CRM development rules and best practices
globs: []
alwaysApply: true
description:
globs:
alwaysApply: false
---
# Twenty Development Rules
@@ -12,8 +12,6 @@ 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** - 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
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
@@ -40,10 +38,9 @@ You can manually reference any rule using the `@ruleName` syntax:
- `@nx-rules` - Include Nx-specific guidance
- `@react-general-guidelines` - Load React best practices
- `@testing-guidelines` - Get testing recommendations
- `@creating-syncable-entity` - Guide for creating new syncable entities
### Rule Types Used
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
- **Auto Attached** - Loaded when matching file patterns are referenced
- **Agent Requested** - Available for AI to include when relevant
- **Manual** - Only included when explicitly mentioned
@@ -55,12 +52,10 @@ You can manually reference any rule using the `@ruleName` syntax:
# Testing
npx nx test twenty-front # Run unit tests
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:test # Run Storybook tests
npx nx storybook:serve-and-test:static # Run Storybook tests
# Development
npx nx lint:diff-with-main twenty-front # Lint files changed vs main (fastest)
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
npx nx lint twenty-front # Lint all files (slower)
npx nx lint twenty-front # Run linter
npx nx typecheck twenty-front # Type checking
npx nx run twenty-front:graphql:generate # Generate GraphQL types
```
@@ -74,9 +69,7 @@ npx nx run twenty-server:database:migrate:prod # Run migrations
# Development
npx nx run twenty-server:start # Start the server
npx nx lint:diff-with-main twenty-server # Lint files changed vs main (fastest)
npx nx lint:diff-with-main twenty-server --configuration=fix # Auto-fix changed files
npx nx run twenty-server:lint # Lint all files (slower)
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
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
+5
View File
@@ -1,4 +1,9 @@
---
description:
globs:
alwaysApply: false
---
---
description: Twenty CRM architecture overview - monorepo structure, tech stack, and development principles
globs: []
alwaysApply: true
-324
View File
@@ -1,324 +0,0 @@
---
description: Process and guidelines for creating release changelogs for Twenty CRM
globs: ["**/releases/*.mdx", "**/releases/**"]
alwaysApply: false
---
# Twenty Release Changelog Process
Complete guide for creating release changelogs, including codebase research, file structure, and content guidelines.
## Prerequisites
Before starting, gather the following information:
### Required Information
**Version Number**: `{VERSION}` (e.g., 1.9.0, 2.0.0, 2.1.0)
**Release Date**: Use today's date in format: YYYY-MM-DD
### Changes/Features to Document
List the features and changes to include in this release:
1. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area (workflow, UI, backend, etc.): ______________________________
2. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area: ______________________________
3. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area: ______________________________
## Codebase Research Guide
If feature descriptions are not provided or need enhancement, research the codebase:
### Where to Look
**For Workflow Features:**
- Frontend: `packages/twenty-front/src/modules/workflow/`
- Backend: `packages/twenty-server/src/modules/workflow/`
- Components: `packages/twenty-front/src/modules/workflow/components/`
**For UI/UX Changes:**
- Components: `packages/twenty-front/src/modules/ui/`
- Layout: `packages/twenty-front/src/modules/layout/`
- Design system: `packages/twenty-ui/src/`
**For Backend/API Features:**
- Server modules: `packages/twenty-server/src/modules/`
- Entities: `packages/twenty-server/src/entities/`
- Services: Look for `*.service.ts` files
**For Database/ORM Changes:**
- Migrations: `packages/twenty-server/src/database/typeorm/`
- Entities: `packages/twenty-server/src/entities/`
### Research Commands
```bash
# Find recent merged PRs (adjust date as needed)
gh pr list --search "merged:>2025-10-01" --limit 50 --state merged
# View recent commits
git log --since="2 weeks ago" --oneline --no-merges
# View commits between releases (replace with actual release tags)
git log v1.7.0..v1.8.0 --oneline
# Search for specific feature keywords in code
grep -r "iterator" packages/twenty-front/src/modules/workflow/
grep -r "bulk select" packages/twenty-front/src/modules/workflow/
# Find recent changes in specific directory
git log --since="2 weeks ago" --oneline -- packages/twenty-front/src/modules/workflow/
```
### Using Codebase Search
Use the AI codebase search to find:
- "How does the workflow iterator node work?"
- "Where is bulk select implemented for workflows?"
- "What changes were made to the search node limit?"
## Step-by-Step Process
### 1. Setup Git Branch
**IMPORTANT**: Always start from an up-to-date main branch to avoid merge conflicts and ensure the changelog is based on the latest code.
```bash
cd /Users/thomascolasdesfrancs/code/twenty
git checkout main
git pull origin main
git checkout -b {VERSION}
```
Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
⚠️ **Do this first** before making any file changes. This ensures your branch is based on the latest main.
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
**Create image folder:**
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
```bash
# Create the image folder
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.png`
Examples:
- `1.9.0-feature-name.png`
- `1.9.0-another-feature.png`
```bash
# Move and rename files
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
```
### 4. Research Features (if needed)
If descriptions are not provided:
1. Use the research commands above to find recent PRs and commits
2. Search the codebase for feature-related code
3. Read PR descriptions for context
4. Check component comments and documentation
### 5. Write Changelog Content
Create the MDX file with this structure:
```markdown
---
release: {VERSION}
Date: {YYYY-MM-DD}
---
# Feature 1 Name
Short description explaining what the feature does and why it's useful. Keep it user-focused and concise (1-2 sentences).
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.png)
# Feature 2 Name
Another short description of the second feature.
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.png)
# Feature 3 Name
Description of the third feature.
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-3.png)
```
**Style Guidelines:**
- Use H1 (`#`) for feature names
- Keep descriptions to 1-2 sentences
- Focus on user benefits, not technical implementation
- Use active voice
- Start with what the user can now do
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
**Reference Previous Changelogs:**
- Check `packages/twenty-website/src/content/releases/` for examples
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
### 6. Review
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
```
Review checklist:
- [ ] Version number is correct in frontmatter
- [ ] Date is today's date
- [ ] All features are documented
- [ ] Image paths are correct
- [ ] Image files exist in the folder
- [ ] Descriptions are clear and user-focused
- [ ] Spelling and grammar are correct
### 7. Present Changelog for User Approval
**IMPORTANT**: Before committing and creating the PR, always show the complete changelog content to the user and wait for explicit approval.
**What to show:**
1. Display the full MDX content of the changelog file
2. Confirm that illustration files were moved to the correct location
3. List the image file names and paths
**What to say:**
```
I've created the changelog for version {VERSION}. Here's the content for your review:
[Show full MDX content]
Images moved to:
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.png
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.png
Please review the content. Once you approve, I'll commit the changes and create the pull request.
```
**Wait for user approval before proceeding to step 8.**
Possible user responses:
- "Looks good" / "Approve" / "Create the PR" → Proceed to step 8
- Requests changes → Make the requested edits, then show content again
- Asks questions → Answer them, then wait for approval
### 8. Commit Changes
```bash
# Check status
git status
# Add files
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
# Commit
git commit -m "Add {VERSION} release changelog"
# Push branch
git push -u origin {VERSION}
```
### 9. Create Pull Request
```bash
# Create PR using GitHub CLI
gh pr create \
--title "Release {VERSION}" \
--body "## Release {VERSION}
This release includes:
- Feature 1
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
```
Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
## File Naming Conventions
### Changelog Files
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
- **Convention**: One file per complete version
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
- **Location**: `packages/twenty-website/src/content/releases/`
### Image Folders
- **Format**: `{MAJOR}.{MINOR}/`
- **Convention**: One folder per minor version (shared across patches)
- **Examples**: `1.6/`, `1.7/`, `2.0/`
- **Location**: `packages/twenty-website/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.png`
- **Convention**: Kebab-case descriptive names
- **Examples**:
- `1.8.0-workflow-iterator.png`
- `1.8.0-bulk-select.png`
- `1.9.0-new-feature.png`
## Quick Reference Template
Copy and fill this for each release:
```
VERSION: ___________
DATE: ___________
MINOR_VERSION: ___________
Features to document:
1. ___________________________
2. ___________________________
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
```
## Tips
- **Start early**: Begin documenting features as they're developed
- **User perspective**: Write for users, not developers
- **Be concise**: 1-2 sentences per feature
- **Visual first**: Illustrations should showcase the feature clearly
- **Consistent style**: Match tone and structure of previous changelogs
- **Test links**: Verify all image paths work before committing
- **Research thoroughly**: Use codebase search to understand features deeply
+11 -80
View File
@@ -1,7 +1,7 @@
---
description: Code style guidelines for Twenty CRM
globs: []
alwaysApply: true
description:
globs:
alwaysApply: false
---
# Code Style Guidelines
@@ -30,18 +30,6 @@ type ButtonProps = {}; // Component props suffix with 'Props'
// ✅ Files and directories - kebab-case
// user-profile.component.tsx
// user-profile.styles.ts
// ❌ NEVER use abbreviations in variable names
// Bad
const users = data.map((u) => u.name);
const field = items.find((f) => f.id === id);
// Good
const users = data.map((user) => user.name);
const field = items.find((item) => item.id === id);
const fieldMetadata = inlineFields.find(
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
);
```
## Import Organization
@@ -71,89 +59,32 @@ const processUserData = (
): ProcessedUser => {
const processedUser = transformUserData(user);
applyOptions(processedUser, options);
if (callback) {
callback(processedUser);
}
return processedUser;
};
```
## Comments
```typescript
// ✅ Use short-form comments, NOT JSDoc blocks
// ✅ Explain business logic and non-obvious intentions (WHY, not WHAT)
// ✅ Explain business logic and non-obvious intentions
// Apply 15% discount for premium users with orders > $100
const discount = isPremiumUser && orderTotal > 100 ? 0.15 : 0;
// TODO: Replace with proper authentication service
const isAuthenticated = localStorage.getItem('token') !== null;
// ✅ Multi-line comments use multiple // lines (NOT /** */ blocks)
// Calculates the total price after applying tax and discount
// Returns the final price that should be charged to the customer
/**
* JSDoc for public APIs
* @param basePrice - The base price before modifications
* @returns The final price after tax and discount
*/
const calculateTotalPrice = (basePrice: number): number => {
// Implementation
};
// ❌ AVOID obvious comments that just describe what code does
// Bad: Get all inline fields dynamically
const { inlineFieldMetadataItems } = useFieldListFieldMetadataItems({...});
// Bad: Define standard fields in display order
const standardFieldOrder = ['startsAt', 'endsAt', 'conferenceLink'];
// Bad: Split fields into standard and custom
const standardFields = standardFieldOrder.map(...)
// ✅ GOOD: Only comment if explaining non-obvious business logic
// Calendar events display standard fields first, then custom fields after participants
// to maintain consistency with the legacy UI behavior
const standardFields = standardFieldOrder.map(...)
// ❌ AVOID JSDoc blocks - use short comments instead
/**
* This style is NOT preferred in this codebase
*/
```
**Comment Guidelines:**
- **DO** comment complex business rules or domain-specific logic
- **DO** comment non-obvious algorithmic decisions
- **DO** add TODOs for future improvements
- **DON'T** comment obvious variable declarations or function calls
- **DON'T** comment what is already clear from well-named variables/functions
- **DON'T** add comments that just repeat what the code says
## Utility Helpers
```typescript
// ✅ Use existing utility helpers instead of manual checks
import { isDefined } from 'twenty-shared/utils';
import { isNonEmptyString, isNonEmptyArray } from '@sniptt/guards';
// ❌ Manual type guards
const validItems = items.filter((item): item is Item => item !== undefined);
const hasValue = value !== null && value !== undefined;
// ✅ Use utility helpers
const validItems = items.filter(isDefined);
const hasValue = isDefined(value);
// Other useful helpers:
// - isDefined(value) - checks !== null && !== undefined
// - isNonEmptyString(value) - checks string is defined and not empty
// - isNonEmptyArray(value) - checks array is defined and has items
```
## Security Patterns
```typescript
// ✅ CSV Export: Always apply security first, then formatting
const safeValue = formatValueForCSV(sanitizeValueForCSVExport(userInput));
// ✅ Input validation before processing
const sanitizedInput = validateAndSanitize(userInput);
const result = processData(sanitizedInput);
```
## Error Handling
File diff suppressed because it is too large Load Diff
-100
View File
@@ -1,100 +0,0 @@
---
description: Guidelines for incorporating user feedback and improving cursor rules
globs: []
alwaysApply: true
---
# Feedback Incorporation Guidelines
## Post-Interaction Reflection
After each coding session or significant interaction, the AI should:
### 1. Reflect on User Feedback
- **Identify patterns** in user corrections or suggestions
- **Note recurring issues** that could be prevented with better rules
- **Recognize gaps** in current cursor rules or guidelines
### 2. Suggest Rule Improvements
When user provides feedback that reveals a pattern or preference:
```typescript
// Example feedback patterns to watch for:
// - "We don't use useEffect, handle state changes in event callbacks"
// - "We don't use JSDoc blocks, prefer // comments"
// - "Always use named exports, never default exports"
// - "We prefer functional components over class components"
// - "Use event handlers over useEffect for state updates"
```
### 3. Proactive Rule Suggestions
At the end of interactions, suggest:
```markdown
## 💡 Suggested Cursor Rule Updates
Based on your feedback today, I recommend adding/updating these rules:
**Code Style Rule Update:**
- Add preference for // comments over JSDoc blocks
- Enforce named exports only (no default exports)
**React Guidelines Update:**
- Document preference for event handlers over useEffect
- Add functional components only rule
Would you like me to help incorporate these into your cursor rules?
```
## Implementation Process
### When to Suggest Updates
- User corrects the same type of mistake multiple times
- User explains a codebase-specific preference
- User points out missing functionality or incomplete implementations
- User provides context about existing patterns not captured in rules
### How to Present Suggestions
1. **Summarize the pattern** observed from feedback
2. **Propose specific rule language** that would prevent the issue
3. **Explain the benefit** of codifying this knowledge
4. **Ask for confirmation** before implementing
### Rule Categories to Consider
- **Code Style**: Formatting, naming, comment styles, export patterns
- **React Patterns**: Hook usage, component structure, state management
- **Architecture**: File organization, import patterns, component composition
- **Testing**: Test structure, naming, coverage expectations
- **Performance**: Optimization patterns, anti-patterns to avoid
## Example Feedback Integration
```markdown
## Today's Learning: React State Management Patterns
**User Feedback Received:**
- "We don't use useEffect, handle state changes in event callbacks"
- "We don't use JSDoc blocks, prefer // comments"
- "Always use named exports, never default exports"
**Proposed Rule Additions:**
```typescript
// ✅ React State Updates - Use event handlers, not useEffect
const handleButtonClick = () => {
setData(newData); // Direct state update in event handler
};
// ❌ Avoid useEffect for state updates
// useEffect(() => { setData(newData); }, [trigger]);
// ✅ Named exports only
export const UserComponent = () => {};
export const useUserData = () => {};
```
**Benefits:**
- Prevents useEffect overuse and related bugs
- Ensures consistent export patterns across codebase
- Documents preferred React patterns for the team
```
This approach helps the AI learn from each interaction and continuously improve the development experience.
+3 -3
View File
@@ -1,7 +1,7 @@
---
description: File structure guidelines for Twenty CRM
globs: []
alwaysApply: true
description:
globs:
alwaysApply: false
---
# File Structure Guidelines
+9 -15
View File
@@ -1,7 +1,12 @@
---
description: Guidelines and best practices for working with Nx in the Twenty workspace, including workspace architecture understanding, configuration management, and generator usage.
globs:
alwaysApply: false
---
---
description: Guidelines and best practices for working with Nx in the Twenty workspace, including workspace architecture understanding, configuration management, and generator usage.
globs: ["**/nx.json", "**/project.json", "**/workspace.json"]
alwaysApply: true
alwaysApply: false
---
# Nx Guidelines
@@ -12,12 +17,7 @@ alwaysApply: true
npx nx run twenty-front:build
npx nx run twenty-server:test
# Lint diff with main (recommended - much faster!)
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
# Run target for all projects (slower)
# Run target for all projects
npx nx run-many --target=build --all
npx nx run-many --target=test --projects=twenty-front,twenty-server
@@ -42,24 +42,18 @@ npx nx g @nx/react:component my-component
},
"test": {
"executor": "@nx/jest:jest",
"options": { "jestConfig": "packages/twenty-front/jest.config.mjs" }
"options": { "jestConfig": "packages/twenty-front/jest.config.ts" }
}
}
}
```
## Linting Strategy
For faster development, always prefer linting only changed files:
- Use `npx nx lint:diff-with-main <project>` to lint only files changed vs main branch
- Use `--configuration=fix` to auto-fix issues in changed files
- Only use `npx nx lint <project>` when you need to lint the entire project
## Dependency Graph
```bash
# View project dependencies
npx nx graph
# Check what's affected by changes (runs target on affected projects)
# Check what's affected by changes
npx nx affected --target=test
npx nx affected --target=build --base=main
```
+2 -1
View File
@@ -1,5 +1,6 @@
---
description: React general guidelines for Twenty CRM
description:
globs:
alwaysApply: false
---
# React Guidelines
+2 -1
View File
@@ -1,5 +1,6 @@
---
description: React state management guidelines for Twenty CRM
description:
globs:
alwaysApply: false
---
# React State Management
-29
View File
@@ -1,29 +0,0 @@
---
description: Guidelines for generating and managing TypeORM migrations in twenty-server
globs: [
"packages/twenty-server/src/**/*.entity.ts",
"packages/twenty-server/src/database/typeorm/**/*.ts"
]
alwaysApply: false
---
## Server Migrations (twenty-server)
- **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:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
```
- Replace `[name]` with a descriptive, kebab-case migration name that reflects the change (for example, `add-agent-turn-evaluation`).
- **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.
- **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.
+2 -40
View File
@@ -1,5 +1,6 @@
---
description: Testing guidelines for Twenty CRM
description:
globs:
alwaysApply: false
---
# Testing Guidelines
@@ -69,45 +70,6 @@ beforeEach(() => {
- **Keep tests isolated** - Independent and repeatable
- **70% unit, 20% integration, 10% E2E** - Test pyramid
## Running Tests
### Single Test File Execution
```bash
# ✅ Run a specific test file (PREFERRED - Fast & Efficient)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Key Benefits:
# - Only runs the specific test file (fast)
# - No dependency resolution overhead
# - Immediate feedback for test development
# ✅ Examples:
# Frontend tests (use .test.ts extension)
npx jest packages/twenty-front/src/modules/localization/utils/detection/detectNumberFormat.test.ts --config=packages/twenty-front/jest.config.mjs
# Server tests (use .spec.ts extension)
npx jest packages/twenty-server/src/utils/__test__/is-work-email.spec.ts --config=packages/twenty-server/jest.config.mjs
# ❌ AVOID - This runs ALL tests (slow):
npx nx test twenty-front --testPathPattern=detectNumberFormat.test.ts
# ✅ Run tests in watch mode for development:
npx jest path/to/test.test.ts --config=packages/twenty-front/jest.config.mjs --watch
# ✅ Run with coverage for single file:
npx jest path/to/test.test.ts --config=packages/twenty-front/jest.config.mjs --coverage
```
### Test Suite Execution
```bash
# Run all tests for a project (use sparingly)
npx nx test twenty-front
npx nx test twenty-server
# Run tests matching a pattern
npx jest --testNamePattern="UserService" --config=packages/twenty-front/jest.config.mjs
```
## Common Patterns
```typescript
// Async testing
+2 -1
View File
@@ -1,5 +1,6 @@
---
description: Translation guidelines for Twenty CRM
description:
globs:
alwaysApply: false
---
# Translation Guidelines
+2 -1
View File
@@ -1,5 +1,6 @@
---
description: TypeScript best practices and conventions for the Twenty codebase, including strict typing, naming conventions, and type safety guidelines.
description:
globs:
alwaysApply: false
---
---
-8
View File
@@ -1,8 +0,0 @@
{
"setup-worktree": [
"nvm use",
"yarn",
"cp $ROOT_WORKTREE_PATH/packages/twenty-server/.env packages/twenty-server/.env || true",
"cp $ROOT_WORKTREE_PATH/packages/twenty-front/.env packages/twenty-front/.env || true"
]
}
-1
View File
@@ -2,4 +2,3 @@
.env
node_modules
.nx/cache
packages/twenty-server/.env
+149
View File
@@ -0,0 +1,149 @@
module.exports = {
root: true,
extends: ['plugin:prettier/recommended', 'plugin:lingui/recommended'],
ignorePatterns: ['node_modules'],
plugins: [
'@nx',
'prefer-arrow',
'import',
'unused-imports',
'unicorn',
'lingui',
],
rules: {
'lingui/no-single-variables-to-translate': 'off',
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
extends: ['plugin:@nx/typescript'],
rules: {
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'no-type-imports' },
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
{
files: ['*.js', '*.jsx'],
extends: ['plugin:@nx/javascript'],
rules: {},
},
{
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
env: {
jest: true,
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
{
files: ['**/constants/*.ts', '**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
{
files: ['*.json'],
parser: 'jsonc-eslint-parser',
},
],
};
+199
View File
@@ -0,0 +1,199 @@
var path = require('path');
module.exports = {
extends: [
'plugin:@nx/react',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:storybook/recommended',
'plugin:prettier/recommended',
'plugin:lingui/recommended',
'plugin:@nx/typescript'
],
plugins: ['react-hooks', 'react-refresh', '@nx', 'prefer-arrow', 'import', 'unused-imports', 'unicorn', 'lingui'],
rules: {
'lingui/no-single-variables-to-translate': 'off',
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@tabler/icons-react'],
message: 'Please import icons from `twenty-ui`',
},
{
group: ['react-hotkeys-web-hook'],
importNames: ['useHotkeys'],
message:
'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`',
},
{
group: ['lodash'],
message:
"Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)",
},
],
},
],
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@nx/workspace-effect-components': 'error',
'@nx/workspace-no-hardcoded-colors': 'error',
'@nx/workspace-matching-state-variable': 'error',
'@nx/workspace-sort-css-properties-alphabetically': 'error',
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
'@nx/workspace-no-state-useref': 'error',
'@nx/workspace-component-props-naming': 'error',
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
'@nx/workspace-no-navigate-prefer-link': 'error',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react/jsx-key': 'off',
'react/display-name': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/jsx-props-no-spreading': [
'error',
{
explicitSpread: 'ignore',
},
],
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: 'useRecoilCallback',
},
],
},
},
{
files: ['*.stories.@(ts|tsx|js|jsx)'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
{
files: ['.storybook/main.@(js|cjs|mjs|ts)'],
rules: {
'storybook/no-uninstalled-addons': [
'error',
{
packageJsonLocation: path.resolve(__dirname, './package.json'),
},
],
},
},
{
files: ['*.js', '*.jsx'],
extends: ['plugin:@nx/javascript'],
rules: {},
},
{
files: [
'*.test.@(ts|tsx|js|jsx)',
],
env: {
jest: true,
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
{
files: ['**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
{
files: ['*.json'],
parser: 'jsonc-eslint-parser',
},
],
};
-28
View File
@@ -1,28 +0,0 @@
* text=auto eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.sh text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
# Patch files may have mixed line endings by design
*.patch -text
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
+5 -5
View File
@@ -1,21 +1,21 @@
# Contributing to Twenty
Thanks for considering contributing to Twenty!
Thanks for considering contributing to Twenty!
Please make sure to go through the [documentation](https://docs.twenty.com) before.
Please make sure to go through the [documentation](https://docs.twenty.com) before.
<br>
## Good first issues
Good first issues are a great way to start contributing and get familiar with the codebase. You can find them on by filtering on the [good first issue](https://github.com/twentyhq/twenty/labels/good%20first%20issue) label.
Good first issues are a great way to start contributing and get familiar with the codebase. You can find them on by filtering on the [good first issue](https://github.com/twentyhq/twenty/labels/good%20first%20issue) label.
## Issue assignment
To avoid conflicts, we follow these guidelines:
1. For `Good First Issue` and `Experienced Contributor` issues without `size: long` labels, we'll merge the first PRs that meet our [code quality standards](https://docs.twenty.com/developers). **We don't assign contributors to these issues**. For `priority: high` issues, our core team will step in within days if no adequate contributions are received.
1. For `Good First Issue` and `Experienced Contributor` issues without `size: long` labels, we'll merge the first PRs that meet our [code quality standards](https://twenty.com/developers). **We don't assign contributors to these issues**. For `priority: high` issues, our core team will step in within days if no adequate contributions are received.
2. For `size: long` Issues, assigned contributors have one week to submit their first draft PR.
## How to Contribute
@@ -63,4 +63,4 @@ git push origin your-branch-name
## Reporting Issues
If you face any issues or have suggestions, please feel free to [create an issue on Twenty's GitHub repository](https://github.com/twentyhq/twenty/issues/new). Please provide as much detail as possible.
If you face any issues or have suggestions, please feel free to (create an issue on Twenty's GitHub repository)[https://github.com/twentyhq/twenty/issues/new]. Please provide as much detail as possible.
@@ -27,7 +27,7 @@ Having a list that is draggable will be useful, not only in dropdown.
Create a folder @/ui/draggable-list with a DraggableList component
This component should take as prop: itemsComponents, onDragEnd((previousIndex, nextIndex) => {})
Use this component in ObjectOptionsDropdownHiddenFieldsContent (move the logic from ObjectOptionsDropdownHiddenFieldsContent to DraggableList) by passing a list of DraggableMenuItems
Use this component in ViewFieldsVisibilityDropdownSection (move the logic from ViewFieldsVisibilityDropdownSection to DraggableList) by passing a list of DraggableMenuItems
Add a storybook test on this list (we don't know how to actually test the draggable behavior, but we can at least make sure the component renders correctly a list of items)
```
-35
View File
@@ -1,35 +0,0 @@
name: Restore cache
inputs:
key:
required: true
description: Prefix to the cache key
additional-paths:
required: false
outputs:
cache-primary-key:
description: actions/cache/restore cache-primary-key outputs proxy
value: ${{ steps.restore-cache.outputs.cache-primary-key }}
cache-hit:
description: String bool indicating whether cache has been directly or indirectly hit
value: ${{ steps.restore-cache.outputs.cache-hit == 'true' || steps.restore-cache.outputs.cache-matched-key != '' }}
runs:
using: composite
steps:
- name: Cache primary key builder
id: cache-primary-key-builder
shell: bash
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@v4
id: restore-cache
with:
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
restore-keys: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-
path: |
.cache
.nx
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
-21
View File
@@ -1,21 +0,0 @@
name: Save cache
inputs:
key:
required: true
description: Primary key to the cache, should be retrieved from `cache-restore` composite action outputs.
additional-paths:
required: false
runs:
using: "composite"
steps:
- name: Save cache
uses: actions/cache/save@v4
with:
key: ${{ inputs.key }}
path: |
.cache
.nx
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
-42
View File
@@ -1,42 +0,0 @@
name: Yarn Install
inputs:
node-version:
required: false
default: '24'
runs:
using: 'composite'
steps:
- name: Cache primary key builder
id: globals
shell: bash
run: |
echo "ACTION_SHELL=bash" >> "${GITHUB_OUTPUT}"
echo "CACHE_KEY_PREFIX=node_modules-cache-node-${{ inputs.node-version }}-${{ hashFiles('yarn.lock') }}" >> "${GITHUB_OUTPUT}"
echo 'PATH_TO_CACHE<<EOF' >> $GITHUB_OUTPUT
echo "node_modules" >> $GITHUB_OUTPUT
echo "packages/*/node_modules" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
- name: Setup Node.js and get yarn cache
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Restore node_modules
id: cache-node-modules
uses: actions/cache/restore@v4
with:
key: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
- name: Install Dependencies
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
shell: ${{ steps.globals.outputs.ACTION_SHELL }}
run: |
yarn config set enableHardenedMode true
yarn --immutable --check-cache
- name: Save cache
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
uses: actions/cache/save@v4
with:
key: ${{ steps.cache-node-modules.outputs.cache-primary-key }}
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
-23
View File
@@ -1,23 +0,0 @@
#
# Crowdin CLI configuration for App translations (twenty-front, twenty-server, twenty-emails)
# Project ID: 1
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
"preserve_hierarchy": true
"base_path": ".."
files: [
{
#
# Source files filter - PO files for Lingui
#
"source": "**/en.po",
#
# Translation files path
#
"translation": "%original_path%/%locale%.po",
}
]
-45
View File
@@ -1,45 +0,0 @@
#
# Crowdin CLI configuration for Documentation translations
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
"project_id": 2
"preserve_hierarchy": true
"base_url": "https://twenty.api.crowdin.com"
"base_path": ".."
files: [
{
#
# MDX documentation files - user-guide
# Using md type to preserve JSX component structure
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
#
"source": "packages/twenty-docs/user-guide/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
},
{
#
# MDX documentation files - developers
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/developers/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
},
{
#
# MDX documentation files - twenty-ui
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
},
{
#
# Navigation labels template - translated into per-locale navigation.json
#
"source": "packages/twenty-docs/navigation/navigation.template.json",
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
}
]
-17
View File
@@ -1,17 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
exclude-paths:
- "packages/twenty-apps/community/**"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
versioning-strategy: "lockfile-only"
assignees:
- "mabdullahabaid"
ignore:
- dependency-name: "@graphql-yoga/nestjs"
- dependency-name: "@nestjs/graphql"
- dependency-name: "@ptc-org/nestjs-query-graphql"
- dependency-name: "typeorm"
@@ -0,0 +1,35 @@
name: Restore cache
inputs:
key:
required: true
description: Prefix to the cache key
additional-paths:
required: false
outputs:
cache-primary-key:
description: actions/cache/restore cache-primary-key outputs proxy
value: ${{ steps.restore-cache.outputs.cache-primary-key }}
cache-hit:
description: String bool indicating whether cache has been directly or indirectly hit
value: ${{ steps.restore-cache.outputs.cache-hit == 'true' || steps.restore-cache.outputs.cache-matched-key != '' }}
runs:
using: composite
steps:
- name: Cache primary key builder
id: cache-primary-key-builder
shell: bash
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v3-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@v4
id: restore-cache
with:
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
restore-keys: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-
path: |
.cache
.nx/cache
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
@@ -0,0 +1,21 @@
name: Save cache
inputs:
key:
required: true
description: Primary key to the cache, should be retrieved from `cache-restore` composite action outputs.
additional-paths:
required: false
runs:
using: "composite"
steps:
- name: Save cache
uses: actions/cache/save@v4
with:
key: ${{ inputs.key }}
path: |
.cache
.nx/cache
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
@@ -0,0 +1,43 @@
name: Yarn Install
inputs:
node-version:
required: false
default: '22'
runs:
using: "composite"
steps:
- name: Cache primary key builder
id: globals
shell: bash
run: |
echo "ACTION_SHELL=bash" >> "${GITHUB_OUTPUT}"
echo "CACHE_KEY_PREFIX=node_modules-cache-node-${{ inputs.node-version }}-${{ hashFiles('yarn.lock') }}" >> "${GITHUB_OUTPUT}"
echo 'PATH_TO_CACHE<<EOF' >> $GITHUB_OUTPUT
echo "node_modules" >> $GITHUB_OUTPUT
echo "packages/*/node_modules" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
- name: Setup Node.js and get yarn cache
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Restore node_modules
id: cache-node-modules
uses: actions/cache/restore@v4
with:
key: v3-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v3-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
- name: Install Dependencies
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
shell: ${{ steps.globals.outputs.ACTION_SHELL }}
run: |
yarn config set enableHardenedMode true
yarn --immutable --check-cache
- name: Save cache
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
uses: actions/cache/save@v4
with:
key: ${{ steps.cache-node-modules.outputs.cache-primary-key }}
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
+777
View File
@@ -0,0 +1,777 @@
name: GraphQL and OpenAPI Breaking Changes Detection
on:
pull_request:
types: [opened, synchronize, edited]
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MAIN_SERVER_PORT: 3000
CURRENT_SERVER_PORT: 3002
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-server/**
packages/twenty-emails/**
packages/twenty-shared/**
api-breaking-changes:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:latest
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
ports:
- 8123:8123
- 9000:9000
options: >-
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout current branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Build shared dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-emails
- name: Build current branch server
run: npx nx build twenty-server
- name: Setup databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "current_branch";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "main_branch";'
- name: Run ClickHouse migrations
run: npx nx clickhouse:migrate twenty-server
env:
CLICKHOUSE_URL: http://default:clickhousePassword@localhost:8123/twenty
CLICKHOUSE_PASSWORD: clickhousePassword
- name: Setup current branch database
run: |
npx nx reset:env twenty-server
# Function to set or update environment variable
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
set_env_var "REDIS_URL" "redis://localhost:6379"
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed current branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
- name: Start current branch server in background
run: |
echo "=== Current branch .env file contents ==="
cat packages/twenty-server/.env
echo "=== Starting current branch server ==="
nohup npx nx run twenty-server:start:prod > /tmp/current-server.log 2>&1 &
echo $! > /tmp/current-server.pid
echo "Current server PID: $(cat /tmp/current-server.pid)"
- name: Wait for current branch server to be ready
run: |
echo "Waiting for current branch server to start..."
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Current branch server is ready!"
break
fi
echo "Current branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for current branch server to start"
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
run: |
# Admin token from jest-integration.config.ts
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o current-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o current-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download current branch OpenAPI specs
echo "Downloading OpenAPI specifications from current server..."
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Current branch files downloaded:"
ls -la current-*
- name: Preserve current branch files
run: |
# Create a temp directory to store current branch files
mkdir -p /tmp/current-branch-files
# Move current branch files to temp directory
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
echo "Preserved current branch files for later restoration"
- name: Stop current branch server
run: |
if [ -f /tmp/current-server.pid ]; then
echo "Stopping current branch server..."
kill $(cat /tmp/current-server.pid) || true
# Wait a bit for graceful shutdown
sleep 5
# Force kill if still running
kill -9 $(cat /tmp/current-server.pid) 2>/dev/null || true
rm -f /tmp/current-server.pid
fi
- name: Checkout main branch
run: |
git stash
git checkout origin/main
git clean -fd
- name: Install dependencies for main branch
uses: ./.github/workflows/actions/yarn-install
- name: Build main branch dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-emails
- name: Build main branch server
run: npx nx build twenty-server
- name: Setup main branch database
run: |
# Function to set or update environment variable
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed main branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
- name: Start main branch server in background
run: |
echo "=== Main branch .env file contents ==="
cat packages/twenty-server/.env
echo "=== Starting main branch server ==="
nohup npx nx run twenty-server:start:prod > /tmp/main-server.log 2>&1 &
echo $! > /tmp/main-server.pid
echo "Main server PID: $(cat /tmp/main-server.pid)"
- name: Wait for main branch server to be ready
run: |
echo "Waiting for main branch server to start..."
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Main branch server is ready!"
break
fi
echo "Main branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for main branch server to start"
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
run: |
# Admin token from jest-integration.config.ts
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o main-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o main-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download main branch OpenAPI specs
echo "Downloading OpenAPI specifications from main server..."
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Main branch files downloaded:"
ls -la main-*
- name: Restore current branch files
run: |
# Move current branch files back to working directory
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
# Verify all files are present
echo "All API files restored:"
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
# Clean up temp directory
rm -rf /tmp/current-branch-files
- name: Install OpenAPI Diff Tool
run: |
# Using the Java-based OpenAPITools/openapi-diff via Docker
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
npm install -g @graphql-inspector/cli
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
# Check if GraphQL schema has changes
echo "Checking GraphQL schema for changes..."
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
echo "⚠️ **Breaking changes or errors detected in GraphQL schema**" >> graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
echo "\`\`\`" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json 2>&1 >> graphql-schema-diff.md || echo "Error generating diff" >> graphql-schema-diff.md
echo "\`\`\`" >> graphql-schema-diff.md
}
fi
# Check if GraphQL metadata schema has changes
echo "Checking GraphQL metadata schema for changes..."
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL metadata schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
echo "⚠️ **Breaking changes or errors detected in GraphQL metadata schema**" >> graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
echo "\`\`\`" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json 2>&1 >> graphql-metadata-diff.md || echo "Error generating diff" >> graphql-metadata-diff.md
echo "\`\`\`" >> graphql-metadata-diff.md
}
fi
# Show summary
echo "Generated diff files:"
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff via Docker
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-api-diff.json \
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
different=$(jq -r '.different // false' rest-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
echo "" >> rest-api-diff.md
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
else "" end,
if (.newEndpoints | length) > 0 then
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end
' rest-api-diff.json >> rest-api-diff.md
elif [ "$different" = "true" ]; then
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed)"
# Generate non-breaking changes report
echo "# REST API Changes" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "## Summary" >> rest-api-diff.md
jq -r '
if (.newEndpoints | length) > 0 then
"### ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n### 🔄 Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- " + .method + " " + .pathUrl + ": " + (.summary // "Modified operation")) | join("\n"))
else "" end
' rest-api-diff.json >> rest-api-diff.md
else
echo "✅ No changes detected in REST API"
# Don't create diff file for no changes
fi
else
echo "⚠️ OpenAPI diff tool could not process the files"
echo "# REST API Analysis Error" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
echo "" >> rest-api-diff.md
echo "## Error Output" >> rest-api-diff.md
echo "\`\`\`" >> rest-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST API analysis tool error - continuing workflow"
fi
- name: Check REST Metadata API Breaking Changes
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff for metadata API as well
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-metadata-api-diff.json \
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
# Generate breaking changes report
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
else "" end,
if (.newEndpoints | length) > 0 then
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end
' rest-metadata-api-diff.json >> rest-metadata-api-diff.md
elif [ "$different" = "true" ]; then
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed)"
# Generate non-breaking changes report
echo "# REST Metadata API Changes" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "## Summary" >> rest-metadata-api-diff.md
jq -r '
if (.newEndpoints | length) > 0 then
"### ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n### 🔄 Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- " + .method + " " + .pathUrl + ": " + (.summary // "Modified operation")) | join("\n"))
else "" end
' rest-metadata-api-diff.json >> rest-metadata-api-diff.md
else
echo "✅ No changes detected in REST Metadata API"
# Don't create diff file for no changes
fi
else
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "## Error Output" >> rest-metadata-api-diff.md
echo "\`\`\`" >> rest-metadata-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-metadata-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Comment API Changes on PR
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let hasChanges = false;
let comment = '';
try {
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
}
}
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += restDiff + '\n\n';
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += metadataDiff + '\n\n';
}
}
// Only post comment if there are changes
if (hasChanges) {
// Check if there are any breaking changes detected
let hasBreakingChanges = false;
let breakingChangeNote = '';
// Check for breaking changes in any of the diff files
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
// Also check GraphQL changes for breaking changes indicators
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
// Check PR title for "breaking"
const prTitle = "${{ github.event.pull_request.title }}";
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
if (hasBreakingChanges) {
if (titleContainsBreaking) {
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
} else {
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
'🔄 **Options**:\n' +
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
}
}
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const commentBody = COMMENT_MARKER + '\n' + comment + '⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
// Get all comments to find existing API changes comment
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
// Find our existing comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing API changes comment');
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
console.log('Created new API changes comment');
}
} else {
console.log('No API changes detected - skipping PR comment');
// Check if there's an existing comment to remove
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted existing API changes comment (no changes detected)');
}
}
} catch (error) {
console.log('Could not post comment:', error);
}
- name: Cleanup servers
if: always()
run: |
if [ -f /tmp/current-server.pid ]; then
kill $(cat /tmp/current-server.pid) || true
fi
if [ -f /tmp/main-server.pid ]; then
kill $(cat /tmp/main-server.pid) || true
fi
- name: Upload API specifications and diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: api-specifications-and-diffs
path: |
/tmp/main-server.log
/tmp/current-server.log
*-api.json
*-schema-introspection.json
*-diff.md
*-diff.json
-5
View File
@@ -1,13 +1,8 @@
name: CD deploy main
permissions:
contents: read
on:
push:
branches:
- main
jobs:
deploy-main:
timeout-minutes: 3
-5
View File
@@ -1,13 +1,8 @@
name: CD deploy tag
permissions:
contents: read
on:
push:
tags:
- 'v*'
jobs:
deploy-tag:
timeout-minutes: 3
-4
View File
@@ -1,5 +1,4 @@
name: Changed files reusable workflow
on:
workflow_call:
inputs:
@@ -10,9 +9,6 @@ on:
any_changed:
value: ${{ jobs.changed-files.outputs.any_changed }}
permissions:
contents: read
jobs:
changed-files:
timeout-minutes: 5
-786
View File
@@ -1,786 +0,0 @@
name: GraphQL and OpenAPI Breaking Changes Detection
on:
pull_request:
types: [opened, synchronize, edited]
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
MAIN_SERVER_PORT: 3000
CURRENT_SERVER_PORT: 3002
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-server/**
packages/twenty-emails/**
packages/twenty-shared/**
.github/workflows/ci-breaking-changes.yaml
api-breaking-changes:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
ports:
- 8123:8123
- 9000:9000
options: >-
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout current branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Try to merge main into current branch
id: merge_attempt
run: |
echo "Attempting to merge main into current branch..."
git fetch origin main
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "Current branch: $CURRENT_BRANCH"
if git merge origin/main --no-edit; then
echo "✅ Successfully merged main into current branch"
echo "merged=true" >> $GITHUB_OUTPUT
echo "BRANCH_STATE=merged" >> $GITHUB_ENV
else
echo "❌ Merge failed due to conflicts"
echo "⚠️ Falling back to comparing current branch against main without merge"
# Abort the failed merge
git merge --abort
echo "merged=false" >> $GITHUB_OUTPUT
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
fi
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build shared dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-emails
- name: Build current branch server
run: npx nx build twenty-server
- name: Setup databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "current_branch";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "main_branch";'
- name: Run ClickHouse migrations
run: npx nx clickhouse:migrate twenty-server
env:
CLICKHOUSE_URL: http://default:clickhousePassword@localhost:8123/twenty
CLICKHOUSE_PASSWORD: clickhousePassword
- name: Setup current branch database
run: |
npx nx reset:env twenty-server
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
echo "" >> "$env_file"
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
set_env_var "REDIS_URL" "redis://localhost:6379"
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed current branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
- name: Start current branch server in background
run: |
echo "=== Current branch .env file contents ==="
cat packages/twenty-server/.env
echo "=== Starting current branch server ==="
nohup npx nx run twenty-server:start:prod > /tmp/current-server.log 2>&1 &
echo $! > /tmp/current-server.pid
echo "Current server PID: $(cat /tmp/current-server.pid)"
- name: Wait for current branch server to be ready
run: |
echo "Waiting for current branch server to start..."
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Current branch server is ready!"
break
fi
echo "Current branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for current branch server to start"
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
run: |
# Read admin token from shared test tokens file (single source of truth)
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o current-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o current-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download current branch OpenAPI specs
echo "Downloading OpenAPI specifications from current server..."
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Current branch files downloaded:"
ls -la current-*
- name: Preserve current branch files
run: |
# Create a temp directory to store current branch files
mkdir -p /tmp/current-branch-files
# Move current branch files to temp directory
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
echo "Preserved current branch files for later restoration"
- name: Stop current branch server
run: |
if [ -f /tmp/current-server.pid ]; then
echo "Stopping current branch server..."
kill $(cat /tmp/current-server.pid) || true
# Wait a bit for graceful shutdown
sleep 5
# Force kill if still running
kill -9 $(cat /tmp/current-server.pid) 2>/dev/null || true
rm -f /tmp/current-server.pid
fi
- name: Checkout main branch
run: |
git stash
git checkout origin/main
git reset --hard
git clean -xfd -ff
rm -rf node_modules packages/*/node_modules packages/*/dist dist .nx/cache
- name: Install dependencies for main branch
uses: ./.github/actions/yarn-install
- name: Build main branch dependencies
run: |
npx nx reset
npx nx build twenty-shared
npx nx build twenty-emails
- name: Build main branch server
run: npx nx build twenty-server
- name: Setup main branch database
run: |
npx nx reset:env twenty-server
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
echo "" >> "$env_file"
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
set_env_var "REDIS_URL" "redis://localhost:6379"
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed main branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
- name: Start main branch server in background
run: |
echo "=== Main branch .env file contents ==="
cat packages/twenty-server/.env
echo "=== Starting main branch server ==="
nohup npx nx run twenty-server:start:prod > /tmp/main-server.log 2>&1 &
echo $! > /tmp/main-server.pid
echo "Main server PID: $(cat /tmp/main-server.pid)"
- name: Wait for main branch server to be ready
run: |
echo "Waiting for main branch server to start..."
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Main branch server is ready!"
break
fi
echo "Main branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for main branch server to start"
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
run: |
# Read admin token from shared test tokens file (single source of truth)
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o main-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o main-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download main branch OpenAPI specs
echo "Downloading OpenAPI specifications from main server..."
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Main branch files downloaded:"
ls -la main-*
- name: Restore current branch files
run: |
# Move current branch files back to working directory
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
# Verify all files are present
echo "All API files restored:"
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
# Clean up temp directory
rm -rf /tmp/current-branch-files
- name: Install OpenAPI Diff Tool
run: |
# Using the Java-based OpenAPITools/openapi-diff via Docker
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
npm install -g @graphql-inspector/cli
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
# Check if GraphQL schema has changes
echo "Checking GraphQL schema for changes..."
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
echo "⚠️ **Breaking changes or errors detected in GraphQL schema**" >> graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
echo "\`\`\`" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json 2>&1 >> graphql-schema-diff.md || echo "Error generating diff" >> graphql-schema-diff.md
echo "\`\`\`" >> graphql-schema-diff.md
}
fi
# Check if GraphQL metadata schema has changes
echo "Checking GraphQL metadata schema for changes..."
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL metadata schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
echo "⚠️ **Breaking changes or errors detected in GraphQL metadata schema**" >> graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
echo "\`\`\`" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json 2>&1 >> graphql-metadata-diff.md || echo "Error generating diff" >> graphql-metadata-diff.md
echo "\`\`\`" >> graphql-metadata-diff.md
}
fi
# Show summary
echo "Generated diff files:"
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff via Docker
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-api-diff.json \
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
different=$(jq -r '.different // false' rest-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
echo "" >> rest-api-diff.md
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
else "" end,
if (.newEndpoints | length) > 0 then
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end
' rest-api-diff.json >> rest-api-diff.md
elif [ "$different" = "true" ]; then
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed) - no PR comment will be posted"
# Don't create markdown file for non-breaking changes to avoid PR comments
else
echo "✅ No changes detected in REST API"
# Don't create diff file for no changes
fi
else
echo "⚠️ OpenAPI diff tool could not process the files"
echo "# REST API Analysis Error" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
echo "" >> rest-api-diff.md
echo "## Error Output" >> rest-api-diff.md
echo "\`\`\`" >> rest-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST API analysis tool error - continuing workflow"
fi
- name: Check REST Metadata API Breaking Changes
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff for metadata API as well
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-metadata-api-diff.json \
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
# Generate breaking changes report (only for breaking changes)
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
else "" end,
if (.newEndpoints | length) > 0 then
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end
' rest-metadata-api-diff.json >> rest-metadata-api-diff.md
elif [ "$different" = "true" ]; then
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed) - no PR comment will be posted"
# Don't create markdown file for non-breaking changes to avoid PR comments
else
echo "✅ No changes detected in REST Metadata API"
fi
else
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "## Error Output" >> rest-metadata-api-diff.md
echo "\`\`\`" >> rest-metadata-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-metadata-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Comment API Changes on PR
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let hasChanges = false;
let comment = '';
try {
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
}
}
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += restDiff + '\n\n';
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += metadataDiff + '\n\n';
}
}
// Only post comment if there are changes
if (hasChanges) {
// Add branch state information only if there were conflicts
const branchState = process.env.BRANCH_STATE || 'unknown';
let branchStateNote = '';
if (branchState === 'conflicts') {
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
}
// Check if there are any breaking changes detected
let hasBreakingChanges = false;
let breakingChangeNote = '';
// Check for breaking changes in any of the diff files
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
// Also check GraphQL changes for breaking changes indicators
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
// Check PR title for "breaking"
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
if (hasBreakingChanges) {
if (titleContainsBreaking) {
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
} else {
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
'🔄 **Options**:\n' +
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
}
}
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
// Get all comments to find existing API changes comment
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
// Find our existing comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing API changes comment');
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
console.log('Created new API changes comment');
}
} else {
console.log('No API changes detected - skipping PR comment');
// Check if there's an existing comment to remove
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted existing API changes comment (no changes detected)');
}
}
} catch (error) {
console.log('Could not post comment:', error);
}
- name: Cleanup servers
if: always()
run: |
if [ -f /tmp/current-server.pid ]; then
kill $(cat /tmp/current-server.pid) || true
fi
if [ -f /tmp/main-server.pid ]; then
kill $(cat /tmp/main-server.pid) || true
fi
- name: Upload API specifications and diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: api-specifications-and-diffs
path: |
/tmp/main-server.log
/tmp/current-server.log
*-api.json
*-schema-introspection.json
*-diff.md
*-diff.json
@@ -0,0 +1,48 @@
name: CI Chrome Extension
on:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-chrome-extension/**
chrome-extension-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
env:
VITE_SERVER_BASE_URL: http://localhost:3000
VITE_FRONT_BASE_URL: http://localhost:3001
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Chrome Extension / Run build
run: npx nx build twenty-chrome-extension
ci-chrome-extension-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, chrome-extension-build]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-57
View File
@@ -1,57 +0,0 @@
name: CI Create App
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/**
create-app-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build create-twenty-app
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:create-app
tasks: ${{ matrix.task }}
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+63
View File
@@ -0,0 +1,63 @@
name: CI Demo check
on:
schedule:
- cron: '30 7,19 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
timeout-minutes: 15
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/twenty-e2e-testing
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Install Playwright Browsers
run: yarn playwright install --with-deps
- name: Run Playwright tests
id: test
run: yarn playwright test --grep "@demo-only"
- name: Upload report after tests
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 90
- name: Send Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: 'Demo check ${{ steps.test.outcome }} - check ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
- name: Send email if demo is not working
if: steps.test.outcome == 'failure'
uses: dawidd6/action-send-mail@v3.12.0
with:
connection_url: ${{ secrets.MAIL_CONNECTION }}
server_address: smtp.gmail.com
server_port: 465
secure: true
username: ${{ secrets.MAIL_USERNAME }}
subject: 'Demo is not working'
from: 'Github CI Demo check'
to: ${{ secrets.RECIPIENTS }}
body: '<a href="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}">Link</a>'
priority: high
-47
View File
@@ -1,47 +0,0 @@
name: CI Docs
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-docs/**
eslint.config.mjs
docs-lint:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Docs / Lint English MDX files
run: npx eslint "packages/twenty-docs/{developers,user-guide,twenty-ui,getting-started,snippets}/**/*.mdx" --max-warnings 0
+133
View File
@@ -0,0 +1,133 @@
name: CI E2E Playwright Tests
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/**
playwright.config.ts
.github/workflows/ci-e2e.yaml
test:
runs-on: ubuntu-latest
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true' && ( github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
timeout-minutes: 30
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
# https://github.com/actions/runner-images/issues/70#issuecomment-589562148
NODE_OPTIONS: "--max-old-space-size=10240"
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Check system resources
run: |
echo "Available memory:"
free -h
echo "Available disk space:"
df -h
echo "CPU info:"
lscpu
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Install Playwright Browsers
run: npx nx setup twenty-e2e-testing
- name: Setup environment files
run: |
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env twenty-server
- name: Build frontend
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start frontend
run: |
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
echo "Waiting for frontend to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: packages/twenty-e2e-testing/run_results/
retention-days: 30
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: packages/twenty-e2e-testing/playwright-report/
retention-days: 30
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+10 -12
View File
@@ -1,8 +1,4 @@
name: CI Emails
permissions:
contents: read
on:
push:
branches:
@@ -12,7 +8,7 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
@@ -25,14 +21,16 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build twenty-emails
run: npx nx build twenty-emails
- name: Run email tests
@@ -40,25 +38,25 @@ jobs:
# Start the email server in the background
npx nx run twenty-emails:start &
SERVER_PID=$!
# Wait for server to start
sleep 20
# Check if server is running
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
echo "Email server failed to start"
kill $SERVER_PID
exit 1
fi
# Kill the server
kill $SERVER_PID
ci-emails-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, emails-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
run: exit 1
+46 -206
View File
@@ -1,21 +1,17 @@
name: CI Front and E2E
name: CI Front
on:
push:
branches:
- main
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY: storybook-build-depot-ubuntu-24.04-8-runner
jobs:
changed-files-check:
@@ -23,25 +19,17 @@ jobs:
with:
files: |
package.json
yarn.lock
packages/twenty-front/**
packages/twenty-ui/**
packages/twenty-shared/**
packages/twenty-sdk/**
changed-files-check-e2e:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/**
playwright.config.ts
.github/workflows/ci-front.yaml
front-sb-build:
needs: changed-files-check
needs: [changed-files-check]
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -52,20 +40,25 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Diagnostic disk space issue
run: df -h
- name: Restore storybook build cache
id: restore-storybook-build-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Front / Build storybook
run: npx nx storybook:build twenty-front
- name: Save storybook build cache
uses: ./.github/actions/save-cache
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
key: ${{ steps.restore-storybook-build-cache.outputs.cache-primary-key }}
front-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: front-sb-build
strategy:
fail-fast: false
@@ -75,35 +68,26 @@ jobs:
env:
SHARD_COUNTER: 4
REACT_APP_SERVER_BASE_URL: http://localhost:3000
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-ui
npx nx build twenty-sdk
uses: ./.github/workflows/actions/yarn-install
- name: Install Playwright
run: |
cd packages/twenty-front
npx playwright install
run: cd packages/twenty-front && npx playwright install
- name: Restore storybook build cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }} --checkCoverage=false
- name: Rename coverage file
run: |
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
else
echo "Error: coverage-final.json not found"
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
exit 1
fi
run: mv packages/twenty-front/coverage/storybook/coverage-storybook.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
@@ -112,7 +96,7 @@ jobs:
path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
merge-reports-and-check-coverage:
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs: front-sb-test
env:
PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
@@ -124,7 +108,7 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- uses: actions/download-artifact@v4
with:
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
@@ -138,36 +122,37 @@ jobs:
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
front-chromatic-deployment:
timeout-minutes: 30
if: false
if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push'
needs: front-sb-build
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Restore storybook build cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
- name: Front / Write .env
run: |
cd packages/twenty-front
touch .env
echo "" >> .env
echo "REACT_APP_SERVER_BASE_URL=$REACT_APP_SERVER_BASE_URL" >> .env
echo "REACT_APP_SERVER_BASE_URL: $REACT_APP_SERVER_BASE_URL" >> .env
- name: Publish to Chromatic
run: npx nx run twenty-front:chromatic:ci
front-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
TASK_CACHE_KEY: front-task-${{ matrix.task }}
strategy:
matrix:
@@ -182,171 +167,35 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Restore ${{ matrix.task }} cache
id: restore-task-cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.TASK_CACHE_KEY }}
- name: Reset .env
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:frontend
tasks: reset:env
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:frontend
tasks: ${{ matrix.task }}
- name: Save ${{ matrix.task }} cache
uses: ./.github/actions/save-cache
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
front-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
env:
NODE_OPTIONS: "--max-old-space-size=10240"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Build frontend
run: npx nx build twenty-front
- name: Upload frontend build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: packages/twenty-front/build
retention-days: 1
e2e-test:
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, front-build]
if: |
always() &&
needs.changed-files-check-e2e.outputs.any_changed == 'true' &&
(needs.front-build.result == 'success' || needs.front-build.result == 'skipped') &&
(github.event_name == 'push' || github.event_name == 'merge_group' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
timeout-minutes: 30
env:
NODE_OPTIONS: "--max-old-space-size=10240"
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Check system resources
run: |
echo "Available memory:"
free -h
echo "Available disk space:"
df -h
echo "CPU info:"
lscpu
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Install Playwright Browsers
run: npx nx setup twenty-e2e-testing
- name: Setup environment files
run: |
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env:e2e-testing-server twenty-server
- name: Download frontend build artifact
if: needs.front-build.result == 'success'
uses: actions/download-artifact@v4
with:
name: frontend-build
path: packages/twenty-front/build
- name: Build frontend (if not available from front-build)
if: needs.front-build.result == 'skipped'
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start frontend
run: |
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
echo "Waiting for frontend to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: packages/twenty-e2e-testing/run_results/
retention-days: 30
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs:
[
changed-files-check,
front-task,
front-build,
front-chromatic-deployment,
merge-reports-and-check-coverage,
front-sb-test,
front-sb-build,
@@ -355,12 +204,3 @@ jobs:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+11 -25
View File
@@ -1,9 +1,4 @@
name: "Release: create"
permissions:
contents: read
pull-requests: write
on:
workflow_dispatch:
inputs:
@@ -18,42 +13,33 @@ on:
default: true
description: Create a release after merging the PR
defaults:
run:
shell: bash --noprofile --norc -euo pipefail {0}
jobs:
create_pr:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
ref: ${{ github.event.inputs.ref }}
- name: Sanitize version
id: sanitize
env:
RAW_VERSION: ${{ github.event.inputs.version }}
run: |
VERSION="${RAW_VERSION#v}"
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"
echo version=$(echo ${{ github.event.inputs.version }} | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Update versions
env:
VERSION: ${{ steps.sanitize.outputs.version }}
run: |
printf '%s\n' "$VERSION" > version.txt
echo ${{ steps.sanitize.outputs.version }} > version.txt
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
branch: release/${{ steps.sanitize.outputs.version }}
commit-message: "chore: release v${{ steps.sanitize.outputs.version }}"
committer: Github Action Deploy <github-action-deploy@twenty.com>
author: Github Action Deploy <github-action-deploy@twenty.com>
title: Release v${{ steps.sanitize.outputs.version }}
labels: |
release
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
branch: release/${{ steps.sanitize.outputs.version }}
commit-message: "chore: release v${{ steps.sanitize.outputs.version }}"
committer: Github Action Deploy <github-action-deploy@twenty.com>
author: Github Action Deploy <github-action-deploy@twenty.com>
title: Release v${{ steps.sanitize.outputs.version }}
labels: |
release
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
+7 -22
View File
@@ -1,17 +1,9 @@
name: "Release: on merge"
permissions:
contents: write
on:
pull_request:
types:
- closed
defaults:
run:
shell: bash --noprofile --norc -euo pipefail {0}
jobs:
tag_and_release:
timeout-minutes: 10
@@ -20,40 +12,33 @@ jobs:
steps:
- name: Check PR Author
id: check_author
env:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
set -euo pipefail
if [[ "$PR_AUTHOR" != "github-actions[bot]" ]]; then
echo "PR author ($PR_AUTHOR) is not trusted. Exiting."
if [[ "${{ github.event.pull_request.user.login }}" != "github-actions[bot]" ]]; then
echo "PR author (${AUTHOR}) is not trusted. Exiting."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
ref: main
- name: Get version from PR title
id: extract_version
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
set -euo pipefail
VERSION=$(printf '%s' "$PR_TITLE" | sed -n 's/.*Release v\([0-9][0-9.]*\).*/\1/p')
VERSION=$(echo "${{ github.event.pull_request.title }}" | sed -n 's/.*Release v\([0-9.]*\).*/\1/p')
if [ -z "$VERSION" ]; then
echo "No valid version found in PR title. Exiting."
exit 1
fi
printf 'VERSION=%s\n' "$VERSION" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Push new tag
run: |
set -euo pipefail
git config --global user.name 'Github Action Deploy'
git config --global user.email 'github-action-deploy@twenty.com'
git tag "v${{ env.VERSION }}"
git push origin "v${{ env.VERSION }}"
git tag v${{ env.VERSION }}
git push origin v${{ env.VERSION }}
- uses: release-drafter/release-drafter@v5
if: contains(github.event.pull_request.labels.*.name, 'create_release')
-101
View File
@@ -1,101 +0,0 @@
name: CI SDK
on:
merge_group:
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/twenty-sdk/**
sdk-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Install Playwright
if: contains(matrix.task, 'storybook')
run: npx playwright install chromium
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
NODE_ENV: test
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: SDK / Run e2e Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:e2e
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+38 -59
View File
@@ -1,16 +1,14 @@
name: CI Server
on:
push:
branches:
- main
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
SERVER_SETUP_CACHE_KEY: server-setup
@@ -21,17 +19,16 @@ jobs:
with:
files: |
package.json
yarn.lock
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-emails/**
packages/twenty-shared/**
server-setup:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -57,23 +54,23 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Restore server setup
id: restore-server-setup-cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Create DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
@@ -90,20 +87,6 @@ jobs:
# If worker failed for other reasons, fail the build
exit $exit_code
fi
- name: Server / Start
run: npx nx start:ci twenty-server &
- name: Waiting for server starting...
run: |
for i in {1..10}; do
if curl -f http://localhost:3000/healthz; then
echo "Server ready!"
exit 0
fi
echo "Waiting..."
sleep 2
done
echo "Server did not become healthy in time" >&2
exit 1
- name: Server / Check for Pending Migrations
run: |
CORE_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate core-migration-check -d src/database/typeorm/core/core.datasource.ts || true)
@@ -115,61 +98,57 @@ jobs:
echo "$CORE_MIGRATION_OUTPUT"
rm -f packages/twenty-server/*core-migration-check.ts
exit 1
fi
- name: GraphQL / Check for Pending Generation
if: steps.changed-files.outputs.any_changed == 'true'
run: |
# Run GraphQL generation commands
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
# Check if GraphQL generated files were modified
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
# Check if any files were modified
if ! git diff --quiet; 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
echo "==================================================="
echo ""
echo "Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
exit 1
fi
- name: Save server setup
uses: ./.github/actions/save-cache
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: server-setup
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Restore server setup
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Run Tests
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: test
server-integration-test:
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
needs: server-setup
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard: [1, 2, 3, 4]
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -190,7 +169,7 @@ jobs:
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
image: clickhouse/clickhouse-server:latest
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
@@ -198,33 +177,33 @@ jobs:
- 8123:8123
- 9000:9000
options: >-
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
NODE_ENV: test
ANALYTICS_ENABLED: true
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
CLICKHOUSE_PASSWORD: clickhousePassword
SHARD_COUNTER: 8
SHARD_COUNTER: 4
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Update .env.test for integrations tests
run: |
echo "" >> .env.test
echo "IS_BILLING_ENABLED=true" >> .env.test
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Restore server setup
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Build
@@ -241,7 +220,7 @@ jobs:
- name: Run ClickHouse seeds
run: npx nx clickhouse:seed twenty-server
- name: Server / Run Integration Tests
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: 'test:integration'
@@ -250,9 +229,9 @@ jobs:
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
- name: Fail job if any needs failed
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+8 -8
View File
@@ -1,16 +1,14 @@
name: CI Shared
on:
merge_group:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
@@ -23,6 +21,8 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
strategy:
matrix:
task: [lint, typecheck, test]
@@ -36,9 +36,9 @@ jobs:
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:frontend
tasks: ${{ matrix.task }}
@@ -1,16 +1,10 @@
name: CI Docker Compose
permissions:
contents: read
name: 'Test Docker Compose'
on:
merge_group:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
@@ -39,7 +33,6 @@ jobs:
echo "Setting up .env file..."
cp .env.example .env
echo "Generating secrets..."
echo "" >> .env
echo "# === Randomly generated secrets ===" >>.env
echo "APP_SECRET=$(openssl rand -base64 32)" >>.env
echo "PGPASSWORD_SUPERUSER=$(openssl rand -hex 16)" >>.env
+3 -6
View File
@@ -1,5 +1,4 @@
name: CI Utils
on:
# it's usually not recommended to use pull_request_target
# but we consider it's safe here if we keep the same steps
@@ -7,7 +6,6 @@ on:
# and: https://github.com/facebook/react-native/pull/34370/files
pull_request_target:
types: [opened, synchronize, reopened, closed]
permissions:
actions: write
checks: write
@@ -15,7 +13,6 @@ permissions:
issues: write
pull-requests: write
statuses: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# We don't cancel in-progress because this workflow is triggered on
@@ -30,12 +27,12 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Utils / Run Danger.js
run: cd packages/twenty-utils && npx nx danger:ci
env:
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
congratulate:
timeout-minutes: 3
runs-on: ubuntu-latest
@@ -43,7 +40,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Run congratulate-dangerfile.js
run: cd packages/twenty-utils && npx nx danger:congratulate
env:
+5 -7
View File
@@ -1,16 +1,14 @@
name: CI Website
permissions:
contents: read
on:
merge_group:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
@@ -45,7 +43,7 @@ jobs:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Server / Create DB
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
-174
View File
@@ -1,174 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]
repository_dispatch:
types: [claude-core-team-issues]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.client_payload.issue_number }}
cancel-in-progress: false
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run Claude Code
id: claude-code
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Post Create-PR link if Claude ran out of turns
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "" ]; then
exit 0
fi
AHEAD=$(git rev-list --count main.."$BRANCH" 2>/dev/null || echo "0")
if [ "$AHEAD" = "0" ]; then
exit 0
fi
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
exit 0
fi
ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
ENCODED_BRANCH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$BRANCH', safe=''))")
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
if [ -n "$ISSUE_NUMBER" ]; then
gh issue comment "$ISSUE_NUMBER" --body "$(echo -e "$BODY")"
fi
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build prompt from dispatch payload
id: prompt
uses: actions/github-script@v7
with:
script: |
const p = context.payload.client_payload;
let prompt;
if (p.comment_body) {
prompt = `You are responding to a comment on issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository.\n\nThe comment by @${p.sender} says:\n\n${p.comment_body}\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
} else {
prompt = `You are responding to issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository, opened by @${p.sender}.\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
}
core.setOutput('prompt', prompt);
core.setOutput('repo', p.repo_full_name);
core.setOutput('issue_number', p.issue_number);
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.prompt.outputs.prompt }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Post response to source issue
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.TWENTY_DISPATCH_TOKEN }}
script: |
const [owner, repo] = '${{ steps.prompt.outputs.repo }}'.split('/');
const issueNumber = parseInt('${{ steps.prompt.outputs.issue_number }}', 10);
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Claude finished processing this request. [See workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
});
-152
View File
@@ -1,152 +0,0 @@
name: 'Pull docs translations from Crowdin'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 */2 * * *' # Every two hours
workflow_dispatch:
inputs:
force_pull:
description: 'Force pull translations regardless of status'
required: false
type: boolean
default: false
workflow_call:
inputs:
force_pull:
description: 'Force pull translations regardless of status'
required: false
type: boolean
default: false
pull_request:
paths:
- 'packages/twenty-docs/**'
- '.github/crowdin-docs.yml'
- '.github/workflows/docs-i18n-pull.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
pull_docs_translations:
name: Pull docs translations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Setup i18n-docs branch
if: github.event_name != 'pull_request'
run: |
git fetch origin i18n-docs || true
git checkout -B i18n-docs origin/i18n-docs || git checkout -b i18n-docs
- name: Configure git
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
- name: Stash any changes before pulling translations
if: github.event_name != 'pull_request'
run: |
git add .
git stash || true
# Install Crowdin CLI for downloading translations
- name: Install Crowdin CLI
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
run: npm install -g @crowdin/cli
# Pull docs translations from Crowdin one language at a time
# This avoids build timeout issues when processing all languages at once
- name: Pull translated docs from Crowdin
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
run: |
# Languages supported by Mintlify (see packages/twenty-docs/src/shared/supported-languages.ts)
LANGUAGES="fr ar cs de es it ja ko pt ro ru tr zh-CN"
for lang in $LANGUAGES; do
echo "=== Pulling translations for $lang ==="
crowdin download \
--config .github/crowdin-docs.yml \
--token "$CROWDIN_PERSONAL_TOKEN" \
--base-url "https://twenty.api.crowdin.com" \
--language "$lang" \
--skip-untranslated-strings=false \
--skip-untranslated-files=false \
--export-only-approved=false \
--verbose || echo "Warning: Failed to pull $lang, continuing with other languages..."
echo ""
done
echo "=== Download complete ==="
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Fix file permissions
if: github.event_name != 'pull_request'
run: sudo chown -R runner:docker . || true
- name: Fix translated documentation links
run: bash packages/twenty-docs/scripts/fix-translated-links.sh
- name: Regenerate navigation template
if: github.event_name == 'pull_request'
run: yarn docs:generate-navigation-template
- name: Regenerate docs.json
run: yarn docs:generate
- name: Regenerate documentation paths constants
run: yarn docs:generate-paths
- name: Commit artifacts to pull request branch
if: github.event_name == 'pull_request'
run: |
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
if git diff --staged --quiet --exit-code; then
echo "No navigation/doc changes to commit."
exit 0
fi
git commit -m "chore: sync docs artifacts"
git push origin "HEAD:$HEAD_REF"
env:
HEAD_REF: ${{ github.head_ref }}
- name: Check for changes and commit
if: github.event_name != 'pull_request'
id: check_changes
run: |
git add .
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: update docs translations from Crowdin and fix internal links"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Push changes
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n-docs
- name: Create pull request
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n-docs --title 'i18n - docs translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-51
View File
@@ -1,51 +0,0 @@
name: 'Push docs to Crowdin'
permissions:
contents: write
on:
workflow_dispatch:
workflow_call:
push:
branches: ['main']
paths:
- 'packages/twenty-docs/**/*.mdx'
- '!packages/twenty-docs/l/**'
- 'packages/twenty-docs/navigation/navigation.template.json'
- '.github/crowdin-docs.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
push_docs:
name: Push documentation to Crowdin
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.ref }}
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Generate navigation template for Crowdin
run: yarn docs:generate-navigation-template
- name: Upload docs to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
localization_branch_name: i18n-docs
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-docs.yml'
env:
# Docs translations project
CROWDIN_PROJECT_ID: '2'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+12 -21
View File
@@ -3,10 +3,6 @@
name: 'Pull translations from Crowdin'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 */2 * * *' # Every two hours.
@@ -27,12 +23,16 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
pull_translations:
name: Pull translations
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -46,7 +46,7 @@ jobs:
git checkout -B i18n origin/i18n || git checkout -b i18n
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
@@ -74,8 +74,6 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
source: '**/en.po'
translation: '%original_path%/%locale%.po'
export_only_approved: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
@@ -85,37 +83,30 @@ jobs:
push_sources: false
skip_untranslated_strings: false
skip_untranslated_files: false
push_translations: false
push_translations: true
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
config: '.github/crowdin-app.yml'
env:
GITHUB_TOKEN: ${{ github.token }}
# App translations project
CROWDIN_PROJECT_ID: '1'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# As the files are extracted from a Docker container, they belong to root:root
# We need to fix this before the next steps
- name: Fix file permissions
run: sudo chown -R runner:docker .
# Fix encoding issues (escaped Unicode like \u62db -> 招) and push fixes back to Crowdin
- name: Fix translation encoding and sync to Crowdin
run: npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Compile translations
id: compile_translations
# Because we have set English as a fallback locale, this condition does not work anymore
# if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
run: |
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
git status
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add .
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile translations"
+9 -9
View File
@@ -1,9 +1,5 @@
name: 'Push translations to Crowdin'
permissions:
contents: write
pull-requests: write
on:
workflow_dispatch:
workflow_call:
@@ -12,12 +8,15 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
extract_translations:
name: Extract and upload translations
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -31,7 +30,7 @@ jobs:
git checkout -B i18n origin/i18n || git checkout -b i18n
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
@@ -87,10 +86,11 @@ jobs:
download_translations: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-app.yml'
env:
# App translations project
CROWDIN_PROJECT_ID: '1'
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: 1
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Create a pull request
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
-118
View File
@@ -1,118 +0,0 @@
# Weekly translation QA report using Crowdin's native QA checks
name: 'Weekly Translation QA Report'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am UTC
workflow_dispatch: # Allow manual trigger
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
qa_report:
name: Generate QA Report
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Generate QA report from Crowdin
id: generate_report
run: |
npx ts-node packages/twenty-utils/translation-qa-report.ts || true
if [ -f TRANSLATION_QA_REPORT.md ]; then
echo "report_generated=true" >> $GITHUB_OUTPUT
# Count critical issues (exclude spellcheck)
CRITICAL=$(grep -oP '⚠️\s+\K\d+' TRANSLATION_QA_REPORT.md 2>/dev/null || echo "0")
echo "critical_issues=$CRITICAL" >> $GITHUB_OUTPUT
else
echo "report_generated=false" >> $GITHUB_OUTPUT
echo "critical_issues=0" >> $GITHUB_OUTPUT
fi
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Create QA branch and commit report
if: steps.generate_report.outputs.report_generated == 'true'
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
BRANCH_NAME="i18n-qa-report-$(date +%Y-%m-%d)"
git checkout -B $BRANCH_NAME
git add TRANSLATION_QA_REPORT.md
if ! git diff --staged --quiet --exit-code; then
git commit -m "docs: weekly translation QA report"
git push origin HEAD:$BRANCH_NAME --force
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
else
echo "No changes to commit"
echo "BRANCH_NAME=" >> $GITHUB_ENV
fi
- name: Create pull request
if: steps.generate_report.outputs.report_generated == 'true' && env.BRANCH_NAME != ''
run: |
CRITICAL="${{ steps.generate_report.outputs.critical_issues }}"
BODY=$(cat <<EOF
## Weekly Translation QA Report
**Critical issues (excluding spellcheck): $CRITICAL**
📊 **View in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
### For AI-Assisted Fixing
Open this PR in Cursor and say:
> "Fix the translation QA issues using the Crowdin API"
The AI can help fix:
- ✅ Variables mismatch (missing/wrong placeholders)
- ✅ Escaped Unicode sequences
- ⚠️ Tags mismatch
- ⚠️ Empty translations
### Available Scripts
\`\`\`bash
# View QA report
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
# Fix encoding issues automatically
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
\`\`\`
---
*Close without merging after issues are addressed*
EOF
)
EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
gh pr edit $EXISTING_PR --body "$BODY"
else
gh pr create \
--base main \
--head $BRANCH_NAME \
--title "i18n: Translation QA Report ($CRITICAL critical issues)" \
--body "$BODY" || true
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -5
View File
@@ -1,10 +1,5 @@
name: 'Preview Environment Dispatch'
permissions:
contents: write
actions: write
pull-requests: read
on:
# Using pull_request_target instead of pull_request to have access to secrets for external contributors
# Security note: This is safe because we're only using the repository-dispatch action with limited scope
@@ -24,6 +19,10 @@ concurrency:
jobs:
trigger-preview:
permissions:
contents: write
actions: write
pull-requests: read
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
timeout-minutes: 5
runs-on: ubuntu-latest
@@ -1,9 +1,5 @@
name: 'Preview Environment Keep Alive'
permissions:
contents: read
pull-requests: write
on:
repository_dispatch:
types: [preview-environment]
@@ -30,14 +26,10 @@ jobs:
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
@@ -61,7 +53,6 @@ jobs:
# Update the SERVER_URL with the tunnel URL
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
# Start the services
+1 -4
View File
@@ -9,7 +9,6 @@
.nx/installation
.nx/cache
.nx/workspace-data
.pnp.*
.yarn/*
@@ -46,6 +45,4 @@ dump.rdb
.crowdin.yml
.react-email/
mcp.json
/.junie/
TRANSLATION_QA_REPORT.md
mcp.json
-22
View File
@@ -1,22 +0,0 @@
{
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "npx",
"args": ["-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": {}
}
}
}
+1 -1
View File
@@ -1 +1 @@
24.5.0
22.12.0
+4
View File
@@ -0,0 +1,4 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "auto"
}
View File
+13 -39
View File
@@ -5,10 +5,13 @@
{
"name": "twenty-server - start debug",
"type": "node",
"runtimeVersion": "24.5",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": ["nx", "run", "twenty-server:start"],
"runtimeArgs": [
"nx",
"run",
"twenty-server:start"
],
"outputCapture": "std",
"internalConsoleOptions": "openOnSessionStart",
"console": "internalConsole",
@@ -19,7 +22,11 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": ["nx", "run", "twenty-server:worker"],
"runtimeArgs": [
"nx",
"run",
"twenty-server:worker"
],
"outputCapture": "std",
"internalConsoleOptions": "openOnSessionStart",
"console": "internalConsole",
@@ -30,7 +37,7 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeVersion": "24.5",
"runtimeVersion": "^22.12.0",
"runtimeArgs": [
"nx",
"run",
@@ -66,30 +73,6 @@
"--",
"--config",
"./jest-integration.config.ts",
"${relativeFile}",
"--silent=false",
"${input:updateSnapshot}"
],
"cwd": "${workspaceFolder}/packages/twenty-server",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
}
},
{
"type": "node",
"request": "launch",
"name": "twenty-server - debug unit test file (to launch with test file open)",
"runtimeExecutable": "npx",
"runtimeArgs": [
"nx",
"run",
"twenty-server:jest",
"--",
"--config",
"./jest.config.mjs",
"${relativeFile}"
],
"cwd": "${workspaceFolder}/packages/twenty-server",
@@ -97,16 +80,7 @@
"internalConsoleOptions": "neverOpen",
"env": {
"NODE_ENV": "test"
}
}
],
"inputs": [
{
"id": "updateSnapshot",
"type": "pickString",
"description": "Update snapshots?",
"options": ["", "--updateSnapshot"],
"default": ""
},
}
]
}
}
+2 -7
View File
@@ -1,8 +1,6 @@
{
"editor.formatOnSave": false,
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"files.eol": "auto",
"[typescript]": {
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
@@ -31,7 +29,6 @@
"editor.formatOnSave": true
},
"javascript.format.enable": false,
"javascript.preferences.importModuleSpecifier": "non-relative",
"typescript.format.enable": false,
"cSpell.enableFiletypes": [
"!javascript",
@@ -52,7 +49,5 @@
"files.associations": {
".cursorrules": "markdown"
},
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.experimental.useTsgo": false
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}"
}
+7 -59
View File
@@ -2,64 +2,12 @@
"version": "2.0.0",
"tasks": [
{
"label": "twenty-server - run integration test file",
"type": "shell",
"command": "npx nx run twenty-server:jest -- --config ./jest-integration.config.ts ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
"options": {
"cwd": "${workspaceFolder}/packages/twenty-server",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
},
"shell": {
"executable": "/bin/zsh",
"args": ["-l", "-c"]
}
},
"presentation": {
"reveal": "always",
"panel": "new",
"close": false
},
"problemMatcher": []
},
{
"label": "twenty-server - run unit test file",
"type": "shell",
"command": "npx nx run twenty-server:jest -- --config ./jest.config.mjs ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
"options": {
"cwd": "${workspaceFolder}/packages/twenty-server",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
},
"shell": {
"executable": "/bin/zsh",
"args": ["-l", "-c"]
}
},
"presentation": {
"reveal": "always",
"panel": "new",
"close": false
},
"problemMatcher": []
}
],
"inputs": [
{
"id": "watchMode",
"type": "pickString",
"description": "Enable watch mode?",
"options": ["", "--watch"],
"default": ""
},
{
"id": "updateSnapshot",
"type": "pickString",
"description": "Update snapshots?",
"options": ["", "--updateSnapshot"],
"default": ""
"type": "npm",
"script": "start",
"path": "server",
"problemMatcher": [],
"label": "yarn: start - server",
"detail": "yarn start"
}
]
}
}
+4
View File
@@ -4,6 +4,10 @@
"name": "ROOT",
"path": "../"
},
{
"name": "packages/twenty-chrome-extension",
"path": "../packages/twenty-chrome-extension"
},
{
"name": "packages/twenty-docker",
"path": "../packages/twenty-docker"
@@ -1,45 +0,0 @@
diff --git a/commonjs/helpers/phoneInputHelpers.js b/commonjs/helpers/phoneInputHelpers.js
index 8fd1b16ec670ab7d0abcb800a2edd7848e86ce1e..ab6e0dd90def507629c2ead59fba929a739f0759 100644
--- a/commonjs/helpers/phoneInputHelpers.js
+++ b/commonjs/helpers/phoneInputHelpers.js
@@ -574,7 +574,9 @@ function onPhoneDigitsChange(phoneDigits, _ref5) {
// Generate the new `value` property.
var value;
if (phoneDigits) {
- if (phoneDigits[0] === '+') {
+ if(phoneDigits === prevPhoneDigits) {
+ value = e164(phoneDigits, country, metadata)
+ } else if (phoneDigits[0] === '+') {
if (phoneDigits === '+') {
value = undefined;
} else if (country && (0, _getInternationalPhoneNumberPrefix["default"])(country, metadata).indexOf(phoneDigits) === 0) {
diff --git a/modules/helpers/phoneInputHelpers.js b/modules/helpers/phoneInputHelpers.js
index 75e8463f6b5d32c560d1dec2776671bee2af1d07..99af2904807cc3c23aea566bbeebda2a5bc9afa4 100644
--- a/modules/helpers/phoneInputHelpers.js
+++ b/modules/helpers/phoneInputHelpers.js
@@ -551,7 +551,9 @@ export function onPhoneDigitsChange(phoneDigits, _ref5) {
// Generate the new `value` property.
var value;
if (phoneDigits) {
- if (phoneDigits[0] === '+') {
+ if(phoneDigits === prevPhoneDigits) {
+ value = e164(phoneDigits, country, metadata)
+ } else if (phoneDigits[0] === '+') {
if (phoneDigits === '+') {
value = undefined;
} else if (country && getInternationalPhoneNumberPrefix(country, metadata).indexOf(phoneDigits) === 0) {
diff --git a/react-styleguidist/project/source/helpers/phoneInputHelpers.js b/react-styleguidist/project/source/helpers/phoneInputHelpers.js
index b844ef0fd183fe4f45e436bdb2382fc2987dcef2..8960834900e59a57e130521fd369e82fe05f5090 100644
--- a/react-styleguidist/project/source/helpers/phoneInputHelpers.js
+++ b/react-styleguidist/project/source/helpers/phoneInputHelpers.js
@@ -505,7 +505,9 @@ export function onPhoneDigitsChange(phoneDigits, {
// Generate the new `value` property.
let value
if (phoneDigits) {
- if (phoneDigits[0] === '+') {
+ if(phoneDigits === prevPhoneDigits) {
+ value = e164(phoneDigits, country, metadata)
+ } else if (phoneDigits[0] === '+') {
if (phoneDigits === '+') {
value = undefined
} else if (country && getInternationalPhoneNumberPrefix(country, metadata).indexOf(phoneDigits) !== 0) {
+925
View File
File diff suppressed because one or more lines are too long
-942
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,4 +6,4 @@ enableInlineHunks: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs
yarnPath: .yarn/releases/yarn-4.4.0.cjs
-201
View File
@@ -1,201 +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
# 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 migrations
# 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
```
### 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, Recoil (state management), Emotion (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
- **Recoil** 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 & Migrations
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **ClickHouse** for analytics (when enabled)
- 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:
- `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 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
### Code Style Notes
- Use **Emotion** for styling with 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()`
## CI Environment (GitHub Actions)
When running in CI, the dev environment is **not** pre-configured. Dependencies are installed but builds, env files, and databases are not set up.
- **Before running tests, builds, lint, type checks, or DB operations**, run: `bash packages/twenty-utils/setup-dev-env.sh`
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
- The script is idempotent and safe to run multiple times.
## 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
+23
View File
@@ -0,0 +1,23 @@
postgres-on-docker:
docker run -d \
--name twenty_pg \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
@echo "Waiting for PostgreSQL to be ready..."
@until docker exec twenty_pg psql -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
sleep 1; \
done
docker exec twenty_pg psql -U postgres -d postgres \
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
redis-on-docker:
docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker:
docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=clickhousePassword clickhouse/clickhouse-server:latest \
+75 -37
View File
@@ -1,3 +1,5 @@
<br />
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
@@ -6,27 +8,27 @@
<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>
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://twenty.com/developers">📚 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://www.twenty.com">
<picture>
<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" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-light.png" />
<img src="./packages/twenty-docs/static/img/preview-light.png" alt="Companies view" />
</picture>
</a>
</p>
<br />
# Installation
# Installation
See:
🚀 [Self-hosting](https://docs.twenty.com/developers/self-hosting/docker-compose)
🖥️ [Local Setup](https://docs.twenty.com/developers/local-setup)
See:
🚀 [Self-hosting](https://twenty.com/developers/section/self-hosting)
🖥️ [Local Setup](https://twenty.com/developers/local-setup)
# Does the world need another CRM?
@@ -41,68 +43,100 @@ We built Twenty for three reasons:
<br />
# What You Can Do With Twenty
We're currently developing Twenty's beta version.
Please feel free to flag any specific needs you have by creating an issue.
Please feel free to flag any specific needs you have by creating an issue.
Below are a few features we have implemented to date:
+ [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)
+ [Add, filter, sort, edit, and track customers](#add-filter-sort-edit-and-track-customers)
+ [Create one or several opportunities for each company](#create-one-or-several-opportunities-for-each-company)
+ [See rich notes tasks displayed in a timeline](#see-rich-notes-tasks-displayed-in-a-timeline)
+ [Create tasks on records](#create-tasks-on-records)
+ [Navigate quickly through the app using keyboard shortcuts and search](#navigate-quickly-through-the-app-using-keyboard-shortcuts-and-search)
## Personalize layouts with filters, sort, group by, kanban and table views
## Add, filter, sort, edit, and track customers:
<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" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-light.png" />
<img src="./packages/twenty-docs/static/img/visualise-customer-light.png" alt="Companies view" />
</picture>
</p>
## Customize your objects and fields
## Create one or several opportunities for each company:
<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" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-light.png" />
<img src="./packages/twenty-docs/static/img/follow-your-deals-light.png" alt="Opportunities view" />
</picture>
</p>
## Create and manage permissions with custom roles
## Track deals effortlessly with the email integration:
<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" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-light.png" />
<img src="./packages/twenty-docs/static/img/emails-light.png" alt="Emails" />
</picture>
</p>
## Automate workflow with triggers and actions
## Tailor your data model to meet business needs:
<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" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-light.png" />
<img src="./packages/twenty-docs/static/img/data-light.png" alt="Data model" />
</picture>
</p>
## Emails, calendar events, files, and more
## See rich notes displayed in a timeline:
<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" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-light.png" />
<img src="./packages/twenty-docs/static/img/notes-light.png" alt="Rich notes" />
</picture>
</p>
## Create tasks on records
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-light.png" />
<img src="./packages/twenty-docs/static/img/create-tasks-light.png" alt="Tasks" />
</picture>
</p>
## Navigate quickly through the app using keyboard shortcuts and search:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-light.png" />
<img src="./packages/twenty-docs/static/img/keyboard-dark.png" alt="Keyboard shortcuts" />
</picture>
</p>
## Connect your CRM to all your tools through our APIs and Webhooks.
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-light.png" />
<img src="./packages/twenty-docs/static/img/api-light.png" alt="API" />
</picture>
</p>
<br />
# Stack
@@ -129,7 +163,11 @@ Below are a few features we have implemented to date:
- 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/)
- 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!
- Improve translations on [Crowdin](https://twenty.crowdin.com/twenty)
- [Contributions](https://github.com/twentyhq/twenty/contribute) are, of course, most welcome!
+31
View File
@@ -0,0 +1,31 @@
#
# Basic Crowdin CLI configuration
# See https://crowdin.github.io/crowdin-cli/configuration for more information
# See https://support.crowdin.com/developer/configuration-file/ for all available options
#
#
# Defines whether to preserve the original directory structure in the Crowdin project
# Recommended to set to true
#
"preserve_hierarchy": true
#
# Files configuration.
# See https://support.crowdin.com/developer/configuration-file/ for all available options
#
files: [
{
#
# Source files filter
# e.g. "/resources/en/*.json"
#
"source": "**/en.po",
#
# Translation files filter
# e.g. "/resources/%two_letters_code%/%original_file_name%"
#
"translation": "%original_path%/%locale%.po",
}
]
-222
View File
@@ -1,222 +0,0 @@
import js from '@eslint/js';
import nxPlugin from '@nx/eslint-plugin';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import * as mdxPlugin from 'eslint-plugin-mdx';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
const twentyRules = await nxPlugin.loadWorkspaceRules(
'packages/twenty-eslint-rules',
);
export default [
// Base JavaScript configuration
js.configs.recommended,
// Lingui recommended rules
linguiPlugin.configs['flat/recommended'],
// Global ignores
{
ignores: ['**/node_modules/**'],
},
// Base configuration for all files
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
prettier: prettierPlugin,
lingui: linguiPlugin,
'@nx': nxPlugin,
'prefer-arrow': preferArrowPlugin,
import: importPlugin,
'unused-imports': unusedImportsPlugin,
unicorn: unicornPlugin,
},
rules: {
// General rules
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': [
'warn',
{ allow: ['group', 'groupCollapsed', 'groupEnd'] },
],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
// Nx rules
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:apps',
onlyDependOnLibsWithTags: ['scope:apps', 'scope:sdk'],
},
{
sourceTag: 'scope:sdk',
onlyDependOnLibsWithTags: ['scope:sdk', 'scope:shared'],
},
{
sourceTag: 'scope:create-app',
onlyDependOnLibsWithTags: ['scope:create-app', 'scope:shared'],
},
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
],
},
],
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
// TypeScript specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// TypeScript rules
'no-redeclare': 'off', // Turn off base rule for TypeScript
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-object-type': [
'error',
{
allowInterfaces: 'with-single-extends',
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
// JavaScript specific configuration
{
files: ['*.{js,jsx}'],
rules: {
// JavaScript-specific rules if needed
},
},
// Test files
{
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// JSON files
{
files: ['**/*.json'],
languageOptions: {
parser: jsoncParser,
},
},
// MDX files
{
...mdxPlugin.flat,
plugins: {
...mdxPlugin.flat.plugins,
'@nx': nxPlugin,
twenty: { rules: twentyRules },
},
},
mdxPlugin.flatCodeBlocks,
{
files: ['**/*.mdx'],
rules: {
'no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'off',
'unused-imports/no-unused-vars': 'off',
// Enforce JSX tags on separate lines to prevent Crowdin translation issues
'twenty/mdx-component-newlines': 'error',
// Disallow angle bracket placeholders to prevent Crowdin translation errors
'twenty/no-angle-bracket-placeholders': 'error',
},
},
];
+5
View File
@@ -0,0 +1,5 @@
import { getJestProjects } from '@nx/jest';
export default {
projects: getJestProjects(),
};
+1 -7
View File
@@ -1,9 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = {
...nxPreset,
// Override the new testEnvironmentOptions added in @nx/jest 22.3.3
// which breaks Lingui's module resolution
testEnvironmentOptions: {},
};
module.exports = { ...nxPreset };
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
command -v node >/dev/null 2>&1 || { echo >&2 "Nx requires NodeJS to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
command -v npm >/dev/null 2>&1 || { echo >&2 "Nx requires npm to be available. To install NodeJS and NPM, see: https://nodejs.org/en/download/ ."; exit 1; }
path_to_root=$(dirname $BASH_SOURCE)
node $path_to_root/.nx/nxw.js $@
+48 -63
View File
@@ -1,8 +1,4 @@
{
"workspaceLayout": {
"appsDir": "packages",
"libsDir": "packages"
},
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"excludeStories": [
@@ -36,7 +32,7 @@
"dependsOn": ["^build"]
},
"start": {
"cache": false,
"cache": true,
"dependsOn": ["^build"]
},
"lint": {
@@ -44,33 +40,17 @@
"cache": true,
"outputs": ["{options.outputFile}"],
"options": {
"eslintConfig": "{projectRoot}/eslint.config.mjs",
"eslintConfig": "{projectRoot}/.eslintrc.cjs",
"cache": true,
"cacheLocation": "{workspaceRoot}/.cache/eslint"
"cacheLocation": "{workspaceRoot}/.cache/eslint",
"ignorePath": "{workspaceRoot}/.gitignore"
},
"configurations": {
"ci": {
"cacheStrategy": "content"
},
"fix": {
"fix": true
}
"ci": { "cacheStrategy": "content" },
"fix": { "fix": true }
},
"dependsOn": ["^build"]
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs \"$@\"; fi' _",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "git diff --name-only --diff-filter=d main | grep -E '{args.pattern}' | grep '^{projectRoot}/' | xargs sh -c 'if [ $# -gt 0 ]; then npx eslint --config {projectRoot}/eslint.config.mjs --fix \"$@\"; fi' _"
}
}
},
"fmt": {
"executor": "nx:run-commands",
"cache": true,
@@ -83,12 +63,8 @@
"write": false
},
"configurations": {
"ci": {
"cacheStrategy": "content"
},
"fix": {
"write": true
}
"ci": { "cacheStrategy": "content" },
"fix": { "write": true }
},
"dependsOn": ["^build"]
},
@@ -97,12 +73,10 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "tsgo -p tsconfig.json"
"command": "tsc -b tsconfig.json --incremental"
},
"configurations": {
"watch": {
"command": "tsgo -p tsconfig.json --watch"
}
"watch": { "watch": true }
},
"dependsOn": ["^build"]
},
@@ -117,7 +91,7 @@
],
"outputs": ["{projectRoot}/coverage"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs",
"jestConfig": "{projectRoot}/jest.config.ts",
"coverage": true,
"coverageReporters": ["text-summary"],
"cacheDirectory": "../../.cache/jest/{projectRoot}"
@@ -127,12 +101,8 @@
"ci": true,
"maxWorkers": 3
},
"coverage": {
"coverageReporters": ["lcov", "text"]
},
"watch": {
"watch": true
}
"coverage": { "coverageReporters": ["lcov", "text"] },
"watch": { "watch": true }
}
},
"test:e2e": {
@@ -146,7 +116,7 @@
"outputs": ["{projectRoot}/{options.output-dir}"],
"options": {
"cwd": "{projectRoot}",
"command": "NODE_OPTIONS='--max-old-space-size=10240' VITE_DISABLE_TYPESCRIPT_CHECKER=true storybook build --test",
"command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test",
"output-dir": "storybook-static",
"config-dir": ".storybook"
},
@@ -181,8 +151,15 @@
"outputs": ["{projectRoot}/coverage/storybook"],
"options": {
"cwd": "{projectRoot}",
"command": "vitest run --coverage --shard={args.shard}",
"shard": "1/1"
"commands": [
"test-storybook --url http://localhost:{args.port} --maxWorkers=3 --coverage --coverageDirectory={args.coverageDir} --shard={args.shard}",
"nx storybook:coverage {projectName} --coverageDir={args.coverageDir} --checkCoverage={args.checkCoverage}"
],
"shard": "1/1",
"parallel": false,
"coverageDir": "coverage/storybook",
"port": 6006,
"checkCoverage": true
}
},
"storybook:test:no-coverage": {
@@ -190,8 +167,10 @@
"inputs": ["^default", "excludeTests"],
"options": {
"cwd": "{projectRoot}",
"command": "vitest run --shard={args.shard}",
"shard": "1/1"
"commands": [
"test-storybook --url http://localhost:{args.port} --maxWorkers=2"
],
"port": 6006
}
},
"storybook:coverage": {
@@ -213,9 +192,18 @@
"checkCoverage": true
},
"configurations": {
"text": {
"reporter": "text"
}
"text": { "reporter": "text" }
}
},
"storybook:serve-and-test:static": {
"executor": "nx:run-commands",
"options": {
"commands": [
"npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --shard={args.shard} --checkCoverage={args.checkCoverage} --port={args.port} --configuration={args.scope}'"
],
"shard": "1/1",
"checkCoverage": true,
"port": 6006
}
},
"chromatic": {
@@ -227,7 +215,7 @@
"command": "nx storybook:build {projectName}",
"forwardAllArgs": false
},
"chromatic --storybook-build-dir=storybook-static {args.ci}"
"cross-var chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --storybook-build-dir=storybook-static {args.ci}"
],
"parallel": false
},
@@ -258,22 +246,22 @@
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/packages/twenty-eslint-rules/**/*"
"{workspaceRoot}/.eslintrc.js",
"{workspaceRoot}/tools/eslint-rules/**/*"
]
},
"@nx/vite:test": {
"cache": true,
"inputs": ["default", "^default"]
},
"@nx/vite:build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["default", "^default"]
},
"@nx/vitest:test": {
"cache": true,
"inputs": ["default", "^default"]
}
},
"installation": {
"version": "22.3.3"
"version": "18.3.3"
},
"generators": {
"@nx/react": {
@@ -306,8 +294,5 @@
}
},
"useInferencePlugins": false,
"defaultBase": "main",
"tui": {
"enabled": false
}
"defaultBase": "main"
}
+219 -81
View File
@@ -2,31 +2,121 @@
"private": true,
"dependencies": {
"@apollo/client": "^3.7.17",
"@apollo/server": "^4.7.3",
"@aws-sdk/client-lambda": "^3.700.0",
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sts": "^3.700.0",
"@aws-sdk/credential-providers": "^3.700.0",
"@codesandbox/sandpack-react": "^2.13.5",
"@dagrejs/dagre": "^1.1.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@envelop/on-resolve": "^4.1.0",
"@floating-ui/react": "^0.24.3",
"@google-cloud/local-auth": "2.1.0",
"@graphiql/plugin-explorer": "^1.0.2",
"@graphql-tools/schema": "^10.0.0",
"@hello-pangea/dnd": "^16.2.0",
"@hookform/resolvers": "^3.1.1",
"@jsdevtools/rehype-toc": "^3.0.2",
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@radix-ui/colors": "^3.0.0",
"@mdx-js/react": "^3.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.1",
"@nestjs/cli": "^9.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/jwt": "^10.0.3",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/serve-static": "^4.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nx/eslint-plugin": "^17.2.8",
"@octokit/graphql": "^7.0.2",
"@ptc-org/nestjs-query-core": "^4.2.0",
"@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2",
"@react-email/components": "0.0.35",
"@react-email/render": "0.0.17",
"@sentry/node": "^9.26.0",
"@sentry/profiling-node": "^9.26.0",
"@sentry/react": "^9.26.0",
"@sniptt/guards": "^0.2.0",
"@stoplight/elements": "^8.0.5",
"@swc/jest": "^0.2.29",
"@tabler/icons-react": "^3.31.0",
"@wyw-in-js/vite": "^0.7.0",
"@types/dompurify": "^3.0.5",
"@types/facepaint": "^1.2.5",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.chunk": "^4.2.9",
"@types/lodash.merge": "^4.6.7",
"@types/lodash.pick": "^4.3.7",
"@types/nodemailer": "^6.4.14",
"@types/passport-microsoft": "^1.0.3",
"@wyw-in-js/vite": "^0.5.3",
"@xyflow/react": "^12.4.2",
"add": "^2.0.6",
"addressparser": "^1.0.1",
"afterframe": "^1.0.2",
"apollo-server-express": "^3.12.0",
"apollo-upload-client": "^17.0.0",
"archiver": "^7.0.1",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"body-parser": "^1.20.2",
"bullmq": "^5.40.0",
"bytes": "^3.1.2",
"class-transformer": "^0.5.1",
"clsx": "^2.1.1",
"cron-parser": "^5.1.1",
"cron-validate": "^1.4.5",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"danger-plugin-todos": "^1.3.1",
"dataloader": "^2.2.2",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"debounce": "^2.0.0",
"deep-equal": "^2.2.2",
"dompurify": "^3.0.11",
"dotenv-cli": "^7.2.1",
"drizzle-orm": "^0.29.3",
"esbuild-plugin-svgr": "^2.1.0",
"facepaint": "^1.2.1",
"file-type": "16.5.4",
"framer-motion": "^11.18.0",
"fuse.js": "^7.1.0",
"googleapis": "105",
"graphiql": "^3.1.1",
"graphql": "16.8.0",
"graphql-fields": "^2.0.3",
"graphql-middleware": "^6.1.35",
"graphql-rate-limit": "^3.3.0",
"graphql-redis-subscriptions": "^2.7.0",
"graphql-scalars": "^1.23.0",
"graphql-sse": "^2.5.4",
"graphql-subscriptions": "2.0.0",
"graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2",
"graphql-upload": "^13.0.0",
"graphql-yoga": "^4.0.4",
"hex-rgb": "^5.0.0",
"immer": "^10.1.1",
"jotai": "^2.17.1",
"iframe-resizer-react": "^1.1.0",
"immer": "^10.0.2",
"jest-mock-extended": "^3.0.4",
"js-cookie": "^3.0.5",
"js-levenshtein": "^1.1.6",
"json-2-csv": "^5.4.0",
"jsonwebtoken": "^9.0.0",
"libphonenumber-js": "^1.10.26",
"lodash.camelcase": "^4.3.0",
"lodash.chunk": "^4.2.0",
"lodash.compact": "^3.0.1",
"lodash.debounce": "^4.0.8",
"lodash.escaperegexp": "^4.1.2",
"lodash.groupby": "^4.6.0",
"lodash.identity": "^3.0.0",
@@ -37,79 +127,134 @@
"lodash.mapvalues": "^4.6.0",
"lodash.merge": "^4.6.2",
"lodash.omit": "^4.5.0",
"lodash.pick": "^4.4.0",
"lodash.pickby": "^4.6.0",
"lodash.snakecase": "^4.1.1",
"lodash.upperfirst": "^4.3.1",
"luxon": "^3.3.0",
"microdiff": "^1.3.2",
"moize": "^6.1.6",
"nest-commander": "^3.12.0",
"next": "14.0.4",
"next-mdx-remote": "^4.4.1",
"nodemailer": "^6.9.8",
"openapi-types": "^12.1.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-microsoft": "^2.0.0",
"patch-package": "^8.0.0",
"pg": "^8.11.3",
"pg-boss": "^9.0.3",
"planer": "^1.2.0",
"pluralize": "^8.0.0",
"prettier": "^3.0.3",
"prism-react-renderer": "^2.1.0",
"qs": "^6.11.2",
"react": "^18.2.0",
"react-data-grid": "7.0.0-beta.13",
"react-datepicker": "^6.7.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.11",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.4",
"react-icons": "^4.12.0",
"react-imask": "^7.6.0",
"react-intersection-observer": "^9.15.1",
"react-loading-skeleton": "^3.3.1",
"react-phone-number-input": "^3.3.4",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"react-textarea-autosize": "^8.4.1",
"react-tooltip": "^5.13.1",
"recoil": "^0.7.7",
"rehype-slug": "^6.0.0",
"remark-behead": "^3.1.0",
"remark-gfm": "^3.0.1",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"scroll-into-view": "^1.16.2",
"semver": "^7.5.4",
"sharp": "^0.32.1",
"slash": "^5.1.0",
"temporal-polyfill": "^0.3.0",
"storybook-addon-mock-date": "^0.6.0",
"stripe": "^17.3.1",
"ts-key-enum": "^2.0.12",
"tslib": "^2.8.1",
"tslib": "^2.3.0",
"tsup": "^8.2.4",
"type-fest": "4.10.1",
"typescript": "5.9.2",
"typescript": "5.3.3",
"use-debounce": "^10.0.0",
"uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.1",
"xlsx-ugnis": "^0.19.3",
"zod": "^4.1.11"
"zod": "3.23.8"
},
"devDependencies": {
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.24.6",
"@chromatic-com/storybook": "^4.1.3",
"@crxjs/vite-plugin": "^1.0.14",
"@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/client-preset": "^4.1.0",
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@nx/eslint": "22.3.3",
"@nx/eslint-plugin": "22.3.3",
"@nx/jest": "22.3.3",
"@nx/js": "22.3.3",
"@nx/react": "22.3.3",
"@nx/storybook": "22.3.3",
"@nx/vite": "22.3.3",
"@nx/web": "22.3.3",
"@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@next/eslint-plugin-next": "^14.1.4",
"@nx/eslint": "18.3.3",
"@nx/eslint-plugin": "18.3.3",
"@nx/jest": "18.3.3",
"@nx/js": "18.3.3",
"@nx/react": "18.3.3",
"@nx/storybook": "18.3.3",
"@nx/vite": "18.3.3",
"@nx/web": "18.3.3",
"@playwright/test": "^1.46.0",
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-links": "^10.1.11",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.1.11",
"@storybook/test-runner": "^0.24.2",
"@storybook/addon-actions": "^7.6.3",
"@storybook/addon-coverage": "^1.0.0",
"@storybook/addon-essentials": "^7.6.7",
"@storybook/addon-interactions": "^7.6.7",
"@storybook/addon-links": "^7.6.7",
"@storybook/addon-onboarding": "^1.0.10",
"@storybook/blocks": "^7.6.3",
"@storybook/core-server": "7.6.3",
"@storybook/icons": "^1.2.9",
"@storybook/jest": "^0.2.3",
"@storybook/react": "^7.6.3",
"@storybook/react-vite": "^7.6.3",
"@storybook/test": "^7.6.3",
"@storybook/test-runner": "^0.16.0",
"@storybook/testing-library": "^0.2.2",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.8.0",
"@swc/cli": "^0.3.12",
"@swc/core": "1.13.3",
"@swc/core": "1.7.42",
"@swc/helpers": "~0.5.2",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/addressparser": "^1.0.3",
"@types/apollo-upload-client": "^17.0.2",
"@types/bcrypt": "^5.0.0",
"@types/bytes": "^3.1.1",
"@types/chrome": "^0.0.267",
"@types/deep-equal": "^1.0.1",
"@types/fs-extra": "^11.0.4",
"@types/express": "^4.17.13",
"@types/file-saver": "^2.0.7",
"@types/graphql-fields": "^1.3.6",
"@types/inquirer": "^9.0.9",
"@types/jest": "^30.0.0",
"@types/graphql-upload": "^8.0.12",
"@types/js-cookie": "^3.0.3",
"@types/js-levenshtein": "^1.1.3",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.compact": "^3.0.9",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.escaperegexp": "^4.1.9",
"@types/lodash.groupby": "^4.6.9",
"@types/lodash.identity": "^3.0.9",
@@ -122,46 +267,47 @@
"@types/lodash.pickby": "^4.6.9",
"@types/lodash.snakecase": "^4.1.7",
"@types/lodash.upperfirst": "^4.3.7",
"@types/luxon": "^3.3.0",
"@types/ms": "^0.7.31",
"@types/node": "^24.0.0",
"@types/node": "^22.0.0",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.8",
"@types/passport-microsoft": "^2.1.0",
"@types/pluralize": "^0.0.33",
"@types/react": "^18.2.39",
"@types/react-datepicker": "^6.2.0",
"@types/react-dom": "^18.2.15",
"@types/scroll-into-view": "^1.16.0",
"@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@typescript-eslint/utils": "^8.39.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "3.11.0",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/experimental-utils": "^5.62.0",
"@typescript-eslint/parser": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/ui": "1.4.0",
"@yarnpkg/types": "^4.0.0",
"chromatic": "^6.18.0",
"concurrently": "^8.2.2",
"danger": "^13.0.4",
"dotenv-cli": "^7.4.4",
"esbuild": "^0.25.10",
"eslint": "^9.32.0",
"cross-var": "^1.1.0",
"danger": "^11.3.0",
"dotenv-cli": "^7.2.1",
"drizzle-kit": "^0.20.14",
"esbuild": "^0.23.0",
"eslint": "^8.53.0",
"eslint-config-next": "14.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-lingui": "^0.9.0",
"eslint-plugin-mdx": "^3.6.2",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-project-structure": "^3.9.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^10.1.11",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-storybook": "^0.6.15",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
"jest": "29.7.0",
@@ -169,53 +315,55 @@
"jest-environment-node": "^29.4.1",
"jest-fetch-mock": "^3.0.3",
"jsdom": "~22.1.0",
"msw": "^2.12.7",
"msw-storybook-addon": "^2.0.6",
"nx": "22.3.3",
"msw": "^2.0.11",
"msw-storybook-addon": "2.0.0--canary.122.b3ed3b1.0",
"nx": "18.3.3",
"playwright": "^1.46.0",
"prettier": "^3.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "^10.1.11",
"storybook-addon-mock-date": "2.0.0",
"storybook-addon-pseudo-states": "^10.1.11",
"storybook": "^7.6.3",
"storybook-addon-cookie": "^3.2.0",
"storybook-addon-pseudo-states": "^2.1.2",
"storybook-dark-mode": "^3.0.3",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
"ts-node": "10.9.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"vite": "^7.0.0",
"vitest": "^4.0.18"
"vite": "^6.3.5",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-cjs-interop": "^2.2.0",
"vite-plugin-dts": "3.8.1",
"vite-plugin-svgr": "^4.2.0",
"vitest": "1.4.0"
},
"engines": {
"node": "^24.5.0",
"node": "^22.12.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"license": "AGPL-3.0",
"name": "twenty",
"packageManager": "yarn@4.9.2",
"packageManager": "yarn@4.4.0",
"resolutions": {
"graphql": "16.8.1",
"graphql": "16.8.0",
"type-fest": "4.10.1",
"typescript": "5.9.2",
"typescript": "5.3.3",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16"
"prosemirror-transform": "1.10.4"
},
"version": "0.2.1",
"nx": {},
"scripts": {
"docs:generate": "tsx packages/twenty-docs/scripts/generate-docs-json.ts",
"docs:generate-navigation-template": "tsx packages/twenty-docs/scripts/generate-navigation-template.ts",
"docs:generate-paths": "tsx packages/twenty-docs/scripts/generate-documentation-paths.ts",
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
},
"workspaces": {
"packages": [
"packages/twenty-chrome-extension",
"packages/twenty-front",
"packages/twenty-server",
"packages/twenty-emails",
@@ -223,19 +371,9 @@
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
"packages/twenty-sdk",
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-eslint-rules"
"tools/eslint-rules"
]
},
"prettier": {
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf"
}
}
-109
View File
@@ -1,109 +0,0 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
<a href="https://www.npmjs.com/package/create-twenty-app"><img alt="NPM version" src="https://img.shields.io/npm/v/create-twenty-app.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://github.com/twentyhq/twenty/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/npm/l/next.svg?style=for-the-badge&labelColor=000000"></a>
<a href="https://discord.gg/cx5n4Jzs57"><img alt="Join the community on Discord" src="https://img.shields.io/badge/Join%20the%20community-blueviolet.svg?style=for-the-badge&logo=Twenty&labelColor=000000&logoWidth=20"></a>
</div>
Create Twenty App is the official scaffolding CLI for building apps on top of [Twenty CRM](https://twenty.com). It sets up a readytorun project that works seamlessly with the [twenty-sdk](https://www.npmjs.com/package/twenty-sdk).
- Zeroconfig project bootstrap
- Preconfigured scripts for auth, dev mode (watch & sync), generate, uninstall, and function management
- Strong TypeScript support and typed client generation
## Documentation
See Twenty application documentation https://docs.twenty.com/developers/extend/capabilities/apps
## Prerequisites
- Node.js 24+ (recommended) and Yarn 4
- A Twenty workspace and an API key (create one at https://app.twenty.com/settings/api-webhooks)
## Quick start
```bash
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# If you don't use yarn@4
corepack enable
yarn install
# Get help
yarn run help
# Authenticate using your API key (you'll be prompted)
yarn auth:login
# Add a new entity to your application (guided)
yarn entity:add
# Generate a typed Twenty client and workspace entity types
yarn app:generate
# Start dev mode: watches, builds, and syncs local changes to your workspace
yarn app:dev
# Watch your application's function logs
yarn function:logs
# Execute a function with a JSON payload
yarn function:execute -n my-function -p '{"key": "value"}'
# Uninstall the application from the current workspace
yarn app:uninstall
```
## What gets scaffolded
- A minimal app structure ready for Twenty with example files:
- `application-config.ts` - Application metadata configuration
- `roles/default-role.ts` - Default role for logic functions
- `logic-functions/hello-world.ts` - Example logic function with HTTP trigger
- `front-components/hello-world.tsx` - Example front component
- TypeScript configuration
- Prewired scripts that wrap the `twenty` CLI from twenty-sdk
## Next steps
- Use `yarn auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn entity:add` (logic functions, front components, objects, roles).
- Use `yarn app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- Keep your types uptodate using `yarn app:generate`.
## Publish your application
Applications are currently stored in `twenty/packages/twenty-apps`.
You can share your application with all Twenty users:
```bash
# pull the Twenty project
git clone https://github.com/twentyhq/twenty.git
cd twenty
# create a new branch
git checkout -b feature/my-awesome-app
```
- Copy your app folder into `twenty/packages/twenty-apps`.
- Commit your changes and open a pull request on https://github.com/twentyhq/twenty
```bash
git commit -m "Add new application"
git push
```
Our team reviews contributions for quality, security, and reusability before merging.
## Troubleshooting
- Auth prompts not appearing: run `yarn auth:login` again and verify the API key permissions.
- Types not generated: ensure `yarn app:generate` runs without errors, then restart `yarn app:dev`.
## Contributing
- See our [GitHub](https://github.com/twentyhq/twenty)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
@@ -1,20 +0,0 @@
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
{
ignores: ['**/dist/**'],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
rules: {
'prettier/prettier': 'error',
},
},
{
rules: {
'no-console': 'off',
},
ignores: ['src/**/*.ts', '!src/cli/**/*.ts'],
},
];
@@ -1,40 +0,0 @@
const jestConfig = {
displayName: 'twenty-cli',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
transform: {
'^.+\\.[tj]sx?$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript', tsx: false },
},
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
coverageDirectory: './coverage',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
'!src/cli.ts', // Exclude CLI entry point from coverage
],
coverageThreshold: {
global: {
statements: 1,
lines: 1,
functions: 1,
},
},
};
export default jestConfig;
-60
View File
@@ -1,60 +0,0 @@
{
"name": "create-twenty-app",
"version": "0.5.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
"files": [
"dist",
"README.md",
"package.json"
],
"scripts": {
"build": "npx rimraf dist && npx vite build",
"prepublishOnly": "tsx ../twenty-utils/pack-scripts/pre-publish-only.ts",
"postpublish": "tsx ../twenty-utils/pack-scripts/post-publish.ts"
},
"keywords": [
"twenty",
"cli",
"crm",
"application",
"development"
],
"exports": {
".": {
"types": "./dist/cli.d.ts",
"import": "./dist/cli.mjs",
"require": "./dist/cli.cjs"
}
},
"license": "AGPL-3.0",
"dependencies": {
"@genql/cli": "^3.0.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"fs-extra": "^11.2.0",
"inquirer": "^10.0.0",
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"lodash.startcase": "^4.4.0",
"twenty-shared": "workspace:*",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.0",
"@types/inquirer": "^9.0.0",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.startcase": "^4",
"@types/node": "^20.0.0",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}
-56
View File
@@ -1,56 +0,0 @@
{
"name": "create-twenty-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:create-app"],
"targets": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"]
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "tsx src/cli.ts"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/create-twenty-app",
"command": "node dist/cli.cjs"
}
},
"typecheck": {},
"lint": {
"options": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"configurations": {
"ci": {
"lintFilePatterns": ["{projectRoot}/src/**/*.{ts,json}"],
"maxWarnings": 0
},
"fix": {}
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs"
},
"configurations": {
"ci": {
"ci": true,
"coverage": true,
"watchAll": false
}
}
}
}
}
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { Command, CommanderError } from 'commander';
import { CreateAppCommand } from '@/create-app.command';
import packageJson from '../package.json';
const program = new Command(packageJson.name)
.description('CLI tool to initialize a new Twenty application')
.version(
packageJson.version,
'-v, --version',
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.helpOption('-h, --help', 'Display this help message.')
.action(async (directory?: string) => {
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
console.error(
chalk.red(
`Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`,
),
);
process.exit(1);
}
await new CreateAppCommand().execute(directory);
});
program.exitOverride();
try {
program.parse();
} catch (error) {
if (error instanceof CommanderError) {
process.exit(error.exitCode);
}
if (error instanceof Error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
}
@@ -1 +0,0 @@
24.5.0
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,46 +0,0 @@
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
First, authenticate to your workspace:
```bash
yarn auth:login
```
Then, start development mode to sync your app and watch for changes:
```bash
yarn app:dev
```
Open your Twenty instance and go to `/settings/applications` section to see the result.
## Available Commands
```bash
# Authentication
yarn auth:login # Authenticate with Twenty
yarn auth:logout # Remove credentials
yarn auth:status # Check auth status
yarn auth:switch # Switch default workspace
yarn auth:list # List all configured workspaces
# Application
yarn app:dev # Start dev mode (watch, build, and sync)
yarn entity:add # Add a new entity (function, front-component, object, role)
yarn app:generate # Generate typed Twenty client
yarn function:logs # Stream function logs
yarn function:execute # Execute a function with JSON payload
yarn app:uninstall # Uninstall app from workspace
```
## Learn More
To learn more about Twenty applications, take a look at the following resources:
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
@@ -1,29 +0,0 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default [
// Base JS recommended rules
js.configs.recommended,
// TypeScript recommended rules
...tseslint.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Common TypeScript-friendly tweaks
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'no-unused-vars': 'off', // handled by TS rule
},
},
];
@@ -1,31 +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"]
}
@@ -1,133 +0,0 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import { tryGitInit } from '@/utils/try-git-init';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
import kebabCase from 'lodash.kebabcase';
import * as path from 'path';
const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd();
export class CreateAppCommand {
async execute(directory?: string): Promise<void> {
try {
const { appName, appDisplayName, appDirectory, appDescription } =
await this.getAppInfos(directory);
await this.validateDirectory(appDirectory);
this.logCreationInfo({ appDirectory, appName });
await fs.ensureDir(appDirectory);
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
});
await install(appDirectory);
await tryGitInit(appDirectory);
this.logSuccess(appDirectory);
} catch (error) {
console.error(
chalk.red('Initialization failed:'),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getAppInfos(directory?: string): Promise<{
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}> {
const { name, displayName, description } = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Application name:',
when: () => !directory,
default: 'my-awesome-app',
validate: (input) => {
if (input.length === 0) return 'Application name is required';
return true;
},
},
{
type: 'input',
name: 'displayName',
message: 'Application display name:',
default: (answers: any) => {
return convertToLabel(answers?.name ?? directory);
},
},
{
type: 'input',
name: 'description',
message: 'Application description (optional):',
default: '',
},
]);
const computedName = name ?? directory;
const appName = computedName.trim();
const appDisplayName = displayName.trim();
const appDescription = description.trim();
const appDirectory = directory
? path.join(CURRENT_EXECUTION_DIRECTORY, directory)
: path.join(CURRENT_EXECUTION_DIRECTORY, kebabCase(appName));
return { appName, appDisplayName, appDirectory, appDescription };
}
private async validateDirectory(appDirectory: string): Promise<void> {
if (!(await fs.pathExists(appDirectory))) {
return;
}
const files = await fs.readdir(appDirectory);
if (files.length > 0) {
throw new Error(
`Directory ${appDirectory} already exists and is not empty`,
);
}
}
private logCreationInfo({
appDirectory,
appName,
}: {
appDirectory: string;
appName: string;
}): void {
console.log(chalk.blue('🎯 Creating Twenty Application'));
console.log(chalk.gray(`📁 Directory: ${appDirectory}`));
console.log(chalk.gray(`📝 Name: ${appName}`));
console.log('');
}
private logSuccess(appDirectory: string): void {
const dirName = appDirectory.split('/').reverse()[0] ?? '';
console.log(chalk.green('✅ Application created!'));
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
console.log(chalk.gray(` corepack enable # if you don't use yarn@4`));
console.log(chalk.gray(` yarn install # if you don't use yarn@4`));
console.log(chalk.gray(' yarn auth:login # Authenticate with Twenty'));
console.log(chalk.gray(' yarn app:dev # Start dev mode'));
}
}
@@ -1,302 +0,0 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { tmpdir } from 'os';
import { copyBaseApplicationProject } from '@/utils/app-template';
// Mock fs-extra's copy function to skip copying base template (not available during tests)
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
return {
...actual,
copy: jest.fn().mockResolvedValue(undefined),
};
});
const APPLICATION_FILE_NAME = 'application-config.ts';
const DEFAULT_ROLE_FILE_NAME = 'default-role.ts';
describe('copyBaseApplicationProject', () => {
let testAppDirectory: string;
beforeEach(async () => {
// Create a unique temp directory for each test
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await fs.ensureDir(testAppDirectory);
jest.clearAllMocks();
});
afterEach(async () => {
// Clean up temp directory after each test
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
});
it('should create the correct folder structure with src/', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
// Verify src/ folder exists
const srcAppPath = join(testAppDirectory, 'src');
expect(await fs.pathExists(srcAppPath)).toBe(true);
// Verify application-config.ts exists in src/
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
expect(await fs.pathExists(appConfigPath)).toBe(true);
// Verify default-role.ts exists in src/
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
expect(await fs.pathExists(roleConfigPath)).toBe(true);
});
it('should create package.json with correct content', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const packageJsonPath = join(testAppDirectory, 'package.json');
expect(await fs.pathExists(packageJsonPath)).toBe(true);
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.dependencies['twenty-sdk']).toBe('0.5.1');
expect(packageJson.scripts['app:dev']).toBe('twenty app:dev');
});
it('should create .gitignore file', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const gitignorePath = join(testAppDirectory, '.gitignore');
expect(await fs.pathExists(gitignorePath)).toBe(true);
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('/node_modules');
expect(gitignoreContent).toContain('generated');
});
it('should create yarn.lock file', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const yarnLockPath = join(testAppDirectory, 'yarn.lock');
expect(await fs.pathExists(yarnLockPath)).toBe(true);
const yarnLockContent = await fs.readFile(yarnLockPath, 'utf8');
expect(yarnLockContent).toContain('yarn lockfile v1');
});
it('should create application-config.ts with defineApplication and correct values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
// Verify it uses defineApplication
expect(appConfigContent).toContain(
"import { defineApplication } from 'twenty-sdk'",
);
expect(appConfigContent).toContain('export default defineApplication({');
// Verify it imports the role identifier
expect(appConfigContent).toContain(
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
);
// Verify display name and description
expect(appConfigContent).toContain("displayName: 'My Test App'");
expect(appConfigContent).toContain("description: 'A test application'");
// Verify it has a universalIdentifier (UUID format)
expect(appConfigContent).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
// Verify it references the role
expect(appConfigContent).toContain(
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
});
it('should create default-role.ts with defineRole and correct values', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const roleConfigPath = join(
testAppDirectory,
'src',
'roles',
DEFAULT_ROLE_FILE_NAME,
);
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
// Verify it uses defineRole
expect(roleConfigContent).toContain(
"import { defineRole } from 'twenty-sdk'",
);
expect(roleConfigContent).toContain('export default defineRole({');
// Verify it exports the universal identifier constant
expect(roleConfigContent).toContain(
'export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
// Verify role label includes app name
expect(roleConfigContent).toContain(
"label: 'My Test App default function role'",
);
// Verify default permissions
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
// Verify it has a universalIdentifier (UUID format)
expect(roleConfigContent).toMatch(
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
);
});
it('should call fs.copy to copy base application template', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
// Verify fs.copy was called with correct destination
expect(fs.copy).toHaveBeenCalledTimes(1);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('base-application'),
testAppDirectory,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: '',
appDirectory: testAppDirectory,
});
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
expect(appConfigContent).toContain("description: ''");
});
it('should generate unique UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
});
// Read both app configs
const firstAppConfig = await fs.readFile(
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
'utf8',
);
const secondAppConfig = await fs.readFile(
join(secondAppDir, 'src', APPLICATION_FILE_NAME),
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
const secondUuid = secondAppConfig.match(uuidRegex)?.[1];
expect(firstUuid).toBeDefined();
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
it('should generate unique role UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
appName: 'app-one',
appDisplayName: 'App One',
appDescription: 'First app',
appDirectory: firstAppDir,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
appName: 'app-two',
appDisplayName: 'App Two',
appDescription: 'Second app',
appDirectory: secondAppDir,
});
const firstRoleConfig = await fs.readFile(
join(firstAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
'utf8',
);
const secondRoleConfig = await fs.readFile(
join(secondAppDir, 'src', 'roles', DEFAULT_ROLE_FILE_NAME),
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
const secondUuid = secondRoleConfig.match(uuidRegex)?.[1];
expect(firstUuid).toBeDefined();
expect(secondUuid).toBeDefined();
expect(firstUuid).not.toBe(secondUuid);
});
});
@@ -1,10 +0,0 @@
import { convertToLabel } from '@/utils/convert-to-label';
describe('convertToLabel', () => {
it('should convert to label', () => {
expect(convertToLabel('toto')).toBe('Toto');
expect(convertToLabel('totoTata')).toBe('Toto tata');
expect(convertToLabel('totoTataTiti')).toBe('Toto tata titi');
expect(convertToLabel('toto-tata-titi')).toBe('Toto tata titi');
});
});
@@ -1,297 +0,0 @@
import * as fs from 'fs-extra';
import { join } from 'path';
import { v4 } from 'uuid';
import { ASSETS_DIR } from 'twenty-shared/application';
const SRC_FOLDER = 'src';
export const copyBaseApplicationProject = async ({
appName,
appDisplayName,
appDescription,
appDirectory,
}: {
appName: string;
appDisplayName: string;
appDescription: string;
appDirectory: string;
}) => {
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
await createPackageJson({ appName, appDirectory });
await createGitignore(appDirectory);
await createPublicAssetDirectory(appDirectory);
await createYarnLock(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
await fs.ensureDir(sourceFolderPath);
await createDefaultRoleConfig({
displayName: appDisplayName,
appDirectory: sourceFolderPath,
fileFolder: 'roles',
fileName: 'default-role.ts',
});
await createDefaultFrontComponent({
appDirectory: sourceFolderPath,
fileFolder: 'front-components',
fileName: 'hello-world.tsx',
});
await createDefaultFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'hello-world.ts',
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
appDirectory: sourceFolderPath,
fileName: 'application-config.ts',
});
};
const createPublicAssetDirectory = async (appDirectory: string) => {
await fs.ensureDir(join(appDirectory, ASSETS_DIR));
};
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 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/*
!.twenty/output/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
`;
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
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;
}) => {
const universalIdentifier = v4();
const content = `import { defineFrontComponent } from 'twenty-sdk';
export const HelloWorld = () => {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '${universalIdentifier}',
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 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!' };
};
// Logic function handler - rename and implement your logic
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 createApplicationConfig = async ({
displayName,
description,
appDirectory,
fileFolder,
fileName,
}: {
displayName: string;
description?: string;
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const content = `import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export default defineApplication({
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createPackageJson = async ({
appName,
appDirectory,
}: {
appName: string;
appDirectory: string;
}) => {
const packageJson = {
name: appName,
version: '0.1.0',
license: 'MIT',
engines: {
node: '^24.5.0',
npm: 'please-use-yarn',
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
'auth:login': 'twenty auth:login',
'auth:logout': 'twenty auth:logout',
'auth:status': 'twenty auth:status',
'auth:switch': 'twenty auth:switch',
'auth:list': 'twenty auth:list',
'app:dev': 'twenty app:dev',
'entity:add': 'twenty entity:add',
'app:generate': 'twenty app:generate',
'function:logs': 'twenty function:logs',
'function:execute': 'twenty function:execute',
'app:uninstall': 'twenty app:uninstall',
help: 'twenty help',
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
dependencies: {
'twenty-sdk': '0.5.1',
},
devDependencies: {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
},
};
await fs.writeFile(
join(appDirectory, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf8',
);
};

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