# Introduction Adding integration test scaffold to the create twenty app and an example to the hello world app This PR also fixes all the sdk e2e tests in local ## `HELLO_WORLD` Removed the legacy implem in the `twenty-apps` folder, replacing it by an exhaustive app generation ## Next step Will in another PR add workflows for CI testing ## Open question - Should we still add vitest config and dep even if the user did not ask for the integration test example ? -> currently we don't - That's the perfect timing to identify if we're ok to handle seed workspace authentication with the known api key
597 lines
15 KiB
TypeScript
597 lines
15 KiB
TypeScript
import * as fs from 'fs-extra';
|
|
import { join } from 'path';
|
|
import { ASSETS_DIR } from 'twenty-shared/application';
|
|
import { v4 } from 'uuid';
|
|
|
|
import { type ExampleOptions } from '@/types/scaffolding-options';
|
|
import { scaffoldIntegrationTest } from '@/utils/test-template';
|
|
import createTwentyAppPackageJson from 'package.json';
|
|
|
|
const SRC_FOLDER = 'src';
|
|
|
|
export const copyBaseApplicationProject = async ({
|
|
appName,
|
|
appDisplayName,
|
|
appDescription,
|
|
appDirectory,
|
|
exampleOptions,
|
|
}: {
|
|
appName: string;
|
|
appDisplayName: string;
|
|
appDescription: string;
|
|
appDirectory: string;
|
|
exampleOptions: ExampleOptions;
|
|
}) => {
|
|
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
|
|
|
|
await createPackageJson({
|
|
appName,
|
|
appDirectory,
|
|
includeExampleIntegrationTest: exampleOptions.includeExampleIntegrationTest,
|
|
});
|
|
|
|
await createGitignore(appDirectory);
|
|
|
|
await createPublicAssetDirectory(appDirectory);
|
|
|
|
await createYarnLock(appDirectory);
|
|
|
|
const sourceFolderPath = join(appDirectory, SRC_FOLDER);
|
|
|
|
await fs.ensureDir(sourceFolderPath);
|
|
|
|
await createDefaultRoleConfig({
|
|
displayName: appDisplayName,
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'roles',
|
|
fileName: 'default-role.ts',
|
|
});
|
|
|
|
if (exampleOptions.includeExampleObject) {
|
|
await createExampleObject({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'objects',
|
|
fileName: 'example-object.ts',
|
|
});
|
|
}
|
|
|
|
if (exampleOptions.includeExampleField) {
|
|
await createExampleField({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'fields',
|
|
fileName: 'example-field.ts',
|
|
});
|
|
}
|
|
|
|
if (exampleOptions.includeExampleLogicFunction) {
|
|
await createDefaultFunction({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'logic-functions',
|
|
fileName: 'hello-world.ts',
|
|
});
|
|
}
|
|
|
|
if (exampleOptions.includeExampleFrontComponent) {
|
|
await createDefaultFrontComponent({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'front-components',
|
|
fileName: 'hello-world.tsx',
|
|
});
|
|
}
|
|
|
|
if (exampleOptions.includeExampleView) {
|
|
await createExampleView({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'views',
|
|
fileName: 'example-view.ts',
|
|
});
|
|
}
|
|
|
|
if (exampleOptions.includeExampleNavigationMenuItem) {
|
|
await createExampleNavigationMenuItem({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'navigation-menu-items',
|
|
fileName: 'example-navigation-menu-item.ts',
|
|
});
|
|
}
|
|
|
|
if (exampleOptions.includeExampleSkill) {
|
|
await createExampleSkill({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'skills',
|
|
fileName: 'example-skill.ts',
|
|
});
|
|
}
|
|
|
|
if (exampleOptions.includeExampleIntegrationTest) {
|
|
await scaffoldIntegrationTest({
|
|
appDirectory,
|
|
sourceFolderPath,
|
|
});
|
|
}
|
|
|
|
await createDefaultPreInstallFunction({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'logic-functions',
|
|
fileName: 'pre-install.ts',
|
|
});
|
|
|
|
await createDefaultPostInstallFunction({
|
|
appDirectory: sourceFolderPath,
|
|
fileFolder: 'logic-functions',
|
|
fileName: 'post-install.ts',
|
|
});
|
|
|
|
await createApplicationConfig({
|
|
displayName: appDisplayName,
|
|
description: appDescription,
|
|
appDirectory: sourceFolderPath,
|
|
fileName: 'application-config.ts',
|
|
});
|
|
};
|
|
|
|
const createPublicAssetDirectory = async (appDirectory: string) => {
|
|
await fs.ensureDir(join(appDirectory, ASSETS_DIR));
|
|
};
|
|
|
|
const createYarnLock = async (appDirectory: string) => {
|
|
const yarnLockContent = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
|
# yarn lockfile v1
|
|
`;
|
|
|
|
await fs.writeFile(join(appDirectory, 'yarn.lock'), yarnLockContent);
|
|
};
|
|
const createGitignore = async (appDirectory: string) => {
|
|
const gitignoreContent = `# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
|
|
# dependencies
|
|
/node_modules
|
|
/.pnp
|
|
.pnp.*
|
|
.yarn
|
|
|
|
# codegen
|
|
generated
|
|
|
|
# testing
|
|
/coverage
|
|
|
|
# dev
|
|
/dist/
|
|
|
|
.twenty
|
|
|
|
# production
|
|
/build
|
|
|
|
# misc
|
|
.DS_Store
|
|
*.pem
|
|
|
|
# debug
|
|
npm-debug.log*
|
|
yarn-debug.log*
|
|
yarn-error.log*
|
|
.pnpm-debug.log*
|
|
|
|
# env files (can opt-in for committing if needed)
|
|
.env*
|
|
|
|
# typescript
|
|
*.tsbuildinfo
|
|
*.d.ts
|
|
`;
|
|
|
|
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
|
|
};
|
|
|
|
const createDefaultRoleConfig = async ({
|
|
displayName,
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
displayName: string;
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineRole } from 'twenty-sdk';
|
|
|
|
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
|
'${universalIdentifier}';
|
|
|
|
export default defineRole({
|
|
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
|
label: '${displayName} default function role',
|
|
description: '${displayName} default function role',
|
|
canReadAllObjectRecords: true,
|
|
canUpdateAllObjectRecords: true,
|
|
canSoftDeleteAllObjectRecords: true,
|
|
canDestroyAllObjectRecords: false,
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createDefaultFrontComponent = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineFrontComponent } from 'twenty-sdk';
|
|
|
|
export const HelloWorld = () => {
|
|
return (
|
|
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
|
<h1>Hello, World!</h1>
|
|
<p>This is your first front component.</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: '${universalIdentifier}',
|
|
name: 'hello-world-front-component',
|
|
description: 'A sample front component',
|
|
component: HelloWorld,
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createDefaultFunction = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
|
|
|
const handler = async (): Promise<{ message: string }> => {
|
|
return { message: 'Hello, World!' };
|
|
};
|
|
|
|
export default defineLogicFunction({
|
|
universalIdentifier: '${universalIdentifier}',
|
|
name: 'hello-world-logic-function',
|
|
description: 'A simple logic function',
|
|
timeoutSeconds: 5,
|
|
handler,
|
|
httpRouteTriggerSettings: {
|
|
path: '/hello-world-logic-function',
|
|
httpMethod: 'GET',
|
|
isAuthRequired: false,
|
|
},
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createDefaultPreInstallFunction = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
|
|
|
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
|
console.log('Pre install logic function executed successfully!', payload.previousVersion);
|
|
};
|
|
|
|
export default definePreInstallLogicFunction({
|
|
universalIdentifier: '${universalIdentifier}',
|
|
name: 'pre-install',
|
|
description: 'Runs before installation to prepare the application.',
|
|
timeoutSeconds: 300,
|
|
handler,
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createDefaultPostInstallFunction = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
|
|
|
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
|
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
|
};
|
|
|
|
export default definePostInstallLogicFunction({
|
|
universalIdentifier: '${universalIdentifier}',
|
|
name: 'post-install',
|
|
description: 'Runs after installation to set up the application.',
|
|
timeoutSeconds: 300,
|
|
handler,
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createExampleObject = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const objectUniversalIdentifier = v4();
|
|
const nameFieldUniversalIdentifier = v4();
|
|
|
|
const content = `import { defineObject, FieldType } from 'twenty-sdk';
|
|
|
|
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
|
|
'${objectUniversalIdentifier}';
|
|
|
|
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
|
|
'${nameFieldUniversalIdentifier}';
|
|
|
|
export default defineObject({
|
|
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
|
nameSingular: 'exampleItem',
|
|
namePlural: 'exampleItems',
|
|
labelSingular: 'Example item',
|
|
labelPlural: 'Example items',
|
|
description: 'A sample custom object',
|
|
icon: 'IconBox',
|
|
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
|
fields: [
|
|
{
|
|
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
|
type: FieldType.TEXT,
|
|
name: 'name',
|
|
label: 'Name',
|
|
description: 'Name of the example item',
|
|
icon: 'IconAbc',
|
|
},
|
|
],
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createExampleField = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineField, FieldType } from 'twenty-sdk';
|
|
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
|
|
|
|
export default defineField({
|
|
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
|
universalIdentifier: '${universalIdentifier}',
|
|
type: FieldType.NUMBER,
|
|
name: 'priority',
|
|
label: 'Priority',
|
|
description: 'Priority level for the example item (1-10)',
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createExampleView = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineView } from 'twenty-sdk';
|
|
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
|
|
|
|
export default defineView({
|
|
universalIdentifier: '${universalIdentifier}',
|
|
name: 'example-view',
|
|
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
|
icon: 'IconList',
|
|
position: 0,
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createExampleNavigationMenuItem = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
|
|
|
|
export default defineNavigationMenuItem({
|
|
universalIdentifier: '${universalIdentifier}',
|
|
name: 'example-navigation-menu-item',
|
|
icon: 'IconList',
|
|
position: 0,
|
|
// Link to a view:
|
|
// viewUniversalIdentifier: '...',
|
|
// Or link to an object:
|
|
// targetObjectUniversalIdentifier: '...',
|
|
// Or link to an external URL:
|
|
// link: 'https://example.com',
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createExampleSkill = async ({
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineSkill } from 'twenty-sdk';
|
|
|
|
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
|
|
'${universalIdentifier}';
|
|
|
|
export default defineSkill({
|
|
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
|
|
name: 'example-skill',
|
|
label: 'Example Skill',
|
|
description: 'A sample skill for your application',
|
|
icon: 'IconBrain',
|
|
content: 'Add your skill instructions here. Skills provide context and capabilities to AI agents.',
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createApplicationConfig = async ({
|
|
displayName,
|
|
description,
|
|
appDirectory,
|
|
fileFolder,
|
|
fileName,
|
|
}: {
|
|
displayName: string;
|
|
description?: string;
|
|
appDirectory: string;
|
|
fileFolder?: string;
|
|
fileName: string;
|
|
}) => {
|
|
const universalIdentifier = v4();
|
|
|
|
const content = `import { defineApplication } from 'twenty-sdk';
|
|
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
|
|
|
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
|
'${universalIdentifier}';
|
|
|
|
export default defineApplication({
|
|
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
|
displayName: '${displayName}',
|
|
description: '${description ?? ''}',
|
|
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
|
});
|
|
`;
|
|
|
|
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
|
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
|
};
|
|
|
|
const createPackageJson = async ({
|
|
appName,
|
|
appDirectory,
|
|
includeExampleIntegrationTest,
|
|
}: {
|
|
appName: string;
|
|
appDirectory: string;
|
|
includeExampleIntegrationTest: 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',
|
|
'twenty-sdk': createTwentyAppPackageJson.version,
|
|
};
|
|
|
|
if (includeExampleIntegrationTest) {
|
|
scripts.test = 'vitest run';
|
|
scripts['test:watch'] = 'vitest';
|
|
devDependencies.vitest = '^3.1.1';
|
|
devDependencies['vite-tsconfig-paths'] = '^4.2.1';
|
|
}
|
|
|
|
const packageJson = {
|
|
name: appName,
|
|
version: '0.1.0',
|
|
license: 'MIT',
|
|
engines: {
|
|
node: '^24.5.0',
|
|
npm: 'please-use-yarn',
|
|
yarn: '>=4.0.2',
|
|
},
|
|
packageManager: 'yarn@4.9.2',
|
|
scripts,
|
|
devDependencies,
|
|
};
|
|
|
|
await fs.writeFile(
|
|
join(appDirectory, 'package.json'),
|
|
JSON.stringify(packageJson, null, 2),
|
|
'utf8',
|
|
);
|
|
};
|