diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 6b2b28975..0ffecaceb 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -84,7 +84,7 @@ export { defineAgent } from './ai/agent.zod'; // DX Validation Utilities (re-exported for convenience) export { objectStackErrorMap, formatZodError, formatZodIssue, safeParsePretty } from './shared/error-map.zod'; export { suggestFieldType, findClosestMatches, formatSuggestion } from './shared/suggestions.zod'; -export { normalizeMetadataCollection, normalizeStackInput, MAP_SUPPORTED_FIELDS } from './shared/metadata-collection.zod'; +export { normalizeMetadataCollection, normalizeStackInput, normalizePluginMetadata, MAP_SUPPORTED_FIELDS, METADATA_ALIASES } from './shared/metadata-collection.zod'; export type { MetadataCollectionInput, MapSupportedField } from './shared/metadata-collection.zod'; export { type PluginContext } from './kernel/plugin.zod'; diff --git a/packages/spec/src/shared/metadata-collection.test.ts b/packages/spec/src/shared/metadata-collection.test.ts index 5d45bc6f3..990e40b15 100644 --- a/packages/spec/src/shared/metadata-collection.test.ts +++ b/packages/spec/src/shared/metadata-collection.test.ts @@ -4,7 +4,9 @@ import { describe, it, expect } from 'vitest'; import { normalizeMetadataCollection, normalizeStackInput, + normalizePluginMetadata, MAP_SUPPORTED_FIELDS, + METADATA_ALIASES, } from './metadata-collection.zod'; describe('normalizeMetadataCollection', () => { @@ -260,3 +262,144 @@ describe('MAP_SUPPORTED_FIELDS', () => { expect(MAP_SUPPORTED_FIELDS).not.toContain('devPlugins'); }); }); + +describe('METADATA_ALIASES', () => { + it('should map triggers to hooks', () => { + expect(METADATA_ALIASES.triggers).toBe('hooks'); + }); +}); + +describe('normalizePluginMetadata', () => { + describe('map → array conversion', () => { + it('should convert map-formatted actions to an array', () => { + const result = normalizePluginMetadata({ + actions: { + lead_convert: { type: 'custom', label: 'Convert Lead' }, + }, + }); + expect(result.actions).toEqual([ + { name: 'lead_convert', type: 'custom', label: 'Convert Lead' }, + ]); + }); + + it('should convert map-formatted workflows to an array', () => { + const result = normalizePluginMetadata({ + workflows: { + auto_assign: { objectName: 'lead', label: 'Auto Assign' }, + }, + }); + expect(result.workflows).toEqual([ + { name: 'auto_assign', objectName: 'lead', label: 'Auto Assign' }, + ]); + }); + + it('should leave array-formatted collections unchanged', () => { + const actions = [{ name: 'convert', type: 'custom' }]; + const result = normalizePluginMetadata({ actions }); + expect(result.actions).toBe(actions); + }); + + it('should handle multiple map-formatted collections at once', () => { + const result = normalizePluginMetadata({ + actions: { a: { label: 'A' } }, + flows: { f: { label: 'F' } }, + hooks: { h: { object: 'lead' } }, + }); + expect(result.actions).toEqual([{ name: 'a', label: 'A' }]); + expect(result.flows).toEqual([{ name: 'f', label: 'F' }]); + expect(result.hooks).toEqual([{ name: 'h', object: 'lead' }]); + }); + }); + + describe('alias resolution (triggers → hooks)', () => { + it('should rename triggers to hooks', () => { + const result = normalizePluginMetadata({ + triggers: { + lead_scoring: { object: 'lead', event: 'afterInsert' }, + }, + }); + expect(result.hooks).toEqual([ + { name: 'lead_scoring', object: 'lead', event: 'afterInsert' }, + ]); + expect(result.triggers).toBeUndefined(); + }); + + it('should merge triggers into existing hooks (array)', () => { + const result = normalizePluginMetadata({ + hooks: [{ name: 'existing_hook', object: 'account' }], + triggers: { + new_trigger: { object: 'lead', event: 'afterInsert' }, + }, + }); + expect(result.hooks).toEqual([ + { name: 'existing_hook', object: 'account' }, + { name: 'new_trigger', object: 'lead', event: 'afterInsert' }, + ]); + expect(result.triggers).toBeUndefined(); + }); + + it('should merge triggers into existing hooks (map)', () => { + const result = normalizePluginMetadata({ + hooks: { existing_hook: { object: 'account' } }, + triggers: { new_trigger: { object: 'lead' } }, + }); + expect(result.hooks).toEqual([ + { name: 'existing_hook', object: 'account' }, + { name: 'new_trigger', object: 'lead' }, + ]); + expect(result.triggers).toBeUndefined(); + }); + }); + + describe('recursive nested plugin normalization', () => { + it('should recursively normalize nested plugins', () => { + const result = normalizePluginMetadata({ + actions: { a1: { label: 'Root Action' } }, + plugins: [ + { + actions: { nested_action: { label: 'Nested' } }, + triggers: { nested_trigger: { object: 'contact' } }, + }, + 'string-plugin-ref', // string refs should pass through + ], + }); + + expect(result.actions).toEqual([{ name: 'a1', label: 'Root Action' }]); + expect(result.plugins).toHaveLength(2); + + const nestedPlugin = result.plugins[0] as Record; + expect(nestedPlugin.actions).toEqual([{ name: 'nested_action', label: 'Nested' }]); + expect(nestedPlugin.hooks).toEqual([{ name: 'nested_trigger', object: 'contact' }]); + expect(nestedPlugin.triggers).toBeUndefined(); + + expect(result.plugins[1]).toBe('string-plugin-ref'); + }); + }); + + describe('pass-through / edge cases', () => { + it('should not modify non-metadata fields', () => { + const result = normalizePluginMetadata({ + manifest: { name: 'test', version: '1.0.0' }, + actions: { a: { label: 'A' } }, + }); + expect(result.manifest).toEqual({ name: 'test', version: '1.0.0' }); + }); + + it('should handle empty input', () => { + expect(normalizePluginMetadata({})).toEqual({}); + }); + + it('should handle input with no metadata collections', () => { + const input = { manifest: { name: 'test' }, i18n: { defaultLocale: 'en' } }; + const result = normalizePluginMetadata(input); + expect(result.manifest).toBe(input.manifest); + expect(result.i18n).toBe(input.i18n); + }); + + it('should handle undefined metadata fields', () => { + const result = normalizePluginMetadata({ actions: undefined, hooks: undefined }); + expect(result.actions).toBeUndefined(); + expect(result.hooks).toBeUndefined(); + }); + }); +}); diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index 9bbbd4cd1..0993d9bf9 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -169,3 +169,87 @@ export function normalizeStackInput>(input: T) } return result; } + +/** + * Mapping of legacy / alternative field names to their canonical names + * in `ObjectStackDefinitionSchema`. + * + * Plugins may use legacy names (e.g., `triggers` instead of `hooks`). + * This map lets `normalizePluginMetadata()` rewrite them automatically. + */ +export const METADATA_ALIASES: Record = { + triggers: 'hooks', +}; + +/** + * Normalize plugin metadata so it matches the canonical format expected by the runtime. + * + * This handles two issues that commonly arise when loading third-party plugin metadata: + * + * 1. **Map → Array conversion** — plugins often define metadata as maps + * (e.g., `actions: { convert_lead: { ... } }`), but the runtime expects arrays. + * Every key listed in {@link MAP_SUPPORTED_FIELDS} is normalized via + * {@link normalizeMetadataCollection}. + * + * 2. **Field aliasing** — plugins may use legacy or alternative field names + * (e.g., `triggers` instead of `hooks`). {@link METADATA_ALIASES} maps them + * to their canonical counterparts. + * + * 3. **Recursive normalization** — if the plugin itself contains nested `plugins`, + * each nested plugin is normalized recursively. + * + * @param metadata - Raw plugin metadata object + * @returns A new object with all collections normalized to arrays, aliases resolved, + * and nested plugins recursively normalized + * + * @example + * ```ts + * const raw = { + * actions: { lead_convert: { type: 'custom', label: 'Convert' } }, + * triggers: { lead_scoring: { object: 'lead', event: 'afterInsert' } }, + * }; + * const normalized = normalizePluginMetadata(raw); + * // normalized.actions → [{ name: 'lead_convert', type: 'custom', label: 'Convert' }] + * // normalized.hooks → [{ name: 'lead_scoring', object: 'lead', event: 'afterInsert' }] + * // normalized.triggers → removed (merged into hooks) + * ``` + */ +export function normalizePluginMetadata>(metadata: T): T { + const result = { ...metadata }; + + // 1. Resolve aliases (e.g. triggers → hooks), merging with any existing canonical values + for (const [alias, canonical] of Object.entries(METADATA_ALIASES)) { + if (alias in result) { + const aliasValue = normalizeMetadataCollection(result[alias]); + const canonicalValue = normalizeMetadataCollection(result[canonical]); + + // Merge: canonical array wins; alias values are appended + if (Array.isArray(aliasValue)) { + (result as Record)[canonical] = Array.isArray(canonicalValue) + ? [...canonicalValue, ...aliasValue] + : aliasValue; + } + + delete (result as Record)[alias]; + } + } + + // 2. Normalize map-formatted collections → arrays + for (const field of MAP_SUPPORTED_FIELDS) { + if (field in result) { + (result as Record)[field] = normalizeMetadataCollection(result[field]); + } + } + + // 3. Recursively normalize nested plugins + if (Array.isArray(result.plugins)) { + (result as Record).plugins = result.plugins.map((p: unknown) => { + if (p && typeof p === 'object' && !Array.isArray(p)) { + return normalizePluginMetadata(p as Record); + } + return p; + }); + } + + return result; +}