First Application POC (#14382)

Quick proof of concept for twenty-apps + twenty-cli, with local
development / hot reload

Let's discuss it!



https://github.com/user-attachments/assets/c6789936-cd5f-4110-a265-863a6ac1af2d
This commit is contained in:
Félix Malfait
2025-09-10 15:12:38 +02:00
committed by GitHub
parent 9a05daa624
commit 30a2164980
79 changed files with 5515 additions and 365 deletions
@@ -0,0 +1,125 @@
import {
createAgentManifest,
createManifest,
createReadmeContent,
} from '../app-template';
// Mock crypto.randomUUID to make tests deterministic
jest.mock('crypto', () => ({
randomUUID: jest.fn(() => 'mocked-uuid-12345'),
}));
describe('app-template', () => {
describe('createManifest', () => {
it('should create a valid app manifest with correct structure', () => {
const appName = 'my-test-app';
const manifest = createManifest(appName);
expect(manifest).toEqual({
$schema:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json',
standardId: 'mocked-uuid-12345',
label: 'My Test App',
description: 'A Twenty application for my-test-app',
version: '1.0.0',
// agents will be discovered from the agents/ folder
});
});
it('should handle single word app names', () => {
const appName = 'calculator';
const manifest = createManifest(appName);
expect(manifest.label).toBe('Calculator');
expect(manifest.standardId).toBe('mocked-uuid-12345');
});
it('should handle kebab-case app names correctly', () => {
const appName = 'user-management-system';
const manifest = createManifest(appName);
expect(manifest.label).toBe('User Management System');
expect(manifest.standardId).toBe('mocked-uuid-12345');
});
it('should generate unique standardIds', () => {
const manifest = createManifest('test-app');
expect(manifest.standardId).toBeDefined();
expect(typeof manifest.standardId).toBe('string');
});
});
describe('createAgentManifest', () => {
it('should create a valid agent manifest with correct structure', () => {
const appName = 'my-test-app';
const agent = createAgentManifest(appName);
expect(agent).toEqual({
$schema:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json',
standardId: 'mocked-uuid-12345',
name: 'myTestAppAgent',
label: 'My Test App Agent',
description: 'AI agent for my-test-app',
prompt:
'You are an AI agent for my-test-app. Help users with their tasks and provide assistance with Twenty CRM features.',
modelId: 'auto',
responseFormat: {
type: 'text',
},
});
});
it('should handle single word app names', () => {
const appName = 'calculator';
const agent = createAgentManifest(appName);
expect(agent.name).toBe('calculatorAgent');
expect(agent.label).toBe('Calculator Agent');
});
it('should handle kebab-case app names correctly', () => {
const appName = 'user-management-system';
const agent = createAgentManifest(appName);
expect(agent.name).toBe('userManagementSystemAgent');
expect(agent.label).toBe('User Management System Agent');
});
});
describe('createReadmeContent', () => {
it('should generate correct README content', () => {
const appName = 'my-awesome-app';
const appDir = '/path/to/my-awesome-app';
const readmeContent = createReadmeContent(appName, appDir);
expect(readmeContent).toContain('# my-awesome-app');
expect(readmeContent).toContain('A Twenty application.');
expect(readmeContent).toContain(
'twenty app dev --path /path/to/my-awesome-app',
);
expect(readmeContent).toContain('cd /path/to/my-awesome-app');
expect(readmeContent).toContain(
'twenty app deploy --path /path/to/my-awesome-app',
);
});
it('should include development and deployment sections', () => {
const readmeContent = createReadmeContent('test-app', '/test/path');
expect(readmeContent).toContain('## Development');
expect(readmeContent).toContain('## Deployment');
expect(readmeContent).toContain('To start development mode:');
expect(readmeContent).toContain('To deploy the application:');
});
it('should handle different app directories', () => {
const appName = 'sample-app';
const appDir = '/custom/directory/sample-app';
const readmeContent = createReadmeContent(appName, appDir);
expect(readmeContent).toContain('/custom/directory/sample-app');
});
});
});
@@ -0,0 +1,78 @@
import * as fs from 'fs-extra';
import * as path from 'path';
export const findProjectRoot = async (): Promise<string | null> => {
let currentDir = process.cwd();
const maxDepth = 10;
let depth = 0;
while (depth < maxDepth) {
const nxConfig = path.join(currentDir, 'nx.json');
const packageJson = path.join(currentDir, 'package.json');
if (await fs.pathExists(nxConfig)) {
return currentDir;
}
if (await fs.pathExists(packageJson)) {
try {
const pkg = await fs.readJson(packageJson);
if (pkg.workspaces || pkg.name === 'twenty') {
return currentDir;
}
} catch {
// Ignore JSON parse errors
}
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
depth++;
}
return null;
};
export const findNearbyApps = async (startDir: string): Promise<string[]> => {
const apps: string[] = [];
try {
const searchPaths = [
startDir,
path.join(startDir, '..'),
path.join(startDir, '../..'),
path.join(startDir, 'packages/twenty-apps'),
path.join(startDir, '../../packages/twenty-apps'),
];
for (const searchPath of searchPaths) {
if (await fs.pathExists(searchPath)) {
const items = await fs.readdir(searchPath, { withFileTypes: true });
for (const item of items) {
if (item.isDirectory()) {
const manifestPath = path.join(
searchPath,
item.name,
'twenty-app.json',
);
if (await fs.pathExists(manifestPath)) {
apps.push(path.join(searchPath, item.name));
}
}
}
}
}
} catch {
// Ignore errors during search
}
return apps.slice(0, 5);
};
export const isValidAppPath = async (appPath: string): Promise<boolean> => {
const manifestPath = path.join(appPath, 'twenty-app.json');
return fs.pathExists(manifestPath);
};
@@ -0,0 +1,160 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { AgentManifest, AppManifest } from '../types/config.types';
import { parseJsoncFile } from './jsonc-parser';
import { schemaValidator } from './schema-validator';
export interface AppManifestWithMeta extends AppManifest {
_meta?: {
agentFiles?: string[];
manifestPath?: string;
};
}
export type AppManifestRaw = Omit<AppManifest, 'agents'> & {
// agents will be discovered from the agents/ folder
agents?: AgentManifest[];
};
export class AppManifestLoader {
private appPath: string;
constructor(appPath: string) {
this.appPath = appPath;
}
async loadManifest(): Promise<AppManifestWithMeta> {
const manifestPath = await this.findManifestFile();
const rawManifest = await parseJsoncFile(manifestPath);
// Validate the raw manifest structure
await schemaValidator.validateAppManifest(rawManifest, manifestPath);
return this.discoverAndLoadAgents(rawManifest, manifestPath);
}
private async findManifestFile(): Promise<string> {
// Try JSONC first, then fall back to JSON for backward compatibility
const jsoncPath = path.join(this.appPath, 'twenty-app.jsonc');
const jsonPath = path.join(this.appPath, 'twenty-app.json');
if (await fs.pathExists(jsoncPath)) {
return jsoncPath;
}
if (await fs.pathExists(jsonPath)) {
return jsonPath;
}
throw new Error(
`No manifest file found. Expected twenty-app.jsonc or twenty-app.json in ${this.appPath}`,
);
}
private async discoverAndLoadAgents(
rawManifest: AppManifestRaw,
manifestPath: string,
): Promise<AppManifestWithMeta> {
const agentsDir = path.join(this.appPath, 'agents');
const agentFiles: string[] = [];
const agents: AgentManifest[] = [];
// Check if agents directory exists
if (await fs.pathExists(agentsDir)) {
const files = await fs.readdir(agentsDir);
const agentFileNames = files.filter(
(file) => file.endsWith('.jsonc') || file.endsWith('.json'),
);
for (const fileName of agentFileNames) {
const agentPath = path.join(agentsDir, fileName);
const agentManifest = await parseJsoncFile(agentPath);
// Validate the agent against schema
await schemaValidator.validateAgent(agentManifest, agentPath);
agents.push(agentManifest);
agentFiles.push(`agents/${fileName}`);
}
}
return {
standardId: rawManifest.standardId,
label: rawManifest.label,
description: rawManifest.description,
icon: rawManifest.icon,
version: rawManifest.version,
agents,
_meta: {
agentFiles,
manifestPath,
},
};
}
// Utility method to split agents from an existing manifest
static async splitAgentsFromManifest(
appPath: string,
options: {
agentsDir?: string;
preserveOriginal?: boolean;
} = {},
): Promise<void> {
const loader = new AppManifestLoader(appPath);
const manifest = await loader.loadManifest();
const agentsDir = options.agentsDir || 'agents';
const agentsDirPath = path.join(appPath, agentsDir);
// Create agents directory
await fs.ensureDir(agentsDirPath);
// Extract agents to separate files
for (const agent of manifest.agents) {
const agentFileName = `${agent.name}.jsonc`;
const agentFilePath = path.join(agentsDirPath, agentFileName);
// Write agent to separate file
await fs.writeFile(agentFilePath, JSON.stringify(agent, null, 2), 'utf8');
}
// Update main manifest (remove agents array since they're now discovered)
const updatedManifest = {
standardId: manifest.standardId,
label: manifest.label,
description: manifest.description,
icon: manifest.icon,
version: manifest.version,
// No agents array - they will be discovered from the agents/ folder
};
// Write updated manifest as JSONC
const newManifestPath = path.join(appPath, 'twenty-app.jsonc');
await fs.writeFile(
newManifestPath,
JSON.stringify(updatedManifest, null, 2),
'utf8',
);
// Optionally remove original JSON file
if (!options.preserveOriginal) {
const oldManifestPath = path.join(appPath, 'twenty-app.json');
if (await fs.pathExists(oldManifestPath)) {
await fs.remove(oldManifestPath);
}
}
}
}
// Convenience function for backward compatibility
export const loadAppManifest = async (
appPath: string,
): Promise<AppManifest> => {
const loader = new AppManifestLoader(appPath);
const manifest = await loader.loadManifest();
// Remove meta information for backward compatibility
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _meta, ...cleanManifest } = manifest;
return cleanManifest;
};
@@ -0,0 +1,116 @@
import chalk from 'chalk';
import * as fs from 'fs-extra';
import * as path from 'path';
import {
findNearbyApps,
findProjectRoot,
isValidAppPath,
} from './app-discovery';
export const resolveAppPath = async (
providedPath?: string,
verbose = false,
): Promise<string> => {
if (providedPath && path.isAbsolute(providedPath)) {
return validateAppPath(providedPath, verbose);
}
if (providedPath) {
return resolveRelativePath(providedPath);
}
return autoDetectAppPath(verbose);
};
const resolveRelativePath = async (providedPath: string): Promise<string> => {
const fromCwd = path.resolve(process.cwd(), providedPath);
if (await isValidAppPath(fromCwd)) {
return fromCwd;
}
const projectRoot = await findProjectRoot();
if (projectRoot) {
const fromProjectRoot = path.resolve(projectRoot, providedPath);
if (await isValidAppPath(fromProjectRoot)) {
return fromProjectRoot;
}
}
throw new Error(`Cannot find twenty-app.json at any of these locations:
- ${fromCwd}
- ${projectRoot ? path.resolve(projectRoot, providedPath) : 'N/A (no project root found)'}
Please check the path or run from the correct directory.`);
};
const autoDetectAppPath = async (verbose = false): Promise<string> => {
let currentDir = process.cwd();
const maxDepth = 10;
let depth = 0;
while (depth < maxDepth) {
if (await isValidAppPath(currentDir)) {
if (verbose) {
console.log(chalk.gray(`Auto-detected app path: ${currentDir}`));
}
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
depth++;
}
const suggestions = await findNearbyApps(process.cwd());
let errorMessage =
'No twenty-app.json found in current directory or parent directories.';
if (suggestions.length > 0) {
errorMessage += '\n\nFound Twenty applications nearby:';
suggestions.forEach((suggestion, i) => {
errorMessage += `\n ${i + 1}. ${suggestion}`;
});
errorMessage +=
'\n\nTry running from one of these directories or use --path option.';
} else {
errorMessage += '\n\nRun `twenty app init` to create a new application.';
}
throw new Error(errorMessage);
};
const validateAppPath = async (
appPath: string,
verbose = false,
): Promise<string> => {
if (verbose) {
console.log(chalk.gray(`Checking app path: ${appPath}`));
}
const jsoncManifestPath = path.join(appPath, 'twenty-app.jsonc');
const jsonManifestPath = path.join(appPath, 'twenty-app.json');
const hasJsoncManifest = await fs.pathExists(jsoncManifestPath);
const hasJsonManifest = await fs.pathExists(jsonManifestPath);
if (!hasJsoncManifest && !hasJsonManifest) {
let errorMessage = `No manifest file found. Expected twenty-app.jsonc or twenty-app.json in: ${appPath}`;
if (await fs.pathExists(appPath)) {
try {
const files = await fs.readdir(appPath);
errorMessage += `\n\nFiles in directory: ${files.join(', ')}`;
} catch {
errorMessage += '\n\nCould not read directory contents.';
}
} else {
errorMessage += '\n\nDirectory does not exist.';
}
throw new Error(errorMessage);
}
return appPath;
};
+28
View File
@@ -0,0 +1,28 @@
import chalk from 'chalk';
import { ApiService } from '../services/api.service';
import { loadAppManifest } from './app-manifest-loader';
export const syncApp = async (
appPath: string,
apiService: ApiService,
): Promise<any> => {
const manifest = await loadAppManifest(appPath);
try {
const result = await apiService.syncApplication(manifest);
if (result.success) {
console.log(chalk.green('✅ Application synced successfully'));
} else {
console.error(chalk.red('❌ Sync failed:'), result.error);
}
return result;
} catch (error) {
console.error(
chalk.red('Sync error:'),
error instanceof Error ? error.message : error,
);
throw error;
}
};
@@ -0,0 +1,81 @@
import { randomUUID } from 'crypto';
import { AgentManifest, AppManifest } from '../types/config.types';
import { SchemaValidator } from './schema-validator';
export type AppManifestTemplate = Omit<AppManifest, 'agents'> & {
$schema?: string;
// agents will be discovered from the agents/ folder
};
export type AgentManifestTemplate = AgentManifest & {
$schema?: string;
};
export const createManifest = (appName: string): AppManifestTemplate => {
const schemas = SchemaValidator.getSchemaUrls();
return {
$schema: schemas.appManifest,
standardId: randomUUID(),
label: appName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
description: `A Twenty application for ${appName}`,
version: '1.0.0',
// agents will be discovered from the agents/ folder
};
};
export const createAgentManifest = (appName: string): AgentManifestTemplate => {
const schemas = SchemaValidator.getSchemaUrls();
return {
$schema: schemas.agent,
standardId: randomUUID(),
name: `${appName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())}Agent`,
label: `${appName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')} Agent`,
description: `AI agent for ${appName}`,
prompt: `You are an AI agent for ${appName}. Help users with their tasks and provide assistance with Twenty CRM features.`,
modelId: 'auto',
responseFormat: {
type: 'text',
},
};
};
export const createReadmeContent = (
appName: string,
appDir: string,
): string => {
return `# ${appName}
A Twenty application.
## Development
To start development mode:
\`\`\`bash
twenty app dev --path ${appDir}
\`\`\`
Or from the app directory:
\`\`\`bash
cd ${appDir}
twenty app dev
\`\`\`
## Deployment
To deploy the application:
\`\`\`bash
twenty app deploy --path ${appDir}
\`\`\`
`;
};
@@ -0,0 +1,68 @@
import * as fs from 'fs-extra';
import { ParseError, parse as parseJsonc } from 'jsonc-parser';
export interface JsoncParseOptions {
allowTrailingComma?: boolean;
disallowComments?: boolean;
allowEmptyContent?: boolean;
}
export class JsoncParseError extends Error {
constructor(
message: string,
public readonly parseErrors: ParseError[],
public readonly filePath?: string,
) {
super(message);
this.name = 'JsoncParseError';
}
}
export const parseJsoncString = (
content: string,
options: JsoncParseOptions = {},
): any => {
const parseErrors: ParseError[] = [];
const result = parseJsonc(content, parseErrors, {
allowTrailingComma: options.allowTrailingComma ?? true,
disallowComments: options.disallowComments ?? false,
allowEmptyContent: options.allowEmptyContent ?? false,
});
if (parseErrors.length > 0) {
const errorMessages = parseErrors.map(
(error) => `Line ${error.offset}: ${error.error}`,
);
throw new JsoncParseError(
`JSONC parse errors:\n${errorMessages.join('\n')}`,
parseErrors,
);
}
return result;
};
export const parseJsoncFile = async (
filePath: string,
options: JsoncParseOptions = {},
): Promise<any> => {
try {
const content = await fs.readFile(filePath, 'utf8');
return parseJsoncString(content, options);
} catch (error) {
if (error instanceof JsoncParseError) {
throw new JsoncParseError(error.message, error.parseErrors, filePath);
}
throw new Error(`Failed to read file ${filePath}: ${error}`);
}
};
export const writeJsoncFile = async (
filePath: string,
data: any,
options: { spaces?: number } = {},
): Promise<void> => {
const content = JSON.stringify(data, null, options.spaces ?? 2);
await fs.writeFile(filePath, content, 'utf8');
};
@@ -0,0 +1,120 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import * as fs from 'fs-extra';
import * as path from 'path';
export class SchemaValidationError extends Error {
constructor(
message: string,
public readonly errors: any[],
public readonly filePath?: string,
) {
super(message);
this.name = 'SchemaValidationError';
}
}
export class SchemaValidator {
private ajv: Ajv;
private schemasLoaded = false;
constructor() {
this.ajv = new Ajv({
allErrors: true,
verbose: true,
strict: false,
});
addFormats(this.ajv);
}
private async loadSchemas(): Promise<void> {
if (this.schemasLoaded) return;
const schemasDir = path.join(__dirname, '../../schemas');
try {
// Load agent schema
const agentSchemaPath = path.join(schemasDir, 'agent.schema.json');
const agentSchema = await fs.readJson(agentSchemaPath);
this.ajv.addSchema(agentSchema, 'agent');
// Load app manifest schema
const appSchemaPath = path.join(schemasDir, 'app-manifest.schema.json');
const appSchema = await fs.readJson(appSchemaPath);
this.ajv.addSchema(appSchema, 'app-manifest');
this.schemasLoaded = true;
} catch {
// Gracefully handle missing schemas in development
console.warn('Warning: Could not load JSON schemas for validation');
this.schemasLoaded = true; // Prevent retry
}
}
async validateAgent(agent: any, filePath?: string): Promise<void> {
await this.loadSchemas();
const validate = this.ajv.getSchema('agent');
if (!validate) {
// Schema not available, skip validation
return;
}
const valid = validate(agent);
if (!valid) {
const errorMessages = this.formatErrors(validate.errors || []);
throw new SchemaValidationError(
`Agent validation failed:\n${errorMessages}`,
validate.errors || [],
filePath,
);
}
}
async validateAppManifest(manifest: any, filePath?: string): Promise<void> {
await this.loadSchemas();
const validate = this.ajv.getSchema('app-manifest');
if (!validate) {
// Schema not available, skip validation
return;
}
const valid = validate(manifest);
if (!valid) {
const errorMessages = this.formatErrors(validate.errors || []);
throw new SchemaValidationError(
`App manifest validation failed:\n${errorMessages}`,
validate.errors || [],
filePath,
);
}
}
private formatErrors(errors: any[]): string {
return errors
.map((error) => {
const path = error.instancePath || 'root';
const message = error.message;
const value =
error.data !== undefined
? ` (got: ${JSON.stringify(error.data)})`
: '';
return `${path}: ${message}${value}`;
})
.join('\n');
}
// Get schema URLs for $schema references
static getSchemaUrls() {
return {
agent:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json',
appManifest:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json',
};
}
}
// Singleton instance
export const schemaValidator = new SchemaValidator();