Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad5616c76 |
@@ -106,24 +106,24 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
|
||||
### 2. Create File Structure
|
||||
|
||||
**Create changelog file:**
|
||||
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
|
||||
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
|
||||
|
||||
**Create image folder:**
|
||||
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
|
||||
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
|
||||
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
|
||||
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
|
||||
|
||||
```bash
|
||||
# Create the image folder
|
||||
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
### 3. Move Illustration Files
|
||||
|
||||
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
|
||||
|
||||
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
|
||||
**Naming Convention:** `{VERSION}-descriptive-name.webp`
|
||||
|
||||
@@ -133,8 +133,8 @@ Examples:
|
||||
|
||||
```bash
|
||||
# Move and rename source files, then convert to webp if needed
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
|
||||
```
|
||||
|
||||
### 4. Research Features (if needed)
|
||||
@@ -183,7 +183,7 @@ Description of the third feature.
|
||||
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
|
||||
|
||||
**Reference Previous Changelogs:**
|
||||
- Check `packages/twenty-website/src/content/releases/` for examples
|
||||
- Check `packages/twenty-website-new/src/content/releases/` for examples
|
||||
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
|
||||
|
||||
### 6. Review
|
||||
@@ -191,10 +191,10 @@ Description of the third feature.
|
||||
Open the changelog file for review:
|
||||
```bash
|
||||
# Open in Cursor
|
||||
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
|
||||
# Open image folder to verify illustrations
|
||||
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
Review checklist:
|
||||
@@ -222,8 +222,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
|
||||
[Show full MDX content]
|
||||
|
||||
Images moved to:
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
|
||||
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
|
||||
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
|
||||
|
||||
Please review the content. Once you approve, I'll commit the changes and create the pull request.
|
||||
```
|
||||
@@ -242,8 +242,8 @@ Possible user responses:
|
||||
git status
|
||||
|
||||
# Add files
|
||||
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
|
||||
# Commit
|
||||
git commit -m "Add {VERSION} release changelog"
|
||||
@@ -266,7 +266,7 @@ This release includes:
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
|
||||
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
|
||||
Release date: {DATE}" \
|
||||
--base main \
|
||||
--head {VERSION}
|
||||
@@ -280,13 +280,13 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
|
||||
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
|
||||
- **Convention**: One file per complete version
|
||||
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
|
||||
- **Location**: `packages/twenty-website/src/content/releases/`
|
||||
- **Location**: `packages/twenty-website-new/src/content/releases/`
|
||||
|
||||
### Image Folders
|
||||
- **Format**: `{MAJOR}.{MINOR}/`
|
||||
- **Convention**: One folder per minor version (shared across patches)
|
||||
- **Examples**: `1.6/`, `1.7/`, `2.0/`
|
||||
- **Location**: `packages/twenty-website/public/images/releases/`
|
||||
- **Location**: `packages/twenty-website-new/public/images/releases/`
|
||||
|
||||
### Image Files
|
||||
- **Format**: `{VERSION}-descriptive-name.webp`
|
||||
@@ -311,8 +311,8 @@ Features to document:
|
||||
3. ___________________________
|
||||
|
||||
Branch name: {VERSION}
|
||||
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty app:publish --private --remote target
|
||||
run: yarn twenty deploy --remote target
|
||||
|
||||
@@ -50,4 +50,4 @@ runs:
|
||||
- name: Install
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty app:install --remote target
|
||||
run: yarn twenty install --remote target
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Website translations (twenty-website)
|
||||
# Crowdin CLI configuration for Website translations (twenty-website-new)
|
||||
# Project ID: 4
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
@@ -16,7 +16,7 @@ files:
|
||||
#
|
||||
# Source file - PO file for Lingui
|
||||
#
|
||||
- source: packages/twenty-website/src/locales/en.po
|
||||
- source: packages/twenty-website-new/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
create-twenty-app --version
|
||||
mkdir -p /tmp/e2e-test-workspace
|
||||
cd /tmp/e2e-test-workspace
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
|
||||
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --workspace-url http://localhost:3000
|
||||
|
||||
- name: Install scaffolded app dependencies
|
||||
run: |
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
- name: Authenticate with twenty-server
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
|
||||
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
|
||||
run: |
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
files: |
|
||||
package.json
|
||||
yarn.lock
|
||||
packages/twenty-website/**
|
||||
packages/twenty-website-new/**
|
||||
packages/twenty-shared/**
|
||||
website-task:
|
||||
needs: changed-files-check
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- name: Trigger preview environment workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# Strict mode fails if there are missing website translations.
|
||||
- name: Compile website translations
|
||||
id: compile_translations_strict
|
||||
run: npx nx run twenty-website:lingui:compile --strict
|
||||
run: npx nx run twenty-website-new:lingui:compile --strict
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
@@ -71,8 +71,8 @@ jobs:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: 'packages/twenty-website/src/locales/en.po'
|
||||
translation: 'packages/twenty-website/src/locales/%locale%.po'
|
||||
source: 'packages/twenty-website-new/src/locales/en.po'
|
||||
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
@@ -101,9 +101,9 @@ jobs:
|
||||
- name: Compile website translations
|
||||
id: compile_translations
|
||||
run: |
|
||||
npx nx run twenty-website:lingui:compile
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
git status
|
||||
git add packages/twenty-website/src/locales
|
||||
git add packages/twenty-website-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'packages/twenty-website/**'
|
||||
- 'packages/twenty-website-new/**'
|
||||
- '.github/crowdin-website.yml'
|
||||
- '.github/workflows/website-i18n-push.yaml'
|
||||
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Extract website translations
|
||||
run: npx nx run twenty-website:lingui:extract
|
||||
run: npx nx run twenty-website-new:lingui:extract
|
||||
|
||||
- name: Check and commit extracted files
|
||||
id: check_extract_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website/src/locales
|
||||
git add packages/twenty-website-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: extract website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
@@ -57,14 +57,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Compile website translations
|
||||
run: npx nx run twenty-website:lingui:compile
|
||||
run: npx nx run twenty-website-new:lingui:compile
|
||||
|
||||
- name: Check and commit compiled files
|
||||
id: check_compile_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website/src/locales/generated
|
||||
git add packages/twenty-website-new/src/locales/generated
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: 'Website Preview Dispatch'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed, labeled]
|
||||
paths:
|
||||
- packages/twenty-website/**
|
||||
- .github/workflows/website-preview-dispatch.yaml
|
||||
|
||||
concurrency:
|
||||
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
|
||||
# would resolve to the base branch under pull_request and collide.
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-build:
|
||||
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
|
||||
# call would fail anyway — skip explicitly to avoid noise.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action != 'closed' && (
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
|
||||
(
|
||||
(
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR'
|
||||
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-build to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-build \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
|
||||
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
|
||||
|
||||
trigger-cleanup:
|
||||
# Covers both merge and close-without-merge — pull_request `closed` fires
|
||||
# for both. PRs left open forever are covered by OpenNext's
|
||||
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
|
||||
# open-next.config.ts, so nothing leaks even if cleanup never runs.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-cleanup to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-cleanup \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
Vendored
+2
-2
@@ -81,8 +81,8 @@
|
||||
"path": "../packages/twenty-sdk"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-website",
|
||||
"path": "../packages/twenty-website"
|
||||
"name": "packages/twenty-website-new",
|
||||
"path": "../packages/twenty-website-new"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -110,7 +110,7 @@ packages/
|
||||
├── twenty-ui/ # Shared UI components library
|
||||
├── twenty-shared/ # Common types and utilities
|
||||
├── twenty-emails/ # Email templates with React Email
|
||||
├── twenty-website/ # Next.js marketing website
|
||||
├── twenty-website-new/ # Next.js marketing website
|
||||
├── twenty-docs/ # Documentation website
|
||||
├── twenty-zapier/ # Zapier integration
|
||||
└── twenty-e2e-testing/ # Playwright E2E tests
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 align="center" >The #1 Open-Source CRM</h2>
|
||||
|
||||
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
|
||||
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
@@ -24,17 +24,17 @@
|
||||
|
||||
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
|
||||
|
||||
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||
|
||||
<br />
|
||||
|
||||
# Installation
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
|
||||
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
|
||||
Scaffold a new app with the Twenty CLI:
|
||||
|
||||
@@ -63,12 +63,12 @@ export default defineObject({
|
||||
Then ship it to your workspace:
|
||||
|
||||
```bash
|
||||
npx twenty app:publish --private
|
||||
npx twenty deploy
|
||||
```
|
||||
|
||||
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
|
||||
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
|
||||
|
||||
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
|
||||
|
||||
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
|
||||
|
||||
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
|
||||
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Stack
|
||||
|
||||
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
|
||||
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
|
||||
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
|
||||
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
|
||||
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
|
||||
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
|
||||
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
|
||||
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
|
||||
|
||||
|
||||
|
||||
# Thanks
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
|
||||
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
|
||||
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
|
||||
</p>
|
||||
|
||||
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Join the Community
|
||||
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
"packages/twenty-ui",
|
||||
"packages/twenty-utils",
|
||||
"packages/twenty-zapier",
|
||||
"packages/twenty-website",
|
||||
"packages/twenty-website-new",
|
||||
"packages/twenty-docs",
|
||||
"packages/twenty-e2e-testing",
|
||||
"packages/twenty-shared",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<a href="https://twenty.com">
|
||||
<picture>
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
|
||||
</picture>
|
||||
</a>
|
||||
<h1>Create Twenty App</h1>
|
||||
@@ -35,7 +35,7 @@ The scaffolder will:
|
||||
| `--name <name>` | Set the app name |
|
||||
| `--display-name <displayName>` | Set the display name |
|
||||
| `--description <description>` | Set the description |
|
||||
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--workspace-url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
|
||||
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
|
||||
|
||||
## Documentation
|
||||
@@ -48,8 +48,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
|
||||
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
|
||||
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
|
||||
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
|
||||
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.7.0",
|
||||
"version": "2.5.1",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -18,8 +18,11 @@ const program = new Command(packageJson.name)
|
||||
.option('-n, --name <name>', 'Application name')
|
||||
.option('-d, --display-name <displayName>', 'Application display name')
|
||||
.option('--description <description>', 'Application description')
|
||||
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --url]')
|
||||
.option(
|
||||
'--workspace-url <workspaceUrl>',
|
||||
'Twenty workspace URL (default: http://localhost:2020)',
|
||||
)
|
||||
.option('--api-url <apiUrl>', '[deprecated: use --workspace-url]')
|
||||
.option(
|
||||
'--authentication-method <method>',
|
||||
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
|
||||
@@ -32,7 +35,7 @@ const program = new Command(packageJson.name)
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
workspaceUrl?: string;
|
||||
apiUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
},
|
||||
@@ -65,18 +68,18 @@ const program = new Command(packageJson.name)
|
||||
|
||||
if (options?.apiUrl) {
|
||||
console.warn(
|
||||
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
|
||||
chalk.yellow(
|
||||
'Warning: --api-url is deprecated. Use --workspace-url instead.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
name: options?.name,
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
serverUrl,
|
||||
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
|
||||
authenticationMethod: options?.authenticationMethod,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -49,19 +49,19 @@
|
||||
|
||||
## Best practice
|
||||
|
||||
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
|
||||
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
|
||||
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------- |
|
||||
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
| Entity type | Command | Generated file |
|
||||
| -------------------- | ------------------------------------ | ------------------------------------- |
|
||||
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
|
||||
This helps automatically generate required IDs etc.
|
||||
|
||||
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
|
||||
## Useful Commands
|
||||
|
||||
- `yarn twenty dev` - Start the development server and sync your app
|
||||
- `yarn twenty docker:status` - Check the local Twenty server status
|
||||
- `yarn twenty docker:start` - Start the local Twenty server
|
||||
- `yarn twenty server status` - Check the local Twenty server status
|
||||
- `yarn twenty server start` - Start the local Twenty server
|
||||
- `yarn test` - Run integration tests
|
||||
|
||||
## Learn More
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
+16
-28
@@ -1,11 +1,4 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
Avatar,
|
||||
IconBox,
|
||||
IconHierarchy,
|
||||
IconLayout,
|
||||
IconSettingsAutomation,
|
||||
} from 'twenty-sdk/ui';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -37,7 +30,7 @@ const CATEGORIES = [
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
label: 'LOGIC FUNCTION',
|
||||
label: 'SERVERLESS FUNCT.',
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
@@ -66,6 +59,15 @@ const CATEGORIES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ItemIcon = ({ color }: { color: string }) => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
|
||||
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
|
||||
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
|
||||
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path
|
||||
@@ -91,18 +93,6 @@ const CategoryCard = ({
|
||||
}) => {
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
|
||||
const CategoryIcon = () => {
|
||||
if (title === 'Data model') {
|
||||
return <IconHierarchy color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Logic') {
|
||||
return <IconSettingsAutomation color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Layout') {
|
||||
return <IconLayout color={color} size={'20px'} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -121,12 +111,8 @@ const CategoryCard = ({
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: `${color}22`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<CategoryIcon />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
@@ -168,7 +154,7 @@ const CategoryCard = ({
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
<IconBox color={color} size={'20px'} />
|
||||
<ItemIcon color={color} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
@@ -204,11 +190,13 @@ const MainPage = () => {
|
||||
padding: '40px',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
{/*<Avatar
|
||||
avatarUrl={getPublicAssetUrl('logo.svg')}
|
||||
placeholder={APP_DISPLAY_NAME}
|
||||
placeholderColorSeed={APP_DISPLAY_NAME}
|
||||
type="squared"
|
||||
size="xl"
|
||||
/>
|
||||
/>*/}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
@@ -232,7 +220,7 @@ const MainPage = () => {
|
||||
You can now add content to your app.
|
||||
</span>
|
||||
<a
|
||||
href="/settings/applications#installed"
|
||||
href="/settings/applications"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DEV_API_URL,
|
||||
serverStart,
|
||||
} from 'twenty-sdk/cli';
|
||||
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
getDockerInstallInstructions,
|
||||
isDockerInstalled,
|
||||
@@ -33,7 +33,7 @@ type CreateAppOptions = {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
serverUrl?: string;
|
||||
workspaceUrl?: string;
|
||||
authenticationMethod?: AuthenticationMethod;
|
||||
};
|
||||
|
||||
@@ -45,9 +45,9 @@ export class CreateAppCommand {
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
this.getAppInfos(options);
|
||||
|
||||
const serverUrl = options.serverUrl ?? DEV_API_URL;
|
||||
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
|
||||
|
||||
const skipLocalInstance = serverUrl !== DEV_API_URL;
|
||||
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
|
||||
|
||||
if (!skipLocalInstance && !isDockerInstalled()) {
|
||||
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
|
||||
@@ -118,7 +118,7 @@ export class CreateAppCommand {
|
||||
console.log('');
|
||||
|
||||
let authSucceeded = false;
|
||||
let resolvedServerUrl = serverUrl;
|
||||
let resolvedWorkspaceUrl = workspaceUrl;
|
||||
let serverReady = skipLocalInstance;
|
||||
|
||||
if (!skipLocalInstance) {
|
||||
@@ -126,7 +126,7 @@ export class CreateAppCommand {
|
||||
const serverResult = await this.ensureDockerServer(dockerPullPromise);
|
||||
|
||||
if (isDefined(serverResult.url)) {
|
||||
resolvedServerUrl = serverResult.url;
|
||||
resolvedWorkspaceUrl = serverResult.url;
|
||||
serverReady = true;
|
||||
}
|
||||
}
|
||||
@@ -134,16 +134,18 @@ export class CreateAppCommand {
|
||||
if (serverReady) {
|
||||
this.logNextStep('Authenticating');
|
||||
|
||||
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
|
||||
authSucceeded = await this.tryExistingAuth(resolvedWorkspaceUrl);
|
||||
|
||||
if (authSucceeded) {
|
||||
this.logDetail('Reusing existing credentials');
|
||||
} else if (authenticationMethod === 'oauth') {
|
||||
this.logDetail('Starting OAuth flow');
|
||||
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
|
||||
authSucceeded =
|
||||
await this.authenticateWithOAuth(resolvedWorkspaceUrl);
|
||||
} else {
|
||||
this.logDetail('Using development API key');
|
||||
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
|
||||
authSucceeded =
|
||||
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +165,10 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
if (syncSucceeded) {
|
||||
await this.openMainPage(appDirectory, resolvedServerUrl);
|
||||
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
|
||||
}
|
||||
|
||||
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
|
||||
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red('\nCreate application failed:'),
|
||||
@@ -313,7 +315,7 @@ export class CreateAppCommand {
|
||||
|
||||
private async openMainPage(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
workspaceUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
@@ -326,7 +328,7 @@ export class CreateAppCommand {
|
||||
|
||||
const [universalIdentifier, frontUrl] = await Promise.all([
|
||||
this.readMainPageLayoutUniversalIdentifier(appDirectory),
|
||||
this.resolveWorkspaceFrontUrl(serverUrl, token),
|
||||
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
|
||||
]);
|
||||
|
||||
if (!universalIdentifier || !frontUrl) {
|
||||
@@ -334,7 +336,7 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
const pageLayoutId = await this.resolvePageLayoutId(
|
||||
serverUrl,
|
||||
workspaceUrl,
|
||||
universalIdentifier,
|
||||
token,
|
||||
);
|
||||
@@ -353,12 +355,12 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
private async resolveWorkspaceFrontUrl(
|
||||
serverUrl: string,
|
||||
workspaceUrl: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
|
||||
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -387,7 +389,7 @@ export class CreateAppCommand {
|
||||
|
||||
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
|
||||
|
||||
return frontUrl ? normalizeUrl(frontUrl) : null;
|
||||
return frontUrl?.replace(/\/+$/, '') ?? null;
|
||||
}
|
||||
|
||||
private async readMainPageLayoutUniversalIdentifier(
|
||||
@@ -408,13 +410,13 @@ export class CreateAppCommand {
|
||||
}
|
||||
|
||||
private async resolvePageLayoutId(
|
||||
serverUrl: string,
|
||||
workspaceUrl: string,
|
||||
universalIdentifier: string,
|
||||
token: string,
|
||||
): Promise<string | null> {
|
||||
const query = `{ getPageLayouts { id universalIdentifier } }`;
|
||||
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -501,7 +503,7 @@ export class CreateAppCommand {
|
||||
});
|
||||
}
|
||||
|
||||
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
|
||||
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const configService = new ConfigService();
|
||||
const remoteNames = await configService.getRemotes();
|
||||
@@ -509,7 +511,7 @@ export class CreateAppCommand {
|
||||
for (const remoteName of remoteNames) {
|
||||
const remoteConfig = await configService.getConfigForRemote(remoteName);
|
||||
|
||||
if (remoteConfig.apiUrl !== serverUrl) {
|
||||
if (remoteConfig.apiUrl !== workspaceUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -519,7 +521,7 @@ export class CreateAppCommand {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/metadata`, {
|
||||
const response = await fetch(`${workspaceUrl}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -553,11 +555,11 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
|
||||
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await authLogin({
|
||||
apiKey: DEV_API_KEY,
|
||||
apiUrl: serverUrl,
|
||||
apiUrl: workspaceUrl,
|
||||
remote: 'local',
|
||||
});
|
||||
|
||||
@@ -572,7 +574,7 @@ export class CreateAppCommand {
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -580,7 +582,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
|
||||
' Authentication failed. Run `yarn twenty remote add --local` manually.',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -596,21 +598,21 @@ export class CreateAppCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
|
||||
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const remoteName = this.deriveRemoteName(serverUrl);
|
||||
const remoteName = this.deriveRemoteName(workspaceUrl);
|
||||
|
||||
ConfigService.setActiveRemote(remoteName);
|
||||
|
||||
this.logDetail('Opening browser for OAuth...');
|
||||
|
||||
const result = await authLoginOAuth({ apiUrl: serverUrl });
|
||||
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
|
||||
|
||||
if (result.success) {
|
||||
const configService = new ConfigService();
|
||||
|
||||
await configService.setDefaultRemote(remoteName);
|
||||
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
|
||||
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -618,7 +620,7 @@ export class CreateAppCommand {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` OAuth failed: ${result.error.message}\n` +
|
||||
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -626,7 +628,7 @@ export class CreateAppCommand {
|
||||
} catch {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
|
||||
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -636,7 +638,7 @@ export class CreateAppCommand {
|
||||
|
||||
private logSuccess(
|
||||
appDirectory: string,
|
||||
serverUrl: string,
|
||||
workspaceUrl: string,
|
||||
authSucceeded: boolean,
|
||||
): void {
|
||||
const dirName = basename(appDirectory);
|
||||
@@ -654,7 +656,9 @@ export class CreateAppCommand {
|
||||
if (!authSucceeded) {
|
||||
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
|
||||
console.log(
|
||||
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
|
||||
chalk.cyan(
|
||||
' yarn twenty remote add --api-url <your-instance-url>\n',
|
||||
),
|
||||
);
|
||||
stepNumber++;
|
||||
}
|
||||
@@ -664,7 +668,7 @@ export class CreateAppCommand {
|
||||
stepNumber++;
|
||||
|
||||
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
|
||||
console.log(chalk.cyan(` ${serverUrl}\n`));
|
||||
console.log(chalk.cyan(` ${workspaceUrl}\n`));
|
||||
|
||||
console.log(
|
||||
chalk.gray(
|
||||
|
||||
@@ -17,7 +17,6 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
|
||||
'constants',
|
||||
'universal-identifiers.ts',
|
||||
);
|
||||
const YARNRC_PATH = 'yarnrc.yml';
|
||||
|
||||
// Template content matching template/src/constants/universal-identifiers.ts
|
||||
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
|
||||
@@ -174,27 +173,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
expect(publicDirectoryContents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
|
||||
await fs.writeFile(
|
||||
join(testAppDirectory, YARNRC_PATH),
|
||||
'nodeLinker: node-modules',
|
||||
);
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
|
||||
onProgress?.('Copying base template');
|
||||
await fs.copy(join(__dirname, './constants/template'), appDirectory);
|
||||
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github)');
|
||||
await renameDotfiles({ appDirectory });
|
||||
|
||||
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
|
||||
@@ -47,7 +47,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
const renames = [
|
||||
{ from: 'gitignore', to: '.gitignore' },
|
||||
{ from: 'github', to: '.github' },
|
||||
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
|
||||
];
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
|
||||
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
|
||||
' Then run this command again.',
|
||||
'',
|
||||
' Alternatively, connect to an existing Twenty instance:',
|
||||
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
|
||||
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ yarn install
|
||||
# Register your local Twenty server as a remote (interactive prompt).
|
||||
# When asked for the URL use http://localhost:2021 and paste an API key
|
||||
# from Settings -> Developers in the Twenty UI.
|
||||
yarn twenty remote:add
|
||||
yarn twenty remote add
|
||||
|
||||
# Build, install, and watch for changes.
|
||||
yarn twenty dev
|
||||
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
|
||||
```bash
|
||||
cd packages/twenty-apps/community/github-connector
|
||||
yarn install
|
||||
yarn twenty remote:add # same prompts as above
|
||||
yarn twenty app:install # builds and installs once
|
||||
yarn twenty remote add # same prompts as above
|
||||
yarn twenty install # builds and installs once
|
||||
```
|
||||
|
||||
## Configure authentication
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"dev": "twenty dev",
|
||||
"exec": "twenty dev:fn-exec",
|
||||
"uninstall": "twenty app:uninstall",
|
||||
"exec": "twenty exec",
|
||||
"uninstall": "twenty uninstall",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Remotes & Authentication
|
||||
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote:status # Check auth status
|
||||
yarn twenty remote:use # Set default remote
|
||||
yarn twenty remote:list # List all configured remotes
|
||||
yarn twenty remote:remove <name> # Remove a remote
|
||||
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
|
||||
yarn twenty remote status # Check auth status
|
||||
yarn twenty remote switch # Switch default remote
|
||||
yarn twenty remote list # List all configured remotes
|
||||
yarn twenty remote remove <name> # Remove a remote
|
||||
|
||||
# Application
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty dev:function:logs # Stream function logs
|
||||
yarn twenty dev:function:exec # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty logs # Stream function logs
|
||||
yarn twenty exec # Execute a function with JSON payload
|
||||
yarn twenty uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeAll(async () => {
|
||||
if (!apiUrl || !token) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error(
|
||||
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
|
||||
'Start a local server: yarn twenty docker:start\n' +
|
||||
'Start a local server: yarn twenty server start\n' +
|
||||
'Or set them in vitest env config.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,15 +64,15 @@ cd packages/twenty-apps/internal/twenty-linear
|
||||
# For day-to-day development (publish + install + watch in one):
|
||||
yarn twenty dev
|
||||
|
||||
# Manual publish flow (publish registers the app, install activates it):
|
||||
yarn twenty app:publish --private
|
||||
yarn twenty app:install
|
||||
# Manual publish flow (deploy registers the app, install activates it):
|
||||
yarn twenty deploy
|
||||
yarn twenty install
|
||||
```
|
||||
|
||||
`twenty dev` is recommended for iteration — it publishes, installs, and
|
||||
watches for changes in one command. Use `twenty app:publish --private` +
|
||||
`twenty app:install` when you want to control each step separately (e.g.
|
||||
deploying to a production server without auto-installing).
|
||||
watches for changes in one command. Use `twenty deploy` + `twenty install`
|
||||
when you want to control each step separately (e.g. deploying to a
|
||||
production server without auto-installing).
|
||||
|
||||
This serves as the reference implementation for Twenty's
|
||||
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"ignorePatterns": ["node_modules", "dist"],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
# twenty-slack
|
||||
|
||||
Slack tools for **Twenty workflows** and **agents** (the same logic functions
|
||||
are available as workflow steps and as tools where your deployment exposes
|
||||
them). Uses the official
|
||||
[`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) `WebClient`
|
||||
(Slack retries and error types).
|
||||
|
||||
## What you can do
|
||||
|
||||
Once the app is installed and Slack is **connected** (see **Twenty setup**
|
||||
below):
|
||||
|
||||
- **Workflow steps** — post, update, or delete bot messages; send ephemerals;
|
||||
add reactions; list channels. Pick a **workspace shared** or **just for me**
|
||||
connection; steps run with that token.
|
||||
- **Agents / AI** — when your Twenty instance surfaces app tools to the model,
|
||||
these functions can be invoked the same way as other app logic functions.
|
||||
- **Quick-send** — command menu **Send Slack message** opens a side panel to
|
||||
pick a channel and post (same Slack connection as workflows).
|
||||
|
||||
## Tools
|
||||
|
||||
| Name | Slack API |
|
||||
|------|-----------|
|
||||
| `slack-post-message` | `chat.postMessage` |
|
||||
| `slack-post-ephemeral-message` | `chat.postEphemeral` |
|
||||
| `slack-update-message` | `chat.update` |
|
||||
| `slack-delete-message` | `chat.delete` |
|
||||
| `slack-add-reaction` | `reactions.add` |
|
||||
| `slack-list-channels` | `conversations.list` |
|
||||
|
||||
### Workflow field names (for authors)
|
||||
|
||||
Fields use camelCase names in the step UI, for example **`slackChannelId`** (Slack channel or DM: **name** or **ID**), **`messageText`**, and **`messageTimestamp`** (Slack’s per-message id — same value as tool output **`slackTs`** when chaining steps). Optional **`parentMessageTimestamp`** is only for **thread replies**. Post / update / ephemeral steps support optional **`messageFormat`**: **`markdown`** sends the body as Slack **`markdown_text`** (e.g. **`**bold**`**), **`plain`** sends **`text`** with markup disabled, omit uses Slack’s default for **`text`**. Ephemeral steps use **`recipientSlackUserId`**; reactions use **`emojiName`** (Slack shortcode, for example `white_check_mark`). Updating a message uses **`newMessageText`**.
|
||||
|
||||
### Quick-send command menu item
|
||||
|
||||
This app also ships a global command menu item — **Send Slack message** — that opens a side-panel form to pick a channel (from `conversations.list`) and post a message via `chat.postMessage`. The form is backed by two HTTP routes exposed by the app:
|
||||
|
||||
- `GET /slack/channels` — lists channels visible to the bot (mirrors `slack-list-channels`).
|
||||
- `POST /slack/messages` — posts a message (mirrors `slack-post-message`).
|
||||
|
||||
Both routes require an authenticated Twenty user and use the same shared Slack connection as the workflow tools.
|
||||
|
||||
### Prerequisites (Slack workspace)
|
||||
|
||||
- You can **install** the Slack app on a workspace you administer (or get an
|
||||
admin to approve it).
|
||||
- For **posting**: invite the bot to the channel, **or** rely on
|
||||
**`chat:write.public`** (included in OAuth) to post to **public** channels
|
||||
without joining — private channels still require membership.
|
||||
- **`slack-list-channels`** and the quick-send channel picker need
|
||||
**`channels:read`** / **`groups:read`** on the token (requested at connect
|
||||
time; see below).
|
||||
|
||||
## Slack app setup
|
||||
|
||||
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
|
||||
(dedicated to this Twenty app — do not reuse for other Twenty apps).
|
||||
2. **OAuth & Permissions** → **Bot Token Scopes**. Twenty uses Slack’s **bot**
|
||||
OAuth (`oauth/v2/authorize` with `scope=…`). You must add scopes here — not
|
||||
only under **User Token Scopes** — or Slack will refuse install with *“doesn’t
|
||||
have a bot user to install”* until at least one bot scope exists.
|
||||
|
||||
The scopes **requested at connect time** are defined in
|
||||
`src/connection-providers/slack-connection.ts` and must also appear under
|
||||
**Bot Token Scopes** on the Slack app (Slack validates the set). Current
|
||||
list:
|
||||
|
||||
- `channels:read` — `conversations.list` / channel picker (public)
|
||||
- `chat:write` — post, update, delete, ephemeral
|
||||
- `chat:write.public` — post to public channels without the bot joining
|
||||
- `groups:read` — list private channels the bot is in
|
||||
- `reactions:write` — add reactions
|
||||
|
||||
If you **add or remove** scopes in that file or in the Slack app, existing
|
||||
installs must **re-authorize** (disconnect and **Add connection** again, or
|
||||
reinstall the Slack app to the workspace) so the token picks up new scopes.
|
||||
|
||||
3. Set the **Redirect URL** on the Slack app to
|
||||
`<YOUR_TWENTY_SERVER_URL>/apps/oauth/callback` — the same origin your
|
||||
Twenty **server** uses for API routes (the callback is not served by the SPA
|
||||
alone). Local monorepo dev often uses `http://localhost:3000` (confirm the
|
||||
port your `twenty-server` / `SERVER_URL` actually uses).
|
||||
|
||||
**Slack “PKCE” app setting vs `localhost`:** If you turn on **PKCE** for the
|
||||
Slack app under **OAuth & Permissions**, Slack treats `http://localhost…`
|
||||
redirect URLs as **desktop** redirects. **Desktop redirects cannot request
|
||||
bot scopes**, so OAuth will fail for this integration while you use a
|
||||
localhost callback. For local dev you can either **leave Slack’s PKCE
|
||||
opt-in disabled** on that Slack app, or use an **`https://` redirect** (for
|
||||
example a tunnel such as ngrok or Cloudflare Tunnel to your local server),
|
||||
register that URL in the Slack app, and point Twenty’s `SERVER_URL` at the
|
||||
same public base URL. See Slack’s [Using
|
||||
PKCE](https://docs.slack.dev/authentication/using-pkce) docs (this is
|
||||
separate from Twenty sending a PKCE challenge on the authorize request).
|
||||
|
||||
4. Copy the Slack **Client ID** and **Client Secret**.
|
||||
|
||||
## Twenty setup
|
||||
|
||||
1. Register / install this app on your Twenty server (`twenty-slack`).
|
||||
2. In **Settings → Applications → Twenty Slack**, open the **Application registration**
|
||||
tab (admin-only) and set:
|
||||
- `SLACK_CLIENT_ID`
|
||||
- `SLACK_CLIENT_SECRET`
|
||||
3. In the same app, open the **Connections** tab and click **Add connection**.
|
||||
Choose **Just for me** or **Workspace shared**, then complete the Slack sign-in.
|
||||
|
||||
Once connected, workflow steps use the connection access token: a
|
||||
**workspace** connection is preferred when present; otherwise the first
|
||||
connection returned for the Slack provider is used (see
|
||||
`src/logic-functions/utils/get-slack-connection.ts`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-slack
|
||||
yarn install
|
||||
yarn lint
|
||||
yarn test
|
||||
```
|
||||
|
||||
Use `yarn twenty dev` from this directory to develop against a local Twenty
|
||||
instance (see other internal apps in this monorepo).
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "twenty-slack",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack workflow connector for Twenty",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"twenty-app"
|
||||
],
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/web-api": "^7.8.0",
|
||||
"twenty-sdk": "2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 127 127"
|
||||
fill="none"
|
||||
role="img"
|
||||
>
|
||||
<title>Slack</title>
|
||||
<path
|
||||
fill="#E01E5A"
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
/>
|
||||
<path
|
||||
fill="#36C5F0"
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
/>
|
||||
<path
|
||||
fill="#2EB67D"
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#ECB22E"
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,37 +0,0 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Twenty Slack',
|
||||
description:
|
||||
'Connect Slack to Twenty. Each workspace member (or a shared workspace connection) can authenticate Slack; workflow steps then post messages, ephemerals, updates, deletes, and reactions on behalf of that connection.',
|
||||
logoUrl: 'public/twenty-slack.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Communication',
|
||||
aboutDescription:
|
||||
'Official Slack connector for Twenty CRM. Install a Slack app on api.slack.com, add the OAuth client ID and secret as server variables, then connect Slack per member or as a shared workspace connection. Use workflow actions to post, update, or delete messages, send ephemeral notes, and add reactions using the connected account.',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
serverVariables: {
|
||||
SLACK_CLIENT_ID: {
|
||||
description:
|
||||
'OAuth client ID from your Slack app (api.slack.com/apps). Public in OAuth flows; only the client secret must stay confidential.',
|
||||
isSecret: false,
|
||||
isRequired: true,
|
||||
},
|
||||
SLACK_CLIENT_SECRET: {
|
||||
description:
|
||||
'OAuth client secret from your Slack app. Stored encrypted; never exposed in API responses.',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Send Slack message',
|
||||
shortLabel: 'Slack message',
|
||||
icon: 'IconBrandSlack',
|
||||
isPinned: false,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier:
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
-673
@@ -1,673 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type SyntheticEvent,
|
||||
} from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
closeSidePanel,
|
||||
enqueueSnackbar,
|
||||
unmountFrontComponent,
|
||||
} from 'twenty-sdk/front-component';
|
||||
import { themeCssVariables } from 'twenty-sdk/ui';
|
||||
|
||||
import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
type SlackChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isMember: boolean;
|
||||
numMembers: number;
|
||||
topic: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
type ListChannelsResponse = {
|
||||
success: boolean;
|
||||
channels?: SlackChannel[];
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type PostMessageResponse = {
|
||||
success: boolean;
|
||||
slackTs?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type PostedMessage = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
slackTs?: string;
|
||||
};
|
||||
|
||||
type MessageFormat = 'plain' | 'markdown';
|
||||
|
||||
const FORMAT_OPTIONS: { value: MessageFormat; label: string }[] = [
|
||||
{ value: 'plain', label: 'Plain text' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
];
|
||||
|
||||
const isMessageFormat = (value: string): value is MessageFormat =>
|
||||
value === 'plain' || value === 'markdown';
|
||||
|
||||
const readSerializedValue = (
|
||||
event: SyntheticEvent<HTMLElement>,
|
||||
): string | undefined => {
|
||||
const object = event as {
|
||||
detail?: { value?: string };
|
||||
value?: string;
|
||||
target?: { value?: string };
|
||||
};
|
||||
|
||||
if (typeof object.detail?.value === 'string') {
|
||||
return object.detail.value;
|
||||
}
|
||||
if (typeof object.value === 'string') {
|
||||
return object.value;
|
||||
}
|
||||
if (typeof object.target?.value === 'string') {
|
||||
return object.target.value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onValueChange =
|
||||
(setValue: (value: string) => void) =>
|
||||
(event: SyntheticEvent<HTMLElement>) => {
|
||||
const value = readSerializedValue(event);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
setValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const callAppRoute = async <TResponse,>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<TResponse> => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token =
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiBaseUrl || !token) {
|
||||
throw new Error('App is missing API URL or access token configuration.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/s${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
messages?: string[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
errorMessage = parsed.messages?.[0] ?? parsed.message ?? parsed.error;
|
||||
} catch {
|
||||
// Body is not JSON; fall through to raw text or status message.
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
errorMessage ??
|
||||
(text.length > 0
|
||||
? text.slice(0, 200)
|
||||
: `Request failed with status ${response.status}.`),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>;
|
||||
};
|
||||
|
||||
const sortChannels = (channels: SlackChannel[]): SlackChannel[] =>
|
||||
[...channels].sort((a, b) => {
|
||||
if (a.isMember !== b.isMember) {
|
||||
return a.isMember ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const formatChannelOptionLabel = (channel: SlackChannel): string => {
|
||||
const prefix = channel.isPrivate ? '🔒' : '#';
|
||||
const suffix = channel.isMember ? '' : ' — bot is not a member';
|
||||
|
||||
return `${prefix} ${channel.name}${suffix}`;
|
||||
};
|
||||
|
||||
const getChannelHelperText = (
|
||||
selectedChannel: SlackChannel | undefined,
|
||||
): string => {
|
||||
if (selectedChannel === undefined) {
|
||||
return 'Pick a channel to post to.';
|
||||
}
|
||||
|
||||
if (selectedChannel.isMember) {
|
||||
return selectedChannel.isPrivate
|
||||
? 'Private channel · bot is a member.'
|
||||
: 'Public channel · bot is a member.';
|
||||
}
|
||||
|
||||
return 'Bot is not a member of this channel — it must be invited before it can post.';
|
||||
};
|
||||
|
||||
const getStyles = (): Record<string, CSSProperties> => ({
|
||||
container: {
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
background: themeCssVariables.background.primary,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
header: {
|
||||
padding: themeCssVariables.spacing[4],
|
||||
borderBottom: `1px solid ${themeCssVariables.border.color.light}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerTitleBlock: {
|
||||
marginBottom: themeCssVariables.spacing[4],
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.semiBold,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
margin: 0,
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.regular,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
margin: 0,
|
||||
marginTop: themeCssVariables.spacing[2],
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: themeCssVariables.spacing[4],
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[4],
|
||||
overflowY: 'auto',
|
||||
},
|
||||
field: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
},
|
||||
fieldGrowing: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
label: {
|
||||
fontSize: themeCssVariables.font.size.xs,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
color: themeCssVariables.font.color.secondary,
|
||||
},
|
||||
helperText: {
|
||||
fontSize: themeCssVariables.font.size.xs,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
},
|
||||
select: {
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
background: themeCssVariables.background.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]}`,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
height: themeCssVariables.spacing[8],
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
textarea: {
|
||||
background: themeCssVariables.background.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
padding: themeCssVariables.spacing[3],
|
||||
color: themeCssVariables.font.color.primary,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
lineHeight: 1.5,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
flex: 1,
|
||||
minHeight: themeCssVariables.spacing[20],
|
||||
},
|
||||
footer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: themeCssVariables.spacing[2],
|
||||
padding: themeCssVariables.spacing[3],
|
||||
borderTop: `1px solid ${themeCssVariables.border.color.light}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
buttonBase: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
height: themeCssVariables.spacing[8],
|
||||
padding: `0 ${themeCssVariables.spacing[3]}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
secondaryButton: {
|
||||
background: themeCssVariables.background.secondary,
|
||||
color: themeCssVariables.font.color.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
},
|
||||
primaryButton: {
|
||||
background: themeCssVariables.color.blue,
|
||||
color: themeCssVariables.font.color.inverted,
|
||||
},
|
||||
primaryButtonDisabled: {
|
||||
background: themeCssVariables.accent.accent4060,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
centeredState: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: themeCssVariables.spacing[4],
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
stateBlock: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: themeCssVariables.spacing[3],
|
||||
maxWidth: '320px',
|
||||
textAlign: 'center',
|
||||
},
|
||||
stateTitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
margin: 0,
|
||||
},
|
||||
stateDescription: {
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
stateError: {
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.danger,
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
const SendMessageForm = () => {
|
||||
const [channels, setChannels] = useState<SlackChannel[]>([]);
|
||||
const [channelsLoading, setChannelsLoading] = useState(true);
|
||||
const [channelsError, setChannelsError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [postedMessage, setPostedMessage] = useState<PostedMessage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [messageFormat, setMessageFormat] = useState<MessageFormat>('markdown');
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
const fetchChannels = useCallback(async () => {
|
||||
try {
|
||||
setChannelsError(null);
|
||||
setChannelsLoading(true);
|
||||
|
||||
const result = await callAppRoute<ListChannelsResponse>(
|
||||
'/slack/channels?limit=200',
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setChannelsError(
|
||||
result.error ?? result.message ?? 'Failed to load Slack channels.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = sortChannels(result.channels ?? []);
|
||||
|
||||
setChannels(sorted);
|
||||
|
||||
const firstMemberChannel = sorted.find((channel) => channel.isMember);
|
||||
|
||||
if (firstMemberChannel !== undefined) {
|
||||
setChannelId(firstMemberChannel.id);
|
||||
} else if (sorted.length === 1) {
|
||||
setChannelId(sorted[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
setChannelsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Slack channels.',
|
||||
);
|
||||
} finally {
|
||||
setChannelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
|
||||
const handleClose = () => {
|
||||
unmountFrontComponent();
|
||||
closeSidePanel();
|
||||
};
|
||||
|
||||
const handleSendAnother = () => {
|
||||
setMessageText('');
|
||||
setPostedMessage(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedMessage = messageText.trim();
|
||||
|
||||
if (channelId === '' || trimmedMessage === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await callAppRoute<PostMessageResponse>(
|
||||
'/slack/messages',
|
||||
'POST',
|
||||
{
|
||||
slackChannelId: channelId,
|
||||
messageText: trimmedMessage,
|
||||
messageFormat,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
result.error ?? result.message ?? 'Failed to send Slack message.',
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = channels.find(
|
||||
(channelItem) => channelItem.id === channelId,
|
||||
);
|
||||
|
||||
setPostedMessage({
|
||||
channelId,
|
||||
channelName: channel?.name ?? channelId,
|
||||
slackTs: result.slackTs,
|
||||
});
|
||||
|
||||
await enqueueSnackbar({
|
||||
message: `Message sent to #${channel?.name ?? channelId}`,
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to send Slack message.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (channelsLoading) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Loading channels…</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
Fetching the list of Slack channels your bot can post to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channelsError !== null) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Couldn't load channels</h2>
|
||||
<p style={styles.stateError}>{channelsError}</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>No channels available</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
The bot doesn't have access to any Slack channels yet. Invite it to
|
||||
a channel and try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (postedMessage !== null) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Message sent</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
{`Posted to #${postedMessage.channelName}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.body}>
|
||||
<p style={styles.stateDescription}>
|
||||
Your message is now visible in Slack.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.primaryButton }}
|
||||
onClick={handleSendAnother}
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedMessage = messageText.trim();
|
||||
const canSubmit = channelId !== '' && trimmedMessage !== '' && !submitting;
|
||||
const selectedChannel = channels.find(
|
||||
(channelItem) => channelItem.id === channelId,
|
||||
);
|
||||
const channelHelperText = getChannelHelperText(selectedChannel);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Send Slack message</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
Post a message to any channel your bot has access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.body}>
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-channel-select" style={styles.label}>
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
id="slack-channel-select"
|
||||
value={channelId}
|
||||
onChange={onValueChange(setChannelId)}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a channel…
|
||||
</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{formatChannelOptionLabel(channel)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>{channelHelperText}</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-message-format-select" style={styles.label}>
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
id="slack-message-format-select"
|
||||
value={messageFormat}
|
||||
onChange={onValueChange((value) => {
|
||||
if (isMessageFormat(value)) {
|
||||
setMessageFormat(value);
|
||||
}
|
||||
})}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
{FORMAT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>
|
||||
{messageFormat === 'markdown'
|
||||
? 'Markdown is rendered using Slack mrkdwn (bold, italics, code, links).'
|
||||
: 'The message will be sent exactly as written, with no formatting.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.fieldGrowing}>
|
||||
<label htmlFor="slack-message-textarea" style={styles.label}>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="slack-message-textarea"
|
||||
value={messageText}
|
||||
onInput={onValueChange(setMessageText)}
|
||||
onChange={onValueChange(setMessageText)}
|
||||
placeholder="Write your message…"
|
||||
style={styles.textarea}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.buttonBase,
|
||||
...styles.primaryButton,
|
||||
...(canSubmit ? {} : styles.primaryButtonDisabled),
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'send-slack-message-form',
|
||||
description:
|
||||
'Form to send a Slack message to any channel the bot can post to, with plain-text or markdown formatting.',
|
||||
component: SendMessageForm,
|
||||
});
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack',
|
||||
displayName: 'Slack',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
|
||||
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
|
||||
revokeEndpoint: 'https://slack.com/api/auth.revoke',
|
||||
scopes: [
|
||||
'channels:read',
|
||||
'chat:write',
|
||||
'chat:write.public',
|
||||
'groups:read',
|
||||
'reactions:write',
|
||||
],
|
||||
clientIdVariable: 'SLACK_CLIENT_ID',
|
||||
clientSecretVariable: 'SLACK_CLIENT_SECRET',
|
||||
tokenRequestContentType: 'form-urlencoded',
|
||||
usePkce: true,
|
||||
},
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'a8c47f21-3b9e-4d2a-8f61-9c0e7d4a2b51';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'b7d36e10-2a8d-4c1b-9e50-8bfd6c3a1940';
|
||||
|
||||
export const SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
|
||||
'8b6c6fd9-8d61-4b6f-9f25-3d92a0f2cc5b';
|
||||
|
||||
export const SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'c6f25d09-1b7c-4e3f-ad42-7aec5b29830f';
|
||||
|
||||
export const SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'd5e14c98-0a6b-4e2e-ac31-69db4a18720e';
|
||||
|
||||
export const SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'e4d03b87-9a5b-4f1d-8b20-58ca3917620d';
|
||||
|
||||
export const SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'f3c92a76-8b4a-4a09-ba19-47b9280651c9';
|
||||
|
||||
export const SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER =
|
||||
'2a8c7f91-4d3e-5b6f-a7c8-9d0e1f2a3b4c';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
|
||||
'3b7d8a92-5e4f-4c7a-b8d9-0e1f2a3b4c5d';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'6e0c3d5f-9a4f-4f62-bc0e-3d5a7f9b4c6e';
|
||||
|
||||
export const SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'7f1d4e60-ab50-4273-9d1f-4e6b8a0c5d7f';
|
||||
|
||||
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'4c8a1b3f-7e2d-4f50-9a8c-1b3e5d7f2a4c';
|
||||
|
||||
export const SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
|
||||
'5d9b2c4e-8f3e-4f50-ab9d-2c4f6e8a3b5d';
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import { type SlackAddReactionInput } from 'src/logic-functions/types/slack-add-reaction-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackAddReactionHandler = async (
|
||||
parameters: SlackAddReactionInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.reactions.add({
|
||||
channel: parameters.slackChannelId,
|
||||
timestamp: parameters.messageTimestamp,
|
||||
name: parameters.emojiName.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Reaction "${parameters.emojiName.trim()}" added to the message.`,
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to add Slack reaction', error);
|
||||
}
|
||||
};
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import { type SlackDeleteMessageInput } from 'src/logic-functions/types/slack-delete-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackDeleteMessageHandler = async (
|
||||
parameters: SlackDeleteMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.chat.delete({
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message deleted.',
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to delete Slack message', error);
|
||||
}
|
||||
};
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type SlackChannelType,
|
||||
type SlackListChannelsInput,
|
||||
} from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
import {
|
||||
type SlackListChannelsResult,
|
||||
type SlackListChannelsResultChannel,
|
||||
} from 'src/logic-functions/types/slack-list-channels-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 1000;
|
||||
const SLACK_PAGE_SIZE = 200;
|
||||
|
||||
const SLACK_TYPES_BY_CHANNEL_TYPE = {
|
||||
Public: 'public_channel',
|
||||
Private: 'private_channel',
|
||||
All: 'public_channel,private_channel',
|
||||
} as const satisfies Record<SlackChannelType, string>;
|
||||
|
||||
export const slackListChannelsHandler = async (
|
||||
parameters: SlackListChannelsInput,
|
||||
): Promise<SlackListChannelsResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const limit = Math.min(
|
||||
Math.max(parameters.limit ?? DEFAULT_LIMIT, 1),
|
||||
MAX_LIMIT,
|
||||
);
|
||||
const excludeArchived = parameters.excludeArchived ?? true;
|
||||
const types = SLACK_TYPES_BY_CHANNEL_TYPE[parameters.channelType ?? 'All'];
|
||||
|
||||
const channels: SlackListChannelsResultChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
try {
|
||||
while (channels.length < limit) {
|
||||
const remaining = limit - channels.length;
|
||||
const response = await client.conversations.list({
|
||||
types,
|
||||
exclude_archived: excludeArchived,
|
||||
limit: Math.min(SLACK_PAGE_SIZE, remaining),
|
||||
cursor,
|
||||
});
|
||||
|
||||
const page = response.channels ?? [];
|
||||
|
||||
for (const channel of page) {
|
||||
if (channels.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isDefined(channel.id) || !isDefined(channel.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channels.push({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
isPrivate: channel.is_private === true,
|
||||
isArchived: channel.is_archived === true,
|
||||
isMember: channel.is_member === true,
|
||||
numMembers: channel.num_members ?? 0,
|
||||
topic: channel.topic?.value ?? '',
|
||||
purpose: channel.purpose?.value ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const nextCursor = response.response_metadata?.next_cursor;
|
||||
|
||||
if (!isDefined(nextCursor) || nextCursor.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channels,
|
||||
count: channels.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { type SlackPostEphemeralMessageInput } from 'src/logic-functions/types/slack-post-ephemeral-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostEphemeralMessageHandler = async (
|
||||
parameters: SlackPostEphemeralMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const postEphemeralPayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
user: parameters.recipientSlackUserId,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
await client.chat.postEphemeral(postEphemeralPayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ephemeral message sent to the user in the channel.',
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack ephemeral message', error);
|
||||
}
|
||||
};
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type SlackPostMessageInput } from 'src/logic-functions/types/slack-post-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostMessageHandler = async (
|
||||
parameters: SlackPostMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const parentTimestamp = parameters.parentMessageTimestamp;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const data = await client.chat.postMessage({
|
||||
channel: parameters.slackChannelId,
|
||||
thread_ts:
|
||||
isDefined(parentTimestamp) && parentTimestamp.trim().length > 0
|
||||
? parentTimestamp.trim()
|
||||
: undefined,
|
||||
...bodyFields,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.ts
|
||||
? `Message posted to Slack (ts=${data.ts}).`
|
||||
: 'Message posted to Slack.',
|
||||
slackTs: data.ts,
|
||||
channel: data.channel,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack message', error);
|
||||
}
|
||||
};
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { type SlackUpdateMessageInput } from 'src/logic-functions/types/slack-update-message-input.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackUpdateMessageHandler = async (
|
||||
parameters: SlackUpdateMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.newMessageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const updatePayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
const data = await client.chat.update(updatePayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message updated.',
|
||||
slackTs: data.ts,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to update Slack message', error);
|
||||
}
|
||||
};
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackAddReactionInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the message lives (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'The message to react to: use the **timestamp** from Slack (same value as `slackTs` after posting, or visible in “Copy link” URLs as the last number).',
|
||||
},
|
||||
emojiName: {
|
||||
type: 'string',
|
||||
label: 'Emoji name',
|
||||
description:
|
||||
'Emoji to add, **without** colons — for example `thumbsup`, `eyes`, or `white_check_mark`. Do not paste `:thumbsup:` style names.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'emojiName'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackDeleteMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to remove: its **timestamp** from Slack (same value as `slackTs` when the bot posted it). Only messages sent by this bot can be deleted.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackListChannelsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelType: {
|
||||
type: 'string',
|
||||
label: 'Channel type',
|
||||
enum: ['Public', 'Private', 'All'],
|
||||
description:
|
||||
'Optional. Which kinds of channels to include. `All` returns both public and private channels. Default: `All`.',
|
||||
},
|
||||
excludeArchived: {
|
||||
type: 'boolean',
|
||||
label: 'Exclude archived',
|
||||
description:
|
||||
'Optional. Hide archived channels from the list. Default: `true`.',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
label: 'Max results',
|
||||
minimum: 1,
|
||||
maximum: 1000,
|
||||
description:
|
||||
'Optional. Cap the total number of channels returned across pagination. Default: `200`. Max: `1000`. Larger workspaces may not be fully covered within the function timeout.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostEphemeralMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the note appears (Slack channel ID like C…). The person you choose must already be in this channel, or they will not see the message.',
|
||||
},
|
||||
recipientSlackUserId: {
|
||||
type: 'string',
|
||||
label: 'Recipient (Slack user ID)',
|
||||
description:
|
||||
'Who can see this message: their Slack **member ID** (starts with U…). Only that person sees it; everyone else in the channel does not. In Slack: click their profile → three dots → Copy member ID (wording may vary by client).',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Short note shown only to the recipient above — for example a private hint, validation result, or next step.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` / `plain` / omit — see that action’s `messageFormat` description.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'recipientSlackUserId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Where to post: the Slack channel or DM ID (looks like C0123…, G… for private channels, or D… for DMs). In Slack: open the channel → channel name → scroll down → copy Channel ID. Using the ID is more reliable than a channel name.',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'The message people will read in Slack. Keep it concise; very long messages may be rejected by Slack.',
|
||||
},
|
||||
parentMessageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Parent message timestamp',
|
||||
description:
|
||||
'Optional. Only when you want a **thread reply**: paste the **Message timestamp** from an earlier Slack step (the value returned as `slackTs` after posting). Leave empty for a normal new message at the bottom of the channel.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. `markdown`: send as Slack `markdown_text` so usual Markdown in `messageText` works (**bold**, lists, fenced code). `plain`: send as `text` with `mrkdwn: false` (no markup). Omit: send as `text` and let Slack use its default (legacy mrkdwn may still apply in `text`).',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackUpdateMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to edit: its **timestamp** from Slack (same value as `slackTs` returned when the bot posted the message). You cannot edit other people’s messages—only ones this bot sent.',
|
||||
},
|
||||
newMessageText: {
|
||||
type: 'string',
|
||||
label: 'New message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Replacement text shown in Slack instead of the old content.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` → `markdown_text`, `plain` → plain `text` without markup, omit → Slack default on `text`.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'newMessageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackAddReactionHandler } from 'src/logic-functions/handlers/slack-add-reaction-handler';
|
||||
import { slackAddReactionInputSchema } from './schemas/slack-add-reaction-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-add-reaction',
|
||||
description:
|
||||
'Add an emoji reaction to a message (for example a checkmark as `white_check_mark`) so the channel can see status at a glance.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackAddReactionInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Add Slack Reaction',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackAddReactionInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackAddReactionHandler,
|
||||
});
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackDeleteMessageHandler } from 'src/logic-functions/handlers/slack-delete-message-handler';
|
||||
import { slackDeleteMessageInputSchema } from './schemas/slack-delete-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-delete-message',
|
||||
description:
|
||||
'Remove a message this bot sent (for example after a mistake or when a workflow is cancelled).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackDeleteMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Delete Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackDeleteMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackDeleteMessageHandler,
|
||||
});
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { type SlackChannelType } from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
|
||||
const VALID_CHANNEL_TYPES: SlackChannelType[] = ['Public', 'Private', 'All'];
|
||||
|
||||
const isSlackChannelType = (value: unknown): value is SlackChannelType =>
|
||||
typeof value === 'string' &&
|
||||
VALID_CHANNEL_TYPES.includes(value as SlackChannelType);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const params = event.queryStringParameters ?? {};
|
||||
|
||||
const rawLimit = params.limit;
|
||||
const parsedLimit =
|
||||
typeof rawLimit === 'string' && rawLimit.length > 0
|
||||
? Number(rawLimit)
|
||||
: undefined;
|
||||
|
||||
return slackListChannelsHandler({
|
||||
channelType: isSlackChannelType(params.channelType)
|
||||
? params.channelType
|
||||
: undefined,
|
||||
excludeArchived: params.excludeArchived === 'false' ? false : undefined,
|
||||
limit:
|
||||
typeof parsedLimit === 'number' && Number.isFinite(parsedLimit)
|
||||
? parsedLimit
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/channels',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { slackListChannelsInputSchema } from './schemas/slack-list-channels-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels',
|
||||
description:
|
||||
'List Slack channels visible to the bot, with basic metadata (id, name, privacy, archive state, membership, member count, topic, purpose). Useful for picking a channel by name or branching a workflow on whether the bot is a member.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackListChannelsInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'List Slack Channels',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackListChannelsInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
isPrivate: { type: 'boolean' },
|
||||
isArchived: { type: 'boolean' },
|
||||
isMember: { type: 'boolean' },
|
||||
numMembers: { type: 'number' },
|
||||
topic: { type: 'string' },
|
||||
purpose: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackListChannelsHandler,
|
||||
});
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostEphemeralMessageHandler } from 'src/logic-functions/handlers/slack-post-ephemeral-message-handler';
|
||||
import { slackPostEphemeralMessageInputSchema } from './schemas/slack-post-ephemeral-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-ephemeral-message',
|
||||
description:
|
||||
'Send a private-on-channel note: only the chosen teammate sees it in that channel (not a DM broadcast to everyone).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostEphemeralMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Ephemeral Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostEphemeralMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostEphemeralMessageHandler,
|
||||
});
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
const VALID_MESSAGE_FORMATS: SlackMessageBodyFormat[] = ['plain', 'markdown'];
|
||||
|
||||
const isSlackMessageBodyFormat = (
|
||||
value: unknown,
|
||||
): value is SlackMessageBodyFormat =>
|
||||
typeof value === 'string' &&
|
||||
VALID_MESSAGE_FORMATS.includes(value as SlackMessageBodyFormat);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
return slackPostMessageHandler({
|
||||
slackChannelId: (body?.slackChannelId as string | undefined) ?? '',
|
||||
messageText: (body?.messageText as string | undefined) ?? '',
|
||||
parentMessageTimestamp: body?.parentMessageTimestamp as string | undefined,
|
||||
messageFormat: isSlackMessageBodyFormat(body?.messageFormat)
|
||||
? body.messageFormat
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/messages',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { slackPostMessageInputSchema } from './schemas/slack-post-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message',
|
||||
description:
|
||||
'Send a message to a Slack channel or DM. Optionally reply inside an existing thread using the parent message’s timestamp from a previous step.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostMessageHandler,
|
||||
});
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackUpdateMessageHandler } from 'src/logic-functions/handlers/slack-update-message-handler';
|
||||
import { slackUpdateMessageInputSchema } from './schemas/slack-update-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-update-message',
|
||||
description:
|
||||
'Change the text of a message this bot already sent. You need the channel ID and the message’s timestamp from when it was posted.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackUpdateMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Update Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackUpdateMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackUpdateMessageHandler,
|
||||
});
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
export type SlackAddReactionInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
emojiName: string;
|
||||
};
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
export type SlackDeleteMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
};
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
export type SlackChannelType = 'Public' | 'Private' | 'All';
|
||||
|
||||
export type SlackListChannelsInput = {
|
||||
channelType?: SlackChannelType;
|
||||
excludeArchived?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
export type SlackListChannelsResultChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isMember: boolean;
|
||||
numMembers: number;
|
||||
topic: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type SlackListChannelsResult = {
|
||||
success: boolean;
|
||||
channels: SlackListChannelsResultChannel[];
|
||||
count: number;
|
||||
error?: string;
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export type SlackMessageBodyFormat = 'plain' | 'markdown';
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostEphemeralMessageInput = {
|
||||
slackChannelId: string;
|
||||
recipientSlackUserId: string;
|
||||
messageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageText: string;
|
||||
parentMessageTimestamp?: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
export type SlackToolResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
slackTs?: string;
|
||||
channel?: string;
|
||||
};
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackUpdateMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
newMessageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
type SlackChatMessageBodyFields =
|
||||
| { markdown_text: string; text?: never; mrkdwn?: never }
|
||||
| { text: string; markdown_text?: never; mrkdwn?: boolean };
|
||||
|
||||
export const getSlackChatMessageBodyFields = (
|
||||
messageText: string,
|
||||
messageFormat: SlackMessageBodyFormat | undefined,
|
||||
): SlackChatMessageBodyFields => {
|
||||
switch (messageFormat) {
|
||||
case 'markdown':
|
||||
return { markdown_text: messageText };
|
||||
case 'plain':
|
||||
return { text: messageText, mrkdwn: false };
|
||||
default:
|
||||
return { text: messageText };
|
||||
}
|
||||
};
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { WebClient } from '@slack/web-api';
|
||||
|
||||
import { getSlackConnection } from 'src/logic-functions/utils/get-slack-connection';
|
||||
|
||||
export const getSlackClient = async (): Promise<
|
||||
{ success: true; client: WebClient } | { success: false; error: string }
|
||||
> => {
|
||||
const connectionResult = await getSlackConnection();
|
||||
|
||||
if (!connectionResult.success) {
|
||||
return connectionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
client: new WebClient(connectionResult.accessToken),
|
||||
};
|
||||
};
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const getSlackConnection = async (): Promise<
|
||||
{ success: true; accessToken: string } | { success: false; error: string }
|
||||
> => {
|
||||
try {
|
||||
const connections = await listConnections({ providerName: 'slack' });
|
||||
const connection =
|
||||
connections.find((item) => item.visibility === 'workspace') ??
|
||||
connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Slack is not connected. Open the Slack app settings and click "Add connection" first.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, accessToken: connection.accessToken };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Slack connection.';
|
||||
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
|
||||
export const slackToolFailure = (
|
||||
message: string,
|
||||
error: unknown,
|
||||
): SlackToolResult => ({
|
||||
success: false,
|
||||
message,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Twenty Slack tools role',
|
||||
description:
|
||||
'No CRM data access — tools only forward requests to Slack using the configured connected account.',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: [],
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.7.0",
|
||||
"version": "2.5.1",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Stub — overwritten by `twenty dev:build` or `twenty dev`
|
||||
// Stub — overwritten by `twenty build` or `twenty dev`
|
||||
export type CoreSchema = {};
|
||||
|
||||
@@ -2998,6 +2998,8 @@ type Query {
|
||||
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
|
||||
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
connectedAccountById(id: UUID!): ConnectedAccountPublicDTO
|
||||
connectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
|
||||
@@ -2598,6 +2598,8 @@ export interface Query {
|
||||
myMessageFolders: MessageFolder[]
|
||||
myMessageChannels: MessageChannel[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
connectedAccountById?: ConnectedAccountPublicDTO
|
||||
connectedAccounts: ConnectedAccountPublicDTO[]
|
||||
myCalendarChannels: CalendarChannel[]
|
||||
webhooks: Webhook[]
|
||||
webhook?: Webhook
|
||||
@@ -5629,6 +5631,8 @@ export interface QueryGenqlSelection{
|
||||
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
|
||||
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
connectedAccountById?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
connectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
webhooks?: WebhookGenqlSelection
|
||||
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
|
||||
@@ -6176,6 +6176,18 @@ export default {
|
||||
"myConnectedAccounts": [
|
||||
269
|
||||
],
|
||||
"connectedAccountById": [
|
||||
269,
|
||||
{
|
||||
"id": [
|
||||
3,
|
||||
"UUID!"
|
||||
]
|
||||
}
|
||||
],
|
||||
"connectedAccounts": [
|
||||
269
|
||||
],
|
||||
"myCalendarChannels": [
|
||||
300,
|
||||
{
|
||||
|
||||
@@ -26,6 +26,12 @@ prod-run:
|
||||
prod-postgres-run:
|
||||
@docker run -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres --name twenty-postgres twenty-postgres:$(TAG)
|
||||
|
||||
prod-website-new-build:
|
||||
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-website-new/Dockerfile --platform $(PLATFORM) --tag twenty-website-new:$(TAG) . && cd -
|
||||
|
||||
prod-website-new-run:
|
||||
@docker run -d -p 3000:3000 --name twenty-website-new twenty-website-new:$(TAG)
|
||||
|
||||
# =============================================================================
|
||||
# Local Development Services
|
||||
# Run these from the repository root: make -C packages/twenty-docker <target>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new-build
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json .
|
||||
COPY ./yarn.lock .
|
||||
COPY ./.yarnrc.yml .
|
||||
COPY ./tsconfig.base.json .
|
||||
COPY ./nx.json .
|
||||
COPY ./.yarn/releases /app/.yarn/releases
|
||||
COPY ./.yarn/patches /app/.yarn/patches
|
||||
COPY ./packages/twenty-oxlint-rules /app/packages/twenty-oxlint-rules
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/package.json
|
||||
COPY ./packages/twenty-website-new/package.json /app/packages/twenty-website-new/package.json
|
||||
|
||||
RUN yarn
|
||||
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-website-new /app/packages/twenty-website-new
|
||||
RUN npx nx build twenty-website-new
|
||||
|
||||
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new
|
||||
|
||||
WORKDIR /app/packages/twenty-website-new
|
||||
|
||||
COPY --from=twenty-website-new-build /app /app
|
||||
|
||||
WORKDIR /app/packages/twenty-website-new
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
|
||||
LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the new marketing website."
|
||||
|
||||
RUN chown -R 1000 /app
|
||||
|
||||
# Use non root user with uid 1000
|
||||
USER 1000
|
||||
|
||||
CMD ["/bin/sh", "-c", "npx nx start twenty-website-new"]
|
||||
@@ -45,7 +45,7 @@ export default definePostInstallLogicFunction({
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:function:exec --postInstall
|
||||
yarn twenty exec --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
@@ -60,7 +60,7 @@ Key points:
|
||||
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
|
||||
- The function's `universalIdentifier`, `shouldRunOnVersionUpgrade`, and `shouldRunSynchronously` are automatically attached to the application manifest under the `postInstallLogicFunction` field during the build — you do not need to reference them in [`defineApplication()`](/developers/extend/apps/config/application).
|
||||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
- **Not executed in dev mode**: when an app is registered locally (via `yarn twenty dev`), the server skips the install flow entirely and syncs files directly through the CLI watcher — so post-install never runs in dev mode, regardless of `shouldRunSynchronously`. Use `yarn twenty dev:function:exec --postInstall` to trigger it manually against a running workspace.
|
||||
- **Not executed in dev mode**: when an app is registered locally (via `yarn twenty dev`), the server skips the install flow entirely and syncs files directly through the CLI watcher — so post-install never runs in dev mode, regardless of `shouldRunSynchronously`. Use `yarn twenty exec --postInstall` to trigger it manually against a running workspace.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePreInstallLogicFunction" description="Runs before the workspace metadata migration is applied">
|
||||
@@ -87,7 +87,7 @@ export default definePreInstallLogicFunction({
|
||||
You can also manually execute the pre-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:function:exec --preInstall
|
||||
yarn twenty exec --preInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
@@ -96,7 +96,7 @@ Key points:
|
||||
- **When the hook runs**: positioned just before the workspace metadata migration (`synchronizeFromManifest`). Before executing, the server runs a purely additive "pared-down sync" that registers the **new** version's pre-install function in the workspace metadata — nothing else is touched — and then executes it. Because this sync is additive-only, the previous version's objects, fields, and data are still intact when your handler runs: you can safely read and back up pre-migration state.
|
||||
- **Execution model**: pre-install is executed **synchronously** and **blocks the install**. If the handler throws, the install is aborted before any schema changes are applied — the workspace stays on the previous version in a consistent state. This is intentional: pre-install is your last chance to refuse a risky upgrade.
|
||||
- As with post-install, only one pre-install function is allowed per application. It is attached to the application manifest under `preInstallLogicFunction` automatically during the build.
|
||||
- **Not executed in dev mode**: same as post-install — the install flow is skipped entirely for locally-registered apps, so pre-install never runs under `yarn twenty dev`. Use `yarn twenty dev:function:exec --preInstall` to trigger it manually.
|
||||
- **Not executed in dev mode**: same as post-install — the install flow is skipped entirely for locally-registered apps, so pre-install never runs under `yarn twenty dev`. Use `yarn twenty exec --preInstall` to trigger it manually.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Pre-install vs post-install: when to use which" description="Choosing the right install hook">
|
||||
|
||||
@@ -13,7 +13,7 @@ Files placed in `public/` are:
|
||||
- **Available in logic functions** — reference asset URLs in emails, API responses, or any server-side logic.
|
||||
- **Used for marketplace metadata** — the `logoUrl` and `screenshots` fields in `defineApplication()` reference files from this folder (e.g., `public/logo.png`). These are displayed in the marketplace when your app is published.
|
||||
- **Auto-synced in dev mode** — when you add, update, or delete a file in `public/`, it is synced to the server automatically. No restart needed.
|
||||
- **Included in builds** — `yarn twenty dev:build` bundles all public assets into the distribution output.
|
||||
- **Included in builds** — `yarn twenty build` bundles all public assets into the distribution output.
|
||||
|
||||
## Accessing public assets with `getPublicAssetUrl`
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export default defineObject({
|
||||
- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`.
|
||||
- The `fields` array is optional — you can define objects without custom fields.
|
||||
- Inline fields defined here do **not** need an `objectUniversalIdentifier` — it's inherited from the parent object. Use [`defineField()`](/developers/extend/apps/data/extending-objects) to add fields to objects you don't own.
|
||||
- You can scaffold new objects with `yarn twenty dev:add object`, which guides you through naming, fields, and relationships. See [Architecture → Scaffolding entities](/developers/extend/apps/getting-started/scaffolding).
|
||||
- You can scaffold new objects with `yarn twenty add object`, which guides you through naming, fields, and relationships. See [Architecture → Scaffolding entities](/developers/extend/apps/getting-started/scaffolding).
|
||||
|
||||
<Note>
|
||||
**Base fields are added automatically.** When you define a custom object, Twenty creates standard fields like `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and `deletedAt` for you. You don't need to declare them in your `fields` array — only your custom fields. You can override a default field by declaring one with the same name, but this is rarely a good idea.
|
||||
|
||||
@@ -65,7 +65,7 @@ your-app/
|
||||
│ npx create-twenty-app → yarn twenty dev (live sync) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Build & Deploy │
|
||||
│ yarn twenty dev:build → yarn twenty app:publish │
|
||||
│ yarn twenty build → yarn twenty deploy │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Install flow │
|
||||
│ upload → [pre-install] → metadata migration → │
|
||||
@@ -77,7 +77,7 @@ your-app/
|
||||
```
|
||||
|
||||
- **`yarn twenty dev`** — watches your source files and live-syncs changes to a connected Twenty server. The typed API client is regenerated automatically when the schema changes.
|
||||
- **`yarn twenty dev:build`** — compiles TypeScript, bundles logic functions and front components with esbuild, and produces a manifest.
|
||||
- **`yarn twenty build`** — compiles TypeScript, bundles logic functions and front components with esbuild, and produces a manifest.
|
||||
- **Pre/post-install hooks** — optional functions that run during installation. See [Install Hooks](/developers/extend/apps/config/install-hooks) for details.
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -6,44 +6,44 @@ icon: "server"
|
||||
|
||||
## Managing the local server
|
||||
|
||||
Use `yarn twenty docker:*` to control the local Twenty container:
|
||||
Use `yarn twenty server` to control the local Twenty container:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `yarn twenty docker:start` | Start the server (pulls the image if needed) |
|
||||
| `yarn twenty docker:start --port 3030` | Start on a custom port |
|
||||
| `yarn twenty docker:stop` | Stop the server (preserves data) |
|
||||
| `yarn twenty docker:status` | Show URL, version, and login credentials |
|
||||
| `yarn twenty docker:logs` | Stream server logs |
|
||||
| `yarn twenty docker:reset` | Wipe data and start fresh |
|
||||
| `yarn twenty docker:upgrade` | Pull the latest `twenty-app-dev` image |
|
||||
| `yarn twenty docker:upgrade 2.2.0` | Upgrade to a specific version |
|
||||
| `yarn twenty server start` | Start the server (pulls the image if needed) |
|
||||
| `yarn twenty server start --port 3030` | Start on a custom port |
|
||||
| `yarn twenty server stop` | Stop the server (preserves data) |
|
||||
| `yarn twenty server status` | Show URL, version, and login credentials |
|
||||
| `yarn twenty server logs` | Stream server logs |
|
||||
| `yarn twenty server reset` | Wipe data and start fresh |
|
||||
| `yarn twenty server upgrade` | Pull the latest `twenty-app-dev` image |
|
||||
| `yarn twenty server upgrade 2.2.0` | Upgrade to a specific version |
|
||||
|
||||
Data persists across restarts in two Docker volumes (`twenty-app-dev-data` for PostgreSQL, `twenty-app-dev-storage` for files). Use `reset` to wipe everything.
|
||||
|
||||
## Upgrading the server image
|
||||
|
||||
`yarn twenty docker:upgrade` pulls the latest image, compares digests, and only recreates the container if anything actually changed. Volumes are preserved — only the container is replaced. If a new image was pulled and the container was running, the upgrade automatically starts a new container; run `yarn twenty docker:start` afterward to wait for it to become healthy.
|
||||
`yarn twenty server upgrade` pulls the latest image, compares digests, and only recreates the container if anything actually changed. Volumes are preserved — only the container is replaced. If a new image was pulled and the container was running, the upgrade automatically starts a new container; run `yarn twenty server start` afterward to wait for it to become healthy.
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty docker:upgrade # Latest
|
||||
yarn twenty docker:upgrade 2.2.0 # Specific version
|
||||
yarn twenty server upgrade # Latest
|
||||
yarn twenty server upgrade 2.2.0 # Specific version
|
||||
```
|
||||
|
||||
Verify the running version with `yarn twenty docker:status` (it shows the `APP_VERSION` baked into the container).
|
||||
Verify the running version with `yarn twenty server status` (it shows the `APP_VERSION` baked into the container).
|
||||
|
||||
## Running a parallel test instance
|
||||
|
||||
Pass `--test` to any `docker:*` command to manage a second, fully isolated instance — useful for integration tests or experiments without touching your main dev data:
|
||||
Pass `--test` to any `server` command to manage a second, fully isolated instance — useful for integration tests or experiments without touching your main dev data:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `yarn twenty docker:start --test` | Start the test instance (defaults to port 2021) |
|
||||
| `yarn twenty docker:stop --test` | Stop it |
|
||||
| `yarn twenty docker:status --test` | Show its status |
|
||||
| `yarn twenty docker:logs --test` | Stream its logs |
|
||||
| `yarn twenty docker:reset --test` | Wipe its data |
|
||||
| `yarn twenty docker:upgrade --test` | Upgrade its image |
|
||||
| `yarn twenty server start --test` | Start the test instance (defaults to port 2021) |
|
||||
| `yarn twenty server stop --test` | Stop it |
|
||||
| `yarn twenty server status --test` | Show its status |
|
||||
| `yarn twenty server logs --test` | Stream its logs |
|
||||
| `yarn twenty server reset --test` | Wipe its data |
|
||||
| `yarn twenty server upgrade --test` | Upgrade its image |
|
||||
|
||||
The test instance has its own container (`twenty-app-dev-test`), volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`), and config — it runs alongside your main instance without conflicts. Combine `--test` with `--port` to override 2021.
|
||||
|
||||
@@ -65,7 +65,7 @@ Add the script to `package.json`:
|
||||
}
|
||||
```
|
||||
|
||||
You can now run `yarn twenty dev`, `yarn twenty docker:start`, and the rest.
|
||||
You can now run `yarn twenty dev`, `yarn twenty server start`, and the rest.
|
||||
|
||||
<Note>
|
||||
Don't install `twenty-sdk` globally — pin it per project so each app uses its own version.
|
||||
|
||||
@@ -43,7 +43,7 @@ The scaffolder offers to start one for you:
|
||||
> **Would you like to set up a local Twenty instance?**
|
||||
|
||||
- **Yes (recommended)** — pulls the `twentycrm/twenty-app-dev` Docker image and starts it on port `2020`. Make sure Docker is running first.
|
||||
- **No** — choose this if you already have a Twenty server you want to connect to. You can wire it up later with `yarn twenty remote:add`.
|
||||
- **No** — choose this if you already have a Twenty server you want to connect to. You can wire it up later with `yarn twenty remote add`.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/start-instance.png" alt="Should start local instance?" />
|
||||
@@ -73,7 +73,7 @@ Your terminal will confirm everything is set up.
|
||||
**After this phase:** you have a running Twenty server at [http://localhost:2020](http://localhost:2020) with your CLI authorized to sync to it.
|
||||
|
||||
<Note>
|
||||
If Docker isn't installed or running, the scaffolder will tell you the right start command for your OS. Once Docker is up, you can resume with `yarn twenty docker:start` — no need to re-scaffold.
|
||||
If Docker isn't installed or running, the scaffolder will tell you the right start command for your OS. Once Docker is up, you can resume with `yarn twenty server start` — no need to re-scaffold.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: Scaffolding
|
||||
description: Generate entity files interactively with yarn twenty dev:add — objects, fields, views, logic functions, and more.
|
||||
description: Generate entity files interactively with yarn twenty add — objects, fields, views, logic functions, and more.
|
||||
icon: "wand-magic-sparkles"
|
||||
---
|
||||
|
||||
Instead of creating entity files by hand, use the interactive scaffolder:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add
|
||||
yarn twenty add
|
||||
```
|
||||
|
||||
It prompts you to pick an entity type and walks you through the required fields, then writes a ready-to-use file with a stable `universalIdentifier` and the correct `defineEntity()` call.
|
||||
@@ -15,29 +15,29 @@ It prompts you to pick an entity type and walks you through the required fields,
|
||||
You can also pass the entity type directly to skip the first prompt:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
yarn twenty dev:add logicFunction
|
||||
yarn twenty dev:add frontComponent
|
||||
yarn twenty add object
|
||||
yarn twenty add logicFunction
|
||||
yarn twenty add frontComponent
|
||||
```
|
||||
|
||||
## Available entity types
|
||||
|
||||
| Entity type | Command | Generated file |
|
||||
|-------------|---------|----------------|
|
||||
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
|
||||
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
|
||||
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
|
||||
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
|
||||
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
|
||||
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
|
||||
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
|
||||
| View | `yarn twenty add view` | `src/views/<name>.ts` |
|
||||
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||||
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
|
||||
|
||||
## What the scaffolder generates
|
||||
|
||||
Each entity type has its own template. For example, `yarn twenty dev:add object` asks for:
|
||||
Each entity type has its own template. For example, `yarn twenty add object` asks for:
|
||||
|
||||
1. **Name (singular)** — e.g., `invoice`
|
||||
2. **Name (plural)** — e.g., `invoices`
|
||||
@@ -54,5 +54,5 @@ The `field` entity type is more detailed: it asks for the field name, label, typ
|
||||
Use the `--path` flag to place the generated file in a custom location:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add logicFunction --path src/custom-folder
|
||||
yarn twenty add logicFunction --path src/custom-folder
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Common first-run issues — Docker, Node version, Yarn, dependencie
|
||||
icon: "wrench"
|
||||
---
|
||||
|
||||
- **Docker errors** — Make sure Docker Desktop (or the daemon) is running before `yarn twenty docker:start`. The error message will show the right start command for your OS.
|
||||
- **Docker errors** — Make sure Docker Desktop (or the daemon) is running before `yarn twenty server start`. The error message will show the right start command for your OS.
|
||||
- **Wrong Node version** — Need 24+. Check with `node -v`.
|
||||
- **Yarn 4 missing** — Run `corepack enable`.
|
||||
- **Dependencies broken** — `rm -rf node_modules && yarn install`.
|
||||
|
||||
@@ -13,8 +13,8 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -61,17 +61,17 @@ Available trigger types:
|
||||
You can also manually execute a function using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:function:exec -n create-new-post-card -p '{"key": "value"}'
|
||||
yarn twenty exec -n create-new-post-card -p '{"key": "value"}'
|
||||
```
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||||
yarn twenty exec -y e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||||
```
|
||||
|
||||
You can watch logs with:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:function:logs
|
||||
yarn twenty logs
|
||||
```
|
||||
</Note>
|
||||
|
||||
@@ -79,10 +79,10 @@ yarn twenty dev:function:logs
|
||||
|
||||
When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the
|
||||
[AWS HTTP API v2 format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
|
||||
Import the `RoutePayload` type from `twenty-sdk/logic-function`:
|
||||
Import the `RoutePayload` type from `twenty-sdk`:
|
||||
|
||||
```ts
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -141,115 +141,6 @@ const handler = async (event: RoutePayload) => {
|
||||
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Database event trigger payload
|
||||
|
||||
When a database event trigger invokes your logic function, it receives one `DatabaseEventPayload` per changed record. The payload combines metadata about the source workspace and object with the record-level event.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
The payload includes:
|
||||
|
||||
| Property | Description |
|
||||
|----------|-------------|
|
||||
| `name` | Event name, such as `person.updated`. |
|
||||
| `workspaceId` | Workspace where the event happened. |
|
||||
| `objectMetadata` | Metadata for the object that changed. |
|
||||
| `recordId` | Id of the changed record. |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Actor fields when the event was caused by a workspace user. |
|
||||
| `properties` | Record data for the event, with `before`, `after`, `diff`, and `updatedFields` depending on the operation. |
|
||||
|
||||
| Event | Record data |
|
||||
|-------|-------------|
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
For soft deletes, `.deleted` follows the update-style shape because the record's `deletedAt` field changes.
|
||||
For permanent deletes, use `.destroyed`.
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields` filters which update events trigger the function.
|
||||
`event.properties.updatedFields` tells you which fields actually changed on the current event.
|
||||
</Note>
|
||||
|
||||
Created event example:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Updated event example:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Trigger only on email updates:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Destroyed event example:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### Exposing a function as an AI tool or workflow action
|
||||
|
||||
Logic functions can be exposed on two surfaces, each with its own trigger:
|
||||
@@ -341,7 +232,7 @@ The `twenty-client-sdk` package provides two typed GraphQL clients for interacti
|
||||
<AccordionGroup>
|
||||
<Accordion title="CoreApiClient" description="Query and mutate workspace data (records, objects)">
|
||||
|
||||
`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty dev:build`, so it is fully typed to match your objects and fields.
|
||||
`CoreApiClient` is the main client for querying and mutating workspace data. It is **generated from your workspace schema** during `yarn twenty dev` or `yarn twenty build`, so it is fully typed to match your objects and fields.
|
||||
|
||||
```ts
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
@@ -381,7 +272,7 @@ const { createCompany } = await client.mutation({
|
||||
The client uses a selection-set syntax: pass `true` to include a field, use `__args` for arguments, and nest objects for relations. You get full autocompletion and type checking based on your workspace schema.
|
||||
|
||||
<Note>
|
||||
**CoreApiClient is generated at dev/build time.** If you use it without running `yarn twenty dev` or `yarn twenty dev:build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema and generates a typed client using `@genql/cli`.
|
||||
**CoreApiClient is generated at dev/build time.** If you use it without running `yarn twenty dev` or `yarn twenty build` first, it throws an error. The generation happens automatically — the CLI introspects your workspace's GraphQL schema and generates a typed client using `@genql/cli`.
|
||||
</Note>
|
||||
|
||||
#### Using CoreSchema for type annotations
|
||||
|
||||
@@ -4,54 +4,54 @@ description: yarn twenty commands for executing functions, streaming logs, manag
|
||||
icon: "terminal"
|
||||
---
|
||||
|
||||
Beyond `dev`, `dev:build`, `dev:add`, and `dev:typecheck`, the `yarn twenty` CLI provides commands for executing functions, viewing logs, and managing app installations.
|
||||
Beyond `dev`, `build`, `add`, and `typecheck`, the `yarn twenty` CLI provides commands for executing functions, viewing logs, and managing app installations.
|
||||
|
||||
## Executing functions (`yarn twenty dev:function:exec`)
|
||||
## Executing functions (`yarn twenty exec`)
|
||||
|
||||
Run a logic function manually without triggering it via HTTP, cron, or database event:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Execute by function name
|
||||
yarn twenty dev:function:exec -n create-new-post-card
|
||||
yarn twenty exec -n create-new-post-card
|
||||
|
||||
# Execute by universalIdentifier
|
||||
yarn twenty dev:function:exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||||
yarn twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||||
|
||||
# Pass a JSON payload
|
||||
yarn twenty dev:function:exec -n create-new-post-card -p '{"name": "Hello"}'
|
||||
yarn twenty exec -n create-new-post-card -p '{"name": "Hello"}'
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty dev:function:exec --postInstall
|
||||
yarn twenty exec --postInstall
|
||||
```
|
||||
|
||||
## Viewing function logs (`yarn twenty dev:function:logs`)
|
||||
## Viewing function logs (`yarn twenty logs`)
|
||||
|
||||
Stream execution logs for your app's logic functions:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Stream all function logs
|
||||
yarn twenty dev:function:logs
|
||||
yarn twenty logs
|
||||
|
||||
# Filter by function name
|
||||
yarn twenty dev:function:logs -n create-new-post-card
|
||||
yarn twenty logs -n create-new-post-card
|
||||
|
||||
# Filter by universalIdentifier
|
||||
yarn twenty dev:function:logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||||
yarn twenty logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||||
```
|
||||
|
||||
<Note>
|
||||
This is different from `yarn twenty docker:logs`, which shows the Docker container logs. `yarn twenty dev:function:logs` shows your app's function execution logs from the Twenty server.
|
||||
This is different from `yarn twenty server logs`, which shows the Docker container logs. `yarn twenty logs` shows your app's function execution logs from the Twenty server.
|
||||
</Note>
|
||||
|
||||
## Uninstalling an app (`yarn twenty app:uninstall`)
|
||||
## Uninstalling an app (`yarn twenty uninstall`)
|
||||
|
||||
Remove your app from the active workspace:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty app:uninstall
|
||||
yarn twenty uninstall
|
||||
|
||||
# Skip the confirmation prompt
|
||||
yarn twenty app:uninstall --yes
|
||||
yarn twenty uninstall --yes
|
||||
```
|
||||
|
||||
## Managing remotes
|
||||
@@ -60,19 +60,19 @@ A **remote** is a Twenty server that your app connects to. During setup, the sca
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Add a new remote (opens a browser for OAuth login)
|
||||
yarn twenty remote:add
|
||||
yarn twenty remote add
|
||||
|
||||
# Connect to a local Twenty server (auto-detects port 2020 or 3000)
|
||||
yarn twenty remote:add --local
|
||||
yarn twenty remote add --local
|
||||
|
||||
# Add a remote non-interactively (useful for CI)
|
||||
yarn twenty remote:add --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
|
||||
yarn twenty remote add --api-url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
|
||||
|
||||
# List all configured remotes
|
||||
yarn twenty remote:list
|
||||
yarn twenty remote list
|
||||
|
||||
# Set the active remote
|
||||
yarn twenty remote:use <name>
|
||||
# Switch the active remote
|
||||
yarn twenty remote switch <name>
|
||||
```
|
||||
|
||||
Your credentials are stored in `~/.twenty/config.json`.
|
||||
|
||||
@@ -9,9 +9,9 @@ The **operations layer** is everything you do *to* your app rather than *with* i
|
||||
```text
|
||||
develop ─▶ test ─▶ build ─▶ deploy / publish
|
||||
─────── ──── ───── ─────────────────
|
||||
yarn yarn yarn yarn twenty app:publish --private (tarball → one server)
|
||||
yarn yarn yarn yarn twenty deploy (tarball → one server)
|
||||
twenty test twenty
|
||||
dev dev:build yarn twenty app:publish (npm → marketplace)
|
||||
dev build yarn twenty publish (npm → marketplace)
|
||||
```
|
||||
|
||||
## In this section
|
||||
|
||||
@@ -18,10 +18,10 @@ Both paths start from the same **build** step.
|
||||
Run the build command to compile your app and generate a distribution-ready `manifest.json`:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:build
|
||||
yarn twenty build
|
||||
```
|
||||
|
||||
This compiles TypeScript sources, transpiles logic functions and front components, and writes everything to `.twenty/output/`. Add `--tarball` to also produce a `.tgz` package for manual distribution or the publish command.
|
||||
This compiles TypeScript sources, transpiles logic functions and front components, and writes everything to `.twenty/output/`. Add `--tarball` to also produce a `.tgz` package for manual distribution or the deploy command.
|
||||
|
||||
## Deploying to a server (tarball)
|
||||
|
||||
@@ -34,7 +34,7 @@ Before deploying, you need a configured remote pointing to the target server. Re
|
||||
Add a remote:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty remote:add --url https://your-twenty-server.com --as production
|
||||
yarn twenty remote add --api-url https://your-twenty-server.com --as production
|
||||
```
|
||||
|
||||
### Deploying
|
||||
@@ -42,9 +42,9 @@ yarn twenty remote:add --url https://your-twenty-server.com --as production
|
||||
Build and upload your app to the server in one step:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty app:publish --private
|
||||
yarn twenty deploy
|
||||
# To deploy to a specific remote:
|
||||
# yarn twenty app:publish --private --remote production
|
||||
# yarn twenty deploy --remote production
|
||||
```
|
||||
|
||||
### Sharing a deployed app
|
||||
@@ -68,7 +68,7 @@ When updating an already deployed tarball app, the server requires the `version`
|
||||
To release an update:
|
||||
|
||||
1. Bump the `version` field in your `package.json` (e.g. `1.2.3` → `1.2.4`, `1.3.0`, or `2.0.0`)
|
||||
2. Run `yarn twenty app:publish --private` (or `yarn twenty app:publish --private --remote production`)
|
||||
2. Run `yarn twenty deploy` (or `yarn twenty deploy --remote production`)
|
||||
3. Workspaces that have the app installed will see the upgrade available in their settings
|
||||
|
||||
<Note>
|
||||
@@ -121,7 +121,7 @@ Runs integration tests on every push to `main` and every pull request.
|
||||
**What it does:**
|
||||
|
||||
1. Checks out your app's source.
|
||||
2. Spawns an isolated Twenty test instance using the `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` composite action (the CI equivalent of `yarn twenty docker:start --test`).
|
||||
2. Spawns an isolated Twenty test instance using the `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` composite action (the CI equivalent of `yarn twenty server start --test`).
|
||||
3. Enables Corepack, sets up Node.js from your `.nvmrc`, and installs dependencies with `yarn install --immutable`.
|
||||
4. Runs `yarn test`, passing `TWENTY_API_URL` and `TWENTY_API_KEY` from the spawned instance so your tests can talk to a real server.
|
||||
|
||||
@@ -139,7 +139,7 @@ Deploys your app to a configured Twenty server on every push to `main`, and opti
|
||||
**What it does:**
|
||||
|
||||
1. Checks out the PR head (for labeled PRs) or the pushed commit.
|
||||
2. Runs `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — the CI equivalent of `yarn twenty app:publish --private`.
|
||||
2. Runs `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — the CI equivalent of `yarn twenty deploy`.
|
||||
3. Runs `twentyhq/twenty/.github/actions/install-twenty-app@main` so the newly deployed version is installed into the target workspace.
|
||||
|
||||
**Required configuration:**
|
||||
@@ -208,13 +208,13 @@ Screenshots of any aspect ratio are displayed in full and are never cropped, but
|
||||
### Publish
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty app:publish
|
||||
yarn twenty publish
|
||||
```
|
||||
|
||||
To publish under a specific dist-tag (e.g., `beta` or `next`):
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty app:publish --tag beta
|
||||
yarn twenty publish --tag beta
|
||||
```
|
||||
|
||||
### How marketplace discovery works
|
||||
@@ -224,9 +224,9 @@ The Twenty server syncs its marketplace catalog from the npm registry **every ho
|
||||
You can trigger the sync immediately instead of waiting:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync
|
||||
yarn twenty server catalog-sync
|
||||
# To target a specific remote:
|
||||
# yarn twenty dev:catalog-sync --remote production
|
||||
# yarn twenty server catalog-sync --remote production
|
||||
```
|
||||
|
||||
The metadata shown in the marketplace comes from your `defineApplication()` config — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
|
||||
@@ -259,12 +259,12 @@ jobs:
|
||||
node-version: "24"
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: yarn install --immutable
|
||||
- run: npx twenty dev:build
|
||||
- run: npx twenty build
|
||||
- run: npm publish --provenance --access public
|
||||
working-directory: .twenty/output
|
||||
```
|
||||
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `yarn twenty dev:build`, then `npm publish` from `.twenty/output`.
|
||||
For other CI systems (GitLab CI, CircleCI, etc.), the same three commands apply: `yarn install`, `yarn twenty build`, then `npm publish` from `.twenty/output`.
|
||||
|
||||
<Note>
|
||||
**npm provenance** is optional but recommended. Publishing with `--provenance` adds a trust badge to your npm listing, letting users verify the package was built from a specific commit in a public CI pipeline. See the [npm provenance docs](https://docs.npmjs.com/generating-provenance-statements) for setup instructions.
|
||||
@@ -281,7 +281,7 @@ Go to the **Settings > Applications** page in Twenty, where both marketplace and
|
||||
You can also install apps from the command line:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty app:install
|
||||
yarn twenty install
|
||||
```
|
||||
|
||||
<Note>
|
||||
@@ -290,5 +290,5 @@ The server enforces semver versioning on install, mirroring the rules on deploy:
|
||||
- Installing the same version that is already installed in your workspace is rejected with an `APP_ALREADY_INSTALLED` error.
|
||||
- Installing a lower version than the one currently installed is rejected with a `CANNOT_DOWNGRADE_APPLICATION` error.
|
||||
|
||||
To install a newer version, deploy or publish it first, then re-run `yarn twenty app:install`.
|
||||
To install a newer version, deploy or publish it first, then re-run `yarn twenty install`.
|
||||
</Note>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user