Create twenty app improvements (#20688)

create-twenty-app updates:
- remove --example option
- sync --once when scaffolding an applicaiton
- rename --api-url option to --workspace-url
- create a standalone page when scaffolding an app
<img width="1494" height="765" alt="image"
src="https://github.com/user-attachments/assets/0e35ed0c-b0aa-466c-9f56-7939294fd2cf"
/>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
martmull
2026-05-19 15:12:47 +02:00
committed by GitHub
parent 71a7a3c42d
commit d5ff9eb515
52 changed files with 812 additions and 715 deletions
@@ -1,201 +0,0 @@
name: CI Hello world App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx run $pkg:set-local-version --releaseVersion=$CI_VERSION
done
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example hello-world --display-name "Test hello-world app" --description "E2E test hello-world app" --api-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute hello-world logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-hello-world-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --api-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: |
@@ -1,199 +0,0 @@
name: CI Postcard App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example postcard --display-name "Test postcard app" --description "E2E test postcard app" --api-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute postcard logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-postcard-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+1 -10
View File
@@ -32,21 +32,12 @@ The scaffolder will:
| Flag | Description |
| ---------------------------------- | --------------------------------------------------------------------- |
| `--example <name>` | Initialize from an example |
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--api-url <url>` | Twenty instance 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) |
By default (no flags), a minimal app is generated with core files and an integration test. Use `--example` to start from a richer example:
```bash
npx create-twenty-app@latest my-twenty-app --example hello-world
```
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
## Documentation
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)**:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.5.0",
"version": "2.5.1",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+13 -6
View File
@@ -15,14 +15,14 @@ const program = new Command(packageJson.name)
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.option('--example <name>', 'Initialize from an example')
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option(
'--api-url <apiUrl>',
'Twenty instance URL (default: http://localhost:2020)',
'--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,10 +32,10 @@ const program = new Command(packageJson.name)
async (
directory?: string,
options?: {
example?: string;
name?: string;
displayName?: string;
description?: string;
workspaceUrl?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
@@ -66,13 +66,20 @@ const program = new Command(packageJson.name)
process.exit(1);
}
if (options?.apiUrl) {
console.warn(
chalk.yellow(
'Warning: --api-url is deprecated. Use --workspace-url instead.',
),
);
}
await new CreateAppCommand().execute({
directory,
example: options?.example,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
apiUrl: options?.apiUrl,
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="16" fill="#141414"/>
<rect x="20" y="20" width="16" height="16" rx="4" fill="#FAFAFA"/>
<rect x="44" y="20" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="20" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="44" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

@@ -2,3 +2,10 @@ export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
@@ -0,0 +1,279 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import { useState } from 'react';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
const DOCS_BASE_URL = 'https://docs.twenty.com/developers/extend/apps';
const CATEGORIES = [
{
title: 'Data model',
color: '#73D08D',
items: [
{ label: 'CUSTOM OBJECT', href: `${DOCS_BASE_URL}/data/objects` },
{
label: 'CUSTOM FIELDS',
href: `${DOCS_BASE_URL}/data/extending-objects`,
},
],
rotation: '2.4deg',
},
{
title: 'Logic',
color: '#F4D345',
items: [
{
label: 'TOOLS',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SERVERLESS FUNCT.',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SKILLS',
href: `${DOCS_BASE_URL}/logic/skills-and-agents`,
},
],
rotation: '0deg',
},
{
title: 'Layout',
color: '#C4A2E0',
items: [
{ label: 'VIEWS', href: `${DOCS_BASE_URL}/layout/views` },
{ label: 'WIDGETS', href: `${DOCS_BASE_URL}/layout/page-layouts` },
{
label: 'LAYOUT PAGES',
href: `${DOCS_BASE_URL}/layout/page-layouts`,
},
{
label: 'COMMANDS',
href: `${DOCS_BASE_URL}/layout/command-menu-items`,
},
],
rotation: '-2.8deg',
},
] as const;
const 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
d="M4.5 3.5H10.5V9.5M10.5 3.5L3.5 10.5"
stroke={color}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CategoryCard = ({
title,
color,
items,
rotation,
}: {
title: string;
color: string;
items: ReadonlyArray<{ label: string; href: string }>;
rotation: string;
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
border: `1px solid ${color}80`,
borderRadius: '12px',
overflow: 'hidden',
width: '240px',
background: '#FFFFFF',
transform: `rotate(${rotation})`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '16px 20px',
background: `${color}22`,
}}
>
<span
style={{
fontSize: '16px',
fontWeight: 600,
color: color,
}}
>
{title}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '8px',
gap: '4px',
}}
>
{items.map((item) => {
const isHovered = hoveredItem === item.label;
return (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHoveredItem(item.label)}
onMouseLeave={() => setHoveredItem(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
cursor: 'pointer',
padding: '10px 12px',
borderRadius: '8px',
background: isHovered ? '#0000000A' : 'transparent',
transition: 'background 0.15s',
}}
>
<ItemIcon color={color} />
<span
style={{
fontSize: '13px',
fontWeight: 300,
color: '#333',
letterSpacing: '0.5px',
flex: 1,
}}
>
{item.label}
</span>
{isHovered && <ArrowUpRight />}
</a>
);
})}
</div>
</div>
);
};
const MainPage = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
gap: '8px',
padding: '40px',
}}
>
{/*<Avatar
avatarUrl={getPublicAssetUrl('logo.svg')}
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
type="squared"
size="xl"
/>*/}
<span
style={{
fontSize: '24px',
fontWeight: 600,
color: '#333',
marginTop: '8px',
}}
>
{APP_DISPLAY_NAME}
</span>
<span
style={{
fontSize: '13px',
color: '#888',
textAlign: 'center',
lineHeight: '1.5',
}}
>
Was installed successfully.
<br />
You can now add content to your app.
</span>
<a
href="/settings/applications"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '16px',
fontSize: '13px',
color: '#333',
textDecoration: 'none',
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
background: '#fafafa',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
Open app settings
<ArrowUpRight color="#333" />
</a>
<div
style={{
display: 'flex',
gap: '16px',
marginTop: '32px',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'flex-start',
}}
>
{CATEGORIES.map((category) => (
<CategoryCard
key={category.title}
title={category.title}
color={category.color}
items={category.items}
rotation={category.rotation}
/>
))}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
description: `${APP_DISPLAY_NAME} front component displaying the app logo and name`,
component: MainPage,
});
@@ -0,0 +1,19 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
icon: 'IconFile',
position: -1,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,37 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Overview',
position: 0,
icon: 'IconApps',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
title: ' ',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1,5 +1,4 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { downloadExample } from '@/utils/download-example';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import { tryGitInit } from '@/utils/try-git-init';
@@ -31,11 +30,10 @@ export type AuthenticationMethod = 'oauth' | 'apiKey';
type CreateAppOptions = {
directory?: string;
example?: string;
name?: string;
displayName?: string;
description?: string;
apiUrl?: string;
workspaceUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
@@ -47,9 +45,9 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const apiUrl = options.apiUrl ?? DEV_API_URL;
const workspaceUrl = options.workspaceUrl ?? DEV_API_URL;
const skipLocalInstance = apiUrl !== DEV_API_URL;
const skipLocalInstance = workspaceUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
@@ -92,30 +90,13 @@ export class CreateAppCommand {
this.logNextStep('Scaffolding project files');
if (options.example) {
const exampleSucceeded = await this.tryDownloadExample(
options.example,
appDirectory,
);
if (!exampleSucceeded) {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
} else {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
this.logNextStep('Installing dependencies');
@@ -137,7 +118,7 @@ export class CreateAppCommand {
console.log('');
let authSucceeded = false;
let resolvedApiUrl = apiUrl;
let resolvedWorkspaceUrl = workspaceUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
@@ -145,20 +126,49 @@ export class CreateAppCommand {
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedApiUrl = serverResult.url;
resolvedWorkspaceUrl = serverResult.url;
serverReady = true;
}
}
if (serverReady && authenticationMethod === 'oauth') {
this.logNextStep('Authenticating via OAuth');
authSucceeded = await this.authenticateWithOAuth(resolvedApiUrl);
} else if (serverReady && authenticationMethod === 'apiKey') {
this.logNextStep('Authenticating via API key');
authSucceeded = await this.authenticateWithDevKey(resolvedApiUrl);
if (serverReady) {
this.logNextStep('Authenticating');
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(resolvedWorkspaceUrl);
} else {
this.logDetail('Using development API key');
authSucceeded =
await this.authenticateWithDevKey(resolvedWorkspaceUrl);
}
}
this.logSuccess(appDirectory, resolvedApiUrl, authSucceeded);
this.logNextStep('Installing application');
let syncSucceeded = false;
if (serverReady && authSucceeded) {
syncSucceeded = await this.syncApplication(appDirectory);
if (!syncSucceeded) {
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
return;
}
} else {
this.logDetail('Skipped (server or authentication not available)');
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedWorkspaceUrl);
}
this.logSuccess(appDirectory, resolvedWorkspaceUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -180,6 +190,7 @@ export class CreateAppCommand {
}
steps += 1; // authenticate (oauth or apiKey)
steps += 1; // sync application
return steps;
}
@@ -193,7 +204,6 @@ export class CreateAppCommand {
const appName = (
options.name ??
options.directory ??
options.example ??
'my-twenty-app'
).trim();
@@ -222,28 +232,6 @@ export class CreateAppCommand {
}
}
private async tryDownloadExample(
example: string,
appDirectory: string,
): Promise<boolean> {
try {
await downloadExample(example, appDirectory);
return true;
} catch (error) {
console.log(
chalk.yellow(
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
),
);
this.logDetail('Falling back to default template...');
await fs.emptyDir(appDirectory);
return false;
}
}
private logPlan({
appName,
appDisplayName,
@@ -325,11 +313,253 @@ export class CreateAppCommand {
return {};
}
private async authenticateWithDevKey(apiUrl: string): Promise<boolean> {
private async openMainPage(
appDirectory: string,
workspaceUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
const config = await configService.getConfig();
const token = config.twentyCLIAccessToken ?? config.apiKey;
if (!token) {
return;
}
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(workspaceUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
return;
}
const pageLayoutId = await this.resolvePageLayoutId(
workspaceUrl,
universalIdentifier,
token,
);
if (!pageLayoutId) {
return;
}
const url = `${frontUrl}/page/${pageLayoutId}`;
this.logDetail(`Opening app welcome page: ${url}`);
this.openInBrowser(url);
} catch {
// Best-effort — don't fail the scaffold if browser open fails
}
}
private async resolveWorkspaceFrontUrl(
workspaceUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
currentWorkspace?: {
workspaceUrls?: { subdomainUrl?: string; customUrl?: string };
};
};
};
const urls = body.data?.currentWorkspace?.workspaceUrls;
if (!urls) {
return null;
}
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl?.replace(/\/+$/, '') ?? null;
}
private async readMainPageLayoutUniversalIdentifier(
appDirectory: string,
): Promise<string | null> {
const filePath = path.join(
appDirectory,
'src',
'constants',
'universal-identifiers.ts',
);
const content = await fs.readFile(filePath, 'utf-8');
const match = content.match(
/MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER\s*=\s*'([^']+)'/,
);
return match?.[1] ?? null;
}
private async resolvePageLayoutId(
workspaceUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
getPageLayouts?: { id: string; universalIdentifier: string }[];
};
};
const matching = body.data?.getPageLayouts?.find(
(layout) => layout.universalIdentifier === universalIdentifier,
);
return matching?.id ?? null;
}
private sanitizeBrowserUrl(url: string): string | null {
if (/[^\u0020-\u007E]/.test(url)) {
return null;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
private openInBrowser(url: string): void {
const safeUrl = this.sanitizeBrowserUrl(url);
if (!safeUrl) {
return;
}
const isWindows = process.platform === 'win32';
const command = isWindows
? 'rundll32'
: process.platform === 'darwin'
? 'open'
: 'xdg-open';
const args = isWindows
? ['url.dll,FileProtocolHandler', safeUrl]
: [safeUrl];
const child = spawn(command, args, {
stdio: 'ignore',
detached: !isWindows,
});
child.on('error', () => undefined);
if (!isWindows) {
child.unref();
}
}
private async syncApplication(appDirectory: string): Promise<boolean> {
this.logDetail('Running `yarn twenty dev --once`...');
return new Promise((resolve) => {
const child = spawn('yarn', ['twenty', 'dev', '--once'], {
cwd: appDirectory,
stdio: ['inherit', 'pipe', 'pipe'],
});
child.stdout?.resume();
child.stderr?.resume();
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async tryExistingAuth(workspaceUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== workspaceUrl) {
continue;
}
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
if (!token) {
continue;
}
const response = await fetch(`${workspaceUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: '{ currentWorkspace { id } }',
}),
});
if (!response.ok) {
continue;
}
const body = (await response.json()) as {
data?: { currentWorkspace?: { id: string } };
errors?: unknown[];
};
if (isDefined(body.data?.currentWorkspace) && !body.errors) {
ConfigService.setActiveRemote(remoteName);
await configService.setDefaultRemote(remoteName);
return true;
}
}
return false;
} catch {
return false;
}
}
private async authenticateWithDevKey(workspaceUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl,
apiUrl: workspaceUrl,
remote: 'local',
});
@@ -368,21 +598,21 @@ export class CreateAppCommand {
}
}
private async authenticateWithOAuth(apiUrl: string): Promise<boolean> {
private async authenticateWithOAuth(workspaceUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(apiUrl);
const remoteName = this.deriveRemoteName(workspaceUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl });
const result = await authLoginOAuth({ apiUrl: workspaceUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${apiUrl}`);
this.logDetail(`Authenticated via OAuth to ${workspaceUrl}`);
return true;
}
@@ -390,7 +620,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
` Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -398,7 +628,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
` Authentication failed. Run \`yarn twenty remote add --api-url ${workspaceUrl}\` manually.`,
),
);
@@ -408,7 +638,7 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
apiUrl: string,
workspaceUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
@@ -438,7 +668,7 @@ export class CreateAppCommand {
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${apiUrl}\n`));
console.log(chalk.cyan(` ${workspaceUrl}\n`));
console.log(
chalk.gray(
@@ -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 --api-url <your-instance-url>',
' npx create-twenty-app@latest my-twenty-app --workspace-url <your-instance-url>',
].join('\n');
};
@@ -1,179 +0,0 @@
import { execSync } from 'node:child_process';
import * as fs from 'fs-extra';
import { join } from 'path';
import { tmpdir } from 'node:os';
import chalk from 'chalk';
const TWENTY_REPO_OWNER = 'twentyhq';
const TWENTY_REPO_NAME = 'twenty';
const TWENTY_FALLBACK_REF = 'main';
const TWENTY_EXAMPLES_PATH = 'packages/twenty-apps/examples';
const TWENTY_EXAMPLES_URL = `https://github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tree/${TWENTY_FALLBACK_REF}/${TWENTY_EXAMPLES_PATH}`;
// Fetches the latest release tag from the repo, or falls back to main
const resolveRef = async (): Promise<string> => {
const response = await fetch(
`https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/releases/latest`,
{ headers: { Accept: 'application/vnd.github.v3+json' } },
);
if (response.ok) {
const release = (await response.json()) as { tag_name: string };
return release.tag_name;
}
return TWENTY_FALLBACK_REF;
};
// Uses the GitHub Contents API to list directories — fast and doesn't download the repo
const fetchGitHubDirectoryContents = async (
path: string,
ref: string,
): Promise<{ name: string; type: string }[] | null> => {
const apiUrl = `https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (!response.ok) {
return null;
}
const data = await response.json();
if (!Array.isArray(data)) {
return null;
}
return data as { name: string; type: string }[];
};
const listAvailableExamples = async (ref: string): Promise<string[]> => {
const contents = await fetchGitHubDirectoryContents(
TWENTY_EXAMPLES_PATH,
ref,
);
if (!contents) {
return [];
}
return contents
.filter((entry) => entry.type === 'dir')
.map((entry) => entry.name);
};
const validateExampleExists = async (
exampleName: string,
ref: string,
): Promise<void> => {
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
const contents = await fetchGitHubDirectoryContents(examplePath, ref);
if (contents !== null) {
return;
}
const availableExamples = await listAvailableExamples(ref);
throw new Error(
`Example "${exampleName}" not found.\n\n` +
(availableExamples.length > 0
? `Available examples:\n${availableExamples.map((name) => ` - ${name}`).join('\n')}\n\n`
: '') +
`Browse all examples: ${TWENTY_EXAMPLES_URL}`,
);
};
export const downloadExample = async (
exampleName: string,
targetDirectory: string,
): Promise<void> => {
if (
exampleName.includes('/') ||
exampleName.includes('\\') ||
exampleName.includes('..')
) {
throw new Error(
`Invalid example name: "${exampleName}". Example names must be simple directory names (e.g., "hello-world").`,
);
}
const ref = await resolveRef();
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
console.log(chalk.gray(`Resolving examples from ref '${ref}'...`));
await validateExampleExists(exampleName, ref);
console.log(chalk.gray(`Example '${examplePath}' validated successfully.`));
const tarballUrl = `https://codeload.github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tar.gz/${ref}`;
const tempDir = join(
tmpdir(),
`create-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await fs.ensureDir(tempDir);
console.log(chalk.gray(`Downloading tarball from ${tarballUrl}...`));
const response = await fetch(tarballUrl);
if (!response.ok) {
if (response.status === 404) {
throw new Error(
`Could not find repository: ${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME} (ref: ${ref})`,
);
}
throw new Error(
`Failed to download from GitHub: ${response.status} ${response.statusText}`,
);
}
console.log(chalk.gray('Tarball downloaded. Writing to disk...'));
const tarballPath = join(tempDir, 'archive.tar.gz');
const buffer = Buffer.from(await response.arrayBuffer());
await fs.writeFile(tarballPath, buffer);
console.log(
chalk.gray(
`Tarball saved (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Extracting...`,
),
);
execSync(`tar xzf "${tarballPath}" -C "${tempDir}"`, {
stdio: 'pipe',
});
// GitHub tarballs extract to a directory named {repo}-{ref}/
const extractedEntries = await fs.readdir(tempDir);
const extractedDir = extractedEntries.find(
(entry) => entry !== 'archive.tar.gz',
);
if (!extractedDir) {
throw new Error('Failed to extract archive: no directory found');
}
const sourcePath = join(tempDir, extractedDir, examplePath);
if (!(await fs.pathExists(sourcePath))) {
throw new Error(
`Example directory not found in archive: "${examplePath}"`,
);
}
await fs.copy(sourcePath, targetDirectory);
} finally {
await fs.remove(tempDir);
}
};
@@ -28,12 +28,6 @@ yarn install
yarn twenty dev
```
Or use it as a template via create-twenty-app:
```bash
npx create-twenty-app@latest my-app --example postcard
```
## Learn more
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.5.0",
"version": "2.5.1",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -1395,6 +1395,7 @@ type PageLayout {
objectMetadataId: UUID
tabs: [PageLayoutTab!]
defaultTabToFocusOnMobileAndSidePanelId: UUID
universalIdentifier: UUID!
createdAt: DateTime!
updatedAt: DateTime!
deletedAt: DateTime
@@ -1056,6 +1056,7 @@ export interface PageLayout {
objectMetadataId?: Scalars['UUID']
tabs?: PageLayoutTab[]
defaultTabToFocusOnMobileAndSidePanelId?: Scalars['UUID']
universalIdentifier: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
deletedAt?: Scalars['DateTime']
@@ -3983,6 +3984,7 @@ export interface PageLayoutGenqlSelection{
objectMetadataId?: boolean | number
tabs?: PageLayoutTabGenqlSelection
defaultTabToFocusOnMobileAndSidePanelId?: boolean | number
universalIdentifier?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
deletedAt?: boolean | number
@@ -2780,6 +2780,9 @@ export default {
"defaultTabToFocusOnMobileAndSidePanelId": [
3
],
"universalIdentifier": [
3
],
"createdAt": [
4
],
@@ -22,7 +22,8 @@ Use the `getPublicAssetUrl` helper from `twenty-sdk` to get the full URL of a fi
**In a logic function:**
```ts src/logic-functions/send-invoice.ts
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const handler = async (): Promise<any> => {
const logoUrl = getPublicAssetUrl('logo.png');
@@ -47,12 +48,19 @@ export default defineLogicFunction({
**In a front component:**
```tsx src/front-components/company-card.tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
export default defineFrontComponent(() => {
const CompanyCard = () => {
const logoUrl = getPublicAssetUrl('logo.png');
return <img src={logoUrl} alt="App logo" />;
};
export default defineFrontComponent({
universalIdentifier: '...',
name: 'company-card',
component: CompanyCard,
});
```
@@ -138,20 +138,6 @@ Both modes need an authenticated remote.
| `--debounceMs <ms>` | Set the file-change debounce delay in milliseconds (default: `2000`). |
| `--verbose` / `--debug` | Show detailed build logs, sync requests, and error traces. |
---
## Starting from an example
Use `--example` to start with a more complete project (custom objects, fields, logic functions, front components):
```bash filename="Terminal"
npx create-twenty-app@latest my-twenty-app --example postcard
```
Examples live in [twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples). You can also scaffold individual entities into an existing project with `yarn twenty add` — see [Scaffolding](/developers/extend/apps/getting-started/scaffolding).
---
## What you can build
Apps are composed of **entities** — each defined as a TypeScript file with a single `export default`:
@@ -390,7 +390,8 @@ export default defineFrontComponent({
Front components can access files from the app's `public/` directory using `getPublicAssetUrl`:
```tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ export const PAGE_LAYOUT_FRAGMENT = gql`
name
objectMetadataId
type
universalIdentifier
defaultTabToFocusOnMobileAndSidePanelId
createdAt
updatedAt
@@ -24,6 +24,7 @@ const MOCK_PAGE_LAYOUT: PageLayout = {
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'obj-1',
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [],
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
@@ -13,6 +13,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
name: 'Default Company Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -13,6 +13,7 @@ export const DEFAULT_MESSAGE_THREAD_RECORD_PAGE_LAYOUT: PageLayout = {
name: 'Default Message Thread Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -13,6 +13,7 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
name: 'Default Note Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -13,6 +13,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
name: 'Default Opportunity Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -13,6 +13,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
name: 'Default Person Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -13,6 +13,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
name: 'Default Record Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -13,6 +13,7 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
name: 'Default Task Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -17,6 +17,7 @@ export const DEFAULT_WORKFLOW_PAGE_LAYOUT: PageLayout = {
name: 'Default Workflow Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -17,6 +17,7 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
name: 'Default Workflow Run Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -17,6 +17,7 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
name: 'Default Workflow Version Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: null,
universalIdentifier: '00000000-0000-0000-0000-000000000000',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -12,6 +12,7 @@ export const UPDATE_PAGE_LAYOUT_WITH_TABS_AND_WIDGETS = gql`
name
type
objectMetadataId
universalIdentifier
defaultTabToFocusOnMobileAndSidePanelId
createdAt
updatedAt
@@ -75,6 +75,7 @@ describe('useIsPageLayoutInEditMode', () => {
name: 'Record Page',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'company-id',
universalIdentifier: '20202020-0000-0000-0000-000000000001',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
@@ -18,6 +18,7 @@ const MOCK_DASHBOARD_LAYOUT: PageLayout = {
name: 'Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'object-metadata-id',
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [],
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
@@ -16,6 +16,7 @@ describe('convertPageLayoutToTabLayouts', () => {
name: 'Page Layout 1',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-1',
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [
{
id: 'tab-1',
@@ -100,6 +101,7 @@ describe('convertPageLayoutToTabLayouts', () => {
name: 'Page Layout 1',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-1',
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [
{
id: 'tab-1',
@@ -23,6 +23,7 @@ const mockPageLayout: PageLayout = {
name: 'Test Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: null,
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
@@ -140,6 +140,7 @@ const createPageLayoutWithWidget = (
name: 'Mock Page Layout',
type: pageLayoutType,
objectMetadataId: companyObjectMetadataItem.id,
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [
{
__typename: 'PageLayoutTab',
@@ -1747,6 +1748,7 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
name: 'Mock Page Layout',
type: pageLayoutType,
objectMetadataId: companyObjectMetadataItem.id,
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs:
variant === 'side-column'
? [
@@ -288,6 +288,7 @@ const createPageLayoutWithWidget = (
name: 'Mock Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId,
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [
{
__typename: 'PageLayoutTab' as const,
@@ -138,6 +138,7 @@ const createPageLayoutWithWidget = (
name: 'Mock Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId,
universalIdentifier: '20202020-0000-0000-0000-000000000001',
tabs: [
{
__typename: 'PageLayoutTab' as const,
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "2.5.0",
"version": "2.5.1",
"sideEffects": false,
"bin": {
"twenty": "dist/cli.cjs"
@@ -42,6 +42,11 @@
"import": "./dist/logic-function/index.mjs",
"require": "./dist/logic-function/index.cjs"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.mjs",
"require": "./dist/utils/index.cjs"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.mjs",
@@ -123,6 +128,9 @@
"logic-function": [
"dist/logic-function/index.d.ts"
],
"utils": [
"dist/utils/index.d.ts"
],
"cli": [
"dist/cli/operations/index.d.ts"
],
+6 -5
View File
@@ -14,9 +14,9 @@
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.browser.ts",
"npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.utils.ts && npx vite build -c vite.config.browser.ts",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist",
"npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' 'dist/logic-function/**/*.d.ts' 'dist/logic-function/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs"
"npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' 'dist/logic-function/**/*.d.ts' 'dist/logic-function/**/*.d.ts.map' 'dist/utils/**/*.d.ts' 'dist/utils/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs"
],
"parallel": false
}
@@ -26,7 +26,7 @@
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-sdk",
"command": "npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.browser.ts && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist && npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' 'dist/logic-function/**/*.d.ts' 'dist/logic-function/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs && npx vite build -c vite.config.node.ts --watch & npx vite build -c vite.config.define.ts --watch & npx vite build -c vite.config.billing.ts --watch & npx vite build -c vite.config.front-component.ts --watch & npx vite build -c vite.config.logic-function.ts --watch & npx vite build -c vite.config.browser.ts --watch"
"command": "npx rimraf dist && npx vite build -c vite.config.node.ts && npx vite build -c vite.config.define.ts && npx vite build -c vite.config.billing.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.utils.ts && npx vite build -c vite.config.browser.ts && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist && npx rimraf 'dist/sdk' 'dist/define/**/*.d.ts' 'dist/define/**/*.d.ts.map' 'dist/billing/**/*.d.ts' 'dist/billing/**/*.d.ts.map' 'dist/front-component/**/*.d.ts' 'dist/front-component/**/*.d.ts.map' 'dist/logic-function/**/*.d.ts' 'dist/logic-function/**/*.d.ts.map' 'dist/utils/**/*.d.ts' 'dist/utils/**/*.d.ts.map' && npx rollup -c rollup.config.sdk-dts.mjs && npx vite build -c vite.config.node.ts --watch & npx vite build -c vite.config.define.ts --watch & npx vite build -c vite.config.billing.ts --watch & npx vite build -c vite.config.front-component.ts --watch & npx vite build -c vite.config.logic-function.ts --watch & npx vite build -c vite.config.utils.ts --watch & npx vite build -c vite.config.browser.ts --watch"
}
},
"start": {
@@ -94,11 +94,12 @@
"outputs": [
"{projectRoot}/dist/define",
"{projectRoot}/dist/front-component",
"{projectRoot}/dist/logic-function"
"{projectRoot}/dist/logic-function",
"{projectRoot}/dist/utils"
],
"options": {
"cwd": "{projectRoot}",
"command": "npx vite build -c vite.config.define.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx rollup -c rollup.config.sdk-dts.mjs"
"command": "npx vite build -c vite.config.define.ts && npx vite build -c vite.config.front-component.ts && npx vite build -c vite.config.logic-function.ts && npx vite build -c vite.config.utils.ts && npx rollup -c rollup.config.sdk-dts.mjs"
}
}
}
@@ -42,4 +42,10 @@ export default [
external,
plugins,
},
{
input: 'src/sdk/utils/index.ts',
output: { file: 'dist/utils/index.d.ts', format: 'es' },
external,
plugins,
},
];
@@ -26,7 +26,6 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"ViewType",
"ViewVisibility",
"generateDefaultFieldUniversalIdentifier",
"getPublicAssetUrl",
"validateFields",
],
"factories": [
@@ -103,8 +103,6 @@ export type {
ViewSortManifest,
} from 'twenty-shared/application';
export { getPublicAssetUrl } from '@/sdk/define/get-public-asset-url';
export {
AggregateOperations,
DateDisplayFormat,
@@ -0,0 +1 @@
export { getPublicAssetUrl } from '@/sdk/utils/get-public-asset-url';
+65
View File
@@ -0,0 +1,65 @@
import path from 'path';
import { type PackageJson } from 'type-fest';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import packageJson from './package.json';
export default defineConfig(() => {
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/twenty-sdk-utils',
resolve: {
alias: {
'@/': path.resolve(__dirname, 'src') + '/',
},
},
plugins: [
tsconfigPaths({
root: __dirname,
}),
],
build: {
emptyOutDir: false,
outDir: 'dist/utils',
sourcemap: true,
lib: {
entry: 'src/sdk/utils/index.ts',
name: 'twenty-sdk-utils',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
},
rollupOptions: {
external: (id: string) => {
if (/^node:/.test(id)) {
return true;
}
const builtins = [
'child_process',
'crypto',
'fs',
'fs/promises',
'module',
'os',
'path',
'stream',
'url',
'util',
];
if (builtins.includes(id)) {
return true;
}
const deps = Object.keys(
(packageJson as PackageJson).dependencies || {},
);
return deps.some((dep) => id === dep || id.startsWith(dep + '/'));
},
},
},
logLevel: 'warn' as const,
};
});
@@ -32,6 +32,9 @@ export class PageLayoutDTO {
@Field(() => UUIDScalarType, { nullable: true })
defaultTabToFocusOnMobileAndSidePanelId?: SerializedRelation;
@Field(() => UUIDScalarType, { nullable: false })
universalIdentifier: string;
@Field()
createdAt: Date;