@enfyra/mcp-server 0.1.63 → 0.1.64
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/README.md +1 -0
- package/dist/lib/dynamic-endpoint-contract.js +3 -0
- package/dist/lib/dynamic-endpoint-contract.js.map +1 -1
- package/dist/lib/extension-search-tools.js +2 -1
- package/dist/lib/extension-search-tools.js.map +1 -1
- package/dist/lib/extension-sfc-analyzer.d.ts +4 -0
- package/dist/lib/extension-sfc-analyzer.js +137 -0
- package/dist/lib/extension-sfc-analyzer.js.map +1 -0
- package/dist/lib/mcp-examples.js +2 -2
- package/dist/lib/mcp-instructions.js +5 -15
- package/dist/lib/mcp-instructions.js.map +1 -1
- package/dist/lib/mcp-usage-telemetry.js +4 -3
- package/dist/lib/mcp-usage-telemetry.js.map +1 -1
- package/dist/lib/mutation-guards.d.ts +1 -0
- package/dist/lib/mutation-guards.js +28 -0
- package/dist/lib/mutation-guards.js.map +1 -1
- package/dist/lib/platform-operation-tools.d.ts +159 -3
- package/dist/lib/platform-operation-tools.js +361 -78
- package/dist/lib/platform-operation-tools.js.map +1 -1
- package/dist/lib/required-knowledge.d.ts +9 -2
- package/dist/lib/required-knowledge.js +50 -16
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/response-format.js +39 -25
- package/dist/lib/response-format.js.map +1 -1
- package/dist/lib/runtime-zone-tools.js +4 -3
- package/dist/lib/runtime-zone-tools.js.map +1 -1
- package/dist/lib/session-safety.d.ts +9 -0
- package/dist/lib/session-safety.js +89 -0
- package/dist/lib/session-safety.js.map +1 -0
- package/dist/lib/source-artifacts.js +3 -1
- package/dist/lib/source-artifacts.js.map +1 -1
- package/dist/lib/table-tools.d.ts +5 -0
- package/dist/lib/table-tools.js +46 -5
- package/dist/lib/table-tools.js.map +1 -1
- package/dist/lib/tool-input-normalization.d.ts +4 -0
- package/dist/lib/tool-input-normalization.js +35 -0
- package/dist/lib/tool-input-normalization.js.map +1 -0
- package/dist/lib/tool-routing.d.ts +6 -2
- package/dist/lib/tool-routing.js +33 -8
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/lib/toolset-filter.d.ts +7 -3
- package/dist/lib/toolset-filter.js +144 -86
- package/dist/lib/toolset-filter.js.map +1 -1
- package/dist/lib/types.d.ts +20 -0
- package/dist/mcp-server-entry.js +49 -19
- package/dist/mcp-server-entry.js.map +1 -1
- package/package.json +4 -1
|
@@ -4,6 +4,9 @@ import { fetchAPI } from './fetch.js';
|
|
|
4
4
|
import { fetchTableCatalog, fetchTableMetadata, fetchTableMetadataByRef, resolveTableCatalogEntry } from './metadata-client.js';
|
|
5
5
|
import { assertCustomEndpointRoute, assertDynamicEndpointContract, extractExplicitRepositoryTableNames, reviewDynamicEndpointContract, } from './dynamic-endpoint-contract.js';
|
|
6
6
|
import { validatePortableScriptSource, validateScriptSourceIfPresent } from './mutation-guards.js';
|
|
7
|
+
import { writeSourceArtifact } from './source-artifacts.js';
|
|
8
|
+
import { normalizeEscapedVueSource, normalizeStrictBoolean, } from './tool-input-normalization.js';
|
|
9
|
+
import { analyzeExtensionSfc, extensionElementAttributeValue, extensionElementHasAttribute, } from './extension-sfc-analyzer.js';
|
|
7
10
|
import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAck, assertGlobalRulesAck, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './required-knowledge.js';
|
|
8
11
|
const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
|
|
9
12
|
'CommonDrawer',
|
|
@@ -66,6 +69,26 @@ function sameId(a, b) {
|
|
|
66
69
|
function firstDataRecord(result) {
|
|
67
70
|
return Array.isArray(result?.data) ? result.data[0] : result;
|
|
68
71
|
}
|
|
72
|
+
export function summarizeWorkflowOperation(operation) {
|
|
73
|
+
const record = firstDataRecord(operation?.result) || {};
|
|
74
|
+
const selectedRecord = Object.fromEntries(['id', '_id', 'name', 'key', 'path', 'label', 'title', 'state', 'severity', 'type', 'isEnabled', 'version', 'jobId', 'flowId']
|
|
75
|
+
.filter((key) => record?.[key] !== undefined)
|
|
76
|
+
.map((key) => [key, record[key]]));
|
|
77
|
+
return {
|
|
78
|
+
action: operation?.action || null,
|
|
79
|
+
result: {
|
|
80
|
+
statusCode: operation?.result?.statusCode ?? null,
|
|
81
|
+
message: operation?.result?.message ?? null,
|
|
82
|
+
...(Object.keys(selectedRecord).length ? { record: selectedRecord } : {}),
|
|
83
|
+
},
|
|
84
|
+
...(operation?.routeReload ? {
|
|
85
|
+
routeReload: {
|
|
86
|
+
attempted: Boolean(operation.routeReload.attempted),
|
|
87
|
+
succeeded: operation.routeReload.succeeded === true,
|
|
88
|
+
},
|
|
89
|
+
} : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
69
92
|
function normalizeRestPath(path) {
|
|
70
93
|
if (!path)
|
|
71
94
|
return '/';
|
|
@@ -625,12 +648,16 @@ export function buildExtensionResourceListSnippet(input) {
|
|
|
625
648
|
const stats = input.statsExpression ? `\n :stats="${input.statsExpression}"` : '';
|
|
626
649
|
const actions = input.actionsExpression ? `\n :actions="${input.actionsExpression}"` : '';
|
|
627
650
|
const topBadge = input.topBadgeExpression ? `\n :top-badge="${input.topBadgeExpression}"` : '';
|
|
628
|
-
const
|
|
629
|
-
|
|
651
|
+
const itemsPerPageExpression = input.itemsPerPageExpression || '0';
|
|
652
|
+
const pageModel = String(itemsPerPageExpression) !== '0'
|
|
653
|
+
? `\n v-model:page="${input.pageExpression || 'page'}"`
|
|
654
|
+
: '';
|
|
655
|
+
const frame = [
|
|
656
|
+
`<CommonResourceListFrame${pageModel}`,
|
|
630
657
|
` :loading="${input.loadingExpression || 'pending'}"`,
|
|
631
658
|
` :has-items="${itemsExpression}.length > 0"`,
|
|
632
659
|
` :total="${input.totalExpression || `${itemsExpression}.length`}"`,
|
|
633
|
-
` :items-per-page="${
|
|
660
|
+
` :items-per-page="${itemsPerPageExpression}"`,
|
|
634
661
|
` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '"')}"`,
|
|
635
662
|
` empty-description="${String(input.emptyDescription || '').replace(/"/g, '"')}"`,
|
|
636
663
|
` empty-icon="${input.emptyIcon || 'lucide:inbox'}"`,
|
|
@@ -646,6 +673,9 @@ export function buildExtensionResourceListSnippet(input) {
|
|
|
646
673
|
' />',
|
|
647
674
|
'</CommonResourceListFrame>',
|
|
648
675
|
].join('\n');
|
|
676
|
+
const snippet = input.constrained === false
|
|
677
|
+
? frame
|
|
678
|
+
: ['<section class="eapp-page-constrained-wide space-y-4">', indentLines(frame, 2), '</section>'].join('\n');
|
|
649
679
|
return {
|
|
650
680
|
action: 'extension_resource_list_built',
|
|
651
681
|
components: ['CommonResourceListFrame', 'CommonResourceListItem'],
|
|
@@ -654,6 +684,8 @@ export function buildExtensionResourceListSnippet(input) {
|
|
|
654
684
|
'Use CommonResourceListFrame and CommonResourceListItem for operational lists instead of ad hoc cards.',
|
|
655
685
|
'CommonResourceListFrame supports extension default slots. It renders rows when loading is false and hasItems is true; inspect the source artifact, hasItems/items expressions, and API response shape before replacing it.',
|
|
656
686
|
'Keep first-load skeleton, empty state, and pagination owned by the frame.',
|
|
687
|
+
'Keep search and filter controls in a separate compact surface before the list; do not wrap filters and all rows in one oversized card.',
|
|
688
|
+
'Keep operational list pages constrained with eapp-page-constrained-wide unless the workflow intentionally owns a full-bleed canvas.',
|
|
657
689
|
'Use explicit bounded list data and natural pagination/search outside this snippet when the domain list can grow.',
|
|
658
690
|
],
|
|
659
691
|
};
|
|
@@ -1119,48 +1151,120 @@ export function buildExtensionConfirmSnippet(input = {}) {
|
|
|
1119
1151
|
],
|
|
1120
1152
|
};
|
|
1121
1153
|
}
|
|
1122
|
-
export function reviewExtensionUiContract(code) {
|
|
1154
|
+
export function reviewExtensionUiContract(code, options = {}) {
|
|
1123
1155
|
const source = String(code || '');
|
|
1156
|
+
const pattern = String(options.pattern || 'auto');
|
|
1124
1157
|
const issues = [];
|
|
1125
1158
|
const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
|
|
1126
|
-
|
|
1159
|
+
const analysis = analyzeExtensionSfc(source);
|
|
1160
|
+
const elements = analysis.elements;
|
|
1161
|
+
const byTag = (tag) => elements.filter((element) => element.tag === tag);
|
|
1162
|
+
const hasStaticOrBound = (element, name) => (extensionElementHasAttribute(element, name, null) || extensionElementHasAttribute(element, name, 'bind'));
|
|
1163
|
+
const drawers = byTag('CommonDrawer');
|
|
1164
|
+
const modals = [...byTag('CommonModal'), ...byTag('UModal')];
|
|
1165
|
+
const allClasses = new Set(elements.flatMap((element) => element.classes));
|
|
1166
|
+
if (!analysis.valid) {
|
|
1167
|
+
push('error', 'vue-sfc-parse', `Vue SFC parsing failed: ${analysis.errors[0]}`, 'Fix the malformed SFC/template before reviewing UI policy.');
|
|
1168
|
+
}
|
|
1169
|
+
if (drawers.some((element) => hasStaticOrBound(element, 'title'))) {
|
|
1127
1170
|
push('error', 'common-drawer-slots', 'CommonDrawer should not use title/:title props in generated extensions.', 'Use #header with a heading, and #body for content.');
|
|
1128
1171
|
}
|
|
1129
|
-
if (
|
|
1172
|
+
if (modals.some((element) => hasStaticOrBound(element, 'title'))) {
|
|
1130
1173
|
push('error', 'common-modal-slots', 'CommonModal/UModal should not use title/:title props in generated extensions.', 'Use #header with a heading, and #body for content.');
|
|
1131
1174
|
}
|
|
1132
|
-
if (
|
|
1175
|
+
if (drawers.some((element) => !hasStaticOrBound(element, 'primary-action') && !hasStaticOrBound(element, 'primaryAction'))) {
|
|
1133
1176
|
push('warning', 'drawer-primary-action', 'CommonDrawer has no primaryAction.', 'Editing/create drawers should wire Save/Create through primaryAction.');
|
|
1134
1177
|
}
|
|
1135
|
-
if (
|
|
1178
|
+
if (drawers.some((element) => !hasStaticOrBound(element, 'cancel-action') && !hasStaticOrBound(element, 'cancelAction'))) {
|
|
1136
1179
|
push('warning', 'drawer-cancel-action', 'CommonDrawer has no cancelAction.', 'Use cancelAction for the ordinary Cancel footer button unless the workflow intentionally has no cancel.');
|
|
1137
1180
|
}
|
|
1138
|
-
if (
|
|
1181
|
+
if (modals.some((element) => /delete|remove|confirm|cannot be undone/i.test(element.text) && !hasStaticOrBound(element, 'danger-action') && !hasStaticOrBound(element, 'dangerAction'))) {
|
|
1139
1182
|
push('warning', 'modal-danger-action', 'Destructive/confirmation modal has no dangerAction.', 'Wire the final destructive action through dangerAction.');
|
|
1140
1183
|
}
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
if (!classMatch || !classMatch[1].split(/\s+/).includes('w-full')) {
|
|
1147
|
-
push('warning', 'modal-drawer-field-width', `${tag} is missing class="w-full".`, 'Use class="w-full" for form controls inside modal/drawer body forms unless intentionally inline.');
|
|
1184
|
+
for (const element of elements.filter((item) => ['UInput', 'UTextarea', 'USelectMenu', 'USelect'].includes(item.tag))) {
|
|
1185
|
+
const intentionallyCompact = extensionElementHasAttribute(element, 'data-compact', null)
|
|
1186
|
+
|| extensionElementHasAttribute(element, 'data-inline', null);
|
|
1187
|
+
if (!intentionallyCompact && !element.classes.includes('w-full')) {
|
|
1188
|
+
push('warning', 'modal-drawer-field-width', `${element.tag} is missing class="w-full".`, 'Use class="w-full" for form controls inside modal/drawer body forms unless intentionally inline.');
|
|
1148
1189
|
}
|
|
1149
1190
|
}
|
|
1150
|
-
const
|
|
1151
|
-
|
|
1152
|
-
while ((buttonMatch = buttonPattern.exec(source))) {
|
|
1153
|
-
if (!/\btype=/.test(buttonMatch[1] || '')) {
|
|
1191
|
+
for (const element of byTag('button')) {
|
|
1192
|
+
if (!hasStaticOrBound(element, 'type')) {
|
|
1154
1193
|
push('warning', 'native-button-type', 'Native button is missing type="button".', 'Add type="button" unless the button intentionally submits a form.');
|
|
1155
1194
|
}
|
|
1156
1195
|
}
|
|
1196
|
+
if (pattern === 'resource_list') {
|
|
1197
|
+
const frames = byTag('CommonResourceListFrame');
|
|
1198
|
+
if (frames.length === 0) {
|
|
1199
|
+
push('error', 'resource-list-frame-required', 'Operational resource lists must use CommonResourceListFrame.', 'Use build_extension_ui kind=resource_list so loading, empty state, total, and pagination stay list-owned.');
|
|
1200
|
+
}
|
|
1201
|
+
if (byTag('CommonResourceListItem').length === 0) {
|
|
1202
|
+
push('error', 'resource-list-item-required', 'Operational resource rows must use CommonResourceListItem.', 'Move title, description, badge, stats, metadata, navigation, and row actions into CommonResourceListItem.');
|
|
1203
|
+
}
|
|
1204
|
+
if (elements.some((element) => ['UCard', 'article'].includes(element.tag) && extensionElementHasAttribute(element, 'for', 'for'))) {
|
|
1205
|
+
push('error', 'resource-list-ad-hoc-cards', 'A resource-list screen still renders inventory rows as repeated cards.', 'Use CommonResourceListItem for homogeneous operational rows; reserve resource_grid for workboards and catalogs.');
|
|
1206
|
+
}
|
|
1207
|
+
if (elements.some((element) => ['table', 'UTable'].includes(element.tag))) {
|
|
1208
|
+
push('error', 'resource-list-ad-hoc-table', 'A resource-list screen still renders its primary inventory as a table.', 'Use CommonResourceListItem so row metadata and actions remain responsive on narrow screens.');
|
|
1209
|
+
}
|
|
1210
|
+
const frame = frames[0];
|
|
1211
|
+
if (frame && !hasStaticOrBound(frame, 'loading')) {
|
|
1212
|
+
push('error', 'resource-list-loading-owned', 'CommonResourceListFrame is missing its loading contract.', 'Bind :loading to the first-load state.');
|
|
1213
|
+
}
|
|
1214
|
+
if (frame && !hasStaticOrBound(frame, 'has-items') && !hasStaticOrBound(frame, 'hasItems')) {
|
|
1215
|
+
push('error', 'resource-list-empty-owned', 'CommonResourceListFrame is missing its has-items contract.', 'Bind :has-items and keep the empty state owned by the frame.');
|
|
1216
|
+
}
|
|
1217
|
+
if (frame && !hasStaticOrBound(frame, 'empty-title') && !hasStaticOrBound(frame, 'emptyTitle')) {
|
|
1218
|
+
push('error', 'resource-list-empty-copy', 'CommonResourceListFrame is missing an empty-state title.', 'Provide concise empty-title and, when useful, empty-description and empty-icon.');
|
|
1219
|
+
}
|
|
1220
|
+
const itemsPerPage = frame
|
|
1221
|
+
? extensionElementAttributeValue(frame, 'items-per-page', 'bind') ?? extensionElementAttributeValue(frame, 'itemsPerPage', 'bind')
|
|
1222
|
+
: null;
|
|
1223
|
+
if (itemsPerPage && itemsPerPage !== '0' && !extensionElementHasAttribute(frame, 'page', 'model')) {
|
|
1224
|
+
push('error', 'resource-list-pagination-owned', 'A paginated CommonResourceListFrame is missing its page model.', 'Bind v-model:page on the frame so pagination state stays list-owned.');
|
|
1225
|
+
}
|
|
1226
|
+
if (!allClasses.has('eapp-page-constrained-wide')) {
|
|
1227
|
+
push('warning', 'resource-list-width', 'The operational list is not constrained for wide admin viewports.', 'Wrap the page inventory in eapp-page-constrained-wide unless this extension intentionally owns a full-bleed canvas.');
|
|
1228
|
+
}
|
|
1229
|
+
const frameIndex = frame ? elements.indexOf(frame) : elements.length;
|
|
1230
|
+
const beforeFrame = elements.slice(0, frameIndex);
|
|
1231
|
+
const hasSearchOrFilterControl = beforeFrame.some((element) => (['UInput', 'UInputMenu', 'USelect', 'USelectMenu'].includes(element.tag)
|
|
1232
|
+
&& element.attributes.some((attribute) => /search|filter/i.test(`${attribute.name} ${attribute.value || ''}`))));
|
|
1233
|
+
const hasFilterSurface = beforeFrame.some((element) => element.classes.some((name) => ['eapp-surface-card', 'eapp-surface-muted'].includes(name)));
|
|
1234
|
+
if (hasSearchOrFilterControl && !hasFilterSurface) {
|
|
1235
|
+
push('warning', 'resource-list-filter-surface', 'Search or filter controls are not in a separate compact surface before the list.', 'Place controls in a compact eapp-surface-card or eapp-surface-muted block, separate from CommonResourceListFrame.');
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (pattern === 'resource_grid') {
|
|
1239
|
+
const frame = byTag('CommonResourceListFrame')[0];
|
|
1240
|
+
if (!frame || extensionElementAttributeValue(frame, 'variant', null) !== 'plain') {
|
|
1241
|
+
push('error', 'resource-grid-frame', 'Resource grids must use CommonResourceListFrame variant="plain".', 'Use build_extension_ui kind=resource_grid so loading and empty state remain list-owned without duplicate contained chrome.');
|
|
1242
|
+
}
|
|
1243
|
+
if (!allClasses.has('md:grid-cols-2') || !allClasses.has('xl:grid-cols-3')) {
|
|
1244
|
+
push('error', 'resource-grid-breakpoints', 'Resource grids must use the admin-shell one/two/three-column breakpoints.', 'Use one column by default, md:grid-cols-2, and xl:grid-cols-3.');
|
|
1245
|
+
}
|
|
1246
|
+
if (!allClasses.has('eapp-page-constrained-wide')) {
|
|
1247
|
+
push('warning', 'resource-grid-width', 'The resource grid is not constrained for the admin shell.', 'Keep eapp-page-constrained-wide unless the workflow intentionally owns a full-bleed canvas.');
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (pattern === 'auto'
|
|
1251
|
+
&& elements.some((element) => ['UCard', 'article'].includes(element.tag) && extensionElementHasAttribute(element, 'for', 'for'))
|
|
1252
|
+
&& byTag('CommonResourceListFrame').length === 0) {
|
|
1253
|
+
push('warning', 'ad-hoc-inventory', 'Repeated cards were found without a shared list frame.', 'Choose resource_list for dense operational rows or resource_grid for a workboard/catalog, then use the matching builder contract.');
|
|
1254
|
+
}
|
|
1255
|
+
if (byTag('UButton').some((element) => hasStaticOrBound(element, 'disabled') && /Already|Completed|Granted|Handled/i.test(element.text))) {
|
|
1256
|
+
push('warning', 'disabled-terminal-action', 'A terminal state appears as a disabled action button.', 'Render terminal state as a badge or metadata and omit the unavailable action.');
|
|
1257
|
+
}
|
|
1157
1258
|
return {
|
|
1158
1259
|
action: 'extension_ui_contract_reviewed',
|
|
1260
|
+
pattern,
|
|
1159
1261
|
valid: issues.every((issue) => issue.severity !== 'error'),
|
|
1160
1262
|
issueCount: issues.length,
|
|
1161
1263
|
issues,
|
|
1162
1264
|
nextSteps: issues.length
|
|
1163
|
-
? [
|
|
1265
|
+
? [pattern === 'resource_list' || pattern === 'resource_grid'
|
|
1266
|
+
? `Use build_extension_ui kind=${pattern} for the canonical layout, then apply with patch_extension_code/update_extension_code.`
|
|
1267
|
+
: 'Use the matching build_extension_ui contract, then apply with patch_extension_code/update_extension_code.']
|
|
1164
1268
|
: ['Snippet matches the checked modal/drawer contract rules. Still validate the final SFC before saving.'],
|
|
1165
1269
|
};
|
|
1166
1270
|
}
|
|
@@ -1168,6 +1272,7 @@ function collectExtensionRuntimeIssues(code) {
|
|
|
1168
1272
|
const source = String(code || '');
|
|
1169
1273
|
const issues = [];
|
|
1170
1274
|
const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
|
|
1275
|
+
const analysis = analyzeExtensionSfc(source);
|
|
1171
1276
|
if (/(?:^|[>\n;])\s*import(?:\s.+?\sfrom\s+|\s*['"])/m.test(source)) {
|
|
1172
1277
|
push('error', 'static-import', 'Static import statements are not allowed in enfyra_extension.code.', 'Use injected globals/components directly, or load app packages with getPackages(["package-name"]) inside runtime code.');
|
|
1173
1278
|
}
|
|
@@ -1183,10 +1288,11 @@ function collectExtensionRuntimeIssues(code) {
|
|
|
1183
1288
|
if (/\b(?:query|body|filter|deep|aggregate)\s*:\s*JSON\.stringify\s*\(/.test(source)) {
|
|
1184
1289
|
push('error', 'use-api-json-stringify-options', 'useApi query/body/filter/deep/aggregate options must be plain objects or computed objects, not JSON strings.', 'Pass the object directly to useApi or execute().');
|
|
1185
1290
|
}
|
|
1186
|
-
if (
|
|
1291
|
+
if (analysis.elements.some((element) => (['CommonModal', 'UModal'].includes(element.tag)
|
|
1292
|
+
&& extensionElementHasAttribute(element, 'model', 'model')))) {
|
|
1187
1293
|
push('error', 'modal-open-model', 'CommonModal/UModal must bind v-model:open, not the default v-model contract.', 'Call build_extension_ui kind=modal and preserve its v-model:open binding.');
|
|
1188
1294
|
}
|
|
1189
|
-
if (
|
|
1295
|
+
if (analysis.elements.some((element) => element.tag === 'CommonEmptyState')) {
|
|
1190
1296
|
push('error', 'unavailable-common-empty-state', 'CommonEmptyState is not registered in the dynamic extension runtime.', 'Use the injected EmptyState alias or build_extension_ui kind=empty_state/resource_list/resource_grid.');
|
|
1191
1297
|
}
|
|
1192
1298
|
const scriptBlocks = [...source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((match) => match[1]);
|
|
@@ -1467,7 +1573,7 @@ export function buildExtensionUiSnippet(kind, input = {}) {
|
|
|
1467
1573
|
if (!input?.code) {
|
|
1468
1574
|
throw new Error('build_extension_ui kind=review requires input.code.');
|
|
1469
1575
|
}
|
|
1470
|
-
const uiReview = reviewExtensionUiContract(input.code);
|
|
1576
|
+
const uiReview = reviewExtensionUiContract(input.code, { pattern: input.pattern });
|
|
1471
1577
|
const themeReview = reviewExtensionThemeContract(input.code);
|
|
1472
1578
|
const runtimeReview = reviewExtensionRuntimeContract(input.code);
|
|
1473
1579
|
result = {
|
|
@@ -1744,39 +1850,44 @@ function findInvalidExtensionSortSyntax(code) {
|
|
|
1744
1850
|
}
|
|
1745
1851
|
return null;
|
|
1746
1852
|
}
|
|
1747
|
-
export function validateExtensionCodeLocally(code) {
|
|
1853
|
+
export function validateExtensionCodeLocally(code, options = {}) {
|
|
1854
|
+
const analysis = analyzeExtensionSfc(code);
|
|
1855
|
+
if (!analysis.valid) {
|
|
1856
|
+
throw new Error(`Invalid Vue SFC: ${analysis.errors[0] || 'template parsing failed.'}`);
|
|
1857
|
+
}
|
|
1748
1858
|
if (/\bresolveComponent\s*\(/.test(String(code || ''))) {
|
|
1749
1859
|
throw new Error('Invalid extension component resolution: do not call resolveComponent() in Enfyra extensions. Use auto-injected components such as <UButton> directly in the template so the app/compiler resolves them correctly.');
|
|
1750
1860
|
}
|
|
1751
1861
|
const invalidSortSyntax = findInvalidExtensionSortSyntax(code);
|
|
1752
1862
|
if (invalidSortSyntax) {
|
|
1753
|
-
throw new Error(`Invalid extension sort contract: ${invalidSortSyntax} Use
|
|
1754
|
-
}
|
|
1755
|
-
const violations = [];
|
|
1756
|
-
for (const template of readTemplateBlocks(code)) {
|
|
1757
|
-
let index = 0;
|
|
1758
|
-
while (index < template.length) {
|
|
1759
|
-
const tagStart = template.indexOf('<', index);
|
|
1760
|
-
if (tagStart === -1)
|
|
1761
|
-
break;
|
|
1762
|
-
const tagName = readTemplateTagName(template, tagStart);
|
|
1763
|
-
if (tagName && tagName === tagName.toLowerCase() && !tagName.includes('-')) {
|
|
1764
|
-
const expected = AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE.get(tagName);
|
|
1765
|
-
if (expected)
|
|
1766
|
-
violations.push({ tag: tagName, expected });
|
|
1767
|
-
}
|
|
1768
|
-
index = tagStart + 1;
|
|
1769
|
-
}
|
|
1863
|
+
throw new Error(`Invalid extension sort contract: ${invalidSortSyntax} Use build_extension_ui kind=api_usage with structured sort entries; Enfyra REST requires one comma-separated string such as "-isPinned,-updatedAt".`);
|
|
1770
1864
|
}
|
|
1865
|
+
const violations = analysis.elements.flatMap((element) => {
|
|
1866
|
+
const tagName = element.tag;
|
|
1867
|
+
if (tagName !== tagName.toLowerCase() || tagName.includes('-'))
|
|
1868
|
+
return [];
|
|
1869
|
+
const expected = AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE.get(tagName);
|
|
1870
|
+
return expected ? [{ tag: tagName, expected }] : [];
|
|
1871
|
+
});
|
|
1771
1872
|
if (violations.length) {
|
|
1772
1873
|
const first = violations[0];
|
|
1773
1874
|
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.`);
|
|
1774
1875
|
}
|
|
1775
|
-
const missingFullWidthFields =
|
|
1876
|
+
const missingFullWidthFields = analysis.elements
|
|
1877
|
+
.filter((element) => FULL_WIDTH_EXTENSION_FIELD_TAGS.includes(element.tag))
|
|
1878
|
+
.filter((element) => !extensionElementHasAttribute(element, 'data-compact', null))
|
|
1879
|
+
.filter((element) => !extensionElementHasAttribute(element, 'data-inline', null))
|
|
1880
|
+
.filter((element) => !element.classes.includes('w-full'))
|
|
1881
|
+
.map((element) => ({ tag: element.tag, snippet: element.source }));
|
|
1776
1882
|
if (missingFullWidthFields.length) {
|
|
1777
1883
|
const first = missingFullWidthFields[0];
|
|
1778
1884
|
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}`);
|
|
1779
1885
|
}
|
|
1886
|
+
const uiReview = reviewExtensionUiContract(code, { pattern: options.uiPattern });
|
|
1887
|
+
const firstUiError = uiReview.issues.find((issue) => issue.severity === 'error');
|
|
1888
|
+
if (firstUiError) {
|
|
1889
|
+
throw new Error(`Invalid extension UI contract: ${firstUiError.message} Rule: ${firstUiError.rule}. ${firstUiError.suggestion}`);
|
|
1890
|
+
}
|
|
1780
1891
|
const themeReview = reviewExtensionThemeContract(code);
|
|
1781
1892
|
const firstThemeError = themeReview.issues.find((issue) => issue.severity === 'error');
|
|
1782
1893
|
if (firstThemeError) {
|
|
@@ -1787,10 +1898,10 @@ export function validateExtensionCodeLocally(code) {
|
|
|
1787
1898
|
if (firstRuntimeError) {
|
|
1788
1899
|
throw new Error(`Invalid extension runtime contract: ${firstRuntimeError.message} Rule: ${firstRuntimeError.rule}. ${firstRuntimeError.suggestion}`);
|
|
1789
1900
|
}
|
|
1790
|
-
return { componentCasing: 'passed', fieldWidth: 'passed', themeContract: 'passed', runtimeContract: 'passed' };
|
|
1901
|
+
return { vueSfcAst: 'passed', componentCasing: 'passed', fieldWidth: 'passed', themeContract: 'passed', runtimeContract: 'passed' };
|
|
1791
1902
|
}
|
|
1792
|
-
export async function validateExtensionCode(apiUrl, code, name) {
|
|
1793
|
-
const localChecks = validateExtensionCodeLocally(code);
|
|
1903
|
+
export async function validateExtensionCode(apiUrl, code, name, options = {}) {
|
|
1904
|
+
const localChecks = validateExtensionCodeLocally(code, options);
|
|
1794
1905
|
const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
|
|
1795
1906
|
method: 'POST',
|
|
1796
1907
|
body: JSON.stringify({ code, name }),
|
|
@@ -1805,18 +1916,95 @@ export async function validateExtensionCode(apiUrl, code, name) {
|
|
|
1805
1916
|
compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
|
|
1806
1917
|
};
|
|
1807
1918
|
}
|
|
1808
|
-
|
|
1919
|
+
function summarizeExtensionSaveResult(result, fallback = {}) {
|
|
1920
|
+
const record = unwrapData(result)[0] || (result?.data && !Array.isArray(result.data) ? result.data : null) || {};
|
|
1921
|
+
return {
|
|
1922
|
+
id: getId(record) ?? fallback.id ?? null,
|
|
1923
|
+
name: record.name ?? fallback.name ?? null,
|
|
1924
|
+
type: record.type ?? fallback.type ?? null,
|
|
1925
|
+
isEnabled: record.isEnabled ?? fallback.isEnabled ?? null,
|
|
1926
|
+
version: record.version ?? fallback.version ?? null,
|
|
1927
|
+
updatedAt: record.updatedAt ?? null,
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
export function buildExtensionRuntimeVerification({ extension, code, validation, uiPattern, expectedSha256 }) {
|
|
1931
|
+
const source = String(code || '');
|
|
1932
|
+
const currentSha256 = sha256Text(source);
|
|
1933
|
+
const review = buildExtensionUiSnippet('review', { code: source, pattern: uiPattern });
|
|
1934
|
+
const rawMenu = Array.isArray(extension?.menu) ? extension.menu[0] : extension?.menu;
|
|
1935
|
+
const isPage = String(extension?.type || '').toLowerCase() === 'page';
|
|
1936
|
+
const savedRecordPassed = getId(extension) !== null;
|
|
1937
|
+
const menuWiringPassed = !isPage || Boolean(getId(rawMenu) !== null && rawMenu?.path);
|
|
1938
|
+
const hashMatches = !expectedSha256 || expectedSha256 === currentSha256;
|
|
1939
|
+
const compilerPassed = validation?.valid === true;
|
|
1940
|
+
const valid = savedRecordPassed && compilerPassed && review.valid && menuWiringPassed && hashMatches;
|
|
1941
|
+
return {
|
|
1942
|
+
action: 'extension_runtime_verified',
|
|
1943
|
+
valid,
|
|
1944
|
+
extension: {
|
|
1945
|
+
id: getId(extension),
|
|
1946
|
+
name: extension?.name || null,
|
|
1947
|
+
type: extension?.type || null,
|
|
1948
|
+
isEnabled: extension?.isEnabled ?? null,
|
|
1949
|
+
version: extension?.version ?? null,
|
|
1950
|
+
sha256: currentSha256,
|
|
1951
|
+
length: source.length,
|
|
1952
|
+
},
|
|
1953
|
+
checks: {
|
|
1954
|
+
savedRecord: { status: savedRecordPassed ? 'passed' : 'failed' },
|
|
1955
|
+
expectedHash: { status: hashMatches ? 'passed' : 'failed', expectedSha256: expectedSha256 || null, currentSha256 },
|
|
1956
|
+
serverCompile: { status: compilerPassed ? 'passed' : 'failed', compiledLength: validation?.compiledLength ?? null },
|
|
1957
|
+
uiContract: { status: review.ui.valid ? 'passed' : 'failed', issueCount: review.ui.issueCount, pattern: review.ui.pattern },
|
|
1958
|
+
themeContract: { status: review.theme.valid ? 'passed' : 'failed', issueCount: review.theme.issueCount },
|
|
1959
|
+
runtimeContract: { status: review.runtime.valid ? 'passed' : 'failed', issueCount: review.runtime.issueCount },
|
|
1960
|
+
menuWiring: {
|
|
1961
|
+
status: menuWiringPassed ? 'passed' : 'failed',
|
|
1962
|
+
applicable: isPage,
|
|
1963
|
+
menu: rawMenu ? { id: getId(rawMenu), label: rawMenu.label || null, path: rawMenu.path || null } : null,
|
|
1964
|
+
},
|
|
1965
|
+
browserRender: {
|
|
1966
|
+
status: 'not_run',
|
|
1967
|
+
reason: 'MCP can verify saved metadata, server compilation, static runtime/UI/theme contracts, and page menu wiring. A signed-in browser is still required to prove component execution, API data shape, console errors, and responsive layout.',
|
|
1968
|
+
},
|
|
1969
|
+
},
|
|
1970
|
+
contractReview: review.valid
|
|
1971
|
+
? { valid: true, issueCount: 0, pattern: review.ui.pattern }
|
|
1972
|
+
: review,
|
|
1973
|
+
coverage: {
|
|
1974
|
+
verified: ['saved metadata', 'expected source hash', 'server Vue compilation', 'static UI/theme/runtime contracts', ...(isPage ? ['page menu wiring'] : [])],
|
|
1975
|
+
browserRequiredForFullRuntimeProof: true,
|
|
1976
|
+
},
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
export async function verifyExtensionRuntime(apiUrl, { id, name, uiPattern, expectedSha256 }) {
|
|
1980
|
+
if (!id && !name)
|
|
1981
|
+
throw new Error('Provide id or name to verify an existing extension.');
|
|
1982
|
+
const existing = id
|
|
1983
|
+
? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,isEnabled,version,updatedAt,menu.id,menu.label,menu.path,code')
|
|
1984
|
+
: await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,isEnabled,version,updatedAt,menu.id,menu.label,menu.path,code');
|
|
1985
|
+
if (!existing)
|
|
1986
|
+
throw new Error(`Extension not found: ${id || name}`);
|
|
1987
|
+
const code = String(existing.code || '');
|
|
1988
|
+
const validation = await validateExtensionCode(apiUrl, code, existing.name || String(id || name), { uiPattern });
|
|
1989
|
+
return buildExtensionRuntimeVerification({ extension: existing, code, validation, uiPattern, expectedSha256 });
|
|
1990
|
+
}
|
|
1991
|
+
async function updateExtensionCode(apiUrl, { id, name, code, description, isEnabled, version, expectedSha256, uiPattern, globalRulesAckKey, extensionKnowledgeAckKey, }) {
|
|
1809
1992
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1810
1993
|
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
1811
1994
|
if (!id && !name)
|
|
1812
1995
|
throw new Error('Provide id or name to update an existing extension.');
|
|
1813
1996
|
const existing = id
|
|
1814
|
-
? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,menu.id')
|
|
1815
|
-
: await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,menu.id');
|
|
1997
|
+
? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,isEnabled,version,menu.id,code')
|
|
1998
|
+
: await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,isEnabled,version,menu.id,code');
|
|
1816
1999
|
if (!existing)
|
|
1817
2000
|
throw new Error(`Extension not found: ${id || name}`);
|
|
1818
2001
|
const extensionId = getId(existing);
|
|
1819
|
-
const
|
|
2002
|
+
const currentSha256 = sha256Text(existing.code || '');
|
|
2003
|
+
if (expectedSha256 && expectedSha256 !== currentSha256) {
|
|
2004
|
+
throw new Error(`Extension code hash mismatch. Expected ${expectedSha256}, got ${currentSha256}. Re-read the extension before replacing it.`);
|
|
2005
|
+
}
|
|
2006
|
+
const validation = await validateExtensionCode(apiUrl, code, name || existing.name || extensionId, { uiPattern });
|
|
2007
|
+
const contractReview = buildExtensionUiSnippet('review', { code, pattern: uiPattern });
|
|
1820
2008
|
const body = {
|
|
1821
2009
|
code,
|
|
1822
2010
|
...(description !== undefined ? { description } : {}),
|
|
@@ -1827,13 +2015,34 @@ async function updateExtensionCode(apiUrl, { id, name, code, description, isEnab
|
|
|
1827
2015
|
method: 'PATCH',
|
|
1828
2016
|
body: JSON.stringify(body),
|
|
1829
2017
|
});
|
|
2018
|
+
const nextSha256 = sha256Text(code);
|
|
2019
|
+
const verification = await verifyExtensionRuntime(apiUrl, {
|
|
2020
|
+
id: extensionId,
|
|
2021
|
+
name: undefined,
|
|
2022
|
+
uiPattern,
|
|
2023
|
+
expectedSha256: nextSha256,
|
|
2024
|
+
});
|
|
1830
2025
|
return {
|
|
1831
2026
|
action: 'extension_code_updated',
|
|
1832
2027
|
id: extensionId,
|
|
1833
2028
|
name: existing.name || name || null,
|
|
1834
2029
|
type: existing.type || null,
|
|
1835
|
-
|
|
2030
|
+
previousSha256: currentSha256,
|
|
2031
|
+
sha256: nextSha256,
|
|
2032
|
+
saved: summarizeExtensionSaveResult(result, {
|
|
2033
|
+
id: extensionId,
|
|
2034
|
+
name: existing.name || name,
|
|
2035
|
+
type: existing.type,
|
|
2036
|
+
isEnabled: isEnabled ?? existing.isEnabled,
|
|
2037
|
+
version: version ?? existing.version,
|
|
2038
|
+
}),
|
|
1836
2039
|
validation,
|
|
2040
|
+
contractReview: {
|
|
2041
|
+
valid: contractReview.valid,
|
|
2042
|
+
issueCount: contractReview.issueCount,
|
|
2043
|
+
pattern: contractReview.ui?.pattern,
|
|
2044
|
+
},
|
|
2045
|
+
verification,
|
|
1837
2046
|
};
|
|
1838
2047
|
}
|
|
1839
2048
|
function sha256Text(value) {
|
|
@@ -1950,7 +2159,30 @@ export function applyExtensionCodePatches(code, patches) {
|
|
|
1950
2159
|
});
|
|
1951
2160
|
return { code: nextCode, patches: normalizedPatches, results };
|
|
1952
2161
|
}
|
|
1953
|
-
|
|
2162
|
+
function patchDiffLines(value, prefix) {
|
|
2163
|
+
const lines = String(value ?? '').split('\n');
|
|
2164
|
+
return lines.map((line) => `${prefix}${line}`).join('\n');
|
|
2165
|
+
}
|
|
2166
|
+
export function buildExtensionPatchDiffArtifact({ id, name, currentSha256, nextSha256, patches }) {
|
|
2167
|
+
const hunks = (patches || []).map((patch, index) => [
|
|
2168
|
+
`@@ patch ${index + 1} (${patch.searchMode || 'exact'}${patch.replaceAll ? ', all matches' : ''}) @@`,
|
|
2169
|
+
patchDiffLines(patch.search, '-'),
|
|
2170
|
+
patchDiffLines(patch.replace, '+'),
|
|
2171
|
+
].join('\n'));
|
|
2172
|
+
const content = [
|
|
2173
|
+
`--- ${name || id || 'extension'}@${currentSha256 || 'unknown'}`,
|
|
2174
|
+
`+++ ${name || id || 'extension'}@${nextSha256 || 'unknown'}`,
|
|
2175
|
+
...hunks,
|
|
2176
|
+
'',
|
|
2177
|
+
].join('\n');
|
|
2178
|
+
return writeSourceArtifact({
|
|
2179
|
+
tableName: 'enfyra_extension',
|
|
2180
|
+
id: id || name || 'extension',
|
|
2181
|
+
fieldName: 'patch.diff',
|
|
2182
|
+
source: content,
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMode, replaceAll, patches, expectedSha256, apply, description, isEnabled, version, uiPattern, globalRulesAckKey, extensionKnowledgeAckKey, }) {
|
|
1954
2186
|
assertGlobalRulesAck(globalRulesAckKey);
|
|
1955
2187
|
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
1956
2188
|
if (!id && !name)
|
|
@@ -1963,6 +2195,9 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
1963
2195
|
const extensionId = getId(existing);
|
|
1964
2196
|
const currentCode = String(existing.code ?? '');
|
|
1965
2197
|
const currentSha256 = sha256Text(currentCode);
|
|
2198
|
+
if (apply && !expectedSha256) {
|
|
2199
|
+
throw new Error('expectedSha256 is required when apply=true. Preview the patch or inspect the extension first, then retry with the current code hash.');
|
|
2200
|
+
}
|
|
1966
2201
|
if (expectedSha256 && expectedSha256 !== currentSha256) {
|
|
1967
2202
|
throw new Error(`Extension code hash mismatch. Expected ${expectedSha256}, got ${currentSha256}. Re-read the extension before patching.`);
|
|
1968
2203
|
}
|
|
@@ -1970,6 +2205,13 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
1970
2205
|
const nextCode = patchResult.code;
|
|
1971
2206
|
const nextSha256 = sha256Text(nextCode);
|
|
1972
2207
|
const occurrences = patchResult.results.reduce((total, item) => total + item.occurrences, 0);
|
|
2208
|
+
const diff = buildExtensionPatchDiffArtifact({
|
|
2209
|
+
id: extensionId,
|
|
2210
|
+
name: existing.name || name,
|
|
2211
|
+
currentSha256,
|
|
2212
|
+
nextSha256,
|
|
2213
|
+
patches: patchResult.patches,
|
|
2214
|
+
});
|
|
1973
2215
|
const nextStepPatchInput = patchResult.patches.length === 1
|
|
1974
2216
|
? {
|
|
1975
2217
|
search: patchResult.patches[0].search,
|
|
@@ -1989,6 +2231,7 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
1989
2231
|
nextLength: nextCode.length,
|
|
1990
2232
|
occurrences,
|
|
1991
2233
|
patchResults: patchResult.results,
|
|
2234
|
+
diff,
|
|
1992
2235
|
atomic: patchResult.patches.length > 1,
|
|
1993
2236
|
apply: Boolean(apply),
|
|
1994
2237
|
};
|
|
@@ -2008,6 +2251,8 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
|
|
|
2008
2251
|
description,
|
|
2009
2252
|
isEnabled,
|
|
2010
2253
|
version,
|
|
2254
|
+
expectedSha256: currentSha256,
|
|
2255
|
+
uiPattern,
|
|
2011
2256
|
globalRulesAckKey,
|
|
2012
2257
|
extensionKnowledgeAckKey,
|
|
2013
2258
|
});
|
|
@@ -2170,13 +2415,21 @@ async function ensureExtension(apiUrl, { name, type, code, menuId, description,
|
|
|
2170
2415
|
isEnabled,
|
|
2171
2416
|
version,
|
|
2172
2417
|
});
|
|
2418
|
+
const extensionId = operation.id || getId(existing);
|
|
2419
|
+
const verification = await verifyExtensionRuntime(apiUrl, {
|
|
2420
|
+
id: extensionId,
|
|
2421
|
+
name: extensionId ? undefined : name,
|
|
2422
|
+
uiPattern: undefined,
|
|
2423
|
+
expectedSha256: sha256Text(code),
|
|
2424
|
+
});
|
|
2173
2425
|
return {
|
|
2174
|
-
id:
|
|
2426
|
+
id: extensionId,
|
|
2175
2427
|
name,
|
|
2176
2428
|
type,
|
|
2177
2429
|
action: operation.action,
|
|
2178
|
-
operation,
|
|
2430
|
+
operation: { action: operation.action, id: extensionId },
|
|
2179
2431
|
validation,
|
|
2432
|
+
verification,
|
|
2180
2433
|
};
|
|
2181
2434
|
}
|
|
2182
2435
|
async function ensureFlow(apiUrl, { name, triggerType = 'manual', triggerConfig, timeout, maxExecutions = 100, isEnabled = true, description, globalRulesAckKey, }) {
|
|
@@ -2464,10 +2717,26 @@ async function runFlowWorkflow(apiUrl, opts) {
|
|
|
2464
2717
|
return {
|
|
2465
2718
|
action: 'flow_workflow_applied',
|
|
2466
2719
|
flow: flowResult.flow,
|
|
2467
|
-
flowResult
|
|
2720
|
+
flowResult: {
|
|
2721
|
+
action: flowResult.action,
|
|
2722
|
+
flow: flowResult.flow,
|
|
2723
|
+
reload: flowResult.reload,
|
|
2724
|
+
},
|
|
2468
2725
|
stepCount: plan.length,
|
|
2469
|
-
plan,
|
|
2470
|
-
|
|
2726
|
+
plan: plan.map(({ sourceCode, ...step }) => ({
|
|
2727
|
+
...step,
|
|
2728
|
+
...(sourceCode ? { source: { length: sourceCode.length, sha256: sha256Text(sourceCode) } } : {}),
|
|
2729
|
+
})),
|
|
2730
|
+
operations: operations.map((operation) => ({
|
|
2731
|
+
index: operation.index,
|
|
2732
|
+
key: operation.key,
|
|
2733
|
+
type: operation.type,
|
|
2734
|
+
action: operation.result.action,
|
|
2735
|
+
flow: operation.result.flow,
|
|
2736
|
+
step: operation.result.step,
|
|
2737
|
+
validation: operation.result.validation,
|
|
2738
|
+
reload: operation.result.reload,
|
|
2739
|
+
})),
|
|
2471
2740
|
sequential: true,
|
|
2472
2741
|
nextSteps: [
|
|
2473
2742
|
'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
|
|
@@ -2837,7 +3106,7 @@ async function runApiEndpointWorkflow(apiUrl, opts) {
|
|
|
2837
3106
|
scriptValidation: latestState.scriptValidation,
|
|
2838
3107
|
contractReview: latestState.contractReview,
|
|
2839
3108
|
steps: latestSteps,
|
|
2840
|
-
operations,
|
|
3109
|
+
operations: operations.map(summarizeWorkflowOperation),
|
|
2841
3110
|
complete: latestSteps.every((item) => ['completed', 'skipped'].includes(item.status)),
|
|
2842
3111
|
nextSteps,
|
|
2843
3112
|
cleanupHints: latestState.endpoint.routeId
|
|
@@ -3077,24 +3346,37 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3077
3346
|
'This calls /enfyra_extension/preview and does not save anything.',
|
|
3078
3347
|
'Call get_extension_theme_contract first when generating or reviewing UI.',
|
|
3079
3348
|
].join(' '), {
|
|
3080
|
-
code: z.string().describe('Vue SFC or compiled extension bundle code.'),
|
|
3349
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC or compiled extension bundle code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
3081
3350
|
name: z.string().optional().describe('Optional extension name/id used by the preview compiler.'),
|
|
3082
|
-
|
|
3351
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern. resource_list/resource_grid enable deterministic layout policy checks.'),
|
|
3352
|
+
}, async ({ code, name, uiPattern }) => jsonText({
|
|
3083
3353
|
action: 'extension_code_validated',
|
|
3084
|
-
validation: await validateExtensionCode(ENFYRA_API_URL, code, name),
|
|
3354
|
+
validation: await validateExtensionCode(ENFYRA_API_URL, code, name, { uiPattern }),
|
|
3085
3355
|
}));
|
|
3356
|
+
server.tool('verify_extension_runtime', [
|
|
3357
|
+
'Verify one saved Enfyra extension through the strongest checks available inside MCP.',
|
|
3358
|
+
'It checks the saved record and expected hash, runs local UI/theme/runtime policy review, calls the server Vue compiler, and verifies page menu wiring.',
|
|
3359
|
+
'It explicitly reports browserRender=not_run because signed-in component execution, real API data shape, console errors, and responsive layout require browser automation outside this MCP server.',
|
|
3360
|
+
].join(' '), {
|
|
3361
|
+
id: z.union([z.string(), z.number()]).optional().describe('Saved extension id. Provide id or name.'),
|
|
3362
|
+
name: z.string().optional().describe('Saved extension unique name. Provide id or name.'),
|
|
3363
|
+
expectedSha256: z.string().optional().describe('Optional expected saved source hash from inspect/update/patch output.'),
|
|
3364
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern for deterministic layout policy checks.'),
|
|
3365
|
+
}, async (input) => jsonText(await verifyExtensionRuntime(ENFYRA_API_URL, input)));
|
|
3086
3366
|
server.tool('update_extension_code', [
|
|
3087
3367
|
'Business operation: update an existing Enfyra admin extension code by id or name.',
|
|
3088
|
-
'It runs local extension guards and /enfyra_extension/preview first, then
|
|
3368
|
+
'It runs local extension guards and /enfyra_extension/preview first, saves only when validation succeeds, then re-reads and verifies the exact saved source in the same call.',
|
|
3089
3369
|
'Use this instead of validate_extension_code followed by update_record when editing an existing page/widget/global extension.',
|
|
3090
3370
|
'Call get_extension_theme_contract first when generating or reviewing UI.',
|
|
3091
3371
|
].join(' '), {
|
|
3092
3372
|
id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
|
|
3093
3373
|
name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
|
|
3094
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
3374
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
3095
3375
|
description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
|
|
3096
3376
|
isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
|
|
3097
3377
|
version: z.string().optional().describe('Optional extension version. Omit to preserve.'),
|
|
3378
|
+
expectedSha256: z.string().optional().describe('Optional SHA-256 of current extension code. Rejects stale full replacements.'),
|
|
3379
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern. Enforces deterministic layout policy before saving.'),
|
|
3098
3380
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
3099
3381
|
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3100
3382
|
}, async (input) => jsonText(await updateExtensionCode(ENFYRA_API_URL, input)));
|
|
@@ -3104,7 +3386,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3104
3386
|
'For edits that temporarily unbalance Vue tags or slots, pass patches=[{search,replace},...] so all patches are applied in memory, then the final SFC is validated and saved atomically when apply=true.',
|
|
3105
3387
|
'Default searchMode="exact"; use searchMode="whitespace" only when indentation/newline variation is the problem.',
|
|
3106
3388
|
'Default replaceAll=false requires exactly one match; set replaceAll=true only after preview confirms the match count.',
|
|
3107
|
-
'It hash-checks the current code, validates with /enfyra_extension/preview,
|
|
3389
|
+
'It hash-checks the current code, validates with /enfyra_extension/preview, saves only when apply=true, then re-reads and verifies the exact saved source.',
|
|
3108
3390
|
'Default apply=false returns a preview and nextStep input.',
|
|
3109
3391
|
].join(' '), {
|
|
3110
3392
|
id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
|
|
@@ -3119,11 +3401,12 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3119
3401
|
searchMode: z.enum(['exact', 'whitespace']).optional().default('exact').describe('Patch matching mode. Use whitespace only for indentation/newline variation.'),
|
|
3120
3402
|
replaceAll: z.boolean().optional().default(false).describe('Patch replace-all mode. false requires exactly one match for this patch.'),
|
|
3121
3403
|
})).optional().describe('Atomic multi-patch list. Patches apply sequentially in memory and only the final SFC is validated/saved when apply=true. Use this for slot/tag pairs that would be invalid as intermediate states.'),
|
|
3122
|
-
expectedSha256: z.string().optional().describe('
|
|
3404
|
+
expectedSha256: z.string().optional().describe('SHA-256 of current extension code from preview/inspect. Required when apply=true and rejects stale patches.'),
|
|
3123
3405
|
apply: z.boolean().optional().default(false).describe('Preview by default. Set true to validate and save.'),
|
|
3124
3406
|
description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
|
|
3125
3407
|
isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
|
|
3126
3408
|
version: z.string().optional().describe('Optional extension version. Omit to preserve.'),
|
|
3409
|
+
uiPattern: z.enum(['resource_list', 'resource_grid', 'master_detail', 'form', 'custom']).optional().describe('Optional intended UI pattern. Enforces deterministic layout policy before saving.'),
|
|
3127
3410
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
3128
3411
|
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3129
3412
|
}, async (input) => jsonText(await patchExtensionCode(ENFYRA_API_URL, input)));
|
|
@@ -3160,7 +3443,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3160
3443
|
'theme_review',
|
|
3161
3444
|
'review',
|
|
3162
3445
|
]).describe('Which extension UI contract builder/reviewer to run.'),
|
|
3163
|
-
input: z.record(z.any()).optional().default({}).describe('Builder input object. For kind=api_usage, pass { path, resource, method? }; for kind=confirm, pass { resource, executeName?, refreshName?, recordName?, idExpression? }; for kind=notify, pass { kind, title, description? }. For kind=theme_classes, pass { intent }. For kind=runtime_review/theme_review/review, pass { code }.'),
|
|
3446
|
+
input: z.record(z.any()).optional().default({}).describe('Builder input object. For kind=api_usage, pass { path, resource, method? }; for kind=confirm, pass { resource, executeName?, refreshName?, recordName?, idExpression? }; for kind=notify, pass { kind, title, description? }. For kind=theme_classes, pass { intent }. For kind=runtime_review/theme_review/review, pass { code, pattern? }, where pattern may be resource_list or resource_grid for deterministic layout policy.'),
|
|
3164
3447
|
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3165
3448
|
}, async ({ kind, input, extensionKnowledgeAckKey }) => {
|
|
3166
3449
|
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
@@ -3238,7 +3521,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3238
3521
|
'Use this before patching or saving generated extension UI when CommonDrawer, CommonModal, UModal, UInput, UTextarea, USelect, or native buttons are involved.',
|
|
3239
3522
|
'This is a static contract review, not a compiler validation; still validate the final SFC before saving.',
|
|
3240
3523
|
].join(' '), {
|
|
3241
|
-
code: z.string().describe('Vue SFC or template snippet to review.'),
|
|
3524
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC or template snippet to review.'),
|
|
3242
3525
|
}, async ({ code }) => jsonText(reviewExtensionUiContract(code)));
|
|
3243
3526
|
server.tool('build_extension_page_shell', [
|
|
3244
3527
|
'Generate page-header and shell-header-action script setup code for Enfyra page extensions.',
|
|
@@ -3412,7 +3695,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3412
3695
|
].join(' '), {
|
|
3413
3696
|
name: z.string().describe('Extension unique name.'),
|
|
3414
3697
|
type: z.enum(['page', 'global', 'widget']).optional().default('page').describe('Extension type. Page extensions need a menu. Global extensions are for shell-wide registration.'),
|
|
3415
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
3698
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
3416
3699
|
menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
|
|
3417
3700
|
menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
|
|
3418
3701
|
menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
|
|
@@ -3556,10 +3839,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3556
3839
|
].join(' '), {
|
|
3557
3840
|
path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
|
|
3558
3841
|
method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
|
|
3559
|
-
sourceCode: z.string().describe('Handler sourceCode for a custom route, which has no main table. Use #secure.table_name or @REPOS.secure.table_name for user-facing explicit-table access. Repository calls are async and reads return result.data. Passing @BODY as create/update data is valid TypeORM-style usage; enforce endpoint-specific owner/tenant/business rules in code. Reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
|
|
3842
|
+
sourceCode: z.string().describe('Handler body sourceCode for a custom route, which has no main table. Do not wrap it in export default/module.exports. Use #secure.table_name or @REPOS.secure.table_name for user-facing explicit-table access. Repository calls are async and reads return result.data. Passing @BODY as create/update data is valid TypeORM-style usage; enforce endpoint-specific owner/tenant/business rules in code. Reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
|
|
3560
3843
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
3561
3844
|
anonymousAccess: z.enum(['public', 'private']).optional().default('private').describe('public adds the method to publicMethods; private removes this method from publicMethods.'),
|
|
3562
|
-
public: z.boolean().optional().describe('Compatibility alias for anonymousAccess. true
|
|
3845
|
+
public: z.preprocess(normalizeStrictBoolean, z.boolean()).optional().describe('Compatibility alias for anonymousAccess. Accepts boolean true/false and exact string "true"/"false"; false means private.'),
|
|
3563
3846
|
roleId: z.union([z.string(), z.number()]).optional().describe('Optional role id for authenticated route permission.'),
|
|
3564
3847
|
roleName: z.string().optional().describe('Optional role name for authenticated route permission, e.g. user.'),
|
|
3565
3848
|
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Optional user id scope for authenticated route permission.'),
|
|
@@ -3585,9 +3868,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
3585
3868
|
].join(' '), {
|
|
3586
3869
|
path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
|
|
3587
3870
|
method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
|
|
3588
|
-
sourceCode: z.string().describe('Handler sourceCode for a custom route, which has no main table. Use #secure.table_name or @REPOS.secure.table_name for user-facing explicit-table access. Repository calls are async and reads return result.data. Passing @BODY as create/update data is valid TypeORM-style usage; enforce endpoint-specific owner/tenant/business rules in code. Reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
|
|
3871
|
+
sourceCode: z.string().describe('Handler body sourceCode for a custom route, which has no main table. Do not wrap it in export default/module.exports. Use #secure.table_name or @REPOS.secure.table_name for user-facing explicit-table access. Repository calls are async and reads return result.data. Passing @BODY as create/update data is valid TypeORM-style usage; enforce endpoint-specific owner/tenant/business rules in code. Reserve trusted repos for intentional field-permission bypass. Do not send compiledCode.'),
|
|
3589
3872
|
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
3590
|
-
public: z.boolean().optional().default(false).describe('When true, the method is added to publicMethods for anonymous access.'),
|
|
3873
|
+
public: z.preprocess(normalizeStrictBoolean, z.boolean()).optional().default(false).describe('When true, the method is added to publicMethods for anonymous access. Exact string "true"/"false" is normalized for weak clients.'),
|
|
3591
3874
|
description: z.string().optional().describe('Route description.'),
|
|
3592
3875
|
timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
|
|
3593
3876
|
overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
|
|
@@ -4299,9 +4582,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
4299
4582
|
})).min(1).describe('Menu order/parent updates, usually the changed siblings from drag-and-drop.'),
|
|
4300
4583
|
globalRulesAckKey: globalRulesAckParam(z),
|
|
4301
4584
|
}, async (input) => jsonText(await reorderMenus(ENFYRA_API_URL, input)));
|
|
4302
|
-
server.tool('ensure_page_extension', 'Business operation: create or update one page extension attached to an existing menu. Validates
|
|
4585
|
+
server.tool('ensure_page_extension', 'Business operation: create or update one page extension attached to an existing menu. Validates before save, then re-reads and verifies the exact saved source and menu wiring. Call get_extension_theme_contract first for UI work.', {
|
|
4303
4586
|
name: z.string().describe('Extension unique name.'),
|
|
4304
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
4587
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
4305
4588
|
menuId: z.union([z.string(), z.number()]).describe('Existing menu id for this page extension.'),
|
|
4306
4589
|
description: z.string().optional().describe('Extension description.'),
|
|
4307
4590
|
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
@@ -4312,9 +4595,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
4312
4595
|
action: 'page_extension_ensured',
|
|
4313
4596
|
extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
|
|
4314
4597
|
}));
|
|
4315
|
-
server.tool('ensure_global_extension', 'Business operation: create or update one global shell extension. Validates
|
|
4598
|
+
server.tool('ensure_global_extension', 'Business operation: create or update one global shell extension. Validates before save, rejects menu coupling, then re-reads and verifies the exact saved source. Call get_extension_theme_contract first for UI work.', {
|
|
4316
4599
|
name: z.string().describe('Extension unique name.'),
|
|
4317
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
4600
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
4318
4601
|
description: z.string().optional().describe('Extension description.'),
|
|
4319
4602
|
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
4320
4603
|
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
@@ -4324,9 +4607,9 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
|
4324
4607
|
action: 'global_extension_ensured',
|
|
4325
4608
|
extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'global' }),
|
|
4326
4609
|
}));
|
|
4327
|
-
server.tool('ensure_widget_extension', 'Business operation: create or update one widget extension. Validates
|
|
4610
|
+
server.tool('ensure_widget_extension', 'Business operation: create or update one widget extension. Validates before save, rejects menu coupling, then re-reads and verifies the exact saved source. Call get_extension_theme_contract first for UI work.', {
|
|
4328
4611
|
name: z.string().describe('Extension unique name.'),
|
|
4329
|
-
code: z.string().describe('Vue SFC extension code.'),
|
|
4612
|
+
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC extension code. Raw source is preferred; a fully JSON-escaped one-line SFC is normalized for weak clients.'),
|
|
4330
4613
|
description: z.string().optional().describe('Extension description.'),
|
|
4331
4614
|
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
4332
4615
|
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|