Refactor twenty client sdk provisioning for logic function and front-component (#18544)

## 1. The `twenty-client-sdk` Package (Source of Truth)

The monorepo package at `packages/twenty-client-sdk` ships with:
- A **pre-built metadata client** (static, generated from a fixed
schema)
- A **stub core client** that throws at runtime (`CoreApiClient was not
generated...`)
- Both ESM (`.mjs`) and CJS (`.cjs`) bundles in `dist/`
- A `package.json` with proper `exports` map for
`twenty-client-sdk/core`, `twenty-client-sdk/metadata`, and
`twenty-client-sdk/generate`

## 2. Generation & Upload (Server-Side, at Migration Time)

**When**: `WorkspaceMigrationRunnerService.run()` executes after a
metadata schema change.

**What happens in `SdkClientGenerationService.generateAndStore()`**:
1. Copies the stub `twenty-client-sdk` package from the server's assets
(resolved via `SDK_CLIENT_PACKAGE_DIRNAME` — from
`dist/assets/twenty-client-sdk/` in production, or from `node_modules`
in dev)
2. Filters out `node_modules/` and `src/` during copy — only
`package.json` + `dist/` are kept (like an npm publish)
3. Calls `replaceCoreClient()` which uses `@genql/cli` to introspect the
**application-scoped** GraphQL schema and generates a real
`CoreApiClient`, then compiles it to ESM+CJS and overwrites
`dist/core.mjs` and `dist/core.cjs`
4. Archives the **entire package** (with `package.json` + `dist/`) into
`twenty-client-sdk.zip`
5. Uploads the single archive to S3 under
`FileFolder.GeneratedSdkClient`
6. Sets `isSdkLayerStale = true` on the `ApplicationEntity` in the
database

## 3. Invalidation Signal

The `isSdkLayerStale` boolean column on `ApplicationEntity` is the
invalidation mechanism:
- **Set to `true`** by `generateAndStore()` after uploading a new client
archive
- **Checked** by both logic function drivers before execution — if
`true`, they rebuild their local layer
- **Set back to `false`** by `markSdkLayerFresh()` after the driver has
successfully consumed the new archive

Default is `false` so existing applications without a generated client
aren't affected.

## 4a. Logic Functions — Local Driver

**`ensureSdkLayer()`** is called before every execution:
1. Checks if the local SDK layer directory exists AND `isSdkLayerStale`
is `false` → early return
2. Otherwise, cleans the local layer directory
3. Calls `downloadAndExtractToPackage()` which streams the zip from S3
directly to disk and extracts the full package into
`<tmpdir>/sdk/<workspaceId>-<appId>/node_modules/twenty-client-sdk/`
4. Calls `markSdkLayerFresh()` to set `isSdkLayerStale = false`

**At execution time**, `assembleNodeModules()` symlinks everything from
the deps layer's `node_modules/` **except** `twenty-client-sdk`, which
is symlinked from the SDK layer instead. This ensures the logic
function's `import ... from 'twenty-client-sdk/core'` resolves to the
generated client.

## 4b. Logic Functions — Lambda Driver

**`ensureSdkLayer()`** is called during `build()`:
1. Checks if `isSdkLayerStale` is `false` and an existing Lambda layer
ARN exists → early return
2. Otherwise, deletes all existing layer versions for this SDK layer
name
3. Calls `downloadArchiveBuffer()` to get the raw zip from S3 (no disk
extraction)
4. Calls `reprefixZipEntries()` which streams the zip entries into a
**new zip** with the path prefix
`nodejs/node_modules/twenty-client-sdk/` — this is the Lambda layer
convention path. All done in memory, no disk round-trip
5. Publishes the re-prefixed zip as a new Lambda layer via
`publishLayer()`
6. Calls `markSdkLayerFresh()`

**At function creation**, the Lambda is created with **two layers**:
`[depsLayerArn, sdkLayerArn]`. The SDK layer is listed last so it
overwrites the stub `twenty-client-sdk` from the deps layer (later
layers take precedence in Lambda's `/opt` merge).

## 5. Front Components

Front components are built by `app:build` with `twenty-client-sdk/core`
and `twenty-client-sdk/metadata` as **esbuild externals**. The stored
`.mjs` in S3 has unresolved bare import specifiers like `import {
CoreApiClient } from 'twenty-client-sdk/core'`.

SDK import resolution is split between the **frontend host** (fetching &
caching SDK modules) and the **Web Worker** (rewriting imports):

**Server endpoints**:
- `GET /rest/front-components/:id` —
`FrontComponentService.getBuiltComponentStream()` returns the **raw
`.mjs`** directly from file storage. No bundling, no SDK injection.
- `GET /rest/sdk-client/:applicationId/:moduleName` —
`SdkClientController` reads a single file (e.g. `dist/core.mjs`) from
the generated SDK archive via
`SdkClientGenerationService.readFileFromArchive()` and serves it as
JavaScript.

**Frontend host** (`FrontComponentRenderer` in `twenty-front`):
1. Queries `FindOneFrontComponent` which returns `applicationId`,
`builtComponentChecksum`, `usesSdkClient`, and `applicationTokenPair`
2. If `usesSdkClient` is `true`, renders
`FrontComponentRendererWithSdkClient` which calls the
`useApplicationSdkClient` hook
3. `useApplicationSdkClient({ applicationId, accessToken })` checks the
Jotai atom family cache for existing blob URLs. On cache miss, fetches
both SDK modules from `GET /rest/sdk-client/:applicationId/core` and
`/metadata`, creates **blob URLs** for each, and stores them in the atom
family
4. Once the blob URLs are cached, passes them as `sdkClientUrls`
(already blob URLs, not server URLs) to `SharedFrontComponentRenderer` →
`FrontComponentWorkerEffect` → worker's `render()` call via
`HostToWorkerRenderContext`

**Worker** (`remote-worker.ts` in `twenty-sdk`):
1. Fetches the raw component `.mjs` source as text
2. If `sdkClientUrls` are provided and the source contains SDK import
specifiers (`twenty-client-sdk/core`, `twenty-client-sdk/metadata`),
**rewrites** the bare specifiers to the blob URLs received from the host
(e.g. `'twenty-client-sdk/core'` → `'blob:...'`)
3. Creates a blob URL for the rewritten source and `import()`s it
4. Revokes only the component blob URL after the module is loaded — the
SDK blob URLs are owned and managed by the host's Jotai cache

This approach eliminates server-side esbuild bundling on every request,
caches SDK modules per application in the frontend, and keeps the
worker's job to a simple string rewrite.

## Summary Diagram

```
app:build (SDK)
  └─ twenty-client-sdk stub (metadata=real, core=stub)
       │
       ▼
WorkspaceMigrationRunnerService.run()
  └─ SdkClientGenerationService.generateAndStore()
       ├─ Copy stub package (package.json + dist/)
       ├─ replaceCoreClient() → regenerate core.mjs/core.cjs
       ├─ Zip entire package → upload to S3
       └─ Set isSdkLayerStale = true
              │
     ┌────────┴────────────────────┐
     ▼                             ▼
Logic Functions               Front Components
     │                             │
     ├─ Local Driver               ├─ GET /rest/sdk-client/:appId/core
     │   └─ downloadAndExtract     │    → core.mjs from archive
     │      → symlink into         │
     │        node_modules         ├─ Host (useApplicationSdkClient)
     │                             │    ├─ Fetch SDK modules
     └─ Lambda Driver              │    ├─ Create blob URLs
         └─ downloadArchiveBuffer  │    └─ Cache in Jotai atom family
            → reprefixZipEntries   │
            → publish as Lambda    ├─ GET /rest/front-components/:id
              layer                │    → raw .mjs (no bundling)
                                   │
                                   └─ Worker (browser)
                                        ├─ Fetch component .mjs
                                        ├─ Rewrite imports → blob URLs
                                        └─ import() rewritten source
```

## Next PR
- Estimate perf improvement by implementing a redis caching for front
component client storage ( we don't even cache front comp initially )
- Implem frontent blob invalidation sse event from server

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Paul Rastoin 2026-03-24 19:10:25 +01:00 committed by GitHub
parent 16451ee2ee
commit 4ea2e32366
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
189 changed files with 12487 additions and 9856 deletions

View file

@ -3,12 +3,13 @@ description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Pulls the specified semver image tag from Docker Hub.
Accepts "latest" (pulls the latest Docker Hub image, checks out main) or a
semver tag (e.g., v0.40.0).
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
description: 'Twenty Docker Hub image tag — either "latest" or a semver tag (e.g., v0.40.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
@ -30,12 +31,19 @@ outputs:
runs:
using: 'composite'
steps:
- name: Validate version
- name: Resolve version
id: resolve
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
if [ "$VERSION" = "latest" ]; then
echo "docker-tag=latest" >> "$GITHUB_OUTPUT"
echo "git-ref=main" >> "$GITHUB_OUTPUT"
elif echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "docker-tag=$VERSION" >> "$GITHUB_OUTPUT"
echo "git-ref=$VERSION" >> "$GITHUB_OUTPUT"
else
echo "::error::twenty-version must be \"latest\" or a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
@ -43,7 +51,7 @@ runs:
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ inputs.twenty-version }}
ref: ${{ steps.resolve.outputs.git-ref }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
@ -56,7 +64,7 @@ runs:
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "TAG=${{ steps.resolve.outputs.docker-tag }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env

View file

@ -10,6 +10,9 @@ packages:
'twenty-sdk':
access: $all
publish: $all
'twenty-client-sdk':
access: $all
publish: $all
'create-twenty-app':
access: $all
publish: $all

View file

@ -21,10 +21,12 @@ jobs:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
create-app-e2e:
@ -64,11 +66,12 @@ jobs:
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 twenty-sdk create-twenty-app --releaseVersion=$CI_VERSION
npx nx run-many -t set-local-version -p twenty-sdk twenty-client-sdk create-twenty-app --releaseVersion=$CI_VERSION
- name: Build packages
run: |
npx nx build twenty-sdk
npx nx build twenty-client-sdk
npx nx build create-twenty-app
- name: Install and start Verdaccio
@ -88,7 +91,7 @@ jobs:
run: |
npm set //localhost:4873/:_authToken "ci-auth-token"
for pkg in twenty-sdk create-twenty-app; do
for pkg in twenty-sdk twenty-client-sdk create-twenty-app; do
cd packages/$pkg
npm publish --registry http://localhost:4873 --tag ci
cd ../..
@ -153,11 +156,15 @@ jobs:
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --token $SEED_API_KEY --url http://localhost:3000
- name: Build scaffolded app
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty build
test -d .twenty/output
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: |
@ -166,6 +173,13 @@ jobs:
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"
- name: Run scaffolded app integration test
env:
TWENTY_API_URL: http://localhost:3000

View file

@ -25,7 +25,7 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-sdk/src/clients/generated/metadata/**
packages/twenty-client-sdk/**
packages/twenty-emails/**
packages/twenty-shared/**
@ -177,14 +177,14 @@ jobs:
HAS_ERRORS=true
fi
npx nx run twenty-sdk:generate-metadata-client
npx nx run twenty-client-sdk:generate-metadata-client
if ! git diff --quiet -- packages/twenty-sdk/src/clients/generated/metadata; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-sdk:generate-metadata-client' and commit the changes."
if ! git diff --quiet -- packages/twenty-client-sdk/src/metadata/generated; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-client-sdk:generate-metadata-client' and commit the changes."
echo ""
echo "The following SDK metadata client changes were detected:"
echo "==================================================="
git diff -- packages/twenty-sdk/src/clients/generated/metadata
git diff -- packages/twenty-client-sdk/src/metadata/generated
echo "==================================================="
echo ""
HAS_ERRORS=true

View file

@ -207,6 +207,7 @@
"packages/twenty-e2e-testing",
"packages/twenty-shared",
"packages/twenty-sdk",
"packages/twenty-client-sdk",
"packages/twenty-apps",
"packages/twenty-cli",
"packages/create-twenty-app",

View file

@ -44,6 +44,7 @@ yarn twenty server start # Start local Twenty server
yarn twenty remote add --local # Authenticate via OAuth
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed CoreApiClient — MetadataApiClient ships pre-built — both available via `twenty-client-sdk`)
yarn twenty dev
# Watch your application's function logs
@ -135,7 +136,7 @@ npx create-twenty-app@latest my-app --port 3000
- Use `yarn twenty remote add --local` to authenticate with your Twenty workspace via OAuth.
- Explore the generated project and add your first entity with `yarn twenty add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Use `yarn twenty dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- `CoreApiClient` (for workspace data via `/graphql`) is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/clients'`.
- `CoreApiClient` is auto-generated by `yarn twenty dev`. `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`) ships pre-built with the SDK. Both are available via `import { CoreApiClient } from 'twenty-client-sdk/core'` and `import { MetadataApiClient } from 'twenty-client-sdk/metadata'`.
## Build and publish your application

View file

@ -106,6 +106,9 @@ describe('copyBaseApplicationProject', () => {
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.devDependencies['twenty-client-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.scripts['twenty']).toBe('twenty');
});
@ -362,11 +365,21 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(true);
@ -448,11 +461,21 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(false);
expect(
await fs.pathExists(join(srcPath, 'views', 'example-view.ts')),
).toBe(false);
@ -480,7 +503,7 @@ describe('copyBaseApplicationProject', () => {
});
describe('selective examples', () => {
it('should create only front component when only that option is enabled', async () => {
it('should create front component and page layout when only front component option is enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
@ -506,6 +529,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'front-components', 'hello-world.tsx'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'page-layouts', 'example-record-page-layout.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@ -545,6 +573,11 @@ describe('copyBaseApplicationProject', () => {
join(srcPath, 'logic-functions', 'hello-world.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'create-hello-world-company.ts'),
),
).toBe(true);
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);

View file

@ -47,15 +47,17 @@ describe('scaffoldIntegrationTest', () => {
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
"import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-sdk/clients'",
"import { MetadataApiClient } from 'twenty-client-sdk/metadata'",
);
expect(content).toContain(
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
);
expect(content).toContain('appBuild');
expect(content).toContain('appDeploy');
expect(content).toContain('appInstall');
expect(content).toContain('appUninstall');
expect(content).toContain('new MetadataApiClient()');
expect(content).toContain('findManyApplications');
@ -136,7 +138,7 @@ describe('scaffoldIntegrationTest', () => {
expect(content).toContain('yarn install --immutable');
expect(content).toContain('yarn test');
expect(content).toContain('TWENTY_API_URL');
expect(content).toContain('TWENTY_TEST_API_KEY');
expect(content).toContain('TWENTY_API_KEY');
});
});

View file

@ -34,6 +34,8 @@ export const copyBaseApplicationProject = async ({
await createGitignore(appDirectory);
await createNvmrc(appDirectory);
await createPublicAssetDirectory(appDirectory);
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
@ -69,6 +71,12 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'logic-functions',
fileName: 'hello-world.ts',
});
await createCreateCompanyFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'create-hello-world-company.ts',
});
}
if (exampleOptions.includeExampleFrontComponent) {
@ -77,6 +85,12 @@ export const copyBaseApplicationProject = async ({
fileFolder: 'front-components',
fileName: 'hello-world.tsx',
});
await createExamplePageLayout({
appDirectory: sourceFolderPath,
fileFolder: 'page-layouts',
fileName: 'example-record-page-layout.ts',
});
}
if (exampleOptions.includeExampleView) {
@ -186,6 +200,10 @@ yarn-error.log*
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
};
const createNvmrc = async (appDirectory: string) => {
await fs.writeFile(join(appDirectory, '.nvmrc'), '24.5.0\n');
};
const createDefaultRoleConfig = async ({
displayName,
appDirectory,
@ -230,19 +248,59 @@ const createDefaultFrontComponent = async ({
}) => {
const universalIdentifier = v4();
const content = `import { defineFrontComponent } from 'twenty-sdk';
const content = `import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '${universalIdentifier}',
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,
@ -253,6 +311,56 @@ export default defineFrontComponent({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExamplePageLayout = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const pageLayoutUniversalIdentifier = v4();
const tabUniversalIdentifier = v4();
const widgetUniversalIdentifier = v4();
const content = `import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '${pageLayoutUniversalIdentifier}',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '${tabUniversalIdentifier}',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '${widgetUniversalIdentifier}',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultFunction = async ({
appDirectory,
fileFolder,
@ -288,6 +396,58 @@ export default defineLogicFunction({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createCreateCompanyFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: \`Created company "\${createCompany?.name}" with id \${createCompany?.id}\`,
};
};
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPreInstallFunction = async ({
appDirectory,
fileFolder,
@ -615,6 +775,7 @@ const createPackageJson = async ({
'react-dom': '^19.0.0',
oxlint: '^0.16.0',
'twenty-sdk': createTwentyAppPackageJson.version,
'twenty-client-sdk': createTwentyAppPackageJson.version,
};
if (includeExampleIntegrationTest) {

View file

@ -149,18 +149,17 @@ const createIntegrationTest = async ({
fileName: string;
}) => {
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
@ -170,14 +169,27 @@ describe('App installation', () => {
);
}
appInstalled = true;
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(\`[deploy] \${message}\`),
});
if (!deployResult.success) {
throw new Error(
\`Deploy failed: \${deployResult.error?.message ?? 'Unknown error'}\`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
\`Install failed: \${installResult.error?.message ?? 'Unknown error'}\`,
);
}
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
@ -257,7 +269,7 @@ jobs:
run: yarn test
env:
TWENTY_API_URL: \${{ steps.twenty.outputs.server-url }}
TWENTY_TEST_API_KEY: \${{ steps.twenty.outputs.access-token }}
TWENTY_API_KEY: \${{ steps.twenty.outputs.access-token }}
`;
const workflowDir = join(appDirectory, '.github', 'workflows');

View file

@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
universalIdentifier: 'b602dbd9-e511-49ce-b6d3-b697218dc69c',
type: FieldType.SELECT,
name: 'category',
label: 'Category',

View file

@ -3,7 +3,7 @@ import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineField({
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
universalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',

View file

@ -25,8 +25,8 @@ export default defineObject({
{
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
type: FieldType.ADDRESS,
label: 'Address',
name: 'address',
label: 'Mailing Address',
name: 'mailingAddress',
icon: 'IconHome',
},
],

View file

@ -0,0 +1,42 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
env:
TWENTY_VERSION: latest
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'yarn'
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}

View file

@ -4,6 +4,7 @@
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/app-seeds/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls

View file

@ -20,7 +20,8 @@
"@types/react": "^18.2.0",
"oxlint": "^0.16.0",
"react": "^18.2.0",
"twenty-sdk": "0.6.4",
"twenty-client-sdk": "portal:../../twenty-client-sdk",
"twenty-sdk": "portal:../../twenty-sdk",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"

View file

@ -1,16 +1,15 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
@ -20,14 +19,27 @@ describe('App installation', () => {
);
}
appInstalled = true;
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {

View file

@ -0,0 +1,13 @@
import { defineAgent } from 'twenty-sdk';
export const EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER =
'110bebc2-f116-46b6-a35d-61e91c3c0a43';
export default defineAgent({
universalIdentifier: EXAMPLE_AGENT_UNIVERSAL_IDENTIFIER,
name: 'example-agent',
label: 'Example Agent',
description: 'A sample AI agent for your application',
icon: 'IconRobot',
prompt: 'You are a helpful assistant. Help users with their questions and tasks.',
});

View file

@ -2,7 +2,7 @@ import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'6563e091-9f5b-4026-a3ea-7e3b3d09e218';
'bb1decf6-dee5-43ef-b881-9799f97b02a8';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,

View file

@ -3,7 +3,7 @@ import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'
export default defineField({
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: '770d32c2-cf12-4ab2-b66d-73f92dc239b5',
universalIdentifier: 'be08a7c6-2586-4d91-9fa7-0a44e6eae30c',
type: FieldType.NUMBER,
name: 'priority',
label: 'Priority',

View file

@ -1,16 +1,56 @@
import { useEffect, useState } from 'react';
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk';
export const HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'7a758f23-5e7d-497d-98c9-7ca8d6c085b0';
export const HelloWorld = () => {
const client = new CoreApiClient();
const [data, setData] = useState<
Pick<CoreSchema.Company, 'name' | 'id'> | undefined
>(undefined);
useEffect(() => {
const fetchData = async () => {
const response = await client.query({
company: {
name: true,
id: true,
__args: {
filter: {
position: {
eq: 1,
},
},
},
},
});
setData(response.company);
};
fetchData();
}, []);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Hello, World!</h1>
<p>This is your first front component.</p>
{data ? (
<div>
<p>Company name: {data.name}</p>
<p>Company id: {data.id}</p>
</div>
) : (
<p>Company not found</p>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'd371f098-5b2c-42f0-898d-94459f1ee337',
universalIdentifier: HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'hello-world-front-component',
description: 'A sample front component',
component: HelloWorld,

View file

@ -0,0 +1,35 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (): Promise<{ message: string }> => {
const client = new CoreApiClient();
const { createCompany } = await client.mutation({
createCompany: {
__args: {
data: {
name: 'Hello World',
},
},
id: true,
name: true,
},
});
return {
message: `Created company "${createCompany?.name}" with id ${createCompany?.id}`,
};
};
export default defineLogicFunction({
universalIdentifier: '94abaa53-d265-4fa4-ae52-1d7ea711ecf2',
name: 'create-hello-world-company',
description: 'Creates a company called Hello World',
timeoutSeconds: 5,
handler,
httpRouteTriggerSettings: {
path: '/create-hello-world-company',
httpMethod: 'POST',
isAuthRequired: true,
},
});

View file

@ -5,7 +5,7 @@ const handler = async (): Promise<{ message: string }> => {
};
export default defineLogicFunction({
universalIdentifier: '2baa26eb-9aaf-4856-a4f4-30d6fd6480ee',
universalIdentifier: 'b05e4b30-72d4-4d7f-8091-32e037b601da',
name: 'hello-world-logic-function',
description: 'A simple logic function',
timeoutSeconds: 5,

View file

@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePostInstallLogicFunction({
universalIdentifier: '7a3f4684-51db-494d-833b-a747a3b90507',
universalIdentifier: '8c726dcc-1709-4eac-aa8b-f99960a9ec1b',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,

View file

@ -5,7 +5,7 @@ const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
};
export default definePreInstallLogicFunction({
universalIdentifier: '1272ffdb-8e2f-492c-ab37-66c2b97e9c23',
universalIdentifier: 'f8ad4b09-6a12-4b12-a52a-3472d3a78dc7',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,

View file

@ -3,7 +3,7 @@ import { NavigationMenuItemType } from 'twenty-shared/types';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '10f90627-e9c2-44b7-9742-bed77e3d1b17',
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
name: 'example-navigation-menu-item',
icon: 'IconList',
color: 'blue',

View file

@ -1,10 +1,10 @@
import { defineObject, FieldType } from 'twenty-sdk';
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
'dfd43356-39b3-4b55-b4a7-279bec689928';
'47fd9bd9-392b-4d9f-9091-9a91b1edf519';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'd2d7f6cd-33f6-456f-bf00-17adeca926ba';
'2d9ff841-cf8e-44ec-ad8e-468455f7eebd';
export default defineObject({
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,

View file

@ -0,0 +1,31 @@
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/front-components/hello-world';
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
name: 'Example Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
title: 'Hello World',
position: 50,
icon: 'IconWorld',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
title: 'Hello World',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});

View file

@ -1,7 +1,7 @@
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'9238bc7b-d38f-4a1c-9d19-31ab7bc67a2f';
'c38f4d11-760c-4d5c-89ed-e569c28b7b70';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,

View file

@ -1,7 +1,7 @@
import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'd0940029-9d3c-40be-903a-52d65393028f';
'90cf9144-4811-4653-93a2-9a6780fe6aac';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,

View file

@ -1,7 +1,7 @@
import { defineView, ViewKey } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = 'e004df40-29f3-47ba-b39d-d3a5c444367a';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER = '965e3776-b966-4be8-83f7-6cd3bce5e1bd';
export default defineView({
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
@ -12,7 +12,7 @@ export default defineView({
position: 0,
fields: [
{
universalIdentifier: '496c40c2-5766-419c-93bf-20fdad3f34bb',
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,

View file

@ -352,9 +352,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/aix-ppc64@npm:0.27.3"
"@esbuild/aix-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/aix-ppc64@npm:0.27.4"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
@ -366,9 +366,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm64@npm:0.27.3"
"@esbuild/android-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm64@npm:0.27.4"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
@ -380,9 +380,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-arm@npm:0.27.3"
"@esbuild/android-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-arm@npm:0.27.4"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
@ -394,9 +394,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/android-x64@npm:0.27.3"
"@esbuild/android-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/android-x64@npm:0.27.4"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
@ -408,9 +408,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-arm64@npm:0.27.3"
"@esbuild/darwin-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-arm64@npm:0.27.4"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
@ -422,9 +422,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/darwin-x64@npm:0.27.3"
"@esbuild/darwin-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/darwin-x64@npm:0.27.4"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
@ -436,9 +436,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-arm64@npm:0.27.3"
"@esbuild/freebsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-arm64@npm:0.27.4"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
@ -450,9 +450,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/freebsd-x64@npm:0.27.3"
"@esbuild/freebsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/freebsd-x64@npm:0.27.4"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
@ -464,9 +464,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm64@npm:0.27.3"
"@esbuild/linux-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm64@npm:0.27.4"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
@ -478,9 +478,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-arm@npm:0.27.3"
"@esbuild/linux-arm@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-arm@npm:0.27.4"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
@ -492,9 +492,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ia32@npm:0.27.3"
"@esbuild/linux-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ia32@npm:0.27.4"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
@ -506,9 +506,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-loong64@npm:0.27.3"
"@esbuild/linux-loong64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-loong64@npm:0.27.4"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
@ -520,9 +520,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-mips64el@npm:0.27.3"
"@esbuild/linux-mips64el@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-mips64el@npm:0.27.4"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
@ -534,9 +534,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-ppc64@npm:0.27.3"
"@esbuild/linux-ppc64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-ppc64@npm:0.27.4"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
@ -548,9 +548,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-riscv64@npm:0.27.3"
"@esbuild/linux-riscv64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-riscv64@npm:0.27.4"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
@ -562,9 +562,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-s390x@npm:0.27.3"
"@esbuild/linux-s390x@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-s390x@npm:0.27.4"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
@ -576,9 +576,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/linux-x64@npm:0.27.3"
"@esbuild/linux-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/linux-x64@npm:0.27.4"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
@ -590,9 +590,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-arm64@npm:0.27.3"
"@esbuild/netbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-arm64@npm:0.27.4"
conditions: os=netbsd & cpu=arm64
languageName: node
linkType: hard
@ -604,9 +604,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/netbsd-x64@npm:0.27.3"
"@esbuild/netbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/netbsd-x64@npm:0.27.4"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
@ -618,9 +618,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-arm64@npm:0.27.3"
"@esbuild/openbsd-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-arm64@npm:0.27.4"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
@ -632,9 +632,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openbsd-x64@npm:0.27.3"
"@esbuild/openbsd-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openbsd-x64@npm:0.27.4"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
@ -646,9 +646,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/openharmony-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/openharmony-arm64@npm:0.27.3"
"@esbuild/openharmony-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/openharmony-arm64@npm:0.27.4"
conditions: os=openharmony & cpu=arm64
languageName: node
linkType: hard
@ -660,9 +660,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/sunos-x64@npm:0.27.3"
"@esbuild/sunos-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/sunos-x64@npm:0.27.4"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
@ -674,9 +674,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-arm64@npm:0.27.3"
"@esbuild/win32-arm64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-arm64@npm:0.27.4"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
@ -688,9 +688,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-ia32@npm:0.27.3"
"@esbuild/win32-ia32@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-ia32@npm:0.27.4"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
@ -702,9 +702,9 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.27.3":
version: 0.27.3
resolution: "@esbuild/win32-x64@npm:0.27.3"
"@esbuild/win32-x64@npm:0.27.4":
version: 0.27.4
resolution: "@esbuild/win32-x64@npm:0.27.4"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@ -1541,11 +1541,11 @@ __metadata:
linkType: hard
"@types/node@npm:*":
version: 25.3.5
resolution: "@types/node@npm:25.3.5"
version: 25.5.0
resolution: "@types/node@npm:25.5.0"
dependencies:
undici-types: "npm:~7.18.0"
checksum: 10c0/4cf0834a6f6933bf0aca6afead117ae3db3b8f02a5f7187a24f871db0fb9344e5e46573ba387bc53b7505e1e219c4c535cbe67221ced95bb5ad98573223b19d0
checksum: 10c0/70c508165b6758c4f88d4f91abca526c3985eee1985503d4c2bd994dbaf588e52ac57e571160f18f117d76e963570ac82bd20e743c18987e82564312b3b62119
languageName: node
linkType: hard
@ -3408,35 +3408,35 @@ __metadata:
linkType: hard
"esbuild@npm:^0.27.0":
version: 0.27.3
resolution: "esbuild@npm:0.27.3"
version: 0.27.4
resolution: "esbuild@npm:0.27.4"
dependencies:
"@esbuild/aix-ppc64": "npm:0.27.3"
"@esbuild/android-arm": "npm:0.27.3"
"@esbuild/android-arm64": "npm:0.27.3"
"@esbuild/android-x64": "npm:0.27.3"
"@esbuild/darwin-arm64": "npm:0.27.3"
"@esbuild/darwin-x64": "npm:0.27.3"
"@esbuild/freebsd-arm64": "npm:0.27.3"
"@esbuild/freebsd-x64": "npm:0.27.3"
"@esbuild/linux-arm": "npm:0.27.3"
"@esbuild/linux-arm64": "npm:0.27.3"
"@esbuild/linux-ia32": "npm:0.27.3"
"@esbuild/linux-loong64": "npm:0.27.3"
"@esbuild/linux-mips64el": "npm:0.27.3"
"@esbuild/linux-ppc64": "npm:0.27.3"
"@esbuild/linux-riscv64": "npm:0.27.3"
"@esbuild/linux-s390x": "npm:0.27.3"
"@esbuild/linux-x64": "npm:0.27.3"
"@esbuild/netbsd-arm64": "npm:0.27.3"
"@esbuild/netbsd-x64": "npm:0.27.3"
"@esbuild/openbsd-arm64": "npm:0.27.3"
"@esbuild/openbsd-x64": "npm:0.27.3"
"@esbuild/openharmony-arm64": "npm:0.27.3"
"@esbuild/sunos-x64": "npm:0.27.3"
"@esbuild/win32-arm64": "npm:0.27.3"
"@esbuild/win32-ia32": "npm:0.27.3"
"@esbuild/win32-x64": "npm:0.27.3"
"@esbuild/aix-ppc64": "npm:0.27.4"
"@esbuild/android-arm": "npm:0.27.4"
"@esbuild/android-arm64": "npm:0.27.4"
"@esbuild/android-x64": "npm:0.27.4"
"@esbuild/darwin-arm64": "npm:0.27.4"
"@esbuild/darwin-x64": "npm:0.27.4"
"@esbuild/freebsd-arm64": "npm:0.27.4"
"@esbuild/freebsd-x64": "npm:0.27.4"
"@esbuild/linux-arm": "npm:0.27.4"
"@esbuild/linux-arm64": "npm:0.27.4"
"@esbuild/linux-ia32": "npm:0.27.4"
"@esbuild/linux-loong64": "npm:0.27.4"
"@esbuild/linux-mips64el": "npm:0.27.4"
"@esbuild/linux-ppc64": "npm:0.27.4"
"@esbuild/linux-riscv64": "npm:0.27.4"
"@esbuild/linux-s390x": "npm:0.27.4"
"@esbuild/linux-x64": "npm:0.27.4"
"@esbuild/netbsd-arm64": "npm:0.27.4"
"@esbuild/netbsd-x64": "npm:0.27.4"
"@esbuild/openbsd-arm64": "npm:0.27.4"
"@esbuild/openbsd-x64": "npm:0.27.4"
"@esbuild/openharmony-arm64": "npm:0.27.4"
"@esbuild/sunos-x64": "npm:0.27.4"
"@esbuild/win32-arm64": "npm:0.27.4"
"@esbuild/win32-ia32": "npm:0.27.4"
"@esbuild/win32-x64": "npm:0.27.4"
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
@ -3492,7 +3492,7 @@ __metadata:
optional: true
bin:
esbuild: bin/esbuild
checksum: 10c0/fdc3f87a3f08b3ef98362f37377136c389a0d180fda4b8d073b26ba930cf245521db0a368f119cc7624bc619248fff1439f5811f062d853576f8ffa3df8ee5f1
checksum: 10c0/2a1c2bcccda279f2afd72a7f8259860cb4483b32453d17878e1ecb4ac416b9e7c1001e7aa0a25ba4c29c1e250a3ceaae5d8bb72a119815bc8db4e9b5f5321490
languageName: node
linkType: hard
@ -3906,6 +3906,7 @@ __metadata:
"@types/react": "npm:^18.2.0"
oxlint: "npm:^0.16.0"
react: "npm:^18.2.0"
twenty-client-sdk: "portal:../../twenty-client-sdk"
twenty-sdk: "portal:../../twenty-sdk"
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
@ -4952,9 +4953,9 @@ __metadata:
linkType: hard
"preact@npm:^10.28.3":
version: 10.28.4
resolution: "preact@npm:10.28.4"
checksum: 10c0/712384e92d6c676c8f4c230eab81329a66acfecf700390aebfe5fc46168fa3686345d47f2292068dcc0a6d86425a3d9a3d743730e356e867a8402442afb989db
version: 10.29.0
resolution: "preact@npm:10.29.0"
checksum: 10c0/d111381e5b48335e3a797a03adb83521cf5e9bdf880570fb2eff4fe9da9c82e6dedcbdf54538b1ed8f60bf813a0df0f4891b03dc32140ad93f8f720a8812dd5c
languageName: node
linkType: hard
@ -5750,6 +5751,17 @@ __metadata:
languageName: node
linkType: hard
"twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-client-sdk@portal:../../twenty-client-sdk::locator=hello-world%40workspace%3A."
dependencies:
"@genql/cli": "npm:^3.0.3"
"@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.25.0"
graphql: "npm:^16.8.1"
languageName: node
linkType: soft
"twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A.":
version: 0.0.0-use.local
resolution: "twenty-sdk@portal:../../twenty-sdk::locator=hello-world%40workspace%3A."

View file

@ -0,0 +1,47 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist", "generated"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": [
"error",
{
"allowInterfaces": "with-single-extends"
}
],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
}

View file

@ -0,0 +1,2 @@
dist
generated

View file

@ -0,0 +1,60 @@
{
"name": "twenty-client-sdk",
"version": "0.7.0-canary.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
"build": "npx rimraf dist && npx vite build && tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
},
"exports": {
"./core": {
"types": "./dist/core/index.d.ts",
"import": "./dist/core.mjs",
"require": "./dist/core.cjs"
},
"./metadata": {
"types": "./dist/metadata/index.d.ts",
"import": "./dist/metadata.mjs",
"require": "./dist/metadata.cjs"
},
"./generate": {
"types": "./dist/generate/index.d.ts",
"import": "./dist/generate.mjs",
"require": "./dist/generate.cjs"
}
},
"typesVersions": {
"*": {
"core": [
"dist/core/index.d.ts"
],
"metadata": [
"dist/metadata/index.d.ts"
],
"generate": [
"dist/generate/index.d.ts"
]
}
},
"files": [
"dist"
],
"dependencies": {
"@genql/cli": "^3.0.3",
"@genql/runtime": "^2.10.0",
"esbuild": "^0.25.0",
"graphql": "^16.8.1"
},
"devDependencies": {
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.18"
},
"engines": {
"node": "^24.5.0",
"yarn": "^4.0.2"
}
}

View file

@ -0,0 +1,44 @@
{
"name": "twenty-client-sdk",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-client-sdk/src",
"projectType": "library",
"tags": ["scope:sdk"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"],
"options": {
"cwd": "{projectRoot}",
"commands": [
"npx rimraf dist && npx vite build",
"tsgo -p tsconfig.lib.json --declaration --emitDeclarationOnly --noEmit false --outDir dist --rootDir src && npx tsc-alias -p tsconfig.lib.json --outDir dist"
],
"parallel": false
}
},
"generate-metadata-client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/src/metadata/generated"],
"options": {
"cwd": "packages/twenty-client-sdk",
"command": "tsx scripts/generate-metadata-client.ts"
}
},
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"config": "{projectRoot}/vitest.config.ts"
}
},
"set-local-version": {},
"typecheck": {},
"lint": {}
}
}

View file

@ -0,0 +1,65 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getIntrospectionQuery, buildClientSchema, printSchema } from 'graphql';
import { generateMetadataClient } from '../src/generate/generate-metadata-client';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TEMPLATE_PATH = path.resolve(
__dirname,
'..',
'src',
'generate',
'twenty-client-template.ts',
);
const introspectSchema = async (url: string): Promise<string> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: getIntrospectionQuery() }),
});
const json = await response.json();
if (json.errors) {
throw new Error(
`GraphQL introspection errors: ${JSON.stringify(json.errors)}`,
);
}
return printSchema(buildClientSchema(json.data));
};
const main = async () => {
const serverUrl = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const schema = await introspectSchema(`${serverUrl}/metadata`);
const clientWrapperTemplateSource = await readFile(TEMPLATE_PATH, 'utf-8');
const outputPath = path.resolve(
__dirname,
'..',
'src',
'metadata',
'generated',
);
await generateMetadataClient({
schema,
outputPath,
clientWrapperTemplateSource,
});
console.log(`Metadata client generated at ${outputPath}`);
};
main().catch((error) => {
console.error('Failed to generate metadata client:', error);
process.exit(1);
});

View file

@ -0,0 +1,10 @@
// Stub — replaced at runtime by the generated client when the app
// is installed on a Twenty instance or during `twenty app:dev`.
export class CoreApiClient {
constructor() {
throw new Error(
'CoreApiClient was not generated. ' +
'Install this app on a Twenty instance or run `twenty app:dev`.',
);
}
}

View file

@ -0,0 +1,2 @@
export { CoreApiClient } from './generated/index';
export * as CoreSchema from './generated/schema';

View file

@ -1,8 +1,10 @@
import { transform } from 'esbuild';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { transform } from 'esbuild';
import {
afterAll,
beforeAll,
@ -13,17 +15,12 @@ import {
vi,
} from 'vitest';
vi.mock('@/cli/constants/clients-dir', () => ({
CLIENTS_GENERATED_DIR: 'src/clients/generated',
}));
import { buildClientWrapperSource } from '../client-wrapper';
vi.mock('twenty-shared/application', () => ({
DEFAULT_APP_ACCESS_TOKEN_NAME: 'TWENTY_APP_ACCESS_TOKEN',
DEFAULT_API_KEY_NAME: 'TWENTY_API_KEY',
DEFAULT_API_URL_NAME: 'TWENTY_API_URL',
}));
import { ClientService } from '@/cli/utilities/client/client-service';
const twentyClientTemplateSource = readFileSync(
join(__dirname, '..', 'twenty-client-template.ts'),
'utf-8',
);
type TwentyClassType = new (options?: {
url?: string;
@ -110,59 +107,33 @@ const getAuthorizationHeaderValue = (requestInit: RequestInit | undefined) => {
return new Headers(requestInit?.headers).get('Authorization');
};
describe('ClientService generated Twenty auth behavior', () => {
let temporaryGeneratedClientDirectory: string;
describe('Generated client wrapper auth behavior', () => {
let temporaryDir: string;
let TwentyClass: TwentyClassType;
beforeAll(async () => {
temporaryGeneratedClientDirectory = await mkdtemp(
join(tmpdir(), 'twenty-generated-client-'),
);
temporaryDir = await mkdtemp(join(tmpdir(), 'twenty-generated-client-'));
const temporaryGeneratedIndexTsPath = join(
temporaryGeneratedClientDirectory,
'index.ts',
);
await writeFile(temporaryGeneratedIndexTsPath, stubGeneratedIndexSource);
const clientService = new ClientService();
await (
clientService as unknown as {
injectClientWrapper: (
output: string,
options: {
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
},
) => Promise<void>;
}
).injectClientWrapper(temporaryGeneratedClientDirectory, {
const wrapperSource = buildClientWrapperSource(twentyClientTemplateSource, {
apiClientName: 'MetadataApiClient',
defaultUrl: '`${process.env.TWENTY_API_URL}/metadata`',
includeUploadFile: true,
});
const generatedIndexContent = await readFile(
temporaryGeneratedIndexTsPath,
'utf-8',
);
const fullSource = stubGeneratedIndexSource + wrapperSource;
const transpiledModule = await transform(generatedIndexContent, {
const transpiledModule = await transform(fullSource, {
loader: 'ts',
format: 'esm',
target: 'es2022',
});
const temporaryGeneratedIndexMjsPath = join(
temporaryGeneratedClientDirectory,
'index.mjs',
);
await writeFile(temporaryGeneratedIndexMjsPath, transpiledModule.code);
const outputPath = join(temporaryDir, 'index.mjs');
await writeFile(outputPath, transpiledModule.code);
const generatedModule = await import(
`${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}`
`${pathToFileURL(outputPath).href}?t=${Date.now()}`
);
TwentyClass = generatedModule.MetadataApiClient as TwentyClassType;
@ -176,11 +147,8 @@ describe('ClientService generated Twenty auth behavior', () => {
});
afterAll(async () => {
if (temporaryGeneratedClientDirectory) {
await rm(temporaryGeneratedClientDirectory, {
recursive: true,
force: true,
});
if (temporaryDir) {
await rm(temporaryDir, { recursive: true, force: true });
}
});
@ -303,6 +271,7 @@ describe('ClientService generated Twenty auth behavior', () => {
.fn<() => Promise<string>>()
.mockImplementation(async () => {
await Promise.resolve();
return 'fresh-token';
});

View file

@ -0,0 +1,51 @@
type ClientWrapperOptions = {
apiClientName: string;
defaultUrl: string;
includeUploadFile: boolean;
};
const STRIPPED_TYPES_START = '// __STRIPPED_DURING_INJECTION_START__';
const STRIPPED_TYPES_END = '// __STRIPPED_DURING_INJECTION_END__';
const UPLOAD_FILE_START = '// __UPLOAD_FILE_START__';
const UPLOAD_FILE_END = '// __UPLOAD_FILE_END__';
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const buildClientWrapperSource = (
templateSource: string,
options: ClientWrapperOptions,
): string => {
let source = templateSource;
source = source.replace(
new RegExp(
`${escapeRegExp(STRIPPED_TYPES_START)}[\\s\\S]*?${escapeRegExp(STRIPPED_TYPES_END)}\\n?`,
),
'',
);
source = source.replace("'__TWENTY_DEFAULT_URL__'", options.defaultUrl);
source = source.replace(/TwentyGeneratedClient/g, options.apiClientName);
if (!options.includeUploadFile) {
source = source.replace(
new RegExp(
`\\s*${escapeRegExp(UPLOAD_FILE_START)}[\\s\\S]*?${escapeRegExp(UPLOAD_FILE_END)}\\n?`,
),
'\n',
);
} else {
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_START)}\\n`),
'\n',
);
source = source.replace(
new RegExp(`\\s*${escapeRegExp(UPLOAD_FILE_END)}\\n`),
'\n',
);
}
return `\n// ${options.apiClientName} (auto-injected by twenty-client-sdk)\n${source}`;
};

View file

@ -0,0 +1,41 @@
import { cp, mkdir, readdir, rename as fsRename, rm } from 'node:fs/promises';
import { join } from 'node:path';
export const ensureDir = (dirPath: string) =>
mkdir(dirPath, { recursive: true });
export const emptyDir = async (dirPath: string): Promise<void> => {
let entries: string[];
try {
entries = await readdir(dirPath);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
await mkdir(dirPath, { recursive: true });
return;
}
throw error;
}
await Promise.all(
entries.map((entry) =>
rm(join(dirPath, entry), { recursive: true, force: true }),
),
);
};
export const move = async (src: string, dest: string): Promise<void> => {
try {
await fsRename(src, dest);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
await cp(src, dest, { recursive: true });
await rm(src, { recursive: true, force: true });
} else {
throw error;
}
}
};
export const remove = (filePath: string) =>
rm(filePath, { recursive: true, force: true });

View file

@ -0,0 +1,122 @@
import { appendFile, copyFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { build } from 'esbuild';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir, move, remove } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const GENERATED_CORE_DIR = 'core/generated';
// Generates the core API client from a GraphQL schema string.
// Produces both TypeScript source and compiled ESM/CJS bundles.
export const generateCoreClientFromSchema = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
const tempPath = `${outputPath}.tmp`;
await ensureDir(tempPath);
await emptyDir(tempPath);
try {
await generate({
schema,
output: tempPath,
scalarTypes: COMMON_SCALAR_TYPES,
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'CoreApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\``,
includeUploadFile: true,
});
await appendFile(join(tempPath, 'index.ts'), clientContent);
await remove(outputPath);
await move(tempPath, outputPath);
await compileGeneratedClient(outputPath);
} catch (error) {
await remove(tempPath);
throw error;
}
};
// Generates the core client and replaces the pre-built stub inside
// an installed twenty-client-sdk package (dist/core.mjs and dist/core.cjs).
// Generated source files are kept in dist/generated-core/ for consumers
// that need the raw .ts files (e.g. the app:dev upload step).
export const replaceCoreClient = async ({
packageRoot,
schema,
}: {
packageRoot: string;
schema: string;
}): Promise<void> => {
const generatedPath = join(packageRoot, 'dist', GENERATED_CORE_DIR);
await generateCoreClientFromSchema({ schema, outputPath: generatedPath });
await copyFile(
join(generatedPath, 'index.mjs'),
join(packageRoot, 'dist', 'core.mjs'),
);
await copyFile(
join(generatedPath, 'index.cjs'),
join(packageRoot, 'dist', 'core.cjs'),
);
};
const compileGeneratedClient = async (generatedDir: string): Promise<void> => {
const entryPoint = join(generatedDir, 'index.ts');
const outfile = join(generatedDir, 'index.mjs');
await build({
entryPoints: [entryPoint],
outfile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await build({
entryPoints: [entryPoint],
outfile: join(generatedDir, 'index.cjs'),
bundle: true,
format: 'cjs',
platform: 'node',
target: 'node18',
sourcemap: false,
minify: false,
});
await writeFile(
join(generatedDir, 'package.json'),
JSON.stringify(
{ type: 'module', main: 'index.mjs', module: 'index.mjs' },
null,
2,
),
);
};

View file

@ -0,0 +1,48 @@
import { appendFile } from 'node:fs/promises';
import { join } from 'node:path';
import { generate } from '@genql/cli';
import { DEFAULT_API_URL_NAME } from 'twenty-shared/application';
import { buildClientWrapperSource } from './client-wrapper';
import { emptyDir, ensureDir } from './fs-utils';
import twentyClientTemplateSource from './twenty-client-template.ts?raw';
const COMMON_SCALAR_TYPES = {
DateTime: 'string',
JSON: 'Record<string, unknown>',
UUID: 'string',
};
export const generateMetadataClient = async ({
schema,
outputPath,
clientWrapperTemplateSource,
}: {
schema: string;
outputPath: string;
clientWrapperTemplateSource?: string;
}): Promise<void> => {
const templateSource =
clientWrapperTemplateSource ?? twentyClientTemplateSource;
await ensureDir(outputPath);
await emptyDir(outputPath);
await generate({
schema,
output: outputPath,
scalarTypes: {
...COMMON_SCALAR_TYPES,
Upload: 'File',
},
});
const clientContent = buildClientWrapperSource(templateSource, {
apiClientName: 'MetadataApiClient',
defaultUrl: `\`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\``,
includeUploadFile: true,
});
await appendFile(join(outputPath, 'index.ts'), clientContent);
};

View file

@ -0,0 +1,6 @@
export {
GENERATED_CORE_DIR,
generateCoreClientFromSchema,
replaceCoreClient,
} from './generate-core-client';
export { generateMetadataClient } from './generate-metadata-client';

View file

@ -0,0 +1,4 @@
declare module '*.ts?raw' {
const content: string;
export default content;
}

View file

@ -0,0 +1,434 @@
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.
// __STRIPPED_DURING_INJECTION_START__
type QueryGenqlSelection = Record<string, unknown>;
type MutationGenqlSelection = Record<string, unknown>;
type GraphqlOperation = Record<string, unknown>;
type ClientOptions = {
url?: string;
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
fetcher?: (
operation: GraphqlOperation | GraphqlOperation[],
) => Promise<unknown>;
fetch?: typeof globalThis.fetch;
batch?: unknown;
};
type Client = {
query: (
request: QueryGenqlSelection & { __name?: string },
) => Promise<unknown>;
mutation: (
request: MutationGenqlSelection & { __name?: string },
) => Promise<unknown>;
};
declare function createClient(options: ClientOptions): Client;
declare class GenqlError extends Error {
constructor(errors: unknown, data: unknown);
}
// __STRIPPED_DURING_INJECTION_END__
const APP_ACCESS_TOKEN_ENV_KEY = 'TWENTY_APP_ACCESS_TOKEN';
const API_KEY_ENV_KEY = 'TWENTY_API_KEY';
type TwentyGeneratedClientOptions = ClientOptions;
type ProcessEnvironment = Record<string, string | undefined>;
type GraphqlErrorPayloadEntry = {
message?: string;
extensions?: { code?: string };
};
type GraphqlResponsePayload = {
data?: Record<string, unknown>;
errors?: GraphqlErrorPayloadEntry[];
};
type GraphqlResponse = {
status: number;
statusText: string;
payload: GraphqlResponsePayload | null;
rawBody: string;
};
const getProcessEnvironment = (): ProcessEnvironment => {
const processObject = (
globalThis as { process?: { env?: ProcessEnvironment } }
).process;
return processObject?.env ?? {};
};
const getTokenFromAuthorizationHeader = (
authorizationHeader: string | undefined,
): string | null => {
if (typeof authorizationHeader !== 'string') {
return null;
}
const trimmedAuthorizationHeader = authorizationHeader.trim();
if (trimmedAuthorizationHeader.length === 0) {
return null;
}
if (trimmedAuthorizationHeader === 'Bearer') {
return null;
}
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
}
return trimmedAuthorizationHeader;
};
const getTokenFromHeaders = (
headers: HeadersInit | undefined,
): string | null => {
if (!headers) {
return null;
}
if (headers instanceof Headers) {
return getTokenFromAuthorizationHeader(
headers.get('Authorization') ?? undefined,
);
}
if (Array.isArray(headers)) {
const matchedAuthorizationHeader = headers.find(
([headerName]) => headerName.toLowerCase() === 'authorization',
);
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
}
const headersRecord = headers as Record<string, string | undefined>;
return getTokenFromAuthorizationHeader(
headersRecord.Authorization ?? headersRecord.authorization,
);
};
const hasAuthenticationErrorInGraphqlPayload = (
payload: GraphqlResponsePayload | null,
): boolean => {
if (!payload?.errors) {
return false;
}
return payload.errors.some((graphqlError) => {
return (
graphqlError.extensions?.code === 'UNAUTHENTICATED' ||
graphqlError.message?.toLowerCase() === 'unauthorized'
);
});
};
const defaultOptions: TwentyGeneratedClientOptions = {
url: '__TWENTY_DEFAULT_URL__',
headers: {
'Content-Type': 'application/json',
},
};
export class TwentyGeneratedClient {
private client: Client;
private url: string;
private requestOptions: RequestInit;
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
private fetchImplementation: typeof globalThis.fetch | null;
private authorizationToken: string | null;
private refreshAccessTokenPromise: Promise<string | null> | null = null;
constructor(options?: TwentyGeneratedClientOptions) {
const merged: TwentyGeneratedClientOptions = {
...defaultOptions,
...options,
};
const {
url,
headers,
fetch: customFetchImplementation,
fetcher: _fetcher,
batch: _batch,
...requestOptions
} = merged;
this.url = url ?? '';
this.requestOptions = requestOptions;
this.headers = headers ?? {};
this.fetchImplementation =
customFetchImplementation ?? globalThis.fetch ?? null;
const processEnvironment = getProcessEnvironment();
const tokenFromHeaders = getTokenFromHeaders(
typeof headers === 'function' ? undefined : headers,
);
// Priority: explicit header > app access token > api key (legacy).
this.authorizationToken =
tokenFromHeaders ??
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
processEnvironment[API_KEY_ENV_KEY] ??
null;
this.client = createClient({
...merged,
headers: undefined,
fetcher: async (operation) =>
this.executeGraphqlRequestWithOptionalRefresh({
operation,
}),
});
}
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
return this.client.query(request);
}
mutation<R extends MutationGenqlSelection>(request: R & { __name?: string }) {
return this.client.mutation(request);
}
// __UPLOAD_FILE_START__
async uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string = 'application/octet-stream',
fieldMetadataUniversalIdentifier: string,
): Promise<{
id: string;
path: string;
size: number;
createdAt: string;
url: string;
}> {
const form = new FormData();
form.append(
'operations',
JSON.stringify({
query: `mutation UploadFilesFieldFileByUniversalIdentifier($file: Upload!, $fieldMetadataUniversalIdentifier: String!) {
uploadFilesFieldFileByUniversalIdentifier(file: $file, fieldMetadataUniversalIdentifier: $fieldMetadataUniversalIdentifier) { id path size createdAt url }
}`,
variables: {
file: null,
fieldMetadataUniversalIdentifier,
},
}),
);
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
form.append(
'0',
new Blob([fileBuffer as BlobPart], { type: contentType }),
filename,
);
const result = await this.executeGraphqlRequestWithOptionalRefresh({
operation: form,
headers: {},
requestInit: {
method: 'POST',
},
});
if (result.errors) {
throw new GenqlError(result.errors, result.data);
}
const data = result.data as Record<string, unknown>;
return data.uploadFilesFieldFileByUniversalIdentifier as {
id: string;
path: string;
size: number;
createdAt: string;
url: string;
};
}
// __UPLOAD_FILE_END__
private async executeGraphqlRequestWithOptionalRefresh({
operation,
headers,
requestInit,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
}) {
const firstResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: this.authorizationToken,
});
if (this.shouldRefreshToken(firstResponse)) {
const refreshedAccessToken = await this.requestRefreshedAccessToken();
if (refreshedAccessToken) {
const retryResponse = await this.executeGraphqlRequest({
operation,
headers,
requestInit,
token: refreshedAccessToken,
});
return this.assertResponseIsSuccessful(retryResponse);
}
}
return this.assertResponseIsSuccessful(firstResponse);
}
private async executeGraphqlRequest({
operation,
headers,
requestInit,
token,
}: {
operation: GraphqlOperation | GraphqlOperation[] | FormData;
headers?: HeadersInit;
requestInit?: RequestInit;
token: string | null;
}): Promise<GraphqlResponse> {
if (!this.fetchImplementation) {
throw new Error(
'Global `fetch` function is not available, ' +
'pass a fetch implementation to the Twenty client',
);
}
const resolvedHeaders = await this.resolveHeaders();
const requestHeaders = new Headers(resolvedHeaders);
if (headers) {
new Headers(headers).forEach((value, key) =>
requestHeaders.set(key, value),
);
}
if (operation instanceof FormData) {
requestHeaders.delete('Content-Type');
} else {
requestHeaders.set('Content-Type', 'application/json');
}
if (token) {
requestHeaders.set('Authorization', `Bearer ${token}`);
} else {
requestHeaders.delete('Authorization');
}
const response = await this.fetchImplementation.call(globalThis, this.url, {
...this.requestOptions,
...requestInit,
method: requestInit?.method ?? 'POST',
headers: requestHeaders,
body:
operation instanceof FormData ? operation : JSON.stringify(operation),
});
const rawBody = await response.text();
let payload: GraphqlResponsePayload | null = null;
if (rawBody.trim().length > 0) {
try {
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
} catch {
payload = null;
}
}
return {
status: response.status,
statusText: response.statusText,
payload,
rawBody,
};
}
private async resolveHeaders(): Promise<HeadersInit> {
if (typeof this.headers === 'function') {
return (await this.headers()) ?? {};
}
return this.headers ?? {};
}
private shouldRefreshToken(response: GraphqlResponse): boolean {
if (response.status === 401) {
return true;
}
return hasAuthenticationErrorInGraphqlPayload(response.payload);
}
private assertResponseIsSuccessful(response: GraphqlResponse) {
if (response.status < 200 || response.status >= 300) {
throw new Error(`${response.statusText}: ${response.rawBody}`);
}
if (response.payload === null) {
throw new Error('Invalid JSON response');
}
return response.payload;
}
private async requestRefreshedAccessToken(): Promise<string | null> {
const refreshAccessTokenFunction = (
globalThis as {
frontComponentHostCommunicationApi?: {
requestAccessTokenRefresh?: () => Promise<string>;
};
}
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
if (typeof refreshAccessTokenFunction !== 'function') {
return null;
}
if (!this.refreshAccessTokenPromise) {
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
.then((refreshedAccessToken) => {
if (
typeof refreshedAccessToken !== 'string' ||
refreshedAccessToken.length === 0
) {
return null;
}
this.setAuthorizationToken(refreshedAccessToken);
return refreshedAccessToken;
})
.catch((refreshError: unknown) => {
console.error('Twenty client: token refresh failed', refreshError);
return null;
})
.finally(() => {
this.refreshAccessTokenPromise = null;
});
}
return this.refreshAccessTokenPromise;
}
private setAuthorizationToken(token: string) {
this.authorizationToken = token;
const processEnvironment = getProcessEnvironment();
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
}
}

View file

@ -78,7 +78,7 @@ export const generateSubscriptionOp: (
)
}
// MetadataApiClient (auto-injected by twenty-sdk)
// MetadataApiClient (auto-injected by twenty-client-sdk)
// Ambient type stubs for the genql-generated code this template gets
// injected into. They enable full typecheck/lint on this file.

View file

@ -0,0 +1,2 @@
export { MetadataApiClient } from './generated/index';
export * as MetadataSchema from './generated/schema';

View file

@ -0,0 +1,19 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"noEmit": true,
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -0,0 +1,78 @@
import path from 'path';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import packageJson from './package.json';
const entries = [
'src/core/index.ts',
'src/metadata/index.ts',
'src/generate/index.ts',
];
const entryFileNames = (chunk: any, extension: 'cjs' | 'mjs') => {
if (!chunk.isEntry) {
throw new Error(
`Should never occur, encountered a non entry chunk ${chunk.facadeModuleId}`,
);
}
const splitFaceModuleId = chunk.facadeModuleId?.split('/');
if (splitFaceModuleId === undefined) {
throw new Error(
`Should never occur, splitFaceModuleId is undefined ${chunk.facadeModuleId}`,
);
}
const moduleDirectory = splitFaceModuleId[splitFaceModuleId?.length - 2];
if (moduleDirectory === 'src') {
return `${chunk.name}.${extension}`;
}
return `${moduleDirectory}.${extension}`;
};
export default defineConfig(() => {
return {
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/twenty-client-sdk',
resolve: {
alias: {
'@/': path.resolve(__dirname, 'src') + '/',
},
},
plugins: [
tsconfigPaths({
root: __dirname,
}),
],
build: {
emptyOutDir: false,
outDir: 'dist',
lib: { entry: entries, name: 'twenty-client-sdk' },
rollupOptions: {
external: [
...Object.keys((packageJson as any).dependencies || {}),
...Object.keys((packageJson as any).devDependencies || {}).filter(
(dep: string) => dep !== 'twenty-shared',
),
'node:fs/promises',
'node:fs',
'node:path',
],
output: [
{
format: 'es',
entryFileNames: (chunk) => entryFileNames(chunk, 'mjs'),
},
{
format: 'cjs',
interop: 'auto',
esModule: true,
exports: 'named',
entryFileNames: (chunk) => entryFileNames(chunk, 'cjs'),
},
],
},
},
logLevel: 'warn',
};
});

View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
name: 'twenty-client-sdk',
environment: 'node',
include: [
'src/**/__tests__/**/*.{test,spec}.{ts,tsx}',
'src/**/*.{test,spec}.{ts,tsx}',
],
exclude: ['**/node_modules/**', '**/.git/**'],
globals: true,
},
});

View file

@ -546,7 +546,7 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
// src/app/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-sdk/clients';
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
@ -784,7 +784,7 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/clients';
import { CoreApiClient } from 'twenty-client-sdk/core';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
@ -1200,8 +1200,8 @@ Two typed clients are auto-generated by `yarn twenty dev` and stored in `node_mo
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
```typescript
import { CoreApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-sdk/clients';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const client = new CoreApiClient();
const { me } = await client.query({ me: { id: true, displayName: true } });
@ -1229,7 +1229,7 @@ Notes:
The `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
```typescript
import { MetadataApiClient } from 'twenty-sdk/clients';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();

View file

@ -1873,6 +1873,7 @@ export enum FileFolder {
Dependencies = 'Dependencies',
File = 'File',
FilesField = 'FilesField',
GeneratedSdkClient = 'GeneratedSdkClient',
PersonPicture = 'PersonPicture',
ProfilePicture = 'ProfilePicture',
PublicAsset = 'PublicAsset',
@ -1920,6 +1921,7 @@ export type FrontComponent = {
sourceComponentPath: Scalars['String'];
universalIdentifier?: Maybe<Scalars['UUID']>;
updatedAt: Scalars['DateTime'];
usesSdkClient: Scalars['Boolean'];
};
export type FrontComponentConfiguration = {
@ -6640,7 +6642,7 @@ export type FindOneFrontComponentQueryVariables = Exact<{
}>;
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, isHeadless: boolean, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null };
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, isHeadless: boolean, usesSdkClient: boolean, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null };
export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, cronTriggerSettings?: any | null, databaseEventTriggerSettings?: any | null, httpRouteTriggerSettings?: any | null, applicationId?: string | null, createdAt: string, updatedAt: string };
@ -8170,7 +8172,7 @@ export const UploadFilesFieldFileDocument = {"kind":"Document","definitions":[{"
export const UploadWorkflowFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkflowFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkflowFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkflowFileMutation, UploadWorkflowFileMutationVariables>;
export const RenewApplicationTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RenewApplicationToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRefreshToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"renewApplicationToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRefreshToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRefreshToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationAccessToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"applicationRefreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]} as unknown as DocumentNode<RenewApplicationTokenMutation, RenewApplicationTokenMutationVariables>;
export const FindManyFrontComponentsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyFrontComponents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"frontComponents"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}}]}}]}}]} as unknown as DocumentNode<FindManyFrontComponentsQuery, FindManyFrontComponentsQueryVariables>;
export const FindOneFrontComponentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneFrontComponent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"frontComponent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"builtComponentChecksum"}},{"kind":"Field","name":{"kind":"Name","value":"isHeadless"}},{"kind":"Field","name":{"kind":"Name","value":"applicationTokenPair"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationAccessToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"applicationRefreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>;
export const FindOneFrontComponentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneFrontComponent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"frontComponent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"builtComponentChecksum"}},{"kind":"Field","name":{"kind":"Name","value":"isHeadless"}},{"kind":"Field","name":{"kind":"Name","value":"usesSdkClient"}},{"kind":"Field","name":{"kind":"Name","value":"applicationTokenPair"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applicationAccessToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"applicationRefreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode<FindOneFrontComponentQuery, FindOneFrontComponentQueryVariables>;
export const CreateOneLogicFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneLogicFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateLogicFunctionFromSourceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneLogicFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LogicFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LogicFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LogicFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"sourceHandlerPath"}},{"kind":"Field","name":{"kind":"Name","value":"handlerName"}},{"kind":"Field","name":{"kind":"Name","value":"toolInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"isTool"}},{"kind":"Field","name":{"kind":"Name","value":"cronTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"databaseEventTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"httpRouteTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<CreateOneLogicFunctionMutation, CreateOneLogicFunctionMutationVariables>;
export const DeleteOneLogicFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneLogicFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"LogicFunctionIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneLogicFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LogicFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LogicFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LogicFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"timeoutSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"sourceHandlerPath"}},{"kind":"Field","name":{"kind":"Name","value":"handlerName"}},{"kind":"Field","name":{"kind":"Name","value":"toolInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"isTool"}},{"kind":"Field","name":{"kind":"Name","value":"cronTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"databaseEventTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"httpRouteTriggerSettings"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<DeleteOneLogicFunctionMutation, DeleteOneLogicFunctionMutationVariables>;
export const ExecuteOneLogicFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ExecuteOneLogicFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ExecuteOneLogicFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"executeOneLogicFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"logs"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode<ExecuteOneLogicFunctionMutation, ExecuteOneLogicFunctionMutationVariables>;

View file

@ -1,4 +1,5 @@
import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider';
import { FrontComponentRendererWithSdkClient } from '@/front-components/components/FrontComponentRendererWithSdkClient';
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
import { frontComponentApplicationTokenPairComponentState } from '@/front-components/states/frontComponentApplicationTokenPairComponentState';
@ -73,11 +74,6 @@ export const FrontComponentRenderer = ({
frontComponentId,
});
const componentUrl = getFrontComponentUrl({
frontComponentId,
checksum: data?.frontComponent?.builtComponentChecksum,
});
const applicationTokenPair =
data?.frontComponent?.applicationTokenPair ?? null;
@ -89,14 +85,39 @@ export const FrontComponentRenderer = ({
return null;
}
const componentUrl = getFrontComponentUrl({
frontComponentId,
checksum: data.frontComponent.builtComponentChecksum,
});
const usesSdkClient = data.frontComponent.usesSdkClient;
const accessToken = applicationTokenPair.applicationAccessToken.token;
if (usesSdkClient) {
return (
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
<FrontComponentRendererWithSdkClient
colorScheme={colorScheme}
componentUrl={componentUrl}
applicationAccessToken={accessToken}
applicationId={data.frontComponent.applicationId}
executionContext={executionContext}
frontComponentHostCommunicationApi={
frontComponentHostCommunicationApi
}
onError={handleError}
/>
</FrontComponentRendererProvider>
);
}
return (
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
<SharedFrontComponentRenderer
colorScheme={colorScheme}
componentUrl={componentUrl}
applicationAccessToken={
applicationTokenPair.applicationAccessToken.token
}
applicationAccessToken={accessToken}
apiUrl={REACT_APP_SERVER_BASE_URL}
executionContext={executionContext}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}

View file

@ -0,0 +1,58 @@
import { useAtomValue } from 'jotai';
import { SdkClientBlobUrlsEffect } from '@/front-components/components/SdkClientBlobUrlsEffect';
import { sdkClientFamilyState } from '@/front-components/states/sdkClientFamilyState';
import {
FrontComponentRenderer as SharedFrontComponentRenderer,
type FrontComponentExecutionContext,
type FrontComponentHostCommunicationApi,
} from 'twenty-sdk/front-component-renderer';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
type FrontComponentRendererWithSdkClientProps = {
colorScheme: 'light' | 'dark';
componentUrl: string;
applicationAccessToken: string;
applicationId: string;
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
onError: (error?: Error) => void;
};
export const FrontComponentRendererWithSdkClient = ({
colorScheme,
componentUrl,
applicationAccessToken,
applicationId,
executionContext,
frontComponentHostCommunicationApi,
onError,
}: FrontComponentRendererWithSdkClientProps) => {
const sdkClientState = useAtomValue(
sdkClientFamilyState.atomFamily(applicationId),
);
return (
<>
<SdkClientBlobUrlsEffect
applicationId={applicationId}
accessToken={applicationAccessToken}
onError={onError}
/>
{sdkClientState.status === 'loaded' && (
<SharedFrontComponentRenderer
colorScheme={colorScheme}
componentUrl={componentUrl}
applicationAccessToken={applicationAccessToken}
apiUrl={REACT_APP_SERVER_BASE_URL}
sdkClientUrls={sdkClientState.blobUrls}
executionContext={executionContext}
frontComponentHostCommunicationApi={
frontComponentHostCommunicationApi
}
onError={onError}
/>
)}
</>
);
};

View file

@ -0,0 +1,49 @@
import { useStore } from 'jotai';
import { useEffect } from 'react';
import { sdkClientFamilyState } from '@/front-components/states/sdkClientFamilyState';
import { fetchSdkClientBlobUrls } from '@/front-components/utils/fetchSdkClientBlobUrls';
export const SdkClientBlobUrlsEffect = ({
applicationId,
accessToken,
onError,
}: {
applicationId: string;
accessToken: string;
onError?: (error: Error) => void;
}) => {
const store = useStore();
useEffect(() => {
const atom = sdkClientFamilyState.atomFamily(applicationId);
const { status } = store.get(atom);
if (status === 'loading' || status === 'loaded') {
return;
}
store.set(atom, { status: 'loading' });
const fetchBlobUrls = async () => {
try {
const blobUrls = await fetchSdkClientBlobUrls(
applicationId,
accessToken,
);
store.set(atom, { status: 'loaded', blobUrls });
} catch (error: unknown) {
const normalizedError =
error instanceof Error ? error : new Error(String(error));
store.set(atom, { status: 'error', error: normalizedError });
onError?.(normalizedError);
}
};
fetchBlobUrls();
}, [applicationId, accessToken, store, onError]);
return null;
};

View file

@ -8,6 +8,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
applicationId
builtComponentChecksum
isHeadless
usesSdkClient
applicationTokenPair {
applicationAccessToken {
token

View file

@ -0,0 +1,20 @@
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export type SdkClientBlobUrls = {
core: string;
metadata: string;
};
export type SdkClientState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'loaded'; blobUrls: SdkClientBlobUrls }
| { status: 'error'; error: Error };
export const sdkClientFamilyState = createAtomFamilyState<
SdkClientState,
string
>({
key: 'sdkClientFamilyState',
defaultValue: { status: 'idle' },
});

View file

@ -0,0 +1,55 @@
import { type SdkClientBlobUrls } from '@/front-components/states/sdkClientFamilyState';
import { getSdkClientUrls } from '@/front-components/utils/getSdkClientUrls';
const fetchAndCreateBlobUrl = async (
url: string,
token: string,
): Promise<string> => {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(
`Failed to fetch SDK module from ${url}: ${response.status}`,
);
}
const source = await response.text();
const blob = new Blob([source], { type: 'application/javascript' });
return URL.createObjectURL(blob);
};
export const fetchSdkClientBlobUrls = async (
applicationId: string,
accessToken: string,
): Promise<SdkClientBlobUrls> => {
const urls = getSdkClientUrls(applicationId);
const [coreResult, metadataResult] = await Promise.allSettled([
fetchAndCreateBlobUrl(urls.core, accessToken),
fetchAndCreateBlobUrl(urls.metadata, accessToken),
]);
if (
coreResult.status === 'fulfilled' &&
metadataResult.status === 'fulfilled'
) {
return { core: coreResult.value, metadata: metadataResult.value };
}
if (coreResult.status === 'fulfilled') {
URL.revokeObjectURL(coreResult.value);
}
if (metadataResult.status === 'fulfilled') {
URL.revokeObjectURL(metadataResult.value);
}
throw coreResult.status === 'rejected'
? coreResult.reason
: metadataResult.status === 'rejected'
? metadataResult.reason
: new Error('Unexpected SDK client fetch failure');
};

View file

@ -0,0 +1,6 @@
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
export const getSdkClientUrls = (applicationId: string) => ({
core: `${REST_API_BASE_URL}/sdk-client/${applicationId}/core`,
metadata: `${REST_API_BASE_URL}/sdk-client/${applicationId}/metadata`,
});

View file

@ -56,7 +56,7 @@ export const useLoadCurrentUser = () => {
const user = currentUserResult.data?.currentUser;
if (!user) {
if (!isDefined(user)) {
throw new Error('No current user result');
}

View file

@ -4,7 +4,7 @@
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist", "src/clients/generated"],
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": "off",

View file

@ -1,4 +1,3 @@
dist
storybook-static
coverage
src/clients/generated

View file

@ -48,11 +48,6 @@
"types": "./dist/build/index.d.ts",
"import": "./dist/build.mjs",
"require": "./dist/build.cjs"
},
"./clients": {
"types": "./dist/clients/index.d.ts",
"import": "./dist/clients.mjs",
"require": "./dist/clients.cjs"
}
},
"license": "AGPL-3.0",
@ -101,6 +96,7 @@
"storybook": "^10.2.13",
"ts-morph": "^25.0.0",
"tsx": "^4.7.0",
"twenty-client-sdk": "workspace:*",
"twenty-shared": "workspace:*",
"twenty-ui": "workspace:*",
"vite-plugin-dts": "^4.5.4",
@ -120,9 +116,6 @@
],
"front-component-renderer": [
"dist/front-component-renderer/index.d.ts"
],
"clients": [
"dist/clients/index.d.ts"
]
}
}

View file

@ -96,16 +96,6 @@
"command": "npx vite build -c vite.config.sdk.ts"
}
},
"generate-metadata-client": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/src/clients/generated/metadata"],
"options": {
"cwd": "packages/twenty-sdk",
"command": "tsx -r tsconfig-paths/register scripts/generate-metadata-client.ts"
}
},
"generate-remote-dom-elements": {
"executor": "nx:run-commands",
"cache": true,

View file

@ -1,42 +0,0 @@
import { readFile } from 'node:fs/promises';
import path from 'path';
import { CLIENTS_GENERATED_DIR } from '@/cli/constants/clients-dir';
import { ClientService } from '@/cli/utilities/client/client-service';
const TEMPLATE_PATH = path.resolve(
__dirname,
'..',
'src',
'cli',
'utilities',
'client',
'twenty-client-template.ts',
);
const main = async () => {
const outputPath = path.resolve(
__dirname,
'..',
CLIENTS_GENERATED_DIR,
'metadata',
);
const serverUrl = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const clientWrapperTemplateSource = await readFile(TEMPLATE_PATH, 'utf-8');
const clientService = new ClientService({
clientWrapperTemplateSource,
serverUrl,
skipAuth: true,
});
await clientService.generateMetadataClient({ outputPath });
console.log(`Metadata client generated at ${outputPath}`);
};
main().catch((error) => {
console.error('Failed to generate metadata client:', error);
process.exit(1);
});

View file

@ -1,6 +1,8 @@
import { vi } from 'vitest';
import { appBuild } from '@/cli/operations/build';
import { appDeploy } from '@/cli/operations/deploy';
import { appInstall } from '@/cli/operations/install';
import { appUninstall } from '@/cli/operations/uninstall';
import { functionExecute } from '@/cli/operations/execute';
import { FUNCTION_EXECUTE_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
@ -11,7 +13,7 @@ const APP_PATH = FUNCTION_EXECUTE_APP_PATH;
describe('functionExecute E2E', () => {
beforeAll(async () => {
const buildResult = await appBuild({ appPath: APP_PATH });
const buildResult = await appBuild({ appPath: APP_PATH, tarball: true });
if (!buildResult.success) {
throw new Error(
@ -19,6 +21,24 @@ describe('functionExecute E2E', () => {
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
});
if (!deployResult.success) {
throw new Error(
`appDeploy failed: ${deployResult.error.code} ${deployResult.error.message}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`appInstall failed: ${installResult.error.code} ${installResult.error.message}`,
);
}
await vi.waitFor(
async () => {
const result = await functionExecute({

View file

@ -15,7 +15,6 @@ export const EXPECTED_MANIFEST: Manifest = {
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
packageJsonChecksum: '[checksum]',
yarnLockChecksum: '[checksum]',
apiClientChecksum: null,
},
skills: [],
agents: [],
@ -345,6 +344,7 @@ export const EXPECTED_MANIFEST: Manifest = {
builtComponentPath: 'my.front-component.mjs',
builtComponentChecksum: '[checksum]',
isHeadless: false,
usesSdkClient: false,
},
],
views: [],

View file

@ -49,7 +49,6 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde',
apiClientChecksum: null,
},
frontComponents: [
{
@ -61,6 +60,7 @@ export const EXPECTED_MANIFEST: Manifest = {
sourceComponentPath: 'src/root.front-component.tsx',
universalIdentifier: 'a0a1a2a3-a4a5-4000-8000-000000000001',
isHeadless: false,
usesSdkClient: false,
},
{
builtComponentPath: 'src/components/card.front-component.mjs',
@ -71,6 +71,7 @@ export const EXPECTED_MANIFEST: Manifest = {
sourceComponentPath: 'src/components/card.front-component.tsx',
universalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
isHeadless: false,
usesSdkClient: false,
},
{
builtComponentPath: 'src/components/greeting.front-component.mjs',
@ -81,6 +82,7 @@ export const EXPECTED_MANIFEST: Manifest = {
sourceComponentPath: 'src/components/greeting.front-component.tsx',
universalIdentifier: '370ae182-743f-4ecb-b625-7ac48e21f0e5',
isHeadless: false,
usesSdkClient: false,
},
{
builtComponentPath: 'src/components/test.front-component.mjs',
@ -91,6 +93,7 @@ export const EXPECTED_MANIFEST: Manifest = {
sourceComponentPath: 'src/components/test.front-component.tsx',
universalIdentifier: 'f1234567-abcd-4000-8000-000000000001',
isHeadless: false,
usesSdkClient: false,
},
],
@ -269,7 +272,7 @@ export const EXPECTED_MANIFEST: Manifest = {
},
],
type: FieldType.SELECT,
universalIdentifier: '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e',
universalIdentifier: 'b602dbd9-e511-49ce-b6d3-b697218dc69c',
},
{
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
@ -277,7 +280,7 @@ export const EXPECTED_MANIFEST: Manifest = {
label: 'Priority',
name: 'priority',
type: FieldType.NUMBER,
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
universalIdentifier: '7b57bd63-5a4c-46ca-9d52-42c8f02d1df6',
},
{
label: 'Recipient',
@ -1266,8 +1269,8 @@ export const EXPECTED_MANIFEST: Manifest = {
},
{
icon: 'IconHome',
label: 'Address',
name: 'address',
label: 'Mailing Address',
name: 'mailingAddress',
type: FieldType.ADDRESS,
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
},

View file

@ -17,9 +17,6 @@ export const normalizeManifestForComparison = <T extends Manifest>(
packageJsonChecksum: manifest.application.packageJsonChecksum
? '[checksum]'
: null,
apiClientChecksum: manifest.application.apiClientChecksum
? '[checksum]'
: null,
},
objects: sortById(
manifest.objects.map((object) => ({

View file

@ -3,6 +3,7 @@ import chalk from 'chalk';
import type { Command } from 'commander';
import { AppBuildCommand } from './build';
import { AppDevCommand } from './dev';
import { AppInstallCommand } from './install';
import { AppPublishCommand } from './publish';
import { AppTypecheckCommand } from './typecheck';
import { AppUninstallCommand } from './uninstall';
@ -17,6 +18,7 @@ import { SyncableEntity } from 'twenty-shared/application';
export const registerCommands = (program: Command): void => {
const buildCommand = new AppBuildCommand();
const devCommand = new AppDevCommand();
const installCommand = new AppInstallCommand();
const publishCommand = new AppPublishCommand();
const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
@ -45,9 +47,20 @@ export const registerCommands = (program: Command): void => {
});
});
program
.command('install [appPath]')
.description('Install a deployed application on the connected server')
.option('-r, --remote <name>', 'Install on a specific remote')
.action(async (appPath, options) => {
await installCommand.execute({
appPath: formatPath(appPath),
remote: options.remote,
});
});
program
.command('deploy [appPath]')
.description('Build and deploy to a Twenty server')
.description('Build and upload application to a Twenty server')
.option('-r, --remote <name>', 'Deploy to a specific remote')
.action(async (appPath, options) => {
await deployCommand.execute({

View file

@ -1,7 +1,7 @@
import { appBuild } from '@/cli/operations/build';
import { appDeploy } from '@/cli/operations/deploy';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
export type DeployCommandOptions = {
@ -15,34 +15,26 @@ export class DeployCommand {
await checkSdkVersionCompatibility(appPath);
const configService = new ConfigService();
let serverUrl: string;
let token: string | undefined;
if (options.remote) {
const remoteConfig = await configService.getConfigForRemote(
options.remote,
);
serverUrl = remoteConfig.apiUrl;
token = remoteConfig.accessToken ?? remoteConfig.apiKey;
} else {
const config = await configService.getConfig();
serverUrl = config.apiUrl;
token = config.accessToken ?? config.apiKey;
}
const remoteName = options.remote ?? ConfigService.getActiveRemote();
console.log(chalk.blue(`Deploying to ${remoteName} (${serverUrl})...`));
console.log(chalk.blue('Deploying application...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appDeploy({
const onProgress = (message: string) => console.log(chalk.gray(message));
const buildResult = await appBuild({
appPath,
serverUrl,
token,
onProgress: (message) => console.log(chalk.gray(message)),
tarball: true,
onProgress,
});
if (!buildResult.success) {
console.error(chalk.red(buildResult.error.message));
process.exit(1);
}
const result = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
remote: options.remote,
onProgress,
});
if (!result.success) {

View file

@ -0,0 +1,32 @@
import { appInstall } from '@/cli/operations/install';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { checkSdkVersionCompatibility } from '@/cli/utilities/version/check-sdk-version-compatibility';
import chalk from 'chalk';
export type AppInstallCommandOptions = {
appPath?: string;
remote?: string;
};
export class AppInstallCommand {
async execute(options: AppInstallCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
await checkSdkVersionCompatibility(appPath);
console.log(chalk.blue('Installing application...'));
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appInstall({
appPath,
remote: options.remote,
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(chalk.green('✓ Application installed'));
}
}

View file

@ -1,2 +0,0 @@
export const CLIENTS_SOURCE_DIR = 'src/clients';
export const CLIENTS_GENERATED_DIR = `${CLIENTS_SOURCE_DIR}/generated`;

View file

@ -2,10 +2,10 @@ import { execSync } from 'child_process';
import path from 'path';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { ClientService } from '@/cli/utilities/client/client-service';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
@ -48,30 +48,12 @@ const innerAppBuild = async (
onProgress?.('Building application files...');
const firstBuildResult = await buildApplication({
const buildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing application schema...');
const firstSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: firstBuildResult.builtFileInfos,
});
if (!firstSyncResult.success) {
return firstSyncResult;
}
onProgress?.('Generating API client...');
const clientService = new ClientService();
await clientService.generateCoreClient({ appPath });
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
@ -91,31 +73,18 @@ const innerAppBuild = async (
};
}
onProgress?.('Rebuilding with generated client...');
const finalBuildResult = await buildApplication({
appPath,
const updatedManifest = manifestUpdateChecksums({
manifest,
filePaths,
builtFileInfos: buildResult.builtFileInfos,
});
onProgress?.('Syncing built files...');
const finalSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: finalBuildResult.builtFileInfos,
});
if (!finalSyncResult.success) {
return finalSyncResult;
}
await writeManifestToOutput(appPath, updatedManifest);
const outputDir = path.join(appPath, '.twenty', 'output');
const result: AppBuildResult = {
outputDir,
fileCount: finalBuildResult.builtFileInfos.size,
fileCount: buildResult.builtFileInfos.size,
};
if (options.tarball) {

View file

@ -1,13 +1,14 @@
import fs from 'fs';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { appBuild } from './build';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppDeployOptions = {
appPath: string;
serverUrl: string;
tarballPath: string;
remote?: string;
serverUrl?: string;
token?: string;
onProgress?: (message: string) => void;
};
@ -19,25 +20,19 @@ export type AppDeployResult = {
const innerAppDeploy = async (
options: AppDeployOptions,
): Promise<CommandResult<AppDeployResult>> => {
const { appPath, serverUrl, token, onProgress } = options;
const { tarballPath, onProgress } = options;
const buildResult = await appBuild({
appPath,
tarball: true,
onProgress,
});
if (!buildResult.success) {
return buildResult;
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
onProgress?.(`Uploading ${buildResult.data.tarballPath}...`);
onProgress?.(`Uploading ${tarballPath}...`);
const tarballBuffer = fs.readFileSync(buildResult.data.tarballPath!);
const tarballBuffer = fs.readFileSync(tarballPath);
const apiService = new ApiService({
serverUrl,
token,
serverUrl: options.serverUrl,
token: options.token,
});
const uploadResult = await apiService.uploadAppTarball({ tarballBuffer });
@ -52,22 +47,6 @@ const innerAppDeploy = async (
};
}
onProgress?.('Installing application...');
const installResult = await apiService.installTarballApp({
universalIdentifier: uploadResult.data.universalIdentifier,
});
if (!installResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.DEPLOY_FAILED,
message: `Install failed: ${installResult.error}`,
},
};
}
return {
success: true,
data: {

View file

@ -11,6 +11,8 @@ export { appBuild } from './build';
export type { AppBuildOptions, AppBuildResult } from './build';
export { appDeploy } from './deploy';
export type { AppDeployOptions, AppDeployResult } from './deploy';
export { appInstall } from './install';
export type { AppInstallOptions } from './install';
export { appPublish } from './publish';
export type { AppPublishOptions, AppPublishResult } from './publish';
export { appUninstall } from './uninstall';

View file

@ -0,0 +1,57 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { runSafe } from '@/cli/utilities/run-safe';
import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppInstallOptions = {
appPath: string;
remote?: string;
};
const innerAppInstall = async (
options: AppInstallOptions,
): Promise<CommandResult> => {
if (options.remote) {
ConfigService.setActiveRemote(options.remote);
}
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Manifest not found. Run `build` or `dev` first.',
},
};
}
const result = await apiService.installTarballApp({
universalIdentifier: manifest.application.universalIdentifier,
});
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Unknown error');
return {
success: false,
error: {
code: APP_ERROR_CODES.INSTALL_FAILED,
message: errorMessage,
},
};
}
return { success: true, data: undefined };
};
export const appInstall = (
options: AppInstallOptions,
): Promise<CommandResult> =>
runSafe(() => innerAppInstall(options), APP_ERROR_CODES.INSTALL_FAILED);

View file

@ -20,6 +20,7 @@ export const APP_ERROR_CODES = {
MANIFEST_BUILD_FAILED: 'MANIFEST_BUILD_FAILED',
BUILD_FAILED: 'BUILD_FAILED',
PUBLISH_FAILED: 'PUBLISH_FAILED',
INSTALL_FAILED: 'INSTALL_FAILED',
UNINSTALL_FAILED: 'UNINSTALL_FAILED',
SYNC_FAILED: 'SYNC_FAILED',
TYPECHECK_FAILED: 'TYPECHECK_FAILED',

View file

@ -9,10 +9,7 @@ import {
import { FileFolder } from 'twenty-shared/types';
import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build';
import {
LOGIC_FUNCTION_EXTERNAL_MODULES,
createSdkClientsResolverPlugin,
} from '@/cli/utilities/build/common/esbuild-watcher';
import { LOGIC_FUNCTION_EXTERNAL_MODULES } from '@/cli/utilities/build/common/esbuild-watcher';
import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options';
import { getFrontComponentBuildPlugins } from '@/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins';
import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
@ -36,6 +33,7 @@ export type BuiltFileInfo = {
builtPath: string;
sourcePath: string;
fileFolder: FileFolder;
usesSdkClient?: boolean;
};
export type AppBuildResult = {
@ -58,6 +56,7 @@ export const buildApplication = async (
builtPath: event.builtPath,
sourcePath: event.sourcePath,
fileFolder: event.fileFolder,
usesSdkClient: event.usesSdkClient,
});
};
@ -80,7 +79,6 @@ export const buildApplication = async (
metafile: true,
logLevel: 'silent',
banner: NODE_ESM_CJS_BANNER,
plugins: [createSdkClientsResolverPlugin(options.appPath)],
},
onFileBuilt: collectFileBuilt,
});
@ -93,10 +91,11 @@ export const buildApplication = async (
...getBaseFrontComponentBuildOptions(),
outdir: join(options.appPath, OUTPUT_DIR),
tsconfig: join(options.appPath, 'tsconfig.json'),
plugins: [
createSdkClientsResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
],
jsx: 'automatic',
sourcemap: true,
metafile: true,
logLevel: 'silent',
plugins: [...getFrontComponentBuildPlugins()],
},
onFileBuilt: collectFileBuilt,
});

View file

@ -5,6 +5,8 @@ import path from 'path';
import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
import { type FileFolder } from 'twenty-shared/types';
const SDK_CLIENT_IMPORT_PREFIX = 'twenty-client-sdk';
export type ProcessEsbuildResultParams = {
result: esbuild.BuildResult;
appPath: string;
@ -42,12 +44,21 @@ export const processEsbuildResult = async ({
lastChecksums.set(relativeBuiltPath, checksum);
const outputMeta = result.metafile?.outputs?.[outputFile];
const usesSdkClient =
outputMeta?.imports?.some(
(imp) =>
imp.external === true &&
imp.path.startsWith(SDK_CLIENT_IMPORT_PREFIX),
) ?? false;
if (onFileBuilt) {
await onFileBuilt({
fileFolder,
builtPath: relativeBuiltPath,
sourcePath: relativeSourcePath,
checksum,
usesSdkClient,
});
}
}

View file

@ -1,4 +1,3 @@
import { CLIENTS_SOURCE_DIR } from '@/cli/constants/clients-dir';
import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files';
import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/front-component-build/constants/front-component-external-modules';
@ -16,6 +15,8 @@ import { NODE_ESM_CJS_BANNER, OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
export const LOGIC_FUNCTION_EXTERNAL_MODULES: string[] = [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
'path',
'fs',
'crypto',
@ -186,25 +187,6 @@ export class EsbuildWatcher implements RestartableWatcher {
}
}
// Resolves twenty-sdk/clients to the source barrel so esbuild
// bundles it instead of treating it as external (via twenty-sdk/*)
export const createSdkClientsResolverPlugin = (
appPath: string,
): esbuild.Plugin => ({
name: 'sdk-clients-resolver',
setup: (build) => {
build.onResolve({ filter: /^twenty-sdk\/clients/ }, () => ({
path: path.join(
appPath,
'node_modules',
'twenty-sdk',
CLIENTS_SOURCE_DIR,
'index.ts',
),
}));
},
});
export type EsbuildWatcherFactoryOptions = RestartableWatcherOptions & {
shouldSkipTypecheck: () => boolean;
};
@ -220,7 +202,6 @@ export const createLogicFunctionsWatcher = (
platform: 'node',
extraPlugins: [
createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkClientsResolverPlugin(options.appPath),
],
banner: NODE_ESM_CJS_BANNER,
},
@ -237,7 +218,6 @@ export const createFrontComponentsWatcher = (
jsx: 'automatic',
extraPlugins: [
createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createSdkClientsResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
],
},

View file

@ -1 +1,4 @@
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [];
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
];

View file

@ -13,6 +13,7 @@ export type OnFileBuiltCallback = (options: {
builtPath: string;
sourcePath: string;
checksum: string;
usesSdkClient?: boolean;
}) => void | Promise<void>;
export type OnBuildErrorCallback = (

View file

@ -13,7 +13,6 @@ const validApplication: ApplicationManifest = {
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
packageJsonChecksum: '98592af7-4be9-4655-b5c4-9bef307a996c',
yarnLockChecksum: '580ee05f-15fe-4146-bac2-6c382483c94e',
apiClientChecksum: null,
};
const validField: FieldManifest = {

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