snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import { ApiClient } from '@/cli/utilities/api/api-client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/cli/utilities/config/config-service', () => ({
ConfigService: class {
getConfig = vi.fn().mockResolvedValue({ apiUrl: 'http://localhost:2020' });
setConfig = vi.fn();
},
}));
describe('ApiClient — frontend URL resolution', () => {
let client: ApiClient;
beforeEach(() => {
client = new ApiClient({ disableInterceptors: true });
});
describe('getFrontendUrl', () => {
it('returns origin of authorization_endpoint', async () => {
vi.spyOn(client.client, 'get').mockResolvedValueOnce({
data: { authorization_endpoint: 'http://app.localhost:3001/authorize' },
});
expect(await client.getFrontendUrl()).toBe('http://app.localhost:3001');
});
it('returns null when authorization_endpoint is missing', async () => {
vi.spyOn(client.client, 'get').mockResolvedValueOnce({ data: {} });
expect(await client.getFrontendUrl()).toBeNull();
});
it('returns null on network error', async () => {
vi.spyOn(client.client, 'get').mockRejectedValueOnce(new Error('boom'));
expect(await client.getFrontendUrl()).toBeNull();
});
});
describe('getWorkspaceFrontendUrl', () => {
it('prefers customUrl over subdomainUrl', async () => {
vi.spyOn(client.client, 'post').mockResolvedValueOnce({
data: {
data: {
currentWorkspace: {
workspaceUrls: {
customUrl: 'https://crm.acme.com',
subdomainUrl: 'http://apple.localhost:3001',
},
},
},
},
});
expect(await client.getWorkspaceFrontendUrl()).toBe(
'https://crm.acme.com',
);
});
it('returns subdomainUrl when customUrl is absent', async () => {
vi.spyOn(client.client, 'post').mockResolvedValueOnce({
data: {
data: {
currentWorkspace: {
workspaceUrls: {
subdomainUrl: 'http://apple.localhost:3001',
},
},
},
},
});
expect(await client.getWorkspaceFrontendUrl()).toBe(
'http://apple.localhost:3001',
);
});
it('falls back to OAuth discovery URL when workspace query returns null', async () => {
vi.spyOn(client.client, 'post').mockResolvedValueOnce({
data: { data: { currentWorkspace: null } },
});
vi.spyOn(client.client, 'get').mockResolvedValueOnce({
data: { authorization_endpoint: 'http://app.localhost:3001/authorize' },
});
expect(await client.getWorkspaceFrontendUrl()).toBe(
'http://app.localhost:3001',
);
});
});
});

View File

@@ -0,0 +1,99 @@
import { ApplicationApi } from '@/cli/utilities/api/application-api';
import { type AxiosInstance } from 'axios';
import { type Manifest } from 'twenty-shared/application';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const MANIFEST = {
application: { universalIdentifier: 'app', displayName: 'App' },
} as unknown as Manifest;
describe('ApplicationApi.syncApplication', () => {
const post = vi.fn();
const api = new ApplicationApi({ post } as unknown as AxiosInstance);
beforeEach(() => {
post.mockReset();
post.mockResolvedValue({
data: {
data: {
syncApplication: {
applicationUniversalIdentifier: 'app',
actions: [],
},
},
},
});
});
const sentOperation = () =>
post.mock.calls[0][1] as {
query: string;
variables: Record<string, unknown>;
};
it('sends only the manifest by default', async () => {
await api.syncApplication(MANIFEST);
const { query, variables } = sentOperation();
expect(query).toContain('mutation SyncApplication($manifest: JSON!)');
expect(query).toContain('syncApplication(manifest: $manifest)');
expect(variables).toEqual({ manifest: MANIFEST });
});
it('declares dryRun only when requested', async () => {
await api.syncApplication(MANIFEST, { dryRun: true });
const { query, variables } = sentOperation();
expect(query).toContain('$dryRun: Boolean');
expect(query).toContain('dryRun: $dryRun');
expect(query).not.toContain('inferDeletionFromMissingEntities');
expect(variables).toEqual({ manifest: MANIFEST, dryRun: true });
});
it('declares inferDeletionFromMissingEntities only when deletions are turned off', async () => {
await api.syncApplication(MANIFEST, {
inferDeletionFromMissingEntities: false,
});
const { query, variables } = sentOperation();
expect(query).toContain('$inferDeletionFromMissingEntities: Boolean');
expect(query).toContain(
'inferDeletionFromMissingEntities: $inferDeletionFromMissingEntities',
);
expect(query).not.toContain('dryRun');
expect(variables).toEqual({
manifest: MANIFEST,
inferDeletionFromMissingEntities: false,
});
});
it('leaves the default deletion behaviour implicit', async () => {
await api.syncApplication(MANIFEST, {
dryRun: true,
inferDeletionFromMissingEntities: true,
});
const { query, variables } = sentOperation();
expect(query).not.toContain('inferDeletionFromMissingEntities');
expect(variables).toEqual({ manifest: MANIFEST, dryRun: true });
});
it('declares both arguments for an additive plan', async () => {
await api.syncApplication(MANIFEST, {
dryRun: true,
inferDeletionFromMissingEntities: false,
});
const { variables } = sentOperation();
expect(variables).toEqual({
manifest: MANIFEST,
dryRun: true,
inferDeletionFromMissingEntities: false,
});
});
});

View File

@@ -0,0 +1,318 @@
import { isNonEmptyString } from '@sniptt/guards';
import axios, {
type AxiosInstance,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
import chalk from 'chalk';
import { isDefined } from 'twenty-shared/utils';
import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper';
import { ConfigService } from '@/cli/utilities/config/config-service';
export class ApiClient {
readonly client: AxiosInstance;
readonly configService: ConfigService;
private readonly tokenOverride?: string;
readonly serverUrlOverride?: string;
private reauthAttempted: boolean = false;
constructor(options?: {
disableInterceptors?: boolean;
serverUrl?: string;
token?: string;
skipAuth?: boolean;
}) {
const {
disableInterceptors = false,
serverUrl,
token,
skipAuth = false,
} = options || {};
this.configService = new ConfigService();
this.tokenOverride = token;
this.serverUrlOverride = serverUrl;
this.client = axios.create();
this.client.interceptors.request.use(async (config) => {
const twentyConfig = await this.configService.getConfig();
config.baseURL = this.serverUrlOverride ?? twentyConfig.apiUrl;
if (!config.headers.Authorization && !skipAuth) {
const authToken = await this.resolveAuthToken();
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
}
}
return config;
});
if (disableInterceptors) {
return;
}
this.client.interceptors.response.use(
async (response) => {
// Handle auth errors returned as GraphQL errors in HTTP 200 responses
if (
response.status === 200 &&
response.data?.errors &&
Array.isArray(response.data.errors)
) {
const hasAuthError = response.data.errors.some(
(error: { message?: string; extensions?: { code?: string } }) =>
error.extensions?.code === 'UNAUTHENTICATED' ||
error.extensions?.code === 'FORBIDDEN' ||
(typeof error.message === 'string' &&
error.message.toLowerCase().includes('unauthenticated')),
);
if (hasAuthError) {
if (!this.reauthAttempted) {
const retried = await this.tryReauthenticateAndRetry(
response.config,
);
if (retried) {
return retried;
}
}
const authError = new Error(
'Authentication failed: GraphQL auth error in response',
) as Error & { response: typeof response };
authError.response = response;
throw authError;
}
}
return response;
},
async (error) => {
if (error.response?.status === 401 && error.config) {
if (!this.reauthAttempted) {
const retried = await this.tryReauthenticateAndRetry(error.config);
if (retried) {
return retried;
}
}
} else if (error.response?.status === 403) {
console.error(
chalk.red(
'Access denied. Check your API key and workspace permissions.',
),
);
} else if (error.code === 'ECONNREFUSED') {
console.error(
chalk.red('Cannot connect to Twenty server. Is it running?'),
);
}
throw error;
},
);
}
private async tryReauthenticateAndRetry(
config: InternalAxiosRequestConfig,
): Promise<AxiosResponse | null> {
// Prevent recursion: only attempt reauth once per client instance
this.reauthAttempted = true;
const remoteName = ConfigService.getActiveRemote();
console.error(`Authentication failed on remote "${remoteName}"`);
const outcome = await promptForReauthentication(remoteName);
if (outcome === 'reauthenticated') {
const authToken = await this.resolveAuthToken();
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
return this.client.request(config);
}
}
return null;
}
async getFrontendUrl(): Promise<string | null> {
try {
const response = await this.client.get(
'/.well-known/oauth-authorization-server',
{ headers: { Accept: 'application/json' } },
);
const authorizationEndpoint = response.data?.authorization_endpoint;
if (!isNonEmptyString(authorizationEndpoint)) {
return null;
}
return new URL(authorizationEndpoint).origin;
} catch {
return null;
}
}
async getWorkspaceFrontendUrl(): Promise<string | null> {
const workspaceFrontendUrl = await this.getCurrentWorkspaceFrontendUrl();
if (isDefined(workspaceFrontendUrl)) {
return workspaceFrontendUrl;
}
return this.getFrontendUrl();
}
private async getCurrentWorkspaceFrontendUrl(): Promise<string | null> {
try {
const query = `
query CurrentWorkspaceForFrontendUrl {
currentWorkspace {
workspaceUrls {
subdomainUrl
customUrl
}
}
}
`;
const response = await this.client.post(
'/metadata',
{ query },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
const workspaceUrls =
response.data?.data?.currentWorkspace?.workspaceUrls;
const workspaceFrontendUrl = isNonEmptyString(workspaceUrls?.customUrl)
? workspaceUrls.customUrl
: workspaceUrls?.subdomainUrl;
if (!isNonEmptyString(workspaceFrontendUrl)) {
return null;
}
return new URL(workspaceFrontendUrl).origin;
} catch {
return null;
}
}
async validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> {
try {
const query = `
query CurrentWorkspace {
currentWorkspace {
id
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
return {
authValid: response.status === 200 && !response.data.errors,
serverUp: response.status === 200,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
authValid: false,
serverUp: true,
};
}
return {
authValid: false,
serverUp: false,
};
}
}
async refreshToken(): Promise<string | null> {
const config = await this.configService.getConfig();
if (
!config.twentyCLIRefreshToken ||
!config.twentyCLIRegistrationClientId
) {
return null;
}
try {
const tokenResponse = await axios.post(`${config.apiUrl}/oauth/token`, {
grant_type: 'refresh_token',
refresh_token: config.twentyCLIRefreshToken,
client_id: config.twentyCLIRegistrationClientId,
});
const { access_token: newAccessToken, refresh_token: newRefreshToken } =
tokenResponse.data;
await this.configService.setConfig({
twentyCLIAccessToken: newAccessToken,
...(newRefreshToken ? { twentyCLIRefreshToken: newRefreshToken } : {}),
});
return newAccessToken;
} catch {
return null;
}
}
async resolveAuthToken(): Promise<string | undefined> {
if (this.tokenOverride) {
return this.tokenOverride;
}
const config = await this.configService.getConfig();
const cliToken = config.twentyCLIAccessToken;
if (cliToken && this.isTokenExpired(cliToken)) {
const refreshed = await this.refreshToken();
if (refreshed) {
return refreshed;
}
}
return cliToken ?? config.apiKey;
}
private isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString(),
);
const EXPIRATION_MARGIN_IN_SECONDS = 30;
return (
payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_IN_SECONDS * 1_000
);
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,14 @@
type SuccessfulApiResponse<TData = unknown> = {
success: true;
data: TData;
message?: string;
};
type FailingApiResponse<TError = unknown> = {
success: false;
error?: TError;
message?: string;
isAuthError?: boolean;
};
export type ApiResponse<TData = unknown, TError = unknown> =
| SuccessfulApiResponse<TData>
| FailingApiResponse<TError>;

View File

@@ -0,0 +1,166 @@
import { ApiClient } from '@/cli/utilities/api/api-client';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { ApplicationApi } from '@/cli/utilities/api/application-api';
import { FileApi } from '@/cli/utilities/api/file-api';
import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api';
import { SchemaApi } from '@/cli/utilities/api/schema-api';
import { type ApplicationExport } from '@/cli/utilities/pull/application-export-type';
import { type Manifest } from 'twenty-shared/application';
import {
type MetadataValidationErrorResponse,
type SyncAction,
} from 'twenty-shared/metadata';
type ApiServiceOptions = {
disableInterceptors?: boolean;
serverUrl?: string;
token?: string;
skipAuth?: boolean;
};
export class ApiService {
private apiClient: ApiClient;
private applicationApi: ApplicationApi;
private schemaApi: SchemaApi;
private logicFunctionApi: LogicFunctionApi;
private fileApi: FileApi;
constructor(options?: ApiServiceOptions) {
this.apiClient = new ApiClient(options);
this.applicationApi = new ApplicationApi(this.apiClient.client);
this.schemaApi = new SchemaApi(this.apiClient.client);
this.logicFunctionApi = new LogicFunctionApi(this.apiClient);
this.fileApi = new FileApi(this.apiClient.client);
}
validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> {
return this.apiClient.validateAuth();
}
getWorkspaceFrontendUrl(): Promise<string | null> {
return this.apiClient.getWorkspaceFrontendUrl();
}
refreshToken(): Promise<string | null> {
return this.apiClient.refreshToken();
}
findApplicationRegistrationByUniversalIdentifier(
...args: Parameters<
ApplicationApi['findApplicationRegistrationByUniversalIdentifier']
>
) {
return this.applicationApi.findApplicationRegistrationByUniversalIdentifier(
...args,
);
}
createApplicationRegistration(
...args: Parameters<ApplicationApi['createApplicationRegistration']>
) {
return this.applicationApi.createApplicationRegistration(...args);
}
rotateApplicationRegistrationClientSecret(
...args: Parameters<
ApplicationApi['rotateApplicationRegistrationClientSecret']
>
) {
return this.applicationApi.rotateApplicationRegistrationClientSecret(
...args,
);
}
createDevelopmentApplication(
...args: Parameters<ApplicationApi['createDevelopmentApplication']>
) {
return this.applicationApi.createDevelopmentApplication(...args);
}
generateApplicationToken(
...args: Parameters<ApplicationApi['generateApplicationToken']>
) {
return this.applicationApi.generateApplicationToken(...args);
}
syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean; inferDeletionFromMissingEntities?: boolean },
): Promise<
ApiResponse<
{
applicationUniversalIdentifier: string;
actions: SyncAction[];
},
MetadataValidationErrorResponse
>
> {
return this.applicationApi.syncApplication(manifest, options);
}
exportApplication(
universalIdentifier: string,
): Promise<ApiResponse<ApplicationExport>> {
return this.applicationApi.exportApplication(universalIdentifier);
}
uninstallApplication(universalIdentifier: string): Promise<ApiResponse> {
return this.applicationApi.uninstallApplication(universalIdentifier);
}
syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
return this.applicationApi.syncMarketplaceCatalog();
}
getSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.schemaApi.getSchema(options);
}
getMetadataSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.schemaApi.getMetadataSchema(options);
}
findLogicFunctions(
...args: Parameters<LogicFunctionApi['findLogicFunctions']>
) {
return this.logicFunctionApi.findLogicFunctions(...args);
}
executeLogicFunction(
...args: Parameters<LogicFunctionApi['executeLogicFunction']>
) {
return this.logicFunctionApi.executeLogicFunction(...args);
}
subscribeToLogs(...args: Parameters<LogicFunctionApi['subscribeToLogs']>) {
return this.logicFunctionApi.subscribeToLogs(...args);
}
uploadAppTarball(...args: Parameters<FileApi['uploadAppTarball']>) {
return this.fileApi.uploadAppTarball(...args);
}
installTarballApp(...args: Parameters<FileApi['installTarballApp']>) {
return this.fileApi.installTarballApp(...args);
}
uploadFile(...args: Parameters<FileApi['uploadFile']>) {
return this.fileApi.uploadFile(...args);
}
createApplicationFileUploads(
...args: Parameters<FileApi['createApplicationFileUploads']>
) {
return this.fileApi.createApplicationFileUploads(...args);
}
completeApplicationFileUploads(
...args: Parameters<FileApi['completeApplicationFileUploads']>
) {
return this.fileApi.completeApplicationFileUploads(...args);
}
}

View File

@@ -0,0 +1,507 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { type ApplicationExport } from '@/cli/utilities/pull/application-export-type';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import axios, { type AxiosInstance } from 'axios';
import { type Manifest } from 'twenty-shared/application';
import {
type MetadataValidationErrorResponse,
type SyncAction,
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
export class ApplicationApi {
constructor(private readonly client: AxiosInstance) {}
async syncMarketplaceCatalog(): Promise<ApiResponse<boolean>> {
try {
const query = `
mutation SyncMarketplaceCatalog {
syncMarketplaceCatalog
}
`;
const response = await this.client.post(
'/metadata',
{ query },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.syncMarketplaceCatalog,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async findApplicationRegistrationByUniversalIdentifier(
universalIdentifier: string,
): Promise<
ApiResponse<{
id: string;
universalIdentifier: string;
name: string;
oAuthClientId: string;
} | null>
> {
try {
const query = `
query FindApplicationRegistrationByUniversalIdentifier($universalIdentifier: String!) {
findApplicationRegistrationByUniversalIdentifier(universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
oAuthClientId
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data
.findApplicationRegistrationByUniversalIdentifier,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async exportApplication(
universalIdentifier: string,
): Promise<ApiResponse<ApplicationExport>> {
try {
const query = `
query ExportApplication($universalIdentifier: UUID!) {
exportApplication(universalIdentifier: $universalIdentifier) {
application {
universalIdentifier
displayName
sourceType
}
manifest
coverage {
metadataName
universalIdentifier
status
reason
}
files {
folder
path
content
}
}
}
`;
const response = await this.client.post(
'/metadata',
{
query,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.exportApplication,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createApplicationRegistration(input: {
name: string;
universalIdentifier: string;
}): Promise<
ApiResponse<{
applicationRegistration: {
id: string;
universalIdentifier: string;
oAuthClientId: string;
};
clientSecret: string;
}>
> {
try {
const mutation = `
mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
createApplicationRegistration(input: $input) {
applicationRegistration {
id
universalIdentifier
oAuthClientId
}
clientSecret
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { input },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.createApplicationRegistration,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async rotateApplicationRegistrationClientSecret(
id: string,
): Promise<ApiResponse<{ clientSecret: string }>> {
try {
const mutation = `
mutation RotateApplicationRegistrationClientSecret($id: String!) {
rotateApplicationRegistrationClientSecret(id: $id) {
clientSecret
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { id },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.rotateApplicationRegistrationClientSecret,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async generateApplicationToken(applicationId: string): Promise<
ApiResponse<{
applicationAccessToken: { token: string; expiresAt: string };
applicationRefreshToken: { token: string; expiresAt: string };
}>
> {
try {
const mutation = `
mutation GenerateApplicationToken($applicationId: UUID!) {
generateApplicationToken(applicationId: $applicationId) {
applicationAccessToken {
token
expiresAt
}
applicationRefreshToken {
token
expiresAt
}
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: { applicationId },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.generateApplicationToken,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async createDevelopmentApplication(input: {
universalIdentifier: string;
name: string;
}): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
try {
const mutation = `
mutation CreateDevelopmentApplication($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
universalIdentifier
}
}
`;
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables: input,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.createDevelopmentApplication,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean; inferDeletionFromMissingEntities?: boolean },
): Promise<
ApiResponse<
{
applicationUniversalIdentifier: string;
actions: SyncAction[];
},
MetadataValidationErrorResponse
>
> {
try {
const optionalArguments = [
options?.dryRun ? 'dryRun' : null,
options?.inferDeletionFromMissingEntities === false
? 'inferDeletionFromMissingEntities'
: null,
].filter(isDefined);
const variableDeclarations = [
'$manifest: JSON!',
...optionalArguments.map((argument) => `$${argument}: Boolean`),
].join(', ');
const argumentAssignments = [
'manifest: $manifest',
...optionalArguments.map((argument) => `${argument}: $${argument}`),
].join(', ');
const mutation = `
mutation SyncApplication(${variableDeclarations}) {
syncApplication(${argumentAssignments}) {
applicationUniversalIdentifier
actions
}
}
`;
const variables = {
manifest,
...(options?.dryRun ? { dryRun: true } : {}),
...(options?.inferDeletionFromMissingEntities === false
? { inferDeletionFromMissingEntities: false }
: {}),
};
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.extensions,
message: response.data.errors[0]?.message,
};
}
return {
success: true,
data: response.data.data.syncApplication,
message: `Successfully synced application: ${manifest.application.displayName}`,
};
} catch (error) {
return {
success: false,
message: serializeError(error),
};
}
}
async uninstallApplication(
universalIdentifier: string,
): Promise<ApiResponse> {
try {
const mutation = `
mutation UninstallApplication($universalIdentifier: String!) {
uninstallApplication(universalIdentifier: $universalIdentifier)
}
`;
const variables = { universalIdentifier };
const response = await this.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to delete application',
};
}
return {
success: true,
data: response.data.data.uninstallApplication,
message: 'Successfully uninstalled application',
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
throw error;
}
}
}

View File

@@ -0,0 +1,438 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { type MetadataValidationErrorResponse } from 'twenty-shared/metadata';
import { type FileFolder } from 'twenty-shared/types';
import { pascalCase } from 'twenty-shared/utils';
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.js': 'application/javascript',
'.ts': 'application/typescript',
'.jsx': 'application/javascript',
'.tsx': 'application/typescript',
'.html': 'text/html',
'.css': 'text/css',
};
const getMimeType = (filename: string): string => {
const ext = path.extname(filename).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
};
export type ApplicationFileUploadRequest = {
fileFolder: FileFolder;
filePath: string;
size: number;
};
export type ApplicationFileUploadTarget = {
fileId: string;
filePath: string;
uploadUrl: string;
contentType: string;
expiresAt: string;
};
export type ApplicationFileUploadError = {
fileFolder: string;
filePath: string;
message: string;
};
export type CreateApplicationFileUploadsResult = {
targets: ApplicationFileUploadTarget[];
errors: ApplicationFileUploadError[];
};
export type ApplicationFileCompletionError = {
fileId: string;
message: string;
};
export type CompleteApplicationFileUploadsResult = {
files: { id: string; path: string }[];
errors: ApplicationFileCompletionError[];
};
export class FileApi {
constructor(private readonly client: AxiosInstance) {}
// TODO: Migrate to MetadataClient once available
// (see https://github.com/twentyhq/core-team-issues/issues/2289)
async uploadAppTarball({
tarballBuffer,
universalIdentifier,
}: {
tarballBuffer: Buffer;
universalIdentifier?: string;
}): Promise<
ApiResponse<{
id: string;
universalIdentifier: string;
name: string;
}>
> {
try {
const mutation = `
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
const operations = JSON.stringify({
query: mutation,
variables: {
file: null,
universalIdentifier: universalIdentifier ?? null,
},
});
const map = JSON.stringify({
'0': ['variables.file'],
});
const formData = new FormData();
formData.append('operations', operations);
formData.append('map', map);
formData.append(
'0',
new Blob([new Uint8Array(tarballBuffer)], {
type: 'application/gzip',
}),
'app.tar.gz',
);
const response: AxiosResponse = await this.client.post(
'/metadata',
formData,
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || 'Failed to upload tarball',
};
}
return {
success: true,
data: response.data.data.uploadAppTarball,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 401) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
isAuthError: true,
};
}
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
async installTarballApp({
universalIdentifier,
}: {
universalIdentifier: string;
}): Promise<ApiResponse<boolean, MetadataValidationErrorResponse>> {
try {
const mutation = `
mutation InstallApplication($universalIdentifier: String!) {
installApplication(universalIdentifier: $universalIdentifier) {
id
}
}
`;
const response: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables: { universalIdentifier },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.extensions,
message:
response.data.errors[0]?.message || 'Failed to install application',
};
}
return {
success: true,
data: response.data.data.installApplication,
};
} catch (error) {
return {
success: false,
message: serializeError(error),
};
}
}
async uploadFile({
filePath,
builtHandlerPath,
fileFolder,
applicationUniversalIdentifier,
}: {
filePath: string;
builtHandlerPath: string;
fileFolder: FileFolder;
applicationUniversalIdentifier: string;
}): Promise<ApiResponse<boolean>> {
try {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
return {
success: false,
error: `File not found: ${absolutePath}`,
};
}
const filename = path.basename(absolutePath);
const buffer = fs.readFileSync(absolutePath);
const mimeType = getMimeType(filename);
const mutation = `
mutation UploadApplicationFile($file: Upload!, $applicationUniversalIdentifier: String!, $fileFolder: FileFolder!, $filePath: String!) {
uploadApplicationFile(file: $file, applicationUniversalIdentifier: $applicationUniversalIdentifier, fileFolder: $fileFolder, filePath: $filePath)
{ path }
}
`;
const graphqlEnumFileFolder = pascalCase(fileFolder);
const operations = JSON.stringify({
query: mutation,
variables: {
file: null,
applicationUniversalIdentifier,
filePath: builtHandlerPath,
fileFolder: graphqlEnumFileFolder,
},
});
const map = JSON.stringify({
'0': ['variables.file'],
});
const formData = new FormData();
formData.append('operations', operations);
formData.append('map', map);
formData.append(
'0',
new Blob([new Uint8Array(buffer)], { type: mimeType }),
filename,
);
const response: AxiosResponse = await this.client.post(
'/metadata',
formData,
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || 'Failed to upload file',
};
}
return {
success: true,
data: response.data.data.uploadApplicationFile,
message: `Successfully uploaded ${filename}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
async createApplicationFileUploads({
applicationUniversalIdentifier,
files,
}: {
applicationUniversalIdentifier: string;
files: ApplicationFileUploadRequest[];
}): Promise<ApiResponse<CreateApplicationFileUploadsResult>> {
const mutation = `
mutation CreateApplicationFileUploads($applicationUniversalIdentifier: String!, $files: [ApplicationFileUploadRequestInput!]!) {
createApplicationFileUploads(applicationUniversalIdentifier: $applicationUniversalIdentifier, files: $files) {
targets {
fileId
filePath
uploadUrl
contentType
expiresAt
}
errors {
fileFolder
filePath
message
}
}
}
`;
return this.runMetadataMutation<CreateApplicationFileUploadsResult>({
mutation,
variables: {
applicationUniversalIdentifier,
files: files.map(({ fileFolder, filePath, size }) => ({
fileFolder: pascalCase(fileFolder),
filePath,
size,
})),
},
resultKey: 'createApplicationFileUploads',
defaultErrorMessage: 'Failed to create application file uploads',
});
}
async completeApplicationFileUploads({
applicationUniversalIdentifier,
fileIds,
}: {
applicationUniversalIdentifier: string;
fileIds: string[];
}): Promise<ApiResponse<CompleteApplicationFileUploadsResult>> {
const mutation = `
mutation CompleteApplicationFileUploads($applicationUniversalIdentifier: String!, $fileIds: [UUID!]!) {
completeApplicationFileUploads(applicationUniversalIdentifier: $applicationUniversalIdentifier, fileIds: $fileIds) {
files {
id
path
}
errors {
fileId
message
}
}
}
`;
return this.runMetadataMutation<CompleteApplicationFileUploadsResult>({
mutation,
variables: { applicationUniversalIdentifier, fileIds },
resultKey: 'completeApplicationFileUploads',
defaultErrorMessage: 'Failed to complete application file uploads',
});
}
private async runMetadataMutation<TData>({
mutation,
variables,
resultKey,
defaultErrorMessage,
}: {
mutation: string;
variables: Record<string, unknown>;
resultKey: string;
defaultErrorMessage: string;
}): Promise<ApiResponse<TData>> {
try {
const response: AxiosResponse = await this.client.post(
'/metadata',
{ query: mutation, variables },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || defaultErrorMessage,
};
}
return {
success: true,
data: response.data.data[resultKey],
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
}

View File

@@ -0,0 +1,188 @@
import { type ApiClient } from '@/cli/utilities/api/api-client';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { createClient } from 'graphql-sse';
export class LogicFunctionApi {
constructor(private readonly apiClient: ApiClient) {}
async findLogicFunctions(): Promise<
ApiResponse<
Array<{
id: string;
name: string;
universalIdentifier: string;
applicationId: string | null;
}>
>
> {
try {
const query = `
query FindManyLogicFunctions {
findManyLogicFunctions {
id
name
universalIdentifier
applicationId
}
}
`;
const response = await this.apiClient.client.post(
'/metadata',
{ query },
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to fetch functions',
};
}
return {
success: true,
data: response.data.data.findManyLogicFunctions,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async executeLogicFunction({
functionId,
payload,
}: {
functionId: string;
payload: Record<string, unknown>;
}): Promise<
ApiResponse<{
data: unknown;
logs: string;
duration: number;
status: string;
error?: {
errorType: string;
errorMessage: string;
stackTrace: string;
};
}>
> {
try {
const mutation = `
mutation ExecuteOneLogicFunction($input: ExecuteOneLogicFunctionInput!) {
executeOneLogicFunction(input: $input) {
data
logs
duration
status
error
}
}
`;
const variables = {
input: {
id: functionId,
payload,
},
};
const response = await this.apiClient.client.post(
'/metadata',
{
query: mutation,
variables,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message ||
'Failed to execute logic function',
};
}
return {
success: true,
data: response.data.data.executeOneLogicFunction,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async subscribeToLogs({
applicationUniversalIdentifier,
functionUniversalIdentifier,
functionName,
}: {
applicationUniversalIdentifier: string;
functionUniversalIdentifier?: string;
functionName?: string;
}) {
const twentyConfig = await this.apiClient.configService.getConfig();
const baseUrl = this.apiClient.serverUrlOverride ?? twentyConfig.apiUrl;
const wsClient = createClient({
url: baseUrl + '/metadata',
headers: async () => {
const authToken = await this.apiClient.resolveAuthToken();
return {
Authorization: authToken ? `Bearer ${authToken}` : '',
'Content-Type': 'application/json',
Accept: 'text/event-stream',
};
},
});
const query = `
subscription SubscribeToLogs($input: LogicFunctionLogsInput!) {
logicFunctionLogs(input: $input) {
logs
}
}
`;
const variables = {
input: {
applicationUniversalIdentifier,
universalIdentifier: functionUniversalIdentifier,
name: functionName,
},
};
wsClient.subscribe<{ logicFunctionLogs: { logs: string } }>(
{
query,
variables,
},
{
next: ({ data }) => console.log(data?.logicFunctionLogs.logs),
error: (err: unknown) => console.error(err),
complete: () => console.log('Completed'),
},
);
}
}

View File

@@ -0,0 +1,70 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import axios, { type AxiosInstance } from 'axios';
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql';
export class SchemaApi {
constructor(private readonly client: AxiosInstance) {}
async getSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/graphql', options);
}
async getMetadataSchema(options?: {
appAccessToken?: string;
}): Promise<ApiResponse<string>> {
return this.introspectEndpoint('/metadata', options);
}
private async introspectEndpoint(
endpoint: string,
options?: { appAccessToken?: string },
): Promise<ApiResponse<string>> {
try {
const introspectionQuery = getIntrospectionQuery();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: '*/*',
};
if (options?.appAccessToken) {
headers.Authorization = `Bearer ${options.appAccessToken}`;
}
const response = await this.client.post(
endpoint,
{
query: introspectionQuery,
},
{ headers },
);
if (response.data.errors) {
return {
success: false,
error: `GraphQL introspection errors: ${JSON.stringify(response.data.errors)}`,
};
}
const schema = buildClientSchema(response.data.data);
return {
success: true,
data: printSchema(schema),
message: `Successfully loaded schema from ${endpoint}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error:
error.response.data?.errors?.[0]?.message ||
`Failed to load schema from ${endpoint}`,
};
}
throw error;
}
}
}