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;
}
}
}

View File

@@ -0,0 +1,23 @@
import { join } from 'path';
import { pathExists } from '@/cli/utilities/file/fs-utils';
export const APP_CONFIG_CANDIDATE_PATHS = [
'src/application.config.ts',
'src/application-config.ts',
'src/applicationConfig.ts',
];
export const findAppConfigPath = async (
projectRoot: string,
): Promise<string | null> => {
for (const candidate of APP_CONFIG_CANDIDATE_PATHS) {
const absolute = join(projectRoot, candidate);
if (await pathExists(absolute)) {
return absolute;
}
}
return null;
};

View File

@@ -0,0 +1,38 @@
import {
APP_CONFIG_CANDIDATE_PATHS,
findAppConfigPath,
} from '@/cli/utilities/application/find-app-config-path';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
type ApplicationConfigWithUniversalIdentifier = {
universalIdentifier?: string;
};
// Scaffolding derives deterministic identifiers from the application
// universal identifier, so nothing can be generated before defineApplication
// declares one.
export const getApplicationUniversalIdentifierOrThrow = async (
appPath: string,
): Promise<string> => {
const configPath = await findAppConfigPath(appPath);
if (configPath === null) {
throw new Error(
`Could not find the application config file (expected one of: ${APP_CONFIG_CANDIDATE_PATHS.join(', ')}). Create it with defineApplication({ universalIdentifier: ... }) before generating entities.`,
);
}
const { config } =
await extractManifestFromFile<ApplicationConfigWithUniversalIdentifier>({
appPath,
filePath: configPath,
});
if (!config?.universalIdentifier) {
throw new Error(
`defineApplication in ${configPath} must declare a universalIdentifier before generating entities.`,
);
}
return config.universalIdentifier;
};

View File

@@ -0,0 +1,144 @@
import http from 'node:http';
import { startCallbackServer } from '../callback-server';
const httpGet = (
url: string,
): Promise<{
status: number;
body: string;
headers: http.IncomingHttpHeaders;
}> =>
new Promise((resolve, reject) => {
http
.get(url, (res) => {
let body = '';
res.on('data', (chunk: string) => (body += chunk));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body,
headers: res.headers,
}),
);
})
.on('error', reject);
});
describe('startCallbackServer', () => {
it('should start on a random port and provide a callback URL', async () => {
const server = await startCallbackServer();
try {
expect(server.port).toBeGreaterThan(0);
expect(server.callbackUrl).toBe(
`http://127.0.0.1:${server.port}/callback`,
);
} finally {
server.close();
}
});
it('should resolve with the authorization code on successful callback', async () => {
const server = await startCallbackServer();
try {
const waitPromise = server.waitForCallback();
await httpGet(`${server.callbackUrl}?code=test-auth-code`);
const result = await waitPromise;
expect(result).toEqual({ success: true, code: 'test-auth-code' });
} finally {
server.close();
}
});
it('should send the success page with an explicit Content-Length (not chunked)', async () => {
const server = await startCallbackServer();
try {
const waitPromise = server.waitForCallback();
const response = await httpGet(
`${server.callbackUrl}?code=test-auth-code`,
);
await waitPromise;
expect(response.status).toBe(200);
expect(response.headers['transfer-encoding']).toBeUndefined();
expect(response.headers['content-length']).toBe(
String(Buffer.byteLength(response.body)),
);
expect(response.body).toContain('Authentication successful');
} finally {
server.close();
}
});
it('should deliver the full page even when the server is closed right after the callback resolves', async () => {
const server = await startCallbackServer();
const responsePromise = httpGet(
`${server.callbackUrl}?code=test-auth-code`,
);
await server.waitForCallback();
server.close();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(response.body).toContain('Authentication successful');
expect(response.body).toContain('</html>');
});
it('should resolve with error when callback contains an error', async () => {
const server = await startCallbackServer();
try {
const waitPromise = server.waitForCallback();
await httpGet(`${server.callbackUrl}?error=access_denied`);
const result = await waitPromise;
expect(result).toEqual({ success: false, error: 'access_denied' });
} finally {
server.close();
}
});
it('should return 404 for non-callback paths', async () => {
const server = await startCallbackServer();
try {
const response = await httpGet(
`http://127.0.0.1:${server.port}/other-path`,
);
expect(response.status).toBe(404);
} finally {
server.close();
}
});
it('should time out if no callback is received', async () => {
const server = await startCallbackServer({ timeoutMs: 500 });
try {
const result = await server.waitForCallback();
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('Timed out');
}
} finally {
server.close();
}
});
});

View File

@@ -0,0 +1,41 @@
import crypto from 'node:crypto';
import { generatePkceChallenge } from '../pkce';
describe('generatePkceChallenge', () => {
it('should return a code verifier and code challenge', () => {
const { codeVerifier, codeChallenge } = generatePkceChallenge();
expect(codeVerifier).toBeDefined();
expect(codeChallenge).toBeDefined();
expect(codeVerifier.length).toBeGreaterThan(0);
expect(codeChallenge.length).toBeGreaterThan(0);
});
it('should produce a challenge that is the SHA256 hash of the verifier', () => {
const { codeVerifier, codeChallenge } = generatePkceChallenge();
const expectedChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
expect(codeChallenge).toBe(expectedChallenge);
});
it('should generate unique values on each call', () => {
const first = generatePkceChallenge();
const second = generatePkceChallenge();
expect(first.codeVerifier).not.toBe(second.codeVerifier);
expect(first.codeChallenge).not.toBe(second.codeChallenge);
});
it('should use base64url encoding with no padding', () => {
const { codeVerifier, codeChallenge } = generatePkceChallenge();
// base64url uses - and _ instead of + and /, and no = padding
expect(codeVerifier).not.toMatch(/[+/=]/);
expect(codeChallenge).not.toMatch(/[+/=]/);
});
});

View File

@@ -0,0 +1,22 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
// Builds the fetchTokenPair token source for
// ensureAppAccessTokenIsValidOrRefresh: mints a workspace-scoped app token
// pair via the generateApplicationToken mutation.
export const buildAppTokenPairFetcher =
(apiService: ApiService, applicationId: string) =>
async (): Promise<
{ accessToken: string; refreshToken?: string } | undefined
> => {
const tokenResult =
await apiService.generateApplicationToken(applicationId);
if (!tokenResult.success || !tokenResult.data) {
return undefined;
}
return {
accessToken: tokenResult.data.applicationAccessToken.token,
refreshToken: tokenResult.data.applicationRefreshToken.token,
};
};

View File

@@ -0,0 +1,265 @@
import http from 'node:http';
import { OAUTH_MODAL_HEADER_IMAGE_BASE64 } from '@/cli/constants/oauth-modal-header-image-base64';
type CallbackResult =
| { success: true; code: string }
| { success: false; error: string };
type CallbackServer = {
port: number;
callbackUrl: string;
waitForCallback: () => Promise<CallbackResult>;
close: () => void;
};
const pageHtml = ({
title,
message,
isSuccess,
isDarkMode,
}: {
title: string;
message: string;
isSuccess: boolean;
isDarkMode: boolean;
}) => `<!DOCTYPE html>
<html lang="en"${isDarkMode ? ' class="dark"' : ''}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} — Twenty</title>
<style>
:root {
--bg-body: #fafafa;
--bg-card: #fff;
--bg-logo-tile: #fff;
--text-primary: #333;
--text-secondary: #666;
--shadow: 2px 4px 16px 0 rgba(0,0,0,0.16), 0 2px 4px 0 rgba(0,0,0,0.08);
--success-bg: #f0faf0;
--error-bg: #fef0f0;
}
html.dark {
--bg-body: #171717;
--bg-card: #1b1b1b;
--bg-logo-tile: #2b2b2b;
--text-primary: #e6e6e6;
--text-secondary: #999;
--shadow: 2px 4px 16px 0 rgba(0,0,0,0.5), 0 2px 4px 0 rgba(0,0,0,0.3);
--success-bg: #0a2e0a;
--error-bg: #2e0a0a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg-body);
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
color: var(--text-primary);
}
.card {
background: var(--bg-card);
border-radius: 8px;
box-shadow: var(--shadow);
max-width: min(100%, 360px);
width: fit-content;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 120px;
width: 100%;
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
background-image: url('data:image/png;base64,${OAUTH_MODAL_HEADER_IMAGE_BASE64}');
background-position: center;
background-size: cover;
}
.logo-tile {
width: 36px;
height: 36px;
padding: 4px;
background: var(--bg-logo-tile);
border-radius: 100%;
box-shadow: var(--shadow);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.status-icon {
width: 24px;
height: 24px;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.status-icon svg {
width: 20px;
height: 20px;
}
.status-icon-success { background: var(--success-bg); }
.status-icon-error { background: var(--error-bg); }
.content {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px;
}
h2 {
font-size: 1rem;
font-weight: 500;
line-height: 1.1;
color: var(--text-primary);
text-align: center;
text-wrap: balance;
margin-bottom: 16px;
}
p {
font-size: 0.82rem;
color: var(--text-secondary);
text-align: center;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="logo-tile">
<div class="status-icon ${isSuccess ? 'status-icon-success' : 'status-icon-error'}">
${
isSuccess
? '<svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
}
</div>
</div>
</div>
<div class="content">
<h2>${title}</h2>
<p>${message}</p>
</div>
</div>
</body>
</html>`;
const successHtml = (isDarkMode: boolean) =>
pageHtml({
title: 'Authentication successful',
message: 'You can close this window and return to the terminal.',
isSuccess: true,
isDarkMode,
});
const escapeHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const errorHtml = (error: string, isDarkMode: boolean) =>
pageHtml({
title: 'Authentication failed',
message: `${escapeHtml(error)}<br>Please return to the terminal and try again.`,
isSuccess: false,
isDarkMode,
});
export const startCallbackServer = (options?: {
timeoutMs?: number;
}): Promise<CallbackServer> => {
const timeoutMs = options?.timeoutMs ?? 120_000;
return new Promise((resolve, reject) => {
let callbackResolve: (result: CallbackResult) => void;
let timeoutHandle: ReturnType<typeof setTimeout>;
const callbackPromise = new Promise<CallbackResult>((res) => {
callbackResolve = res;
});
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://127.0.0.1`);
if (url.pathname !== '/callback') {
res.writeHead(404, { Connection: 'close' });
res.end('Not found');
return;
}
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
const isDarkMode = url.searchParams.get('theme') === 'dark';
const result: CallbackResult = code
? { success: true, code }
: {
success: false,
error:
error ??
url.searchParams.get('error_description') ??
'Unknown error',
};
const body = result.success
? successHtml(isDarkMode)
: errorHtml(result.error, isDarkMode);
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(body),
Connection: 'close',
});
res.on('close', () => callbackResolve(result));
res.end(body);
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to start callback server'));
return;
}
const port = address.port;
resolve({
port,
callbackUrl: `http://127.0.0.1:${port}/callback`,
waitForCallback: () => {
timeoutHandle = setTimeout(() => {
callbackResolve({
success: false,
error: `Timed out waiting for authorization (${timeoutMs / 1000}s)`,
});
}, timeoutMs);
return callbackPromise.finally(() => {
clearTimeout(timeoutHandle);
});
},
close: () => {
clearTimeout(timeoutHandle);
server.close();
server.closeIdleConnections();
},
});
});
server.on('error', reject);
});
};

View File

@@ -0,0 +1,107 @@
import { type ConfigService } from '@/cli/utilities/config/config-service';
import { isOAuthInvalidClientError } from '@/cli/utilities/error/parse-server-error';
import { exchangeCredentialsForTokens } from './exchange-credentials-for-tokens';
const EXPIRATION_MARGIN_MS = 30_000;
const isTokenExpired = (token: string): boolean => {
try {
const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString(),
);
return payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_MS;
} catch {
return false;
}
};
export type AppTokenSources = {
credentials?: { clientId: string; clientSecret: string };
// Workspace-scoped token minting (generateApplicationToken mutation). Used
// when the registration is not owned by this workspace (e.g. an app synced
// from the marketplace catalog), where no client secret is available and
// rotating the shared one would break the published app.
fetchTokenPair?: () => Promise<
{ accessToken: string; refreshToken?: string } | undefined
>;
};
export const ensureAppAccessTokenIsValidOrRefresh = async (
configService: ConfigService,
tokenSources?: AppTokenSources,
): Promise<string | undefined> => {
const config = await configService.getConfig();
if (config.appAccessToken && !isTokenExpired(config.appAccessToken)) {
return config.appAccessToken;
}
if (config.appRefreshToken && config.appRegistrationClientId) {
const response = await fetch(`${config.apiUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: config.appRefreshToken,
client_id: config.appRegistrationClientId,
}),
});
if (response.ok) {
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
};
await configService.setConfig({
appAccessToken: data.access_token,
...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}),
});
return data.access_token;
}
try {
const body = await response.json();
if (isOAuthInvalidClientError(body)) {
await configService.setConfig({
appRegistrationId: undefined,
appRegistrationClientId: undefined,
appAccessToken: undefined,
appRefreshToken: undefined,
});
}
} catch {
// Non-JSON error response (e.g. proxy 502) — fall through to the other token sources
}
}
if (tokenSources?.fetchTokenPair) {
const tokenPair = await tokenSources.fetchTokenPair();
if (tokenPair) {
await configService.setConfig({
appAccessToken: tokenPair.accessToken,
...(tokenPair.refreshToken
? { appRefreshToken: tokenPair.refreshToken }
: {}),
});
return tokenPair.accessToken;
}
}
if (tokenSources?.credentials) {
const result = await exchangeCredentialsForTokens(
configService,
tokenSources.credentials,
);
return result.accessToken;
}
return undefined;
};

View File

@@ -0,0 +1,79 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { type ConfigService } from '@/cli/utilities/config/config-service';
import { hasGraphQLErrorSubCode } from '@/cli/utilities/error/parse-server-error';
export const ensureAppRegistration = async (
apiService: ApiService,
configService: ConfigService,
app: { name: string; universalIdentifier: string },
): Promise<{
clientId: string;
clientSecret?: string;
isNewRegistration: boolean;
}> => {
const createResult = await apiService.createApplicationRegistration({
name: app.name,
universalIdentifier: app.universalIdentifier,
});
if (createResult.success) {
const { applicationRegistration, clientSecret } = createResult.data;
await configService.setConfig({
appRegistrationId: applicationRegistration.id,
appRegistrationClientId: applicationRegistration.oAuthClientId,
appAccessToken: undefined,
appRefreshToken: undefined,
});
return {
clientId: applicationRegistration.oAuthClientId,
clientSecret,
isNewRegistration: true,
};
}
const isAlreadyClaimed = hasGraphQLErrorSubCode(
createResult.error,
'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
);
if (!isAlreadyClaimed) {
const errorDetail =
createResult.error instanceof Error
? createResult.error.message
: ((createResult.error as { message?: string })?.message ??
String(createResult.error));
throw new Error(`Failed to create app registration: ${errorDetail}`);
}
const findResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
app.universalIdentifier,
);
if (!findResult.success || !findResult.data) {
throw new Error(
`App registration exists but could not be found: ${app.universalIdentifier}`,
);
}
const registration = findResult.data;
await configService.setConfig({
appRegistrationId: registration.id,
appRegistrationClientId: registration.oAuthClientId,
appAccessToken: undefined,
appRefreshToken: undefined,
});
// The registration may be a catalog-synced npm app owned by another (or no)
// workspace, so rotating its shared client secret is neither allowed nor
// desirable. Dev mode mints workspace-scoped app tokens via the
// generateApplicationToken mutation instead.
return {
clientId: registration.oAuthClientId,
isNewRegistration: false,
};
};

View File

@@ -0,0 +1,39 @@
import { type ConfigService } from '@/cli/utilities/config/config-service';
export const exchangeCredentialsForTokens = async (
configService: ConfigService,
params: { clientId: string; clientSecret: string },
): Promise<{ accessToken: string; refreshToken?: string }> => {
const config = await configService.getConfig();
const response = await fetch(`${config.apiUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: params.clientId,
client_secret: params.clientSecret,
}),
});
if (!response.ok) {
throw new Error(
`Token exchange failed: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as {
access_token: string;
refresh_token?: string;
};
await configService.setConfig({
appAccessToken: data.access_token,
...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}),
});
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
};

View File

@@ -0,0 +1,3 @@
export { ensureAppAccessTokenIsValidOrRefresh } from './ensure-app-access-token-is-valid-or-refresh';
export { ensureAppRegistration } from './ensure-app-registration';
export { exchangeCredentialsForTokens } from './exchange-credentials-for-tokens';

View File

@@ -0,0 +1,22 @@
import { execFile } from 'node:child_process';
export const openBrowser = (url: string): Promise<boolean> => {
try {
new URL(url);
} catch {
return Promise.resolve(false);
}
const [command, args]: [string, string[]] =
process.platform === 'darwin'
? ['open', [url]]
: process.platform === 'win32'
? ['cmd', ['/c', 'start', '', url]]
: ['xdg-open', [url]];
return new Promise((resolve) => {
execFile(command, args, (error) => {
resolve(!error);
});
});
};

View File

@@ -0,0 +1,16 @@
import crypto from 'node:crypto';
export type PkceChallenge = {
codeVerifier: string;
codeChallenge: string;
};
export const generatePkceChallenge = (): PkceChallenge => {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
};

View File

@@ -0,0 +1,43 @@
import inquirer from 'inquirer';
import chalk from 'chalk';
import { authLoginOAuth } from '@/cli/operations/login-oauth';
import { ConfigService } from '@/cli/utilities/config/config-service';
export type ReauthOutcome = 'reauthenticated' | 'declined' | 'non-interactive';
export const promptForReauthentication = async (
remoteName: string,
): Promise<ReauthOutcome> => {
if (!process.stdout.isTTY) {
return 'non-interactive';
}
const { proceed } = await inquirer.prompt<{ proceed: boolean }>([
{
type: 'confirm',
name: 'proceed',
message: `Re-authenticate remote "${remoteName}" now?`,
default: true,
},
]);
if (!proceed) {
return 'declined';
}
const configService = new ConfigService();
const { apiUrl } = await configService.getConfig();
const result = await authLoginOAuth({ apiUrl, remote: remoteName });
if (result.success) {
console.log(chalk.green(`✓ Re-authenticated "${remoteName}".`));
return 'reauthenticated';
}
console.log(chalk.yellow(result.error.message));
console.log(chalk.yellow('Run `yarn twenty remote:add` to re-authenticate.'));
return 'declined';
};

View File

@@ -0,0 +1,63 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
copyReadmeToOutput,
findReadmeFileName,
} from '@/cli/utilities/build/common/copy-readme-to-output';
import { pathExists } from '@/cli/utilities/file/fs-utils';
describe('findReadmeFileName', () => {
it('should match readme files regardless of case', () => {
expect(findReadmeFileName(['README.md'])).toBe('README.md');
expect(findReadmeFileName(['readme.md'])).toBe('readme.md');
expect(findReadmeFileName(['Readme.txt'])).toBe('Readme.txt');
expect(findReadmeFileName(['README'])).toBe('README');
});
it('should prefer the markdown readme over other variants', () => {
expect(findReadmeFileName(['README.txt', 'README.md'])).toBe('README.md');
});
it('should ignore unrelated files', () => {
expect(
findReadmeFileName(['index.ts', 'package.json', 'readme-notes.md']),
).toBeUndefined();
});
});
describe('copyReadmeToOutput', () => {
let appPath: string;
beforeEach(async () => {
appPath = await mkdtemp(join(tmpdir(), 'readme-test-'));
});
afterEach(async () => {
await rm(appPath, { recursive: true, force: true });
});
it('should copy the readme into the output directory', async () => {
await writeFile(join(appPath, 'README.md'), '# My App', 'utf-8');
await copyReadmeToOutput(appPath);
const copiedReadme = await readFile(
join(appPath, OUTPUT_DIR, 'README.md'),
'utf-8',
);
expect(copiedReadme).toBe('# My App');
});
it('should do nothing when the app has no readme', async () => {
await writeFile(join(appPath, 'package.json'), '{}', 'utf-8');
await copyReadmeToOutput(appPath);
expect(await pathExists(join(appPath, OUTPUT_DIR))).toBe(false);
});
});

View File

@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as esbuild from 'esbuild';
import { FileFolder } from 'twenty-shared/types';
import { EsbuildWatcher } from '@/cli/utilities/build/common/esbuild-watcher';
vi.mock('esbuild', () => ({
context: vi.fn(),
}));
vi.mock('@/cli/utilities/build/common/cleanup-removed-files', () => ({
cleanupRemovedFiles: vi.fn(async () => {}),
}));
const contextMock = vi.mocked(esbuild.context);
const createWatcher = () =>
new EsbuildWatcher({
appPath: '/app',
sourcePaths: [],
watch: false,
handleFileBuilt: vi.fn(),
handleBuildError: vi.fn(),
config: {
externalModules: [],
fileFolder: FileFolder.BuiltFrontComponent,
},
});
describe('EsbuildWatcher', () => {
beforeEach(() => {
vi.clearAllMocks();
contextMock.mockImplementation(async (options: esbuild.BuildOptions) => {
const onEndCallbacks: ((result: esbuild.BuildResult) => void)[] = [];
for (const plugin of options.plugins ?? []) {
plugin.setup({
onEnd: (callback: (result: esbuild.BuildResult) => void) => {
onEndCallbacks.push(callback);
},
} as esbuild.PluginBuild);
}
return {
rebuild: vi.fn(async () => {
for (const callback of onEndCallbacks) {
await callback({
errors: [],
metafile: { inputs: {}, outputs: {} },
} as unknown as esbuild.BuildResult);
}
}),
watch: vi.fn(async () => {}),
dispose: vi.fn(async () => {}),
} as unknown as esbuild.BuildContext;
});
});
it('applies the last requested source paths exactly once when restarted during a restart', async () => {
const watcher = createWatcher();
await watcher.start();
await Promise.all([
watcher.restart(['a.front-component.tsx']),
watcher.restart(['b.front-component.tsx']),
watcher.restart(['c.front-component.tsx']),
]);
expect(contextMock).toHaveBeenCalledTimes(2);
const lastContextOptions = contextMock.mock.calls[1][0];
expect(Object.keys(lastContextOptions.entryPoints ?? {})).toEqual([
'c.front-component',
]);
expect(watcher.shouldRestart(['c.front-component.tsx'])).toBe(false);
});
});

View File

@@ -0,0 +1,248 @@
import crypto from 'crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'path';
import {
NODE_ESM_CJS_BANNER,
OUTPUT_DIR,
type Manifest,
} from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { copyReadmeToOutput } from '@/cli/utilities/build/common/copy-readme-to-output';
import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type';
import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build';
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 { createStubTwentySdkDefinePlugin } from '@/cli/utilities/build/common/plugins/stub-twenty-sdk-define.plugin';
import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
import { buildSharedDependenciesBundle } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/build-shared-dependencies-bundle';
import { type SharedDependenciesBuildContext } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-build-context.type';
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
import { loadFrontComponentTranslationCatalogs } from '@/cli/utilities/translations/load-front-component-translation-catalogs';
import {
copy,
emptyDir,
ensureDir,
pathExists,
pathExistsSync,
} from '@/cli/utilities/file/fs-utils';
import { FRONT_COMPONENT_TRANSLATIONS_KEY } from '@/sdk/front-component/constants/front-component-translations-key';
export type AppBuildOptions = {
appPath: string;
manifest: Manifest;
filePaths: EntityFilePaths;
generatedAssets?: GeneratedAsset[];
};
export type BuiltFileInfo = {
checksum: string;
builtPath: string;
sourcePath: string;
fileFolder: FileFolder;
usesSdkClient?: boolean;
};
export type AppBuildResult = {
builtFileInfos: Map<string, BuiltFileInfo>;
};
export const buildApplication = async (
options: AppBuildOptions,
): Promise<AppBuildResult> => {
const outputDir = join(options.appPath, OUTPUT_DIR);
await ensureDir(outputDir);
await emptyDir(outputDir);
const builtFileInfos = new Map<string, BuiltFileInfo>();
const collectFileBuilt: OnFileBuiltCallback = (event) => {
builtFileInfos.set(event.builtPath, {
checksum: event.checksum,
builtPath: event.builtPath,
sourcePath: event.sourcePath,
fileFolder: event.fileFolder,
usesSdkClient: event.usesSdkClient,
});
};
const { logicFunctions, frontComponents } = options.filePaths;
const sharedDependencies =
options.manifest.application.frontComponentSharedDependencies;
// Bake the app's compiled translation catalogs into every front-component
// bundle so the runtime t()/<Trans> resolves them in the sandboxed worker
// without a server round-trip. Omitted entirely when the app has no
// translations, leaving the runtime to fall back to source strings.
const frontComponentTranslationCatalogs =
await loadFrontComponentTranslationCatalogs(options.appPath);
const frontComponentTranslationsBanner = Object.keys(
frontComponentTranslationCatalogs,
).length
? {
js: `globalThis[${JSON.stringify(FRONT_COMPONENT_TRANSLATIONS_KEY)}]=${JSON.stringify(
frontComponentTranslationCatalogs,
)};`,
}
: undefined;
await esbuildOneShotBuild({
appPath: options.appPath,
sourcePaths: logicFunctions,
fileFolder: FileFolder.BuiltLogicFunction,
buildOptions: {
bundle: true,
splitting: false,
format: 'esm',
platform: 'node',
outdir: join(options.appPath, OUTPUT_DIR),
outExtension: { '.js': '.mjs' },
external: LOGIC_FUNCTION_EXTERNAL_MODULES,
tsconfig: join(options.appPath, 'tsconfig.json'),
sourcemap: true,
metafile: true,
logLevel: 'silent',
banner: NODE_ESM_CJS_BANNER,
plugins: [createStubTwentySdkDefinePlugin()],
},
onFileBuilt: collectFileBuilt,
});
const sharedDependenciesBuildContext: SharedDependenciesBuildContext | null =
isDefined(sharedDependencies)
? await buildSharedDependenciesBundle({
appPath: options.appPath,
sharedDependencies,
onFileBuilt: collectFileBuilt,
})
: null;
await esbuildOneShotBuild({
appPath: options.appPath,
sourcePaths: frontComponents,
fileFolder: FileFolder.BuiltFrontComponent,
buildOptions: {
...getBaseFrontComponentBuildOptions(),
outdir: join(options.appPath, OUTPUT_DIR),
tsconfig: join(options.appPath, 'tsconfig.json'),
jsx: 'automatic',
sourcemap: true,
metafile: true,
logLevel: 'silent',
...(frontComponentTranslationsBanner !== undefined
? { banner: frontComponentTranslationsBanner }
: {}),
plugins: [
...getFrontComponentBuildPlugins({
getSharedDependenciesBuildContext: () =>
sharedDependenciesBuildContext,
}),
createStubTwentySdkDefinePlugin(),
],
},
onFileBuilt: collectFileBuilt,
});
await copyStaticFiles({
appPath: options.appPath,
fileFolder: FileFolder.Source,
filePaths: [...new Set([...logicFunctions, ...frontComponents])],
collectFileBuilt,
});
await copyStaticFiles({
appPath: options.appPath,
fileFolder: FileFolder.PublicAsset,
filePaths: options.filePaths.publicAssets,
collectFileBuilt,
});
await copyStaticFiles({
appPath: options.appPath,
fileFolder: FileFolder.Dependencies,
filePaths: ['package.json', 'yarn.lock'].filter((filePath) =>
pathExistsSync(join(options.appPath, filePath)),
),
collectFileBuilt,
});
for (const generatedAsset of options.generatedAssets ?? []) {
await writeGeneratedAsset({
appPath: options.appPath,
generatedAsset,
collectFileBuilt,
});
}
await copyReadmeToOutput(options.appPath);
return { builtFileInfos };
};
const writeGeneratedAsset = async ({
appPath,
generatedAsset,
collectFileBuilt,
}: {
appPath: string;
generatedAsset: GeneratedAsset;
collectFileBuilt: OnFileBuiltCallback;
}) => {
const builtPath = join(OUTPUT_DIR, generatedAsset.relativePath);
const absoluteBuiltPath = join(appPath, builtPath);
await ensureDir(dirname(absoluteBuiltPath));
await writeFile(absoluteBuiltPath, generatedAsset.content);
const checksum = crypto
.createHash('md5')
.update(generatedAsset.content)
.digest('hex');
collectFileBuilt({
fileFolder: FileFolder.PublicAsset,
builtPath,
sourcePath: generatedAsset.relativePath,
checksum,
});
};
const copyStaticFiles = async ({
appPath,
fileFolder,
filePaths,
collectFileBuilt,
}: {
appPath: string;
fileFolder: FileFolder;
filePaths: string[];
collectFileBuilt: OnFileBuiltCallback;
}) => {
for (const sourcePath of filePaths) {
const absoluteSourcePath = join(appPath, sourcePath);
if (!(await pathExists(absoluteSourcePath))) {
continue;
}
const builtPath = join(OUTPUT_DIR, sourcePath);
const absoluteBuiltPath = join(appPath, builtPath);
await ensureDir(dirname(absoluteBuiltPath));
await copy(absoluteSourcePath, absoluteBuiltPath);
const content = await readFile(absoluteBuiltPath);
const checksum = crypto.createHash('md5').update(content).digest('hex');
collectFileBuilt({
fileFolder,
builtPath,
sourcePath,
checksum,
});
}
};

View File

@@ -0,0 +1,21 @@
import path from 'path';
import { remove } from '@/cli/utilities/file/fs-utils';
export const cleanupRemovedFiles = async (
outputDir: string,
oldPaths: string[],
newPaths: string[],
): Promise<void> => {
const newPathsSet = new Set(newPaths);
const removedPaths = oldPaths.filter((p) => !newPathsSet.has(p));
for (const removedPath of removedPaths) {
const outputBaseName = removedPath.replace(/\.tsx?$/, '.mjs');
const outputFile = path.join(outputDir, outputBaseName);
const sourceMapFile = `${outputFile}.map`;
await remove(outputFile);
await remove(sourceMapFile);
}
};

View File

@@ -0,0 +1,11 @@
import {
defineCommandMenuItem,
numberOfSelectedRecords,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'comparison-operator-cmd',
label: 'Comparison Operator',
frontComponentUniversalIdentifier: 'comparison-operator',
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
});

View File

@@ -0,0 +1,17 @@
import {
defineCommandMenuItem,
none,
numberOfSelectedRecords,
objectPermissions,
selectedRecords,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'complex-soft-delete-cmd',
label: 'Complex Soft Delete',
frontComponentUniversalIdentifier: 'complex-soft-delete',
conditionalAvailabilityExpression:
objectPermissions.canSoftDeleteObjectRecords &&
none(selectedRecords, 'isRemote') &&
numberOfSelectedRecords > 0,
});

View File

@@ -0,0 +1,12 @@
import {
defineCommandMenuItem,
selectedRecords,
someDefined,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'custom-function-cmd',
label: 'Custom Function',
frontComponentUniversalIdentifier: 'custom-function',
conditionalAvailabilityExpression: someDefined(selectedRecords, 'deletedAt'),
});

View File

@@ -0,0 +1,14 @@
import {
defineCommandMenuItem,
featureFlags,
objectPermissions,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'feature-flag-gated-cmd',
label: 'Feature Flag Gated',
frontComponentUniversalIdentifier: 'feature-flag-gated',
conditionalAvailabilityExpression:
featureFlags.IS_JUNCTION_RELATIONS_ENABLED &&
objectPermissions.canReadObjectRecords,
});

View File

@@ -0,0 +1,15 @@
import {
defineCommandMenuItem,
favoriteRecordIds,
objectMetadataItem,
pageType,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'parenthesized-expression-cmd',
label: 'Parenthesized Expression',
frontComponentUniversalIdentifier: 'parenthesized-expression',
conditionalAvailabilityExpression:
(pageType === 'RECORD_PAGE' || favoriteRecordIds.length > 0) &&
!objectMetadataItem.isRemote,
});

View File

@@ -0,0 +1,13 @@
import {
defineCommandMenuItem,
isInSidePanel,
objectPermissions,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'permissions-check-cmd',
label: 'Permissions Check',
frontComponentUniversalIdentifier: 'permissions-check',
conditionalAvailabilityExpression:
objectPermissions.canUpdateObjectRecords && !isInSidePanel,
});

View File

@@ -0,0 +1,8 @@
import { defineCommandMenuItem, pageType } from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'simple-boolean-cmd',
label: 'Simple Boolean',
frontComponentUniversalIdentifier: 'simple-boolean',
conditionalAvailabilityExpression: pageType === 'RECORD_PAGE',
});

View File

@@ -0,0 +1,15 @@
import {
defineCommandMenuItem,
everyEquals,
pageType,
selectedRecords,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'string-comparison-cmd',
label: 'String Comparison',
frontComponentUniversalIdentifier: 'string-comparison',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' &&
everyEquals(selectedRecords, 'company.name', 'apple'),
});

View File

@@ -0,0 +1,13 @@
import {
defineCommandMenuItem,
pageType,
targetObjectWritePermissions,
} from '@/sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'target-permissions-cmd',
label: 'Target Permissions',
frontComponentUniversalIdentifier: 'target-permissions',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' && targetObjectWritePermissions.person,
});

View File

@@ -0,0 +1,107 @@
import { toExprEval } from '@/cli/utilities/build/common/conditional-availability/utils/to-expr-eval';
describe('toExprEval', () => {
describe('strict equality (===) to loose equality (==)', () => {
it('should convert a single ===', () => {
expect(toExprEval('a === b')).toBe('a == b');
});
it('should convert multiple ===', () => {
expect(toExprEval('a === b === c')).toBe('a == b == c');
});
});
describe('strict inequality (!==) to loose inequality (!=)', () => {
it('should convert a single !==', () => {
expect(toExprEval('a !== b')).toBe('a != b');
});
it('should convert multiple !==', () => {
expect(toExprEval('a !== 0 !== b')).toBe('a != 0 != b');
});
});
describe('logical AND (&&) to and', () => {
it('should convert a single &&', () => {
expect(toExprEval('a && b')).toBe('a and b');
});
it('should convert multiple &&', () => {
expect(toExprEval('a && b && c')).toBe('a and b and c');
});
});
describe('logical OR (||) to or', () => {
it('should convert a single ||', () => {
expect(toExprEval('a || b')).toBe('a or b');
});
it('should convert multiple ||', () => {
expect(toExprEval('a || b || c')).toBe('a or b or c');
});
});
describe('logical NOT (!) to not', () => {
it('should convert a prefix !', () => {
expect(toExprEval('!a')).toBe('not a');
});
it('should convert multiple prefix !', () => {
expect(toExprEval('!a && !b')).toBe('not a and not b');
});
it('should not convert ! that is part of !==', () => {
expect(toExprEval('a !== b')).toBe('a != b');
});
it('should not convert ! that is part of !=', () => {
expect(toExprEval('a != b')).toBe('a != b');
});
});
describe('combined operators', () => {
it('should convert a complex expression with all operators', () => {
expect(toExprEval('a && !b || c === 1 && d !== 0')).toBe(
'a and not b or c == 1 and d != 0',
);
});
it('should handle parenthesized expressions', () => {
expect(toExprEval('(a || b) && !c')).toBe('(a or b) and not c');
});
it('should handle nested property access', () => {
expect(toExprEval('obj.prop === true && !obj.other')).toBe(
'obj.prop == true and not obj.other',
);
});
});
describe('array .length to arrayLength()', () => {
it('should convert favoriteRecordIds.length to arrayLength(favoriteRecordIds)', () => {
expect(toExprEval('favoriteRecordIds.length > 0')).toBe(
'arrayLength(favoriteRecordIds) > 0',
);
});
it('should convert selectedRecords.length to arrayLength(selectedRecords)', () => {
expect(toExprEval('selectedRecords.length === 1')).toBe(
'arrayLength(selectedRecords) == 1',
);
});
});
describe('edge cases', () => {
it('should return an empty string unchanged', () => {
expect(toExprEval('')).toBe('');
});
it('should return a plain identifier unchanged', () => {
expect(toExprEval('isShowPage')).toBe('isShowPage');
});
it('should leave numeric comparisons intact', () => {
expect(toExprEval('count > 0')).toBe('count > 0');
});
});
});

View File

@@ -0,0 +1,502 @@
import * as fs from 'fs';
import * as path from 'path';
import { transformConditionalAvailabilityExpressionsForEsBuildPlugin } from '@/cli/utilities/build/common/conditional-availability/utils/transform-conditional-availability-expressions';
import {
ContextStorePageType,
type CommandMenuContextApi,
} from 'twenty-shared/types';
import { evaluateConditionalAvailabilityExpression } from 'twenty-shared/utils';
const MOCKS_DIR = path.join(__dirname, '__mocks__');
const readMock = (filename: string): string =>
fs.readFileSync(path.join(MOCKS_DIR, filename), 'utf8');
const buildMockCommandMenuContextApi = (
overrides: Partial<CommandMenuContextApi> = {},
): CommandMenuContextApi => ({
pageType: ContextStorePageType.Index,
isInSidePanel: false,
isDashboardPageLayoutInEditMode: false,
isLayoutCustomizationModeEnabled: false,
favoriteRecordIds: [],
isSelectAll: false,
hasAnySoftDeleteFilterOnView: false,
numberOfSelectedRecords: 0,
objectPermissions: {
objectMetadataId: 'test-metadata-id',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
selectedRecords: [],
featureFlags: {},
permissionFlags: {},
targetObjectReadPermissions: {},
targetObjectWritePermissions: {},
canImpersonate: false,
canAccessFullAdminPanel: false,
objectMetadataItem: {},
objectMetadataLabel: '',
...overrides,
});
const CONDITIONAL_AVAILABILITY_EXPRESSION_REGEX =
/conditionalAvailabilityExpression\s*:\s*("(?:[^"\\]|\\.)*")/;
const extractConditionalAvailabilityExpressionFromTransformedSource = (
transformedSource: string,
): string | undefined => {
const match = transformedSource.match(
CONDITIONAL_AVAILABILITY_EXPRESSION_REGEX,
);
if (!match?.[1]) {
return undefined;
}
return JSON.parse(match[1]) as string;
};
const transformMockAndEvaluate = (
filename: string,
context: CommandMenuContextApi,
): boolean => {
const source = readMock(filename);
const transformedSourceForEsBuildPlugin =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
const conditionalAvailabilityExpression =
extractConditionalAvailabilityExpressionFromTransformedSource(
transformedSourceForEsBuildPlugin,
);
return evaluateConditionalAvailabilityExpression(
conditionalAvailabilityExpression,
context,
);
};
describe('transformConditionalAvailabilityExpressionsForEsBuildPlugin', () => {
describe('transform correctness', () => {
it('should convert && to and', () => {
const source = '{ conditionalAvailabilityExpression: a && b }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a and b"}');
});
it('should convert || to or', () => {
const source = '{ conditionalAvailabilityExpression: a || b }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a or b"}');
});
it('should convert ! to not', () => {
const source = '{ conditionalAvailabilityExpression: !a }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "not a"}');
});
it('should convert === to ==', () => {
const source = '{ conditionalAvailabilityExpression: a === 1 }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a == 1"}');
});
it('should convert !== to !=', () => {
const source = '{ conditionalAvailabilityExpression: a !== 0 }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe('{ conditionalAvailabilityExpression: "a != 0"}');
});
it('should convert all operators in a complex expression', () => {
const source =
'{ conditionalAvailabilityExpression: a && !b || c === 1 && d !== 0 }';
const result =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(result).toBe(
'{ conditionalAvailabilityExpression: "a and not b or c == 1 and d != 0"}',
);
});
});
describe('regex edge cases', () => {
it('should skip already-quoted string expressions without leading space', () => {
const source = '{ conditionalAvailabilityExpression:"already quoted" }';
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toBe(source);
});
it('should skip single-quoted string expressions without leading space', () => {
const source = "{ conditionalAvailabilityExpression:'already quoted' }";
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toBe(source);
});
it('should skip template literal expressions without leading space', () => {
const source = '{ conditionalAvailabilityExpression:`already quoted` }';
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toBe(source);
});
it('should handle multiple expressions in one source', () => {
const source = [
'const a = { conditionalAvailabilityExpression: pageType === "RECORD_PAGE" };',
'const b = { conditionalAvailabilityExpression: favoriteRecordIds.length > 0 && !objectMetadataItem.isRemote };',
].join('\n');
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toContain(
'conditionalAvailabilityExpression: "pageType == \\"RECORD_PAGE\\""',
);
expect(transformed).toContain(
'conditionalAvailabilityExpression: "arrayLength(favoriteRecordIds) > 0 and not objectMetadataItem.isRemote"',
);
});
it('should handle extra spaces around colon', () => {
const source =
'{ conditionalAvailabilityExpression : pageType === "RECORD_PAGE" && favoriteRecordIds.length > 0 }';
const transformed =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
expect(transformed).toContain(
'"pageType == \\"RECORD_PAGE\\" and arrayLength(favoriteRecordIds) > 0"',
);
});
});
describe('e2e: mock file -> transform -> evaluate', () => {
describe('simple-boolean-front-component', () => {
it('should evaluate pageType when RECORD_PAGE', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Record,
});
expect(
transformMockAndEvaluate(
'simple-boolean-front-component.ts',
context,
),
).toBe(true);
});
it('should evaluate pageType when INDEX_PAGE', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Index,
});
expect(
transformMockAndEvaluate(
'simple-boolean-front-component.ts',
context,
),
).toBe(false);
});
});
describe('permissions-check-front-component', () => {
it('should allow when has permission and not in side panel', () => {
const context = buildMockCommandMenuContextApi({
isInSidePanel: false,
});
expect(
transformMockAndEvaluate(
'permissions-check-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when in side panel', () => {
const context = buildMockCommandMenuContextApi({
isInSidePanel: true,
});
expect(
transformMockAndEvaluate(
'permissions-check-front-component.ts',
context,
),
).toBe(false);
});
});
describe('comparison-operator-front-component', () => {
it('should allow when records are selected', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 3,
});
expect(
transformMockAndEvaluate(
'comparison-operator-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when no records are selected', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 0,
});
expect(
transformMockAndEvaluate(
'comparison-operator-front-component.ts',
context,
),
).toBe(false);
});
});
describe('feature-flag-gated-front-component', () => {
it('should allow when feature flag is enabled', () => {
const context = buildMockCommandMenuContextApi({
featureFlags: { IS_JUNCTION_RELATIONS_ENABLED: true },
});
expect(
transformMockAndEvaluate(
'feature-flag-gated-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when feature flag is disabled', () => {
const context = buildMockCommandMenuContextApi({
featureFlags: { IS_JUNCTION_RELATIONS_ENABLED: false },
});
expect(
transformMockAndEvaluate(
'feature-flag-gated-front-component.ts',
context,
),
).toBe(false);
});
});
describe('complex-soft-delete-front-component', () => {
it('should allow soft delete for local record with selection', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 2,
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
isRemote: false,
},
],
});
expect(
transformMockAndEvaluate(
'complex-soft-delete-front-component.ts',
context,
),
).toBe(true);
});
it('should deny soft delete when record is remote', () => {
const context = buildMockCommandMenuContextApi({
numberOfSelectedRecords: 1,
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
isRemote: true,
},
],
});
expect(
transformMockAndEvaluate(
'complex-soft-delete-front-component.ts',
context,
),
).toBe(false);
});
});
describe('parenthesized-expression-front-component', () => {
it('should allow when favorite and not remote', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Index,
favoriteRecordIds: ['rec-1'],
objectMetadataItem: { isRemote: false },
});
expect(
transformMockAndEvaluate(
'parenthesized-expression-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when remote even if record page', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Record,
favoriteRecordIds: [],
objectMetadataItem: { isRemote: true },
});
expect(
transformMockAndEvaluate(
'parenthesized-expression-front-component.ts',
context,
),
).toBe(false);
});
});
describe('custom-function-front-component', () => {
it('should return false when deletedAt is null', () => {
const context = buildMockCommandMenuContextApi({
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
},
],
});
expect(
transformMockAndEvaluate(
'custom-function-front-component.ts',
context,
),
).toBe(false);
});
it('should return true when deletedAt is set', () => {
const context = buildMockCommandMenuContextApi({
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: '2024-06-01',
},
],
});
expect(
transformMockAndEvaluate(
'custom-function-front-component.ts',
context,
),
).toBe(true);
});
});
describe('string-comparison-front-component', () => {
it('should match when on record page and company name matches', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Record,
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
company: { name: 'apple' },
},
],
});
expect(
transformMockAndEvaluate(
'string-comparison-front-component.ts',
context,
),
).toBe(true);
});
it('should not match when company name differs', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Record,
selectedRecords: [
{
id: 'rec-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
deletedAt: null,
company: { name: 'google' },
},
],
});
expect(
transformMockAndEvaluate(
'string-comparison-front-component.ts',
context,
),
).toBe(false);
});
});
describe('target-permissions-front-component', () => {
it('should allow when on record page with write permission', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Record,
targetObjectWritePermissions: { person: true },
});
expect(
transformMockAndEvaluate(
'target-permissions-front-component.ts',
context,
),
).toBe(true);
});
it('should deny when not on record page', () => {
const context = buildMockCommandMenuContextApi({
pageType: ContextStorePageType.Index,
targetObjectWritePermissions: { person: true },
});
expect(
transformMockAndEvaluate(
'target-permissions-front-component.ts',
context,
),
).toBe(false);
});
});
});
});

View File

@@ -0,0 +1,29 @@
import type * as esbuild from 'esbuild';
import * as fs from 'fs/promises';
import { transformConditionalAvailabilityExpressionsForEsBuildPlugin } from './utils/transform-conditional-availability-expressions';
export const conditionalAvailabilityTransformPlugin: esbuild.Plugin = {
name: 'conditional-availability-transform',
setup: (build) => {
build.onLoad({ filter: /\.tsx?$/ }, async (args) => {
const source = await fs.readFile(args.path, 'utf8');
if (!source.includes('conditionalAvailabilityExpression')) {
return null;
}
const transformedSource =
transformConditionalAvailabilityExpressionsForEsBuildPlugin(source);
if (transformedSource === source) {
return null;
}
return {
contents: transformedSource,
loader: args.path.endsWith('.tsx') ? 'tsx' : 'ts',
};
});
},
};

View File

@@ -0,0 +1,16 @@
const ARRAY_LENGTH_REPLACEMENTS: [RegExp, string][] = [
[/\bfavoriteRecordIds\.length\b/g, 'arrayLength(favoriteRecordIds)'],
[/\bselectedRecords\.length\b/g, 'arrayLength(selectedRecords)'],
];
export const toExprEval = (raw: string): string =>
ARRAY_LENGTH_REPLACEMENTS.reduce(
(acc, [pattern, replacement]) => acc.replace(pattern, replacement),
raw
.replace(/!==/g, '!=')
.replace(/===/g, '==')
.replace(/&&/g, 'and')
.replace(/\|\|/g, 'or')
.replace(/!(?!=)/g, 'not ')
.replace(/,(\s*\))/g, '$1'),
);

View File

@@ -0,0 +1,13 @@
import { toExprEval } from './to-expr-eval';
const CONDITIONAL_AVAILABILITY_EXPRESSION_PATTERN =
/(conditionalAvailabilityExpression\s*:\s*)(?!['"`])((?:[^,}()]|\([^()]*\))+)/g;
export const transformConditionalAvailabilityExpressionsForEsBuildPlugin = (
source: string,
): string =>
source.replace(
CONDITIONAL_AVAILABILITY_EXPRESSION_PATTERN,
(_, prefix: string, rawExpression: string) =>
prefix + JSON.stringify(toExprEval(rawExpression.trim())),
);

View File

@@ -0,0 +1,35 @@
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { copy, ensureDir } from '@/cli/utilities/file/fs-utils';
// npm only ships a README when the file lives in the package root, so the
// app's readme has to be copied into the build output that gets published.
const README_FILE_NAME_REGEX = /^readme(\.[^.]+)?$/i;
export const findReadmeFileName = (
entries: string[],
): string | undefined => {
const readmeFileNames = entries.filter((entry) =>
README_FILE_NAME_REGEX.test(entry),
);
// Prefer markdown, matching how npm ranks README candidates.
return (
readmeFileNames.find((entry) => /\.md$/i.test(entry)) ?? readmeFileNames[0]
);
};
export const copyReadmeToOutput = async (appPath: string): Promise<void> => {
const readmeFileName = findReadmeFileName(await readdir(appPath));
if (readmeFileName === undefined) {
return;
}
const outputDir = join(appPath, OUTPUT_DIR);
await ensureDir(outputDir);
await copy(join(appPath, readmeFileName), join(outputDir, readmeFileName));
};

View File

@@ -0,0 +1,45 @@
import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor';
import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
import * as esbuild from 'esbuild';
import path from 'path';
import { type FileFolder } from 'twenty-shared/types';
export type EsbuildOneShotBuildOptions = {
appPath: string;
sourcePaths: string[];
fileFolder: FileFolder;
buildOptions: esbuild.BuildOptions;
onFileBuilt: OnFileBuiltCallback;
};
export const esbuildOneShotBuild = async ({
appPath,
sourcePaths,
fileFolder,
buildOptions,
onFileBuilt,
}: EsbuildOneShotBuildOptions): Promise<void> => {
if (sourcePaths.length === 0) {
return;
}
const entryPoints: Record<string, string> = {};
for (const sourcePath of sourcePaths) {
const entryName = sourcePath.replace(/\.tsx?$/, '');
entryPoints[entryName] = path.join(appPath, sourcePath);
}
const result = await esbuild.build({
...buildOptions,
entryPoints,
});
await processEsbuildResult({
result,
appPath,
fileFolder,
lastChecksums: new Map(),
onFileBuilt,
});
};

View File

@@ -0,0 +1,70 @@
import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
import crypto from 'crypto';
import type * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';
import path from 'path';
import { FileFolder } from 'twenty-shared/types';
const SDK_CLIENT_IMPORT_PREFIX = 'twenty-client-sdk';
export type ProcessEsbuildResultParams = {
result: esbuild.BuildResult;
appPath: string;
fileFolder: FileFolder;
lastChecksums: Map<string, string>;
onFileBuilt?: OnFileBuiltCallback;
};
export const processEsbuildResult = async ({
result,
appPath,
fileFolder,
lastChecksums,
onFileBuilt,
}: ProcessEsbuildResultParams) => {
const outputFiles = Object.keys(result.metafile?.outputs ?? {}).filter(
(file) => file.endsWith('.mjs'),
);
for (const outputFile of outputFiles) {
const absoluteBuiltFile = path.resolve(outputFile);
const relativeBuiltPath = path.relative(appPath, absoluteBuiltFile);
const absoluteSourcePath =
result.metafile?.outputs?.[outputFile]?.entryPoint || '';
const relativeSourcePath = path.relative(appPath, absoluteSourcePath);
const content = await readFile(absoluteBuiltFile);
const checksumAlgorithm =
fileFolder === FileFolder.BuiltFrontComponent ? 'sha256' : 'md5';
const checksum = crypto
.createHash(checksumAlgorithm)
.update(content)
.digest('hex');
const lastChecksum = lastChecksums.get(relativeBuiltPath);
if (lastChecksum === checksum) {
continue;
}
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

@@ -0,0 +1,222 @@
import { cleanupRemovedFiles } from '@/cli/utilities/build/common/cleanup-removed-files';
import { processEsbuildResult } from '@/cli/utilities/build/common/esbuild-result-processor';
import { createStubTwentySdkDefinePlugin } from '@/cli/utilities/build/common/plugins/stub-twenty-sdk-define.plugin';
import {
type OnBuildErrorCallback,
type OnFileBuiltCallback,
type RestartableWatcher,
type RestartableWatcherOptions,
} from '@/cli/utilities/build/common/restartable-watcher-interface';
import { createTypecheckPlugin } from '@/cli/utilities/build/common/typecheck-plugin';
import * as esbuild from 'esbuild';
import path from 'path';
import { NODE_ESM_CJS_BANNER, OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const LOGIC_FUNCTION_EXTERNAL_MODULES: string[] = [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
'path',
'fs',
'crypto',
'stream',
'util',
'os',
'url',
'http',
'https',
'events',
'buffer',
'querystring',
'assert',
'zlib',
'net',
'tls',
'child_process',
'worker_threads',
];
export type EsbuildWatcherConfig = {
externalModules: string[];
fileFolder: FileFolder;
platform?: esbuild.Platform;
jsx?: 'automatic';
extraPlugins?: esbuild.Plugin[];
minify?: boolean;
banner?: esbuild.BuildOptions['banner'];
};
export type EsbuildWatcherOptions = RestartableWatcherOptions & {
config: EsbuildWatcherConfig;
};
export class EsbuildWatcher implements RestartableWatcher {
private appPath: string;
private sourcePaths: string[];
private esBuildContext: esbuild.BuildContext | null = null;
private isRestarting = false;
private pendingRestartSourcePaths: string[] | null = null;
private watchMode: boolean;
private lastChecksums: Map<string, string> = new Map();
private onFileBuilt?: OnFileBuiltCallback;
private onBuildError?: OnBuildErrorCallback;
private buildCompletePromise: Promise<void> = Promise.resolve();
private resolveBuildComplete: (() => void) | null = null;
private config: EsbuildWatcherConfig;
constructor(options: EsbuildWatcherOptions) {
this.appPath = options.appPath;
this.sourcePaths = options.sourcePaths;
this.watchMode = options.watch ?? true;
this.onFileBuilt = options.handleFileBuilt;
this.onBuildError = options.handleBuildError;
this.config = options.config;
}
shouldRestart(sourcePaths: string[]): boolean {
const currentPaths = this.sourcePaths.sort().join(',');
const newPaths = [...sourcePaths].sort().join(',');
return currentPaths !== newPaths;
}
async start(): Promise<void> {
if (this.sourcePaths.length > 0) {
await this.createContext();
}
}
async close(): Promise<void> {
await this.esBuildContext?.dispose();
this.esBuildContext = null;
}
async restart(sourcePaths: string[]): Promise<void> {
if (this.isRestarting) {
this.pendingRestartSourcePaths = sourcePaths;
return;
}
this.isRestarting = true;
try {
await this.close();
const outputDir = path.join(this.appPath, OUTPUT_DIR);
await cleanupRemovedFiles(outputDir, this.sourcePaths, sourcePaths);
this.sourcePaths = sourcePaths;
this.lastChecksums.clear();
if (this.sourcePaths.length > 0) {
await this.createContext();
}
} finally {
this.isRestarting = false;
const pendingRestartSourcePaths = this.pendingRestartSourcePaths;
if (isDefined(pendingRestartSourcePaths)) {
this.pendingRestartSourcePaths = null;
await this.restart(pendingRestartSourcePaths);
}
}
}
private async createContext(): Promise<void> {
const outputDir = path.join(this.appPath, OUTPUT_DIR);
const entryPoints: Record<string, string> = {};
for (const sourcePath of this.sourcePaths) {
const entryName = sourcePath.replace(/\.tsx?$/, '');
entryPoints[entryName] = path.join(this.appPath, sourcePath);
}
const watcher = this;
const plugins: esbuild.Plugin[] = [
...(this.config.extraPlugins ?? []),
{
name: 'build-notifications',
setup: (build) => {
build.onEnd(async (result) => {
try {
if (result.errors.length > 0) {
if (!result.errors[0].text.includes('Could not resolve')) {
await this.onBuildError?.(
result.errors.map((err) => ({
error: err.text,
location: err.location,
})),
);
}
return;
}
await processEsbuildResult({
result,
appPath: this.appPath,
fileFolder: this.config.fileFolder,
lastChecksums: watcher.lastChecksums,
onFileBuilt: watcher.onFileBuilt,
});
} finally {
watcher.resolveBuildComplete?.();
}
});
},
},
];
this.esBuildContext = await esbuild.context({
entryPoints,
bundle: true,
splitting: false,
format: 'esm',
platform: this.config.platform,
outdir: outputDir,
outExtension: { '.js': '.mjs' },
external: this.config.externalModules,
tsconfig: path.join(this.appPath, 'tsconfig.json'),
jsx: this.config.jsx,
sourcemap: true,
metafile: true,
logLevel: 'silent',
minify: this.config.minify,
banner: this.config.banner,
plugins,
});
this.buildCompletePromise = new Promise<void>((resolve) => {
this.resolveBuildComplete = resolve;
});
await this.esBuildContext.rebuild();
await this.buildCompletePromise;
if (this.watchMode) {
await this.esBuildContext.watch();
}
}
}
export type EsbuildWatcherFactoryOptions = RestartableWatcherOptions & {
shouldSkipTypecheck: () => boolean;
};
export const createLogicFunctionsWatcher = (
options: EsbuildWatcherFactoryOptions,
): EsbuildWatcher =>
new EsbuildWatcher({
...options,
config: {
externalModules: LOGIC_FUNCTION_EXTERNAL_MODULES,
fileFolder: FileFolder.BuiltLogicFunction,
platform: 'node',
extraPlugins: [
createTypecheckPlugin(options.appPath, options.shouldSkipTypecheck),
createStubTwentySdkDefinePlugin(),
],
banner: NODE_ESM_CJS_BANNER,
},
});

View File

@@ -0,0 +1,108 @@
import chokidar, { type FSWatcher } from 'chokidar';
import crypto from 'crypto';
import { readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
import { type FileFolder } from 'twenty-shared/types';
import {
copy,
ensureDir,
pathExists,
remove,
} from '@/cli/utilities/file/fs-utils';
export type AssetWatcherOptions = {
appPath: string;
fileFolder: FileFolder;
watchPaths: string[];
handleFileBuilt: (options: {
fileFolder: FileFolder;
builtPath: string;
sourcePath: string;
checksum: string;
}) => void;
};
export class FileUploadWatcher {
private appPath: string;
private watcher: FSWatcher | null = null;
private fileFolder: FileFolder;
private watchPaths: string[];
private handleFileBuilt: AssetWatcherOptions['handleFileBuilt'];
constructor(options: AssetWatcherOptions) {
this.appPath = options.appPath;
this.fileFolder = options.fileFolder;
this.watchPaths = options.watchPaths;
this.handleFileBuilt = options.handleFileBuilt;
}
async start(): Promise<void> {
const rootPaths = (
await Promise.all(
this.watchPaths.map(async (watchPath) => {
const fullPath = join(this.appPath, watchPath);
return (await pathExists(fullPath)) ? fullPath : null;
}),
)
).filter((p): p is string => p !== null);
if (rootPaths.length === 0) {
return;
}
this.watcher = chokidar.watch(rootPaths, {
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 50,
},
});
this.watcher.on('all', (event, filePath) => {
if (event === 'addDir' || event === 'unlinkDir') {
return;
}
if (event === 'add' || event === 'change') {
void this.copyAndNotify(filePath);
}
if (event === 'unlink') {
void this.handleUnlink(filePath);
}
});
}
async close(): Promise<void> {
await this.watcher?.close();
}
private async copyAndNotify(absoluteFilePath: string): Promise<void> {
const sourcePath = relative(this.appPath, absoluteFilePath);
const outputPath = join(OUTPUT_DIR, sourcePath);
const absoluteOutputPath = join(this.appPath, outputPath);
await ensureDir(dirname(absoluteOutputPath));
await copy(absoluteFilePath, absoluteOutputPath);
const content = await readFile(absoluteOutputPath);
const checksum = crypto.createHash('md5').update(content).digest('hex');
this.handleFileBuilt({
fileFolder: this.fileFolder,
builtPath: outputPath,
sourcePath,
checksum,
});
}
private async handleUnlink(absoluteFilePath: string): Promise<void> {
const sourcePath = relative(this.appPath, absoluteFilePath);
const builtPath = join(OUTPUT_DIR, sourcePath);
const absoluteBuiltPath = join(this.appPath, builtPath);
await remove(absoluteBuiltPath);
}
}

View File

@@ -0,0 +1,33 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'path';
import * as esbuild from 'esbuild';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options';
describe('front-component build CSS injection', () => {
it('inlines an imported twenty-ui/style.css as a runtime document.head style injection', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'css-injection-'));
try {
await esbuild.build({
...getBaseFrontComponentBuildOptions(),
entryPoints: [join(MINIMAL_APP_PATH, 'my.front-component.tsx')],
outdir: outputDir,
});
const output = await readFile(
join(outputDir, 'my.front-component.mjs'),
'utf-8',
);
expect(output).toContain('document.createElement("style")');
expect(output).toContain('document.head.appendChild');
expect(output).toContain('box-sizing');
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}, 30000);
});

View File

@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileFolder } from 'twenty-shared/types';
import { EsbuildWatcher } from '@/cli/utilities/build/common/esbuild-watcher';
import { FrontComponentsWatcher } from '@/cli/utilities/build/common/front-component-build/front-components-watcher';
import { buildSharedDependenciesBundle } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/build-shared-dependencies-bundle';
vi.mock(
'@/cli/utilities/build/common/front-component-build/shared-dependencies-build/build-shared-dependencies-bundle',
() => ({
buildSharedDependenciesBundle: vi.fn(),
}),
);
vi.mock('@/cli/utilities/file/fs-utils', () => ({
pathExists: vi.fn(async () => false),
}));
vi.mock('@/cli/utilities/build/common/esbuild-watcher', () => ({
EsbuildWatcher: vi.fn(),
}));
const SHARED_DEPENDENCIES_MANIFEST = {
dependencies: ['react'],
builtPath: 'src/sharedDependencies.mjs',
builtChecksum: null,
};
const SOURCE_PATHS = ['src/my.front-component.tsx'];
const buildSharedDependenciesBundleMock = vi.mocked(
buildSharedDependenciesBundle,
);
const esbuildWatcherMock = vi.mocked(EsbuildWatcher);
const createComponentsWatcherInstance = () => ({
start: vi.fn(async () => {}),
restart: vi.fn(async () => {}),
close: vi.fn(async () => {}),
shouldRestart: vi.fn(() => false),
});
let componentsWatcherInstance: ReturnType<
typeof createComponentsWatcherInstance
>;
const mockSharedDependenciesBuildWithChecksum = (checksum: string) => {
buildSharedDependenciesBundleMock.mockImplementation(
async ({ onFileBuilt }) => {
await onFileBuilt?.({
fileFolder: FileFolder.BuiltFrontComponent,
builtPath: 'src/sharedDependencies.mjs',
sourcePath: 'package.json',
checksum,
});
return { exportNamesBySpecifier: new Map() };
},
);
};
const createWatcher = (
sharedDependencies?: typeof SHARED_DEPENDENCIES_MANIFEST,
) =>
new FrontComponentsWatcher({
appPath: '/app',
sourcePaths: SOURCE_PATHS,
sharedDependencies,
shouldSkipTypecheck: () => true,
handleFileBuilt: vi.fn(),
handleBuildError: vi.fn(),
});
const triggerSharedDependenciesBuild = (
watcher: FrontComponentsWatcher,
): Promise<void> =>
(
watcher as unknown as {
requestSharedDependenciesBuild: () => Promise<void>;
}
).requestSharedDependenciesBuild();
describe('FrontComponentsWatcher', () => {
beforeEach(() => {
vi.clearAllMocks();
componentsWatcherInstance = createComponentsWatcherInstance();
esbuildWatcherMock.mockImplementation(function () {
return componentsWatcherInstance as unknown as EsbuildWatcher;
});
mockSharedDependenciesBuildWithChecksum('checksum-1');
});
it('builds the sharedDependencies before starting the component builds, without a restart', async () => {
const watcher = createWatcher(SHARED_DEPENDENCIES_MANIFEST);
await watcher.start();
expect(buildSharedDependenciesBundleMock).toHaveBeenCalledTimes(1);
expect(componentsWatcherInstance.start).toHaveBeenCalledTimes(1);
expect(
buildSharedDependenciesBundleMock.mock.invocationCallOrder[0],
).toBeLessThan(componentsWatcherInstance.start.mock.invocationCallOrder[0]);
expect(componentsWatcherInstance.restart).not.toHaveBeenCalled();
});
it('serializes concurrent build requests into one in flight build and one queued build', async () => {
let concurrentBuildCount = 0;
let maxConcurrentBuildCount = 0;
buildSharedDependenciesBundleMock.mockImplementation(async () => {
concurrentBuildCount += 1;
maxConcurrentBuildCount = Math.max(
maxConcurrentBuildCount,
concurrentBuildCount,
);
await new Promise((resolve) => setTimeout(resolve, 10));
concurrentBuildCount -= 1;
return { exportNamesBySpecifier: new Map() };
});
const watcher = createWatcher(SHARED_DEPENDENCIES_MANIFEST);
await Promise.all([
triggerSharedDependenciesBuild(watcher),
triggerSharedDependenciesBuild(watcher),
triggerSharedDependenciesBuild(watcher),
]);
expect(maxConcurrentBuildCount).toBe(1);
expect(buildSharedDependenciesBundleMock).toHaveBeenCalledTimes(2);
});
it('restarts the components exactly once when a sharedDependencies is added', async () => {
const watcher = createWatcher(undefined);
await watcher.start();
expect(buildSharedDependenciesBundleMock).not.toHaveBeenCalled();
await watcher.restart(SOURCE_PATHS, SHARED_DEPENDENCIES_MANIFEST);
expect(buildSharedDependenciesBundleMock).toHaveBeenCalledTimes(1);
expect(componentsWatcherInstance.restart).toHaveBeenCalledTimes(1);
});
it('restarts the components when a dependency rebuild changes the sharedDependencies checksum', async () => {
const watcher = createWatcher(SHARED_DEPENDENCIES_MANIFEST);
await watcher.start();
mockSharedDependenciesBuildWithChecksum('checksum-2');
await triggerSharedDependenciesBuild(watcher);
expect(componentsWatcherInstance.restart).toHaveBeenCalledTimes(1);
});
it('does not restart the components when the rebuilt sharedDependencies is unchanged', async () => {
const watcher = createWatcher(SHARED_DEPENDENCIES_MANIFEST);
await watcher.start();
await triggerSharedDependenciesBuild(watcher);
expect(componentsWatcherInstance.restart).not.toHaveBeenCalled();
});
it('ignores a sharedDependencies build that was in flight when the manifest changed', async () => {
const handleFileBuilt = vi.fn();
const watcher = new FrontComponentsWatcher({
appPath: '/app',
sourcePaths: SOURCE_PATHS,
sharedDependencies: SHARED_DEPENDENCIES_MANIFEST,
shouldSkipTypecheck: () => true,
handleFileBuilt,
handleBuildError: vi.fn(),
});
await watcher.start();
let resolveInFlightBuild: (() => void) | undefined;
buildSharedDependenciesBundleMock.mockImplementationOnce(
async ({ onFileBuilt }) => {
await new Promise<void>((resolve) => {
resolveInFlightBuild = resolve;
});
await onFileBuilt?.({
fileFolder: FileFolder.BuiltFrontComponent,
builtPath: 'src/sharedDependencies.mjs',
sourcePath: 'package.json',
checksum: 'stale-checksum',
});
return { exportNamesBySpecifier: new Map() };
},
);
const inFlightBuild = triggerSharedDependenciesBuild(watcher);
mockSharedDependenciesBuildWithChecksum('checksum-2');
const restartPromise = watcher.restart(SOURCE_PATHS, {
...SHARED_DEPENDENCIES_MANIFEST,
dependencies: ['react', 'react-dom/client'],
});
resolveInFlightBuild?.();
await Promise.all([inFlightBuild, restartPromise]);
expect(handleFileBuilt).not.toHaveBeenCalledWith(
expect.objectContaining({ checksum: 'stale-checksum' }),
);
expect(handleFileBuilt).toHaveBeenCalledWith(
expect.objectContaining({ checksum: 'checksum-2' }),
);
});
});

View File

@@ -0,0 +1,7 @@
import { FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER } from 'twenty-shared/application';
export const FRONT_COMPONENT_EXTERNAL_MODULES: string[] = [
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER,
];

View File

@@ -0,0 +1,24 @@
import { readFile } from 'node:fs/promises';
import type * as esbuild from 'esbuild';
const buildStyleInjectionModule = (cssText: string): string =>
`if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
document.head.appendChild(styleElement);
styleElement.textContent = ${JSON.stringify(cssText)};
}`;
export const cssInjectionPlugin: esbuild.Plugin = {
name: 'css-injection',
setup: (build) => {
build.onLoad({ filter: /\.css$/ }, async ({ path }) => {
const cssText = await readFile(path, 'utf8');
return {
contents: buildStyleInjectionModule(cssText),
loader: 'js',
};
});
},
};

View File

@@ -0,0 +1,275 @@
import { EsbuildWatcher } from '@/cli/utilities/build/common/esbuild-watcher';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '@/cli/utilities/build/common/front-component-build/constants/front-component-external-modules';
import { getFrontComponentBuildPlugins } from '@/cli/utilities/build/common/front-component-build/utils/get-front-component-build-plugins';
import { createStubTwentySdkDefinePlugin } from '@/cli/utilities/build/common/plugins/stub-twenty-sdk-define.plugin';
import {
type OnBuildErrorCallback,
type OnFileBuiltCallback,
} from '@/cli/utilities/build/common/restartable-watcher-interface';
import { createTypecheckPlugin } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildSharedDependenciesBundle } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/build-shared-dependencies-bundle';
import { type SharedDependenciesBuildContext } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-build-context.type';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import { isNonEmptyArray } from '@sniptt/guards';
import chokidar, { type FSWatcher } from 'chokidar';
import { join } from 'path';
import { FileFolder } from 'twenty-shared/types';
import { type FrontComponentSharedDependenciesManifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
const INSTALLED_DEPENDENCY_VERSION_PATHS = ['package.json', 'yarn.lock'];
export type FrontComponentsWatcherOptions = {
appPath: string;
sourcePaths: string[];
sharedDependencies?: FrontComponentSharedDependenciesManifest;
watch?: boolean;
shouldSkipTypecheck: () => boolean;
handleFileBuilt: OnFileBuiltCallback;
handleBuildError: OnBuildErrorCallback;
};
export class FrontComponentsWatcher {
private appPath: string;
private sourcePaths: string[];
private sharedDependencies:
| FrontComponentSharedDependenciesManifest
| undefined;
private watchMode: boolean;
private shouldSkipTypecheck: () => boolean;
private handleFileBuilt: OnFileBuiltCallback;
private handleBuildError: OnBuildErrorCallback;
private componentsWatcher: EsbuildWatcher | null = null;
private sharedDependenciesBuildContext: SharedDependenciesBuildContext | null =
null;
private lastSharedDependenciesChecksum: string | null = null;
private installedDependenciesWatcher: FSWatcher | null = null;
private isClosed = false;
private isApplyingRestart = false;
private sharedDependenciesBuildGeneration = 0;
private inFlightSharedDependenciesBuildPromise: Promise<void> | null = null;
private hasSharedDependenciesBuildRequestedDuringInFlightBuild = false;
constructor(options: FrontComponentsWatcherOptions) {
this.appPath = options.appPath;
this.sourcePaths = options.sourcePaths;
this.sharedDependencies = options.sharedDependencies;
this.watchMode = options.watch ?? true;
this.shouldSkipTypecheck = options.shouldSkipTypecheck;
this.handleFileBuilt = options.handleFileBuilt;
this.handleBuildError = options.handleBuildError;
}
getSharedDependenciesBuildContext(): SharedDependenciesBuildContext | null {
return this.sharedDependenciesBuildContext;
}
shouldRestart(
sourcePaths: string[],
sharedDependencies: FrontComponentSharedDependenciesManifest | undefined,
): boolean {
return (
(this.componentsWatcher?.shouldRestart(sourcePaths) ?? true) ||
this.hasSharedDependenciesManifestChanged(sharedDependencies)
);
}
async start(): Promise<void> {
if (isDefined(this.sharedDependencies)) {
await this.requestSharedDependenciesBuild();
await this.startInstalledDependenciesWatcher();
}
this.componentsWatcher = new EsbuildWatcher({
appPath: this.appPath,
sourcePaths: this.sourcePaths,
watch: this.watchMode,
handleFileBuilt: this.handleFileBuilt,
handleBuildError: this.handleBuildError,
config: {
externalModules: FRONT_COMPONENT_EXTERNAL_MODULES,
fileFolder: FileFolder.BuiltFrontComponent,
jsx: 'automatic',
extraPlugins: [
createTypecheckPlugin(this.appPath, this.shouldSkipTypecheck),
...getFrontComponentBuildPlugins({
getSharedDependenciesBuildContext: () =>
this.sharedDependenciesBuildContext,
}),
createStubTwentySdkDefinePlugin(),
],
},
});
await this.componentsWatcher.start();
}
async restart(
sourcePaths: string[],
sharedDependencies: FrontComponentSharedDependenciesManifest | undefined,
): Promise<void> {
this.isApplyingRestart = true;
try {
const hasSharedDependenciesManifestChanged =
this.hasSharedDependenciesManifestChanged(sharedDependencies);
this.sourcePaths = sourcePaths;
if (hasSharedDependenciesManifestChanged) {
this.sharedDependenciesBuildGeneration += 1;
this.hasSharedDependenciesBuildRequestedDuringInFlightBuild = false;
await this.inFlightSharedDependenciesBuildPromise;
await this.installedDependenciesWatcher?.close();
this.installedDependenciesWatcher = null;
this.sharedDependencies = sharedDependencies;
this.sharedDependenciesBuildContext = null;
this.lastSharedDependenciesChecksum = null;
if (isDefined(sharedDependencies)) {
await this.requestSharedDependenciesBuild();
await this.startInstalledDependenciesWatcher();
}
}
await this.componentsWatcher?.restart(sourcePaths);
} finally {
this.isApplyingRestart = false;
}
}
async close(): Promise<void> {
this.isClosed = true;
await this.installedDependenciesWatcher?.close();
this.installedDependenciesWatcher = null;
await this.componentsWatcher?.close();
this.componentsWatcher = null;
}
private hasSharedDependenciesManifestChanged(
sharedDependencies: FrontComponentSharedDependenciesManifest | undefined,
): boolean {
if (!isDefined(sharedDependencies) || !isDefined(this.sharedDependencies)) {
return (
isDefined(sharedDependencies) !== isDefined(this.sharedDependencies)
);
}
return (
sharedDependencies.dependencies.join(',') !==
this.sharedDependencies.dependencies.join(',')
);
}
private async requestSharedDependenciesBuild(): Promise<void> {
if (isDefined(this.inFlightSharedDependenciesBuildPromise)) {
this.hasSharedDependenciesBuildRequestedDuringInFlightBuild = true;
return;
}
this.inFlightSharedDependenciesBuildPromise =
this.runSharedDependenciesBuild();
try {
await this.inFlightSharedDependenciesBuildPromise;
} finally {
this.inFlightSharedDependenciesBuildPromise = null;
if (this.hasSharedDependenciesBuildRequestedDuringInFlightBuild) {
this.hasSharedDependenciesBuildRequestedDuringInFlightBuild = false;
await this.requestSharedDependenciesBuild();
}
}
}
private async runSharedDependenciesBuild(): Promise<void> {
const sharedDependencies = this.sharedDependencies;
const generation = this.sharedDependenciesBuildGeneration;
if (!isDefined(sharedDependencies)) {
return;
}
const isObsolete = () =>
this.isClosed || generation !== this.sharedDependenciesBuildGeneration;
try {
let builtChecksum: string | null = null;
const sharedDependenciesBuildContext =
await buildSharedDependenciesBundle({
appPath: this.appPath,
sharedDependencies,
onFileBuilt: async (event) => {
builtChecksum = event.checksum;
if (
isObsolete() ||
event.checksum === this.lastSharedDependenciesChecksum
) {
return;
}
await this.handleFileBuilt(event);
},
});
if (isObsolete()) {
return;
}
this.sharedDependenciesBuildContext = sharedDependenciesBuildContext;
if (builtChecksum !== this.lastSharedDependenciesChecksum) {
this.lastSharedDependenciesChecksum = builtChecksum;
if (!this.isApplyingRestart) {
await this.componentsWatcher?.restart(this.sourcePaths);
}
}
} catch (error) {
if (isObsolete()) {
return;
}
await this.handleBuildError([
{
error: error instanceof Error ? error.message : String(error),
location: null,
},
]);
}
}
private async startInstalledDependenciesWatcher(): Promise<void> {
const watchedPaths = (
await Promise.all(
INSTALLED_DEPENDENCY_VERSION_PATHS.map(async (watchPath) => {
const absolutePath = join(this.appPath, watchPath);
return (await pathExists(absolutePath)) ? absolutePath : null;
}),
)
).filter(isDefined);
if (!isNonEmptyArray(watchedPaths)) {
return;
}
this.installedDependenciesWatcher = chokidar.watch(watchedPaths, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 50,
},
});
this.installedDependenciesWatcher.on('all', (event) => {
if (event === 'add' || event === 'change') {
void this.requestSharedDependenciesBuild();
}
});
}
}

View File

@@ -0,0 +1,283 @@
import type * as esbuild from 'esbuild';
const SHARED_HELPERS = `
export var customElementMap = globalThis.__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__ || {};
var _injectedStyleKeys = {};
export function injectStyleViaHead(cssText) {
if (!cssText) return;
var hash = 0;
for (var i = 0; i < cssText.length; i++) {
hash = ((hash << 5) - hash + cssText.charCodeAt(i)) | 0;
}
var key = 'jsx-style-' + hash;
if (_injectedStyleKeys[key]) return;
_injectedStyleKeys[key] = true;
var el = document.createElement('style');
el.setAttribute('data-jsx-style', key);
el.textContent = cssText;
document.head.appendChild(el);
}
export function extractCssText(children) {
if (typeof children === 'string') return children;
if (Array.isArray(children))
return children
.filter(function (c) { return typeof c === 'string'; })
.join('');
return '';
}
var _reactToDomEvent = {
ondoubleclick: 'ondblclick',
};
function _isEventProp(name) {
return (
name.length > 2 &&
name.charCodeAt(0) === 111 &&
name.charCodeAt(1) === 110 &&
name.charCodeAt(2) >= 65 &&
name.charCodeAt(2) <= 90
);
}
export function splitEventProps(props) {
if (!props) return { cleanProps: props, events: null };
var events = null;
var cleanProps = null;
for (var k in props) {
if (_isEventProp(k) && typeof props[k] === 'function') {
if (!events) {
events = {};
cleanProps = {};
for (var j in props) {
if (j === k) break;
cleanProps[j] = props[j];
}
}
events[k] = props[k];
} else if (events) {
cleanProps[k] = props[k];
}
}
return { cleanProps: cleanProps || props, events: events };
}
export function makeEventRef(events, userRef) {
return function(el) {
if (el) {
for (var name in events) {
var domName = _reactToDomEvent[name.toLowerCase()] || name.toLowerCase();
el[domName] = events[name];
}
}
if (typeof userRef === 'function') userRef(el);
else if (userRef != null && typeof userRef === 'object') userRef.current = el;
};
}
`.trim();
const JSX_RUNTIME_WRAPPER = `
import {
jsx as _originalJsx,
jsxs as _originalJsxs,
Fragment,
} from '__real_react_jsx_runtime__';
import {
customElementMap,
injectStyleViaHead,
extractCssText,
splitEventProps,
makeEventRef,
} from '__jsx_shared_helpers__';
function _wrapJsxFactory(originalFactory) {
return function wrappedJsx(type, props, key) {
if (typeof type === 'string') {
if (type === 'style') {
var css =
props && props.dangerouslySetInnerHTML
? props.dangerouslySetInnerHTML.__html || ''
: extractCssText(props && props.children);
injectStyleViaHead(css);
return null;
}
var customTag = customElementMap[type];
if (customTag) {
var split = splitEventProps(props);
if (split.events) {
var cp = split.cleanProps;
cp.ref = makeEventRef(split.events, cp.ref);
return originalFactory(customTag, cp, key);
}
return originalFactory(customTag, props, key);
}
}
return originalFactory(type, props, key);
};
}
export var jsx = _wrapJsxFactory(_originalJsx);
export var jsxs = _wrapJsxFactory(_originalJsxs);
export { Fragment };
`.trim();
const REACT_WRAPPER = `
export * from '__real_react__';
import _React from '__real_react__';
import {
customElementMap,
injectStyleViaHead,
extractCssText,
splitEventProps,
makeEventRef,
} from '__jsx_shared_helpers__';
var _originalCreateElement = _React.createElement;
function createElement(type) {
var args = arguments;
if (typeof type === 'string') {
if (type === 'style') {
var props = args.length > 1 ? args[1] : null;
if (props) {
var css = props.dangerouslySetInnerHTML
? props.dangerouslySetInnerHTML.__html || ''
: extractCssText(props.children);
injectStyleViaHead(css);
}
return null;
}
var customTag = customElementMap[type];
if (customTag) {
var ceProps = args.length > 1 ? args[1] : null;
var split = splitEventProps(ceProps);
if (split.events) {
var cp = split.cleanProps || {};
cp.ref = makeEventRef(split.events, cp.ref);
var newArgs = [customTag, cp];
for (var i = 2; i < args.length; i++) newArgs.push(args[i]);
return _originalCreateElement.apply(null, newArgs);
}
var newArgs2 = [customTag];
for (var i2 = 1; i2 < args.length; i2++) newArgs2.push(args[i2]);
return _originalCreateElement.apply(null, newArgs2);
}
}
return _originalCreateElement.apply(null, args);
}
export { createElement };
export default Object.assign({}, _React, { createElement: createElement });
`.trim();
type JsxRuntimeRemoteWrapperPluginOptions = {
usePreact?: boolean;
};
export const createJsxRuntimeRemoteWrapperPlugin = (
options?: JsxRuntimeRemoteWrapperPluginOptions,
): esbuild.Plugin => {
const usePreact = options?.usePreact ?? false;
const jsxRuntimeModule = usePreact
? 'preact/jsx-runtime'
: 'react/jsx-runtime';
const reactModule = usePreact ? 'preact/compat' : 'react';
return {
name: 'jsx-runtime-remote-wrapper',
setup: (build) => {
let realJsxRuntimePath: string | undefined;
let realReactPath: string | undefined;
build.onResolve({ filter: /^__jsx_shared_helpers__$/ }, () => ({
path: '__jsx_shared_helpers__',
namespace: 'jsx-shared-helpers',
}));
build.onLoad({ filter: /.*/, namespace: 'jsx-shared-helpers' }, () => ({
contents: SHARED_HELPERS,
loader: 'js' as const,
}));
build.onResolve({ filter: /^react\/jsx-runtime$/ }, async (args) => {
if (args.pluginData?.skipJsxWrapper) {
return undefined;
}
if (!realJsxRuntimePath) {
const resolved = await build.resolve(jsxRuntimeModule, {
kind: args.kind,
resolveDir: args.resolveDir,
pluginData: { skipJsxWrapper: true },
});
realJsxRuntimePath = resolved.path;
}
return {
path: 'react/jsx-runtime',
namespace: 'jsx-runtime-wrapper',
};
});
build.onResolve({ filter: /^__real_react_jsx_runtime__$/ }, () => {
if (!realJsxRuntimePath) {
throw new Error(
'jsx-runtime-remote-wrapper: real jsx-runtime path not resolved yet',
);
}
return { path: realJsxRuntimePath };
});
build.onLoad({ filter: /.*/, namespace: 'jsx-runtime-wrapper' }, () => ({
contents: JSX_RUNTIME_WRAPPER,
loader: 'js' as const,
}));
build.onResolve({ filter: /^react$/ }, async (args) => {
if (args.pluginData?.skipJsxWrapper) {
return undefined;
}
if (!realReactPath) {
const resolved = await build.resolve(reactModule, {
kind: args.kind,
resolveDir: args.resolveDir,
pluginData: { skipJsxWrapper: true },
});
realReactPath = resolved.path;
}
return {
path: 'react',
namespace: 'react-wrapper',
};
});
build.onResolve({ filter: /^__real_react__$/ }, () => {
if (!realReactPath) {
throw new Error(
'jsx-runtime-remote-wrapper: real react path not resolved yet',
);
}
return { path: realReactPath };
});
build.onLoad({ filter: /.*/, namespace: 'react-wrapper' }, () => ({
contents: REACT_WRAPPER,
loader: 'js' as const,
}));
},
};
};

View File

@@ -0,0 +1,33 @@
import type * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';
import { unwrapDefineFrontComponentToDirectExport } from './utils/unwrap-define-front-component-to-direct-export';
export const jsxTransformToRemoteDomWorkerFormatPlugin: esbuild.Plugin = {
name: 'jsx-transform-to-remote-dom-worker-format-plugin',
setup: (esbuildBuild) => {
esbuildBuild.onLoad(
{ filter: /\.tsx$/ },
async ({ path }): Promise<esbuild.OnLoadResult> => {
try {
const frontComponentSourceCode = await readFile(path, 'utf8');
const transformedContents = unwrapDefineFrontComponentToDirectExport(
frontComponentSourceCode,
);
return { contents: transformedContents, loader: 'tsx' };
} catch (transformError) {
return {
errors: [
{
text: `Failed to transform front component: ${transformError instanceof Error ? transformError.message : String(transformError)}`,
location: { file: path },
},
],
};
}
},
);
},
};

View File

@@ -0,0 +1,30 @@
import type * as esbuild from 'esbuild';
export const createPreactAliasPlugin = (): esbuild.Plugin => ({
name: 'preact-alias',
setup: (build) => {
let preactCompatClientPath: string | undefined;
build.onResolve({ filter: /^react-dom\/client$/ }, async (args) => {
if (!preactCompatClientPath) {
const resolved = await build.resolve('preact/compat/client', {
kind: args.kind,
resolveDir: args.resolveDir,
});
preactCompatClientPath = resolved.path;
}
return { path: preactCompatClientPath };
});
build.onResolve({ filter: /^react-dom$/ }, async (args) => {
const resolved = await build.resolve('preact/compat', {
kind: args.kind,
resolveDir: args.resolveDir,
});
return { path: resolved.path };
});
},
});

View File

@@ -0,0 +1,60 @@
import { rm } from 'node:fs/promises';
import { join } from 'path';
import { isDefined } from 'twenty-shared/utils';
import {
FRONT_COMPONENT_SHARED_DEPENDENCIES_BUILT_PATH,
OUTPUT_DIR,
type Manifest,
} from 'twenty-shared/application';
import { SHARED_DEPENDENCIES_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
describe('buildApplication with a shared dependencies bundle', () => {
let builtManifest: Manifest;
beforeAll(async () => {
const { manifest, filePaths, errors } = await buildManifest(
SHARED_DEPENDENCIES_APP_PATH,
);
expect(errors).toEqual([]);
if (!isDefined(manifest)) {
throw new Error(
'The shared-dependencies-app manifest could not be built',
);
}
const { builtFileInfos } = await buildApplication({
appPath: SHARED_DEPENDENCIES_APP_PATH,
manifest,
filePaths,
});
builtManifest = manifestUpdateChecksums({ manifest, builtFileInfos });
}, 180000);
afterAll(async () => {
await rm(join(SHARED_DEPENDENCIES_APP_PATH, OUTPUT_DIR), {
recursive: true,
force: true,
});
});
it('records the declared dependencies and the built bundle checksum in the manifest', () => {
expect(
builtManifest.application.frontComponentSharedDependencies,
).toMatchObject({
dependencies: ['react', 'react-dom/client', 'react/jsx-runtime'],
builtPath: FRONT_COMPONENT_SHARED_DEPENDENCIES_BUILT_PATH,
});
expect(
builtManifest.application.frontComponentSharedDependencies?.builtChecksum,
).toMatch(/^[0-9a-f]{64}$/);
});
});

View File

@@ -0,0 +1,34 @@
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { enumerateSharedDependenciesExportNames } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/enumerate-shared-dependencies-export-names';
describe('enumerateSharedDependenciesExportNames', () => {
it('reports the named exports and the default of a commonjs dependency', async () => {
const exportNames = await enumerateSharedDependenciesExportNames({
appPath: MINIMAL_APP_PATH,
specifier: 'react',
});
expect(exportNames.namedExports).toContain('useState');
expect(exportNames.namedExports).not.toContain('default');
expect(exportNames.hasDefaultExport).toBe(true);
}, 60000);
it('reports no default for an es module without one', async () => {
const exportNames = await enumerateSharedDependenciesExportNames({
appPath: MINIMAL_APP_PATH,
specifier: '@remote-dom/core',
});
expect(exportNames.namedExports).toContain('ROOT_ID');
expect(exportNames.hasDefaultExport).toBe(false);
}, 60000);
it('throws for a dependency the application cannot resolve', async () => {
await expect(
enumerateSharedDependenciesExportNames({
appPath: MINIMAL_APP_PATH,
specifier: 'this-package-does-not-exist',
}),
).rejects.toThrow('Unable to determine the exports');
}, 60000);
});

View File

@@ -0,0 +1,140 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'path';
import * as esbuild from 'esbuild';
import {
OUTPUT_DIR,
FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER,
} from 'twenty-shared/application';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
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 { buildSharedDependenciesBundle } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/build-shared-dependencies-bundle';
import { removeBuiltSharedDependenciesBundle } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/__tests__/utils/remove-built-shared-dependencies-bundle';
import { type SharedDependenciesBuildContext } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-build-context.type';
const REACT_BUNDLED_MARKER = '@license React';
const SHARED_DEPENDENCIES_MANIFEST = {
dependencies: ['react', 'react/jsx-runtime', 'react-dom/client'],
builtPath: 'front-component-shared-dependencies.mjs',
builtChecksum: null,
};
const buildFrontComponent = async ({
outputDir,
sharedDependenciesBuildContext,
}: {
outputDir: string;
sharedDependenciesBuildContext: SharedDependenciesBuildContext | null;
}) => {
const result = await esbuild.build({
...getBaseFrontComponentBuildOptions(),
entryPoints: [join(MINIMAL_APP_PATH, 'my.front-component.tsx')],
outdir: outputDir,
plugins: getFrontComponentBuildPlugins({
getSharedDependenciesBuildContext: () => sharedDependenciesBuildContext,
}),
});
const output = await readFile(
join(outputDir, 'my.front-component.mjs'),
'utf-8',
);
return { result, output };
};
describe('shared dependencies build', () => {
let sharedDependenciesBuildContext: SharedDependenciesBuildContext;
let frontComponentSharedDependenciesChecksum: string;
beforeAll(async () => {
sharedDependenciesBuildContext = await buildSharedDependenciesBundle({
appPath: MINIMAL_APP_PATH,
sharedDependencies: SHARED_DEPENDENCIES_MANIFEST,
onFileBuilt: ({ checksum }) => {
frontComponentSharedDependenciesChecksum = checksum;
},
});
}, 120000);
afterAll(async () => {
await removeBuiltSharedDependenciesBundle({
appPath: MINIMAL_APP_PATH,
builtPath: SHARED_DEPENDENCIES_MANIFEST.builtPath,
});
});
it('bundles every declared dependency behind its own namespace export, through the jsx wrapper', async () => {
const sharedDependenciesBundle = await readFile(
join(
MINIMAL_APP_PATH,
OUTPUT_DIR,
SHARED_DEPENDENCIES_MANIFEST.builtPath,
),
'utf-8',
);
expect(sharedDependenciesBundle).toContain('__shared_dependencies_react__');
expect(sharedDependenciesBundle).toContain(
'__shared_dependencies_react_jsx_runtime__',
);
expect(sharedDependenciesBundle).toContain(
'__shared_dependencies_react_dom_client__',
);
expect(sharedDependenciesBundle).toContain(
'__HTML_TAG_TO_CUSTOM_ELEMENT_TAG__',
);
expect(frontComponentSharedDependenciesChecksum).toMatch(/^[0-9a-f]{64}$/);
});
it('keeps react out of a front component bundle that shares them', async () => {
const outputDir = await mkdtemp(
join(tmpdir(), 'shared-dependencies-component-'),
);
try {
const { result, output } = await buildFrontComponent({
outputDir,
sharedDependenciesBuildContext,
});
expect(output).toContain(FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER);
expect(output).not.toContain(REACT_BUNDLED_MARKER);
const outputMeta = Object.values(result.metafile?.outputs ?? {}).find(
(metaOutput) => metaOutput.entryPoint?.endsWith('.tsx'),
);
expect(
outputMeta?.imports.some(
(moduleImport) =>
moduleImport.external === true &&
moduleImport.path === FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER,
),
).toBe(true);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}, 60000);
it('bundles react into a front component when no shared dependencies are declared', async () => {
const outputDir = await mkdtemp(join(tmpdir(), 'plain-component-'));
try {
const { output } = await buildFrontComponent({
outputDir,
sharedDependenciesBuildContext: null,
});
expect(output).not.toContain(FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER);
expect(output).toContain(REACT_BUNDLED_MARKER);
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}, 60000);
});

View File

@@ -0,0 +1,69 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'path';
import * as esbuild from 'esbuild';
import { FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER } from 'twenty-shared/application';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
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 { buildSharedDependenciesBundle } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/build-shared-dependencies-bundle';
import { removeBuiltSharedDependenciesBundle } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/__tests__/utils/remove-built-shared-dependencies-bundle';
const SHARED_DEPENDENCIES_MANIFEST = {
dependencies: ['axios'],
builtPath: 'default-export-shared-dependencies.mjs',
builtChecksum: null,
};
describe('shared dependency shims for a dependency with a default export', () => {
it('lets a component import the default of a shared es module', async () => {
const outputDir = await mkdtemp(
join(MINIMAL_APP_PATH, 'shared-dependencies-default-'),
);
const entryPoint = join(outputDir, 'entry.js');
try {
const sharedDependenciesBuildContext =
await buildSharedDependenciesBundle({
appPath: MINIMAL_APP_PATH,
sharedDependencies: SHARED_DEPENDENCIES_MANIFEST,
onFileBuilt: () => {},
});
await writeFile(
entryPoint,
'import axios from "axios";\nexport default () => axios;\n',
);
const result = await esbuild.build({
...getBaseFrontComponentBuildOptions(),
entryPoints: [entryPoint],
outdir: outputDir,
plugins: getFrontComponentBuildPlugins({
getSharedDependenciesBuildContext: () =>
sharedDependenciesBuildContext,
}),
});
expect(result.errors).toEqual([]);
const outputMeta = Object.values(result.metafile?.outputs ?? {}).find(
(metaOutput) => metaOutput.entryPoint?.endsWith('entry.js'),
);
expect(
outputMeta?.imports.some(
(moduleImport) =>
moduleImport.path === FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER,
),
).toBe(true);
} finally {
await rm(outputDir, { recursive: true, force: true });
await removeBuiltSharedDependenciesBundle({
appPath: MINIMAL_APP_PATH,
builtPath: SHARED_DEPENDENCIES_MANIFEST.builtPath,
});
}
}, 120000);
});

View File

@@ -0,0 +1,17 @@
import { rm } from 'node:fs/promises';
import { join } from 'path';
import { OUTPUT_DIR } from 'twenty-shared/application';
export const removeBuiltSharedDependenciesBundle = async ({
appPath,
builtPath,
}: {
appPath: string;
builtPath: string;
}): Promise<void> => {
const absoluteBuiltPath = join(appPath, OUTPUT_DIR, builtPath);
await rm(absoluteBuiltPath, { force: true });
await rm(`${absoluteBuiltPath}.map`, { force: true });
};

View File

@@ -0,0 +1,86 @@
import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options';
import { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
import { type SharedDependenciesBuildContext } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-build-context.type';
import { enumerateSharedDependenciesExportNames } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/enumerate-shared-dependencies-export-names';
import { getSharedDependenciesEntrySource } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/get-shared-dependencies-entry-source';
import { getSharedDependenciesNamespaceCollisions } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/get-shared-dependencies-namespace-collisions';
import { ensureDir } from '@/cli/utilities/file/fs-utils';
import crypto from 'crypto';
import * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'path';
import {
OUTPUT_DIR,
type FrontComponentSharedDependenciesManifest,
} from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isNonEmptyArray } from '@sniptt/guards';
export const buildSharedDependenciesBundle = async ({
appPath,
sharedDependencies,
onFileBuilt,
}: {
appPath: string;
sharedDependencies: FrontComponentSharedDependenciesManifest;
onFileBuilt: OnFileBuiltCallback;
}): Promise<SharedDependenciesBuildContext> => {
const namespaceCollisions = getSharedDependenciesNamespaceCollisions(
sharedDependencies.dependencies,
);
if (isNonEmptyArray(namespaceCollisions)) {
throw new Error(
`Shared dependencies map to the same bundle namespace: ${namespaceCollisions
.map((specifiers) => specifiers.join(' and '))
.join(', ')}`,
);
}
const builtPath = join(OUTPUT_DIR, sharedDependencies.builtPath);
const absoluteBuiltPath = join(appPath, builtPath);
await ensureDir(dirname(absoluteBuiltPath));
await esbuild.build({
...getBaseFrontComponentBuildOptions(),
stdin: {
contents: getSharedDependenciesEntrySource(
sharedDependencies.dependencies,
),
resolveDir: appPath,
sourcefile: 'twenty-shared-dependencies-entry.js',
loader: 'js',
},
outfile: absoluteBuiltPath,
outExtension: undefined,
external: [],
});
const exportNamesBySpecifier = new Map(
await Promise.all(
sharedDependencies.dependencies.map(
async (specifier) =>
[
specifier,
await enumerateSharedDependenciesExportNames({
appPath,
specifier,
}),
] as const,
),
),
);
const content = await readFile(absoluteBuiltPath);
const checksum = crypto.createHash('sha256').update(content).digest('hex');
await onFileBuilt({
fileFolder: FileFolder.BuiltFrontComponent,
builtPath,
sourcePath: 'package.json',
checksum,
});
return { exportNamesBySpecifier };
};

View File

@@ -0,0 +1,49 @@
import { type SharedDependenciesBuildContext } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-build-context.type';
import { getSharedDependenciesShimSource } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/get-shared-dependencies-shim-source';
import type * as esbuild from 'esbuild';
import { isDefined } from 'twenty-shared/utils';
const SHARED_DEPENDENCIES_SHIM_NAMESPACE = 'twenty-shared-dependencies-shim';
export const createSharedDependenciesShimPlugin = (
getSharedDependenciesBuildContext: () => SharedDependenciesBuildContext | null,
): esbuild.Plugin => ({
name: 'twenty-shared-dependencies-shim',
setup: (build) => {
build.onResolve({ filter: /.*/ }, ({ path: specifier }) => {
const sharedDependenciesBuildContext =
getSharedDependenciesBuildContext();
if (!isDefined(sharedDependenciesBuildContext)) {
return undefined;
}
if (
!sharedDependenciesBuildContext.exportNamesBySpecifier.has(specifier)
) {
return undefined;
}
return { path: specifier, namespace: SHARED_DEPENDENCIES_SHIM_NAMESPACE };
});
build.onLoad(
{ filter: /.*/, namespace: SHARED_DEPENDENCIES_SHIM_NAMESPACE },
({ path: specifier }) => {
const exportNames =
getSharedDependenciesBuildContext()?.exportNamesBySpecifier.get(
specifier,
);
if (!isDefined(exportNames)) {
return undefined;
}
return {
contents: getSharedDependenciesShimSource({ specifier, exportNames }),
loader: 'js' as const,
};
},
);
},
});

View File

@@ -0,0 +1,5 @@
import { type SharedDependenciesExportNames } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-export-names.type';
export type SharedDependenciesBuildContext = {
exportNamesBySpecifier: Map<string, SharedDependenciesExportNames>;
};

View File

@@ -0,0 +1,4 @@
export type SharedDependenciesExportNames = {
namedExports: string[];
hasDefaultExport: boolean;
};

View File

@@ -0,0 +1,91 @@
import { getBaseFrontComponentBuildOptions } from '@/cli/utilities/build/common/front-component-build/utils/get-base-front-component-build-options';
import { type SharedDependenciesExportNames } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-export-names.type';
import * as esbuild from 'esbuild';
import { isDefined } from 'twenty-shared/utils';
const DEFAULT_EXPORT_NAME = 'default';
const PROBE_NAMESPACE_IDENTIFIER = 'sharedDependenciesNamespace';
const buildNamespaceProbeSource = (specifier: string): string =>
`import * as ${PROBE_NAMESPACE_IDENTIFIER} from ${JSON.stringify(specifier)};\nexport { ${PROBE_NAMESPACE_IDENTIFIER} };`;
const buildProbeBundle = async ({
appPath,
specifier,
}: {
appPath: string;
specifier: string;
}): Promise<string | null> => {
try {
const probeResult = await esbuild.build({
...getBaseFrontComponentBuildOptions(),
stdin: {
contents: buildNamespaceProbeSource(specifier),
resolveDir: appPath,
sourcefile: 'shared-dependencies-export-probe.js',
loader: 'js',
},
write: false,
outfile: 'shared-dependencies-export-probe-out.js',
outExtension: undefined,
external: [],
sourcemap: false,
});
return (
probeResult.outputFiles?.find((outputFile) =>
outputFile.path.endsWith('.js'),
)?.text ?? null
);
} catch {
return null;
}
};
const importProbeNamespace = async (
probeBundleSource: string,
): Promise<Record<string, unknown> | null> => {
try {
const probeModuleUrl = `data:text/javascript;base64,${Buffer.from(
probeBundleSource,
).toString('base64')}`;
const probeModule = (await import(probeModuleUrl)) as Record<
string,
Record<string, unknown> | undefined
>;
return probeModule[PROBE_NAMESPACE_IDENTIFIER] ?? null;
} catch {
return null;
}
};
export const enumerateSharedDependenciesExportNames = async ({
appPath,
specifier,
}: {
appPath: string;
specifier: string;
}): Promise<SharedDependenciesExportNames> => {
const probeBundleSource = await buildProbeBundle({ appPath, specifier });
const sharedDependenciesNamespace = isDefined(probeBundleSource)
? await importProbeNamespace(probeBundleSource)
: null;
if (!isDefined(sharedDependenciesNamespace)) {
throw new Error(
`Unable to determine the exports of shared dependency "${specifier}". Check that it is installed and importable from the application.`,
);
}
const namedExports = Object.keys(sharedDependenciesNamespace)
.filter((exportName) => exportName !== DEFAULT_EXPORT_NAME)
.sort();
return {
namedExports,
hasDefaultExport: DEFAULT_EXPORT_NAME in sharedDependenciesNamespace,
};
};

View File

@@ -0,0 +1,13 @@
import { toSharedDependenciesNamespaceIdentifier } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/to-shared-dependencies-namespace-identifier';
export const getSharedDependenciesEntrySource = (
dependencies: string[],
): string =>
dependencies
.map((specifier) => {
const namespaceIdentifier =
toSharedDependenciesNamespaceIdentifier(specifier);
return `import * as ${namespaceIdentifier} from ${JSON.stringify(specifier)};\nexport { ${namespaceIdentifier} };`;
})
.join('\n');

View File

@@ -0,0 +1,21 @@
import { toSharedDependenciesNamespaceIdentifier } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/to-shared-dependencies-namespace-identifier';
export const getSharedDependenciesNamespaceCollisions = (
dependencies: string[],
): string[][] => {
const specifiersByNamespaceIdentifier = new Map<string, string[]>();
for (const specifier of dependencies) {
const namespaceIdentifier =
toSharedDependenciesNamespaceIdentifier(specifier);
const specifiers =
specifiersByNamespaceIdentifier.get(namespaceIdentifier) ?? [];
specifiers.push(specifier);
specifiersByNamespaceIdentifier.set(namespaceIdentifier, specifiers);
}
return [...specifiersByNamespaceIdentifier.values()].filter(
(specifiers) => specifiers.length > 1,
);
};

View File

@@ -0,0 +1,31 @@
import { type SharedDependenciesExportNames } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-export-names.type';
import { toSharedDependenciesNamespaceIdentifier } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/utils/to-shared-dependencies-namespace-identifier';
import { FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER } from 'twenty-shared/application';
export const getSharedDependenciesShimSource = ({
specifier,
exportNames,
}: {
specifier: string;
exportNames: SharedDependenciesExportNames;
}): string => {
const namespaceIdentifier =
toSharedDependenciesNamespaceIdentifier(specifier);
const lines = [
`import { ${namespaceIdentifier} } from ${JSON.stringify(FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER)};`,
];
for (const [index, exportName] of exportNames.namedExports.entries()) {
lines.push(
`const __sharedDependenciesExport${index} = ${namespaceIdentifier}[${JSON.stringify(exportName)}];`,
`export { __sharedDependenciesExport${index} as ${JSON.stringify(exportName)} };`,
);
}
if (exportNames.hasDefaultExport) {
lines.push(`export default ${namespaceIdentifier}.default;`);
}
return lines.join('\n');
};

View File

@@ -0,0 +1,4 @@
export const toSharedDependenciesNamespaceIdentifier = (
specifier: string,
): string =>
`__shared_dependencies_${specifier.replace(/[^A-Za-z0-9]/g, '_')}__`;

View File

@@ -0,0 +1,31 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'path';
import type * as esbuild from 'esbuild';
const SINGLE_LINE_COMMENT_PATTERN = /^\/\/.*$\n/gm;
export const stripCommentsPlugin: esbuild.Plugin = {
name: 'strip-comments',
setup: (build) => {
build.onEnd(async (result) => {
if (result.errors.length > 0) {
return;
}
const outputFiles = Object.keys(result.metafile?.outputs ?? {}).filter(
(file) => file.endsWith('.mjs'),
);
for (const outputFile of outputFiles) {
const absolutePath = path.resolve(outputFile);
const content = await readFile(absolutePath, 'utf-8');
const stripped = content.replace(SINGLE_LINE_COMMENT_PATTERN, '');
if (stripped !== content) {
await writeFile(absolutePath, stripped, 'utf-8');
}
}
});
},
};

View File

@@ -0,0 +1,112 @@
import { unwrapDefineFrontComponentToDirectExport } from '@/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export';
const INLINE_EXPRESSION_COMPONENT_SOURCE = `import { defineFrontComponent } from 'twenty-sdk/define';
import { SUMMARIZE_COMPANY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { createSummarizeRecordEffect } from 'src/front-components/utils/create-summarize-record-effect';
import { SUMMARIZE_TARGETS } from 'src/front-components/utils/summarize-target';
const SummarizeRecordEffect = createSummarizeRecordEffect(SUMMARIZE_TARGETS.company)
export default defineFrontComponent({
universalIdentifier: SUMMARIZE_COMPANY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Summarize Company',
description: 'Asks AI to summarize the selected company',
isHeadless: true,
component: createSummarizeRecordEffect(SUMMARIZE_TARGETS.company),
});
`;
const IDENTIFIER_COMPONENT_SOURCE = `import { defineFrontComponent } from 'twenty-sdk/define';
import { SUMMARIZE_COMPANY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { createSummarizeRecordEffect } from 'src/front-components/utils/create-summarize-record-effect';
import { SUMMARIZE_TARGETS } from 'src/front-components/utils/summarize-target';
const SummarizeRecordEffect = createSummarizeRecordEffect(SUMMARIZE_TARGETS.company)
export default defineFrontComponent({
universalIdentifier: SUMMARIZE_COMPANY_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Summarize Company',
description: 'Asks AI to summarize the selected company',
isHeadless: true,
component: SummarizeRecordEffect,
});
`;
const SETTINGS_COMPONENT_SOURCE = `import { defineSettingsFrontComponent } from 'twenty-sdk/define';
import { APP_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { AppSettings } from 'src/front-components/components/AppSettings';
export default defineSettingsFrontComponent({
universalIdentifier: APP_SETTINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'app-settings',
description: 'Custom settings tab',
component: AppSettings,
});
`;
describe('unwrapDefineFrontComponentToDirectExport', () => {
it('renders the component when component is an inline factory call', () => {
const output = unwrapDefineFrontComponentToDirectExport(
INLINE_EXPRESSION_COMPONENT_SOURCE,
);
expect(output).toContain(
'component: createSummarizeRecordEffect(SUMMARIZE_TARGETS.company),',
);
expect(output).toContain(
'__frontComponentJsx(__frontComponentDefinition.component, {})',
);
expect(output).not.toContain('defineFrontComponent');
});
it('renders the component when component is a bare identifier', () => {
const output = unwrapDefineFrontComponentToDirectExport(
IDENTIFIER_COMPONENT_SOURCE,
);
expect(output).toContain('component: SummarizeRecordEffect,');
expect(output).toContain(
'__frontComponentJsx(__frontComponentDefinition.component, {})',
);
expect(output).not.toContain('defineFrontComponent');
});
it('produces an equivalent renderer for the inline and identifier forms', () => {
const inlineOutput = unwrapDefineFrontComponentToDirectExport(
INLINE_EXPRESSION_COMPONENT_SOURCE,
);
const identifierOutput = unwrapDefineFrontComponentToDirectExport(
IDENTIFIER_COMPONENT_SOURCE,
);
const renderer =
'\nexport default function __renderFrontComponent(__container) { __createRoot(__container).render(__frontComponentJsx(__frontComponentDefinition.component, {})); }\n';
expect(inlineOutput).toContain(renderer);
expect(identifierOutput).toContain(renderer);
});
it('renders the component when defined with defineSettingsFrontComponent', () => {
const output = unwrapDefineFrontComponentToDirectExport(
SETTINGS_COMPONENT_SOURCE,
);
expect(output).toContain('component: AppSettings,');
expect(output).toContain(
'__frontComponentJsx(__frontComponentDefinition.component, {})',
);
expect(output).not.toContain('defineSettingsFrontComponent');
});
it('leaves a source without a defineFrontComponent default export untouched', () => {
const source = `import { useEffect } from 'react';
export const createSummarizeRecordEffect = () => () => null;
`;
expect(unwrapDefineFrontComponentToDirectExport(source)).toBe(source);
});
});

View File

@@ -0,0 +1,19 @@
import type * as esbuild from 'esbuild';
import { FRONT_COMPONENT_EXTERNAL_MODULES } from '../constants/front-component-external-modules';
import { getFrontComponentBuildPlugins } from './get-front-component-build-plugins';
export const getBaseFrontComponentBuildOptions = (): esbuild.BuildOptions => ({
bundle: true,
splitting: false,
format: 'esm',
outExtension: { '.js': '.mjs' },
external: FRONT_COMPONENT_EXTERNAL_MODULES,
jsx: 'automatic',
sourcemap: true,
metafile: true,
logLevel: 'silent',
minify: true,
define: { 'process.env.NODE_ENV': '"production"' },
plugins: [...getFrontComponentBuildPlugins()],
});

View File

@@ -0,0 +1,34 @@
import type * as esbuild from 'esbuild';
import { isDefined } from 'twenty-shared/utils';
import { createSharedDependenciesShimPlugin } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/create-shared-dependencies-shim-plugin';
import { type SharedDependenciesBuildContext } from '@/cli/utilities/build/common/front-component-build/shared-dependencies-build/types/shared-dependencies-build-context.type';
import { cssInjectionPlugin } from '../css-injection-plugin';
import { createJsxRuntimeRemoteWrapperPlugin } from '../jsx-runtime-remote-wrapper-plugin';
import { jsxTransformToRemoteDomWorkerFormatPlugin } from '../jsx-transform-to-remote-dom-worker-format-plugin';
import { createPreactAliasPlugin } from '../preact-alias-plugin';
import { stripCommentsPlugin } from '../strip-comments-plugin';
type GetFrontComponentBuildPluginsOptions = {
usePreact?: boolean;
getSharedDependenciesBuildContext?: () => SharedDependenciesBuildContext | null;
};
export const getFrontComponentBuildPlugins = (
options?: GetFrontComponentBuildPluginsOptions,
): esbuild.Plugin[] => [
...(isDefined(options?.getSharedDependenciesBuildContext)
? [
createSharedDependenciesShimPlugin(
options.getSharedDependenciesBuildContext,
),
]
: []),
createJsxRuntimeRemoteWrapperPlugin(
options?.usePreact ? { usePreact: true } : undefined,
),
...(options?.usePreact ? [createPreactAliasPlugin()] : []),
jsxTransformToRemoteDomWorkerFormatPlugin,
cssInjectionPlugin,
stripCommentsPlugin,
];

View File

@@ -0,0 +1,48 @@
const DEFINE_FRONT_COMPONENT_IMPORT_PATTERN =
/import\s*\{\s*define(?:Settings)?FrontComponent\s*\}\s*from\s*['"][^'"]+['"];?\n?/g;
const DEFINE_FRONT_COMPONENT_EXPORT_OPENING_PATTERN =
/export\s+default\s+define(?:Settings)?FrontComponent\s*\(/;
const IDENTIFIER_COMPONENT_VALUE_PATTERN =
/component\s*:\s*([A-Za-z_$][\w$]*)\s*[,}]/;
const FRONT_COMPONENT_DEFINITION_NAME = '__frontComponentDefinition';
export const unwrapDefineFrontComponentToDirectExport = (
sourceCode: string,
): string => {
let transformedSource = sourceCode.replace(
DEFINE_FRONT_COMPONENT_IMPORT_PATTERN,
'',
);
if (!DEFINE_FRONT_COMPONENT_EXPORT_OPENING_PATTERN.test(transformedSource)) {
return transformedSource;
}
transformedSource = transformedSource.replace(
DEFINE_FRONT_COMPONENT_EXPORT_OPENING_PATTERN,
`const ${FRONT_COMPONENT_DEFINITION_NAME} = (`,
);
const identifierComponentMatch = transformedSource.match(
IDENTIFIER_COMPONENT_VALUE_PATTERN,
);
if (identifierComponentMatch) {
const wrappedComponentName = identifierComponentMatch[1];
transformedSource = transformedSource.replace(
new RegExp(`export\\s+(const|function)\\s+${wrappedComponentName}\\b`),
`$1 ${wrappedComponentName}`,
);
}
return (
`import { createRoot as __createRoot } from 'react-dom/client';\n` +
`import { jsx as __frontComponentJsx } from 'react/jsx-runtime';\n` +
transformedSource +
`\nexport default function __renderFrontComponent(__container) { __createRoot(__container).render(__frontComponentJsx(${FRONT_COMPONENT_DEFINITION_NAME}.component, {})); }\n`
);
};

View File

@@ -0,0 +1,108 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1`] = `
{
"any": [
"AggregateOperations",
"CommandMenuItemAvailabilityType",
"DateDisplayFormat",
"FieldMetadataSettingsOnClickAction",
"FieldType",
"HTTPMethod",
"IndexType",
"MetadataReadability",
"MetadataWritability",
"NavigationMenuItemType",
"NumberDataType",
"ObjectOpenRecordIn",
"ObjectRecordGroupByDateGranularity",
"OnDeleteAction",
"PageLayoutTabLayoutMode",
"PageLayoutType",
"PageLayoutWidgetVerticalListHeightBehavior",
"RelationType",
"RowLevelPermissionPredicateGroupLogicalOperator",
"RowLevelPermissionPredicateOperand",
"STANDARD_OBJECT",
"STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS",
"STANDARD_PAGE_LAYOUT",
"STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS",
"SYSTEM_VIEW_KEYS",
"SystemPermissionFlag",
"ViewCalendarLayout",
"ViewFilterGroupLogicalOperator",
"ViewFilterOperand",
"ViewKey",
"ViewOpenRecordIn",
"ViewSortDirection",
"ViewType",
"ViewVisibility",
"WidgetType",
"canAccessFullAdminPanel",
"canImpersonate",
"every",
"everyDefined",
"everyEquals",
"favoriteRecordIds",
"featureFlags",
"getFieldUniversalIdentifier",
"getSystemPageLayoutTabUniversalIdentifier",
"getSystemRecordPageLayoutUniversalIdentifier",
"getSystemRelationFieldUniversalIdentifier",
"getSystemViewFieldUniversalIdentifier",
"getSystemViewUniversalIdentifier",
"hasAnySoftDeleteFilterOnView",
"includes",
"includesEvery",
"isDashboardPageLayoutInEditMode",
"isDefined",
"isInSidePanel",
"isLayoutCustomizationModeEnabled",
"isNonEmptyString",
"isSelectAll",
"none",
"noneDefined",
"noneEquals",
"numberOfSelectedRecords",
"objectMetadataItem",
"objectMetadataLabel",
"objectPermissions",
"pageType",
"selectedRecords",
"some",
"someDefined",
"someEquals",
"someNonEmptyString",
"targetObjectReadPermissions",
"targetObjectWritePermissions",
"validateFields",
],
"factories": [
"createValidationResult",
"defineAgent",
"defineApplication",
"defineApplicationRole",
"defineCommandMenuItem",
"defineConnectionProvider",
"defineField",
"defineFrontComponent",
"defineIndex",
"defineLogicFunction",
"defineNavigationMenuItem",
"defineObject",
"definePageLayout",
"definePageLayoutTab",
"definePageLayoutWidget",
"definePermissionFlag",
"definePostInstallLogicFunction",
"definePreInstallLogicFunction",
"defineRole",
"defineSettingsFrontComponent",
"defineSkill",
"defineTimelineActivityType",
"defineUninstallLogicFunction",
"defineView",
"defineViewField",
],
}
`;

View File

@@ -0,0 +1,42 @@
import * as twentySdkDefine from '@/sdk/define';
import {
TWENTY_SDK_DEFINE_STUBBED_EXPORTS,
isDefineFactoryExportName,
} from '@/cli/utilities/build/common/plugins/stub-twenty-sdk-define.plugin';
describe('stub-twenty-sdk-define plugin', () => {
const realExports = Object.keys(twentySdkDefine).sort();
const stubbedExports = [
...TWENTY_SDK_DEFINE_STUBBED_EXPORTS.factories,
...TWENTY_SDK_DEFINE_STUBBED_EXPORTS.any,
].sort();
it('classifies every twenty-sdk/define value-export', () => {
expect(stubbedExports).toEqual(realExports);
});
it('classifies all defineX exports (and createValidationResult) as factories', () => {
const expectedFactories = realExports
.filter(isDefineFactoryExportName)
.sort();
expect([...TWENTY_SDK_DEFINE_STUBBED_EXPORTS.factories].sort()).toEqual(
expectedFactories,
);
});
it('every factory is callable in the real module (would-be misclassification guard)', () => {
for (const name of TWENTY_SDK_DEFINE_STUBBED_EXPORTS.factories) {
const actual = (twentySdkDefine as unknown as Record<string, unknown>)[
name
];
expect(typeof actual).toBe('function');
}
});
// Snapshot to surface new exports in PR review. Update with
// `npx vitest -u` when intentional.
it('matches the recorded export partition', () => {
expect(TWENTY_SDK_DEFINE_STUBBED_EXPORTS).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,90 @@
import type * as esbuild from 'esbuild';
import * as twentySdkDefine from '@/sdk/define';
// Everything in twenty-sdk/define is build-time metadata for the manifest
// extractor and is dead code in the user-app bundle. Stubbing it drops zod,
// twenty-shared and ~1MB per logic-function bundle.
//
// Exports are derived from `Object.keys(twentySdkDefine)` so new ones land
// in the stub automatically; the unit test snapshots the partition.
// Factories must keep the `(config) => ({ config, ... })` shape so the
// manifest extractor can still read `.config`; anything else can be a Proxy.
export const isDefineFactoryExportName = (name: string): boolean =>
/^define[A-Z]/.test(name) || name === 'createValidationResult';
const partitionDefineExports = (
mod: Record<string, unknown>,
): { factories: readonly string[]; any: readonly string[] } => {
const factories: string[] = [];
const any: string[] = [];
for (const name of Object.keys(mod).sort()) {
if (isDefineFactoryExportName(name)) {
factories.push(name);
} else {
any.push(name);
}
}
return { factories, any };
};
export const TWENTY_SDK_DEFINE_STUBBED_EXPORTS = partitionDefineExports(
twentySdkDefine as unknown as Record<string, unknown>,
);
const VIRTUAL_NAMESPACE = 'twenty-sdk-define-stub';
const STUB_RESOLVED_PATH = '__twenty-sdk-define-stub__';
const STUB_PRELUDE = `
// Auto-generated stub for twenty-sdk/define injected by the SDK CLI build.
// Real implementations would pull in zod, twenty-shared and ~1MB of code; at
// runtime only \`default.config.handler\` is consumed, so tiny no-ops suffice.
const __defineFactoryStub = (config) => ({
success: true,
config,
errors: [],
});
const __anyHandler = {
get(_target, prop) {
if (prop === '__esModule') return true;
if (prop === Symbol.toPrimitive) return () => '';
if (typeof prop === 'symbol') return undefined;
return new Proxy(() => undefined, __anyHandler);
},
apply() {
return new Proxy(() => undefined, __anyHandler);
},
};
const __anyStub = new Proxy(() => undefined, __anyHandler);
`;
const buildStubModuleSource = (): string => {
const exportLines: string[] = [];
for (const name of TWENTY_SDK_DEFINE_STUBBED_EXPORTS.factories) {
exportLines.push(`export const ${name} = __defineFactoryStub;`);
}
for (const name of TWENTY_SDK_DEFINE_STUBBED_EXPORTS.any) {
exportLines.push(`export const ${name} = __anyStub;`);
}
return `${STUB_PRELUDE}\n${exportLines.join('\n')}\n`;
};
export const createStubTwentySdkDefinePlugin = (): esbuild.Plugin => ({
name: 'twenty-sdk-define-stub',
setup(build) {
build.onResolve({ filter: /^twenty-sdk\/define$/ }, () => ({
path: STUB_RESOLVED_PATH,
namespace: VIRTUAL_NAMESPACE,
}));
build.onLoad({ filter: /.*/, namespace: VIRTUAL_NAMESPACE }, () => ({
contents: buildStubModuleSource(),
loader: 'js',
}));
},
});

View File

@@ -0,0 +1,29 @@
import { type FileFolder } from 'twenty-shared/types';
import { type Location } from 'esbuild';
export interface RestartableWatcher {
restart(sourcePaths: string[]): Promise<void>;
start(): Promise<void>;
close(): Promise<void>;
shouldRestart(sourcePaths: string[]): boolean;
}
export type OnFileBuiltCallback = (options: {
fileFolder: FileFolder;
builtPath: string;
sourcePath: string;
checksum: string;
usesSdkClient?: boolean;
}) => void | Promise<void>;
export type OnBuildErrorCallback = (
errors: { error: string; location: Location | null }[],
) => void | Promise<void>;
export type RestartableWatcherOptions = {
appPath: string;
sourcePaths: string[];
watch?: boolean;
handleFileBuilt: OnFileBuiltCallback;
handleBuildError: OnBuildErrorCallback;
};

View File

@@ -0,0 +1,104 @@
import { spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import {
parseTscOutputLine,
type TypecheckError,
} from '@/cli/utilities/build/common/typecheck-plugin';
export type TscWatcherOptions = {
appPath: string;
onErrors: (errors: TypecheckError[]) => void;
};
export class TscWatcher {
private appPath: string;
private onErrors: (errors: TypecheckError[]) => void;
private process: ChildProcess | null = null;
private pendingErrors: TypecheckError[] = [];
private buffer = '';
private hasErrors = false;
constructor(options: TscWatcherOptions) {
this.appPath = options.appPath;
this.onErrors = options.onErrors;
}
async start(): Promise<void> {
const tscPath = path.join(this.appPath, 'node_modules', '.bin', 'tsc');
if (!(await pathExists(tscPath))) {
return;
}
const tsconfigPath = path.join(this.appPath, 'tsconfig.json');
this.process = spawn(
tscPath,
['--watch', '--noEmit', '--pretty', 'false', '-p', tsconfigPath],
{ cwd: this.appPath, stdio: ['ignore', 'pipe', 'pipe'] },
);
this.process.on('error', () => {
this.process = null;
});
this.process.stdout?.on('data', (chunk: Buffer) => {
this.handleOutput(chunk.toString());
});
this.process.stderr?.on('data', (chunk: Buffer) => {
this.handleOutput(chunk.toString());
});
}
close(): void {
this.process?.kill();
this.process = null;
}
private handleOutput(data: string): void {
this.buffer += data;
const lines = this.buffer.split('\n');
this.buffer = lines.pop() ?? '';
for (const line of lines) {
this.processLine(line);
}
}
private processLine(line: string): void {
if (
line.includes('Starting compilation in watch mode...') ||
line.includes('Starting incremental compilation...')
) {
this.pendingErrors = [];
return;
}
if (line.includes('Watching for file changes.')) {
const hadErrors = this.hasErrors;
this.hasErrors = this.pendingErrors.length > 0;
if (this.hasErrors || hadErrors) {
this.onErrors(this.pendingErrors);
}
this.pendingErrors = [];
return;
}
const error = parseTscOutputLine(line);
if (error) {
this.pendingErrors.push(error);
}
}
}

View File

@@ -0,0 +1,91 @@
import { execFile } from 'node:child_process';
import type * as esbuild from 'esbuild';
import path from 'node:path';
export type TypecheckError = {
text: string;
file: string;
line: number;
column: number;
};
const TSC_ERROR_REGEX = /^(.+)\((\d+),(\d+)\): error TS\d+: (.+)$/;
export const parseTscOutputLine = (line: string): TypecheckError | null => {
const match = line.match(TSC_ERROR_REGEX);
if (!match) {
return null;
}
const [, filePath, lineStr, columnStr, text] = match;
return {
text,
file: filePath,
line: Number(lineStr),
column: Number(columnStr) - 1,
};
};
const parseTscOutput = (output: string): TypecheckError[] => {
const errors: TypecheckError[] = [];
for (const line of output.split('\n')) {
const error = parseTscOutputLine(line);
if (error) {
errors.push(error);
}
}
return errors;
};
export const runTypecheck = (appPath: string): Promise<TypecheckError[]> => {
const tsconfigPath = path.join(appPath, 'tsconfig.json');
const tscPath = path.join(appPath, 'node_modules', '.bin', 'tsc');
return new Promise((resolve) => {
execFile(
tscPath,
['--noEmit', '--pretty', 'false', '-p', tsconfigPath],
{ cwd: appPath },
(_error, stdout, stderr) => {
resolve(parseTscOutput(stderr || stdout));
},
);
});
};
const toEsbuildErrors = (errors: TypecheckError[]): esbuild.PartialMessage[] =>
errors.map((error) => ({
text: error.text,
location: {
file: error.file,
line: error.line,
column: error.column,
lineText: '',
length: 0,
namespace: '',
suggestion: '',
},
}));
export const createTypecheckPlugin = (
appPath: string,
shouldSkipTypecheck: () => boolean,
): esbuild.Plugin => ({
name: 'typecheck',
setup: (build) => {
build.onStart(async () => {
if (shouldSkipTypecheck()) {
return;
}
const errors = await runTypecheck(appPath);
return { errors: toEsbuildErrors(errors) };
});
},
});

View File

@@ -0,0 +1,109 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { type Manifest } from 'twenty-shared/application';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyGeneratedCover } from '@/cli/utilities/build/cover/apply-generated-cover';
import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image';
import { GENERATED_COVER_PATH } from '@/cli/utilities/build/cover/generated-cover-path';
vi.mock('@/cli/utilities/build/cover/generate-cover-image');
const mockedGenerateCoverImage = vi.mocked(generateCoverImage);
const COVER_BUFFER = Buffer.from('generated-cover');
const buildManifest = (
application: Record<string, unknown>,
publicAssets: Manifest['publicAssets'] = [],
): Manifest =>
({
application,
publicAssets,
}) as unknown as Manifest;
describe('applyGeneratedCover', () => {
let appPath: string;
beforeEach(async () => {
vi.clearAllMocks();
mockedGenerateCoverImage.mockResolvedValue(COVER_BUFFER);
appPath = await mkdtemp(join(tmpdir(), 'cover-test-'));
});
afterEach(async () => {
await rm(appPath, { recursive: true, force: true });
});
const writeLogo = async (relativePath: string) => {
await mkdir(join(appPath, 'public'), { recursive: true });
await writeFile(join(appPath, relativePath), Buffer.from('logo'));
};
it('generates a cover when a local logo exists and no gallery images are set', async () => {
await writeLogo('public/logo.png');
const manifest = buildManifest({ logo: 'public/logo.png' });
const result = await applyGeneratedCover({ appPath, manifest });
expect(mockedGenerateCoverImage).toHaveBeenCalledTimes(1);
expect(result.manifest.application.galleryImages).toEqual([
GENERATED_COVER_PATH,
]);
expect(
result.manifest.publicAssets.some(
(asset) => asset.filePath === GENERATED_COVER_PATH,
),
).toBe(true);
expect(result.generatedAssets).toEqual([
{ relativePath: GENERATED_COVER_PATH, content: COVER_BUFFER },
]);
});
it('does nothing when there is no logo', async () => {
const manifest = buildManifest({});
const result = await applyGeneratedCover({ appPath, manifest });
expect(mockedGenerateCoverImage).not.toHaveBeenCalled();
expect(result.generatedAssets).toEqual([]);
expect(result.manifest.application.galleryImages).toBeUndefined();
});
it('does nothing when gallery images are already provided', async () => {
await writeLogo('public/logo.png');
const manifest = buildManifest({
logo: 'public/logo.png',
galleryImages: ['public/shot.png'],
});
const result = await applyGeneratedCover({ appPath, manifest });
expect(mockedGenerateCoverImage).not.toHaveBeenCalled();
expect(result.generatedAssets).toEqual([]);
expect(result.manifest.application.galleryImages).toEqual([
'public/shot.png',
]);
});
it('does nothing when the logo is an absolute url', async () => {
const manifest = buildManifest({
logo: 'https://example.com/logo.png',
});
const result = await applyGeneratedCover({ appPath, manifest });
expect(mockedGenerateCoverImage).not.toHaveBeenCalled();
expect(result.generatedAssets).toEqual([]);
});
it('does nothing when the logo file is missing', async () => {
const manifest = buildManifest({ logo: 'public/missing.png' });
const result = await applyGeneratedCover({ appPath, manifest });
expect(mockedGenerateCoverImage).not.toHaveBeenCalled();
expect(result.generatedAssets).toEqual([]);
});
});

View File

@@ -0,0 +1,38 @@
import sharp from 'sharp';
import { describe, expect, it } from 'vitest';
import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image';
const createLogoBuffer = () =>
sharp({
create: {
width: 200,
height: 200,
channels: 4,
background: { r: 108, g: 92, b: 224, alpha: 1 },
},
})
.png()
.toBuffer();
describe('generateCoverImage', () => {
it('produces a 1600x1000 png from a logo buffer', async () => {
const logoBuffer = await createLogoBuffer();
const cover = await generateCoverImage({ logoBuffer });
const metadata = await sharp(cover).metadata();
expect(metadata.format).toBe('png');
expect(metadata.width).toBe(1388);
expect(metadata.height).toBe(858);
});
it('renders deterministically for the same logo', async () => {
const logoBuffer = await createLogoBuffer();
const first = await generateCoverImage({ logoBuffer });
const second = await generateCoverImage({ logoBuffer });
expect(first.equals(second)).toBe(true);
});
});

View File

@@ -0,0 +1,67 @@
import { readFile } from 'node:fs/promises';
import { basename, extname, join } from 'path';
import { type AssetManifest, type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { generateCoverImage } from '@/cli/utilities/build/cover/generate-cover-image';
import { type GeneratedAsset } from '@/cli/utilities/build/cover/generated-asset.type';
import { GENERATED_COVER_PATH } from '@/cli/utilities/build/cover/generated-cover-path';
import { pathExists } from '@/cli/utilities/file/fs-utils';
const isAbsoluteUrl = (url: string): boolean =>
url.startsWith('http://') || url.startsWith('https://');
export const applyGeneratedCover = async ({
appPath,
manifest,
}: {
appPath: string;
manifest: Manifest;
}): Promise<{ manifest: Manifest; generatedAssets: GeneratedAsset[] }> => {
const { logo, galleryImages } = manifest.application;
if (!isDefined(logo) || isAbsoluteUrl(logo)) {
return { manifest, generatedAssets: [] };
}
if ((galleryImages ?? []).length > 0) {
return { manifest, generatedAssets: [] };
}
const logoAbsolutePath = join(appPath, logo);
if (!(await pathExists(logoAbsolutePath))) {
return { manifest, generatedAssets: [] };
}
const logoBuffer = await readFile(logoAbsolutePath);
const coverBuffer = await generateCoverImage({ logoBuffer });
const coverAsset: AssetManifest = {
filePath: GENERATED_COVER_PATH,
fileName: basename(GENERATED_COVER_PATH),
fileType: extname(GENERATED_COVER_PATH).replace(/^\./, ''),
checksum: null,
};
const publicAssets = [
...manifest.publicAssets.filter(
(asset) => asset.filePath !== GENERATED_COVER_PATH,
),
coverAsset,
];
return {
manifest: {
...manifest,
application: {
...manifest.application,
galleryImages: [GENERATED_COVER_PATH],
},
publicAssets,
},
generatedAssets: [
{ relativePath: GENERATED_COVER_PATH, content: coverBuffer },
],
};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,2 @@
export const TWENTY_LOGO_MARK_PATH =
'M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z';

View File

@@ -0,0 +1,67 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import sharp from 'sharp';
import { TWENTY_LOGO_MARK_PATH } from '@/cli/utilities/build/cover/assets/twenty-logo-mark-path';
const readHalftoneBackdropDataUri = async (): Promise<string> => {
const backdropBuffer = await readFile(
join(__dirname, 'assets', 'halftone-backdrop.png'),
);
return `data:image/png;base64,${backdropBuffer.toString('base64')}`;
};
const CANVAS_WIDTH = 1388;
const CANVAS_HEIGHT = 858;
const TILE_SIZE = 156;
const TILE_RADIUS = 16;
const TILE_Y = 351;
const LEFT_TILE_X = 434;
const RIGHT_TILE_X = 798;
const CENTER_X = CANVAS_WIDTH / 2;
const CENTER_Y = CANVAS_HEIGHT / 2;
const CROSS_ARM = 19;
const CROSS_STROKE = 8;
const CROSS_COLOR = '#b3b3b3';
const TWENTY_MARK_VIEWBOX = 40;
const TWENTY_MARK_SCALE = TILE_SIZE / TWENTY_MARK_VIEWBOX;
type GenerateCoverImageOptions = {
logoBuffer: Buffer;
};
export const generateCoverImage = async ({
logoBuffer,
}: GenerateCoverImageOptions): Promise<Buffer> => {
const logoPngBuffer = await sharp(logoBuffer)
.resize(TILE_SIZE, TILE_SIZE, { fit: 'cover', position: 'centre' })
.png()
.toBuffer();
const logoDataUri = `data:image/png;base64,${logoPngBuffer.toString('base64')}`;
const backdropDataUri = await readHalftoneBackdropDataUri();
const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" viewBox="0 0 ${CANVAS_WIDTH} ${CANVAS_HEIGHT}">`,
`<image href="${backdropDataUri}" x="0" y="0" width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" preserveAspectRatio="none" />`,
'<defs>',
'<filter id="tileShadow" x="-40%" y="-40%" width="180%" height="180%">',
'<feDropShadow dx="0" dy="6" stdDeviation="10" flood-color="#0b0b0f" flood-opacity="0.14" />',
'</filter>',
`<clipPath id="leftLogoClip"><rect x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" /></clipPath>`,
'</defs>',
'<g filter="url(#tileShadow)">',
`<rect x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" fill="#ffffff" />`,
`<rect x="${RIGHT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" fill="#000000" />`,
'</g>',
`<image href="${logoDataUri}" x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" preserveAspectRatio="xMidYMid slice" clip-path="url(#leftLogoClip)" />`,
`<rect x="${LEFT_TILE_X}" y="${TILE_Y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="${TILE_RADIUS}" fill="none" stroke="#000000" stroke-opacity="0.08" stroke-width="1" />`,
`<g transform="translate(${RIGHT_TILE_X} ${TILE_Y}) scale(${TWENTY_MARK_SCALE})"><path d="${TWENTY_LOGO_MARK_PATH}" fill="#ffffff" /></g>`,
`<line x1="${CENTER_X - CROSS_ARM}" y1="${CENTER_Y - CROSS_ARM}" x2="${CENTER_X + CROSS_ARM}" y2="${CENTER_Y + CROSS_ARM}" stroke="${CROSS_COLOR}" stroke-width="${CROSS_STROKE}" stroke-linecap="round" />`,
`<line x1="${CENTER_X - CROSS_ARM}" y1="${CENTER_Y + CROSS_ARM}" x2="${CENTER_X + CROSS_ARM}" y2="${CENTER_Y - CROSS_ARM}" stroke="${CROSS_COLOR}" stroke-width="${CROSS_STROKE}" stroke-linecap="round" />`,
'</svg>',
].join('');
return sharp(Buffer.from(svg)).png().toBuffer();
};

View File

@@ -0,0 +1,4 @@
export type GeneratedAsset = {
relativePath: string;
content: Buffer;
};

View File

@@ -0,0 +1,3 @@
import { ASSETS_DIR } from 'twenty-shared/application';
export const GENERATED_COVER_PATH = `${ASSETS_DIR}/cover.generated.png`;

View File

@@ -0,0 +1,41 @@
import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
const ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER =
'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d';
const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER =
'54b589ca-eeed-4950-a176-358418b85c05';
describe('buildManifest logic function input schema inference', () => {
it('resolves record-typed inputs to standard and app object universal identifiers', async () => {
const { manifest, errors } = await buildManifest(RICH_APP_PATH);
expect(errors).toEqual([]);
expect(manifest).not.toBeNull();
const logicFunction = manifest?.logicFunctions.find(
(entry) =>
entry.universalIdentifier ===
ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER,
);
expect(logicFunction).toBeDefined();
expect(logicFunction?.workflowActionTriggerSettings?.inputSchema).toEqual([
{
type: 'object',
properties: {
companyId: {
type: 'record',
objectUniversalIdentifier:
STANDARD_OBJECTS.company.universalIdentifier,
},
postCardIds: {
type: 'records',
objectUniversalIdentifier: POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER,
},
},
},
]);
}, 60000);
});

View File

@@ -0,0 +1,30 @@
import { readFile } from 'node:fs/promises';
import { join } from 'path';
import {
FUNCTION_EXECUTE_APP_PATH,
MINIMAL_APP_PATH,
} from '@/cli/__tests__/apps/fixture-paths';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
describe('buildManifest aboutDescription from README', () => {
it('populates application.aboutDescription from the app README when not set in config', async () => {
const { manifest, errors } = await buildManifest(FUNCTION_EXECUTE_APP_PATH);
expect(errors).toEqual([]);
const readme = await readFile(
join(FUNCTION_EXECUTE_APP_PATH, 'README.md'),
'utf-8',
);
expect(manifest?.application.aboutDescription).toBe(readme);
}, 60000);
it('leaves application.aboutDescription undefined when the app has no README', async () => {
const { manifest, errors } = await buildManifest(MINIMAL_APP_PATH);
expect(errors).toEqual([]);
expect(manifest?.application.aboutDescription).toBeUndefined();
}, 60000);
});

View File

@@ -0,0 +1,31 @@
import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
const POST_CARD_NUMBER_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
'cd582d11-ea21-4dc3-b9c1-0298ce3b6b54';
const ALL_POST_CARDS_VIEW_ID = 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d';
const POST_CARD_NUMBER_FIELD_UNIVERSAL_IDENTIFIER =
'7b57bd63-5a4c-46ca-9d52-42c8f02d1df6';
describe('buildManifest standalone view fields', () => {
it('collects top-level defineViewField exports into manifest.viewFields', async () => {
const { manifest, errors } = await buildManifest(RICH_APP_PATH);
expect(errors).toEqual([]);
expect(manifest).not.toBeNull();
const viewField = manifest?.viewFields.find(
(entry) =>
entry.universalIdentifier ===
POST_CARD_NUMBER_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
);
expect(viewField).toBeDefined();
expect(viewField?.viewUniversalIdentifier).toBe(ALL_POST_CARDS_VIEW_ID);
expect(viewField?.fieldMetadataUniversalIdentifier).toBe(
POST_CARD_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
);
expect(viewField?.position).toBe(5);
expect(viewField?.isVisible).toBe(true);
}, 60000);
});

View File

@@ -0,0 +1,135 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import vm from 'node:vm';
import { createRequire } from 'module';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { type ApplicationConfig } from '@/sdk/define';
describe('extractManifestFromFile', () => {
const filePath = join(MINIMAL_APP_PATH, 'application.config.ts');
it('extracts the default-exported config from a bundled entity file', async () => {
const result = await extractManifestFromFile<ApplicationConfig>({
filePath,
appPath: MINIMAL_APP_PATH,
});
expect(result.config.displayName).toBe('Root App');
}, 60000);
// Regression test for the dev-mode OOM crash: bundled entity modules used to be
// written to disk and required, leaking one fully-bundled module per file into
// the require cache on every rebuild. Evaluating in memory keeps it bounded.
it('does not grow the require cache across rebuilds', async () => {
const requireCache = createRequire(import.meta.url).cache;
// Warm up so first-time dependency loads don't count against the assertion.
await extractManifestFromFile<ApplicationConfig>({
filePath,
appPath: MINIMAL_APP_PATH,
});
const before = Object.keys(requireCache).length;
for (let index = 0; index < 5; index++) {
await extractManifestFromFile<ApplicationConfig>({
filePath,
appPath: MINIMAL_APP_PATH,
});
}
expect(Object.keys(requireCache).length).toBe(before);
}, 60000);
});
// Regression guard for the dev-mode OOM (twenty#2601). The manifest is rebuilt
// on every file change and previously recompiled every entity file through
// vm.compileFunction each time, which V8 never releases. The compiled wrapper
// is now cached per file, so an unchanged file must not be recompiled and a
// changed file must replace (not accumulate) its previous build. Fresh temp
// files are used so the per-file cache is cold regardless of test order.
describe('extractManifestFromFile compiled-module caching', () => {
let tmpDir: string;
const writeConfigFile = async (
configFilePath: string,
config: Record<string, unknown>,
): Promise<void> => {
await writeFile(
configFilePath,
`export default ${JSON.stringify(config)};\n`,
'utf8',
);
};
beforeEach(async () => {
tmpDir = await mkdtemp(join(os.tmpdir(), 'manifest-extract-cache-test-'));
await writeFile(
join(tmpDir, 'package.json'),
'{ "name": "test-app" }',
'utf8',
);
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(tmpDir, { recursive: true, force: true });
});
it('compiles an unchanged file only once across repeated builds', async () => {
const configFilePath = join(tmpDir, 'unchanged-config.ts');
const config = { name: 'unchanged' };
await writeConfigFile(configFilePath, config);
const compileFunctionSpy = vi.spyOn(vm, 'compileFunction');
const firstResult = await extractManifestFromFile({
filePath: configFilePath,
appPath: tmpDir,
});
const secondResult = await extractManifestFromFile({
filePath: configFilePath,
appPath: tmpDir,
});
const thirdResult = await extractManifestFromFile({
filePath: configFilePath,
appPath: tmpDir,
});
// Three rebuilds, but identical output is compiled only once.
expect(compileFunctionSpy).toHaveBeenCalledTimes(1);
// Behaviour is unchanged: every call still returns the extracted config.
expect(firstResult).toEqual(config);
expect(secondResult).toEqual(config);
expect(thirdResult).toEqual(config);
}, 60000);
it('recompiles when the file output changes', async () => {
const configFilePath = join(tmpDir, 'changing-config.ts');
await writeConfigFile(configFilePath, { name: 'before' });
const compileFunctionSpy = vi.spyOn(vm, 'compileFunction');
const beforeResult = await extractManifestFromFile({
filePath: configFilePath,
appPath: tmpDir,
});
await writeConfigFile(configFilePath, { name: 'after' });
const afterResult = await extractManifestFromFile({
filePath: configFilePath,
appPath: tmpDir,
});
// One compile per distinct output, and the new content is returned.
expect(compileFunctionSpy).toHaveBeenCalledTimes(2);
expect(beforeResult).toEqual({ name: 'before' });
expect(afterResult).toEqual({ name: 'after' });
}, 60000);
});

View File

@@ -0,0 +1,93 @@
import { extractDefineEntity } from '@/cli/utilities/build/manifest/manifest-extract-config';
describe('extractDefineEntity', () => {
it('should detect defineApplication in default export', () => {
const fileContent = `
import { defineApplication } from 'twenty-sdk/define';
export default defineApplication({ name: 'MyApp' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineApplication');
});
it('should detect defineField in default export', () => {
const fileContent = `
import { defineField } from 'twenty-sdk/define';
export default defineField({ name: 'myField' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineField');
});
it('should detect defineLogicFunction in default export', () => {
const fileContent = `
import { defineLogicFunction } from 'twenty-sdk/define';
export default defineLogicFunction({ name: 'myFunction' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineLogicFunction');
});
it('should detect defineObject in default export', () => {
const fileContent = `
import { defineObject } from 'twenty-sdk/define';
export default defineObject({ name: 'myObject' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineObject');
});
it('should detect defineRole in default export', () => {
const fileContent = `
import { defineRole } from 'twenty-sdk/define';
export default defineRole({ name: 'myRole' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineRole');
});
it('should detect defineApplicationRole in default export', () => {
const fileContent = `
import { defineApplicationRole } from 'twenty-sdk/define';
export default defineApplicationRole({ universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061', label: 'Default function role' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineApplicationRole');
});
it('should detect defineFrontComponent in default export', () => {
const fileContent = `
import { defineFrontComponent } from 'twenty-sdk/define';
export default defineFrontComponent({ name: 'myComponent' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBe('defineFrontComponent');
});
it('should not detect non-target function in default export', () => {
const fileContent = `
import { someOtherFunction } from 'twenty-sdk/define';
export default someOtherFunction({ name: 'myFunction' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBeUndefined();
});
it('should handle files without default export', () => {
const fileContent = `
import { defineApplication } from 'twenty-sdk/define';
const app = defineApplication({ name: 'MyApp' });
`;
const result = extractDefineEntity(fileContent);
expect(result).toBeUndefined();
});
it('should handle files with non-call default export', () => {
const fileContent = `
import { defineApplication } from 'twenty-sdk/define';
export default { name: 'MyApp' };
`;
const result = extractDefineEntity(fileContent);
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import { join } from 'path';
import { MINIMAL_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
describe('extractManifestFromFile - front component importing twenty-ui', () => {
it('extracts the config from a front component that side-effect imports twenty-ui/style.css', async () => {
const filePath = join(MINIMAL_APP_PATH, 'my.front-component.tsx');
const result = await extractManifestFromFile<FrontComponentConfig>({
appPath: MINIMAL_APP_PATH,
filePath,
});
expect(result.errors).toEqual([]);
expect(result.config.universalIdentifier).toBe(
'e1e2e3e4-e5e6-4000-8000-000000000020',
);
expect(result.config.name).toBe('my-component');
}, 30000);
});

View File

@@ -0,0 +1,69 @@
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
import { validatePackageJsonDependencies } from '@/cli/utilities/build/manifest/utils/validate-package-json-dependencies';
import { validateYarnLock } from '@/cli/utilities/build/manifest/utils/validate-yarn-lock';
import { type Manifest } from 'twenty-shared/application';
export type BuildAndValidateManifestSuccess = {
success: true;
manifest: Manifest;
filePaths: EntityFilePaths;
warnings: string[];
};
export type BuildAndValidateManifestFailure = {
success: false;
errors: string[];
};
export type BuildAndValidateManifestResult =
| BuildAndValidateManifestSuccess
| BuildAndValidateManifestFailure;
export const buildAndValidateManifest = async (
appPath: string,
): Promise<BuildAndValidateManifestResult> => {
const result = await buildManifest(appPath);
if (result.errors.length > 0 || !result.manifest) {
return {
success: false,
errors:
result.errors.length > 0
? result.errors
: ['Failed to build manifest.'],
};
}
const validation = manifestValidate(result.manifest);
if (!validation.isValid) {
return {
success: false,
errors: validation.errors,
};
}
const yarnLockErrors = await validateYarnLock(appPath);
if (yarnLockErrors.length > 0) {
return {
success: false,
errors: yarnLockErrors,
};
}
const packageJsonWarnings = await validatePackageJsonDependencies(appPath);
return {
success: true,
manifest: result.manifest,
filePaths: result.filePaths,
warnings: [
...result.warnings,
...validation.warnings,
...packageJsonWarnings,
],
};
};

View File

@@ -0,0 +1,743 @@
import {
type EntityFilePaths,
extractDefineEntity,
ManifestEntityKey,
TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING,
TargetFunction,
} from '@/cli/utilities/build/manifest/manifest-extract-config';
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids';
import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest';
import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields';
import { extractFrontComponentSharedDependencies } from '@/cli/utilities/build/manifest/utils/extract-front-component-shared-dependencies';
import { validateConditionalAvailabilityUsage } from '@/cli/utilities/build/manifest/utils/validate-conditional-availability-usage';
import { validateViewFilterOperands } from '@/cli/utilities/build/manifest/utils/validate-view-filter-operands';
import { getEngineVersionRange } from '@/cli/utilities/version/get-engine-version-range';
import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define';
import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
import { type IndexConfig } from '@/sdk/define/indexes/index-config';
import { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config';
import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/pre-install-logic-function-config';
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
import { type RoleConfig } from '@/sdk/define/roles/role-config';
import { type TimelineActivityTypeConfig } from '@/sdk/define/timeline-activity-types/timeline-activity-type-config';
import { type ViewConfig } from '@/sdk/define/views/view-config';
import { readFile } from 'node:fs/promises';
import { basename, extname, join, relative } from 'path';
import { glob } from 'tinyglobby';
import {
type AgentManifest,
type ApplicationManifest,
type AssetManifest,
ASSETS_DIR,
type CommandMenuItemManifest,
type ConnectionProviderManifest,
type FieldManifest,
type FrontComponentManifest,
type IndexManifest,
type LogicFunctionManifest,
type Manifest,
type NavigationMenuItemManifest,
type ObjectManifest,
type PageLayoutManifest,
type PageLayoutTabManifest,
type PermissionFlagManifest,
type PostInstallLogicFunctionApplicationManifest,
type PreInstallLogicFunctionApplicationManifest,
type UninstallLogicFunctionApplicationManifest,
type RoleManifest,
type SkillManifest,
type StandalonePageLayoutWidgetManifest,
type StandaloneViewFieldManifest,
type TimelineActivityTypeManifest,
type ViewManifest,
} from 'twenty-shared/application';
import {
getInputSchemaFromSourceCode,
jsonSchemaToInputSchema,
} from 'twenty-shared/logic-function';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
const loadSources = async (appPath: string): Promise<string[]> => {
return await glob(['**/*.ts', '**/*.tsx'], {
cwd: appPath,
absolute: true,
ignore: ['**/node_modules/**', '**/*.d.ts', '**/dist/**', '**/.twenty/**'],
onlyFiles: true,
});
};
const loadAssets = async (appPath: string) => {
return await glob([`${ASSETS_DIR}/**/*`], {
cwd: appPath,
absolute: true,
onlyFiles: true,
});
};
const loadReadme = async (appPath: string): Promise<string | undefined> => {
try {
const content = await readFile(join(appPath, 'README.md'), 'utf-8');
return content.trim().length > 0 ? content : undefined;
} catch {
return undefined;
}
};
export const buildManifest = async (
appPath: string,
): Promise<{
manifest: Manifest | null;
filePaths: EntityFilePaths;
errors: string[];
warnings: string[];
}> => {
const filePaths = await loadSources(appPath);
const readmeContent = await loadReadme(appPath);
const errors: string[] = [];
const warnings: string[] = [];
let applicationConfig: ApplicationConfig | undefined;
const objectConfigs: ObjectConfig[] = [];
const roleConfigs: RoleConfig[] = [];
const objects: ObjectManifest[] = [];
const fields: FieldManifest[] = [];
const indexes: IndexManifest[] = [];
const permissionFlags: PermissionFlagManifest[] = [];
const roles: RoleManifest[] = [];
const skills: SkillManifest[] = [];
const agents: AgentManifest[] = [];
const connectionProviders: ConnectionProviderManifest[] = [];
const logicFunctions: LogicFunctionManifest[] = [];
const frontComponents: FrontComponentManifest[] = [];
const publicAssets: AssetManifest[] = [];
const views: ViewManifest[] = [];
const viewFields: StandaloneViewFieldManifest[] = [];
const navigationMenuItems: NavigationMenuItemManifest[] = [];
const pageLayouts: PageLayoutManifest[] = [];
const pageLayoutTabs: PageLayoutTabManifest[] = [];
const pageLayoutWidgets: StandalonePageLayoutWidgetManifest[] = [];
const commandMenuItems: CommandMenuItemManifest[] = [];
const timelineActivityTypes: TimelineActivityTypeManifest[] = [];
const postInstallLogicFunctions: PostInstallLogicFunctionApplicationManifest[] =
[];
const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] =
[];
const uninstallLogicFunctions: UninstallLogicFunctionApplicationManifest[] =
[];
const settingsFrontComponentUniversalIdentifiers: string[] = [];
const applicationRoleUniversalIdentifiers: string[] = [];
const applicationFilePaths: string[] = [];
const objectsFilePaths: string[] = [];
const fieldsFilePaths: string[] = [];
const indexesFilePaths: string[] = [];
const permissionFlagsFilePaths: string[] = [];
const rolesFilePaths: string[] = [];
const skillsFilePaths: string[] = [];
const agentsFilePaths: string[] = [];
const connectionProvidersFilePaths: string[] = [];
const logicFunctionsFilePaths: string[] = [];
const frontComponentsFilePaths: string[] = [];
const publicAssetsFilePaths: string[] = [];
const viewsFilePaths: string[] = [];
const viewFieldsFilePaths: string[] = [];
const navigationMenuItemsFilePaths: string[] = [];
const pageLayoutsFilePaths: string[] = [];
const pageLayoutTabsFilePaths: string[] = [];
const pageLayoutWidgetsFilePaths: string[] = [];
const commandMenuItemsFilePaths: string[] = [];
const timelineActivityTypesFilePaths: string[] = [];
for (const filePath of filePaths) {
const fileContent = await readFile(filePath, 'utf-8');
const relativePath = relative(appPath, filePath);
errors.push(
...validateConditionalAvailabilityUsage(fileContent, relativePath),
);
const targetFunctionName = extractDefineEntity(fileContent);
if (!targetFunctionName) {
continue;
}
const entity = TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING[targetFunctionName];
switch (entity) {
case ManifestEntityKey.Application: {
const extract = await extractManifestFromFile<ApplicationConfig>({
appPath,
filePath,
});
applicationConfig = extract.config;
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
applicationFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Objects: {
const extract = await extractManifestFromFile<ObjectConfig>({
appPath,
filePath,
});
objectConfigs.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
objectsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Fields: {
const extract = await extractManifestFromFile<FieldManifest>({
appPath,
filePath,
});
const fieldConfig = addMissingFieldOptionIds(extract.config);
fields.push(fieldConfig);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
fieldsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PermissionFlags: {
const extract = await extractManifestFromFile<PermissionFlagManifest>({
appPath,
filePath,
});
permissionFlags.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
permissionFlagsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Roles: {
const extract = await extractManifestFromFile<RoleConfig>({
appPath,
filePath,
});
roleConfigs.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
rolesFilePaths.push(relativePath);
if (targetFunctionName === TargetFunction.DefineApplicationRole) {
applicationRoleUniversalIdentifiers.push(
extract.config.universalIdentifier,
);
}
break;
}
case ManifestEntityKey.Skills: {
const extract = await extractManifestFromFile<SkillManifest>({
appPath,
filePath,
});
skills.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
skillsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Agents: {
const extract = await extractManifestFromFile<AgentManifest>({
appPath,
filePath,
});
agents.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
agentsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.ConnectionProviders: {
const extract =
await extractManifestFromFile<ConnectionProviderManifest>({
appPath,
filePath,
});
connectionProviders.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
connectionProvidersFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.LogicFunctions: {
const extract = await extractManifestFromFile<LogicFunctionConfig>({
appPath,
filePath,
});
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
const { handler: _, ...rest } = extract.config;
const inferredJsonSchema =
(rest.toolTriggerSettings && !rest.toolTriggerSettings.inputSchema) ||
(rest.workflowActionTriggerSettings &&
!rest.workflowActionTriggerSettings.inputSchema)
? await getInputSchemaFromSourceCode(fileContent)
: null;
const toolTriggerSettings = rest.toolTriggerSettings
? {
...rest.toolTriggerSettings,
inputSchema:
rest.toolTriggerSettings.inputSchema ??
inferredJsonSchema ??
undefined,
}
: undefined;
const workflowActionTriggerSettings = rest.workflowActionTriggerSettings
? {
...rest.workflowActionTriggerSettings,
inputSchema:
rest.workflowActionTriggerSettings.inputSchema ??
(inferredJsonSchema
? jsonSchemaToInputSchema(inferredJsonSchema)
: undefined),
}
: undefined;
const config: LogicFunctionManifest = {
...rest,
...(toolTriggerSettings ? { toolTriggerSettings } : {}),
...(workflowActionTriggerSettings
? { workflowActionTriggerSettings }
: {}),
handlerName: 'default.config.handler',
sourceHandlerPath: relativePath,
builtHandlerPath: relativePath.replace(/\.tsx?$/, '.mjs'),
builtHandlerChecksum: '[default-checksum]',
};
logicFunctions.push(config);
logicFunctionsFilePaths.push(relativePath);
if (
targetFunctionName === TargetFunction.DefinePostInstallLogicFunction
) {
const postInstallHookConfig =
extract.config as PostInstallLogicFunctionConfig;
postInstallLogicFunctions.push({
universalIdentifier: extract.config.universalIdentifier,
shouldRunOnVersionUpgrade:
postInstallHookConfig.shouldRunOnVersionUpgrade ?? false,
shouldRunSynchronously:
postInstallHookConfig.shouldRunSynchronously ?? false,
});
}
if (
targetFunctionName === TargetFunction.DefinePreInstallLogicFunction
) {
const preInstallHookConfig =
extract.config as PreInstallLogicFunctionConfig;
preInstallLogicFunctions.push({
universalIdentifier: extract.config.universalIdentifier,
shouldRunOnVersionUpgrade:
preInstallHookConfig.shouldRunOnVersionUpgrade ?? false,
});
}
if (
targetFunctionName === TargetFunction.DefineUninstallLogicFunction
) {
uninstallLogicFunctions.push({
universalIdentifier: extract.config.universalIdentifier,
});
}
break;
}
case ManifestEntityKey.FrontComponents: {
const extract = await extractManifestFromFile<FrontComponentConfig>({
appPath,
filePath,
});
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
const { component, ...rest } = extract.config;
const relativeFilePath = relative(appPath, filePath);
const config: FrontComponentManifest = {
...rest,
componentName: component.name,
sourceComponentPath: relativeFilePath,
builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
builtComponentChecksum: '',
isHeadless: rest.isHeadless ?? false,
};
frontComponents.push(config);
frontComponentsFilePaths.push(relativePath);
if (
targetFunctionName === TargetFunction.DefineSettingsFrontComponent
) {
settingsFrontComponentUniversalIdentifiers.push(
extract.config.universalIdentifier,
);
}
break;
}
case ManifestEntityKey.Views: {
const extract = await extractManifestFromFile<ViewConfig>({
appPath,
filePath,
});
const viewManifest: ViewManifest = {
...extract.config,
};
views.push(viewManifest);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
viewsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.ViewFields: {
const extract =
await extractManifestFromFile<StandaloneViewFieldManifest>({
appPath,
filePath,
});
viewFields.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
viewFieldsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.NavigationMenuItems: {
const extract =
await extractManifestFromFile<NavigationMenuItemManifest>({
appPath,
filePath,
});
navigationMenuItems.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
navigationMenuItemsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PageLayouts: {
const extract = await extractManifestFromFile<PageLayoutConfig>({
appPath,
filePath,
});
const pageLayoutManifest: PageLayoutManifest = {
...extract.config,
};
pageLayouts.push(pageLayoutManifest);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
pageLayoutsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.Indexes: {
const extract = await extractManifestFromFile<IndexConfig>({
appPath,
filePath,
});
const indexManifest: IndexManifest = {
...extract.config,
};
indexes.push(indexManifest);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
indexesFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PageLayoutTabs: {
const extract = await extractManifestFromFile<PageLayoutTabConfig>({
appPath,
filePath,
});
const pageLayoutTabManifest: PageLayoutTabManifest = {
...extract.config,
};
pageLayoutTabs.push(pageLayoutTabManifest);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
pageLayoutTabsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PageLayoutWidgets: {
const extract =
await extractManifestFromFile<StandalonePageLayoutWidgetManifest>({
appPath,
filePath,
});
pageLayoutWidgets.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
pageLayoutWidgetsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.CommandMenuItems: {
const extract = await extractManifestFromFile<CommandMenuItemConfig>({
appPath,
filePath,
});
commandMenuItems.push(
extract.config as unknown as CommandMenuItemManifest,
);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
commandMenuItemsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.TimelineActivityTypes: {
const extract =
await extractManifestFromFile<TimelineActivityTypeConfig>({
appPath,
filePath,
});
timelineActivityTypes.push(extract.config);
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
timelineActivityTypesFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PublicAssets: {
// Public assets are handled below
break;
}
default: {
assertUnreachable(entity);
}
}
}
const assetFiles = await loadAssets(appPath);
for (const assetFile of assetFiles) {
const relativePath = relative(appPath, assetFile);
publicAssets.push({
filePath: relativePath,
fileName: basename(assetFile),
fileType: extname(assetFile).replace(/^\./, ''),
checksum: null,
});
publicAssetsFilePaths.push(relativePath);
}
if (!applicationConfig) {
errors.push(
'Cannot build application, please export default defineApplication() to define an application',
);
}
if (applicationConfig) {
for (const objectConfig of objectConfigs) {
const { objectFields: objectFieldsWithDefaults } =
getDefaultFieldsInObjectFields({
objectConfig,
applicationUniversalIdentifier: applicationConfig.universalIdentifier,
});
const labelIdentifierFieldMetadataUniversalIdentifier =
objectConfig.labelIdentifierFieldMetadataUniversalIdentifier ??
objectFieldsWithDefaults.find((field) => field.name === 'name')
?.universalIdentifier;
if (!labelIdentifierFieldMetadataUniversalIdentifier) {
errors.push(
`No label identifier field found for object ${objectConfig.nameSingular}. Please add a field with name "name" to your object.`,
);
continue;
}
const objectManifest: ObjectManifest = {
...objectConfig,
fields: objectFieldsWithDefaults.map(addMissingFieldOptionIds),
labelIdentifierFieldMetadataUniversalIdentifier,
};
objects.push(objectManifest);
}
for (const roleConfig of roleConfigs) {
roles.push(
fromRoleConfigToRoleManifest({
roleConfig,
applicationUniversalIdentifier: applicationConfig.universalIdentifier,
}),
);
}
}
if (postInstallLogicFunctions.length > 1) {
errors.push(
'Only one post install logic function is allowed per application',
);
}
if (preInstallLogicFunctions.length > 1) {
errors.push(
'Only one pre install logic function is allowed per application',
);
}
if (uninstallLogicFunctions.length > 1) {
errors.push('Only one uninstall logic function is allowed per application');
}
if (settingsFrontComponentUniversalIdentifiers.length > 1) {
errors.push('Only one settings front component is allowed per application');
}
if (applicationRoleUniversalIdentifiers.length > 1) {
errors.push('Only one defineApplicationRole is allowed per application');
}
const { sharedDependencies, errors: sharedDependenciesErrors } =
await extractFrontComponentSharedDependencies(appPath);
errors.push(...sharedDependenciesErrors);
const resolvedDefaultRoleUniversalIdentifier =
applicationConfig?.defaultRoleUniversalIdentifier ??
(applicationRoleUniversalIdentifiers.length === 1
? applicationRoleUniversalIdentifiers[0]
: undefined);
if (applicationConfig && !resolvedDefaultRoleUniversalIdentifier) {
errors.push(
'Application must declare a default role: either pass `defaultRoleUniversalIdentifier` to defineApplication() or mark a role file with defineApplicationRole()',
);
}
errors.push(
...validateViewFilterOperands({
views,
objects,
fields,
}),
);
const application: ApplicationManifest | undefined =
applicationConfig && resolvedDefaultRoleUniversalIdentifier
? (() => {
const {
logoUrl: _logoUrl,
screenshots: _screenshots,
...applicationConfigRest
} = applicationConfig;
return {
...applicationConfigRest,
logo: applicationConfig.logo,
galleryImages: applicationConfig.galleryImages ?? [],
defaultRoleUniversalIdentifier:
resolvedDefaultRoleUniversalIdentifier,
aboutDescription: readmeContent,
yarnLockChecksum: null,
packageJsonChecksum: null,
requiredServerVersionRange: getEngineVersionRange(appPath),
...(postInstallLogicFunctions.length >= 1
? { postInstallLogicFunction: postInstallLogicFunctions[0] }
: {}),
...(preInstallLogicFunctions.length >= 1
? { preInstallLogicFunction: preInstallLogicFunctions[0] }
: {}),
...(uninstallLogicFunctions.length >= 1
? { uninstallLogicFunction: uninstallLogicFunctions[0] }
: {}),
...(settingsFrontComponentUniversalIdentifiers.length >= 1
? {
settingsFrontComponent: {
universalIdentifier:
settingsFrontComponentUniversalIdentifiers[0],
},
}
: {}),
...(isDefined(sharedDependencies)
? { frontComponentSharedDependencies: sharedDependencies }
: {}),
};
})()
: undefined;
const byId = <T extends { universalIdentifier: string }>(a: T, b: T) =>
a.universalIdentifier.localeCompare(b.universalIdentifier);
const byPath = <T extends { filePath: string }>(a: T, b: T) =>
a.filePath.localeCompare(b.filePath);
const manifest: Manifest | null = !application
? null
: {
application,
objects: objects.sort(byId),
fields: fields.sort(byId),
indexes: indexes.sort(byId),
permissionFlags: permissionFlags.sort(byId),
roles: roles.sort(byId),
skills: skills.sort(byId),
agents: agents.sort(byId),
connectionProviders: connectionProviders.sort(byId),
logicFunctions: logicFunctions.sort(byId),
frontComponents: frontComponents.sort(byId),
publicAssets: publicAssets.sort(byPath),
views: views.sort(byId),
viewFields: viewFields.sort(byId),
navigationMenuItems: navigationMenuItems.sort(byId),
pageLayouts: pageLayouts.sort(byId),
pageLayoutTabs: pageLayoutTabs.sort(byId),
pageLayoutWidgets: pageLayoutWidgets.sort(byId),
commandMenuItems: commandMenuItems.sort(byId),
timelineActivityTypes: timelineActivityTypes.sort(byId),
};
const entityFilePaths: EntityFilePaths = {
application: applicationFilePaths,
objects: objectsFilePaths,
fields: fieldsFilePaths,
indexes: indexesFilePaths,
permissionFlags: permissionFlagsFilePaths,
roles: rolesFilePaths,
skills: skillsFilePaths,
agents: agentsFilePaths,
connectionProviders: connectionProvidersFilePaths,
logicFunctions: logicFunctionsFilePaths,
frontComponents: frontComponentsFilePaths,
publicAssets: publicAssetsFilePaths,
views: viewsFilePaths,
viewFields: viewFieldsFilePaths,
navigationMenuItems: navigationMenuItemsFilePaths,
pageLayouts: pageLayoutsFilePaths,
pageLayoutTabs: pageLayoutTabsFilePaths,
pageLayoutWidgets: pageLayoutWidgetsFilePaths,
commandMenuItems: commandMenuItemsFilePaths,
timelineActivityTypes: timelineActivityTypesFilePaths,
};
return { manifest, filePaths: entityFilePaths, errors, warnings };
};

View File

@@ -0,0 +1,176 @@
import { conditionalAvailabilityTransformPlugin } from '@/cli/utilities/build/common/conditional-availability/conditional-availability-transform-plugin';
import { pathExists } from '@/cli/utilities/file/fs-utils';
import { type ValidationResult } from '@/sdk/define';
import { createHash } from 'node:crypto';
import * as esbuild from 'esbuild';
import { createRequire } from 'module';
import vm from 'node:vm';
import path from 'path';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
type CompiledModuleWrapper = (
exports: Record<string, unknown>,
require: NodeRequire,
module: { exports: Record<string, unknown> },
filename: string,
dirname: string,
) => void;
type CachedCompiledModule = {
outputHash: string;
wrapper: CompiledModuleWrapper;
};
// vm.compileFunction pins every function it compiles at the V8 isolate level
// and never releases it (nodejs/node#35375). In dev mode the manifest is
// rebuilt on every file change and recompiles every entity file, so compiling
// the same file over and over grows the heap without bound and eventually
// OOM-crashes the process.
//
// We keep only the latest build per file, keyed by file path: when a file is
// rebuilt with unchanged output we reuse its wrapper, and when its output
// changes we overwrite the entry so the previous build is dropped instead of
// accumulating. This bounds the cache to one entry per file rather than one
// per rebuild.
const compiledModuleCacheByFilePath = new Map<string, CachedCompiledModule>();
const getCompiledWrapper = (
code: string,
filePath: string,
): CompiledModuleWrapper => {
const outputHash = createHash('sha1').update(code).digest('hex');
const cachedModule = compiledModuleCacheByFilePath.get(filePath);
if (isDefined(cachedModule) && cachedModule.outputHash === outputHash) {
return cachedModule.wrapper;
}
const compiledWrapper = vm.compileFunction(
code,
['exports', 'require', 'module', '__filename', '__dirname'],
{ filename: filePath },
) as unknown as CompiledModuleWrapper;
compiledModuleCacheByFilePath.set(filePath, {
outputHash,
wrapper: compiledWrapper,
});
return compiledWrapper;
};
const MANIFEST_MOCK_MODULES = [
'twenty-ui',
'twenty-client-sdk/core',
'twenty-client-sdk/metadata',
];
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const manifestMockPlugin: esbuild.Plugin = {
name: 'manifest-mock',
setup: (build) => {
const escapedModules = MANIFEST_MOCK_MODULES.map(escapeRegExp);
const filter = new RegExp(`^(${escapedModules.join('|')})(/.*)?$`);
build.onResolve({ filter }, ({ path: modulePath }) => {
if (modulePath.endsWith('.css')) {
return null;
}
return {
path: modulePath,
namespace: 'manifest-mock',
};
});
build.onLoad({ filter: /.*/, namespace: 'manifest-mock' }, () => ({
contents: 'module.exports = new Proxy({}, { get: () => () => {} });',
loader: 'js',
}));
},
};
export const extractManifestFromFile = async <T>({
filePath,
appPath,
}: {
filePath: string;
appPath: string;
}): Promise<ValidationResult<T>> => {
const module = await loadModule({ filePath, appPath });
return extractDefaultConfigFromModuleOrThrow<T>(module, filePath);
};
const loadModule = async ({
filePath,
appPath,
}: {
filePath: string;
appPath: string;
}): Promise<Record<string, unknown>> => {
const tsconfigPath = path.join(appPath, 'tsconfig.json');
const hasTsconfig = await pathExists(tsconfigPath);
// Resolve react from the app's node_modules for the alias
const appRequire = createRequire(path.join(appPath, 'package.json'));
let reactPath: string | undefined;
let reactDomPath: string | undefined;
try {
reactPath = path.dirname(appRequire.resolve('react/package.json'));
reactDomPath = path.dirname(appRequire.resolve('react-dom/package.json'));
} catch {
// React not installed in app, will be bundled if used
}
const result = await esbuild.build({
entryPoints: [filePath],
bundle: true,
write: false,
format: 'cjs',
platform: 'node',
target: 'node18',
jsx: 'automatic',
tsconfig: hasTsconfig ? tsconfigPath : undefined,
loader: { '.css': 'empty' },
alias: {
...(reactPath && { react: reactPath }),
...(reactDomPath && { 'react-dom': reactDomPath }),
},
plugins: [conditionalAvailabilityTransformPlugin, manifestMockPlugin],
logLevel: 'silent',
});
const code = result.outputFiles[0].text;
const compiledWrapper = getCompiledWrapper(code, filePath);
const moduleShim: { exports: Record<string, unknown> } = { exports: {} };
compiledWrapper(
moduleShim.exports,
appRequire,
moduleShim,
filePath,
path.dirname(filePath),
);
return moduleShim.exports;
};
const extractDefaultConfigFromModuleOrThrow = <T>(
module: Record<string, unknown>,
filePath: string,
): ValidationResult<T> => {
if (isDefined(module.default) && isPlainObject(module.default)) {
return module.default as ValidationResult<T>;
}
throw new Error(
`Config file ${filePath} must export a config object default export`,
);
};

View File

@@ -0,0 +1,138 @@
import * as ts from 'typescript';
export enum TargetFunction {
DefineApplication = 'defineApplication',
DefineApplicationRole = 'defineApplicationRole',
DefineField = 'defineField',
DefineIndex = 'defineIndex',
DefineLogicFunction = 'defineLogicFunction',
DefinePostInstallLogicFunction = 'definePostInstallLogicFunction',
DefinePreInstallLogicFunction = 'definePreInstallLogicFunction',
DefineUninstallLogicFunction = 'defineUninstallLogicFunction',
DefineObject = 'defineObject',
DefinePermissionFlag = 'definePermissionFlag',
DefineRole = 'defineRole',
DefineSkill = 'defineSkill',
DefineAgent = 'defineAgent',
DefineConnectionProvider = 'defineConnectionProvider',
DefineFrontComponent = 'defineFrontComponent',
DefineSettingsFrontComponent = 'defineSettingsFrontComponent',
DefineView = 'defineView',
DefineViewField = 'defineViewField',
DefineNavigationMenuItem = 'defineNavigationMenuItem',
DefinePageLayout = 'definePageLayout',
DefinePageLayoutTab = 'definePageLayoutTab',
DefinePageLayoutWidget = 'definePageLayoutWidget',
DefineCommandMenuItem = 'defineCommandMenuItem',
DefineTimelineActivityType = 'defineTimelineActivityType',
}
export enum ManifestEntityKey {
Application = 'application',
Fields = 'fields',
Indexes = 'indexes',
LogicFunctions = 'logicFunctions',
Objects = 'objects',
PermissionFlags = 'permissionFlags',
Roles = 'roles',
Skills = 'skills',
Agents = 'agents',
ConnectionProviders = 'connectionProviders',
FrontComponents = 'frontComponents',
PublicAssets = 'publicAssets',
Views = 'views',
ViewFields = 'viewFields',
NavigationMenuItems = 'navigationMenuItems',
PageLayouts = 'pageLayouts',
PageLayoutTabs = 'pageLayoutTabs',
PageLayoutWidgets = 'pageLayoutWidgets',
CommandMenuItems = 'commandMenuItems',
TimelineActivityTypes = 'timelineActivityTypes',
}
export type EntityFilePaths = Record<ManifestEntityKey, string[]>;
export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
TargetFunction,
ManifestEntityKey
> = {
[TargetFunction.DefineApplication]: ManifestEntityKey.Application,
[TargetFunction.DefineApplicationRole]: ManifestEntityKey.Roles,
[TargetFunction.DefineField]: ManifestEntityKey.Fields,
[TargetFunction.DefineIndex]: ManifestEntityKey.Indexes,
[TargetFunction.DefineLogicFunction]: ManifestEntityKey.LogicFunctions,
[TargetFunction.DefinePostInstallLogicFunction]:
ManifestEntityKey.LogicFunctions,
[TargetFunction.DefinePreInstallLogicFunction]:
ManifestEntityKey.LogicFunctions,
[TargetFunction.DefineUninstallLogicFunction]:
ManifestEntityKey.LogicFunctions,
[TargetFunction.DefineObject]: ManifestEntityKey.Objects,
[TargetFunction.DefinePermissionFlag]: ManifestEntityKey.PermissionFlags,
[TargetFunction.DefineRole]: ManifestEntityKey.Roles,
[TargetFunction.DefineSkill]: ManifestEntityKey.Skills,
[TargetFunction.DefineAgent]: ManifestEntityKey.Agents,
[TargetFunction.DefineConnectionProvider]:
ManifestEntityKey.ConnectionProviders,
[TargetFunction.DefineFrontComponent]: ManifestEntityKey.FrontComponents,
[TargetFunction.DefineSettingsFrontComponent]:
ManifestEntityKey.FrontComponents,
[TargetFunction.DefineView]: ManifestEntityKey.Views,
[TargetFunction.DefineViewField]: ManifestEntityKey.ViewFields,
[TargetFunction.DefineNavigationMenuItem]:
ManifestEntityKey.NavigationMenuItems,
[TargetFunction.DefinePageLayout]: ManifestEntityKey.PageLayouts,
[TargetFunction.DefinePageLayoutTab]: ManifestEntityKey.PageLayoutTabs,
[TargetFunction.DefinePageLayoutWidget]: ManifestEntityKey.PageLayoutWidgets,
[TargetFunction.DefineCommandMenuItem]: ManifestEntityKey.CommandMenuItems,
[TargetFunction.DefineTimelineActivityType]:
ManifestEntityKey.TimelineActivityTypes,
};
const computeIsTargetFunctionCall = (node: ts.Node): string | undefined => {
if (!ts.isCallExpression(node)) {
return undefined;
}
const expression = node.expression;
if (ts.isIdentifier(expression)) {
if ((Object.values(TargetFunction) as string[]).includes(expression.text)) {
return expression.text;
}
}
return undefined;
};
export const extractDefineEntity = (
fileContent: string,
): TargetFunction | undefined => {
const sourceFile = ts.createSourceFile(
'temp.ts',
fileContent,
ts.ScriptTarget.Latest,
true,
);
const children: ts.Node[] = [];
ts.forEachChild(sourceFile, (node) => {
children.push(node);
});
for (const node of children) {
if (ts.isExportAssignment(node)) {
if (node.isExportEquals || !node.expression) {
return;
}
const targetFunction = computeIsTargetFunctionCall(node.expression);
if (targetFunction) {
return targetFunction as TargetFunction;
}
}
}
return;
};

View File

@@ -0,0 +1,21 @@
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { ensureDir, pathExists, readJson } from '@/cli/utilities/file/fs-utils';
import path from 'path';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
export const readManifestFromFile = async (
appPath: string,
): Promise<Manifest | null> => {
const outputDir = path.join(appPath, OUTPUT_DIR);
await ensureDir(outputDir);
const manifestPath = path.join(outputDir, 'manifest.json');
if (!(await pathExists(manifestPath))) {
const { manifest } = await buildManifest(appPath);
return manifest;
}
return await readJson(manifestPath);
};

View File

@@ -0,0 +1,4 @@
export type ValidationError = {
path: string;
message: string;
};

View File

@@ -0,0 +1,125 @@
import { relative } from 'path';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
export type ManifestBuildResult = {
manifest: Manifest | null;
filePaths: EntityFilePaths;
error?: string;
};
export type UpdateManifestChecksumParams = {
manifest: Manifest;
builtFileInfos: Map<
string,
{
checksum: string;
builtPath: string;
fileFolder: FileFolder;
usesSdkClient?: boolean;
}
>;
};
export const manifestUpdateChecksums = ({
manifest,
builtFileInfos,
}: UpdateManifestChecksumParams): Manifest => {
let result = structuredClone(manifest);
for (const [
builtPath,
{ fileFolder, checksum },
] of builtFileInfos.entries()) {
const rootBuiltPath = relative(OUTPUT_DIR, builtPath);
if (fileFolder === FileFolder.BuiltLogicFunction) {
const logicFunctions = result.logicFunctions;
const fnIndex = logicFunctions.findIndex(
(f) => f.builtHandlerPath === rootBuiltPath,
);
if (fnIndex === -1) {
continue;
}
result = {
...result,
logicFunctions: logicFunctions.map((fn, index) =>
index === fnIndex ? { ...fn, builtHandlerChecksum: checksum } : fn,
),
};
}
if (fileFolder === FileFolder.PublicAsset) {
const assets = result.publicAssets;
const assetIndex = assets.findIndex((a) => a.filePath === rootBuiltPath);
if (assetIndex === -1) {
continue;
}
result = {
...result,
publicAssets: assets.map((asset, index) =>
index === assetIndex ? { ...asset, checksum } : asset,
),
};
continue;
}
if (fileFolder === FileFolder.BuiltFrontComponent) {
const sharedDependencies =
result.application.frontComponentSharedDependencies;
if (
isDefined(sharedDependencies) &&
sharedDependencies.builtPath === rootBuiltPath
) {
result = {
...result,
application: {
...result.application,
frontComponentSharedDependencies: {
...sharedDependencies,
builtChecksum: checksum,
},
},
};
continue;
}
const frontComponents = result.frontComponents;
const componentIndex =
frontComponents.findIndex(
(c) => c.builtComponentPath === rootBuiltPath,
) ?? -1;
if (componentIndex === -1) {
continue;
}
const builtFileInfo = builtFileInfos.get(builtPath);
result = {
...result,
frontComponents: frontComponents.map((component, index) =>
index === componentIndex
? {
...component,
builtComponentChecksum: checksum,
usesSdkClient: builtFileInfo?.usesSdkClient ?? false,
}
: component,
),
};
}
if (fileFolder === FileFolder.Dependencies) {
if (rootBuiltPath === 'package.json') {
result.application.packageJsonChecksum = checksum;
}
if (rootBuiltPath === 'yarn.lock') {
result.application.yarnLockChecksum = checksum;
}
}
}
return result;
};

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