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:
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user