1541 extensibility twenty cli use workspace migration v2 to synchronize application objects fields views (#14706)
- synchronize objects https://github.com/user-attachments/assets/257317bc-2881-4b98-a3d4-6ae52bd72aa0
This commit is contained in:
@@ -1,95 +1,64 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { AgentManifest, AppManifest } from '../types/config.types';
|
||||
import { AppManifest, CoreEntityManifest } from '../types/config.types';
|
||||
import { parseJsoncFile } from './jsonc-parser';
|
||||
import { schemaValidator } from './schema-validator';
|
||||
import { validateSchema } from '../utils/schema-validator';
|
||||
|
||||
export interface AppManifestWithMeta extends AppManifest {
|
||||
_meta?: {
|
||||
agentFiles?: string[];
|
||||
manifestPath?: string;
|
||||
};
|
||||
}
|
||||
const findPackageJsonFile = async (appPath: string): Promise<string> => {
|
||||
const jsonPath = path.join(appPath, 'package.json');
|
||||
|
||||
export type AppManifestRaw = Omit<AppManifest, 'agents'> & {
|
||||
// agents will be discovered from the agents/ folder
|
||||
agents?: AgentManifest[];
|
||||
if (await fs.pathExists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
throw new Error(`package.json not found in ${appPath}`);
|
||||
};
|
||||
|
||||
export class AppManifestLoader {
|
||||
private appPath: string;
|
||||
const loadCoreEntity = async (
|
||||
coreEntityPath: string,
|
||||
validator: (manifest: CoreEntityManifest, path: string) => Promise<void>,
|
||||
): Promise<CoreEntityManifest[]> => {
|
||||
const coreEntities: CoreEntityManifest[] = [];
|
||||
|
||||
constructor(appPath: string) {
|
||||
this.appPath = appPath;
|
||||
}
|
||||
if (await fs.pathExists(coreEntityPath)) {
|
||||
const files = await fs.readdir(coreEntityPath);
|
||||
const coreEntityFileNames = files.filter(
|
||||
(file) => file.endsWith('.jsonc') || file.endsWith('.json'),
|
||||
);
|
||||
|
||||
async loadManifest(): Promise<AppManifestWithMeta> {
|
||||
const packageJsonPath = await this.findPackageJsonFile();
|
||||
const rawPackageJson = await parseJsoncFile(packageJsonPath);
|
||||
|
||||
// Validate the raw manifest structure
|
||||
await schemaValidator.validateAppManifest(rawPackageJson, packageJsonPath);
|
||||
|
||||
return this.discoverAndLoadAgents(rawPackageJson, packageJsonPath);
|
||||
}
|
||||
|
||||
private async findPackageJsonFile(): Promise<string> {
|
||||
const jsonPath = path.join(this.appPath, 'package.json');
|
||||
|
||||
if (await fs.pathExists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
throw new Error(`package.json not found 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 coreEntityFileNames) {
|
||||
const coreEntityManifest = await parseJsoncFile(
|
||||
path.join(coreEntityPath, fileName),
|
||||
);
|
||||
|
||||
for (const fileName of agentFileNames) {
|
||||
const agentPath = path.join(agentsDir, fileName);
|
||||
const agentManifest = await parseJsoncFile(agentPath);
|
||||
await validator(coreEntityManifest, coreEntityPath);
|
||||
|
||||
// Validate the agent against schema
|
||||
await schemaValidator.validateAgent(agentManifest, agentPath);
|
||||
|
||||
agents.push(agentManifest);
|
||||
agentFiles.push(`agents/${fileName}`);
|
||||
}
|
||||
coreEntities.push(coreEntityManifest);
|
||||
}
|
||||
|
||||
return {
|
||||
...rawManifest,
|
||||
agents,
|
||||
_meta: {
|
||||
agentFiles,
|
||||
manifestPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
return coreEntities;
|
||||
};
|
||||
|
||||
export const loadManifest = async (appPath: string): Promise<AppManifest> => {
|
||||
const packageJsonPath = await findPackageJsonFile(appPath);
|
||||
const rawPackageJson = await parseJsoncFile(packageJsonPath);
|
||||
|
||||
await validateSchema('app-manifest', rawPackageJson, packageJsonPath);
|
||||
|
||||
const agents = await loadCoreEntity(
|
||||
path.join(appPath, 'agents'),
|
||||
(manifest, path) => validateSchema('agent', manifest, path),
|
||||
);
|
||||
|
||||
const objects = await loadCoreEntity(
|
||||
path.join(appPath, 'objects'),
|
||||
(manifest, path) => validateSchema('object', manifest, path),
|
||||
);
|
||||
|
||||
return {
|
||||
...rawPackageJson,
|
||||
agents,
|
||||
objects,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import chalk from 'chalk';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { loadAppManifest } from './app-manifest-loader';
|
||||
import { loadManifest } from './app-manifest-loader';
|
||||
|
||||
export const syncApp = async (
|
||||
appPath: string,
|
||||
apiService: ApiService,
|
||||
): Promise<any> => {
|
||||
const manifest = await loadAppManifest(appPath);
|
||||
const manifest = await loadManifest(appPath);
|
||||
|
||||
try {
|
||||
const result = await apiService.syncApplication(manifest);
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { AgentManifest, AppManifest } from '../types/config.types';
|
||||
import { SchemaValidator } from './schema-validator';
|
||||
import { AgentManifest, PackageJson } from '../types/config.types';
|
||||
import { getSchemaUrls } 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 createBasePackageJson = (appName: string): AppManifestTemplate => {
|
||||
const schemas = SchemaValidator.getSchemaUrls();
|
||||
export const createBasePackageJson = (appName: string): PackageJson => {
|
||||
const schemas = getSchemaUrls();
|
||||
|
||||
return {
|
||||
$schema: schemas.appManifest,
|
||||
@@ -26,8 +17,8 @@ export const createBasePackageJson = (appName: string): AppManifestTemplate => {
|
||||
};
|
||||
};
|
||||
|
||||
export const createAgentManifest = (appName: string): AgentManifestTemplate => {
|
||||
const schemas = SchemaValidator.getSchemaUrls();
|
||||
export const createAgentManifest = (appName: string): AgentManifest => {
|
||||
const schemas = getSchemaUrls();
|
||||
|
||||
return {
|
||||
$schema: schemas.agent,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -14,107 +13,64 @@ export class SchemaValidationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SchemaValidator {
|
||||
private ajv: Ajv;
|
||||
private schemasLoaded = false;
|
||||
const 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');
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.ajv = new Ajv({
|
||||
allErrors: true,
|
||||
verbose: true,
|
||||
strict: false,
|
||||
});
|
||||
addFormats(this.ajv);
|
||||
}
|
||||
export const validateSchema = async (
|
||||
schemaName: 'app-manifest' | 'agent' | 'object',
|
||||
manifest: any,
|
||||
filePath?: string,
|
||||
): Promise<void> => {
|
||||
const ajv = new Ajv({
|
||||
allErrors: true,
|
||||
verbose: true,
|
||||
strict: false,
|
||||
});
|
||||
|
||||
private async loadSchemas(): Promise<void> {
|
||||
if (this.schemasLoaded) return;
|
||||
const schemaUrls = getSchemaUrls();
|
||||
let schema;
|
||||
|
||||
for (const name of Object.keys(schemaUrls) as (keyof typeof schemaUrls)[]) {
|
||||
const formattedName = name === 'appManifest' ? 'app-manifest' : name;
|
||||
const schemasDir = path.join(__dirname, '../../schemas');
|
||||
const schemaPath = path.join(schemasDir, `${formattedName}.schema.json`);
|
||||
ajv.addSchema(await fs.readJson(schemaPath));
|
||||
|
||||
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
|
||||
if (formattedName === schemaName) {
|
||||
schema = ajv.getSchema(schemaUrls[name])?.schema;
|
||||
}
|
||||
}
|
||||
|
||||
async validateAgent(agent: any, filePath?: string): Promise<void> {
|
||||
await this.loadSchemas();
|
||||
if (!schema) throw new Error(`Schema ${schemaName} not found.`);
|
||||
|
||||
const validate = this.ajv.getSchema('agent');
|
||||
if (!validate) {
|
||||
// Schema not available, skip validation
|
||||
return;
|
||||
}
|
||||
const valid = ajv.validate(schema, manifest);
|
||||
|
||||
const valid = validate(agent);
|
||||
if (!valid) {
|
||||
const errorMessages = this.formatErrors(validate.errors || []);
|
||||
throw new SchemaValidationError(
|
||||
`Agent validation failed:\n${errorMessages}`,
|
||||
validate.errors || [],
|
||||
filePath,
|
||||
);
|
||||
}
|
||||
if (!valid) {
|
||||
const errorMessages = formatErrors(ajv.errors || []);
|
||||
throw new SchemaValidationError(
|
||||
`${schemaName} validation failed:\n${errorMessages}`,
|
||||
ajv.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();
|
||||
export const getSchemaUrls = () => {
|
||||
return {
|
||||
agent:
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json',
|
||||
object:
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/object.schema.json',
|
||||
appManifest:
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json',
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user