@enfyra/mcp-server 0.1.43 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/mcp-instructions.js +1 -1
- package/dist/lib/mcp-instructions.js.map +1 -1
- package/dist/lib/platform-operation-tools.d.ts +63 -0
- package/dist/lib/platform-operation-tools.js +530 -2
- package/dist/lib/platform-operation-tools.js.map +1 -1
- package/dist/lib/required-knowledge.js +2 -1
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/tool-routing.js +2 -2
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/lib/toolset-filter.js +10 -0
- package/dist/lib/toolset-filter.js.map +1 -1
- package/package.json +1 -1
|
@@ -19,6 +19,11 @@ const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
|
|
|
19
19
|
'UFormField',
|
|
20
20
|
'UIcon',
|
|
21
21
|
'UInput',
|
|
22
|
+
'UInputMenu',
|
|
23
|
+
'UInputNumber',
|
|
24
|
+
'UInputTags',
|
|
25
|
+
'UInputTime',
|
|
26
|
+
'UInputDate',
|
|
22
27
|
'UModal',
|
|
23
28
|
'USelect',
|
|
24
29
|
'USelectMenu',
|
|
@@ -30,6 +35,18 @@ const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
|
|
|
30
35
|
'Widget',
|
|
31
36
|
];
|
|
32
37
|
const AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE = new Map(AUTO_INJECTED_EXTENSION_COMPONENT_TAGS.map((tag) => [tag.toLowerCase(), tag]));
|
|
38
|
+
const FULL_WIDTH_EXTENSION_FIELD_TAGS = [
|
|
39
|
+
'UInput',
|
|
40
|
+
'UTextarea',
|
|
41
|
+
'USelect',
|
|
42
|
+
'USelectMenu',
|
|
43
|
+
'UInputMenu',
|
|
44
|
+
'UInputNumber',
|
|
45
|
+
'UInputTags',
|
|
46
|
+
'UInputTime',
|
|
47
|
+
'UInputDate',
|
|
48
|
+
];
|
|
49
|
+
const FULL_WIDTH_EXTENSION_FIELD_PATTERN = new RegExp(`<(${FULL_WIDTH_EXTENSION_FIELD_TAGS.join('|')})(\\s[^<>]*?)(\\/?)>`, 'g');
|
|
33
50
|
function unwrapData(result) {
|
|
34
51
|
return Array.isArray(result?.data) ? result.data : [];
|
|
35
52
|
}
|
|
@@ -308,7 +325,9 @@ function quoteJsString(value) {
|
|
|
308
325
|
function normalizeVueBodySnippet(body) {
|
|
309
326
|
let code = String(body || '').trim();
|
|
310
327
|
const changes = [];
|
|
311
|
-
code = code.replace(
|
|
328
|
+
code = code.replace(FULL_WIDTH_EXTENSION_FIELD_PATTERN, (full, tag, attrs, slash) => {
|
|
329
|
+
if (/\bdata-compact\b/.test(attrs) || /\bdata-inline\b/.test(attrs))
|
|
330
|
+
return full;
|
|
312
331
|
if (/\bclass=/.test(attrs)) {
|
|
313
332
|
const nextAttrs = attrs.replace(/class="([^"]*)"/, (classMatch, classes) => {
|
|
314
333
|
if (String(classes).split(/\s+/).includes('w-full'))
|
|
@@ -331,6 +350,23 @@ function normalizeVueBodySnippet(body) {
|
|
|
331
350
|
});
|
|
332
351
|
return { code, changes: Array.from(new Set(changes)) };
|
|
333
352
|
}
|
|
353
|
+
function findMissingFullWidthFieldControls(code) {
|
|
354
|
+
const violations = [];
|
|
355
|
+
for (const template of readTemplateBlocks(code)) {
|
|
356
|
+
let match;
|
|
357
|
+
FULL_WIDTH_EXTENSION_FIELD_PATTERN.lastIndex = 0;
|
|
358
|
+
while ((match = FULL_WIDTH_EXTENSION_FIELD_PATTERN.exec(template))) {
|
|
359
|
+
const [snippet, tag, attrs] = match;
|
|
360
|
+
if (/\bdata-compact\b/.test(attrs) || /\bdata-inline\b/.test(attrs))
|
|
361
|
+
continue;
|
|
362
|
+
const classMatch = attrs.match(/\bclass="([^"]*)"/);
|
|
363
|
+
if (!classMatch || !classMatch[1].split(/\s+/).includes('w-full')) {
|
|
364
|
+
violations.push({ tag, snippet: snippet.length > 160 ? `${snippet.slice(0, 157)}...` : snippet });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return violations;
|
|
369
|
+
}
|
|
334
370
|
function indentLines(code, spaces = 2) {
|
|
335
371
|
const pad = ' '.repeat(spaces);
|
|
336
372
|
return String(code || '')
|
|
@@ -473,6 +509,333 @@ export function buildExtensionModalSnippet(input) {
|
|
|
473
509
|
],
|
|
474
510
|
};
|
|
475
511
|
}
|
|
512
|
+
function jsObjectLiteral(entries) {
|
|
513
|
+
return `{ ${entries.filter(Boolean).join(', ')} }`;
|
|
514
|
+
}
|
|
515
|
+
function jsArrayLiteral(values) {
|
|
516
|
+
return `[${(values || []).map(quoteJsString).join(', ')}]`;
|
|
517
|
+
}
|
|
518
|
+
function attrStaticOrBound(name, value, expression) {
|
|
519
|
+
if (expression)
|
|
520
|
+
return `:${name}="${expression}"`;
|
|
521
|
+
if (value === undefined || value === null || value === '')
|
|
522
|
+
return null;
|
|
523
|
+
return `${name}="${String(value).replace(/"/g, '"')}"`;
|
|
524
|
+
}
|
|
525
|
+
function buildHeaderActionLiteral(action) {
|
|
526
|
+
const entries = [
|
|
527
|
+
action.id ? `id: ${quoteJsString(action.id)}` : null,
|
|
528
|
+
action.label ? `label: ${quoteJsString(action.label)}` : null,
|
|
529
|
+
action.icon ? `icon: ${quoteJsString(action.icon)}` : null,
|
|
530
|
+
`color: ${quoteJsString(action.color || 'neutral')}`,
|
|
531
|
+
`variant: ${quoteJsString(action.variant || 'outline')}`,
|
|
532
|
+
action.loading ? `loading: ${action.loading}` : null,
|
|
533
|
+
action.disabled ? `disabled: ${action.disabled}` : null,
|
|
534
|
+
action.to ? `to: ${quoteJsString(action.to)}` : null,
|
|
535
|
+
action.onClick ? `onClick: ${action.onClick}` : null,
|
|
536
|
+
typeof action.order === 'number' ? `order: ${action.order}` : null,
|
|
537
|
+
action.side ? `side: ${quoteJsString(action.side)}` : null,
|
|
538
|
+
];
|
|
539
|
+
return jsObjectLiteral(entries);
|
|
540
|
+
}
|
|
541
|
+
export function buildExtensionPageShellSnippet(input) {
|
|
542
|
+
const title = input.titleExpression || quoteJsString(input.title || 'Untitled');
|
|
543
|
+
const headerEntries = [
|
|
544
|
+
`title: ${title}`,
|
|
545
|
+
input.description ? `description: ${quoteJsString(input.description)}` : null,
|
|
546
|
+
input.leadingIcon ? `leadingIcon: ${quoteJsString(input.leadingIcon)}` : null,
|
|
547
|
+
`gradient: ${quoteJsString(input.gradient || 'none')}`,
|
|
548
|
+
`variant: ${quoteJsString(input.variant || 'minimal')}`,
|
|
549
|
+
];
|
|
550
|
+
const actions = Array.isArray(input.headerActions) ? input.headerActions : [];
|
|
551
|
+
const lines = [
|
|
552
|
+
'const { registerPageHeader } = usePageHeaderRegistry();',
|
|
553
|
+
`registerPageHeader(${jsObjectLiteral(headerEntries)});`,
|
|
554
|
+
];
|
|
555
|
+
if (actions.length) {
|
|
556
|
+
lines.push('const { register: registerHeaderActions } = useHeaderActionRegistry();');
|
|
557
|
+
lines.push(`registerHeaderActions([\n${actions.map((action) => ` ${buildHeaderActionLiteral(action)}`).join(',\n')}\n]);`);
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
action: 'extension_page_shell_built',
|
|
561
|
+
snippet: lines.join('\n'),
|
|
562
|
+
contract: [
|
|
563
|
+
'Use usePageHeaderRegistry so the app shell renders the page header.',
|
|
564
|
+
'Use useHeaderActionRegistry for toolbar actions instead of rendering duplicate page headers or local top bars.',
|
|
565
|
+
'Use primary solid only for the main scope action; secondary actions default to neutral outline.',
|
|
566
|
+
],
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
export function buildExtensionPermissionGateSnippet(input) {
|
|
570
|
+
const normalized = normalizeVueBodySnippet(input.body || '<slot />');
|
|
571
|
+
let condition;
|
|
572
|
+
if (input.condition) {
|
|
573
|
+
condition = input.condition;
|
|
574
|
+
}
|
|
575
|
+
else if (input.route) {
|
|
576
|
+
const methods = Array.isArray(input.methods) && input.methods.length ? input.methods : ['GET'];
|
|
577
|
+
condition = `{ or: [{ route: ${quoteJsString(input.route)}, methods: [${methods.map(quoteJsString).join(', ')}] }] }`;
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
condition = 'null';
|
|
581
|
+
}
|
|
582
|
+
const snippet = [
|
|
583
|
+
`<PermissionGate :condition="${condition}">`,
|
|
584
|
+
indentLines(normalized.code, 2),
|
|
585
|
+
'</PermissionGate>',
|
|
586
|
+
].join('\n');
|
|
587
|
+
return {
|
|
588
|
+
action: 'extension_permission_gate_built',
|
|
589
|
+
component: 'PermissionGate',
|
|
590
|
+
snippet,
|
|
591
|
+
normalizedBodyChanges: normalized.changes,
|
|
592
|
+
warnings: condition === 'null' ? ['No condition/route was provided. PermissionGate with null condition permits the slot.'] : [],
|
|
593
|
+
contract: [
|
|
594
|
+
'PermissionGate is only operator UX; backend route permissions and owner checks remain authoritative.',
|
|
595
|
+
'PermissionGate renders its slot directly and should not be used as a layout wrapper.',
|
|
596
|
+
],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
export function buildExtensionEmptyStateSnippet(input) {
|
|
600
|
+
const action = input.action
|
|
601
|
+
? `\n :action="${buildFooterActionObject(input.action)}"`
|
|
602
|
+
: '';
|
|
603
|
+
return {
|
|
604
|
+
action: 'extension_empty_state_built',
|
|
605
|
+
component: 'CommonEmptyState',
|
|
606
|
+
snippet: `<CommonEmptyState\n title="${String(input.title || 'No items found').replace(/"/g, '"')}"\n description="${String(input.description || '').replace(/"/g, '"')}"\n icon="${input.icon || 'lucide:inbox'}"\n size="${input.size || 'sm'}"\n variant="${input.variant || 'naked'}"${action}\n/>`,
|
|
607
|
+
contract: [
|
|
608
|
+
'Use CommonEmptyState for app-matched empty states.',
|
|
609
|
+
'Use variant="naked" inside framed panels/lists and outline/subtle for standalone framed empty surfaces.',
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
export function buildExtensionResourceListSnippet(input) {
|
|
614
|
+
const itemsExpression = input.itemsExpression || 'items';
|
|
615
|
+
const itemName = input.itemName || 'item';
|
|
616
|
+
const keyExpression = input.keyExpression || `${itemName}.id`;
|
|
617
|
+
const titleExpression = input.titleExpression || `${itemName}.title || ${quoteJsString('Untitled')}`;
|
|
618
|
+
const descriptionExpression = input.descriptionExpression || `${itemName}.description`;
|
|
619
|
+
const iconExpression = input.iconExpression || quoteJsString(input.icon || 'lucide:file-text');
|
|
620
|
+
const onClick = input.onClick ? `\n :on-click="() => ${input.onClick}"` : '';
|
|
621
|
+
const stats = input.statsExpression ? `\n :stats="${input.statsExpression}"` : '';
|
|
622
|
+
const actions = input.actionsExpression ? `\n :actions="${input.actionsExpression}"` : '';
|
|
623
|
+
const topBadge = input.topBadgeExpression ? `\n :top-badge="${input.topBadgeExpression}"` : '';
|
|
624
|
+
const snippet = [
|
|
625
|
+
'<CommonResourceListFrame',
|
|
626
|
+
` :loading="${input.loadingExpression || 'pending'}"`,
|
|
627
|
+
` :has-items="${itemsExpression}.length > 0"`,
|
|
628
|
+
` :total="${input.totalExpression || `${itemsExpression}.length`}"`,
|
|
629
|
+
` :items-per-page="${input.itemsPerPageExpression || '0'}"`,
|
|
630
|
+
` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '"')}"`,
|
|
631
|
+
` empty-description="${String(input.emptyDescription || '').replace(/"/g, '"')}"`,
|
|
632
|
+
` empty-icon="${input.emptyIcon || 'lucide:inbox'}"`,
|
|
633
|
+
'>',
|
|
634
|
+
` <CommonResourceListItem`,
|
|
635
|
+
` v-for="${itemName} in ${itemsExpression}"`,
|
|
636
|
+
` :key="${keyExpression}"`,
|
|
637
|
+
` :title="${titleExpression}"`,
|
|
638
|
+
` :description="${descriptionExpression}"`,
|
|
639
|
+
` :icon="${iconExpression}"`,
|
|
640
|
+
' icon-color="primary"',
|
|
641
|
+
`${stats}${actions}${topBadge}${onClick}`,
|
|
642
|
+
' />',
|
|
643
|
+
'</CommonResourceListFrame>',
|
|
644
|
+
].join('\n');
|
|
645
|
+
return {
|
|
646
|
+
action: 'extension_resource_list_built',
|
|
647
|
+
components: ['CommonResourceListFrame', 'CommonResourceListItem'],
|
|
648
|
+
snippet,
|
|
649
|
+
contract: [
|
|
650
|
+
'Use CommonResourceListFrame and CommonResourceListItem for operational lists instead of ad hoc cards.',
|
|
651
|
+
'Keep first-load skeleton, empty state, and pagination owned by the frame.',
|
|
652
|
+
'Use explicit bounded list data and natural pagination/search outside this snippet when the domain list can grow.',
|
|
653
|
+
],
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
export function buildExtensionFormEditorSnippet(input) {
|
|
657
|
+
const tag = input.lazy === false ? 'FormEditor' : 'FormEditorLazy';
|
|
658
|
+
const attrs = [
|
|
659
|
+
`v-model="${input.model || 'form'}"`,
|
|
660
|
+
`v-model:errors="${input.errors || 'errors'}"`,
|
|
661
|
+
attrStaticOrBound('table-name', input.tableName, input.tableNameExpression),
|
|
662
|
+
input.mode ? `mode="${input.mode}"` : null,
|
|
663
|
+
input.loadingExpression ? `:loading="${input.loadingExpression}"` : null,
|
|
664
|
+
input.layout ? `layout="${input.layout}"` : null,
|
|
665
|
+
input.includes?.length ? `:includes="${jsArrayLiteral(input.includes)}"` : null,
|
|
666
|
+
input.excluded?.length ? `:excluded="${jsArrayLiteral(input.excluded)}"` : null,
|
|
667
|
+
input.sectionsExpression ? `:sections="${input.sectionsExpression}"` : null,
|
|
668
|
+
input.fieldMapExpression ? `:field-map="${input.fieldMapExpression}"` : null,
|
|
669
|
+
input.virtualFieldsExpression ? `:virtual-fields="${input.virtualFieldsExpression}"` : null,
|
|
670
|
+
input.currentRecordIdExpression ? `:current-record-id="${input.currentRecordIdExpression}"` : null,
|
|
671
|
+
input.hasChangedHandler ? `@has-changed="${input.hasChangedHandler}"` : null,
|
|
672
|
+
input.virtualFieldEmitHandler ? `@virtual-field-emit="${input.virtualFieldEmitHandler}"` : null,
|
|
673
|
+
].filter(Boolean);
|
|
674
|
+
const snippet = [
|
|
675
|
+
`<${tag}`,
|
|
676
|
+
...attrs.map((attr) => ` ${attr}`),
|
|
677
|
+
'/>',
|
|
678
|
+
].join('\n');
|
|
679
|
+
return {
|
|
680
|
+
action: 'extension_form_editor_built',
|
|
681
|
+
component: tag,
|
|
682
|
+
snippet,
|
|
683
|
+
contract: [
|
|
684
|
+
'Prefer FormEditor/FormEditorLazy for direct table-backed forms instead of hand-built UInput/UTextarea fields.',
|
|
685
|
+
'Use v-model for record state and v-model:errors for validation errors.',
|
|
686
|
+
'Use includes/sections to keep generated forms focused; do not expose compiledCode or unrelated system fields.',
|
|
687
|
+
'Use fieldMap only for behavior/renderer overrides such as code fields or custom labels.',
|
|
688
|
+
],
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
export function buildExtensionWidgetSnippet(input) {
|
|
692
|
+
const attrs = [`:id="${typeof input.id === 'number' ? input.id : quoteJsString(input.id)}"`];
|
|
693
|
+
for (const [key, value] of Object.entries(input.props || {})) {
|
|
694
|
+
attrs.push(`:${key}="${value}"`);
|
|
695
|
+
}
|
|
696
|
+
for (const [event, handler] of Object.entries(input.events || {})) {
|
|
697
|
+
attrs.push(`@${event}="${handler}"`);
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
action: 'extension_widget_built',
|
|
701
|
+
component: 'Widget',
|
|
702
|
+
snippet: `<Widget ${attrs.join(' ')} />`,
|
|
703
|
+
warnings: typeof input.id === 'number' ? [] : ['Widget ids should be numeric enfyra_extension ids; do not pass extension name or extensionId string.'],
|
|
704
|
+
contract: [
|
|
705
|
+
'Widget :id is the numeric enfyra_extension id, not name or extensionId.',
|
|
706
|
+
'Pass safe props/events; keep page-level mutation and modal ownership in the page unless the widget intentionally owns the full workflow.',
|
|
707
|
+
],
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
export function buildExtensionMenuNotificationSnippet(input) {
|
|
711
|
+
const targetEntries = [
|
|
712
|
+
input.targetId !== undefined ? `id: ${quoteJsString(input.targetId)}` : null,
|
|
713
|
+
input.path ? `path: ${quoteJsString(input.path)}` : null,
|
|
714
|
+
input.route ? `route: ${quoteJsString(input.route)}` : null,
|
|
715
|
+
];
|
|
716
|
+
const entries = [
|
|
717
|
+
`id: ${quoteJsString(input.id || 'extension-menu-notification')}`,
|
|
718
|
+
`target: ${jsObjectLiteral(targetEntries)}`,
|
|
719
|
+
input.valueExpression ? `value: ${input.valueExpression}` : input.value !== undefined ? `value: ${quoteJsString(input.value)}` : null,
|
|
720
|
+
`color: ${quoteJsString(input.color || 'primary')}`,
|
|
721
|
+
input.title ? `title: ${quoteJsString(input.title)}` : null,
|
|
722
|
+
typeof input.order === 'number' ? `order: ${input.order}` : null,
|
|
723
|
+
];
|
|
724
|
+
return {
|
|
725
|
+
action: 'extension_menu_notification_built',
|
|
726
|
+
snippet: [
|
|
727
|
+
'const { register: registerMenuNotification } = useMenuNotificationRegistry();',
|
|
728
|
+
`registerMenuNotification(${jsObjectLiteral(entries)});`,
|
|
729
|
+
].join('\n'),
|
|
730
|
+
contract: [
|
|
731
|
+
'Use count/value only when the signal source already owns an exact or bounded count.',
|
|
732
|
+
'Omit value for a dot-only notification when realtime only proves new attention exists.',
|
|
733
|
+
'Do not fetch destination domain lists solely to decorate the menu.',
|
|
734
|
+
],
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
export function buildExtensionAccountPanelSnippet(input) {
|
|
738
|
+
const entries = [
|
|
739
|
+
`id: ${quoteJsString(input.id || 'extension-account-panel-item')}`,
|
|
740
|
+
typeof input.order === 'number' ? `order: ${input.order}` : null,
|
|
741
|
+
input.label ? `label: ${quoteJsString(input.label)}` : null,
|
|
742
|
+
input.description ? `description: ${quoteJsString(input.description)}` : null,
|
|
743
|
+
input.icon ? `icon: ${quoteJsString(input.icon)}` : null,
|
|
744
|
+
input.countExpression ? `count: ${input.countExpression}` : input.count !== undefined ? `count: ${quoteJsString(input.count)}` : null,
|
|
745
|
+
input.badgeExpression ? `badge: ${input.badgeExpression}` : input.badge !== undefined ? `badge: ${quoteJsString(input.badge)}` : null,
|
|
746
|
+
input.badgeColor ? `badgeColor: ${quoteJsString(input.badgeColor)}` : null,
|
|
747
|
+
input.trailingIcon ? `trailingIcon: ${quoteJsString(input.trailingIcon)}` : null,
|
|
748
|
+
input.expandedExpression ? `expanded: ${input.expandedExpression}` : null,
|
|
749
|
+
input.contentComponent ? `contentComponent: ${input.contentComponent}` : null,
|
|
750
|
+
input.contentPropsExpression ? `contentProps: ${input.contentPropsExpression}` : null,
|
|
751
|
+
input.onClick ? `onClick: ${input.onClick}` : null,
|
|
752
|
+
input.onToggle ? `onToggle: ${input.onToggle}` : null,
|
|
753
|
+
];
|
|
754
|
+
return {
|
|
755
|
+
action: 'extension_account_panel_item_built',
|
|
756
|
+
snippet: [
|
|
757
|
+
'const { register: registerAccountPanelItem } = useAccountPanelRegistry();',
|
|
758
|
+
`registerAccountPanelItem(${jsObjectLiteral(entries)});`,
|
|
759
|
+
].join('\n'),
|
|
760
|
+
contract: [
|
|
761
|
+
'Prefer data-driven account panel rows over fully custom row components.',
|
|
762
|
+
'Use count for notification-style chips; count takes precedence over badge.',
|
|
763
|
+
'Use onClick for direct actions and onToggle/contentComponent for expandable inline UI.',
|
|
764
|
+
],
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
export function buildExtensionTabsSnippet(input) {
|
|
768
|
+
const model = input.model || 'activeTab';
|
|
769
|
+
const items = input.itemsExpression || 'tabs';
|
|
770
|
+
const body = input.body || '<div>{{ item.label }}</div>';
|
|
771
|
+
const snippet = [
|
|
772
|
+
`<UTabs v-model="${model}" :items="${items}" class="w-full">`,
|
|
773
|
+
' <template #content="{ item }">',
|
|
774
|
+
indentLines(normalizeVueBodySnippet(body).code, 4),
|
|
775
|
+
' </template>',
|
|
776
|
+
'</UTabs>',
|
|
777
|
+
].join('\n');
|
|
778
|
+
return {
|
|
779
|
+
action: 'extension_tabs_built',
|
|
780
|
+
component: 'UTabs',
|
|
781
|
+
snippet,
|
|
782
|
+
contract: [
|
|
783
|
+
'Use app-level UTabs chrome instead of custom tab bars.',
|
|
784
|
+
'Do not add local full-width bottom borders/dividers to tab lists.',
|
|
785
|
+
'Keep tab items data-driven and render panel content through #content.',
|
|
786
|
+
],
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
export function buildExtensionUploadModalSnippet(input) {
|
|
790
|
+
const model = input.model || 'showUploadModal';
|
|
791
|
+
const attrs = [
|
|
792
|
+
`v-model="${model}"`,
|
|
793
|
+
`title="${String(input.title || 'Upload Files').replace(/"/g, '"')}"`,
|
|
794
|
+
`accept="${input.accept || '*/*'}"`,
|
|
795
|
+
input.multiple !== false ? ':multiple="true"' : ':multiple="false"',
|
|
796
|
+
input.maxSizeExpression ? `:max-size="${input.maxSizeExpression}"` : ':max-size="10 * 1024 * 1024"',
|
|
797
|
+
input.loadingExpression ? `:loading="${input.loadingExpression}"` : null,
|
|
798
|
+
input.uploadProgressExpression ? `:upload-progress="${input.uploadProgressExpression}"` : null,
|
|
799
|
+
input.fileProgressExpression ? `:file-progress="${input.fileProgressExpression}"` : null,
|
|
800
|
+
input.dragText ? `drag-text="${String(input.dragText).replace(/"/g, '"')}"` : null,
|
|
801
|
+
input.acceptText ? `accept-text="${String(input.acceptText).replace(/"/g, '"')}"` : null,
|
|
802
|
+
input.uploadText ? `upload-text="${String(input.uploadText).replace(/"/g, '"')}"` : null,
|
|
803
|
+
input.uploadingText ? `uploading-text="${String(input.uploadingText).replace(/"/g, '"')}"` : null,
|
|
804
|
+
`@upload="${input.uploadHandler || 'handleUpload'}"`,
|
|
805
|
+
input.errorHandler ? `@error="${input.errorHandler}"` : null,
|
|
806
|
+
].filter(Boolean);
|
|
807
|
+
const headerContent = input.headerContent ? [
|
|
808
|
+
'>',
|
|
809
|
+
' <template #header-content>',
|
|
810
|
+
indentLines(normalizeVueBodySnippet(input.headerContent).code, 4),
|
|
811
|
+
' </template>',
|
|
812
|
+
'</CommonUploadModal>',
|
|
813
|
+
] : ['/>'];
|
|
814
|
+
return {
|
|
815
|
+
action: 'extension_upload_modal_built',
|
|
816
|
+
component: 'CommonUploadModal',
|
|
817
|
+
snippet: [
|
|
818
|
+
'<CommonUploadModal',
|
|
819
|
+
...attrs.map((attr) => ` ${attr}`),
|
|
820
|
+
...headerContent,
|
|
821
|
+
].join('\n'),
|
|
822
|
+
companionSnippet: [
|
|
823
|
+
'const {',
|
|
824
|
+
' uploadProgress,',
|
|
825
|
+
' trackedUploadProgressById,',
|
|
826
|
+
' beginTrackedUploadProgress,',
|
|
827
|
+
' getUploadProgressHeaders,',
|
|
828
|
+
' resetUploadProgress,',
|
|
829
|
+
'} = useFileUploadProgress();',
|
|
830
|
+
].join('\n'),
|
|
831
|
+
contract: [
|
|
832
|
+
'Use useFileUploadProgress for admin-socket upload progress.',
|
|
833
|
+
'Send x-enfyra-upload-id via getUploadProgressHeaders(id) for each uploaded file.',
|
|
834
|
+
'For multi-file uploads, call the useApi batch files path once, pass per-file headers through headersByIndex, and map each upload id to fileProgress[index].',
|
|
835
|
+
'CommonUploadModal owns selected-file rows and per-row progress chrome.',
|
|
836
|
+
],
|
|
837
|
+
};
|
|
838
|
+
}
|
|
476
839
|
export function reviewExtensionUiContract(code) {
|
|
477
840
|
const source = String(code || '');
|
|
478
841
|
const issues = [];
|
|
@@ -583,6 +946,7 @@ function getExtensionThemeContract() {
|
|
|
583
946
|
'Buttons should have stable geometry: hover may change color, border, or shadow but must not move the button or resize its content. Disabled buttons keep disabled cursor/visual state.',
|
|
584
947
|
'Inputs and textareas should not add hover movement or decorative hover states; focus, invalid, disabled, and loading states must be explicit.',
|
|
585
948
|
'Inside CommonDrawer/CommonModal body forms, UInput, UTextarea, USelect, USelectMenu, and similar field controls must use class="w-full" unless the control is intentionally inline or compact.',
|
|
949
|
+
'Extension validation rejects UInput, UTextarea, USelect, USelectMenu, UInputMenu, UInputNumber, UInputTags, UInputTime, and UInputDate without class="w-full" unless marked data-compact or data-inline.',
|
|
586
950
|
'Dynamic extensions resolve UModal to the app CommonModal. Do not pass ui.content: "eapp-surface-card" or "surface-card" to UModal/CommonModal; modal content uses the app modal surface and caller ui.content should only append z-index, width, or max-width classes.',
|
|
587
951
|
'CommonModal and CommonDrawer own action-only footers through cancelAction, primaryAction, dangerAction, leadingActions, and footerHint. Pass footer button intent through those props instead of custom footer slots. cancelAction defaults to neutral outline; use dangerAction for irreversible destructive work and tone: "primary" for Keep editing in discard dialogs.',
|
|
588
952
|
'Use custom #footer content only when the footer contains real custom layout or non-button content. Every modal/drawer button should use type="button" unless it intentionally submits a form.',
|
|
@@ -863,7 +1227,12 @@ export function validateExtensionCodeLocally(code) {
|
|
|
863
1227
|
const first = violations[0];
|
|
864
1228
|
throw new Error(`Invalid extension component casing: use <${first.expected}> instead of <${first.tag}>. Enfyra/Nuxt UI auto-injected components must keep PascalCase in extension templates; lowercase tags render as unresolved DOM elements.`);
|
|
865
1229
|
}
|
|
866
|
-
|
|
1230
|
+
const missingFullWidthFields = findMissingFullWidthFieldControls(code);
|
|
1231
|
+
if (missingFullWidthFields.length) {
|
|
1232
|
+
const first = missingFullWidthFields[0];
|
|
1233
|
+
throw new Error(`Invalid extension field width: <${first.tag}> must include class="w-full" in Enfyra extensions unless it is intentionally compact with data-compact or data-inline. First offending snippet: ${first.snippet}`);
|
|
1234
|
+
}
|
|
1235
|
+
return { componentCasing: 'passed', fieldWidth: 'passed' };
|
|
867
1236
|
}
|
|
868
1237
|
export async function validateExtensionCode(apiUrl, code, name) {
|
|
869
1238
|
const localChecks = validateExtensionCodeLocally(code);
|
|
@@ -2075,6 +2444,19 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
2075
2444
|
tone: z.string().optional().describe('Optional action tone when supported by the shell component.'),
|
|
2076
2445
|
onClick: z.string().describe('Raw Vue expression or function reference for the click handler, e.g. saveNote or () => (open = false).'),
|
|
2077
2446
|
});
|
|
2447
|
+
const extensionHeaderActionSchema = z.object({
|
|
2448
|
+
id: z.string().describe('Stable action id.'),
|
|
2449
|
+
label: z.string().optional().describe('Action label.'),
|
|
2450
|
+
icon: z.string().optional().describe('Icon name such as lucide:plus or lucide:refresh-cw.'),
|
|
2451
|
+
color: z.string().optional().default('neutral').describe('Nuxt UI color. Use primary only for the single main scope action; otherwise neutral.'),
|
|
2452
|
+
variant: z.string().optional().default('outline').describe('Nuxt UI variant. Use solid for the main scope action; otherwise outline/ghost.'),
|
|
2453
|
+
loading: z.string().optional().describe('Raw Vue expression/ref name for loading state.'),
|
|
2454
|
+
disabled: z.string().optional().describe('Raw Vue expression/ref name for disabled state.'),
|
|
2455
|
+
to: z.string().optional().describe('Route path for visible navigation actions.'),
|
|
2456
|
+
onClick: z.string().optional().describe('Raw Vue expression or function reference for click behavior.'),
|
|
2457
|
+
order: z.number().optional().describe('Sort order in the shell header action area.'),
|
|
2458
|
+
side: z.enum(['left', 'right']).optional().describe('Optional shell side.'),
|
|
2459
|
+
});
|
|
2078
2460
|
server.tool('validate_dynamic_script', [
|
|
2079
2461
|
'Validate Enfyra dynamic script code before saving it to any script-backed metadata record.',
|
|
2080
2462
|
'Use this before create/update of handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts when the user is iterating on code.',
|
|
@@ -2187,6 +2569,152 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
2187
2569
|
].join(' '), {
|
|
2188
2570
|
code: z.string().describe('Vue SFC or template snippet to review.'),
|
|
2189
2571
|
}, async ({ code }) => jsonText(reviewExtensionUiContract(code)));
|
|
2572
|
+
server.tool('build_extension_page_shell', [
|
|
2573
|
+
'Generate page-header and shell-header-action script setup code for Enfyra page extensions.',
|
|
2574
|
+
'Use this so generated page extensions register shell chrome through usePageHeaderRegistry/useHeaderActionRegistry instead of rendering duplicate local headers.',
|
|
2575
|
+
].join(' '), {
|
|
2576
|
+
title: z.string().optional().describe('Static page title.'),
|
|
2577
|
+
titleExpression: z.string().optional().describe('Raw Vue expression for a dynamic title.'),
|
|
2578
|
+
description: z.string().optional().describe('Optional page description.'),
|
|
2579
|
+
leadingIcon: z.string().optional().describe('Optional page header icon.'),
|
|
2580
|
+
gradient: z.enum(['none', 'purple', 'blue', 'cyan']).optional().default('none').describe('Generated operational extensions should usually use none.'),
|
|
2581
|
+
variant: z.enum(['default', 'minimal', 'stats-focus']).optional().default('minimal').describe('Page header variant.'),
|
|
2582
|
+
headerActions: z.array(extensionHeaderActionSchema).optional().describe('Optional shell header actions registered through useHeaderActionRegistry.'),
|
|
2583
|
+
}, async (input) => jsonText(buildExtensionPageShellSnippet(input)));
|
|
2584
|
+
server.tool('build_extension_permission_gate', [
|
|
2585
|
+
'Generate a PermissionGate wrapper snippet for Enfyra admin extension UI.',
|
|
2586
|
+
'Use this when a visible button/block/list needs operator UX gating; backend route permissions and owner checks still remain authoritative.',
|
|
2587
|
+
].join(' '), {
|
|
2588
|
+
route: z.string().optional().describe('API route path to gate against, e.g. /notes.'),
|
|
2589
|
+
methods: z.array(z.string()).optional().describe('HTTP methods for the route condition. Defaults to GET when route is provided.'),
|
|
2590
|
+
condition: z.string().optional().describe('Raw Vue permission condition expression. Overrides route/methods when provided.'),
|
|
2591
|
+
body: z.string().describe('Vue template content to render inside PermissionGate. Field controls are normalized to w-full.'),
|
|
2592
|
+
}, async (input) => jsonText(buildExtensionPermissionGateSnippet(input)));
|
|
2593
|
+
server.tool('build_extension_empty_state', [
|
|
2594
|
+
'Generate a CommonEmptyState snippet for Enfyra admin extensions.',
|
|
2595
|
+
'Use this for app-matched empty/error/no-results states instead of hand-rolled blank panels.',
|
|
2596
|
+
].join(' '), {
|
|
2597
|
+
title: z.string().optional().describe('Empty state title.'),
|
|
2598
|
+
description: z.string().optional().describe('Empty state description.'),
|
|
2599
|
+
icon: z.string().optional().describe('Icon name. Defaults to lucide:inbox.'),
|
|
2600
|
+
size: z.enum(['sm', 'md', 'lg']).optional().default('sm').describe('Empty state size.'),
|
|
2601
|
+
variant: z.enum(['outline', 'naked', 'soft', 'subtle', 'solid']).optional().default('naked').describe('Use naked inside existing panels/lists.'),
|
|
2602
|
+
action: extensionFooterActionSchema.optional().describe('Optional primary empty-state action.'),
|
|
2603
|
+
}, async (input) => jsonText(buildExtensionEmptyStateSnippet(input)));
|
|
2604
|
+
server.tool('build_extension_resource_list', [
|
|
2605
|
+
'Generate a CommonResourceListFrame/CommonResourceListItem snippet for Enfyra admin extensions.',
|
|
2606
|
+
'Use this for operational list pages so loading, empty state, pagination placement, row chrome, icons, stats, and row actions follow the app contract.',
|
|
2607
|
+
].join(' '), {
|
|
2608
|
+
itemsExpression: z.string().optional().default('items').describe('Vue expression for the row array, e.g. notes.'),
|
|
2609
|
+
itemName: z.string().optional().default('item').describe('Loop variable name.'),
|
|
2610
|
+
keyExpression: z.string().optional().describe('Vue expression for :key. Defaults to item.id.'),
|
|
2611
|
+
titleExpression: z.string().optional().describe('Vue expression for item title. Defaults to item.title || "Untitled".'),
|
|
2612
|
+
descriptionExpression: z.string().optional().describe('Vue expression for item description. Defaults to item.description.'),
|
|
2613
|
+
icon: z.string().optional().describe('Static row icon when iconExpression is omitted.'),
|
|
2614
|
+
iconExpression: z.string().optional().describe('Vue expression for row icon.'),
|
|
2615
|
+
loadingExpression: z.string().optional().default('pending').describe('Vue expression for frame loading.'),
|
|
2616
|
+
totalExpression: z.string().optional().describe('Vue expression for total rows.'),
|
|
2617
|
+
itemsPerPageExpression: z.string().optional().describe('Vue expression for items per page; use 0 to hide pagination.'),
|
|
2618
|
+
statsExpression: z.string().optional().describe('Vue expression returning ResourceListStat[] for each row.'),
|
|
2619
|
+
actionsExpression: z.string().optional().describe('Vue expression returning ResourceListAction[] for each row.'),
|
|
2620
|
+
topBadgeExpression: z.string().optional().describe('Vue expression returning a ResourceListTopBadge for each row.'),
|
|
2621
|
+
onClick: z.string().optional().describe('Raw Vue expression called for row click, e.g. openEdit(item).'),
|
|
2622
|
+
emptyTitle: z.string().optional().describe('Empty title.'),
|
|
2623
|
+
emptyDescription: z.string().optional().describe('Empty description.'),
|
|
2624
|
+
emptyIcon: z.string().optional().describe('Empty icon.'),
|
|
2625
|
+
}, async (input) => jsonText(buildExtensionResourceListSnippet(input)));
|
|
2626
|
+
server.tool('build_extension_form_editor', [
|
|
2627
|
+
'Generate a FormEditor/FormEditorLazy snippet for Enfyra table-backed extension forms.',
|
|
2628
|
+
'Use this instead of hand-writing UInput/UTextarea fields when the form maps directly to a table record.',
|
|
2629
|
+
].join(' '), {
|
|
2630
|
+
tableName: z.string().optional().describe('Static table name.'),
|
|
2631
|
+
tableNameExpression: z.string().optional().describe('Raw Vue expression for dynamic table name.'),
|
|
2632
|
+
model: z.string().optional().default('form').describe('Record state variable for v-model.'),
|
|
2633
|
+
errors: z.string().optional().default('errors').describe('Errors state variable for v-model:errors.'),
|
|
2634
|
+
mode: z.enum(['create', 'update']).optional().describe('Optional fixed form mode.'),
|
|
2635
|
+
loadingExpression: z.string().optional().describe('Raw Vue expression/ref for loading.'),
|
|
2636
|
+
layout: z.enum(['stack', 'grid']).optional().describe('Form layout.'),
|
|
2637
|
+
includes: z.array(z.string()).optional().describe('Fields to include. Prefer explicit includes for focused generated forms.'),
|
|
2638
|
+
excluded: z.array(z.string()).optional().describe('Fields to exclude. compiledCode is always excluded by FormEditor.'),
|
|
2639
|
+
sectionsExpression: z.string().optional().describe('Raw Vue expression for FormEditorSection[].'),
|
|
2640
|
+
fieldMapExpression: z.string().optional().describe('Raw Vue expression for fieldMap overrides.'),
|
|
2641
|
+
virtualFieldsExpression: z.string().optional().describe('Raw Vue expression for virtual fields.'),
|
|
2642
|
+
currentRecordIdExpression: z.string().optional().describe('Raw Vue expression for current record id.'),
|
|
2643
|
+
hasChangedHandler: z.string().optional().describe('Handler expression for @has-changed.'),
|
|
2644
|
+
virtualFieldEmitHandler: z.string().optional().describe('Handler expression for @virtual-field-emit.'),
|
|
2645
|
+
lazy: z.boolean().optional().default(true).describe('Use FormEditorLazy by default.'),
|
|
2646
|
+
}, async (input) => jsonText(buildExtensionFormEditorSnippet(input)));
|
|
2647
|
+
server.tool('build_extension_widget', [
|
|
2648
|
+
'Generate a Widget snippet for reusing a widget extension inside an Enfyra page extension.',
|
|
2649
|
+
'Use this so agents pass numeric widget ids and keep prop/event ownership explicit.',
|
|
2650
|
+
].join(' '), {
|
|
2651
|
+
id: z.union([z.number(), z.string()]).describe('Numeric enfyra_extension widget id. Strings are allowed but return a warning because names/extensionId are wrong for Widget.'),
|
|
2652
|
+
props: z.record(z.string()).optional().describe('Map of prop name to raw Vue expression.'),
|
|
2653
|
+
events: z.record(z.string()).optional().describe('Map of event name to handler expression.'),
|
|
2654
|
+
}, async (input) => jsonText(buildExtensionWidgetSnippet(input)));
|
|
2655
|
+
server.tool('build_extension_menu_notification', [
|
|
2656
|
+
'Generate useMenuNotificationRegistry registration code for a global extension.',
|
|
2657
|
+
'Use this for sidebar menu count chips or dot notifications without mutating enfyra_menu records.',
|
|
2658
|
+
].join(' '), {
|
|
2659
|
+
id: z.string().optional().describe('Stable notification id.'),
|
|
2660
|
+
targetId: z.union([z.string(), z.number()]).optional().describe('Target menu id.'),
|
|
2661
|
+
path: z.string().optional().describe('Target menu path.'),
|
|
2662
|
+
route: z.string().optional().describe('Target route path.'),
|
|
2663
|
+
value: z.union([z.string(), z.number()]).optional().describe('Static count/chip value. Omit with valueExpression for a dot.'),
|
|
2664
|
+
valueExpression: z.string().optional().describe('Raw Vue expression for count/chip value. Omit value for a dot-only notification.'),
|
|
2665
|
+
color: z.enum(['primary', 'success', 'warning', 'error', 'info', 'neutral']).optional().default('primary').describe('Chip/dot color intent.'),
|
|
2666
|
+
title: z.string().optional().describe('Optional tooltip/title.'),
|
|
2667
|
+
order: z.number().optional().describe('Sort order when multiple notifications target the same menu.'),
|
|
2668
|
+
}, async (input) => jsonText(buildExtensionMenuNotificationSnippet(input)));
|
|
2669
|
+
server.tool('build_extension_account_panel_item', [
|
|
2670
|
+
'Generate useAccountPanelRegistry registration code for a global extension.',
|
|
2671
|
+
'Use this for data-driven account panel rows instead of drawing full custom sidebar/account UI.',
|
|
2672
|
+
].join(' '), {
|
|
2673
|
+
id: z.string().optional().describe('Stable account panel item id.'),
|
|
2674
|
+
order: z.number().optional().describe('Display order.'),
|
|
2675
|
+
label: z.string().optional().describe('Row label.'),
|
|
2676
|
+
description: z.string().optional().describe('Row description.'),
|
|
2677
|
+
icon: z.string().optional().describe('Leading icon.'),
|
|
2678
|
+
count: z.union([z.string(), z.number()]).optional().describe('Static notification chip value.'),
|
|
2679
|
+
countExpression: z.string().optional().describe('Raw Vue expression for notification chip value.'),
|
|
2680
|
+
badge: z.union([z.string(), z.number()]).optional().describe('Legacy static badge value. Prefer count.'),
|
|
2681
|
+
badgeExpression: z.string().optional().describe('Raw Vue expression for badge. Prefer countExpression.'),
|
|
2682
|
+
badgeColor: z.enum(['primary', 'neutral', 'info', 'error', 'warning', 'success']).optional().describe('Chip color.'),
|
|
2683
|
+
trailingIcon: z.string().optional().describe('Trailing icon.'),
|
|
2684
|
+
expandedExpression: z.string().optional().describe('Raw Vue expression controlling expanded state.'),
|
|
2685
|
+
contentComponent: z.string().optional().describe('Raw component reference for inline expanded content.'),
|
|
2686
|
+
contentPropsExpression: z.string().optional().describe('Raw Vue expression for content props.'),
|
|
2687
|
+
onClick: z.string().optional().describe('Direct action handler expression.'),
|
|
2688
|
+
onToggle: z.string().optional().describe('Expandable row toggle handler expression.'),
|
|
2689
|
+
}, async (input) => jsonText(buildExtensionAccountPanelSnippet(input)));
|
|
2690
|
+
server.tool('build_extension_tabs', [
|
|
2691
|
+
'Generate a UTabs snippet for Enfyra extension page sections.',
|
|
2692
|
+
'Use this instead of custom tab bars so app-wide tab chrome owns active indicators, focus rings, spacing, and theme contrast.',
|
|
2693
|
+
].join(' '), {
|
|
2694
|
+
model: z.string().optional().default('activeTab').describe('Active tab model variable.'),
|
|
2695
|
+
itemsExpression: z.string().optional().default('tabs').describe('Raw Vue expression for tab items.'),
|
|
2696
|
+
body: z.string().optional().describe('Vue template body for #content="{ item }".'),
|
|
2697
|
+
}, async (input) => jsonText(buildExtensionTabsSnippet(input)));
|
|
2698
|
+
server.tool('build_extension_upload_modal', [
|
|
2699
|
+
'Generate a CommonUploadModal snippet and upload-progress companion snippet for Enfyra extensions.',
|
|
2700
|
+
'Use this for file upload UI so progress, selected-file rows, and x-enfyra-upload-id wiring follow the app contract.',
|
|
2701
|
+
].join(' '), {
|
|
2702
|
+
model: z.string().optional().default('showUploadModal').describe('Modal open state variable.'),
|
|
2703
|
+
title: z.string().optional().describe('Upload modal title.'),
|
|
2704
|
+
accept: z.string().optional().default('*/*').describe('Accepted mime/extensions.'),
|
|
2705
|
+
multiple: z.boolean().optional().default(true).describe('Allow multiple files.'),
|
|
2706
|
+
maxSizeExpression: z.string().optional().describe('Raw Vue expression for max file size.'),
|
|
2707
|
+
loadingExpression: z.string().optional().describe('Raw Vue expression/ref for upload pending state.'),
|
|
2708
|
+
uploadProgressExpression: z.string().optional().describe('Raw Vue expression/ref for aggregate upload progress.'),
|
|
2709
|
+
fileProgressExpression: z.string().optional().describe('Raw Vue expression for per-row progress map.'),
|
|
2710
|
+
dragText: z.string().optional().describe('Drag/drop text.'),
|
|
2711
|
+
acceptText: z.string().optional().describe('Accept/help text.'),
|
|
2712
|
+
uploadText: z.string().optional().describe('Upload action text.'),
|
|
2713
|
+
uploadingText: z.string().optional().describe('Uploading action text.'),
|
|
2714
|
+
uploadHandler: z.string().optional().default('handleUpload').describe('@upload handler expression.'),
|
|
2715
|
+
errorHandler: z.string().optional().describe('@error handler expression.'),
|
|
2716
|
+
headerContent: z.string().optional().describe('Optional #header-content template, e.g. storage selector. Fields are normalized to w-full.'),
|
|
2717
|
+
}, async (input) => jsonText(buildExtensionUploadModalSnippet(input)));
|
|
2190
2718
|
server.tool('extension_workflow', [
|
|
2191
2719
|
'Step-by-step workflow for creating or updating Enfyra admin page, global, or widget extensions.',
|
|
2192
2720
|
'Use this when an LLM is building extension UI, menu shell notifications, account panel entries, or page/menu wiring and should follow live nextSteps instead of guessing raw enfyra_extension mutations.',
|