Compare commits

...

23 Commits

Author SHA1 Message Date
prastoin 82f9a193f3 feat(create-app): scaffolding 2026-03-03 13:24:36 +01:00
prastoin 19bca23043 test(apps): integration 2026-03-03 12:57:50 +01:00
prastoin 90fb67c90b chore 2026-03-03 11:42:07 +01:00
prastoin e75057a1df lint and review 2026-03-03 11:40:12 +01:00
prastoin 1b900a5f32 review 2026-03-03 11:28:41 +01:00
prastoin 6e913c2f91 chore 2026-03-02 19:14:36 +01:00
prastoin e676c3fea6 chore 2026-03-02 19:13:10 +01:00
prastoin 5dfe9b7b49 naming 2026-03-02 19:05:39 +01:00
prastoin 670d88d373 naming and location 2026-03-02 18:58:29 +01:00
prastoin c5af23afc2 lint 2026-03-02 18:52:12 +01:00
prastoin 2f4cd688fd feat(sdk): logging typecheck and client export 2026-03-02 18:50:48 +01:00
prastoin 0ca6325f0d refactor(apps): hello world 2026-03-02 18:50:24 +01:00
prastoin 26b7e0a97d typecheck second sync 2026-03-02 18:01:52 +01:00
prastoin 868c019089 lint 2026-03-02 17:57:44 +01:00
prastoin 90658a8476 factorization 2026-03-02 17:57:27 +01:00
prastoin 43678cb782 revert app dev updates 2026-03-02 16:26:18 +01:00
prastoin b52ba1b3e3 Revert "refactor(sdk): no more two phases watcher"
This reverts commit 4f48a5d001.
2026-03-02 15:42:02 +01:00
prastoin 4f48a5d001 refactor(sdk): no more two phases watcher 2026-03-02 14:53:24 +01:00
prastoin a2b727cd7f avoid too many manifest buikd 2026-03-02 14:23:04 +01:00
prastoin 1d26940960 refactor(sdk): extract app build logic 2026-03-02 13:55:59 +01:00
prastoin 16aa8fc72a lockfile 2026-03-02 11:51:06 +01:00
prastoin cf0ad46587 naming 2026-03-02 11:45:55 +01:00
prastoin 9cea7c47e9 refactor(sdk): split cli and command logic 2026-03-02 11:36:53 +01:00
44 changed files with 6402 additions and 454 deletions
@@ -27,5 +27,5 @@
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.integration-test.ts"]
}
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
include: ['src/**/*.integration-test.ts'],
env: {
NODE_ENV: 'integration',
TWENTY_API_URL: 'http://localhost:3000',
},
},
});
@@ -115,6 +115,7 @@ export class CreateAppCommand {
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleSkill: false,
includeIntegrationTest: false,
};
}
@@ -127,6 +128,7 @@ export class CreateAppCommand {
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
includeIntegrationTest: true,
};
}
@@ -171,19 +173,32 @@ export class CreateAppCommand {
value: 'skill',
checked: true,
},
{
name: 'Integration test (vitest test verifying app installation)',
value: 'integrationTest',
checked: true,
},
],
},
]);
const includeField = selectedExamples.includes('field');
const includeView = selectedExamples.includes('view');
const includeIntegrationTest =
selectedExamples.includes('integrationTest');
const includeObject =
selectedExamples.includes('object') || includeField || includeView;
selectedExamples.includes('object') ||
includeField ||
includeView ||
includeIntegrationTest;
if ((includeField || includeView) && !selectedExamples.includes('object')) {
if (
(includeField || includeView || includeIntegrationTest) &&
!selectedExamples.includes('object')
) {
console.log(
chalk.yellow(
'Note: Example object auto-included because example field/view depends on it.',
'Note: Example object auto-included because example field/view/integration test depends on it.',
),
);
}
@@ -197,6 +212,7 @@ export class CreateAppCommand {
includeExampleNavigationMenuItem:
selectedExamples.includes('navigationMenuItem'),
includeExampleSkill: selectedExamples.includes('skill'),
includeIntegrationTest,
};
}
@@ -8,4 +8,5 @@ export type ExampleOptions = {
includeExampleView: boolean;
includeExampleNavigationMenuItem: boolean;
includeExampleSkill: boolean;
includeIntegrationTest: boolean;
};
@@ -24,6 +24,7 @@ const ALL_EXAMPLES: ExampleOptions = {
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
includeIntegrationTest: true,
};
const NO_EXAMPLES: ExampleOptions = {
@@ -34,6 +35,7 @@ const NO_EXAMPLES: ExampleOptions = {
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeIntegrationTest: false,
};
describe('copyBaseApplicationProject', () => {
@@ -352,6 +354,12 @@ describe('copyBaseApplicationProject', () => {
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
),
).toBe(true);
// Install functions should always exist
expect(
await fs.pathExists(
@@ -425,6 +433,11 @@ describe('copyBaseApplicationProject', () => {
),
),
).toBe(false);
expect(
await fs.pathExists(
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
),
).toBe(false);
});
});
@@ -443,6 +456,7 @@ describe('copyBaseApplicationProject', () => {
includeExampleFrontComponent: true,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeIntegrationTest: false,
},
});
@@ -480,6 +494,7 @@ describe('copyBaseApplicationProject', () => {
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeIntegrationTest: false,
},
});
@@ -735,6 +750,76 @@ describe('copyBaseApplicationProject', () => {
});
});
describe('integration test', () => {
it('should create app-install.integration-test.ts with correct structure when enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const testPath = join(
testAppDirectory,
'src',
'__tests__',
'app-install.integration-test.ts',
);
expect(await fs.pathExists(testPath)).toBe(true);
const content = await fs.readFile(testPath, 'utf8');
expect(content).toContain(
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
);
expect(content).toContain(
"import { MetadataApiClient } from 'twenty-sdk/generated'",
);
expect(content).toContain('assertServerIsReachable');
expect(content).toContain('appBuild');
expect(content).toContain('appUninstall');
expect(content).toContain('findManyApplications');
});
it('should include vitest and test scripts in package.json when enabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: ALL_EXAMPLES,
});
const packageJson = await fs.readJson(
join(testAppDirectory, 'package.json'),
);
expect(packageJson.scripts.test).toBe('vitest run');
expect(packageJson.scripts['test:watch']).toBe('vitest');
expect(packageJson.devDependencies.vitest).toBeDefined();
});
it('should not include vitest or test scripts when disabled', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const packageJson = await fs.readJson(
join(testAppDirectory, 'package.json'),
);
expect(packageJson.scripts.test).toBeUndefined();
expect(packageJson.scripts['test:watch']).toBeUndefined();
expect(packageJson.devDependencies.vitest).toBeUndefined();
});
});
describe('post-install logic function', () => {
it('should create post-install.ts with definePostInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
@@ -22,7 +22,11 @@ export const copyBaseApplicationProject = async ({
}) => {
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
await createPackageJson({ appName, appDirectory });
await createPackageJson({
appName,
appDirectory,
includeIntegrationTest: exampleOptions.includeIntegrationTest,
});
await createGitignore(appDirectory);
@@ -97,6 +101,14 @@ export const copyBaseApplicationProject = async ({
});
}
if (exampleOptions.includeIntegrationTest) {
await createIntegrationTest({
appDirectory: sourceFolderPath,
fileFolder: '__tests__',
fileName: 'app-install.integration-test.ts',
});
}
await createDefaultPreInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
@@ -496,6 +508,113 @@ export default defineSkill({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createIntegrationTest = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const content = `import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = path.resolve(__dirname, '../..');
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const readApiKeyFromConfig = (): string | undefined => {
const configPath = path.join(os.homedir(), '.twenty', 'config.json');
if (!fs.existsSync(configPath)) {
return undefined;
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const defaultProfile = config.profiles?.default;
return defaultProfile?.apiKey ?? config.apiKey;
};
const assertServerIsReachable = async () => {
try {
const response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
if (!response.ok) {
throw new Error(\`Server returned \${response.status}\`);
}
} catch {
throw new Error(
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
'Make sure the server is running before executing integration tests.',
);
}
};
describe('App installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(\`[build] \${message}\`),
});
if (!buildResult.success) {
throw new Error(
\`App build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
\`App uninstall failed: \${uninstallResult.error?.message ?? 'Unknown error'}\`,
);
}
});
it('should find the installed app in the applications list', async () => {
const apiKey = readApiKeyFromConfig();
const metadataClient = new MetadataApiClient({
url: \`\${TWENTY_API_URL}/metadata\`,
headers: {
Authorization: \`Bearer \${apiKey}\`,
},
});
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
expect(result.findManyApplications.length).toBeGreaterThan(0);
});
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createApplicationConfig = async ({
displayName,
description,
@@ -527,10 +646,33 @@ export default defineApplication({
const createPackageJson = async ({
appName,
appDirectory,
includeIntegrationTest,
}: {
appName: string;
appDirectory: string;
includeIntegrationTest: boolean;
}) => {
const scripts: Record<string, string> = {
twenty: 'twenty',
lint: 'eslint',
'lint:fix': 'eslint --fix',
};
const devDependencies: Record<string, string> = {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
};
if (includeIntegrationTest) {
scripts.test = 'vitest run';
scripts['test:watch'] = 'vitest';
devDependencies.vitest = '^3.1.1';
}
const packageJson = {
name: appName,
version: '0.1.0',
@@ -541,22 +683,11 @@ const createPackageJson = async ({
yarn: '>=4.0.2',
},
packageManager: 'yarn@4.9.2',
scripts: {
twenty: 'twenty',
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
scripts,
dependencies: {
'twenty-sdk': 'latest',
},
devDependencies: {
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
react: '^18.2.0',
eslint: '^9.32.0',
'typescript-eslint': '^8.50.0',
},
devDependencies,
};
await fs.writeFile(
@@ -1,2 +1,3 @@
.yarn/install-state.gz
.env
.twenty
@@ -13,12 +13,15 @@
"dev": "twenty app dev",
"sync": "twenty app sync",
"uninstall": "twenty app uninstall",
"auth": "twenty auth login"
"auth": "twenty auth login",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-sdk": "0.2.4"
"twenty-sdk": "portal:../../twenty-sdk"
},
"devDependencies": {
"@types/node": "^24.7.2"
"@types/node": "^24.7.2",
"vitest": "^3.1.1"
}
}
@@ -0,0 +1,118 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appBuild, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-sdk/generated';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = path.resolve(__dirname, '../..');
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER =
'54b589ca-eeed-4950-a176-358418b85c05';
const readApiKeyFromConfig = (): string | undefined => {
const configPath = path.join(os.homedir(), '.twenty', 'config.json');
if (!fs.existsSync(configPath)) {
return undefined;
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const defaultProfile = config.profiles?.default;
return defaultProfile?.apiKey ?? config.apiKey;
};
const assertServerIsReachable = async () => {
try {
const response = await fetch(`${TWENTY_API_URL}/healthz`);
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
} catch {
throw new Error(
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
'Make sure the server is running before executing integration tests.',
);
}
};
describe('Hello World app installation', () => {
let appInstalled = false;
beforeAll(async () => {
await assertServerIsReachable();
const buildResult = await appBuild({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`App build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
appInstalled = true;
});
afterAll(async () => {
if (!appInstalled) {
return;
}
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
it('should have the postCard object in object metadata after installation', async () => {
const apiKey = readApiKeyFromConfig();
const metadataClient = new MetadataApiClient({
url: `${TWENTY_API_URL}/metadata`,
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const result = await metadataClient.query({
objects: {
__args: {
paging: { first: 200 },
filter: {},
},
edges: {
node: {
id: true,
universalIdentifier: true,
nameSingular: true,
namePlural: true,
isActive: true,
isCustom: true,
},
},
},
});
const postCardObject = result.objects.edges.find(
(edge) =>
edge.node.universalIdentifier ===
POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER,
);
expect(postCardObject).toMatchObject({
node: {
nameSingular: 'postCard',
namePlural: 'postCards',
isActive: true,
},
});
});
});
@@ -1,67 +1,55 @@
import type {
FunctionConfig,
DatabaseEventPayload,
ObjectRecordCreateEvent,
CronPayload,
import {
defineLogicFunction,
type CronPayload,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
} from 'twenty-sdk';
import Twenty, { type Person } from '../../generated';
import { CoreApiClient as Twenty, type CoreSchema } from 'twenty-sdk/generated';
type CreateNewPostCardParams =
| { name?: string }
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
| DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Person>>
| CronPayload;
export const main = async (params: CreateNewPostCardParams) => {
try {
const client = new Twenty();
const handler = async (params: CreateNewPostCardParams) => {
const client = new Twenty();
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const name =
'name' in params
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
},
const createPostCard = await client.mutation({
createPostCard: {
__args: {
data: {
name,
},
name: true,
id: true,
},
});
name: true,
id: true,
},
});
console.log('createPostCard result', createPostCard);
console.log('createPostCard result', createPostCard);
return createPostCard;
} catch (error) {
console.error(error);
throw error;
}
return createPostCard;
};
export const config: FunctionConfig = {
export default defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'create-new-post-card',
timeoutSeconds: 2,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
},
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron',
pattern: '0 0 1 1 *', // Every year 1st of January
},
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent',
eventName: 'person.created',
},
],
};
handler,
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
},
cronTriggerSettings: {
pattern: '0 0 1 1 *',
},
databaseEventTriggerSettings: {
eventName: 'person.created',
},
});
@@ -1,6 +1,6 @@
import { type ApplicationConfig } from 'twenty-sdk';
import { defineApplication } from 'twenty-sdk';
const config: ApplicationConfig = {
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'Hello World',
description: 'A simple hello world app',
@@ -14,6 +14,4 @@ const config: ApplicationConfig = {
},
},
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
export default config;
});
@@ -1,112 +1,89 @@
import { type Note } from '../../generated';
import { defineObject, FieldType } from 'twenty-sdk';
import {
type AddressField,
Field,
FieldType,
type FullNameField,
Object,
OnDeleteAction,
Relation,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
const POST_CARD_STATUS = {
DRAFT: 'DRAFT',
SENT: 'SENT',
DELIVERED: 'DELIVERED',
RETURNED: 'RETURNED',
} as const;
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
@Object({
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
description: ' A post card object',
description: 'A post card object',
icon: 'IconMail',
})
export class PostCard {
@Field({
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
})
content: string;
@Field({
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
label: 'Recipient name',
icon: 'IconUser',
})
recipientName: FullNameField;
@Field({
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
label: 'Recipient address',
icon: 'IconHome',
})
recipientAddress: AddressField;
@Field({
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
})
status: PostCardStatus;
@Relation({
universalIdentifier: 'c9e2b4f4-b9ad-4427-9b42-9971b785edfe',
type: RelationType.ONE_TO_MANY,
label: 'Notes',
icon: 'IconComment',
inverseSideTargetUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note.universalIdentifier,
onDelete: OnDeleteAction.CASCADE,
})
notes: Note[];
@Field({
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
})
deliveredAt?: Date;
}
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldType.TEXT,
name: 'content',
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
type: FieldType.FULL_NAME,
name: 'recipientName',
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
type: FieldType.ADDRESS,
name: 'recipientAddress',
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
name: 'status',
label: 'Status',
icon: 'IconSend',
defaultValue: `'${POST_CARD_STATUS.DRAFT}'`,
options: [
{
id: 'a1b2c3d4-0001-4000-8000-000000000001',
value: POST_CARD_STATUS.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: 'a1b2c3d4-0002-4000-8000-000000000002',
value: POST_CARD_STATUS.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: 'a1b2c3d4-0003-4000-8000-000000000003',
value: POST_CARD_STATUS.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: 'a1b2c3d4-0004-4000-8000-000000000004',
value: POST_CARD_STATUS.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
name: 'deliveredAt',
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
@@ -1,6 +1,6 @@
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
import { defineRole, PermissionFlag } from 'twenty-sdk';
export const functionRole: RoleConfig = {
export default defineRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
description: 'Default role for function Twenty client',
@@ -14,7 +14,7 @@ export const functionRole: RoleConfig = {
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
@@ -23,11 +23,11 @@ export const functionRole: RoleConfig = {
],
fieldPermissions: [
{
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
};
});
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
include: ['src/**/*.integration-test.ts'],
env: {
// The SDK's ConfigService reads credentials from ~/.twenty/config.json
// but falls back to a temp dir when NODE_ENV=test.
NODE_ENV: 'integration',
// MetadataApiClient defaults to TWENTY_API_URL for its GraphQL endpoint.
TWENTY_API_URL: 'http://localhost:3000',
},
},
});
File diff suppressed because it is too large Load Diff
+8
View File
@@ -35,6 +35,11 @@
"import": "./dist/ui/index.mjs",
"require": "./dist/ui/index.cjs"
},
"./cli": {
"types": "./dist/cli/public-operations/index.d.ts",
"import": "./dist/operations.mjs",
"require": "./dist/operations.cjs"
},
"./front-component-renderer": {
"types": "./dist/front-component-renderer/index.d.ts",
"import": "./dist/front-component-renderer/index.mjs",
@@ -120,6 +125,9 @@
},
"typesVersions": {
"*": {
"cli": [
"dist/cli/public-operations/index.d.ts"
],
"ui": [
"dist/ui/index.d.ts"
],
@@ -1,6 +1,7 @@
import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { AppBuildCommand } from './app/app-build';
import { AppDevCommand } from './app/app-dev';
import { AppTypecheckCommand } from './app/app-typecheck';
import { AppUninstallCommand } from './app/app-uninstall';
@@ -60,6 +61,7 @@ export const registerCommands = (program: Command): void => {
});
// App commands
const buildCommand = new AppBuildCommand();
const devCommand = new AppDevCommand();
const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
@@ -67,6 +69,15 @@ export const registerCommands = (program: Command): void => {
const logsCommand = new LogicFunctionLogsCommand();
const executeCommand = new LogicFunctionExecuteCommand();
program
.command('app:build [appPath]')
.description('Build the application without watching for changes')
.action(async (appPath) => {
await buildCommand.execute({
appPath: formatPath(appPath),
});
});
program
.command('app:dev [appPath]')
.description('Watch and sync local application changes')
@@ -0,0 +1,33 @@
import { appBuild } from '@/cli/public-operations/app-build';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
export type AppBuildCommandOptions = {
appPath?: string;
};
export class AppBuildCommand {
async execute(options: AppBuildCommandOptions): Promise<void> {
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
console.log(chalk.blue('Building and syncing application...'));
console.log(chalk.gray(`App path: ${appPath}`));
console.log('');
const result = await appBuild({
appPath,
onProgress: (message) => console.log(chalk.gray(message)),
});
if (!result.success) {
console.error(chalk.red(result.error.message));
process.exit(1);
}
console.log(
chalk.green(
`✓ Build and sync succeeded (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
),
);
}
}
@@ -1,13 +1,10 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { appUninstall } from '@/cli/public-operations/app-uninstall';
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
export class AppUninstallCommand {
private apiService = new ApiService();
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
askForConfirmation,
@@ -25,23 +22,15 @@ export class AppUninstallCommand {
process.exit(1);
}
const manifest = await readManifestFromFile(appPath);
const result = await appUninstall({ appPath });
if (!manifest) {
return { success: false, error: 'Build failed' };
if (!result.success) {
console.error(chalk.red('❌ Uninstall failed:'), result.error.message);
return { success: false, error: result.error.message };
}
const result = await this.apiService.uninstallApplication(
manifest.application.universalIdentifier,
);
if (result.success === false) {
console.error(chalk.red('❌ Uninstall failed:'), result.error);
} else {
console.log(chalk.green('✅ Application uninstalled successfully'));
}
return result;
console.log(chalk.green('✅ Application uninstalled successfully'));
return { success: true, data: undefined };
} catch (error) {
console.error(
chalk.red('Uninstall failed:'),
@@ -1,5 +1,5 @@
import chalk from 'chalk';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
export class AuthListCommand {
private configService = new ConfigService();
@@ -21,18 +21,20 @@ export class AuthListCommand {
console.log(chalk.blue('Available workspaces:\n'));
for (const workspace of availableWorkspaces) {
for (const workspaceName of availableWorkspaces) {
const config =
await this.configService.getConfigForWorkspace(workspace);
await this.configService.getConfigForWorkspace(workspaceName);
const hasCredentials = !!config.apiKey;
const isDefault = workspace === currentDefault;
const isDefault = workspaceName === currentDefault;
const defaultIndicator = isDefault ? chalk.green(' (default)') : '';
const credentialStatus = hasCredentials
? chalk.green('●')
: chalk.gray('○');
console.log(` ${credentialStatus} ${workspace}${defaultIndicator}`);
console.log(
` ${credentialStatus} ${workspaceName}${defaultIndicator}`,
);
console.log(chalk.gray(` API URL: ${config.apiUrl}`));
}
@@ -1,20 +1,17 @@
import { authLogin } from '@/cli/public-operations/auth-login';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
export class AuthLoginCommand {
private configService = new ConfigService();
private apiService = new ApiService();
async execute(options: { apiKey?: string; apiUrl?: string }): Promise<void> {
try {
let { apiKey, apiUrl } = options;
// Get current config
const config = await this.configService.getConfig();
// Prompt for missing values
if (!apiUrl) {
const urlAnswer = await inquirer.prompt([
{
@@ -48,16 +45,9 @@ export class AuthLoginCommand {
apiKey = keyAnswer.apiKey;
}
// Update config
await this.configService.setConfig({
apiUrl,
apiKey,
});
const result = await authLogin({ apiKey: apiKey!, apiUrl: apiUrl! });
// Validate authentication
const validateAuth = await this.apiService.validateAuth();
if (validateAuth.authValid) {
if (result.success) {
const activeWorkspace = ConfigService.getActiveWorkspace();
console.log(
chalk.green(
@@ -1,12 +1,11 @@
import chalk from 'chalk';
import { authLogout } from '@/cli/public-operations/auth-logout';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
export class AuthLogoutCommand {
private configService = new ConfigService();
async execute(): Promise<void> {
try {
await this.configService.clearConfig();
await authLogout();
const activeWorkspace = ConfigService.getActiveWorkspace();
console.log(
chalk.green(
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
export class AuthStatusCommand {
private configService = new ConfigService();
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import chalk from 'chalk';
import inquirer from 'inquirer';
export class AuthSwitchCommand {
private configService = new ConfigService();
@@ -24,9 +24,7 @@ export class AuthSwitchCommand {
return;
}
// If workspace is not provided, show interactive selection
if (!workspace) {
// Build choices with indicators for current default
const choices = availableWorkspaces.map((ws) => ({
name: ws === currentDefault ? `${ws} (current default)` : ws,
value: ws,
@@ -45,7 +43,6 @@ export class AuthSwitchCommand {
workspace = answer.workspace as string;
}
// Validate that the workspace exists (workspace is guaranteed to be defined here)
if (!availableWorkspaces.includes(workspace!)) {
console.log(
chalk.red(
@@ -55,7 +52,6 @@ export class AuthSwitchCommand {
process.exit(1);
}
// If already the default, inform and exit
if (workspace === currentDefault) {
console.log(
chalk.blue(` "${workspace}" is already the default workspace.`),
@@ -63,13 +59,9 @@ export class AuthSwitchCommand {
return;
}
// Set the new default workspace
await this.configService.setDefaultWorkspace(workspace!);
// Also set it as active for the current session to validate
ConfigService.setActiveWorkspace(workspace);
// Check authentication status for the new workspace
const config = await this.configService.getConfig();
const hasCredentials = !!config.apiKey;
@@ -79,6 +71,7 @@ export class AuthSwitchCommand {
if (hasCredentials) {
const validateAuth = await this.apiService.validateAuth();
if (validateAuth.authValid) {
console.log(chalk.green('✓ Authentication is valid'));
} else {
@@ -1,13 +1,13 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { functionExecute } from '@/cli/public-operations/function-execute';
import {
APP_ERROR_CODES,
FUNCTION_ERROR_CODES,
} from '@/cli/public-operations/types';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
export class LogicFunctionExecuteCommand {
private apiService = new ApiService();
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
postInstall = false,
@@ -32,90 +32,73 @@ export class LogicFunctionExecuteCommand {
process.exit(1);
}
const manifest = await readManifestFromFile(appPath);
const identifier = postInstall
? 'post install'
: (functionUniversalIdentifier ?? functionName);
if (!manifest) {
console.error(chalk.red('Failed to build manifest.'));
process.exit(1);
}
const functionsResult = await this.apiService.findLogicFunctions();
if (!functionsResult.success) {
console.error(
chalk.red('Failed to fetch functions:'),
functionsResult.error instanceof Error
? functionsResult.error.message
: functionsResult.error,
);
process.exit(1);
}
const appFunctions = functionsResult.data.filter(
(fn) =>
fn.universalIdentifier && this.belongsToApplication(fn, manifest),
);
const targetFunction = appFunctions.find((fn) => {
if (postInstall) {
return (
fn.universalIdentifier ===
manifest.application.postInstallLogicFunctionUniversalIdentifier
);
}
if (functionUniversalIdentifier) {
return fn.universalIdentifier === functionUniversalIdentifier;
}
if (functionName) {
return fn.name === functionName;
}
return false;
});
if (!targetFunction) {
const identifier = postInstall
? 'post install'
: functionUniversalIdentifier || functionName;
console.error(
chalk.red(`Function "${identifier}" not found in application.`),
);
console.log('');
if (appFunctions.length > 0) {
console.log(chalk.cyan('Available functions:'));
appFunctions.forEach((fn) => {
console.log(
` - ${chalk.white(fn.name)} (${fn.universalIdentifier})`,
);
});
} else {
console.log(
chalk.yellow(
'No functions found for this application. Have you synced your app with `yarn app:dev`?',
),
);
}
process.exit(1);
}
console.log(
chalk.blue(`🚀 Executing function "${targetFunction.name}"...`),
);
console.log(chalk.blue(`🚀 Executing function "${identifier}"...`));
console.log(chalk.gray(` Payload: ${JSON.stringify(parsedPayload)}`));
console.log('');
const result = await this.apiService.executeLogicFunction({
functionId: targetFunction.id,
payload: parsedPayload,
});
const executeOptions = postInstall
? { appPath, postInstall: true as const, payload: parsedPayload }
: functionUniversalIdentifier
? { appPath, functionUniversalIdentifier, payload: parsedPayload }
: { appPath, functionName: functionName!, payload: parsedPayload };
const result = await functionExecute(executeOptions);
if (!result.success) {
console.error(
chalk.red('Execution failed:'),
result.error instanceof Error ? result.error.message : result.error,
);
switch (result.error.code) {
case APP_ERROR_CODES.MANIFEST_NOT_FOUND: {
console.error(chalk.red('Failed to build manifest.'));
break;
}
case FUNCTION_ERROR_CODES.FETCH_FUNCTIONS_FAILED: {
console.error(
chalk.red('Failed to fetch functions:'),
result.error.message,
);
break;
}
case FUNCTION_ERROR_CODES.FUNCTION_NOT_FOUND: {
console.error(chalk.red(result.error.message));
console.log('');
const availableFunctions = (result.error.details
?.availableFunctions ?? []) as Array<{
name: string;
universalIdentifier: string;
}>;
if (availableFunctions.length > 0) {
console.log(chalk.cyan('Available functions:'));
availableFunctions.forEach((logicFunction) => {
console.log(
` - ${chalk.white(logicFunction.name)} (${logicFunction.universalIdentifier})`,
);
});
} else {
console.log(
chalk.yellow(
'No functions found for this application. Have you synced your app with `yarn app:dev`?',
),
);
}
break;
}
case FUNCTION_ERROR_CODES.EXECUTION_FAILED: {
console.error(chalk.red('Execution failed:'), result.error.message);
break;
}
default: {
console.error(chalk.red(result.error.message));
}
}
process.exit(1);
}
const executionResult = result.data!;
const executionResult = result.data;
console.log(chalk.cyan('─'.repeat(60)));
console.log(chalk.cyan('Execution Result'));
@@ -168,13 +151,4 @@ export class LogicFunctionExecuteCommand {
process.exit(1);
}
}
private belongsToApplication(
fn: { universalIdentifier: string; applicationId: string | null },
manifest: Manifest,
): boolean {
return manifest.logicFunctions.some(
(manifestFn) => manifestFn.universalIdentifier === fn.universalIdentifier,
);
}
}
@@ -0,0 +1,109 @@
import { buildApplication } from '@/cli/utilities/build/common/build-application';
import { synchronizeBuiltApplication } from '@/cli/utilities/build/common/synchronize-built-application';
import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { ClientService } from '@/cli/utilities/client/client-service';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppBuildOptions = {
appPath: string;
onProgress?: (message: string) => void;
};
export type AppBuildResult = {
fileCount: number;
};
export const appBuild = async (
options: AppBuildOptions,
): Promise<CommandResult<AppBuildResult>> => {
const { appPath, onProgress } = options;
onProgress?.('Building manifest...');
const manifestResult = await buildAndValidateManifest(appPath);
if (!manifestResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_BUILD_FAILED,
message: manifestResult.errors.join('\n'),
},
};
}
const { manifest, filePaths } = manifestResult;
const clientService = new ClientService();
await clientService.ensureGeneratedClientStub({ appPath });
onProgress?.('Building application files...');
const firstBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing application schema...');
const firstSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: firstBuildResult.builtFileInfos,
});
if (!firstSyncResult.success) {
return firstSyncResult;
}
onProgress?.('Generating API client...');
await clientService.generate({ appPath });
onProgress?.('Running typecheck...');
const typecheckErrors = await runTypecheck(appPath);
if (typecheckErrors.length > 0) {
const errorMessages = typecheckErrors.map(
(error) => `${error.file}(${error.line},${error.column}): ${error.text}`,
);
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Typecheck failed:\n${errorMessages.join('\n')}`,
},
};
}
onProgress?.('Rebuilding with generated client...');
const finalBuildResult = await buildApplication({
appPath,
manifest,
filePaths,
});
onProgress?.('Syncing built files...');
const finalSyncResult = await synchronizeBuiltApplication({
appPath,
manifest,
builtFileInfos: finalBuildResult.builtFileInfos,
});
if (!finalSyncResult.success) {
return finalSyncResult;
}
return {
success: true,
data: {
fileCount: finalBuildResult.builtFileInfos.size,
},
};
};
@@ -0,0 +1,51 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { APP_ERROR_CODES, type CommandResult } from './types';
export type AppUninstallOptions = {
appPath: string;
workspace?: string;
};
export const appUninstall = async (
options: AppUninstallOptions,
): Promise<CommandResult> => {
if (options.workspace) {
ConfigService.setActiveWorkspace(options.workspace);
}
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Failed to build manifest.',
},
};
}
const result = await apiService.uninstallApplication(
manifest.application.universalIdentifier,
);
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Unknown error');
return {
success: false,
error: {
code: APP_ERROR_CODES.UNINSTALL_FAILED,
message: errorMessage,
},
};
}
return { success: true, data: undefined };
};
@@ -0,0 +1,40 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { AUTH_ERROR_CODES, type CommandResult } from './types';
export type AuthLoginOptions = {
apiKey: string;
apiUrl: string;
workspace?: string;
};
export const authLogin = async (
options: AuthLoginOptions,
): Promise<CommandResult> => {
const { apiKey, apiUrl, workspace } = options;
if (workspace) {
ConfigService.setActiveWorkspace(workspace);
}
const configService = new ConfigService();
await configService.setConfig({ apiUrl, apiKey });
const apiService = new ApiService();
const validateAuth = await apiService.validateAuth();
if (!validateAuth.authValid) {
await configService.clearConfig();
return {
success: false,
error: {
code: AUTH_ERROR_CODES.AUTH_FAILED,
message: 'Authentication failed. Please check your credentials.',
},
};
}
return { success: true, data: undefined };
};
@@ -0,0 +1,20 @@
import { ConfigService } from '@/cli/utilities/config/config-service';
import { type CommandResult } from './types';
export type AuthLogoutOptions = {
workspace?: string;
};
export const authLogout = async (
options?: AuthLogoutOptions,
): Promise<CommandResult> => {
if (options?.workspace) {
ConfigService.setActiveWorkspace(options.workspace);
}
const configService = new ConfigService();
await configService.clearConfig();
return { success: true, data: undefined };
};
@@ -0,0 +1,155 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { type Manifest } from 'twenty-shared/application';
import {
APP_ERROR_CODES,
FUNCTION_ERROR_CODES,
type CommandResult,
type FunctionExecutionResult,
} from './types';
export type FunctionExecuteOptions = {
appPath: string;
workspace?: string;
payload?: Record<string, unknown>;
} & (
| { postInstall: true }
| { functionUniversalIdentifier: string }
| { functionName: string }
);
type LogicFunction = {
id: string;
name: string;
universalIdentifier: string;
applicationId: string | null;
};
const belongsToApplication = (
logicFunction: LogicFunction,
manifest: Manifest,
): boolean => {
return manifest.logicFunctions.some(
(manifestFn) =>
manifestFn.universalIdentifier === logicFunction.universalIdentifier,
);
};
const resolveIdentifier = (options: FunctionExecuteOptions): string => {
if ('postInstall' in options) return 'post install';
if ('functionUniversalIdentifier' in options)
return options.functionUniversalIdentifier;
if ('functionName' in options) return options.functionName;
return 'unknown';
};
export const functionExecute = async (
options: FunctionExecuteOptions,
): Promise<CommandResult<FunctionExecutionResult>> => {
if (options.workspace) {
ConfigService.setActiveWorkspace(options.workspace);
}
const apiService = new ApiService();
const manifest = await readManifestFromFile(options.appPath);
if (!manifest) {
return {
success: false,
error: {
code: APP_ERROR_CODES.MANIFEST_NOT_FOUND,
message: 'Failed to build manifest.',
},
};
}
const functionsResult = await apiService.findLogicFunctions();
if (!functionsResult.success) {
const errorMessage =
functionsResult.error instanceof Error
? functionsResult.error.message
: String(functionsResult.error ?? 'Failed to fetch functions');
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.FETCH_FUNCTIONS_FAILED,
message: errorMessage,
},
};
}
const appFunctions = functionsResult.data.filter(
(logicFunction) =>
logicFunction.universalIdentifier &&
belongsToApplication(logicFunction, manifest),
);
const targetFunction = appFunctions.find((logicFunction) => {
if ('postInstall' in options && options.postInstall) {
return (
logicFunction.universalIdentifier ===
manifest.application.postInstallLogicFunctionUniversalIdentifier
);
}
if ('functionUniversalIdentifier' in options) {
return (
logicFunction.universalIdentifier ===
options.functionUniversalIdentifier
);
}
if ('functionName' in options) {
return logicFunction.name === options.functionName;
}
return false;
});
if (!targetFunction) {
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.FUNCTION_NOT_FOUND,
message: `Function "${resolveIdentifier(options)}" not found in application.`,
details: {
identifier: resolveIdentifier(options),
availableFunctions: appFunctions.map((logicFunction) => ({
name: logicFunction.name,
universalIdentifier: logicFunction.universalIdentifier,
})),
},
},
};
}
const result = await apiService.executeLogicFunction({
functionId: targetFunction.id,
payload: options.payload ?? {},
});
if (!result.success) {
const errorMessage =
result.error instanceof Error
? result.error.message
: String(result.error ?? 'Execution failed');
return {
success: false,
error: {
code: FUNCTION_ERROR_CODES.EXECUTION_FAILED,
message: errorMessage,
},
};
}
return {
success: true,
data: {
functionName: targetFunction.name,
...result.data!,
},
};
};
@@ -0,0 +1,30 @@
// Auth
export { authLogin } from './auth-login';
export type { AuthLoginOptions } from './auth-login';
export { authLogout } from './auth-logout';
export type { AuthLogoutOptions } from './auth-logout';
// App
export { appBuild } from './app-build';
export type { AppBuildOptions, AppBuildResult } from './app-build';
export { appUninstall } from './app-uninstall';
export type { AppUninstallOptions } from './app-uninstall';
// Functions
export { functionExecute } from './function-execute';
export type { FunctionExecuteOptions } from './function-execute';
// Shared types and error codes
export {
APP_ERROR_CODES,
AUTH_ERROR_CODES,
FUNCTION_ERROR_CODES,
} from './types';
export type {
AuthListWorkspace,
AuthStatusResult,
CommandError,
CommandResult,
FunctionExecutionResult,
TypecheckResult,
} from './types';
@@ -0,0 +1,65 @@
export type CommandError = {
code: string;
message: string;
details?: Record<string, unknown>;
};
export type CommandResult<T = void> =
| { success: true; data: T }
| { success: false; error: CommandError };
export const AUTH_ERROR_CODES = {
AUTH_FAILED: 'AUTH_FAILED',
NO_WORKSPACES: 'NO_WORKSPACES',
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
} as const;
export const APP_ERROR_CODES = {
MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND',
MANIFEST_BUILD_FAILED: 'MANIFEST_BUILD_FAILED',
UNINSTALL_FAILED: 'UNINSTALL_FAILED',
SYNC_FAILED: 'SYNC_FAILED',
} as const;
export const FUNCTION_ERROR_CODES = {
FETCH_FUNCTIONS_FAILED: 'FETCH_FUNCTIONS_FAILED',
FUNCTION_NOT_FOUND: 'FUNCTION_NOT_FOUND',
EXECUTION_FAILED: 'EXECUTION_FAILED',
} as const;
export type AuthStatusResult = {
workspace: string;
apiUrl: string;
apiKeyMasked: string | null;
isAuthenticated: boolean;
isValid: boolean;
};
export type AuthListWorkspace = {
name: string;
apiUrl: string;
hasCredentials: boolean;
isDefault: boolean;
};
export type TypecheckResult = {
errors: Array<{
text: string;
file: string;
line: number;
column: number;
}>;
};
export type FunctionExecutionResult = {
functionName: string;
data: unknown;
logs: string;
duration: number;
status: string;
error?: {
errorType: string;
errorMessage: string;
stackTrace: string;
};
};
@@ -0,0 +1,80 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
export type EnsureApplicationSuccess = {
success: true;
applicationId: string;
universalIdentifier: string;
created: boolean;
};
export type EnsureApplicationFailure = {
success: false;
error: string;
};
export type EnsureApplicationResult =
| EnsureApplicationSuccess
| EnsureApplicationFailure;
export const findOrCreateApplication = async ({
apiService,
manifest,
applicationRegistrationId,
}: {
apiService: ApiService;
manifest: Manifest;
applicationRegistrationId?: string;
}): Promise<EnsureApplicationResult> => {
const universalIdentifier = manifest.application.universalIdentifier;
const findResult = await apiService.findOneApplication(universalIdentifier);
if (!findResult.success) {
return {
success: false,
error: `Failed to resolve application: ${serializeError(findResult.error)}`,
};
}
if (findResult.data) {
return {
success: true,
applicationId: findResult.data.id,
universalIdentifier: findResult.data.universalIdentifier,
created: false,
};
}
let registrationId = applicationRegistrationId;
if (!registrationId) {
const registerResult =
await apiService.findApplicationRegistrationByUniversalIdentifier(
universalIdentifier,
);
if (registerResult.success && registerResult.data) {
registrationId = registerResult.data.id;
}
}
const createResult = await apiService.createApplication(manifest, {
applicationRegistrationId: registrationId,
});
if (!createResult.success) {
return {
success: false,
error: `Failed to create application: ${serializeError(createResult.error)}`,
};
}
return {
success: true,
applicationId: createResult.data!.id,
universalIdentifier: createResult.data!.universalIdentifier,
created: true,
};
};
@@ -0,0 +1,157 @@
import { esbuildOneShotBuild } from '@/cli/utilities/build/common/esbuild-one-shot-build';
import {
LOGIC_FUNCTION_EXTERNAL_MODULES,
createSdkGeneratedResolverPlugin,
} 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 { type OnFileBuiltCallback } from '@/cli/utilities/build/common/restartable-watcher-interface';
import { type EntityFilePaths } from '@/cli/utilities/build/manifest/manifest-extract-config';
import crypto from 'crypto';
import * as fs from 'fs-extra';
import { dirname, join } from 'path';
import {
NODE_ESM_CJS_BANNER,
OUTPUT_DIR,
type Manifest,
} from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
export type AppBuildOptions = {
appPath: string;
manifest: Manifest;
filePaths: EntityFilePaths;
};
export type BuiltFileInfo = {
checksum: string;
builtPath: string;
sourcePath: string;
fileFolder: FileFolder;
};
export type AppBuildResult = {
builtFileInfos: Map<string, BuiltFileInfo>;
};
export const buildApplication = async (
options: AppBuildOptions,
): Promise<AppBuildResult> => {
const outputDir = join(options.appPath, OUTPUT_DIR);
await fs.ensureDir(outputDir);
await fs.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,
});
};
const { logicFunctions, frontComponents } = options.filePaths;
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: [createSdkGeneratedResolverPlugin(options.appPath)],
},
onFileBuilt: collectFileBuilt,
});
await esbuildOneShotBuild({
appPath: options.appPath,
sourcePaths: frontComponents,
fileFolder: FileFolder.BuiltFrontComponent,
buildOptions: {
bundle: true,
splitting: false,
format: 'esm',
outdir: join(options.appPath, OUTPUT_DIR),
outExtension: { '.js': '.mjs' },
external: FRONT_COMPONENT_EXTERNAL_MODULES,
tsconfig: join(options.appPath, 'tsconfig.json'),
jsx: 'automatic',
sourcemap: true,
metafile: true,
logLevel: 'silent',
plugins: [
createSdkGeneratedResolverPlugin(options.appPath),
...getFrontComponentBuildPlugins(),
],
},
onFileBuilt: 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) =>
fs.pathExistsSync(join(options.appPath, filePath)),
),
collectFileBuilt,
});
return { builtFileInfos };
};
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 fs.pathExists(absoluteSourcePath))) {
continue;
}
const builtPath = join(OUTPUT_DIR, sourcePath);
const absoluteBuiltPath = join(appPath, builtPath);
await fs.ensureDir(dirname(absoluteBuiltPath));
await fs.copy(absoluteSourcePath, absoluteBuiltPath);
const content = await fs.readFile(absoluteBuiltPath);
const checksum = crypto.createHash('md5').update(content).digest('hex');
collectFileBuilt({
fileFolder,
builtPath,
sourcePath,
checksum,
});
}
};
@@ -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,
});
};
@@ -12,9 +12,9 @@ import { createTypecheckPlugin } from '@/cli/utilities/build/common/typecheck-pl
import * as esbuild from 'esbuild';
import path from 'path';
import {
OUTPUT_DIR,
NODE_ESM_CJS_BANNER,
GENERATED_DIR,
NODE_ESM_CJS_BANNER,
OUTPUT_DIR,
} from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
@@ -195,7 +195,9 @@ export class EsbuildWatcher implements RestartableWatcher {
// Resolves twenty-sdk/generated to the actual file path so esbuild
// bundles it instead of treating it as external (via twenty-sdk/*)
const createSdkGeneratedResolverPlugin = (appPath: string): esbuild.Plugin => ({
export const createSdkGeneratedResolverPlugin = (
appPath: string,
): esbuild.Plugin => ({
name: 'sdk-generated-resolver',
setup: (build) => {
build.onResolve({ filter: /^twenty-sdk\/generated/ }, () => ({
@@ -0,0 +1,92 @@
import {
APP_ERROR_CODES,
type CommandResult,
} from '@/cli/public-operations/types';
import { ApiService } from '@/cli/utilities/api/api-service';
import { findOrCreateApplication } from '@/cli/utilities/application/find-or-create-application';
import { type BuiltFileInfo } from '@/cli/utilities/build/common/build-application';
import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
import { type Manifest } from 'twenty-shared/application';
export type AppSyncOptions = {
appPath: string;
workspace?: string;
};
export const synchronizeBuiltApplication = async ({
appPath,
manifest,
builtFileInfos,
}: {
appPath: string;
manifest: Manifest;
builtFileInfos: Map<string, BuiltFileInfo>;
}): Promise<CommandResult> => {
const apiService = new ApiService();
const ensureResult = await findOrCreateApplication({
apiService,
manifest,
});
if (!ensureResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: ensureResult.error,
},
};
}
const universalIdentifier = manifest.application.universalIdentifier;
const fileUploader = new FileUploader({
applicationUniversalIdentifier: universalIdentifier,
appPath,
});
const uploadPromises = Array.from(builtFileInfos.values()).map((fileInfo) =>
fileUploader.uploadFile({
builtPath: fileInfo.builtPath,
fileFolder: fileInfo.fileFolder,
}),
);
const uploadResults = await Promise.all(uploadPromises);
const failedUploads = uploadResults.filter((result) => !result.success);
if (failedUploads.length > 0) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Failed to upload ${failedUploads.length} file(s).`,
},
};
}
const updatedManifest = manifestUpdateChecksums({
manifest,
builtFileInfos,
});
await writeManifestToOutput(appPath, updatedManifest);
const syncResult = await apiService.syncApplication(updatedManifest);
if (!syncResult.success) {
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message: `Sync failed: ${serializeError(syncResult.error)}`,
},
};
}
return { success: true, data: undefined };
};
@@ -0,0 +1,52 @@
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 { 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,
};
}
return {
success: true,
manifest: result.manifest,
filePaths: result.filePaths,
warnings: validation.warnings,
};
};
@@ -6,7 +6,7 @@ import os from 'os';
import path from 'path';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
const MANIFEST_MOCK_MODULES = ['twenty-sdk/ui'];
const MANIFEST_MOCK_MODULES = ['twenty-sdk/ui', 'twenty-sdk/generated'];
const manifestMockPlugin: esbuild.Plugin = {
name: 'manifest-mock',
@@ -163,6 +163,8 @@ export class ClientService {
private async writeBarrelIndex(outputDir: string): Promise<void> {
const barrelContent = `export { CoreApiClient } from './core/index';
export { MetadataApiClient } from './metadata/index';
export * as CoreSchema from './core/schema';
export * as MetadataSchema from './metadata/schema';
`;
await fs.writeFile(join(outputDir, 'index.ts'), barrelContent);
@@ -1,6 +1,5 @@
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest';
import { type ManifestBuildResult } from '@/cli/utilities/build/manifest/manifest-update-checksums';
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
import {
type OrchestratorState,
type OrchestratorStateStepEvent,
@@ -37,9 +36,9 @@ export class BuildManifestOrchestratorStep {
{ message: 'Building manifest', status: 'info' },
];
const result = await buildManifest(input.appPath);
const result = await buildAndValidateManifest(input.appPath);
if (result.errors.length > 0 || !result.manifest) {
if (!result.success) {
for (const error of result.errors) {
events.push({ message: error, status: 'error' });
}
@@ -52,23 +51,8 @@ export class BuildManifestOrchestratorStep {
return null;
}
const validation = manifestValidate(result.manifest);
if (!validation.isValid) {
for (const validationError of validation.errors) {
events.push({ message: validationError, status: 'error' });
}
step.output = { result: null };
step.status = 'error';
this.state.updatePipeline({ status: 'error' });
this.state.applyStepEvents(events);
return null;
}
if (validation.warnings.length > 0) {
for (const warning of validation.warnings) {
if (result.warnings.length > 0) {
for (const warning of result.warnings) {
events.push({ message: `${warning}`, status: 'warning' });
}
}
@@ -78,7 +62,12 @@ export class BuildManifestOrchestratorStep {
status: 'success',
});
step.output = { result };
const buildResult: ManifestBuildResult = {
manifest: result.manifest,
filePaths: result.filePaths,
};
step.output = { result: buildResult };
step.status = 'done';
this.state.updatePipeline({
appName: result.manifest.application.displayName,
@@ -86,6 +75,6 @@ export class BuildManifestOrchestratorStep {
this.state.updateEntitiesFromManifest(result.filePaths);
this.state.applyStepEvents(events);
return result;
return buildResult;
}
}
@@ -1,4 +1,5 @@
import { type ApiService } from '@/cli/utilities/api/api-service';
import { findOrCreateApplication } from '@/cli/utilities/application/find-or-create-application';
import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type Manifest } from 'twenty-shared/application';
@@ -35,15 +36,16 @@ export class ResolveApplicationOrchestratorStep {
step.status = 'in_progress';
this.notify();
const universalIdentifier = input.manifest.application.universalIdentifier;
const result = await findOrCreateApplication({
apiService: this.apiService,
manifest: input.manifest,
applicationRegistrationId: input.applicationRegistrationId,
});
const findResult =
await this.apiService.findOneApplication(universalIdentifier);
if (!findResult.success) {
if (!result.success) {
this.state.applyStepEvents([
{
message: `Failed to find application ${universalIdentifier}`,
message: result.error,
status: 'error',
},
]);
@@ -53,44 +55,17 @@ export class ResolveApplicationOrchestratorStep {
return step.output;
}
if (findResult.data) {
step.output = {
applicationId: findResult.data.id,
universalIdentifier: findResult.data.universalIdentifier,
};
step.status = 'done';
this.notify();
return step.output;
}
const createResult = await this.apiService.createApplication(
input.manifest,
{ applicationRegistrationId: input.applicationRegistrationId },
);
if (!createResult.success) {
if (result.created) {
this.state.applyStepEvents([
{ message: 'Creating application', status: 'info' },
{
message: `Application creation failed with error ${JSON.stringify(createResult.error, null, 2)}`,
status: 'error',
},
{ message: 'Application created', status: 'success' },
]);
step.status = 'error';
this.state.updatePipeline({ status: 'error' });
return step.output;
}
step.output = {
applicationId: createResult.data!.id,
universalIdentifier: createResult.data!.universalIdentifier,
applicationId: result.applicationId,
universalIdentifier: result.universalIdentifier,
};
this.state.applyStepEvents([
{ message: 'Creating application', status: 'info' },
{ message: 'Application created', status: 'success' },
]);
step.status = 'done';
this.notify();
+5 -1
View File
@@ -23,7 +23,11 @@ export default defineConfig(() => {
emptyOutDir: false,
outDir: 'dist',
lib: {
entry: ['src/sdk/index.ts', 'src/cli/cli.ts'],
entry: {
index: 'src/sdk/index.ts',
cli: 'src/cli/cli.ts',
operations: 'src/cli/public-operations/index.ts',
},
name: 'twenty-sdk',
},
rollupOptions: {
-13
View File
@@ -22418,15 +22418,6 @@ __metadata:
languageName: node
linkType: hard
"@swc/plugin-emotion@npm:14.6.0":
version: 14.6.0
resolution: "@swc/plugin-emotion@npm:14.6.0"
dependencies:
"@swc/counter": "npm:^0.1.3"
checksum: 10c0/0bc0dd199d46a42d19d429ca823ec9cd1666fe76b8d130cf50c631bf0e22a4e616bb7366e505b62bbd6159172bc50e5c396a3990c8a617a94b40b4f8c4d2c9fe
languageName: node
linkType: hard
"@swc/types@npm:^0.1.24, @swc/types@npm:^0.1.25":
version: 0.1.25
resolution: "@swc/types@npm:0.1.25"
@@ -58173,14 +58164,10 @@ __metadata:
dependencies:
"@babel/preset-env": "npm:^7.26.9"
"@babel/preset-react": "npm:^7.26.3"
"@emotion/is-prop-valid": "npm:^1.3.0"
"@emotion/react": "npm:^11.11.1"
"@emotion/styled": "npm:^11.11.0"
"@linaria/react": "npm:^6.2.1"
"@monaco-editor/react": "npm:^4.7.0"
"@prettier/sync": "npm:^0.5.2"
"@sniptt/guards": "npm:^0.2.0"
"@swc/plugin-emotion": "npm:14.6.0"
"@tabler/icons-react": "npm:^3.31.0"
"@types/babel__preset-env": "npm:^7"
"@types/react": "npm:^18.2.39"