Add Client Api generation (#17961)

## Add API client generation to SDK dev mode and refactor orchestrator
into step-based pipeline

### Why

The SDK dev mode lacked typed API client generation, forcing developers
to work without auto-generated GraphQL types when building applications.
Additionally, the orchestrator was a monolithic class that mixed watcher
management, token handling, and sync logic — making it difficult to
extend with new steps like client generation.

### How

- **Refactored the orchestrator** into a step-based pipeline with
dedicated classes: `CheckServer`, `EnsureValidTokens`,
`ResolveApplication`, `BuildManifest`, `UploadFiles`,
`GenerateApiClient`, `SyncApplication`, and `StartWatchers`. Each step
has typed input/output/status, managed by a new `OrchestratorState`
class.
- **Added `GenerateApiClientOrchestratorStep`** that detects
object/field schema changes and regenerates a typed GraphQL client (via
`@genql/cli`) into `node_modules/twenty-sdk/generated` for seamless
imports.
- **Replaced `checkApplicationExist`** with `findOneApplication` on both
server resolver and SDK API service, returning the entity data instead
of a boolean.
- **Added application token pair mutations**
(`generateApplicationToken`, `renewApplicationToken`) to the API
service, with the server now returning `ApplicationTokenPairDTO`
containing both access and refresh tokens.
- **Restructured the dev UI** into `dev/ui/components/` with dedicated
panel, section, and event log components.
- **Simplified `AppDevCommand`** from ~180 lines of watcher management
down to ~40 lines that delegate entirely to the orchestrator.
This commit is contained in:
Charles Bochet
2026-02-17 18:45:52 +01:00
committed by GitHub
parent 0891886aa0
commit c0cc0689d6
72 changed files with 2419 additions and 1422 deletions
@@ -3,16 +3,9 @@ import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import chalk from 'chalk';
import * as fs from 'fs';
import { createClient } from 'graphql-sse';
import {
buildClientSchema,
getIntrospectionQuery,
printSchema,
} from 'graphql/index';
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql';
import * as path from 'path';
import {
type ApplicationManifest,
type Manifest,
} from 'twenty-shared/application';
import { type Manifest } from 'twenty-shared/application';
import { type FileFolder } from 'twenty-shared/types';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { pascalCase } from 'twenty-shared/utils';
@@ -31,7 +24,7 @@ export class ApiService {
config.baseURL = twentyConfig.apiUrl;
if (twentyConfig.apiKey) {
if (!config.headers.Authorization && twentyConfig.apiKey) {
config.headers.Authorization = `Bearer ${twentyConfig.apiKey}`;
}
@@ -102,15 +95,19 @@ export class ApiService {
}
}
async checkApplicationExist(
async findOneApplication(
universalIdentifier: string,
): Promise<ApiResponse<boolean>> {
): Promise<ApiResponse<{ id: string; universalIdentifier: string } | null>> {
try {
const query = `
query CheckApplicationExist($universalIdentifier: UUID!) {
checkApplicationExist(universalIdentifier: $universalIdentifier)
query FindOneApplication($universalIdentifier: UUID!) {
findOneApplication(universalIdentifier: $universalIdentifier) {
id
universalIdentifier
}
}
`;
const response = await this.client.post(
'/metadata',
{
@@ -125,6 +122,70 @@ export class ApiService {
},
);
if (response.data.errors) {
const isNotFound = response.data.errors.some(
(error: { extensions?: { code?: string } }) =>
error.extensions?.code === 'NOT_FOUND',
);
if (isNotFound) {
return { success: true, data: null };
}
return {
success: false,
error: response.data.errors[0],
};
}
return {
success: true,
data: response.data.data.findOneApplication,
};
} 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: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables: { applicationId },
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
);
if (response.data.errors) {
return {
success: false,
@@ -134,8 +195,62 @@ export class ApiService {
return {
success: true,
data: response.data.data.checkApplicationExist,
message: `Successfully find application`,
data: response.data.data.generateApplicationToken,
};
} catch (error) {
return {
success: false,
error,
};
}
}
async renewApplicationToken(applicationRefreshToken: string): Promise<
ApiResponse<{
applicationAccessToken: { token: string; expiresAt: string };
applicationRefreshToken: { token: string; expiresAt: string };
}>
> {
try {
const mutation = `
mutation RenewApplicationToken($applicationRefreshToken: String!) {
renewApplicationToken(applicationRefreshToken: $applicationRefreshToken) {
applicationAccessToken {
token
expiresAt
}
applicationRefreshToken {
token
expiresAt
}
}
}
`;
const response: AxiosResponse = await this.client.post(
'/metadata',
{
query: mutation,
variables: { applicationRefreshToken },
},
{
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.renewApplicationToken,
};
} catch (error) {
return {
@@ -147,7 +262,7 @@ export class ApiService {
async createApplication(
manifest: Manifest,
): Promise<ApiResponse<ApplicationManifest>> {
): Promise<ApiResponse<{ id: string; universalIdentifier: string }>> {
try {
const mutation = `
mutation CreateOneApplication($input: CreateApplicationInput!) {
@@ -298,21 +413,27 @@ export class ApiService {
}
}
async getSchema(): Promise<ApiResponse<string>> {
async getSchema(options?: {
authToken?: string;
}): Promise<ApiResponse<string>> {
try {
const introspectionQuery = getIntrospectionQuery();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: '*/*',
};
if (options?.authToken) {
headers.Authorization = `Bearer ${options.authToken}`;
}
const response = await this.client.post(
'/graphql',
{
query: introspectionQuery,
},
{
headers: {
'Content-Type': 'application/json',
Accept: '*/*',
},
},
{ headers },
);
if (response.data.errors) {