@enfyra/mcp-server 0.1.69 → 0.1.71
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 -1
- package/dist/lib/config-local-adapters.d.ts +18 -0
- package/dist/lib/config-local-adapters.js +271 -0
- package/dist/lib/config-local-adapters.js.map +1 -0
- package/dist/lib/config-local-contracts.d.ts +28 -0
- package/dist/lib/config-local-contracts.js +200 -0
- package/dist/lib/config-local-contracts.js.map +1 -0
- package/dist/lib/config-local-prompts.d.ts +10 -0
- package/dist/lib/config-local-prompts.js +185 -0
- package/dist/lib/config-local-prompts.js.map +1 -0
- package/dist/lib/config-local-types.d.ts +23 -0
- package/dist/lib/config-local-types.js +2 -0
- package/dist/lib/config-local-types.js.map +1 -0
- package/dist/lib/config-local.js +3 -649
- package/dist/lib/config-local.js.map +1 -1
- package/dist/lib/discovery-tools.d.ts +4 -0
- package/dist/lib/discovery-tools.js +546 -0
- package/dist/lib/discovery-tools.js.map +1 -0
- package/dist/lib/dynamic-repository-builder.d.ts +1 -1
- package/dist/lib/dynamic-script-context-contract.d.ts +100 -0
- package/dist/lib/dynamic-script-context-contract.js +163 -0
- package/dist/lib/dynamic-script-context-contract.js.map +1 -0
- package/dist/lib/enfyra-mcp-server.d.ts +3 -0
- package/dist/lib/enfyra-mcp-server.js +79 -0
- package/dist/lib/enfyra-mcp-server.js.map +1 -0
- package/dist/lib/enfyra-tool-logic.d.ts +7 -0
- package/dist/lib/enfyra-tool-logic.js +8 -0
- package/dist/lib/enfyra-tool-logic.js.map +1 -0
- package/dist/lib/enfyra-tool-types.d.ts +27 -0
- package/dist/lib/enfyra-tool-types.js +2 -0
- package/dist/lib/enfyra-tool-types.js.map +1 -0
- package/dist/lib/extension-api-builders.d.ts +17 -0
- package/dist/lib/extension-api-builders.js +235 -0
- package/dist/lib/extension-api-builders.js.map +1 -0
- package/dist/lib/extension-component-builders.d.ts +87 -0
- package/dist/lib/extension-component-builders.js +623 -0
- package/dist/lib/extension-component-builders.js.map +1 -0
- package/dist/lib/extension-contract-review.d.ts +26 -0
- package/dist/lib/extension-contract-review.js +187 -0
- package/dist/lib/extension-contract-review.js.map +1 -0
- package/dist/lib/extension-theme-contract.d.ts +63 -0
- package/dist/lib/extension-theme-contract.js +417 -0
- package/dist/lib/extension-theme-contract.js.map +1 -0
- package/dist/lib/identity-tools.d.ts +4 -0
- package/dist/lib/identity-tools.js +46 -0
- package/dist/lib/identity-tools.js.map +1 -0
- package/dist/lib/log-tools.d.ts +4 -0
- package/dist/lib/log-tools.js +65 -0
- package/dist/lib/log-tools.js.map +1 -0
- package/dist/lib/mcp-example-connect.d.ts +9 -0
- package/dist/lib/mcp-example-connect.js +442 -0
- package/dist/lib/mcp-example-connect.js.map +1 -0
- package/dist/lib/mcp-example-extensions.d.ts +9 -0
- package/dist/lib/mcp-example-extensions.js +729 -0
- package/dist/lib/mcp-example-extensions.js.map +1 -0
- package/dist/lib/mcp-example-files.d.ts +9 -0
- package/dist/lib/mcp-example-files.js +51 -0
- package/dist/lib/mcp-example-files.js.map +1 -0
- package/dist/lib/mcp-example-flows.d.ts +9 -0
- package/dist/lib/mcp-example-flows.js +76 -0
- package/dist/lib/mcp-example-flows.js.map +1 -0
- package/dist/lib/mcp-example-graphql.d.ts +9 -0
- package/dist/lib/mcp-example-graphql.js +57 -0
- package/dist/lib/mcp-example-graphql.js.map +1 -0
- package/dist/lib/mcp-example-handlers-hooks.d.ts +9 -0
- package/dist/lib/mcp-example-handlers-hooks.js +328 -0
- package/dist/lib/mcp-example-handlers-hooks.js.map +1 -0
- package/dist/lib/mcp-example-oauth-setup.d.ts +9 -0
- package/dist/lib/mcp-example-oauth-setup.js +41 -0
- package/dist/lib/mcp-example-oauth-setup.js.map +1 -0
- package/dist/lib/mcp-example-permissions-rls.d.ts +9 -0
- package/dist/lib/mcp-example-permissions-rls.js +210 -0
- package/dist/lib/mcp-example-permissions-rls.js.map +1 -0
- package/dist/lib/mcp-example-queries-deep.d.ts +9 -0
- package/dist/lib/mcp-example-queries-deep.js +220 -0
- package/dist/lib/mcp-example-queries-deep.js.map +1 -0
- package/dist/lib/mcp-example-schema-relations.d.ts +9 -0
- package/dist/lib/mcp-example-schema-relations.js +148 -0
- package/dist/lib/mcp-example-schema-relations.js.map +1 -0
- package/dist/lib/mcp-example-websocket.d.ts +9 -0
- package/dist/lib/mcp-example-websocket.js +103 -0
- package/dist/lib/mcp-example-websocket.js.map +1 -0
- package/dist/lib/mcp-examples.d.ts +5 -5
- package/dist/lib/mcp-examples.js +22 -2394
- package/dist/lib/mcp-examples.js.map +1 -1
- package/dist/lib/method-tools.d.ts +4 -0
- package/dist/lib/method-tools.js +217 -0
- package/dist/lib/method-tools.js.map +1 -0
- package/dist/lib/package-tools.d.ts +4 -0
- package/dist/lib/package-tools.js +98 -0
- package/dist/lib/package-tools.js.map +1 -0
- package/dist/lib/platform-data-operations.d.ts +24 -0
- package/dist/lib/platform-data-operations.js +71 -0
- package/dist/lib/platform-data-operations.js.map +1 -0
- package/dist/lib/platform-endpoint-workflow.d.ts +42 -0
- package/dist/lib/platform-endpoint-workflow.js +382 -0
- package/dist/lib/platform-endpoint-workflow.js.map +1 -0
- package/dist/lib/platform-extension-source.d.ts +487 -0
- package/dist/lib/platform-extension-source.js +524 -0
- package/dist/lib/platform-extension-source.js.map +1 -0
- package/dist/lib/platform-extension-tools.d.ts +1 -0
- package/dist/lib/platform-extension-tools.js +416 -0
- package/dist/lib/platform-extension-tools.js.map +1 -0
- package/dist/lib/platform-extension-ui.d.ts +4 -0
- package/dist/lib/platform-extension-ui.js +5 -0
- package/dist/lib/platform-extension-ui.js.map +1 -0
- package/dist/lib/platform-extension-workflow.d.ts +54 -0
- package/dist/lib/platform-extension-workflow.js +194 -0
- package/dist/lib/platform-extension-workflow.js.map +1 -0
- package/dist/lib/platform-flow-operations.d.ts +554 -0
- package/dist/lib/platform-flow-operations.js +317 -0
- package/dist/lib/platform-flow-operations.js.map +1 -0
- package/dist/lib/platform-flow-tools.d.ts +1 -0
- package/dist/lib/platform-flow-tools.js +248 -0
- package/dist/lib/platform-flow-tools.js.map +1 -0
- package/dist/lib/platform-operation-logic.d.ts +10 -0
- package/dist/lib/platform-operation-logic.js +11 -0
- package/dist/lib/platform-operation-logic.js.map +1 -0
- package/dist/lib/platform-operation-tools.d.ts +1 -324
- package/dist/lib/platform-operation-tools.js +13 -4620
- package/dist/lib/platform-operation-tools.js.map +1 -1
- package/dist/lib/platform-operation-types.d.ts +46 -0
- package/dist/lib/platform-operation-types.js +2 -0
- package/dist/lib/platform-operation-types.js.map +1 -0
- package/dist/lib/platform-policy-tools.d.ts +1 -0
- package/dist/lib/platform-policy-tools.js +248 -0
- package/dist/lib/platform-policy-tools.js.map +1 -0
- package/dist/lib/platform-resource-operations.d.ts +124 -0
- package/dist/lib/platform-resource-operations.js +109 -0
- package/dist/lib/platform-resource-operations.js.map +1 -0
- package/dist/lib/platform-resource-tools.d.ts +1 -0
- package/dist/lib/platform-resource-tools.js +70 -0
- package/dist/lib/platform-resource-tools.js.map +1 -0
- package/dist/lib/platform-route-operations.d.ts +222 -0
- package/dist/lib/platform-route-operations.js +290 -0
- package/dist/lib/platform-route-operations.js.map +1 -0
- package/dist/lib/platform-route-tools.d.ts +1 -0
- package/dist/lib/platform-route-tools.js +307 -0
- package/dist/lib/platform-route-tools.js.map +1 -0
- package/dist/lib/platform-shared-operations.d.ts +7 -0
- package/dist/lib/platform-shared-operations.js +4 -0
- package/dist/lib/platform-shared-operations.js.map +1 -0
- package/dist/lib/platform-websocket-tools.d.ts +1 -0
- package/dist/lib/platform-websocket-tools.js +70 -0
- package/dist/lib/platform-websocket-tools.js.map +1 -0
- package/dist/lib/record-tools.d.ts +4 -0
- package/dist/lib/record-tools.js +418 -0
- package/dist/lib/record-tools.js.map +1 -0
- package/dist/lib/required-knowledge.d.ts +1 -1
- package/dist/lib/required-knowledge.js +3 -2
- package/dist/lib/required-knowledge.js.map +1 -1
- package/dist/lib/route-access-tools.d.ts +4 -0
- package/dist/lib/route-access-tools.js +172 -0
- package/dist/lib/route-access-tools.js.map +1 -0
- package/dist/lib/route-definition-tools.d.ts +4 -0
- package/dist/lib/route-definition-tools.js +285 -0
- package/dist/lib/route-definition-tools.js.map +1 -0
- package/dist/lib/route-inspection-tools.d.ts +4 -0
- package/dist/lib/route-inspection-tools.js +319 -0
- package/dist/lib/route-inspection-tools.js.map +1 -0
- package/dist/lib/route-tools.d.ts +1 -0
- package/dist/lib/route-tools.js +9 -0
- package/dist/lib/route-tools.js.map +1 -0
- package/dist/lib/runtime-zone-registry.d.ts +27 -0
- package/dist/lib/runtime-zone-registry.js +81 -0
- package/dist/lib/runtime-zone-registry.js.map +1 -0
- package/dist/lib/runtime-zone-search.d.ts +303 -0
- package/dist/lib/runtime-zone-search.js +596 -0
- package/dist/lib/runtime-zone-search.js.map +1 -0
- package/dist/lib/runtime-zone-tools.d.ts +2 -307
- package/dist/lib/runtime-zone-tools.js +2 -674
- package/dist/lib/runtime-zone-tools.js.map +1 -1
- package/dist/lib/schema-column-tools.d.ts +3 -0
- package/dist/lib/schema-column-tools.js +80 -0
- package/dist/lib/schema-column-tools.js.map +1 -0
- package/dist/lib/schema-mutation-coordinator.d.ts +28 -0
- package/dist/lib/schema-mutation-coordinator.js +154 -0
- package/dist/lib/schema-mutation-coordinator.js.map +1 -0
- package/dist/lib/schema-relation-contracts.d.ts +39 -0
- package/dist/lib/schema-relation-contracts.js +160 -0
- package/dist/lib/schema-relation-contracts.js.map +1 -0
- package/dist/lib/schema-relation-tools.d.ts +3 -0
- package/dist/lib/schema-relation-tools.js +61 -0
- package/dist/lib/schema-relation-tools.js.map +1 -0
- package/dist/lib/schema-table-contracts.d.ts +28 -0
- package/dist/lib/schema-table-contracts.js +263 -0
- package/dist/lib/schema-table-contracts.js.map +1 -0
- package/dist/lib/schema-table-tools.d.ts +3 -0
- package/dist/lib/schema-table-tools.js +297 -0
- package/dist/lib/schema-table-tools.js.map +1 -0
- package/dist/lib/schema-tool-operations.d.ts +150 -0
- package/dist/lib/schema-tool-operations.js +468 -0
- package/dist/lib/schema-tool-operations.js.map +1 -0
- package/dist/lib/schema-tool-types.d.ts +38 -0
- package/dist/lib/schema-tool-types.js +2 -0
- package/dist/lib/schema-tool-types.js.map +1 -0
- package/dist/lib/schema-verification.d.ts +14 -0
- package/dist/lib/schema-verification.js +105 -0
- package/dist/lib/schema-verification.js.map +1 -0
- package/dist/lib/script-tools.d.ts +4 -0
- package/dist/lib/script-tools.js +127 -0
- package/dist/lib/script-tools.js.map +1 -0
- package/dist/lib/system-tools.d.ts +4 -0
- package/dist/lib/system-tools.js +41 -0
- package/dist/lib/system-tools.js.map +1 -0
- package/dist/lib/table-tool-logic.d.ts +5 -0
- package/dist/lib/table-tool-logic.js +6 -0
- package/dist/lib/table-tool-logic.js.map +1 -0
- package/dist/lib/table-tools.d.ts +1 -84
- package/dist/lib/table-tools.js +7 -1522
- package/dist/lib/table-tools.js.map +1 -1
- package/dist/lib/tool-catalog.d.ts +2 -1
- package/dist/lib/tool-catalog.js +23 -2
- package/dist/lib/tool-catalog.js.map +1 -1
- package/dist/lib/tool-metadata-operations.d.ts +27 -0
- package/dist/lib/tool-metadata-operations.js +93 -0
- package/dist/lib/tool-metadata-operations.js.map +1 -0
- package/dist/lib/tool-permission-profile.d.ts +37 -0
- package/dist/lib/tool-permission-profile.js +106 -0
- package/dist/lib/tool-permission-profile.js.map +1 -0
- package/dist/lib/tool-record-operations.d.ts +102 -0
- package/dist/lib/tool-record-operations.js +260 -0
- package/dist/lib/tool-record-operations.js.map +1 -0
- package/dist/lib/tool-route-inspection.d.ts +47 -0
- package/dist/lib/tool-route-inspection.js +168 -0
- package/dist/lib/tool-route-inspection.js.map +1 -0
- package/dist/lib/tool-routing.d.ts +3 -417
- package/dist/lib/tool-routing.js +3 -1038
- package/dist/lib/tool-routing.js.map +1 -1
- package/dist/lib/tool-runtime-config.d.ts +33 -0
- package/dist/lib/tool-runtime-config.js +237 -0
- package/dist/lib/tool-runtime-config.js.map +1 -0
- package/dist/lib/tool-script-operations.d.ts +34 -0
- package/dist/lib/tool-script-operations.js +153 -0
- package/dist/lib/tool-script-operations.js.map +1 -0
- package/dist/lib/workflow-definitions.d.ts +366 -0
- package/dist/lib/workflow-definitions.js +703 -0
- package/dist/lib/workflow-definitions.js.map +1 -0
- package/dist/lib/workflow-planner.d.ts +45 -0
- package/dist/lib/workflow-planner.js +338 -0
- package/dist/lib/workflow-planner.js.map +1 -0
- package/dist/lib/workflow-types.d.ts +39 -0
- package/dist/lib/workflow-types.js +2 -0
- package/dist/lib/workflow-types.js.map +1 -0
- package/dist/mcp-server-entry.d.ts +0 -3
- package/dist/mcp-server-entry.js +2 -3249
- package/dist/mcp-server-entry.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,4623 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { normalizeEscapedVueSource, normalizeStrictBoolean, } from './tool-input-normalization.js';
|
|
9
|
-
import { analyzeExtensionSfc, extensionElementAttributeValue, extensionElementHasAttribute, } from './extension-sfc-analyzer.js';
|
|
10
|
-
import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAck, assertGlobalRulesAck, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './required-knowledge.js';
|
|
11
|
-
const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
|
|
12
|
-
'CommonDrawer',
|
|
13
|
-
'CommonModal',
|
|
14
|
-
'EmptyState',
|
|
15
|
-
'FormEditor',
|
|
16
|
-
'FormEditorLazy',
|
|
17
|
-
'NuxtLink',
|
|
18
|
-
'PermissionGate',
|
|
19
|
-
'UBadge',
|
|
20
|
-
'UButton',
|
|
21
|
-
'UCheckbox',
|
|
22
|
-
'UDropdownMenu',
|
|
23
|
-
'UForm',
|
|
24
|
-
'UFormField',
|
|
25
|
-
'UIcon',
|
|
26
|
-
'UInput',
|
|
27
|
-
'UInputMenu',
|
|
28
|
-
'UInputNumber',
|
|
29
|
-
'UInputTags',
|
|
30
|
-
'UInputTime',
|
|
31
|
-
'UInputDate',
|
|
32
|
-
'UModal',
|
|
33
|
-
'USelect',
|
|
34
|
-
'USelectMenu',
|
|
35
|
-
'USkeleton',
|
|
36
|
-
'USwitch',
|
|
37
|
-
'UTabs',
|
|
38
|
-
'UTextarea',
|
|
39
|
-
'UTooltip',
|
|
40
|
-
'Widget',
|
|
41
|
-
];
|
|
42
|
-
const AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE = new Map(AUTO_INJECTED_EXTENSION_COMPONENT_TAGS.map((tag) => [tag.toLowerCase(), tag]));
|
|
43
|
-
const FULL_WIDTH_EXTENSION_FIELD_TAGS = [
|
|
44
|
-
'UInput',
|
|
45
|
-
'UTextarea',
|
|
46
|
-
'USelect',
|
|
47
|
-
'USelectMenu',
|
|
48
|
-
'UInputMenu',
|
|
49
|
-
'UInputNumber',
|
|
50
|
-
'UInputTags',
|
|
51
|
-
'UInputTime',
|
|
52
|
-
'UInputDate',
|
|
53
|
-
];
|
|
54
|
-
const FULL_WIDTH_EXTENSION_FIELD_PATTERN = new RegExp(`<(${FULL_WIDTH_EXTENSION_FIELD_TAGS.join('|')})(\\s[^<>]*?)(\\/?)>`, 'g');
|
|
55
|
-
function unwrapData(result) {
|
|
56
|
-
return Array.isArray(result?.data) ? result.data : [];
|
|
57
|
-
}
|
|
58
|
-
function getId(record) {
|
|
59
|
-
return record?.id ?? record?._id ?? null;
|
|
60
|
-
}
|
|
61
|
-
function refId(value) {
|
|
62
|
-
return typeof value === 'object' && value !== null ? getId(value) : value;
|
|
63
|
-
}
|
|
64
|
-
function sameId(a, b) {
|
|
65
|
-
if (a === null || a === undefined || b === null || b === undefined)
|
|
66
|
-
return false;
|
|
67
|
-
return String(a) === String(b);
|
|
68
|
-
}
|
|
69
|
-
function firstDataRecord(result) {
|
|
70
|
-
return Array.isArray(result?.data) ? result.data[0] : result;
|
|
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
|
-
}
|
|
92
|
-
function normalizeRestPath(path) {
|
|
93
|
-
if (!path)
|
|
94
|
-
return '/';
|
|
95
|
-
if (/^https?:\/\//i.test(path)) {
|
|
96
|
-
throw new Error('Only Enfyra API paths are allowed, not full external URLs.');
|
|
97
|
-
}
|
|
98
|
-
return path.startsWith('/') ? path : `/${path}`;
|
|
99
|
-
}
|
|
100
|
-
function normalizeMethodName(method) {
|
|
101
|
-
const value = String(method || '').trim().toUpperCase();
|
|
102
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(value)) {
|
|
103
|
-
throw new Error(`Invalid method "${method}". Method names must start with A-Z and contain only A-Z, 0-9, or underscore.`);
|
|
104
|
-
}
|
|
105
|
-
return value;
|
|
106
|
-
}
|
|
107
|
-
function methodNamesFromRecords(records, methodIdNameMap) {
|
|
108
|
-
return (records || [])
|
|
109
|
-
.map((method) => method?.name || methodIdNameMap[String(getId(method))] || null)
|
|
110
|
-
.filter(Boolean)
|
|
111
|
-
.map(normalizeMethodName);
|
|
112
|
-
}
|
|
113
|
-
function uniqueMethodNames(names) {
|
|
114
|
-
return Array.from(new Set((names || []).map((name) => normalizeMethodName(name))));
|
|
115
|
-
}
|
|
116
|
-
function resolveMethodRefs(methodMap, names) {
|
|
117
|
-
return uniqueMethodNames(names).map((name) => {
|
|
118
|
-
const id = methodMap[name];
|
|
119
|
-
if (!id)
|
|
120
|
-
throw new Error(`Unknown method "${name}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
|
|
121
|
-
return { id };
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
function mergeMethods(existing, requested, mode) {
|
|
125
|
-
const existingNames = uniqueMethodNames(existing);
|
|
126
|
-
const requestedNames = uniqueMethodNames(requested);
|
|
127
|
-
if (mode === 'replace')
|
|
128
|
-
return requestedNames;
|
|
129
|
-
if (mode === 'remove')
|
|
130
|
-
return existingNames.filter((method) => !requestedNames.includes(method));
|
|
131
|
-
return uniqueMethodNames([...existingNames, ...requestedNames]);
|
|
132
|
-
}
|
|
133
|
-
async function fetchAll(apiUrl, path) {
|
|
134
|
-
return unwrapData(await fetchAPI(apiUrl, path));
|
|
135
|
-
}
|
|
136
|
-
async function getMethodContext(apiUrl) {
|
|
137
|
-
const methods = await fetchAll(apiUrl, '/enfyra_method?limit=0&fields=id,_id,name');
|
|
138
|
-
const methodMap = {};
|
|
139
|
-
const methodIdNameMap = {};
|
|
140
|
-
for (const method of methods) {
|
|
141
|
-
if (!method?.name)
|
|
142
|
-
continue;
|
|
143
|
-
const name = normalizeMethodName(method.name);
|
|
144
|
-
const id = getId(method);
|
|
145
|
-
methodMap[name] = id;
|
|
146
|
-
methodIdNameMap[String(id)] = name;
|
|
147
|
-
}
|
|
148
|
-
return { methods, methodMap, methodIdNameMap };
|
|
149
|
-
}
|
|
150
|
-
async function reloadRoutes(apiUrl) {
|
|
151
|
-
try {
|
|
152
|
-
const result = await fetchAPI(apiUrl, '/admin/reload/routes', { method: 'POST' });
|
|
153
|
-
return { attempted: true, succeeded: true, result };
|
|
154
|
-
}
|
|
155
|
-
catch (error) {
|
|
156
|
-
return { attempted: true, succeeded: false, error: error?.message || String(error) };
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
async function resolveRoute(apiUrl, { path, routeId }) {
|
|
160
|
-
if (!path && !routeId)
|
|
161
|
-
throw new Error('Provide path or routeId.');
|
|
162
|
-
if (path && routeId)
|
|
163
|
-
throw new Error('Provide path or routeId, not both.');
|
|
164
|
-
const routes = await fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
|
|
165
|
-
const normalizedPath = path ? normalizeRestPath(path) : null;
|
|
166
|
-
const route = routes.find((item) => (routeId ? sameId(getId(item), routeId) : item.path === normalizedPath));
|
|
167
|
-
if (!route)
|
|
168
|
-
throw new Error(`Route not found: ${routeId || normalizedPath}`);
|
|
169
|
-
return { route, routes, path: route.path };
|
|
170
|
-
}
|
|
171
|
-
async function updateRouteMethods(apiUrl, { path, routeId, methods, mode, isEnabled, globalRulesAckKey }) {
|
|
172
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
173
|
-
const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
|
|
174
|
-
resolveRoute(apiUrl, { path, routeId }),
|
|
175
|
-
getMethodContext(apiUrl),
|
|
176
|
-
]);
|
|
177
|
-
const existingAvailable = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
|
|
178
|
-
const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
|
|
179
|
-
const finalAvailable = mergeMethods(existingAvailable, methods, mode);
|
|
180
|
-
const finalPublic = existingPublic.filter((method) => finalAvailable.includes(method));
|
|
181
|
-
const body = {
|
|
182
|
-
availableMethods: resolveMethodRefs(methodMap, finalAvailable),
|
|
183
|
-
publicMethods: resolveMethodRefs(methodMap, finalPublic),
|
|
184
|
-
};
|
|
185
|
-
if (isEnabled !== undefined)
|
|
186
|
-
body.isEnabled = isEnabled;
|
|
187
|
-
const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
|
|
188
|
-
method: 'PATCH',
|
|
189
|
-
body: JSON.stringify(body),
|
|
190
|
-
});
|
|
191
|
-
const routeReload = await reloadRoutes(apiUrl);
|
|
192
|
-
return {
|
|
193
|
-
action: 'route_methods_updated',
|
|
194
|
-
route: { id: getId(route), path: route.path },
|
|
195
|
-
before: { availableMethods: existingAvailable, publicMethods: existingPublic },
|
|
196
|
-
after: { availableMethods: finalAvailable, publicMethods: finalPublic },
|
|
197
|
-
result,
|
|
198
|
-
routeReload,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
async function updateRoutePublicMethods(apiUrl, { path, routeId, methods, mode, globalRulesAckKey }) {
|
|
202
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
203
|
-
const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
|
|
204
|
-
resolveRoute(apiUrl, { path, routeId }),
|
|
205
|
-
getMethodContext(apiUrl),
|
|
206
|
-
]);
|
|
207
|
-
const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
|
|
208
|
-
const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
|
|
209
|
-
const requestedMethods = uniqueMethodNames(methods);
|
|
210
|
-
const unavailable = requestedMethods.filter((method) => !availableMethods.includes(method));
|
|
211
|
-
if (unavailable.length > 0) {
|
|
212
|
-
throw new Error(`Cannot make unavailable route method(s) public: ${unavailable.join(', ')}. First call add_route_methods to add them to availableMethods.`);
|
|
213
|
-
}
|
|
214
|
-
const finalPublic = mergeMethods(existingPublic, requestedMethods, mode);
|
|
215
|
-
const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
|
|
216
|
-
method: 'PATCH',
|
|
217
|
-
body: JSON.stringify({ publicMethods: resolveMethodRefs(methodMap, finalPublic) }),
|
|
218
|
-
});
|
|
219
|
-
const routeReload = await reloadRoutes(apiUrl);
|
|
220
|
-
return {
|
|
221
|
-
action: 'route_public_methods_updated',
|
|
222
|
-
route: { id: getId(route), path: route.path },
|
|
223
|
-
availableMethods,
|
|
224
|
-
publicMethodsBefore: existingPublic,
|
|
225
|
-
publicMethodsAfter: finalPublic,
|
|
226
|
-
publicAccess: finalPublic.length > 0 ? 'Methods listed in publicMethods bypass auth/RoleGuard.' : 'No public methods remain on this route.',
|
|
227
|
-
result,
|
|
228
|
-
routeReload,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
async function setRouteEnabled(apiUrl, { path, routeId, isEnabled, globalRulesAckKey }) {
|
|
232
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
233
|
-
const { route } = await resolveRoute(apiUrl, { path, routeId });
|
|
234
|
-
const before = route?.isEnabled !== false;
|
|
235
|
-
if (before === isEnabled) {
|
|
236
|
-
return {
|
|
237
|
-
action: isEnabled ? 'route_already_enabled' : 'route_already_disabled',
|
|
238
|
-
route: { id: getId(route), path: route.path },
|
|
239
|
-
before: { isEnabled: before },
|
|
240
|
-
after: { isEnabled },
|
|
241
|
-
runtimeBehavior: isEnabled ? 'Enabled routes are registered at runtime.' : 'Disabled routes are not registered at runtime and return 404.',
|
|
242
|
-
routeReload: { attempted: false, succeeded: true, reason: 'No route lifecycle change was needed.' },
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
|
|
246
|
-
method: 'PATCH',
|
|
247
|
-
body: JSON.stringify({ isEnabled }),
|
|
248
|
-
});
|
|
249
|
-
const routeReload = await reloadRoutes(apiUrl);
|
|
250
|
-
return {
|
|
251
|
-
action: isEnabled ? 'route_enabled' : 'route_disabled',
|
|
252
|
-
route: { id: getId(route), path: route.path },
|
|
253
|
-
before: { isEnabled: before },
|
|
254
|
-
after: { isEnabled },
|
|
255
|
-
runtimeBehavior: isEnabled ? 'The route should now be registered at runtime.' : 'The route should now return 404 because disabled routes are not registered at runtime.',
|
|
256
|
-
result,
|
|
257
|
-
routeReload,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
async function fetchRouteDependencies(apiUrl, routeId) {
|
|
261
|
-
const routeFilter = filterQuery({ route: { id: { _eq: routeId } } });
|
|
262
|
-
const routeIdFilter = filterQuery({ routeId: { _eq: routeId } });
|
|
263
|
-
const [handlers, permissions, preHooks, postHooks, guards] = await Promise.all([
|
|
264
|
-
fetchAll(apiUrl, `/enfyra_route_handler?filter=${routeIdFilter}&fields=id,_id,routeId,method.name&limit=0`),
|
|
265
|
-
fetchAll(apiUrl, `/enfyra_route_permission?filter=${routeFilter}&fields=id,_id,route.id,role.name,isEnabled&limit=0`),
|
|
266
|
-
fetchAll(apiUrl, `/enfyra_pre_hook?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
|
|
267
|
-
fetchAll(apiUrl, `/enfyra_post_hook?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
|
|
268
|
-
fetchAll(apiUrl, `/enfyra_guard?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
|
|
269
|
-
]);
|
|
270
|
-
return { handlers, permissions, preHooks, postHooks, guards };
|
|
271
|
-
}
|
|
272
|
-
function summarizeRouteDependencies(dependencies) {
|
|
273
|
-
return {
|
|
274
|
-
handlers: dependencies.handlers.map((item) => ({ id: getId(item), method: item?.method?.name || null })),
|
|
275
|
-
permissions: dependencies.permissions.map((item) => ({ id: getId(item), role: item?.role?.name || null, isEnabled: item?.isEnabled !== false })),
|
|
276
|
-
preHooks: dependencies.preHooks.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
|
|
277
|
-
postHooks: dependencies.postHooks.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
|
|
278
|
-
guards: dependencies.guards.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
async function deleteRows(apiUrl, tableName, rows) {
|
|
282
|
-
const deleted = [];
|
|
283
|
-
for (const row of rows) {
|
|
284
|
-
const id = getId(row);
|
|
285
|
-
if (id === null || id === undefined)
|
|
286
|
-
continue;
|
|
287
|
-
await fetchAPI(apiUrl, `/${tableName}/${encodeURIComponent(String(id))}`, { method: 'DELETE' });
|
|
288
|
-
deleted.push(id);
|
|
289
|
-
}
|
|
290
|
-
return deleted;
|
|
291
|
-
}
|
|
292
|
-
async function deleteRoute(apiUrl, { path, routeId, expectedPath, confirm, globalRulesAckKey }) {
|
|
293
|
-
const { route } = await resolveRoute(apiUrl, { path, routeId });
|
|
294
|
-
if (expectedPath && route.path !== normalizeRestPath(expectedPath)) {
|
|
295
|
-
throw new Error(`Route path mismatch: resolved ${route.path}, expected ${normalizeRestPath(expectedPath)}.`);
|
|
296
|
-
}
|
|
297
|
-
const dependencies = await fetchRouteDependencies(apiUrl, getId(route));
|
|
298
|
-
const dependencySummary = summarizeRouteDependencies(dependencies);
|
|
299
|
-
const preview = {
|
|
300
|
-
route: { id: getId(route), path: route.path, isEnabled: route?.isEnabled !== false },
|
|
301
|
-
dependencies: dependencySummary,
|
|
302
|
-
};
|
|
303
|
-
if (!confirm) {
|
|
304
|
-
return {
|
|
305
|
-
action: 'delete_route_preview',
|
|
306
|
-
...preview,
|
|
307
|
-
next: 'Call delete_route again with confirm=true and expectedPath set to this route path to delete the route and related handlers/hooks/permissions/guards.',
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
311
|
-
await deleteRows(apiUrl, 'enfyra_route_handler', dependencies.handlers);
|
|
312
|
-
await deleteRows(apiUrl, 'enfyra_pre_hook', dependencies.preHooks);
|
|
313
|
-
await deleteRows(apiUrl, 'enfyra_post_hook', dependencies.postHooks);
|
|
314
|
-
await deleteRows(apiUrl, 'enfyra_guard', dependencies.guards);
|
|
315
|
-
await deleteRows(apiUrl, 'enfyra_route_permission', dependencies.permissions);
|
|
316
|
-
const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, { method: 'DELETE' });
|
|
317
|
-
const routeReload = await reloadRoutes(apiUrl);
|
|
318
|
-
return {
|
|
319
|
-
action: 'route_deleted',
|
|
320
|
-
...preview,
|
|
321
|
-
deleted: {
|
|
322
|
-
handlers: dependencies.handlers.map(getId).filter((id) => id !== null && id !== undefined),
|
|
323
|
-
permissions: dependencies.permissions.map(getId).filter((id) => id !== null && id !== undefined),
|
|
324
|
-
preHooks: dependencies.preHooks.map(getId).filter((id) => id !== null && id !== undefined),
|
|
325
|
-
postHooks: dependencies.postHooks.map(getId).filter((id) => id !== null && id !== undefined),
|
|
326
|
-
guards: dependencies.guards.map(getId).filter((id) => id !== null && id !== undefined),
|
|
327
|
-
route: getId(route),
|
|
328
|
-
},
|
|
329
|
-
result,
|
|
330
|
-
routeReload,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
async function findHandler(apiUrl, routeId, methodId) {
|
|
334
|
-
const filter = encodeURIComponent(JSON.stringify({
|
|
335
|
-
route: { id: { _eq: routeId } },
|
|
336
|
-
method: { id: { _eq: methodId } },
|
|
337
|
-
}));
|
|
338
|
-
const result = await fetchAPI(apiUrl, `/enfyra_route_handler?filter=${filter}&limit=1&fields=id,_id,route.id,method.id,method.name,sourceCode,scriptLanguage,timeout`);
|
|
339
|
-
return unwrapData(result)[0] || null;
|
|
340
|
-
}
|
|
341
|
-
function jsonText(payload) {
|
|
342
|
-
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
343
|
-
}
|
|
344
|
-
function escapeSingleQuoted(value) {
|
|
345
|
-
return String(value ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
346
|
-
}
|
|
347
|
-
function quoteJsString(value) {
|
|
348
|
-
return `'${escapeSingleQuoted(value)}'`;
|
|
349
|
-
}
|
|
350
|
-
function normalizeVueBodySnippet(body) {
|
|
351
|
-
let code = String(body || '').trim();
|
|
352
|
-
const changes = [];
|
|
353
|
-
code = code.replace(FULL_WIDTH_EXTENSION_FIELD_PATTERN, (full, tag, attrs, slash) => {
|
|
354
|
-
if (/\bdata-compact\b/.test(attrs) || /\bdata-inline\b/.test(attrs))
|
|
355
|
-
return full;
|
|
356
|
-
if (/\bclass=/.test(attrs)) {
|
|
357
|
-
const nextAttrs = attrs.replace(/class="([^"]*)"/, (classMatch, classes) => {
|
|
358
|
-
if (String(classes).split(/\s+/).includes('w-full'))
|
|
359
|
-
return classMatch;
|
|
360
|
-
changes.push(`Added w-full to ${tag}.`);
|
|
361
|
-
return `class="${classes} w-full"`;
|
|
362
|
-
});
|
|
363
|
-
if (nextAttrs !== attrs)
|
|
364
|
-
return `<${tag}${nextAttrs}${slash}>`;
|
|
365
|
-
return full;
|
|
366
|
-
}
|
|
367
|
-
changes.push(`Added class="w-full" to ${tag}.`);
|
|
368
|
-
return `<${tag} class="w-full"${attrs}${slash}>`;
|
|
369
|
-
});
|
|
370
|
-
code = code.replace(/<button(\s[^>]*)?>/g, (full, attrs = '') => {
|
|
371
|
-
if (/\btype=/.test(attrs))
|
|
372
|
-
return full;
|
|
373
|
-
changes.push('Added type="button" to a native button.');
|
|
374
|
-
return `<button type="button"${attrs}>`;
|
|
375
|
-
});
|
|
376
|
-
return { code, changes: Array.from(new Set(changes)) };
|
|
377
|
-
}
|
|
378
|
-
function findMissingFullWidthFieldControls(code) {
|
|
379
|
-
const violations = [];
|
|
380
|
-
for (const template of readTemplateBlocks(code)) {
|
|
381
|
-
let match;
|
|
382
|
-
FULL_WIDTH_EXTENSION_FIELD_PATTERN.lastIndex = 0;
|
|
383
|
-
while ((match = FULL_WIDTH_EXTENSION_FIELD_PATTERN.exec(template))) {
|
|
384
|
-
const [snippet, tag, attrs] = match;
|
|
385
|
-
if (/\bdata-compact\b/.test(attrs) || /\bdata-inline\b/.test(attrs))
|
|
386
|
-
continue;
|
|
387
|
-
const classMatch = attrs.match(/\bclass="([^"]*)"/);
|
|
388
|
-
if (!classMatch || !classMatch[1].split(/\s+/).includes('w-full')) {
|
|
389
|
-
violations.push({ tag, snippet: snippet.length > 160 ? `${snippet.slice(0, 157)}...` : snippet });
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
return violations;
|
|
394
|
-
}
|
|
395
|
-
function indentLines(code, spaces = 2) {
|
|
396
|
-
const pad = ' '.repeat(spaces);
|
|
397
|
-
return String(code || '')
|
|
398
|
-
.split('\n')
|
|
399
|
-
.map((line) => (line.trim() ? `${pad}${line}` : line))
|
|
400
|
-
.join('\n');
|
|
401
|
-
}
|
|
402
|
-
function buildFooterActionObject(action) {
|
|
403
|
-
if (!action)
|
|
404
|
-
return null;
|
|
405
|
-
if (typeof action === 'string')
|
|
406
|
-
return action;
|
|
407
|
-
const entries = [];
|
|
408
|
-
if (action.labelExpression)
|
|
409
|
-
entries.push(`label: ${action.labelExpression}`);
|
|
410
|
-
else if (action.label)
|
|
411
|
-
entries.push(`label: ${quoteJsString(action.label)}`);
|
|
412
|
-
if (action.icon)
|
|
413
|
-
entries.push(`icon: ${quoteJsString(action.icon)}`);
|
|
414
|
-
if (action.loading)
|
|
415
|
-
entries.push(`loading: ${action.loading}`);
|
|
416
|
-
if (action.disabled)
|
|
417
|
-
entries.push(`disabled: ${action.disabled}`);
|
|
418
|
-
if (action.color)
|
|
419
|
-
entries.push(`color: ${quoteJsString(action.color)}`);
|
|
420
|
-
if (action.variant)
|
|
421
|
-
entries.push(`variant: ${quoteJsString(action.variant)}`);
|
|
422
|
-
if (action.tone)
|
|
423
|
-
entries.push(`tone: ${quoteJsString(action.tone)}`);
|
|
424
|
-
if (action.onClick)
|
|
425
|
-
entries.push(`onClick: ${action.onClick}`);
|
|
426
|
-
return `{ ${entries.join(', ')} }`;
|
|
427
|
-
}
|
|
428
|
-
function titleMarkup(title, titleExpression) {
|
|
429
|
-
if (titleExpression)
|
|
430
|
-
return `{{ ${titleExpression} }}`;
|
|
431
|
-
return String(title || 'Untitled');
|
|
432
|
-
}
|
|
433
|
-
export function buildExtensionDrawerSnippet(input) {
|
|
434
|
-
const normalized = normalizeVueBodySnippet(input.body || '');
|
|
435
|
-
const titleContent = titleMarkup(input.title, input.titleExpression);
|
|
436
|
-
const model = input.model || 'drawerOpen';
|
|
437
|
-
const attrs = [
|
|
438
|
-
`v-model="${model}"`,
|
|
439
|
-
`direction="${input.direction || 'right'}"`,
|
|
440
|
-
];
|
|
441
|
-
if (input.nested)
|
|
442
|
-
attrs.push('nested');
|
|
443
|
-
const cancelAction = input.cancelAction === false
|
|
444
|
-
? null
|
|
445
|
-
: buildFooterActionObject(input.cancelAction || { label: 'Cancel', onClick: `() => (${model} = false)` });
|
|
446
|
-
const primaryAction = buildFooterActionObject(input.primaryAction);
|
|
447
|
-
const dangerAction = buildFooterActionObject(input.dangerAction);
|
|
448
|
-
if (cancelAction)
|
|
449
|
-
attrs.push(`:cancel-action="${cancelAction}"`);
|
|
450
|
-
if (primaryAction)
|
|
451
|
-
attrs.push(`:primary-action="${primaryAction}"`);
|
|
452
|
-
if (dangerAction)
|
|
453
|
-
attrs.push(`:danger-action="${dangerAction}"`);
|
|
454
|
-
if (input.footerHint)
|
|
455
|
-
attrs.push(`footer-hint="${escapeSingleQuoted(input.footerHint).replace(/"/g, '"')}"`);
|
|
456
|
-
const snippet = [
|
|
457
|
-
'<CommonDrawer',
|
|
458
|
-
...attrs.map((attr) => ` ${attr}`),
|
|
459
|
-
'>',
|
|
460
|
-
' <template #header>',
|
|
461
|
-
` <h2 class="text-lg font-semibold eapp-text-primary">${titleContent}</h2>`,
|
|
462
|
-
' </template>',
|
|
463
|
-
'',
|
|
464
|
-
' <template #body>',
|
|
465
|
-
indentLines(normalized.code, 4),
|
|
466
|
-
' </template>',
|
|
467
|
-
'</CommonDrawer>',
|
|
468
|
-
].join('\n');
|
|
469
|
-
const warnings = [];
|
|
470
|
-
if (!primaryAction)
|
|
471
|
-
warnings.push('Editing/create drawers usually need primaryAction for Save/Create.');
|
|
472
|
-
return {
|
|
473
|
-
action: 'extension_drawer_built',
|
|
474
|
-
component: 'CommonDrawer',
|
|
475
|
-
snippet,
|
|
476
|
-
normalizedBodyChanges: normalized.changes,
|
|
477
|
-
warnings,
|
|
478
|
-
contract: [
|
|
479
|
-
'Use #header and #body slots; do not use a title prop.',
|
|
480
|
-
'Use primaryAction for Save/Create and dangerAction for destructive edit actions.',
|
|
481
|
-
'Body form controls are normalized to class="w-full" unless intentionally compact.',
|
|
482
|
-
'Native buttons in the body are normalized to type="button".',
|
|
483
|
-
],
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
export function buildExtensionModalSnippet(input) {
|
|
487
|
-
const normalized = normalizeVueBodySnippet(input.body || '');
|
|
488
|
-
const titleContent = titleMarkup(input.title, input.titleExpression);
|
|
489
|
-
const model = input.model || 'modalOpen';
|
|
490
|
-
const tag = input.alias === 'UModal' ? 'UModal' : 'CommonModal';
|
|
491
|
-
const attrs = [`v-model:open="${model}"`];
|
|
492
|
-
const cancelAction = input.cancelAction === false
|
|
493
|
-
? null
|
|
494
|
-
: buildFooterActionObject(input.cancelAction || { label: 'Cancel', onClick: `() => (${model} = false)` });
|
|
495
|
-
const primaryAction = buildFooterActionObject(input.primaryAction);
|
|
496
|
-
const dangerAction = buildFooterActionObject(input.dangerAction);
|
|
497
|
-
if (cancelAction)
|
|
498
|
-
attrs.push(`:cancel-action="${cancelAction}"`);
|
|
499
|
-
if (primaryAction)
|
|
500
|
-
attrs.push(`:primary-action="${primaryAction}"`);
|
|
501
|
-
if (dangerAction)
|
|
502
|
-
attrs.push(`:danger-action="${dangerAction}"`);
|
|
503
|
-
if (input.footerHint)
|
|
504
|
-
attrs.push(`footer-hint="${escapeSingleQuoted(input.footerHint).replace(/"/g, '"')}"`);
|
|
505
|
-
const snippet = [
|
|
506
|
-
`<${tag}`,
|
|
507
|
-
...attrs.map((attr) => ` ${attr}`),
|
|
508
|
-
'>',
|
|
509
|
-
' <template #header>',
|
|
510
|
-
` <h2 class="text-lg font-semibold eapp-text-primary">${titleContent}</h2>`,
|
|
511
|
-
' </template>',
|
|
512
|
-
'',
|
|
513
|
-
' <template #body>',
|
|
514
|
-
indentLines(normalized.code, 4),
|
|
515
|
-
' </template>',
|
|
516
|
-
`</${tag}>`,
|
|
517
|
-
].join('\n');
|
|
518
|
-
const warnings = [];
|
|
519
|
-
if (!primaryAction && !dangerAction)
|
|
520
|
-
warnings.push('Mutation or confirmation modals usually need primaryAction or dangerAction for the final action.');
|
|
521
|
-
return {
|
|
522
|
-
action: 'extension_modal_built',
|
|
523
|
-
component: tag,
|
|
524
|
-
snippet,
|
|
525
|
-
normalizedBodyChanges: normalized.changes,
|
|
526
|
-
warnings,
|
|
527
|
-
contract: [
|
|
528
|
-
'Use v-model:open and #header/#body slots; do not use a title prop.',
|
|
529
|
-
'Use primaryAction for ordinary final actions and dangerAction for destructive confirmation.',
|
|
530
|
-
'Body form controls are normalized to class="w-full" unless intentionally compact.',
|
|
531
|
-
'Native buttons in the body are normalized to type="button".',
|
|
532
|
-
],
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
function jsObjectLiteral(entries) {
|
|
536
|
-
return `{ ${entries.filter(Boolean).join(', ')} }`;
|
|
537
|
-
}
|
|
538
|
-
function jsArrayLiteral(values) {
|
|
539
|
-
return `[${(values || []).map(quoteJsString).join(', ')}]`;
|
|
540
|
-
}
|
|
541
|
-
function attrStaticOrBound(name, value, expression) {
|
|
542
|
-
if (expression)
|
|
543
|
-
return `:${name}="${expression}"`;
|
|
544
|
-
if (value === undefined || value === null || value === '')
|
|
545
|
-
return null;
|
|
546
|
-
return `${name}="${String(value).replace(/"/g, '"')}"`;
|
|
547
|
-
}
|
|
548
|
-
function buildHeaderActionLiteral(action) {
|
|
549
|
-
const bareAssignment = String(action.onClick || '').match(/^\s*\(\s*\)\s*=>\s*\(?\s*([A-Za-z_$][\w$]*)\s*=(?!=)/);
|
|
550
|
-
if (bareAssignment) {
|
|
551
|
-
throw new Error(`Invalid header action onClick: assign ${bareAssignment[1]}.value inside script callbacks, or pass a handler name. Template ref auto-unwrapping does not apply in registry callbacks.`);
|
|
552
|
-
}
|
|
553
|
-
const entries = [
|
|
554
|
-
action.id ? `id: ${quoteJsString(action.id)}` : null,
|
|
555
|
-
action.label ? `label: ${quoteJsString(action.label)}` : null,
|
|
556
|
-
action.icon ? `icon: ${quoteJsString(action.icon)}` : null,
|
|
557
|
-
`color: ${quoteJsString(action.color || 'neutral')}`,
|
|
558
|
-
`variant: ${quoteJsString(action.variant || 'outline')}`,
|
|
559
|
-
action.loading ? `loading: ${action.loading}` : null,
|
|
560
|
-
action.disabled ? `disabled: ${action.disabled}` : null,
|
|
561
|
-
action.to ? `to: ${quoteJsString(action.to)}` : null,
|
|
562
|
-
action.onClick ? `onClick: ${action.onClick}` : null,
|
|
563
|
-
typeof action.order === 'number' ? `order: ${action.order}` : null,
|
|
564
|
-
action.side ? `side: ${quoteJsString(action.side)}` : null,
|
|
565
|
-
];
|
|
566
|
-
return jsObjectLiteral(entries);
|
|
567
|
-
}
|
|
568
|
-
export function buildExtensionPageShellSnippet(input) {
|
|
569
|
-
const title = input.titleExpression || quoteJsString(input.title || 'Untitled');
|
|
570
|
-
const headerEntries = [
|
|
571
|
-
`title: ${title}`,
|
|
572
|
-
input.description ? `description: ${quoteJsString(input.description)}` : null,
|
|
573
|
-
input.leadingIcon ? `leadingIcon: ${quoteJsString(input.leadingIcon)}` : null,
|
|
574
|
-
`gradient: ${quoteJsString(input.gradient || 'none')}`,
|
|
575
|
-
`variant: ${quoteJsString(input.variant || 'minimal')}`,
|
|
576
|
-
];
|
|
577
|
-
const actions = Array.isArray(input.headerActions) ? input.headerActions : [];
|
|
578
|
-
const lines = [
|
|
579
|
-
'const { registerPageHeader } = usePageHeaderRegistry();',
|
|
580
|
-
`registerPageHeader(${jsObjectLiteral(headerEntries)});`,
|
|
581
|
-
];
|
|
582
|
-
if (actions.length) {
|
|
583
|
-
lines.push('const { register: registerHeaderActions } = useHeaderActionRegistry();');
|
|
584
|
-
lines.push(`onMounted(() => {\n registerHeaderActions([\n${actions.map((action) => ` ${buildHeaderActionLiteral(action)}`).join(',\n')}\n ]);\n});`);
|
|
585
|
-
}
|
|
586
|
-
return {
|
|
587
|
-
action: 'extension_page_shell_built',
|
|
588
|
-
snippet: lines.join('\n'),
|
|
589
|
-
contract: [
|
|
590
|
-
'Use usePageHeaderRegistry so the app shell renders the page header.',
|
|
591
|
-
'Use useHeaderActionRegistry for toolbar actions instead of rendering duplicate page headers or local top bars; register dynamic extension actions in onMounted after setup state exists.',
|
|
592
|
-
'Use primary solid only for the main scope action; secondary actions default to neutral outline.',
|
|
593
|
-
],
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
|
-
export function buildExtensionPermissionGateSnippet(input) {
|
|
597
|
-
const normalized = normalizeVueBodySnippet(input.body || '<slot />');
|
|
598
|
-
let condition;
|
|
599
|
-
if (input.condition) {
|
|
600
|
-
condition = input.condition;
|
|
601
|
-
}
|
|
602
|
-
else if (input.route) {
|
|
603
|
-
const methods = Array.isArray(input.methods) && input.methods.length ? input.methods : ['GET'];
|
|
604
|
-
condition = `{ or: [{ route: ${quoteJsString(input.route)}, methods: [${methods.map(quoteJsString).join(', ')}] }] }`;
|
|
605
|
-
}
|
|
606
|
-
else {
|
|
607
|
-
condition = 'null';
|
|
608
|
-
}
|
|
609
|
-
const snippet = [
|
|
610
|
-
`<PermissionGate :condition="${condition}">`,
|
|
611
|
-
indentLines(normalized.code, 2),
|
|
612
|
-
'</PermissionGate>',
|
|
613
|
-
].join('\n');
|
|
614
|
-
return {
|
|
615
|
-
action: 'extension_permission_gate_built',
|
|
616
|
-
component: 'PermissionGate',
|
|
617
|
-
snippet,
|
|
618
|
-
normalizedBodyChanges: normalized.changes,
|
|
619
|
-
warnings: condition === 'null' ? ['No condition/route was provided. PermissionGate with null condition permits the slot.'] : [],
|
|
620
|
-
contract: [
|
|
621
|
-
'PermissionGate is only operator UX; backend route permissions and owner checks remain authoritative.',
|
|
622
|
-
'PermissionGate renders its slot directly and should not be used as a layout wrapper.',
|
|
623
|
-
],
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
export function buildExtensionEmptyStateSnippet(input) {
|
|
627
|
-
const action = input.action
|
|
628
|
-
? `\n :action="${buildFooterActionObject(input.action)}"`
|
|
629
|
-
: '';
|
|
630
|
-
return {
|
|
631
|
-
action: 'extension_empty_state_built',
|
|
632
|
-
component: 'EmptyState',
|
|
633
|
-
snippet: `<EmptyState\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/>`,
|
|
634
|
-
contract: [
|
|
635
|
-
'Dynamic extensions expose the app empty-state component as EmptyState.',
|
|
636
|
-
'Use variant="naked" inside framed panels/lists and outline/subtle for standalone framed empty surfaces.',
|
|
637
|
-
],
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
|
-
export function buildExtensionResourceListSnippet(input) {
|
|
641
|
-
const itemsExpression = input.itemsExpression || 'items';
|
|
642
|
-
const itemName = input.itemName || 'item';
|
|
643
|
-
const keyExpression = input.keyExpression || `${itemName}.id`;
|
|
644
|
-
const titleExpression = input.titleExpression || `${itemName}.title || ${quoteJsString('Untitled')}`;
|
|
645
|
-
const descriptionExpression = input.descriptionExpression || `${itemName}.description`;
|
|
646
|
-
const iconExpression = input.iconExpression || quoteJsString(input.icon || 'lucide:file-text');
|
|
647
|
-
const onClick = input.onClick ? `\n :on-click="() => ${input.onClick}"` : '';
|
|
648
|
-
const stats = input.statsExpression ? `\n :stats="${input.statsExpression}"` : '';
|
|
649
|
-
const actions = input.actionsExpression ? `\n :actions="${input.actionsExpression}"` : '';
|
|
650
|
-
const topBadge = input.topBadgeExpression ? `\n :top-badge="${input.topBadgeExpression}"` : '';
|
|
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}`,
|
|
657
|
-
` :loading="${input.loadingExpression || 'pending'}"`,
|
|
658
|
-
` :has-items="${itemsExpression}.length > 0"`,
|
|
659
|
-
` :total="${input.totalExpression || `${itemsExpression}.length`}"`,
|
|
660
|
-
` :items-per-page="${itemsPerPageExpression}"`,
|
|
661
|
-
` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '"')}"`,
|
|
662
|
-
` empty-description="${String(input.emptyDescription || '').replace(/"/g, '"')}"`,
|
|
663
|
-
` empty-icon="${input.emptyIcon || 'lucide:inbox'}"`,
|
|
664
|
-
'>',
|
|
665
|
-
` <CommonResourceListItem`,
|
|
666
|
-
` v-for="${itemName} in ${itemsExpression}"`,
|
|
667
|
-
` :key="${keyExpression}"`,
|
|
668
|
-
` :title="${titleExpression}"`,
|
|
669
|
-
` :description="${descriptionExpression}"`,
|
|
670
|
-
` :icon="${iconExpression}"`,
|
|
671
|
-
' icon-color="primary"',
|
|
672
|
-
`${stats}${actions}${topBadge}${onClick}`,
|
|
673
|
-
' />',
|
|
674
|
-
'</CommonResourceListFrame>',
|
|
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');
|
|
679
|
-
return {
|
|
680
|
-
action: 'extension_resource_list_built',
|
|
681
|
-
components: ['CommonResourceListFrame', 'CommonResourceListItem'],
|
|
682
|
-
snippet,
|
|
683
|
-
contract: [
|
|
684
|
-
'Use CommonResourceListFrame and CommonResourceListItem for operational lists instead of ad hoc cards.',
|
|
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.',
|
|
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.',
|
|
689
|
-
'Use explicit bounded list data and natural pagination/search outside this snippet when the domain list can grow.',
|
|
690
|
-
],
|
|
691
|
-
};
|
|
692
|
-
}
|
|
693
|
-
export function buildExtensionResourceGridSnippet(input) {
|
|
694
|
-
const itemsExpression = input.itemsExpression || 'items';
|
|
695
|
-
const itemName = input.itemName || 'item';
|
|
696
|
-
const keyExpression = input.keyExpression || `${itemName}.id`;
|
|
697
|
-
const defaultBody = [
|
|
698
|
-
`<h2 class="font-semibold eapp-text-primary">{{ ${itemName}.title || 'Untitled' }}</h2>`,
|
|
699
|
-
`<p v-if="${itemName}.description" class="text-sm eapp-text-secondary line-clamp-2">{{ ${itemName}.description }}</p>`,
|
|
700
|
-
].join('\n');
|
|
701
|
-
const normalized = normalizeVueBodySnippet(input.cardBody || defaultBody);
|
|
702
|
-
const frame = [
|
|
703
|
-
'<CommonResourceListFrame',
|
|
704
|
-
' variant="plain"',
|
|
705
|
-
` :loading="${input.loadingExpression || 'pending'}"`,
|
|
706
|
-
` :has-items="${itemsExpression}.length > 0"`,
|
|
707
|
-
` :total="${input.totalExpression || `${itemsExpression}.length`}"`,
|
|
708
|
-
` :items-per-page="${input.itemsPerPageExpression || '0'}"`,
|
|
709
|
-
` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '"')}"`,
|
|
710
|
-
` empty-description="${String(input.emptyDescription || '').replace(/"/g, '"')}"`,
|
|
711
|
-
` empty-icon="${input.emptyIcon || 'lucide:inbox'}"`,
|
|
712
|
-
'>',
|
|
713
|
-
' <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">',
|
|
714
|
-
` <UCard v-for="${itemName} in ${itemsExpression}" :key="${keyExpression}" class="h-full eapp-surface-card eapp-radius-panel border eapp-divider">`,
|
|
715
|
-
' <div class="flex h-full flex-col gap-4">',
|
|
716
|
-
indentLines(normalized.code, 8),
|
|
717
|
-
' </div>',
|
|
718
|
-
' </UCard>',
|
|
719
|
-
' </div>',
|
|
720
|
-
'</CommonResourceListFrame>',
|
|
721
|
-
].join('\n');
|
|
722
|
-
const constrained = input.constrained === false
|
|
723
|
-
? frame
|
|
724
|
-
: ['<section class="eapp-page-constrained-wide space-y-4">', indentLines(frame, 2), '</section>'].join('\n');
|
|
725
|
-
return {
|
|
726
|
-
action: 'extension_resource_grid_built',
|
|
727
|
-
component: 'CommonResourceListFrame',
|
|
728
|
-
snippet: constrained,
|
|
729
|
-
normalizedBodyChanges: normalized.changes,
|
|
730
|
-
contract: [
|
|
731
|
-
'Use this card grid for dashboard/workboard/catalog collections; use resource_list for dense operational rows.',
|
|
732
|
-
'The default desktop layout uses three columns only at xl because the admin sidebar consumes viewport width.',
|
|
733
|
-
'Keep the page constrained unless the workflow intentionally owns a canvas or other full-bleed surface.',
|
|
734
|
-
'Keep card actions inside cardBody and align them with flex layout rather than floating them at the viewport edge.',
|
|
735
|
-
],
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
export function buildExtensionFormEditorSnippet(input) {
|
|
739
|
-
const tag = input.lazy === false ? 'FormEditor' : 'FormEditorLazy';
|
|
740
|
-
const attrs = [
|
|
741
|
-
`v-model="${input.model || 'form'}"`,
|
|
742
|
-
`v-model:errors="${input.errors || 'errors'}"`,
|
|
743
|
-
attrStaticOrBound('table-name', input.tableName, input.tableNameExpression),
|
|
744
|
-
input.mode ? `mode="${input.mode}"` : null,
|
|
745
|
-
input.loadingExpression ? `:loading="${input.loadingExpression}"` : null,
|
|
746
|
-
input.layout ? `layout="${input.layout}"` : null,
|
|
747
|
-
input.includes?.length ? `:includes="${jsArrayLiteral(input.includes)}"` : null,
|
|
748
|
-
input.excluded?.length ? `:excluded="${jsArrayLiteral(input.excluded)}"` : null,
|
|
749
|
-
input.sectionsExpression ? `:sections="${input.sectionsExpression}"` : null,
|
|
750
|
-
input.fieldMapExpression ? `:field-map="${input.fieldMapExpression}"` : null,
|
|
751
|
-
input.virtualFieldsExpression ? `:virtual-fields="${input.virtualFieldsExpression}"` : null,
|
|
752
|
-
input.currentRecordIdExpression ? `:current-record-id="${input.currentRecordIdExpression}"` : null,
|
|
753
|
-
input.hasChangedHandler ? `@has-changed="${input.hasChangedHandler}"` : null,
|
|
754
|
-
input.virtualFieldEmitHandler ? `@virtual-field-emit="${input.virtualFieldEmitHandler}"` : null,
|
|
755
|
-
].filter(Boolean);
|
|
756
|
-
const snippet = [
|
|
757
|
-
`<${tag}`,
|
|
758
|
-
...attrs.map((attr) => ` ${attr}`),
|
|
759
|
-
'/>',
|
|
760
|
-
].join('\n');
|
|
761
|
-
return {
|
|
762
|
-
action: 'extension_form_editor_built',
|
|
763
|
-
component: tag,
|
|
764
|
-
snippet,
|
|
765
|
-
contract: [
|
|
766
|
-
'Prefer FormEditor/FormEditorLazy for direct table-backed forms instead of hand-built UInput/UTextarea fields.',
|
|
767
|
-
'Use v-model for record state and v-model:errors for validation errors.',
|
|
768
|
-
'Use includes/sections to keep generated forms focused; do not expose compiledCode or unrelated system fields.',
|
|
769
|
-
'Use fieldMap only for behavior/renderer overrides such as code fields or custom labels.',
|
|
770
|
-
],
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
export function buildExtensionWidgetSnippet(input) {
|
|
774
|
-
const attrs = [`:id="${typeof input.id === 'number' ? input.id : quoteJsString(input.id)}"`];
|
|
775
|
-
for (const [key, value] of Object.entries(input.props || {})) {
|
|
776
|
-
attrs.push(`:${key}="${value}"`);
|
|
777
|
-
}
|
|
778
|
-
for (const [event, handler] of Object.entries(input.events || {})) {
|
|
779
|
-
attrs.push(`@${event}="${handler}"`);
|
|
780
|
-
}
|
|
781
|
-
return {
|
|
782
|
-
action: 'extension_widget_built',
|
|
783
|
-
component: 'Widget',
|
|
784
|
-
snippet: `<Widget ${attrs.join(' ')} />`,
|
|
785
|
-
warnings: typeof input.id === 'number' ? [] : ['Widget ids should be numeric enfyra_extension ids; do not pass extension name or extensionId string.'],
|
|
786
|
-
contract: [
|
|
787
|
-
'Widget :id is the numeric enfyra_extension id, not name or extensionId.',
|
|
788
|
-
'Pass safe props/events; keep page-level mutation and modal ownership in the page unless the widget intentionally owns the full workflow.',
|
|
789
|
-
],
|
|
790
|
-
};
|
|
791
|
-
}
|
|
792
|
-
export function buildExtensionMenuNotificationSnippet(input) {
|
|
793
|
-
const targetEntries = [
|
|
794
|
-
input.targetId !== undefined ? `id: ${quoteJsString(input.targetId)}` : null,
|
|
795
|
-
input.path ? `path: ${quoteJsString(input.path)}` : null,
|
|
796
|
-
input.route ? `route: ${quoteJsString(input.route)}` : null,
|
|
797
|
-
];
|
|
798
|
-
const entries = [
|
|
799
|
-
`id: ${quoteJsString(input.id || 'extension-menu-notification')}`,
|
|
800
|
-
`target: ${jsObjectLiteral(targetEntries)}`,
|
|
801
|
-
input.valueExpression ? `value: ${input.valueExpression}` : input.value !== undefined ? `value: ${quoteJsString(input.value)}` : null,
|
|
802
|
-
`color: ${quoteJsString(input.color || 'primary')}`,
|
|
803
|
-
input.title ? `title: ${quoteJsString(input.title)}` : null,
|
|
804
|
-
typeof input.order === 'number' ? `order: ${input.order}` : null,
|
|
805
|
-
];
|
|
806
|
-
return {
|
|
807
|
-
action: 'extension_menu_notification_built',
|
|
808
|
-
snippet: [
|
|
809
|
-
'const { register: registerMenuNotification } = useMenuNotificationRegistry();',
|
|
810
|
-
`registerMenuNotification(${jsObjectLiteral(entries)});`,
|
|
811
|
-
].join('\n'),
|
|
812
|
-
contract: [
|
|
813
|
-
'Use count/value only when the signal source already owns an exact or bounded count.',
|
|
814
|
-
'Omit value for a dot-only notification when realtime only proves new attention exists.',
|
|
815
|
-
'Do not fetch destination domain lists solely to decorate the menu.',
|
|
816
|
-
],
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
export function buildExtensionAccountPanelSnippet(input) {
|
|
820
|
-
const entries = [
|
|
821
|
-
`id: ${quoteJsString(input.id || 'extension-account-panel-item')}`,
|
|
822
|
-
typeof input.order === 'number' ? `order: ${input.order}` : null,
|
|
823
|
-
input.label ? `label: ${quoteJsString(input.label)}` : null,
|
|
824
|
-
input.description ? `description: ${quoteJsString(input.description)}` : null,
|
|
825
|
-
input.icon ? `icon: ${quoteJsString(input.icon)}` : null,
|
|
826
|
-
input.countExpression ? `count: ${input.countExpression}` : input.count !== undefined ? `count: ${quoteJsString(input.count)}` : null,
|
|
827
|
-
input.badgeExpression ? `badge: ${input.badgeExpression}` : input.badge !== undefined ? `badge: ${quoteJsString(input.badge)}` : null,
|
|
828
|
-
input.badgeColor ? `badgeColor: ${quoteJsString(input.badgeColor)}` : null,
|
|
829
|
-
input.trailingIcon ? `trailingIcon: ${quoteJsString(input.trailingIcon)}` : null,
|
|
830
|
-
input.expandedExpression ? `expanded: ${input.expandedExpression}` : null,
|
|
831
|
-
input.contentComponent ? `contentComponent: ${input.contentComponent}` : null,
|
|
832
|
-
input.contentPropsExpression ? `contentProps: ${input.contentPropsExpression}` : null,
|
|
833
|
-
input.onClick ? `onClick: ${input.onClick}` : null,
|
|
834
|
-
input.onToggle ? `onToggle: ${input.onToggle}` : null,
|
|
835
|
-
];
|
|
836
|
-
return {
|
|
837
|
-
action: 'extension_account_panel_item_built',
|
|
838
|
-
snippet: [
|
|
839
|
-
'const { register: registerAccountPanelItem } = useAccountPanelRegistry();',
|
|
840
|
-
`registerAccountPanelItem(${jsObjectLiteral(entries)});`,
|
|
841
|
-
].join('\n'),
|
|
842
|
-
contract: [
|
|
843
|
-
'Prefer data-driven account panel rows over fully custom row components.',
|
|
844
|
-
'Use count for notification-style chips; count takes precedence over badge.',
|
|
845
|
-
'Use onClick for direct actions and onToggle/contentComponent for expandable inline UI.',
|
|
846
|
-
],
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
export function buildExtensionTabsSnippet(input) {
|
|
850
|
-
const model = input.model || 'activeTab';
|
|
851
|
-
const items = input.itemsExpression || 'tabs';
|
|
852
|
-
const body = input.body || '<div>{{ item.label }}</div>';
|
|
853
|
-
const snippet = [
|
|
854
|
-
`<UTabs v-model="${model}" :items="${items}" class="w-full">`,
|
|
855
|
-
' <template #content="{ item }">',
|
|
856
|
-
indentLines(normalizeVueBodySnippet(body).code, 4),
|
|
857
|
-
' </template>',
|
|
858
|
-
'</UTabs>',
|
|
859
|
-
].join('\n');
|
|
860
|
-
return {
|
|
861
|
-
action: 'extension_tabs_built',
|
|
862
|
-
component: 'UTabs',
|
|
863
|
-
snippet,
|
|
864
|
-
contract: [
|
|
865
|
-
'Use app-level UTabs chrome instead of custom tab bars.',
|
|
866
|
-
'Do not add local full-width bottom borders/dividers to tab lists.',
|
|
867
|
-
'Keep tab items data-driven and render panel content through #content.',
|
|
868
|
-
],
|
|
869
|
-
};
|
|
870
|
-
}
|
|
871
|
-
export function buildExtensionUploadModalSnippet(input) {
|
|
872
|
-
const model = input.model || 'showUploadModal';
|
|
873
|
-
const attrs = [
|
|
874
|
-
`v-model="${model}"`,
|
|
875
|
-
`title="${String(input.title || 'Upload Files').replace(/"/g, '"')}"`,
|
|
876
|
-
`accept="${input.accept || '*/*'}"`,
|
|
877
|
-
input.multiple !== false ? ':multiple="true"' : ':multiple="false"',
|
|
878
|
-
input.maxSizeExpression ? `:max-size="${input.maxSizeExpression}"` : ':max-size="10 * 1024 * 1024"',
|
|
879
|
-
input.loadingExpression ? `:loading="${input.loadingExpression}"` : null,
|
|
880
|
-
input.uploadProgressExpression ? `:upload-progress="${input.uploadProgressExpression}"` : null,
|
|
881
|
-
input.fileProgressExpression ? `:file-progress="${input.fileProgressExpression}"` : null,
|
|
882
|
-
input.dragText ? `drag-text="${String(input.dragText).replace(/"/g, '"')}"` : null,
|
|
883
|
-
input.acceptText ? `accept-text="${String(input.acceptText).replace(/"/g, '"')}"` : null,
|
|
884
|
-
input.uploadText ? `upload-text="${String(input.uploadText).replace(/"/g, '"')}"` : null,
|
|
885
|
-
input.uploadingText ? `uploading-text="${String(input.uploadingText).replace(/"/g, '"')}"` : null,
|
|
886
|
-
`@upload="${input.uploadHandler || 'handleUpload'}"`,
|
|
887
|
-
input.errorHandler ? `@error="${input.errorHandler}"` : null,
|
|
888
|
-
].filter(Boolean);
|
|
889
|
-
const headerContent = input.headerContent ? [
|
|
890
|
-
'>',
|
|
891
|
-
' <template #header-content>',
|
|
892
|
-
indentLines(normalizeVueBodySnippet(input.headerContent).code, 4),
|
|
893
|
-
' </template>',
|
|
894
|
-
'</CommonUploadModal>',
|
|
895
|
-
] : ['/>'];
|
|
896
|
-
return {
|
|
897
|
-
action: 'extension_upload_modal_built',
|
|
898
|
-
component: 'CommonUploadModal',
|
|
899
|
-
snippet: [
|
|
900
|
-
'<CommonUploadModal',
|
|
901
|
-
...attrs.map((attr) => ` ${attr}`),
|
|
902
|
-
...headerContent,
|
|
903
|
-
].join('\n'),
|
|
904
|
-
companionSnippet: [
|
|
905
|
-
'const {',
|
|
906
|
-
' uploadProgress,',
|
|
907
|
-
' trackedUploadProgressById,',
|
|
908
|
-
' beginTrackedUploadProgress,',
|
|
909
|
-
' getUploadProgressHeaders,',
|
|
910
|
-
' resetUploadProgress,',
|
|
911
|
-
'} = useFileUploadProgress();',
|
|
912
|
-
].join('\n'),
|
|
913
|
-
contract: [
|
|
914
|
-
'Use useFileUploadProgress for admin-socket upload progress.',
|
|
915
|
-
'Send x-enfyra-upload-id via getUploadProgressHeaders(id) for each uploaded file.',
|
|
916
|
-
'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].',
|
|
917
|
-
'CommonUploadModal owns selected-file rows and per-row progress chrome.',
|
|
918
|
-
],
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
function toPascalIdentifier(value, fallback = 'Items') {
|
|
922
|
-
const raw = String(value || fallback)
|
|
923
|
-
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
|
924
|
-
.trim()
|
|
925
|
-
.split(/\s+/)
|
|
926
|
-
.filter(Boolean)
|
|
927
|
-
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
928
|
-
.join('');
|
|
929
|
-
return raw || fallback;
|
|
930
|
-
}
|
|
931
|
-
function buildExtensionSort(sort) {
|
|
932
|
-
if (!Array.isArray(sort) || sort.length === 0)
|
|
933
|
-
return undefined;
|
|
934
|
-
const fields = sort.map((entry) => {
|
|
935
|
-
const field = String(entry?.field || '').trim();
|
|
936
|
-
if (!field)
|
|
937
|
-
throw new Error('Each extension sort entry requires a field.');
|
|
938
|
-
return String(entry?.direction || 'asc').toLowerCase() === 'desc'
|
|
939
|
-
? `-${field}`
|
|
940
|
-
: field;
|
|
941
|
-
});
|
|
942
|
-
return fields.join(',');
|
|
943
|
-
}
|
|
944
|
-
export function buildExtensionApiUsageSnippet(input = {}) {
|
|
945
|
-
const resource = String(input.resource || input.name || 'items');
|
|
946
|
-
const pascal = toPascalIdentifier(resource, 'Items');
|
|
947
|
-
const operation = String(input.operation || input.mode || input.intent || '').toLowerCase() || String(input.method || 'GET').toLowerCase();
|
|
948
|
-
const normalizedOperation = {
|
|
949
|
-
get: 'list',
|
|
950
|
-
read: 'list',
|
|
951
|
-
load: 'list',
|
|
952
|
-
post: 'create',
|
|
953
|
-
patch: 'update',
|
|
954
|
-
put: 'update',
|
|
955
|
-
del: 'delete',
|
|
956
|
-
remove: 'delete',
|
|
957
|
-
destroy: 'delete',
|
|
958
|
-
}[operation] || operation;
|
|
959
|
-
const defaultMethodByOperation = {
|
|
960
|
-
list: 'GET',
|
|
961
|
-
find_one: 'GET',
|
|
962
|
-
create: 'POST',
|
|
963
|
-
update: 'PATCH',
|
|
964
|
-
delete: 'DELETE',
|
|
965
|
-
batch_update: 'PATCH',
|
|
966
|
-
batch_delete: 'DELETE',
|
|
967
|
-
};
|
|
968
|
-
const method = String(input.method || defaultMethodByOperation[normalizedOperation] || 'GET').toUpperCase();
|
|
969
|
-
const rawPath = String(input.path || `/${resource}`);
|
|
970
|
-
const path = rawPath.replace(/\/:id\/?$/, '');
|
|
971
|
-
const responseName = input.responseName || `${resource}Response`;
|
|
972
|
-
const pendingName = input.pendingName || `${resource}Pending`;
|
|
973
|
-
const errorName = input.errorName || `${resource}Error`;
|
|
974
|
-
const executeName = input.executeName || (method === 'GET' ? `load${pascal}` : `${normalizedOperation.replace(/(^|_)([a-z])/g, (_m, _p, ch) => ch.toUpperCase()).replace(/^./, (ch) => ch.toLowerCase())}${pascal}Api`);
|
|
975
|
-
const refreshName = input.refreshName || `refresh${pascal}`;
|
|
976
|
-
const sort = buildExtensionSort(input.sort);
|
|
977
|
-
const rawQuery = input.query && typeof input.query === 'object' && !Array.isArray(input.query)
|
|
978
|
-
? input.query
|
|
979
|
-
: null;
|
|
980
|
-
if (rawQuery?.sort !== undefined && !sort) {
|
|
981
|
-
throw new Error('Pass extension sort through the structured sort input, not query.sort.');
|
|
982
|
-
}
|
|
983
|
-
const structuredQuery = rawQuery || sort
|
|
984
|
-
? { ...(rawQuery || {}), ...(sort ? { sort } : {}) }
|
|
985
|
-
: null;
|
|
986
|
-
if (structuredQuery && input.queryExpression) {
|
|
987
|
-
throw new Error('Pass either query or queryExpression to build_extension_api_usage, not both. Use structured query plus sort for Enfyra REST ordering.');
|
|
988
|
-
}
|
|
989
|
-
const queryName = input.queryName || `${resource}Query`;
|
|
990
|
-
const queryExpression = structuredQuery ? queryName : input.queryExpression;
|
|
991
|
-
const options = [];
|
|
992
|
-
if (method !== 'GET')
|
|
993
|
-
options.push(`method: ${quoteJsString(method)}`);
|
|
994
|
-
if (queryExpression)
|
|
995
|
-
options.push(`query: ${queryExpression}`);
|
|
996
|
-
if (input.bodyExpression)
|
|
997
|
-
options.push(`body: ${input.bodyExpression}`);
|
|
998
|
-
if (input.errorContext)
|
|
999
|
-
options.push(`errorContext: ${quoteJsString(input.errorContext)}`);
|
|
1000
|
-
if (input.onErrorExpression)
|
|
1001
|
-
options.push(`onError: ${input.onErrorExpression}`);
|
|
1002
|
-
const optionsLiteral = options.length ? `, {\n ${options.join(',\n ')}\n}` : '';
|
|
1003
|
-
const lines = [
|
|
1004
|
-
...(structuredQuery ? [`const ${queryName} = computed(() => (${JSON.stringify(structuredQuery, null, 2)}));`, ''] : []),
|
|
1005
|
-
`const { data: ${responseName}, pending: ${pendingName}, error: ${errorName}, execute: ${executeName}, refresh: ${refreshName} } = useApi(${quoteJsString(path)}${optionsLiteral});`,
|
|
1006
|
-
];
|
|
1007
|
-
if (method === 'GET') {
|
|
1008
|
-
const rowsName = input.rowsName || resource;
|
|
1009
|
-
lines.push(`const ${rowsName} = computed(() => ${responseName}.value?.data || []);`);
|
|
1010
|
-
if (input.autoLoad !== false) {
|
|
1011
|
-
lines.push(`onMounted(() => { ${executeName}(); });`);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
else if (normalizedOperation === 'create') {
|
|
1015
|
-
const handlerName = input.handlerName || `create${pascal.replace(/s$/, '')}`;
|
|
1016
|
-
const payloadName = input.payloadName || 'payload';
|
|
1017
|
-
lines.push(...[
|
|
1018
|
-
'',
|
|
1019
|
-
`async function ${handlerName}(${payloadName}) {`,
|
|
1020
|
-
` const response = await ${executeName}({ body: ${payloadName} });`,
|
|
1021
|
-
' if (!response) return null;',
|
|
1022
|
-
' return response;',
|
|
1023
|
-
'}',
|
|
1024
|
-
]);
|
|
1025
|
-
}
|
|
1026
|
-
else if (normalizedOperation === 'update') {
|
|
1027
|
-
const handlerName = input.handlerName || `update${pascal.replace(/s$/, '')}`;
|
|
1028
|
-
const recordName = input.recordName || 'record';
|
|
1029
|
-
const bodyName = input.bodyName || 'body';
|
|
1030
|
-
const idExpression = input.idExpression || `${recordName}.id`;
|
|
1031
|
-
const bodyArg = bodyName === 'body' ? 'body' : `body: ${bodyName}`;
|
|
1032
|
-
lines.push(...[
|
|
1033
|
-
'',
|
|
1034
|
-
`async function ${handlerName}(${recordName}, ${bodyName}) {`,
|
|
1035
|
-
` const response = await ${executeName}({ id: ${idExpression}, ${bodyArg} });`,
|
|
1036
|
-
' if (!response) return null;',
|
|
1037
|
-
' return response;',
|
|
1038
|
-
'}',
|
|
1039
|
-
]);
|
|
1040
|
-
}
|
|
1041
|
-
else if (normalizedOperation === 'delete') {
|
|
1042
|
-
const handlerName = input.handlerName || `delete${pascal.replace(/s$/, '')}`;
|
|
1043
|
-
const recordName = input.recordName || 'record';
|
|
1044
|
-
const idExpression = input.idExpression || `${recordName}.id`;
|
|
1045
|
-
lines.push(...[
|
|
1046
|
-
'',
|
|
1047
|
-
`async function ${handlerName}(${recordName}) {`,
|
|
1048
|
-
` const response = await ${executeName}({ id: ${idExpression} });`,
|
|
1049
|
-
' if (!response) return null;',
|
|
1050
|
-
' return response;',
|
|
1051
|
-
'}',
|
|
1052
|
-
]);
|
|
1053
|
-
}
|
|
1054
|
-
else if (normalizedOperation === 'batch_update' || normalizedOperation === 'batch_delete') {
|
|
1055
|
-
const handlerName = input.handlerName || `${normalizedOperation === 'batch_update' ? 'update' : 'delete'}${pascal}Batch`;
|
|
1056
|
-
const idsName = input.idsName || 'ids';
|
|
1057
|
-
const bodyName = input.bodyName || 'body';
|
|
1058
|
-
const args = normalizedOperation === 'batch_update' ? `{ ids: ${idsName}, body: ${bodyName} }` : `{ ids: ${idsName} }`;
|
|
1059
|
-
lines.push(...[
|
|
1060
|
-
'',
|
|
1061
|
-
`async function ${handlerName}(${normalizedOperation === 'batch_update' ? `${idsName}, ${bodyName}` : idsName}) {`,
|
|
1062
|
-
` const response = await ${executeName}(${args});`,
|
|
1063
|
-
' if (!response) return null;',
|
|
1064
|
-
' return response;',
|
|
1065
|
-
'}',
|
|
1066
|
-
]);
|
|
1067
|
-
}
|
|
1068
|
-
else {
|
|
1069
|
-
const handlerName = input.handlerName || `${method.toLowerCase()}${pascal}Record`;
|
|
1070
|
-
lines.push(...[
|
|
1071
|
-
'',
|
|
1072
|
-
`async function ${handlerName}(payload) {`,
|
|
1073
|
-
` const response = await ${executeName}({ body: payload });`,
|
|
1074
|
-
' if (!response) return null;',
|
|
1075
|
-
' return response;',
|
|
1076
|
-
'}',
|
|
1077
|
-
]);
|
|
1078
|
-
}
|
|
1079
|
-
return {
|
|
1080
|
-
action: 'extension_api_usage_built',
|
|
1081
|
-
operation: normalizedOperation,
|
|
1082
|
-
snippet: lines.join('\n'),
|
|
1083
|
-
contract: [
|
|
1084
|
-
'useApi returns refs plus execute/refresh; it does not auto-run.',
|
|
1085
|
-
'The useApi path is the base route string or a () => string getter; do not pass computed refs and do not put :id placeholders in the path.',
|
|
1086
|
-
'Pass query/body as objects or computed objects, not JSON.stringify strings.',
|
|
1087
|
-
'For Enfyra REST ordering, use structured sort entries with field and direction; the generated query always emits one comma-separated sort string such as "-isPinned,-updatedAt", never sort arrays or field:DESC tokens.',
|
|
1088
|
-
'Read normal list rows from data.value?.data or from the direct execute() response.',
|
|
1089
|
-
'For mutations, call execute({ body }), execute({ id, body }), execute({ id }), or execute({ ids }) from a user action.',
|
|
1090
|
-
],
|
|
1091
|
-
};
|
|
1092
|
-
}
|
|
1093
|
-
export function buildExtensionNotifySnippet(input = {}) {
|
|
1094
|
-
const kind = ['success', 'error', 'warning', 'info'].includes(input.kind) ? input.kind : 'success';
|
|
1095
|
-
const title = input.title || (kind === 'success' ? 'Saved' : 'Notice');
|
|
1096
|
-
const description = input.description || '';
|
|
1097
|
-
const args = description ? `${quoteJsString(title)}, ${quoteJsString(description)}` : quoteJsString(title);
|
|
1098
|
-
return {
|
|
1099
|
-
action: 'extension_notify_usage_built',
|
|
1100
|
-
snippet: [
|
|
1101
|
-
'const notify = useNotify();',
|
|
1102
|
-
`await notify.${kind}(${args});`,
|
|
1103
|
-
].join('\n'),
|
|
1104
|
-
contract: [
|
|
1105
|
-
'useNotify exposes success/error/warning/info(title, description?) helpers.',
|
|
1106
|
-
'Do not pass Nuxt toast object payloads and do not call notify.add().',
|
|
1107
|
-
'The helpers are async; await them inside submit/mutation handlers when ordering matters.',
|
|
1108
|
-
],
|
|
1109
|
-
};
|
|
1110
|
-
}
|
|
1111
|
-
export function buildExtensionConfirmSnippet(input = {}) {
|
|
1112
|
-
const resource = String(input.resource || 'items');
|
|
1113
|
-
const singular = resource.replace(/s$/i, '') || 'item';
|
|
1114
|
-
const pascal = toPascalIdentifier(resource, 'Items');
|
|
1115
|
-
const recordName = input.recordName || singular;
|
|
1116
|
-
const handlerName = input.handlerName || `confirmDelete${toPascalIdentifier(singular, 'Item')}`;
|
|
1117
|
-
const executeName = input.executeName || `delete${pascal}Api`;
|
|
1118
|
-
const refreshName = input.refreshName || `refresh${pascal}`;
|
|
1119
|
-
const idExpression = input.idExpression || `${recordName}.id`;
|
|
1120
|
-
const title = input.title || `Delete ${singular}`;
|
|
1121
|
-
const contentExpression = input.contentExpression || `\`Delete "\${${recordName}.title || 'this ${singular}'}"?\``;
|
|
1122
|
-
const confirmText = input.confirmText || 'Delete';
|
|
1123
|
-
const cancelText = input.cancelText || 'Cancel';
|
|
1124
|
-
const mutationExpression = input.mutationExpression || `${executeName}({ id: ${idExpression} })`;
|
|
1125
|
-
const refresh = input.refresh === false ? null : input.refreshExpression || refreshName;
|
|
1126
|
-
return {
|
|
1127
|
-
action: 'extension_confirm_workflow_built',
|
|
1128
|
-
snippet: [
|
|
1129
|
-
'const { confirm } = useConfirm();',
|
|
1130
|
-
'',
|
|
1131
|
-
`async function ${handlerName}(${recordName}) {`,
|
|
1132
|
-
' const confirmed = await confirm({',
|
|
1133
|
-
` title: ${quoteJsString(title)},`,
|
|
1134
|
-
` content: ${contentExpression},`,
|
|
1135
|
-
` confirmText: ${quoteJsString(confirmText)},`,
|
|
1136
|
-
` cancelText: ${quoteJsString(cancelText)},`,
|
|
1137
|
-
' });',
|
|
1138
|
-
' if (!confirmed) return null;',
|
|
1139
|
-
'',
|
|
1140
|
-
` const response = await ${mutationExpression};`,
|
|
1141
|
-
' if (!response) return null;',
|
|
1142
|
-
...(refresh ? ['', ` await ${refresh}();`] : []),
|
|
1143
|
-
' return response;',
|
|
1144
|
-
'}',
|
|
1145
|
-
].join('\n'),
|
|
1146
|
-
contract: [
|
|
1147
|
-
'useConfirm() opens the eApp GlobalConfirm/CommonModal and resolves true only after the user accepts.',
|
|
1148
|
-
'Run the destructive mutation only after confirmed is true, then refresh the affected resource list when needed.',
|
|
1149
|
-
'Never use window.confirm, window.alert, alert, or prompt in an extension.',
|
|
1150
|
-
'Use CommonModal directly only when the confirmation needs form fields, richer detail, or a custom destructive workflow that useConfirm cannot express.',
|
|
1151
|
-
],
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
export function reviewExtensionUiContract(code, options = {}) {
|
|
1155
|
-
const source = String(code || '');
|
|
1156
|
-
const pattern = String(options.pattern || 'auto');
|
|
1157
|
-
const issues = [];
|
|
1158
|
-
const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
|
|
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'))) {
|
|
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.');
|
|
1171
|
-
}
|
|
1172
|
-
if (modals.some((element) => hasStaticOrBound(element, 'title'))) {
|
|
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.');
|
|
1174
|
-
}
|
|
1175
|
-
if (drawers.some((element) => !hasStaticOrBound(element, 'primary-action') && !hasStaticOrBound(element, 'primaryAction'))) {
|
|
1176
|
-
push('warning', 'drawer-primary-action', 'CommonDrawer has no primaryAction.', 'Editing/create drawers should wire Save/Create through primaryAction.');
|
|
1177
|
-
}
|
|
1178
|
-
if (drawers.some((element) => !hasStaticOrBound(element, 'cancel-action') && !hasStaticOrBound(element, 'cancelAction'))) {
|
|
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.');
|
|
1180
|
-
}
|
|
1181
|
-
if (modals.some((element) => /delete|remove|confirm|cannot be undone/i.test(element.text) && !hasStaticOrBound(element, 'danger-action') && !hasStaticOrBound(element, 'dangerAction'))) {
|
|
1182
|
-
push('warning', 'modal-danger-action', 'Destructive/confirmation modal has no dangerAction.', 'Wire the final destructive action through dangerAction.');
|
|
1183
|
-
}
|
|
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.');
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
for (const element of byTag('button')) {
|
|
1192
|
-
if (!hasStaticOrBound(element, 'type')) {
|
|
1193
|
-
push('warning', 'native-button-type', 'Native button is missing type="button".', 'Add type="button" unless the button intentionally submits a form.');
|
|
1194
|
-
}
|
|
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
|
-
}
|
|
1258
|
-
return {
|
|
1259
|
-
action: 'extension_ui_contract_reviewed',
|
|
1260
|
-
pattern,
|
|
1261
|
-
valid: issues.every((issue) => issue.severity !== 'error'),
|
|
1262
|
-
issueCount: issues.length,
|
|
1263
|
-
issues,
|
|
1264
|
-
nextSteps: issues.length
|
|
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.']
|
|
1268
|
-
: ['Snippet matches the checked modal/drawer contract rules. Still validate the final SFC before saving.'],
|
|
1269
|
-
};
|
|
1270
|
-
}
|
|
1271
|
-
function collectExtensionRuntimeIssues(code) {
|
|
1272
|
-
const source = String(code || '');
|
|
1273
|
-
const issues = [];
|
|
1274
|
-
const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
|
|
1275
|
-
const analysis = analyzeExtensionSfc(source);
|
|
1276
|
-
if (/(?:^|[>\n;])\s*import(?:\s.+?\sfrom\s+|\s*['"])/m.test(source)) {
|
|
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.');
|
|
1278
|
-
}
|
|
1279
|
-
if (/\buseToast\s*\(/.test(source)) {
|
|
1280
|
-
push('error', 'use-toast-directly', 'Dynamic extensions should not call useToast() directly.', 'Use useNotify() and call success/error/warning/info(title, description?).');
|
|
1281
|
-
}
|
|
1282
|
-
if (/\b(?:window|globalThis)\.(?:confirm|alert|prompt)\s*\(/.test(source) || /(^|[^.\w])(?:alert|prompt)\s*\(/m.test(source)) {
|
|
1283
|
-
push('error', 'browser-dialog', 'Dynamic extensions must not use browser alert/confirm/prompt dialogs.', 'For ordinary destructive confirmation call build_extension_ui kind=confirm and use useConfirm(); use CommonModal only for richer confirmation content.');
|
|
1284
|
-
}
|
|
1285
|
-
if (/\buseNotify\s*\(\s*\)\s*\.add\s*\(/.test(source) || /\b\w+\s*\.add\s*\(\s*\{\s*title\s*:/.test(source)) {
|
|
1286
|
-
push('error', 'use-notify-add', 'useNotify() does not accept Nuxt toast object payloads through add().', 'Call notify.success/error/warning/info(title, description?) instead.');
|
|
1287
|
-
}
|
|
1288
|
-
if (/\b(?:query|body|filter|deep|aggregate)\s*:\s*JSON\.stringify\s*\(/.test(source)) {
|
|
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().');
|
|
1290
|
-
}
|
|
1291
|
-
if (analysis.elements.some((element) => (['CommonModal', 'UModal'].includes(element.tag)
|
|
1292
|
-
&& extensionElementHasAttribute(element, 'model', 'model')))) {
|
|
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.');
|
|
1294
|
-
}
|
|
1295
|
-
if (analysis.elements.some((element) => element.tag === 'CommonEmptyState')) {
|
|
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.');
|
|
1297
|
-
}
|
|
1298
|
-
const scriptBlocks = [...source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((match) => match[1]);
|
|
1299
|
-
const scriptSource = scriptBlocks.length ? scriptBlocks.join('\n') : (/<template\b/i.test(source) ? '' : source);
|
|
1300
|
-
const refNames = new Set([...scriptSource.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:ref|shallowRef)\s*\(/g)].map((match) => match[1]));
|
|
1301
|
-
const bareRefActionPattern = /\bonClick\s*:\s*\(\s*\)\s*=>\s*\(?\s*([A-Za-z_$][\w$]*)\s*=(?!=)/g;
|
|
1302
|
-
let bareRefActionMatch;
|
|
1303
|
-
while ((bareRefActionMatch = bareRefActionPattern.exec(scriptSource))) {
|
|
1304
|
-
const refName = bareRefActionMatch[1];
|
|
1305
|
-
if (refNames.has(refName)) {
|
|
1306
|
-
push('error', 'script-ref-assignment', `Script callback tries to reassign const ref ${refName}.`, `Assign ${refName}.value or call a handler; template ref auto-unwrapping does not apply inside registry/action callbacks.`);
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
const executeAliasPattern = /\bexecute\s*:\s*([A-Za-z_$][\w$]*)/g;
|
|
1310
|
-
let executeAliasMatch;
|
|
1311
|
-
while ((executeAliasMatch = executeAliasPattern.exec(source))) {
|
|
1312
|
-
const executeAlias = executeAliasMatch[1];
|
|
1313
|
-
const references = source.match(new RegExp(`\\b${escapeRegExp(executeAlias)}\\b`, 'g'))?.length || 0;
|
|
1314
|
-
if (references === 1) {
|
|
1315
|
-
push('error', 'use-api-unused-execute', `useApi execute alias ${executeAlias} is never used.`, `Call or reference ${executeAlias} from onMounted, a watcher, or a user action; for read builders keep autoLoad enabled.`);
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
if (/\buseApi\s*\(/.test(source) && !/\bexecute\s*:/.test(source) && !/\brefresh\s*:/.test(source) && !/\.\s*(?:execute|refresh)\s*\(/.test(source)) {
|
|
1319
|
-
push('warning', 'use-api-no-execute-alias', 'useApi() appears without an execute/refresh alias or call.', 'Call useApi() as a top-level setup composable, then call or await execute()/refresh() from onMounted, watchers, or user actions when the request should run.');
|
|
1320
|
-
}
|
|
1321
|
-
if (/\buseNotify\s*\(/.test(source) && !/\bnotify\.(?:success|error|warning|info)\s*\(/.test(source) && !/\b(?:success|error|warning|info)\s*:\s*\w+/.test(source)) {
|
|
1322
|
-
push('warning', 'use-notify-no-helper-call', 'useNotify() appears without a success/error/warning/info helper call.', 'Use the semantic helper methods instead of low-level toast payloads.');
|
|
1323
|
-
}
|
|
1324
|
-
return issues;
|
|
1325
|
-
}
|
|
1326
|
-
export function reviewExtensionRuntimeContract(code) {
|
|
1327
|
-
const issues = collectExtensionRuntimeIssues(code);
|
|
1328
|
-
return {
|
|
1329
|
-
action: 'extension_runtime_contract_reviewed',
|
|
1330
|
-
valid: issues.every((issue) => issue.severity !== 'error'),
|
|
1331
|
-
issueCount: issues.length,
|
|
1332
|
-
issues,
|
|
1333
|
-
nextSteps: issues.length
|
|
1334
|
-
? ['Use build_extension_ui kind=api_usage or kind=notify for known-good snippets, then patch/update the extension.']
|
|
1335
|
-
: ['Snippet matches the checked runtime composable/package rules. Still validate the final SFC before saving.'],
|
|
1336
|
-
};
|
|
1337
|
-
}
|
|
1338
|
-
const THEME_CLASS_INTENTS = {
|
|
1339
|
-
neutral_surface: {
|
|
1340
|
-
classes: 'eapp-surface-card eapp-radius-panel border eapp-divider',
|
|
1341
|
-
use: 'Ordinary cards, panels, KPI containers, list containers, detail blocks, and status blocks that should stay neutral.',
|
|
1342
|
-
},
|
|
1343
|
-
muted_surface: {
|
|
1344
|
-
classes: 'eapp-surface-muted eapp-radius-panel',
|
|
1345
|
-
use: 'Recessed areas, tracks, secondary panels, or subdued containers.',
|
|
1346
|
-
},
|
|
1347
|
-
flat_surface: {
|
|
1348
|
-
classes: 'eapp-surface-flat',
|
|
1349
|
-
use: 'Flush content areas that should follow the app surface without card chrome.',
|
|
1350
|
-
},
|
|
1351
|
-
hover_row: {
|
|
1352
|
-
classes: 'eapp-surface-hover eapp-divider',
|
|
1353
|
-
use: 'Clickable rows inside lists or tables.',
|
|
1354
|
-
},
|
|
1355
|
-
primary_identity: {
|
|
1356
|
-
classes: 'eapp-primary-surface eapp-radius-panel border',
|
|
1357
|
-
use: 'A selected/current entity, active plan, active package, or the single larger block representing current identity.',
|
|
1358
|
-
},
|
|
1359
|
-
primary_soft_icon_tile: {
|
|
1360
|
-
classes: 'eapp-primary-soft eapp-icon-tile',
|
|
1361
|
-
childClasses: 'eapp-primary-text',
|
|
1362
|
-
use: 'Compact runtime-primary icon tiles, selected chips, and small identity accents.',
|
|
1363
|
-
},
|
|
1364
|
-
primary_progress: {
|
|
1365
|
-
trackClasses: 'eapp-surface-muted eapp-radius-pill',
|
|
1366
|
-
fillClasses: 'eapp-primary-solid',
|
|
1367
|
-
use: 'Progress or meter fill controlled by the runtime primary color.',
|
|
1368
|
-
},
|
|
1369
|
-
status_success: {
|
|
1370
|
-
classes: 'eapp-status-success-soft eapp-status-success-text eapp-status-success-border',
|
|
1371
|
-
nuxtUi: { color: 'success', variant: 'soft' },
|
|
1372
|
-
use: 'Small success/healthy badges, chips, or icons only.',
|
|
1373
|
-
},
|
|
1374
|
-
status_warning: {
|
|
1375
|
-
classes: 'eapp-status-warning-soft eapp-status-warning-text eapp-status-warning-border',
|
|
1376
|
-
nuxtUi: { color: 'warning', variant: 'soft' },
|
|
1377
|
-
use: 'Small warning/attention badges, chips, or icons only.',
|
|
1378
|
-
},
|
|
1379
|
-
status_danger: {
|
|
1380
|
-
classes: 'eapp-status-danger-soft eapp-status-danger-text eapp-status-danger-border',
|
|
1381
|
-
nuxtUi: { color: 'error', variant: 'soft' },
|
|
1382
|
-
use: 'Small danger/error/destructive badges, chips, or icons only.',
|
|
1383
|
-
},
|
|
1384
|
-
status_info: {
|
|
1385
|
-
classes: 'eapp-status-info-soft eapp-status-info-text eapp-status-info-border',
|
|
1386
|
-
nuxtUi: { color: 'info', variant: 'soft' },
|
|
1387
|
-
use: 'Small informational badges, chips, or icons only.',
|
|
1388
|
-
},
|
|
1389
|
-
primary_action: {
|
|
1390
|
-
nuxtUi: { color: 'primary', variant: 'solid' },
|
|
1391
|
-
use: 'The single main action for the current scope.',
|
|
1392
|
-
},
|
|
1393
|
-
secondary_action: {
|
|
1394
|
-
nuxtUi: { color: 'neutral', variant: 'outline' },
|
|
1395
|
-
use: 'Visible secondary actions, refresh, filters, cancel, and navigation alternatives.',
|
|
1396
|
-
},
|
|
1397
|
-
ghost_navigation_action: {
|
|
1398
|
-
nuxtUi: { color: 'neutral', variant: 'ghost' },
|
|
1399
|
-
use: 'Back/navigation/icon actions that should not compete with the primary action.',
|
|
1400
|
-
},
|
|
1401
|
-
danger_action: {
|
|
1402
|
-
nuxtUi: { color: 'error', variant: 'solid' },
|
|
1403
|
-
use: 'Final destructive actions such as Delete or Remove.',
|
|
1404
|
-
},
|
|
1405
|
-
divider: {
|
|
1406
|
-
classes: 'eapp-divider',
|
|
1407
|
-
listClasses: 'eapp-divide-y',
|
|
1408
|
-
use: 'Borders and row separators in extension UI.',
|
|
1409
|
-
},
|
|
1410
|
-
text: {
|
|
1411
|
-
primary: 'eapp-text-primary',
|
|
1412
|
-
secondary: 'eapp-text-secondary',
|
|
1413
|
-
tertiary: 'eapp-text-tertiary',
|
|
1414
|
-
quaternary: 'eapp-text-quaternary',
|
|
1415
|
-
use: 'Copy hierarchy in extension UI.',
|
|
1416
|
-
},
|
|
1417
|
-
};
|
|
1418
|
-
function buildExtensionThemeClasses(input = {}) {
|
|
1419
|
-
const intent = String(input.intent || '').trim();
|
|
1420
|
-
if (!intent || !(intent in THEME_CLASS_INTENTS)) {
|
|
1421
|
-
return {
|
|
1422
|
-
action: 'extension_theme_classes_listed',
|
|
1423
|
-
validIntents: Object.keys(THEME_CLASS_INTENTS),
|
|
1424
|
-
note: 'Call again with one intent to get the exact classes/props for that theme contract.',
|
|
1425
|
-
};
|
|
1426
|
-
}
|
|
1427
|
-
const contract = THEME_CLASS_INTENTS[intent];
|
|
1428
|
-
return {
|
|
1429
|
-
action: 'extension_theme_classes_built',
|
|
1430
|
-
intent,
|
|
1431
|
-
contract,
|
|
1432
|
-
hardRules: [
|
|
1433
|
-
'Do not use raw CSS variable utilities such as text-[var(...)], bg-[var(...)], or border-[var(...)] in extension templates.',
|
|
1434
|
-
'Do not use hardcoded Tailwind palettes such as bg-violet-*, text-cyan-*, bg-green-*, dark:bg-zinc-*, or hex/rgb/hsl colors.',
|
|
1435
|
-
'Use status classes only for compact badges/icons/short text; keep large panels neutral.',
|
|
1436
|
-
],
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
function collectExtensionThemeIssues(code) {
|
|
1440
|
-
const source = String(code || '');
|
|
1441
|
-
const issues = [];
|
|
1442
|
-
const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
|
|
1443
|
-
const concretePalettes = [
|
|
1444
|
-
'slate', 'gray', 'zinc', 'neutral', 'stone',
|
|
1445
|
-
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo',
|
|
1446
|
-
'violet', 'purple', 'fuchsia', 'pink', 'rose',
|
|
1447
|
-
].join('|');
|
|
1448
|
-
const paletteClassPattern = new RegExp(`(?:^|[\\s"'])(?:dark:)?(?:bg|text|border|ring|divide|from|via|to)-(${concretePalettes})-(?:\\d{2,3}|950)(?:\\/\\d+)?`, 'i');
|
|
1449
|
-
const rawCssVarPattern = /\b(?:bg|text|border|ring|divide|from|via|to)-\[\s*var\(--/i;
|
|
1450
|
-
const neutralSemanticClassPattern = /\b(?:bg-default|bg-muted|border-default|divide-default|text-muted|text-dimmed)\b/;
|
|
1451
|
-
const concreteNuxtUiColors = [
|
|
1452
|
-
'slate', 'gray', 'zinc', 'stone',
|
|
1453
|
-
'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo',
|
|
1454
|
-
'violet', 'purple', 'fuchsia', 'pink', 'rose',
|
|
1455
|
-
].join('|');
|
|
1456
|
-
const concreteNuxtColorPattern = new RegExp(`\\bcolor\\s*=\\s*["'](${concreteNuxtUiColors})["']`, 'i');
|
|
1457
|
-
if (rawCssVarPattern.test(source)) {
|
|
1458
|
-
push('error', 'raw-css-var-utility', 'Raw CSS variable utility classes are not allowed in generated extension templates.', 'Use eapp-* class tokens or Nuxt UI semantic color props by intent.');
|
|
1459
|
-
}
|
|
1460
|
-
if (paletteClassPattern.test(source)) {
|
|
1461
|
-
push('error', 'hardcoded-tailwind-palette', 'Hardcoded Tailwind palette classes are not allowed in themeable extension UI.', 'Use eapp-surface-*, eapp-primary-*, eapp-status-*, or Nuxt UI semantic colors.');
|
|
1462
|
-
}
|
|
1463
|
-
if (neutralSemanticClassPattern.test(source)) {
|
|
1464
|
-
push('error', 'nuxt-neutral-class', 'Nuxt UI neutral shortcut classes are not part of the extension theme contract.', 'Use eapp-surface-* and eapp-text-* classes instead.');
|
|
1465
|
-
}
|
|
1466
|
-
if (concreteNuxtColorPattern.test(source)) {
|
|
1467
|
-
push('error', 'concrete-nuxt-color', 'Concrete Nuxt UI palette colors are not allowed in generated extension UI.', 'Use color="primary|neutral|success|warning|error|info" by semantic intent.');
|
|
1468
|
-
}
|
|
1469
|
-
if (/#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/i.test(source) || /\b(?:rgba?|hsla?|oklch|lab|lch)\s*\(/i.test(source)) {
|
|
1470
|
-
push('error', 'hardcoded-color-value', 'Hardcoded color values are not allowed in extension UI.', 'Use the Enfyra theme class contract instead of hex/rgb/hsl/oklch/lab/lch values.');
|
|
1471
|
-
}
|
|
1472
|
-
if (/\bstyle\s*=\s*["'][^"']*(?:color|background(?:-color)?|border-color)\s*:/i.test(source)) {
|
|
1473
|
-
push('error', 'inline-color-style', 'Inline color styles bypass the app theme contract.', 'Use eapp-* classes or Nuxt UI semantic props instead of inline color/background/border-color.');
|
|
1474
|
-
}
|
|
1475
|
-
if (/\bgradient\s*:\s*['"](?!none['"])[^'"]+['"]/.test(source) || /\bgradient\s*=\s*['"](?!none['"])[^'"]+['"]/.test(source)) {
|
|
1476
|
-
push('error', 'page-header-gradient', 'Generated operational extension pages must use PageHeader gradient "none" unless the user explicitly requests decoration.', 'Set gradient: "none" in usePageHeaderRegistry or omit decorative gradients.');
|
|
1477
|
-
}
|
|
1478
|
-
if (/<(?:article|section|div)\b[^>]*class=["'][^"']*(?:eapp-status-(?:success|warning|danger|info)-soft|bg-(?:success|warning|error|info)(?:\b|\/))/i.test(source)) {
|
|
1479
|
-
push('warning', 'large-status-surface', 'Status color appears on a large container.', 'Keep large panels neutral and put status color on a compact UBadge/icon/short text inside.');
|
|
1480
|
-
}
|
|
1481
|
-
const primaryActionCount = (source.match(/\bcolor\s*=\s*["']primary["']/g) || []).length;
|
|
1482
|
-
if (primaryActionCount > 2) {
|
|
1483
|
-
push('warning', 'primary-overuse', 'Many primary-colored controls were found.', 'Use primary only for the main action or identity accent; use neutral variants for secondary actions.');
|
|
1484
|
-
}
|
|
1485
|
-
const primarySurfaceCount = (source.match(/\beapp-primary-surface\b/g) || []).length;
|
|
1486
|
-
if (primarySurfaceCount > 3) {
|
|
1487
|
-
push('warning', 'primary-surface-overuse', 'eapp-primary-surface appears many times.', 'Use eapp-primary-surface only for selected/current identity blocks, not every card in a list/grid.');
|
|
1488
|
-
}
|
|
1489
|
-
if (/<(?:article|section|div)\b[^>]*class=["'][^"']*\bborder\b(?![^"']*\beapp-divider\b)/i.test(source)) {
|
|
1490
|
-
push('warning', 'bare-border-token', 'A bordered panel is missing eapp-divider.', 'Pair intentional borders with eapp-divider so borders follow the app theme contract.');
|
|
1491
|
-
}
|
|
1492
|
-
return issues;
|
|
1493
|
-
}
|
|
1494
|
-
export function reviewExtensionThemeContract(code) {
|
|
1495
|
-
const issues = collectExtensionThemeIssues(code);
|
|
1496
|
-
return {
|
|
1497
|
-
action: 'extension_theme_contract_reviewed',
|
|
1498
|
-
valid: issues.every((issue) => issue.severity !== 'error'),
|
|
1499
|
-
issueCount: issues.length,
|
|
1500
|
-
issues,
|
|
1501
|
-
nextSteps: issues.length
|
|
1502
|
-
? ['Use build_extension_ui kind=theme_classes to choose classes/props by intent, then patch/update the extension.']
|
|
1503
|
-
: ['Snippet matches the checked theme contract rules. Still validate the final SFC before saving.'],
|
|
1504
|
-
};
|
|
1505
|
-
}
|
|
1506
|
-
export function buildExtensionUiSnippet(kind, input = {}) {
|
|
1507
|
-
let result;
|
|
1508
|
-
switch (kind) {
|
|
1509
|
-
case 'drawer':
|
|
1510
|
-
result = buildExtensionDrawerSnippet(input);
|
|
1511
|
-
break;
|
|
1512
|
-
case 'modal':
|
|
1513
|
-
result = buildExtensionModalSnippet(input);
|
|
1514
|
-
break;
|
|
1515
|
-
case 'page_shell':
|
|
1516
|
-
result = buildExtensionPageShellSnippet(input);
|
|
1517
|
-
break;
|
|
1518
|
-
case 'permission_gate':
|
|
1519
|
-
result = buildExtensionPermissionGateSnippet(input);
|
|
1520
|
-
break;
|
|
1521
|
-
case 'empty_state':
|
|
1522
|
-
result = buildExtensionEmptyStateSnippet(input);
|
|
1523
|
-
break;
|
|
1524
|
-
case 'resource_list':
|
|
1525
|
-
result = buildExtensionResourceListSnippet(input);
|
|
1526
|
-
break;
|
|
1527
|
-
case 'resource_grid':
|
|
1528
|
-
result = buildExtensionResourceGridSnippet(input);
|
|
1529
|
-
break;
|
|
1530
|
-
case 'form_editor':
|
|
1531
|
-
result = buildExtensionFormEditorSnippet(input);
|
|
1532
|
-
break;
|
|
1533
|
-
case 'widget':
|
|
1534
|
-
result = buildExtensionWidgetSnippet(input);
|
|
1535
|
-
break;
|
|
1536
|
-
case 'menu_notification':
|
|
1537
|
-
result = buildExtensionMenuNotificationSnippet(input);
|
|
1538
|
-
break;
|
|
1539
|
-
case 'account_panel_item':
|
|
1540
|
-
result = buildExtensionAccountPanelSnippet(input);
|
|
1541
|
-
break;
|
|
1542
|
-
case 'tabs':
|
|
1543
|
-
result = buildExtensionTabsSnippet(input);
|
|
1544
|
-
break;
|
|
1545
|
-
case 'upload_modal':
|
|
1546
|
-
result = buildExtensionUploadModalSnippet(input);
|
|
1547
|
-
break;
|
|
1548
|
-
case 'api_usage':
|
|
1549
|
-
result = buildExtensionApiUsageSnippet(input);
|
|
1550
|
-
break;
|
|
1551
|
-
case 'notify':
|
|
1552
|
-
result = buildExtensionNotifySnippet(input);
|
|
1553
|
-
break;
|
|
1554
|
-
case 'confirm':
|
|
1555
|
-
result = buildExtensionConfirmSnippet(input);
|
|
1556
|
-
break;
|
|
1557
|
-
case 'runtime_review':
|
|
1558
|
-
if (!input?.code) {
|
|
1559
|
-
throw new Error('build_extension_ui kind=runtime_review requires input.code.');
|
|
1560
|
-
}
|
|
1561
|
-
result = reviewExtensionRuntimeContract(input.code);
|
|
1562
|
-
break;
|
|
1563
|
-
case 'theme_classes':
|
|
1564
|
-
result = buildExtensionThemeClasses(input);
|
|
1565
|
-
break;
|
|
1566
|
-
case 'theme_review':
|
|
1567
|
-
if (!input?.code) {
|
|
1568
|
-
throw new Error('build_extension_ui kind=theme_review requires input.code.');
|
|
1569
|
-
}
|
|
1570
|
-
result = reviewExtensionThemeContract(input.code);
|
|
1571
|
-
break;
|
|
1572
|
-
case 'review':
|
|
1573
|
-
if (!input?.code) {
|
|
1574
|
-
throw new Error('build_extension_ui kind=review requires input.code.');
|
|
1575
|
-
}
|
|
1576
|
-
const uiReview = reviewExtensionUiContract(input.code, { pattern: input.pattern });
|
|
1577
|
-
const themeReview = reviewExtensionThemeContract(input.code);
|
|
1578
|
-
const runtimeReview = reviewExtensionRuntimeContract(input.code);
|
|
1579
|
-
result = {
|
|
1580
|
-
action: 'extension_ui_theme_runtime_contract_reviewed',
|
|
1581
|
-
valid: uiReview.valid && themeReview.valid && runtimeReview.valid,
|
|
1582
|
-
issueCount: uiReview.issueCount + themeReview.issueCount + runtimeReview.issueCount,
|
|
1583
|
-
ui: uiReview,
|
|
1584
|
-
theme: themeReview,
|
|
1585
|
-
runtime: runtimeReview,
|
|
1586
|
-
};
|
|
1587
|
-
break;
|
|
1588
|
-
default:
|
|
1589
|
-
throw new Error(`Unsupported extension UI builder kind: ${kind}`);
|
|
1590
|
-
}
|
|
1591
|
-
return {
|
|
1592
|
-
gateway: 'build_extension_ui',
|
|
1593
|
-
kind,
|
|
1594
|
-
...result,
|
|
1595
|
-
};
|
|
1596
|
-
}
|
|
1597
|
-
function getExtensionThemeContract() {
|
|
1598
|
-
return {
|
|
1599
|
-
action: 'extension_theme_contract',
|
|
1600
|
-
useBefore: [
|
|
1601
|
-
'Call this before writing or reviewing Enfyra admin page, widget, or global extension UI.',
|
|
1602
|
-
'Then call validate_extension_code or an ensure_*_extension tool before saving.',
|
|
1603
|
-
],
|
|
1604
|
-
layout: [
|
|
1605
|
-
'The extension is already mounted inside the Enfyra app shell. Do not add a duplicate page header, centered page wrapper, or root-level page padding.',
|
|
1606
|
-
'Page extensions should be full-bleed, responsive, and split large operations into focused pages or UTabs.',
|
|
1607
|
-
'Use usePageHeaderRegistry for the shell title and useHeaderActionRegistry/useSubHeaderActionRegistry for page actions.',
|
|
1608
|
-
'Register dynamic extension header actions inside onMounted after setup refs and handlers exist; build_extension_ui kind=page_shell generates this lifecycle shape.',
|
|
1609
|
-
'Use build_extension_ui kind=menu_notification for sidebar menu notification registration snippets.',
|
|
1610
|
-
'For shell menu notifications, first decide the signal source. Use a count only when the source already owns an exact count, such as a notification summary endpoint or bounded unread-notification query. Use a dot when a realtime event only proves that something new exists. Do not poll a domain list such as messages, tickets, orders, or jobs solely to decorate the menu; the destination page owns domain fetching.',
|
|
1611
|
-
'Use build_extension_ui kind=account_panel_item for account panel row registration snippets.',
|
|
1612
|
-
'For detail/form workflows that should stay left-aligned with empty space on the right, wrap the body in eapp-page-constrained; use eapp-page-constrained-wide only when the workflow genuinely needs more width.',
|
|
1613
|
-
'Card/list grids inside the default shell must account for the 280px desktop sidebar. Do not switch general card grids to three columns at lg; use md:grid-cols-2 xl:grid-cols-3 unless a local container proves three columns have enough width.',
|
|
1614
|
-
],
|
|
1615
|
-
theme: [
|
|
1616
|
-
'Do not choose theme classes from memory. Decide the UI intent, then call build_extension_ui kind=theme_classes with that intent to receive the exact class/prop contract.',
|
|
1617
|
-
'Call build_extension_ui kind=theme_review or kind=review before saving extension UI; validate_extension_code and extension write tools also reject hard theme violations.',
|
|
1618
|
-
'Never fix one extension by injecting global CSS, redefining the app palette, or adding theme guards.',
|
|
1619
|
-
'Use get_theme_class_reference only when debugging theme internals or when the user explicitly asks for the full theme/class map.',
|
|
1620
|
-
],
|
|
1621
|
-
themeIntents: [
|
|
1622
|
-
'neutral_surface',
|
|
1623
|
-
'muted_surface',
|
|
1624
|
-
'flat_surface',
|
|
1625
|
-
'hover_row',
|
|
1626
|
-
'primary_identity',
|
|
1627
|
-
'primary_soft_icon_tile',
|
|
1628
|
-
'primary_progress',
|
|
1629
|
-
'status_success',
|
|
1630
|
-
'status_warning',
|
|
1631
|
-
'status_danger',
|
|
1632
|
-
'status_info',
|
|
1633
|
-
'primary_action',
|
|
1634
|
-
'secondary_action',
|
|
1635
|
-
'ghost_navigation_action',
|
|
1636
|
-
'danger_action',
|
|
1637
|
-
'divider',
|
|
1638
|
-
'text',
|
|
1639
|
-
],
|
|
1640
|
-
components: [
|
|
1641
|
-
'Use Nuxt UI/eApp components for normal controls: UButton, UInput, UTextarea, USelectMenu/USelect, USwitch, UCheckbox, UTabs, UBadge, UModal, and CommonDrawer when available.',
|
|
1642
|
-
'Use auto-injected components directly in the template with PascalCase names. Do not call resolveComponent() to manually resolve Nuxt UI/eApp components inside extension SFCs; it can compile but render unresolved lowercase DOM tags such as <ubutton>.',
|
|
1643
|
-
'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.',
|
|
1644
|
-
'Inputs and textareas should not add hover movement or decorative hover states; focus, invalid, disabled, and loading states must be explicit.',
|
|
1645
|
-
'For drawers, modals, page shell headers/actions, permission gates, empty states, resource lists, resource grids, form editors, widgets, menu/account panel registries, tabs, upload modals, api_usage, notify, and runtime/theming reviews, call build_extension_ui with the matching kind after extension acknowledgement before patching raw Vue.',
|
|
1646
|
-
'Use build_extension_ui kind=theme_classes for theme classes by intent, and kind=runtime_review, theme_review, or review before saving generated snippets that include composables, theme classes, high-contract UI, or native buttons.',
|
|
1647
|
-
'Use build_extension_ui kind=resource_grid for workboards, dashboards, catalogs, and responsive card collections instead of placing UCard children directly into a full-width list frame.',
|
|
1648
|
-
'Use the EmptyState runtime alias returned by build_extension_ui kind=empty_state; CommonEmptyState is not registered as a dynamic extension tag.',
|
|
1649
|
-
'Extension validation rejects UInput, UTextarea, USelect, USelectMenu, UInputMenu, UInputNumber, UInputTags, UInputTime, and UInputDate without class="w-full" unless marked data-compact or data-inline.',
|
|
1650
|
-
'Use UBadge or token-backed badge spans for status. Keep badges legible in both themes with tokenized background, text, and border.',
|
|
1651
|
-
],
|
|
1652
|
-
appComposables: [
|
|
1653
|
-
'Call useApi() as a top-level setup composable. It returns data/error/pending/status refs plus execute/refresh; call or await execute()/refresh() from onMounted, watchers, or user actions when the request should run.',
|
|
1654
|
-
'Do not write useNotify shapes from memory. Use build_extension_ui kind=notify for known-good notification snippets. Use build_extension_ui kind=api_usage only when a generated fetch/mutation scaffold is useful.',
|
|
1655
|
-
'Use build_extension_ui kind=runtime_review or kind=review before saving extension code that includes useApi, useNotify, getPackages, or package loading.',
|
|
1656
|
-
'validate_extension_code and extension write tools reject static imports, useToast/useNotify.add misuse, and JSON.stringify useApi options.',
|
|
1657
|
-
],
|
|
1658
|
-
shellComponentContracts: {
|
|
1659
|
-
CommonDrawer: [
|
|
1660
|
-
'Use build_extension_ui kind=drawer for generated drawer/editing snippets.',
|
|
1661
|
-
'The builder owns slots, managed footer actions, full-width fields, native button types, and loading/error/body structure. CommonDrawer disables drag dismissal globally; do not add handle-only, drag handlers, or swipe-to-close behavior.',
|
|
1662
|
-
],
|
|
1663
|
-
CommonModal: [
|
|
1664
|
-
'Use build_extension_ui kind=modal for generated modal/confirmation snippets.',
|
|
1665
|
-
'The builder owns UModal/CommonModal aliasing, slots, managed footer actions, full-width fields, native button types, and modal surface constraints.',
|
|
1666
|
-
],
|
|
1667
|
-
PermissionGate: [
|
|
1668
|
-
'Use build_extension_ui kind=permission_gate for generated permission wrapper snippets.',
|
|
1669
|
-
'UI gates are operator UX only; backend route permissions and handler/hook owner checks remain authoritative.',
|
|
1670
|
-
],
|
|
1671
|
-
Widget: [
|
|
1672
|
-
'Use build_extension_ui kind=widget for generated widget include snippets.',
|
|
1673
|
-
'The builder owns numeric id usage, reactive prop/event wiring, and page/widget ownership warnings.',
|
|
1674
|
-
],
|
|
1675
|
-
actionButtons: [
|
|
1676
|
-
'Use build_extension_ui kind=review for generated snippets with native buttons or theme classes.',
|
|
1677
|
-
'Validation/review catches missing type="button" and high-contract component mistakes before saving.',
|
|
1678
|
-
],
|
|
1679
|
-
},
|
|
1680
|
-
loadingAndLists: [
|
|
1681
|
-
'For first load of card/list pages, render calm skeleton cards with a slow pulse. Use USkeleton or shared loading components so the app-owned skeleton theme controls contrast and accent matching. For subsequent pagination/filter refreshes, keep the card shells mounted and skeletonize card content until the new list is ready.',
|
|
1682
|
-
'Keep pagination inside the same transition/loading branch as the list. Do not show pagination before the list content has left loading.',
|
|
1683
|
-
'Use bounded pagination for operational lists. Do not replace pagination with arbitrary fixed caps such as 30 or 50.',
|
|
1684
|
-
'Empty states should use an app-matched card surface with compact icon tile, title, and description; do not use huge blank white panels or naked UEmpty chrome on page surfaces.',
|
|
1685
|
-
],
|
|
1686
|
-
interaction: [
|
|
1687
|
-
'Every mutating button needs pending/disabled state, success/error feedback, and must close or update its modal when the operation completes.',
|
|
1688
|
-
'Do not refetch broad lists after selecting one row. Keep local selection state and fetch only the detail or mutation result needed.',
|
|
1689
|
-
'Customer-facing toasts must describe the operation. Do not surface raw job ids, flow ids, or worker ids.',
|
|
1690
|
-
],
|
|
1691
|
-
security: [
|
|
1692
|
-
'Decide route permission, owner scope, and field exposure before writing UI or backend logic.',
|
|
1693
|
-
'UI checks are only guidance; handlers/hooks must independently enforce owner/root-admin authorization.',
|
|
1694
|
-
'Use the most specific business route or MCP tool. Do not write directly to raw tables when a domain route exists.',
|
|
1695
|
-
],
|
|
1696
|
-
shellNotificationContract: {
|
|
1697
|
-
menu: 'useMenuNotificationRegistry().register({ id, target: { id?, path?, route? }, value?, color?, title?, order? }). value renders a count/chip; omitting value renders a dot. Parent menus sum numeric child values.',
|
|
1698
|
-
accountPanel: 'useAccountPanelRegistry().register({ id, label, description, icon, count?, badge?, badgeColor?, expanded?, onToggle?, contentComponent? }). count is preferred over badge and the account trigger sums numeric visible item counts, capped at 99+.',
|
|
1699
|
-
lifecycle: 'Register from global extensions for app-wide notification state; stable ids replace previous registrations and component-owned registrations are removed on unmount.',
|
|
1700
|
-
reasoning: 'Counts and dots are different promises. A count says the shell knows an exact or bounded number from an appropriate notification/summary source. A dot says the shell only knows that new attention exists. Avoid fetching the destination domain list just to make a menu badge more precise.',
|
|
1701
|
-
},
|
|
1702
|
-
};
|
|
1703
|
-
}
|
|
1704
|
-
function getThemeClassReference() {
|
|
1705
|
-
return {
|
|
1706
|
-
action: 'theme_class_reference',
|
|
1707
|
-
authority: 'Authoritative Enfyra theme & color contract. Source of truth: documents/app/theme-color-contract.md. App owns color via theme.css + main.css + app.config.ts only; pages/extensions consume classes and Nuxt UI props.',
|
|
1708
|
-
baseLayers: {
|
|
1709
|
-
material: '--md-* (runtime primary picker, HCT/Material You). Drives identity/brand. Never read directly in templates.',
|
|
1710
|
-
status: '--st-success/--st-warning/--st-error/--st-info. Fixed semantic palette. Never read directly in templates.',
|
|
1711
|
-
},
|
|
1712
|
-
nuxtUiColors: {
|
|
1713
|
-
primary: 'runtime --md-primary (main brand action/identity). NEVER substitute a concrete palette.',
|
|
1714
|
-
secondary: 'runtime --md-tertiary (intentional secondary accent only).',
|
|
1715
|
-
success: '--st-success (healthy/success).',
|
|
1716
|
-
warning: '--st-warning (pending/attention).',
|
|
1717
|
-
error: 'single --danger-* lane from Material error roles (destructive/error). Ghost danger text uses --danger-on-surface; danger fills use --danger-surface.',
|
|
1718
|
-
info: '--st-info (informational).',
|
|
1719
|
-
neutral: 'neutral surfaces (secondary chrome, non-actions).',
|
|
1720
|
-
},
|
|
1721
|
-
classes: [
|
|
1722
|
-
{ group: 'Surfaces (large ordinary - keep neutral)', classes: 'eapp-surface-card, eapp-surface-muted, eapp-surface-flat, eapp-surface-hover' },
|
|
1723
|
-
{ group: 'Text', classes: 'eapp-text-primary, eapp-text-secondary, eapp-text-tertiary, eapp-text-quaternary' },
|
|
1724
|
-
{ group: 'Runtime primary identity', classes: 'eapp-primary-solid, eapp-primary-text, eapp-primary-soft(+hover), eapp-primary-subtle, eapp-primary-surface(+hover), eapp-primary-border, eapp-primary-ring' },
|
|
1725
|
-
{ group: 'Status (badges/small icons/short text only)', classes: 'eapp-status-{success|warning|danger|info|neutral}-{soft|text|border}' },
|
|
1726
|
-
{ group: 'Radius', classes: 'eapp-radius-card, eapp-radius-panel, eapp-radius-control, eapp-radius-subcontrol, eapp-radius-pill' },
|
|
1727
|
-
{ group: 'Icon tile geometry', classes: 'eapp-icon-tile, eapp-icon-tile-sm, eapp-icon-tile-lg' },
|
|
1728
|
-
{ group: 'Dividers', classes: 'eapp-divider, eapp-divide-y' },
|
|
1729
|
-
{ group: 'Modal', classes: 'eapp-modal-surface (never surface-card as modal ui.content)' },
|
|
1730
|
-
],
|
|
1731
|
-
forbidden: [
|
|
1732
|
-
'Raw CSS variables in templates: text-[var(--*)], bg-[var(--*)], border-[var(--*)].',
|
|
1733
|
-
'Tailwind palette accents: from-cyan-*, text-violet-*, bg-green-*, bg-emerald-*, text-gray-*, bg-slate-*, dark:bg-zinc-950.',
|
|
1734
|
-
'Concrete palette substitution (color="violet"/"cyan"/..., from-cyan-*, text-violet-*, bg-green-*, bg-emerald-*, dark:bg-zinc-950).',
|
|
1735
|
-
'Hardcoded hex colors or inline style="color:#..." for theme-driven surfaces.',
|
|
1736
|
-
'Reading --md-* / --st-* / --badge-* base variables directly from extension templates.',
|
|
1737
|
-
],
|
|
1738
|
-
allowedShortUtilities: [
|
|
1739
|
-
'Tailwind v4 short utilities ARE canonical and preferred: bg-primary, text-primary, border-primary, ring-primary, bg-success, text-error, bg-warning, text-info, bg-secondary.',
|
|
1740
|
-
'Opacity modifiers work natively via v4 color-mix: bg-primary/10, ring-primary/20, text-primary/70, bg-success/15.',
|
|
1741
|
-
'Use eapp-* classes only for intent surfaces with no Tailwind equivalent (eapp-primary-surface/solid/soft/subtle, eapp-surface-card/muted/flat/hover, eapp-divider/divide-y, eapp-radius-*, eapp-modal-surface).',
|
|
1742
|
-
],
|
|
1743
|
-
chooseByIntent: [
|
|
1744
|
-
'Normal accent / active tab / progress / primary CTA -> primary (eapp-primary-* or color="primary").',
|
|
1745
|
-
'True semantic state -> status (eapp-status-* or color="success|warning|error|info").',
|
|
1746
|
-
'Large ordinary surface -> eapp-surface-*; put a small status badge inside.',
|
|
1747
|
-
'Whole block is active identity -> eapp-primary-surface (+hover), subtle only.',
|
|
1748
|
-
],
|
|
1749
|
-
};
|
|
1750
|
-
}
|
|
1751
|
-
function parseJsonObjectArg(name, value, fallback = {}) {
|
|
1752
|
-
if (value === undefined || value === null || value === '')
|
|
1753
|
-
return fallback;
|
|
1754
|
-
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
1755
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1756
|
-
throw new Error(`${name} must be a JSON object.`);
|
|
1757
|
-
}
|
|
1758
|
-
return parsed;
|
|
1759
|
-
}
|
|
1760
|
-
function normalizeMenuPermissionArg(permission) {
|
|
1761
|
-
const parsed = parseJsonObjectArg('permission', permission, null);
|
|
1762
|
-
if (!parsed)
|
|
1763
|
-
return null;
|
|
1764
|
-
if (Object.keys(parsed).length === 0)
|
|
1765
|
-
return null;
|
|
1766
|
-
return parsed;
|
|
1767
|
-
}
|
|
1768
|
-
function parseJsonArrayArg(name, value, fallback = []) {
|
|
1769
|
-
if (value === undefined || value === null || value === '')
|
|
1770
|
-
return fallback;
|
|
1771
|
-
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
1772
|
-
if (!Array.isArray(parsed)) {
|
|
1773
|
-
throw new Error(`${name} must be a JSON array.`);
|
|
1774
|
-
}
|
|
1775
|
-
return parsed;
|
|
1776
|
-
}
|
|
1777
|
-
function filterQuery(filter) {
|
|
1778
|
-
return encodeURIComponent(JSON.stringify(filter));
|
|
1779
|
-
}
|
|
1780
|
-
async function reloadBestEffort(apiUrl, path) {
|
|
1781
|
-
try {
|
|
1782
|
-
const result = await fetchAPI(apiUrl, path, { method: 'POST' });
|
|
1783
|
-
return { attempted: true, succeeded: true, result };
|
|
1784
|
-
}
|
|
1785
|
-
catch (error) {
|
|
1786
|
-
return { attempted: true, succeeded: false, error: error?.message || String(error) };
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
function naturalPartialReload(reason) {
|
|
1790
|
-
return { attempted: false, succeeded: true, reason };
|
|
1791
|
-
}
|
|
1792
|
-
async function validateDynamicScript(apiUrl, sourceCode, scriptLanguage = 'javascript') {
|
|
1793
|
-
validatePortableScriptSource(sourceCode);
|
|
1794
|
-
const result = await fetchAPI(apiUrl, '/admin/script/validate', {
|
|
1795
|
-
method: 'POST',
|
|
1796
|
-
body: JSON.stringify({ sourceCode, scriptLanguage }),
|
|
1797
|
-
});
|
|
1798
|
-
if (result?.valid === false || result?.success === false) {
|
|
1799
|
-
throw new Error(result?.error?.message || 'Dynamic script validation failed.');
|
|
1800
|
-
}
|
|
1801
|
-
return {
|
|
1802
|
-
valid: true,
|
|
1803
|
-
scriptLanguage,
|
|
1804
|
-
compiledLength: typeof result?.data?.compiledCode === 'string' ? result.data.compiledCode.length : undefined,
|
|
1805
|
-
};
|
|
1806
|
-
}
|
|
1807
|
-
function readTemplateBlocks(code) {
|
|
1808
|
-
const blocks = [];
|
|
1809
|
-
const lower = String(code || '').toLowerCase();
|
|
1810
|
-
let index = 0;
|
|
1811
|
-
while (index < lower.length) {
|
|
1812
|
-
const openStart = lower.indexOf('<template', index);
|
|
1813
|
-
if (openStart === -1)
|
|
1814
|
-
break;
|
|
1815
|
-
const boundary = lower[openStart + '<template'.length];
|
|
1816
|
-
if (boundary && !/\s|>/.test(boundary)) {
|
|
1817
|
-
index = openStart + 1;
|
|
1818
|
-
continue;
|
|
1819
|
-
}
|
|
1820
|
-
const openEnd = lower.indexOf('>', openStart + '<template'.length);
|
|
1821
|
-
if (openEnd === -1)
|
|
1822
|
-
break;
|
|
1823
|
-
const closeStart = lower.indexOf('</template', openEnd + 1);
|
|
1824
|
-
if (closeStart === -1)
|
|
1825
|
-
break;
|
|
1826
|
-
blocks.push(String(code).slice(openEnd + 1, closeStart));
|
|
1827
|
-
index = closeStart + '</template'.length;
|
|
1828
|
-
}
|
|
1829
|
-
return blocks;
|
|
1830
|
-
}
|
|
1831
|
-
function readTemplateTagName(template, start) {
|
|
1832
|
-
const next = template[start + 1];
|
|
1833
|
-
if (!next || next === '!' || next === '?')
|
|
1834
|
-
return null;
|
|
1835
|
-
let index = start + (next === '/' ? 2 : 1);
|
|
1836
|
-
while (/\s/.test(template[index] || ''))
|
|
1837
|
-
index += 1;
|
|
1838
|
-
const nameStart = index;
|
|
1839
|
-
while (/[\w.-]/.test(template[index] || ''))
|
|
1840
|
-
index += 1;
|
|
1841
|
-
return index > nameStart ? template.slice(nameStart, index) : null;
|
|
1842
|
-
}
|
|
1843
|
-
function findInvalidExtensionSortSyntax(code) {
|
|
1844
|
-
const source = String(code || '');
|
|
1845
|
-
if (/\bsort\s*:\s*\[[\s\S]*?\]/u.test(source)) {
|
|
1846
|
-
return 'sort arrays create repeated query parameters.';
|
|
1847
|
-
}
|
|
1848
|
-
if (/\bsort\s*:\s*(['"`])[^'"`]*:\s*(?:asc|desc)\1/iu.test(source)) {
|
|
1849
|
-
return 'SQL-style field:ASC/field:DESC tokens are not valid Enfyra REST sort syntax.';
|
|
1850
|
-
}
|
|
1851
|
-
return null;
|
|
1852
|
-
}
|
|
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
|
-
}
|
|
1858
|
-
if (/\bresolveComponent\s*\(/.test(String(code || ''))) {
|
|
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.');
|
|
1860
|
-
}
|
|
1861
|
-
const invalidSortSyntax = findInvalidExtensionSortSyntax(code);
|
|
1862
|
-
if (invalidSortSyntax) {
|
|
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".`);
|
|
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
|
-
});
|
|
1872
|
-
if (violations.length) {
|
|
1873
|
-
const first = violations[0];
|
|
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.`);
|
|
1875
|
-
}
|
|
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 }));
|
|
1882
|
-
if (missingFullWidthFields.length) {
|
|
1883
|
-
const first = missingFullWidthFields[0];
|
|
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}`);
|
|
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
|
-
}
|
|
1891
|
-
const themeReview = reviewExtensionThemeContract(code);
|
|
1892
|
-
const firstThemeError = themeReview.issues.find((issue) => issue.severity === 'error');
|
|
1893
|
-
if (firstThemeError) {
|
|
1894
|
-
throw new Error(`Invalid extension theme contract: ${firstThemeError.message} Rule: ${firstThemeError.rule}. ${firstThemeError.suggestion}`);
|
|
1895
|
-
}
|
|
1896
|
-
const runtimeReview = reviewExtensionRuntimeContract(code);
|
|
1897
|
-
const firstRuntimeError = runtimeReview.issues.find((issue) => issue.severity === 'error');
|
|
1898
|
-
if (firstRuntimeError) {
|
|
1899
|
-
throw new Error(`Invalid extension runtime contract: ${firstRuntimeError.message} Rule: ${firstRuntimeError.rule}. ${firstRuntimeError.suggestion}`);
|
|
1900
|
-
}
|
|
1901
|
-
return { vueSfcAst: 'passed', componentCasing: 'passed', fieldWidth: 'passed', themeContract: 'passed', runtimeContract: 'passed' };
|
|
1902
|
-
}
|
|
1903
|
-
export async function validateExtensionCode(apiUrl, code, name, options = {}) {
|
|
1904
|
-
const localChecks = validateExtensionCodeLocally(code, options);
|
|
1905
|
-
const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
|
|
1906
|
-
method: 'POST',
|
|
1907
|
-
body: JSON.stringify({ code, name }),
|
|
1908
|
-
});
|
|
1909
|
-
if (result?.success === false) {
|
|
1910
|
-
throw new Error(result?.error?.message || 'Extension validation failed.');
|
|
1911
|
-
}
|
|
1912
|
-
return {
|
|
1913
|
-
valid: true,
|
|
1914
|
-
localChecks,
|
|
1915
|
-
extensionId: result?.extensionId || name || null,
|
|
1916
|
-
compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
|
|
1917
|
-
};
|
|
1918
|
-
}
|
|
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, }) {
|
|
1992
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
1993
|
-
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
1994
|
-
if (!id && !name)
|
|
1995
|
-
throw new Error('Provide id or name to update an existing extension.');
|
|
1996
|
-
const existing = 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');
|
|
1999
|
-
if (!existing)
|
|
2000
|
-
throw new Error(`Extension not found: ${id || name}`);
|
|
2001
|
-
const extensionId = getId(existing);
|
|
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 });
|
|
2008
|
-
const body = {
|
|
2009
|
-
code,
|
|
2010
|
-
...(description !== undefined ? { description } : {}),
|
|
2011
|
-
...(isEnabled !== undefined ? { isEnabled } : {}),
|
|
2012
|
-
...(version !== undefined ? { version } : {}),
|
|
2013
|
-
};
|
|
2014
|
-
const result = await fetchAPI(apiUrl, `/enfyra_extension/${encodeURIComponent(String(extensionId))}`, {
|
|
2015
|
-
method: 'PATCH',
|
|
2016
|
-
body: JSON.stringify(body),
|
|
2017
|
-
});
|
|
2018
|
-
const nextSha256 = sha256Text(code);
|
|
2019
|
-
const verification = await verifyExtensionRuntime(apiUrl, {
|
|
2020
|
-
id: extensionId,
|
|
2021
|
-
name: undefined,
|
|
2022
|
-
uiPattern,
|
|
2023
|
-
expectedSha256: nextSha256,
|
|
2024
|
-
});
|
|
2025
|
-
return {
|
|
2026
|
-
action: 'extension_code_updated',
|
|
2027
|
-
id: extensionId,
|
|
2028
|
-
name: existing.name || name || null,
|
|
2029
|
-
type: existing.type || null,
|
|
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
|
-
}),
|
|
2039
|
-
validation,
|
|
2040
|
-
contractReview: {
|
|
2041
|
-
valid: contractReview.valid,
|
|
2042
|
-
issueCount: contractReview.issueCount,
|
|
2043
|
-
pattern: contractReview.ui?.pattern,
|
|
2044
|
-
},
|
|
2045
|
-
verification,
|
|
2046
|
-
};
|
|
2047
|
-
}
|
|
2048
|
-
function sha256Text(value) {
|
|
2049
|
-
return createHash('sha256').update(String(value ?? '')).digest('hex');
|
|
2050
|
-
}
|
|
2051
|
-
function escapeRegExp(value) {
|
|
2052
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2053
|
-
}
|
|
2054
|
-
function whitespaceFlexiblePattern(search) {
|
|
2055
|
-
const parts = String(search ?? '').trim().split(/\s+/).filter(Boolean);
|
|
2056
|
-
if (!parts.length)
|
|
2057
|
-
throw new Error('search must contain at least one non-whitespace token.');
|
|
2058
|
-
return new RegExp(parts.map(escapeRegExp).join('\\s+'), 'g');
|
|
2059
|
-
}
|
|
2060
|
-
function regexMatches(code, regex) {
|
|
2061
|
-
const matches = [];
|
|
2062
|
-
regex.lastIndex = 0;
|
|
2063
|
-
let match = regex.exec(code);
|
|
2064
|
-
while (match) {
|
|
2065
|
-
matches.push(match);
|
|
2066
|
-
if (match[0] === '')
|
|
2067
|
-
regex.lastIndex += 1;
|
|
2068
|
-
match = regex.exec(code);
|
|
2069
|
-
}
|
|
2070
|
-
regex.lastIndex = 0;
|
|
2071
|
-
return matches;
|
|
2072
|
-
}
|
|
2073
|
-
function replaceFirstExact(code, search, replace) {
|
|
2074
|
-
const index = code.indexOf(search);
|
|
2075
|
-
if (index === -1)
|
|
2076
|
-
return code;
|
|
2077
|
-
return `${code.slice(0, index)}${replace}${code.slice(index + search.length)}`;
|
|
2078
|
-
}
|
|
2079
|
-
function normalizeExtensionPatch(input, index) {
|
|
2080
|
-
const search = input?.search;
|
|
2081
|
-
if (typeof search !== 'string' || search.length === 0) {
|
|
2082
|
-
throw new Error(`patches[${index}].search must be a non-empty string.`);
|
|
2083
|
-
}
|
|
2084
|
-
if (input?.replace === undefined) {
|
|
2085
|
-
throw new Error(`patches[${index}].replace is required.`);
|
|
2086
|
-
}
|
|
2087
|
-
const searchMode = input?.searchMode || 'exact';
|
|
2088
|
-
if (!['exact', 'whitespace'].includes(searchMode)) {
|
|
2089
|
-
throw new Error(`patches[${index}].searchMode must be "exact" or "whitespace".`);
|
|
2090
|
-
}
|
|
2091
|
-
return {
|
|
2092
|
-
search,
|
|
2093
|
-
replace: String(input.replace),
|
|
2094
|
-
searchMode,
|
|
2095
|
-
replaceAll: Boolean(input?.replaceAll),
|
|
2096
|
-
};
|
|
2097
|
-
}
|
|
2098
|
-
function normalizeExtensionPatchInputs({ search, replace, searchMode, replaceAll, patches }) {
|
|
2099
|
-
if (Array.isArray(patches) && patches.length > 0) {
|
|
2100
|
-
return patches.map(normalizeExtensionPatch);
|
|
2101
|
-
}
|
|
2102
|
-
return [normalizeExtensionPatch({ search, replace, searchMode, replaceAll }, 0)];
|
|
2103
|
-
}
|
|
2104
|
-
function applyOneExtensionPatch(code, patch, index) {
|
|
2105
|
-
const beforeLength = code.length;
|
|
2106
|
-
let occurrences = 0;
|
|
2107
|
-
let nextCode = code;
|
|
2108
|
-
if (patch.searchMode === 'whitespace') {
|
|
2109
|
-
const regex = whitespaceFlexiblePattern(patch.search);
|
|
2110
|
-
occurrences = regexMatches(code, regex).length;
|
|
2111
|
-
if (occurrences === 0) {
|
|
2112
|
-
throw new Error(`Patch ${index} search fragment was not found with whitespace-flex matching.`);
|
|
2113
|
-
}
|
|
2114
|
-
if (!patch.replaceAll && occurrences !== 1) {
|
|
2115
|
-
throw new Error(`Patch ${index} expected search fragment to occur exactly once; found ${occurrences}. Use replaceAll=true, a more specific fragment, or update_extension_code for a full replacement.`);
|
|
2116
|
-
}
|
|
2117
|
-
let replaced = false;
|
|
2118
|
-
nextCode = code.replace(regex, (match) => {
|
|
2119
|
-
if (patch.replaceAll)
|
|
2120
|
-
return patch.replace;
|
|
2121
|
-
if (replaced)
|
|
2122
|
-
return match;
|
|
2123
|
-
replaced = true;
|
|
2124
|
-
return patch.replace;
|
|
2125
|
-
});
|
|
2126
|
-
}
|
|
2127
|
-
else {
|
|
2128
|
-
occurrences = code.split(patch.search).length - 1;
|
|
2129
|
-
if (occurrences === 0) {
|
|
2130
|
-
throw new Error(`Patch ${index} search fragment was not found.`);
|
|
2131
|
-
}
|
|
2132
|
-
if (!patch.replaceAll && occurrences !== 1) {
|
|
2133
|
-
throw new Error(`Patch ${index} expected search fragment to occur exactly once; found ${occurrences}. Use replaceAll=true, a more specific fragment, or update_extension_code for a full replacement.`);
|
|
2134
|
-
}
|
|
2135
|
-
nextCode = patch.replaceAll
|
|
2136
|
-
? code.split(patch.search).join(patch.replace)
|
|
2137
|
-
: replaceFirstExact(code, patch.search, patch.replace);
|
|
2138
|
-
}
|
|
2139
|
-
return {
|
|
2140
|
-
code: nextCode,
|
|
2141
|
-
result: {
|
|
2142
|
-
index,
|
|
2143
|
-
searchMode: patch.searchMode,
|
|
2144
|
-
replaceAll: patch.replaceAll,
|
|
2145
|
-
occurrences,
|
|
2146
|
-
beforeLength,
|
|
2147
|
-
afterLength: nextCode.length,
|
|
2148
|
-
},
|
|
2149
|
-
};
|
|
2150
|
-
}
|
|
2151
|
-
export function applyExtensionCodePatches(code, patches) {
|
|
2152
|
-
const normalizedPatches = normalizeExtensionPatchInputs(patches);
|
|
2153
|
-
let nextCode = String(code ?? '');
|
|
2154
|
-
const results = [];
|
|
2155
|
-
normalizedPatches.forEach((patch, index) => {
|
|
2156
|
-
const applied = applyOneExtensionPatch(nextCode, patch, index);
|
|
2157
|
-
nextCode = applied.code;
|
|
2158
|
-
results.push(applied.result);
|
|
2159
|
-
});
|
|
2160
|
-
return { code: nextCode, patches: normalizedPatches, results };
|
|
2161
|
-
}
|
|
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, }) {
|
|
2186
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
2187
|
-
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
2188
|
-
if (!id && !name)
|
|
2189
|
-
throw new Error('Provide id or name to patch an existing extension.');
|
|
2190
|
-
const existing = id
|
|
2191
|
-
? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,menu.id,code')
|
|
2192
|
-
: await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,menu.id,code');
|
|
2193
|
-
if (!existing)
|
|
2194
|
-
throw new Error(`Extension not found: ${id || name}`);
|
|
2195
|
-
const extensionId = getId(existing);
|
|
2196
|
-
const currentCode = String(existing.code ?? '');
|
|
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
|
-
}
|
|
2201
|
-
if (expectedSha256 && expectedSha256 !== currentSha256) {
|
|
2202
|
-
throw new Error(`Extension code hash mismatch. Expected ${expectedSha256}, got ${currentSha256}. Re-read the extension before patching.`);
|
|
2203
|
-
}
|
|
2204
|
-
const patchResult = applyExtensionCodePatches(currentCode, { search, replace, searchMode, replaceAll, patches });
|
|
2205
|
-
const nextCode = patchResult.code;
|
|
2206
|
-
const nextSha256 = sha256Text(nextCode);
|
|
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
|
-
});
|
|
2215
|
-
const nextStepPatchInput = patchResult.patches.length === 1
|
|
2216
|
-
? {
|
|
2217
|
-
search: patchResult.patches[0].search,
|
|
2218
|
-
replace: patchResult.patches[0].replace,
|
|
2219
|
-
searchMode: patchResult.patches[0].searchMode,
|
|
2220
|
-
replaceAll: patchResult.patches[0].replaceAll,
|
|
2221
|
-
}
|
|
2222
|
-
: { patches: patchResult.patches };
|
|
2223
|
-
const preview = {
|
|
2224
|
-
action: apply ? 'extension_code_patch_applied' : 'extension_code_patch_previewed',
|
|
2225
|
-
id: extensionId,
|
|
2226
|
-
name: existing.name || name || null,
|
|
2227
|
-
type: existing.type || null,
|
|
2228
|
-
currentSha256,
|
|
2229
|
-
nextSha256,
|
|
2230
|
-
currentLength: currentCode.length,
|
|
2231
|
-
nextLength: nextCode.length,
|
|
2232
|
-
occurrences,
|
|
2233
|
-
patchResults: patchResult.results,
|
|
2234
|
-
diff,
|
|
2235
|
-
atomic: patchResult.patches.length > 1,
|
|
2236
|
-
apply: Boolean(apply),
|
|
2237
|
-
};
|
|
2238
|
-
if (!apply) {
|
|
2239
|
-
return {
|
|
2240
|
-
...preview,
|
|
2241
|
-
nextStep: {
|
|
2242
|
-
tool: 'patch_extension_code',
|
|
2243
|
-
input: { id: extensionId, expectedSha256: currentSha256, ...nextStepPatchInput, apply: true },
|
|
2244
|
-
},
|
|
2245
|
-
};
|
|
2246
|
-
}
|
|
2247
|
-
const result = await updateExtensionCode(apiUrl, {
|
|
2248
|
-
id: extensionId,
|
|
2249
|
-
name: undefined,
|
|
2250
|
-
code: nextCode,
|
|
2251
|
-
description,
|
|
2252
|
-
isEnabled,
|
|
2253
|
-
version,
|
|
2254
|
-
expectedSha256: currentSha256,
|
|
2255
|
-
uiPattern,
|
|
2256
|
-
globalRulesAckKey,
|
|
2257
|
-
extensionKnowledgeAckKey,
|
|
2258
|
-
});
|
|
2259
|
-
return {
|
|
2260
|
-
...preview,
|
|
2261
|
-
result,
|
|
2262
|
-
validation: result.validation,
|
|
2263
|
-
};
|
|
2264
|
-
}
|
|
2265
|
-
function resolveColumn(table, columnName) {
|
|
2266
|
-
const column = (table.columns || []).find((item) => item?.name === columnName || sameId(getId(item), columnName));
|
|
2267
|
-
if (!column)
|
|
2268
|
-
throw new Error(`Column not found: ${table.name}.${columnName}`);
|
|
2269
|
-
return column;
|
|
2270
|
-
}
|
|
2271
|
-
function resolveRelation(table, relationName) {
|
|
2272
|
-
const relation = (table.relations || []).find((item) => item?.propertyName === relationName || item?.name === relationName || sameId(getId(item), relationName));
|
|
2273
|
-
if (!relation)
|
|
2274
|
-
throw new Error(`Relation not found: ${table.name}.${relationName}`);
|
|
2275
|
-
return relation;
|
|
2276
|
-
}
|
|
2277
|
-
async function findRecord(apiUrl, tableName, filter, fields = '*') {
|
|
2278
|
-
const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=1&fields=${encodeURIComponent(fields)}`);
|
|
2279
|
-
return unwrapData(result)[0] || null;
|
|
2280
|
-
}
|
|
2281
|
-
async function fetchRecords(apiUrl, tableName, filter, fields = '*', limit = 1000) {
|
|
2282
|
-
const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=${limit}&fields=${encodeURIComponent(fields)}`);
|
|
2283
|
-
return unwrapData(result);
|
|
2284
|
-
}
|
|
2285
|
-
async function createOrPatch(apiUrl, tableName, existing, body) {
|
|
2286
|
-
if (existing) {
|
|
2287
|
-
const result = await fetchAPI(apiUrl, `/${tableName}/${encodeURIComponent(String(getId(existing)))}`, {
|
|
2288
|
-
method: 'PATCH',
|
|
2289
|
-
body: JSON.stringify(body),
|
|
2290
|
-
});
|
|
2291
|
-
return { action: 'updated', result, id: getId(firstDataRecord(result)) || getId(existing) };
|
|
2292
|
-
}
|
|
2293
|
-
const result = await fetchAPI(apiUrl, `/${tableName}`, {
|
|
2294
|
-
method: 'POST',
|
|
2295
|
-
body: JSON.stringify(body),
|
|
2296
|
-
});
|
|
2297
|
-
return { action: 'created', result, id: getId(firstDataRecord(result)) };
|
|
2298
|
-
}
|
|
2299
|
-
async function resolveRole(apiUrl, { roleId, roleName }) {
|
|
2300
|
-
if (roleId && roleName)
|
|
2301
|
-
throw new Error('Provide roleId or roleName, not both.');
|
|
2302
|
-
if (!roleId && !roleName)
|
|
2303
|
-
return null;
|
|
2304
|
-
if (roleId)
|
|
2305
|
-
return { id: roleId, name: null };
|
|
2306
|
-
const role = await findRecord(apiUrl, 'enfyra_role', { name: { _eq: roleName } }, 'id,_id,name');
|
|
2307
|
-
if (!role)
|
|
2308
|
-
throw new Error(`Role not found: ${roleName}`);
|
|
2309
|
-
return { id: getId(role), name: role.name };
|
|
2310
|
-
}
|
|
2311
|
-
function assertOneScope({ roleId, roleName, allowedUserIds }) {
|
|
2312
|
-
if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
|
|
2313
|
-
throw new Error('Provide roleId, roleName, or allowedUserIds.');
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
function normalizeFlowStepBody(step, flowId) {
|
|
2317
|
-
const body = {
|
|
2318
|
-
key: step.key,
|
|
2319
|
-
type: step.type,
|
|
2320
|
-
stepOrder: step.order ?? 0,
|
|
2321
|
-
config: step.config ?? {},
|
|
2322
|
-
timeout: step.timeout,
|
|
2323
|
-
isEnabled: step.isEnabled ?? true,
|
|
2324
|
-
flow: { id: flowId },
|
|
2325
|
-
};
|
|
2326
|
-
if (step.sourceCode !== undefined)
|
|
2327
|
-
body.sourceCode = step.sourceCode;
|
|
2328
|
-
if (step.scriptLanguage !== undefined)
|
|
2329
|
-
body.scriptLanguage = step.scriptLanguage;
|
|
2330
|
-
return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
|
|
2331
|
-
}
|
|
2332
|
-
async function ensureMenu(apiUrl, { label, path, icon, type = 'Menu', order = 0, permission, description, isEnabled = true, globalRulesAckKey, }) {
|
|
2333
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
2334
|
-
const normalizedPath = path ? normalizeRestPath(path) : undefined;
|
|
2335
|
-
const existing = normalizedPath
|
|
2336
|
-
? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,path,label')
|
|
2337
|
-
: await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: label } }, 'id,_id,path,label');
|
|
2338
|
-
const body = {
|
|
2339
|
-
label,
|
|
2340
|
-
...(normalizedPath ? { path: normalizedPath } : {}),
|
|
2341
|
-
icon,
|
|
2342
|
-
type,
|
|
2343
|
-
order,
|
|
2344
|
-
description,
|
|
2345
|
-
isEnabled,
|
|
2346
|
-
};
|
|
2347
|
-
if (permission !== undefined) {
|
|
2348
|
-
body.permission = normalizeMenuPermissionArg(permission);
|
|
2349
|
-
}
|
|
2350
|
-
else if (!existing) {
|
|
2351
|
-
body.permission = null;
|
|
2352
|
-
}
|
|
2353
|
-
const operation = await createOrPatch(apiUrl, 'enfyra_menu', existing, body);
|
|
2354
|
-
return {
|
|
2355
|
-
id: operation.id || getId(existing),
|
|
2356
|
-
path: normalizedPath || existing?.path || null,
|
|
2357
|
-
label,
|
|
2358
|
-
action: operation.action,
|
|
2359
|
-
operation,
|
|
2360
|
-
};
|
|
2361
|
-
}
|
|
2362
|
-
async function reorderMenus(apiUrl, { updates, globalRulesAckKey }) {
|
|
2363
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
2364
|
-
const seen = new Set();
|
|
2365
|
-
const normalizedUpdates = updates.map((item, index) => {
|
|
2366
|
-
const id = item?.id;
|
|
2367
|
-
if (id === null || id === undefined || String(id).trim() === '') {
|
|
2368
|
-
throw new Error(`updates[${index}].id is required.`);
|
|
2369
|
-
}
|
|
2370
|
-
const key = String(id);
|
|
2371
|
-
if (seen.has(key))
|
|
2372
|
-
throw new Error(`Duplicate menu id in reorder payload: ${key}`);
|
|
2373
|
-
seen.add(key);
|
|
2374
|
-
const order = Number(item.order);
|
|
2375
|
-
if (!Number.isInteger(order) || order < 0) {
|
|
2376
|
-
throw new Error(`updates[${index}].order must be a non-negative integer.`);
|
|
2377
|
-
}
|
|
2378
|
-
const parent = item.parent === undefined || item.parent === null || String(item.parent).trim() === ''
|
|
2379
|
-
? null
|
|
2380
|
-
: item.parent;
|
|
2381
|
-
return { id, order, parent };
|
|
2382
|
-
});
|
|
2383
|
-
const result = await fetchAPI(apiUrl, '/admin/menu/reorder', {
|
|
2384
|
-
method: 'POST',
|
|
2385
|
-
body: JSON.stringify({ updates: normalizedUpdates }),
|
|
2386
|
-
});
|
|
2387
|
-
return {
|
|
2388
|
-
action: 'menus_reordered',
|
|
2389
|
-
updates: normalizedUpdates,
|
|
2390
|
-
result,
|
|
2391
|
-
reload: {
|
|
2392
|
-
attempted: false,
|
|
2393
|
-
succeeded: true,
|
|
2394
|
-
reason: '/admin/menu/reorder persists order/parent updates and emits enfyra_menu cache invalidation.',
|
|
2395
|
-
},
|
|
2396
|
-
};
|
|
2397
|
-
}
|
|
2398
|
-
async function ensureExtension(apiUrl, { name, type, code, menuId, description, isEnabled = true, version = '1.0.0', globalRulesAckKey, extensionKnowledgeAckKey, }) {
|
|
2399
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
2400
|
-
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
2401
|
-
if (type === 'page' && !menuId) {
|
|
2402
|
-
throw new Error('menuId is required for page extensions. Use ensure_menu first, then ensure_page_extension.');
|
|
2403
|
-
}
|
|
2404
|
-
if (type !== 'page' && menuId) {
|
|
2405
|
-
throw new Error('menuId is only valid for page extensions.');
|
|
2406
|
-
}
|
|
2407
|
-
const validation = await validateExtensionCode(apiUrl, code, name);
|
|
2408
|
-
const existing = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,menu.id,type');
|
|
2409
|
-
const operation = await createOrPatch(apiUrl, 'enfyra_extension', existing, {
|
|
2410
|
-
name,
|
|
2411
|
-
type,
|
|
2412
|
-
code,
|
|
2413
|
-
...(menuId ? { menu: { id: menuId } } : {}),
|
|
2414
|
-
description,
|
|
2415
|
-
isEnabled,
|
|
2416
|
-
version,
|
|
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
|
-
});
|
|
2425
|
-
return {
|
|
2426
|
-
id: extensionId,
|
|
2427
|
-
name,
|
|
2428
|
-
type,
|
|
2429
|
-
action: operation.action,
|
|
2430
|
-
operation: { action: operation.action, id: extensionId },
|
|
2431
|
-
validation,
|
|
2432
|
-
verification,
|
|
2433
|
-
};
|
|
2434
|
-
}
|
|
2435
|
-
async function ensureFlow(apiUrl, { name, triggerType = 'manual', triggerConfig, timeout, maxExecutions = 100, isEnabled = true, description, globalRulesAckKey, }) {
|
|
2436
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
2437
|
-
const existing = await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: name } }, 'id,_id,name');
|
|
2438
|
-
const operation = await createOrPatch(apiUrl, 'enfyra_flow', existing, {
|
|
2439
|
-
name,
|
|
2440
|
-
triggerType,
|
|
2441
|
-
triggerConfig: parseJsonObjectArg('triggerConfig', triggerConfig, {}),
|
|
2442
|
-
timeout,
|
|
2443
|
-
maxExecutions,
|
|
2444
|
-
isEnabled,
|
|
2445
|
-
description,
|
|
2446
|
-
});
|
|
2447
|
-
const reload = naturalPartialReload('Flow metadata writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
|
|
2448
|
-
return { action: 'flow_ensured', flow: { id: operation.id, name }, operation, reload };
|
|
2449
|
-
}
|
|
2450
|
-
async function ensureFlowStep(apiUrl, { flowName, flowId, key, type, order, config, sourceCode, scriptLanguage, timeout, isEnabled, globalRulesAckKey, knowledgeAckKey, }) {
|
|
2451
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
2452
|
-
if (!flowName && !flowId)
|
|
2453
|
-
throw new Error('Provide flowName or flowId.');
|
|
2454
|
-
if (flowName && flowId)
|
|
2455
|
-
throw new Error('Provide flowName or flowId, not both.');
|
|
2456
|
-
const flow = flowId
|
|
2457
|
-
? await findRecord(apiUrl, 'enfyra_flow', { id: { _eq: flowId } }, 'id,_id,name')
|
|
2458
|
-
: await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
|
|
2459
|
-
if (!flow)
|
|
2460
|
-
throw new Error(`Flow not found: ${flowId || flowName}`);
|
|
2461
|
-
const parsedConfig = parseJsonObjectArg('config', config, {});
|
|
2462
|
-
assertDynamicCodeKnowledgeAckIf(Boolean(sourceCode && ['script', 'condition'].includes(type)), knowledgeAckKey);
|
|
2463
|
-
const validation = sourceCode && ['script', 'condition'].includes(type)
|
|
2464
|
-
? await validateDynamicScript(apiUrl, sourceCode, scriptLanguage)
|
|
2465
|
-
: { validated: false, reason: 'no script validation required' };
|
|
2466
|
-
const existing = await findRecord(apiUrl, 'enfyra_flow_step', {
|
|
2467
|
-
flow: { id: { _eq: getId(flow) } },
|
|
2468
|
-
key: { _eq: key },
|
|
2469
|
-
}, 'id,_id,key,flow.id');
|
|
2470
|
-
const operation = await createOrPatch(apiUrl, 'enfyra_flow_step', existing, normalizeFlowStepBody({
|
|
2471
|
-
key,
|
|
2472
|
-
type,
|
|
2473
|
-
order,
|
|
2474
|
-
config: parsedConfig,
|
|
2475
|
-
sourceCode,
|
|
2476
|
-
scriptLanguage,
|
|
2477
|
-
timeout,
|
|
2478
|
-
isEnabled,
|
|
2479
|
-
}, getId(flow)));
|
|
2480
|
-
const reload = naturalPartialReload('Flow step writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
|
|
2481
|
-
return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
|
|
2482
|
-
}
|
|
2483
|
-
const FLOW_STEP_TOOL_GUIDANCE = [
|
|
2484
|
-
{
|
|
2485
|
-
tool: 'ensure_query_flow_step',
|
|
2486
|
-
type: 'query',
|
|
2487
|
-
when: 'Read/list records from one table without custom branching or transformation.',
|
|
2488
|
-
config: { table: 'table_name', filter: {}, fields: 'id,name', limit: 20, sort: '-createdAt' },
|
|
2489
|
-
},
|
|
2490
|
-
{
|
|
2491
|
-
tool: 'ensure_create_flow_step',
|
|
2492
|
-
type: 'create',
|
|
2493
|
-
when: 'Create one record from static config only. Fixed step config is not template-transformed; use a script step when data comes from @FLOW_PAYLOAD, @FLOW_LAST, or @FLOW.',
|
|
2494
|
-
config: { table: 'table_name', data: { field: 'value' } },
|
|
2495
|
-
},
|
|
2496
|
-
{
|
|
2497
|
-
tool: 'ensure_update_flow_step',
|
|
2498
|
-
type: 'update',
|
|
2499
|
-
when: 'Update one statically known record. Use a script step when id or data comes from runtime flow values.',
|
|
2500
|
-
config: { table: 'table_name', id: '<static-id>', data: { field: 'value' } },
|
|
2501
|
-
},
|
|
2502
|
-
{
|
|
2503
|
-
tool: 'ensure_delete_flow_step',
|
|
2504
|
-
type: 'delete',
|
|
2505
|
-
when: 'Delete one statically known record. Use a script step when id comes from runtime flow values.',
|
|
2506
|
-
config: { table: 'table_name', id: '<static-id>' },
|
|
2507
|
-
},
|
|
2508
|
-
{
|
|
2509
|
-
tool: 'ensure_http_flow_step',
|
|
2510
|
-
type: 'http',
|
|
2511
|
-
when: 'Call an external HTTP API.',
|
|
2512
|
-
config: { url: 'https://example.com/api', method: 'POST', headers: {}, body: {}, timeout: 10000 },
|
|
2513
|
-
},
|
|
2514
|
-
{
|
|
2515
|
-
tool: 'ensure_condition_flow_step',
|
|
2516
|
-
type: 'condition',
|
|
2517
|
-
when: 'Branch into true/false child steps based on JavaScript truthiness.',
|
|
2518
|
-
sourceCode: 'return Boolean(@FLOW_PAYLOAD.enabled)',
|
|
2519
|
-
},
|
|
2520
|
-
{
|
|
2521
|
-
tool: 'ensure_sleep_flow_step',
|
|
2522
|
-
type: 'sleep',
|
|
2523
|
-
when: 'Wait for a short bounded delay.',
|
|
2524
|
-
config: { ms: 1000 },
|
|
2525
|
-
},
|
|
2526
|
-
{
|
|
2527
|
-
tool: 'ensure_trigger_flow_step',
|
|
2528
|
-
type: 'trigger_flow',
|
|
2529
|
-
when: 'Trigger another flow as a child/orchestration step.',
|
|
2530
|
-
config: { flowName: 'child-flow', payload: {} },
|
|
2531
|
-
},
|
|
2532
|
-
{
|
|
2533
|
-
tool: 'ensure_log_flow_step',
|
|
2534
|
-
type: 'log',
|
|
2535
|
-
when: 'Record a small execution note for diagnostics.',
|
|
2536
|
-
config: { message: 'Reached step_name' },
|
|
2537
|
-
},
|
|
2538
|
-
{
|
|
2539
|
-
tool: 'ensure_script_flow_step',
|
|
2540
|
-
type: 'script',
|
|
2541
|
-
when: 'Use only when logic needs loops, multiple tables, crypto, package calls, non-trivial transforms, or runtime checks not covered by the atomic step tools.',
|
|
2542
|
-
sourceCode: 'return { ok: true }',
|
|
2543
|
-
},
|
|
2544
|
-
];
|
|
2545
|
-
function chooseFlowStepTool(intent) {
|
|
2546
|
-
const text = String(intent || '').toLowerCase();
|
|
2547
|
-
const hasAny = (patterns) => patterns.some((pattern) => pattern.test(text));
|
|
2548
|
-
if (hasAny([/\bif\b/, /\belse\b/, /\bbranch\b/, /\bcondition\b/, /\bwhen\b/, /\bcheck\b/, /nếu/, /điều kiện/]))
|
|
2549
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'condition');
|
|
2550
|
-
if (hasAny([/\bhttp\b/, /\bapi\b/, /\bwebhook\b/, /\bfetch\b/, /\brequest\b/, /\bpost\b/, /\bget\b/, /\bcall\b/, /gọi api/]))
|
|
2551
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'http');
|
|
2552
|
-
if (hasAny([/\bsleep\b/, /\bwait\b/, /\bdelay\b/, /\bpause\b/, /chờ/, /đợi/]))
|
|
2553
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'sleep');
|
|
2554
|
-
if (hasAny([/\btrigger\b/, /\bchild flow\b/, /\banother flow\b/, /\bsubflow\b/, /flow khác/]))
|
|
2555
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'trigger_flow');
|
|
2556
|
-
if (hasAny([/\bdelete\b/, /\bremove\b/, /\bdestroy\b/, /xóa/, /xoá/]))
|
|
2557
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'delete');
|
|
2558
|
-
if (hasAny([/\bupdate\b/, /\bpatch\b/, /\bset\b/, /\bmark\b/, /\bchange\b/, /cập nhật/, /đánh dấu/]))
|
|
2559
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'update');
|
|
2560
|
-
if (hasAny([/\bcreate\b/, /\binsert\b/, /\badd\b/, /\bstore\b/, /\bsave\b/, /tạo/, /thêm/, /lưu/]))
|
|
2561
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'create');
|
|
2562
|
-
if (hasAny([/\blog\b/, /\bdebug\b/, /\btrace\b/, /ghi log/]))
|
|
2563
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'log');
|
|
2564
|
-
if (hasAny([/\bquery\b/, /\bfind\b/, /\blist\b/, /\bread\b/, /\bload\b/, /\bcount\b/, /\bsearch\b/, /đọc/, /tìm/, /liệt kê/]))
|
|
2565
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'query');
|
|
2566
|
-
return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'script');
|
|
2567
|
-
}
|
|
2568
|
-
const FIXED_FLOW_STEP_TYPES = new Set(['query', 'create', 'update', 'delete', 'http', 'sleep', 'trigger_flow', 'log']);
|
|
2569
|
-
const FLOW_RUNTIME_MACRO_PATTERN = /@FLOW(?:_PAYLOAD|_LAST|_META)?\b/u;
|
|
2570
|
-
function findFlowRuntimeMacro(value) {
|
|
2571
|
-
if (typeof value === 'string')
|
|
2572
|
-
return FLOW_RUNTIME_MACRO_PATTERN.exec(value)?.[0] || null;
|
|
2573
|
-
if (Array.isArray(value)) {
|
|
2574
|
-
for (const item of value) {
|
|
2575
|
-
const match = findFlowRuntimeMacro(item);
|
|
2576
|
-
if (match)
|
|
2577
|
-
return match;
|
|
2578
|
-
}
|
|
2579
|
-
return null;
|
|
2580
|
-
}
|
|
2581
|
-
if (!value || typeof value !== 'object')
|
|
2582
|
-
return null;
|
|
2583
|
-
for (const item of Object.values(value)) {
|
|
2584
|
-
const match = findFlowRuntimeMacro(item);
|
|
2585
|
-
if (match)
|
|
2586
|
-
return match;
|
|
2587
|
-
}
|
|
2588
|
-
return null;
|
|
2589
|
-
}
|
|
2590
|
-
export function assertFixedFlowStepConfigIsStatic(type, config, index = 0) {
|
|
2591
|
-
if (!FIXED_FLOW_STEP_TYPES.has(String(type)))
|
|
2592
|
-
return;
|
|
2593
|
-
const macro = findFlowRuntimeMacro(config);
|
|
2594
|
-
if (!macro)
|
|
2595
|
-
return;
|
|
2596
|
-
throw new Error(`steps[${index}] uses ${macro} inside a ${type} config, but ESV fixed flow step configs are static and are not template-transformed. Use a script step for runtime payload/previous-step values, keep one business operation in that script, and call @LOGS(message, details?) for captured logs.`);
|
|
2597
|
-
}
|
|
2598
|
-
function planFlowSteps(steps) {
|
|
2599
|
-
const items = Array.isArray(steps) ? steps : [];
|
|
2600
|
-
return items.map((step, index) => {
|
|
2601
|
-
const intent = typeof step === 'string' ? step : step?.intent;
|
|
2602
|
-
const key = typeof step === 'object' && step?.key ? String(step.key) : `step_${index + 1}`;
|
|
2603
|
-
const recommendation = chooseFlowStepTool(intent);
|
|
2604
|
-
return {
|
|
2605
|
-
order: index + 1,
|
|
2606
|
-
key,
|
|
2607
|
-
intent,
|
|
2608
|
-
tool: recommendation.tool,
|
|
2609
|
-
type: recommendation.type,
|
|
2610
|
-
suggestedInput: {
|
|
2611
|
-
key,
|
|
2612
|
-
name: typeof step === 'object' && step?.name ? step.name : key.replace(/_/g, ' '),
|
|
2613
|
-
order: index + 1,
|
|
2614
|
-
...(recommendation.config ? { config: recommendation.config } : {}),
|
|
2615
|
-
...(recommendation.sourceCode ? { sourceCode: recommendation.sourceCode } : {}),
|
|
2616
|
-
...(recommendation.condition ? { condition: recommendation.condition } : {}),
|
|
2617
|
-
},
|
|
2618
|
-
reason: recommendation.when,
|
|
2619
|
-
};
|
|
2620
|
-
});
|
|
2621
|
-
}
|
|
2622
|
-
function normalizeFlowWorkflowStep(step, index) {
|
|
2623
|
-
const input = typeof step === 'string' ? { intent: step } : (step || {});
|
|
2624
|
-
const intent = String(input.intent || input.name || input.key || `Step ${index + 1}`);
|
|
2625
|
-
const recommended = chooseFlowStepTool(input.type || intent);
|
|
2626
|
-
const type = String(input.type || recommended.type || 'script');
|
|
2627
|
-
const guidance = FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === type);
|
|
2628
|
-
if (!guidance) {
|
|
2629
|
-
throw new Error(`steps[${index}].type must be one of ${FLOW_STEP_TOOL_GUIDANCE.map((item) => item.type).join(', ')}.`);
|
|
2630
|
-
}
|
|
2631
|
-
const key = String(input.key || intent)
|
|
2632
|
-
.trim()
|
|
2633
|
-
.toLowerCase()
|
|
2634
|
-
.replace(/[^a-z0-9]+/g, '_')
|
|
2635
|
-
.replace(/^_+|_+$/g, '')
|
|
2636
|
-
.slice(0, 64) || `step_${index + 1}`;
|
|
2637
|
-
const normalized = {
|
|
2638
|
-
index,
|
|
2639
|
-
key,
|
|
2640
|
-
name: input.name || intent,
|
|
2641
|
-
intent,
|
|
2642
|
-
type,
|
|
2643
|
-
order: input.order ?? index * 10,
|
|
2644
|
-
config: input.config ?? guidance.config ?? {},
|
|
2645
|
-
sourceCode: input.sourceCode ?? guidance.sourceCode,
|
|
2646
|
-
scriptLanguage: input.scriptLanguage || 'javascript',
|
|
2647
|
-
timeout: input.timeout,
|
|
2648
|
-
isEnabled: input.isEnabled ?? true,
|
|
2649
|
-
chosenByIntent: !input.type,
|
|
2650
|
-
recommendedTool: guidance.tool,
|
|
2651
|
-
};
|
|
2652
|
-
assertFixedFlowStepConfigIsStatic(type, normalized.config, index);
|
|
2653
|
-
return normalized;
|
|
2654
|
-
}
|
|
2655
|
-
async function runFlowWorkflow(apiUrl, opts) {
|
|
2656
|
-
const steps = parseJsonArrayArg('steps', opts.steps, []);
|
|
2657
|
-
const plan = steps.map(normalizeFlowWorkflowStep);
|
|
2658
|
-
const hasDynamicCode = plan.some((step) => ['script', 'condition'].includes(step.type) && step.sourceCode);
|
|
2659
|
-
const triggerType = opts.triggerType || 'manual';
|
|
2660
|
-
const flowInput = {
|
|
2661
|
-
name: opts.name,
|
|
2662
|
-
triggerType,
|
|
2663
|
-
triggerConfig: triggerType === 'schedule' ? opts.triggerConfig : (opts.triggerConfig ?? {}),
|
|
2664
|
-
timeout: opts.timeout,
|
|
2665
|
-
maxExecutions: opts.maxExecutions,
|
|
2666
|
-
isEnabled: opts.isEnabled,
|
|
2667
|
-
description: opts.description,
|
|
2668
|
-
globalRulesAckKey: opts.globalRulesAckKey,
|
|
2669
|
-
};
|
|
2670
|
-
if (!opts.apply) {
|
|
2671
|
-
return {
|
|
2672
|
-
action: 'flow_workflow_planned',
|
|
2673
|
-
flow: {
|
|
2674
|
-
name: opts.name,
|
|
2675
|
-
triggerType,
|
|
2676
|
-
},
|
|
2677
|
-
stepCount: plan.length,
|
|
2678
|
-
plan,
|
|
2679
|
-
requiredAckParams: ['globalRulesAckKey', ...(hasDynamicCode ? ['knowledgeAckKey'] : [])],
|
|
2680
|
-
nextSteps: [
|
|
2681
|
-
'Review the plan. Prefer fixed step types only for static config; ESV does not interpolate @FLOW_PAYLOAD/@FLOW_LAST/@FLOW inside fixed-step config. Use one focused script step when runtime values are required.',
|
|
2682
|
-
'Call flow_workflow again with apply=true and the required ack params to create/update the flow and steps sequentially.',
|
|
2683
|
-
'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
|
|
2684
|
-
],
|
|
2685
|
-
};
|
|
2686
|
-
}
|
|
2687
|
-
if (!opts.name)
|
|
2688
|
-
throw new Error('name is required.');
|
|
2689
|
-
assertGlobalRulesAck(opts.globalRulesAckKey);
|
|
2690
|
-
if (hasDynamicCode)
|
|
2691
|
-
assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
|
|
2692
|
-
const flowResult = await ensureFlow(apiUrl, flowInput);
|
|
2693
|
-
const flowId = flowResult.flow.id;
|
|
2694
|
-
const operations = [];
|
|
2695
|
-
for (const step of plan) {
|
|
2696
|
-
const result = await ensureFlowStep(apiUrl, {
|
|
2697
|
-
flowName: undefined,
|
|
2698
|
-
flowId,
|
|
2699
|
-
key: step.key,
|
|
2700
|
-
type: step.type,
|
|
2701
|
-
order: step.order,
|
|
2702
|
-
config: step.config,
|
|
2703
|
-
sourceCode: step.sourceCode,
|
|
2704
|
-
scriptLanguage: step.scriptLanguage,
|
|
2705
|
-
timeout: step.timeout,
|
|
2706
|
-
isEnabled: step.isEnabled,
|
|
2707
|
-
globalRulesAckKey: opts.globalRulesAckKey,
|
|
2708
|
-
knowledgeAckKey: opts.knowledgeAckKey,
|
|
2709
|
-
});
|
|
2710
|
-
operations.push({
|
|
2711
|
-
index: step.index,
|
|
2712
|
-
key: step.key,
|
|
2713
|
-
type: step.type,
|
|
2714
|
-
result,
|
|
2715
|
-
});
|
|
2716
|
-
}
|
|
2717
|
-
return {
|
|
2718
|
-
action: 'flow_workflow_applied',
|
|
2719
|
-
flow: flowResult.flow,
|
|
2720
|
-
flowResult: {
|
|
2721
|
-
action: flowResult.action,
|
|
2722
|
-
flow: flowResult.flow,
|
|
2723
|
-
reload: flowResult.reload,
|
|
2724
|
-
},
|
|
2725
|
-
stepCount: plan.length,
|
|
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
|
-
})),
|
|
2740
|
-
sequential: true,
|
|
2741
|
-
nextSteps: [
|
|
2742
|
-
'Use test_flow_step for script, condition, or high-risk steps before triggering the flow.',
|
|
2743
|
-
'Use trigger_flow only after saved behavior is verified.',
|
|
2744
|
-
],
|
|
2745
|
-
};
|
|
2746
|
-
}
|
|
2747
|
-
function normalizeEndpointAccess(anonymousAccess, makePublic) {
|
|
2748
|
-
if (makePublic !== undefined)
|
|
2749
|
-
return makePublic ? 'public' : 'private';
|
|
2750
|
-
return anonymousAccess || 'private';
|
|
2751
|
-
}
|
|
2752
|
-
async function reviewCustomEndpointSource(apiUrl, method, sourceCode) {
|
|
2753
|
-
const repositoryTables = extractExplicitRepositoryTableNames(sourceCode);
|
|
2754
|
-
const selectedTables = repositoryTables.slice(0, 5);
|
|
2755
|
-
const results = await Promise.allSettled(selectedTables.map(async (tableName) => [tableName, await fetchTableMetadata(apiUrl, tableName)]));
|
|
2756
|
-
const tableMetadata = {};
|
|
2757
|
-
const metadataUnavailable = [];
|
|
2758
|
-
for (const [index, result] of results.entries()) {
|
|
2759
|
-
if (result.status === 'fulfilled') {
|
|
2760
|
-
tableMetadata[result.value[0]] = result.value[1];
|
|
2761
|
-
}
|
|
2762
|
-
else {
|
|
2763
|
-
metadataUnavailable.push(selectedTables[index]);
|
|
2764
|
-
}
|
|
2765
|
-
}
|
|
2766
|
-
return reviewDynamicEndpointContract({
|
|
2767
|
-
routeKind: 'custom',
|
|
2768
|
-
method,
|
|
2769
|
-
sourceCode,
|
|
2770
|
-
tableMetadata,
|
|
2771
|
-
metadataUnavailable,
|
|
2772
|
-
metadataTruncated: repositoryTables.length > selectedTables.length,
|
|
2773
|
-
});
|
|
2774
|
-
}
|
|
2775
|
-
function sourceMatches(existingHandler, sourceCode, scriptLanguage, timeout) {
|
|
2776
|
-
if (!existingHandler)
|
|
2777
|
-
return false;
|
|
2778
|
-
if (String(existingHandler.sourceCode ?? '') !== String(sourceCode ?? ''))
|
|
2779
|
-
return false;
|
|
2780
|
-
if (scriptLanguage && String(existingHandler.scriptLanguage || 'javascript') !== String(scriptLanguage))
|
|
2781
|
-
return false;
|
|
2782
|
-
if (timeout !== undefined && Number(existingHandler.timeout) !== Number(timeout))
|
|
2783
|
-
return false;
|
|
2784
|
-
return true;
|
|
2785
|
-
}
|
|
2786
|
-
function extensionMatches(existingExtension, opts, menuId) {
|
|
2787
|
-
if (!existingExtension)
|
|
2788
|
-
return false;
|
|
2789
|
-
if (String(existingExtension.type || '') !== String(opts.type || 'page'))
|
|
2790
|
-
return false;
|
|
2791
|
-
if (String(existingExtension.code ?? '') !== String(opts.code ?? ''))
|
|
2792
|
-
return false;
|
|
2793
|
-
if (opts.description !== undefined && String(existingExtension.description || '') !== String(opts.description || ''))
|
|
2794
|
-
return false;
|
|
2795
|
-
if (opts.isEnabled !== undefined && Boolean(existingExtension.isEnabled) !== Boolean(opts.isEnabled))
|
|
2796
|
-
return false;
|
|
2797
|
-
if (opts.version !== undefined && String(existingExtension.version || '') !== String(opts.version))
|
|
2798
|
-
return false;
|
|
2799
|
-
if ((opts.type || 'page') === 'page' && menuId && String(refId(existingExtension.menu)) !== String(menuId))
|
|
2800
|
-
return false;
|
|
2801
|
-
if ((opts.type || 'page') !== 'page' && refId(existingExtension.menu))
|
|
2802
|
-
return false;
|
|
2803
|
-
return true;
|
|
2804
|
-
}
|
|
2805
|
-
function step(status, id, title, detail = {}) {
|
|
2806
|
-
return { id, title, status, ...detail };
|
|
2807
|
-
}
|
|
2808
|
-
async function resolveApiEndpointWorkflowState(apiUrl, opts) {
|
|
2809
|
-
const normalizedPath = normalizeRestPath(opts.path);
|
|
2810
|
-
const methodName = normalizeMethodName(opts.method);
|
|
2811
|
-
const access = normalizeEndpointAccess(opts.anonymousAccess, opts.public);
|
|
2812
|
-
assertDynamicEndpointContract(reviewDynamicEndpointContract({
|
|
2813
|
-
routeKind: 'custom',
|
|
2814
|
-
method: methodName,
|
|
2815
|
-
sourceCode: opts.sourceCode,
|
|
2816
|
-
}));
|
|
2817
|
-
const { methodMap, methodIdNameMap } = await getMethodContext(apiUrl);
|
|
2818
|
-
const methodId = methodMap[methodName];
|
|
2819
|
-
if (!methodId)
|
|
2820
|
-
throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
|
|
2821
|
-
const [routes, scriptValidation, contractReview] = await Promise.all([
|
|
2822
|
-
fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,description,availableMethods.*,publicMethods.*,mainTable.name'),
|
|
2823
|
-
validateScriptSourceIfPresent(fetchAPI, apiUrl, 'enfyra_route_handler', {
|
|
2824
|
-
sourceCode: opts.sourceCode,
|
|
2825
|
-
scriptLanguage: opts.scriptLanguage || 'javascript',
|
|
2826
|
-
}),
|
|
2827
|
-
reviewCustomEndpointSource(apiUrl, methodName, opts.sourceCode),
|
|
2828
|
-
]);
|
|
2829
|
-
const route = routes.find((item) => item.path === normalizedPath) || null;
|
|
2830
|
-
assertCustomEndpointRoute(route);
|
|
2831
|
-
const routeId = getId(route);
|
|
2832
|
-
const availableMethods = methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
|
|
2833
|
-
const publicMethods = methodNamesFromRecords(route?.publicMethods || [], methodIdNameMap);
|
|
2834
|
-
const methodAvailable = availableMethods.includes(methodName);
|
|
2835
|
-
const routeNeedsUpdate = !!route && (route.isEnabled === false
|
|
2836
|
-
|| !methodAvailable
|
|
2837
|
-
|| (access === 'public' && !publicMethods.includes(methodName))
|
|
2838
|
-
|| (access === 'private' && publicMethods.includes(methodName))
|
|
2839
|
-
|| (opts.description !== undefined && route.description !== opts.description));
|
|
2840
|
-
const handler = route ? await findHandler(apiUrl, routeId, methodId) : null;
|
|
2841
|
-
const handlerMatches = sourceMatches(handler, opts.sourceCode, opts.scriptLanguage || 'javascript', opts.timeout);
|
|
2842
|
-
const handlerNeedsOverwrite = !!handler && !handlerMatches;
|
|
2843
|
-
let permission = null;
|
|
2844
|
-
let role = null;
|
|
2845
|
-
let permissionMethods = [];
|
|
2846
|
-
let permissionMissingMethods = [];
|
|
2847
|
-
if (opts.roleName || opts.roleId || opts.allowedUserIds?.length) {
|
|
2848
|
-
if (access === 'public') {
|
|
2849
|
-
permissionMissingMethods = [];
|
|
2850
|
-
}
|
|
2851
|
-
else if (!route) {
|
|
2852
|
-
permissionMissingMethods = [methodName];
|
|
2853
|
-
}
|
|
2854
|
-
else {
|
|
2855
|
-
const permissions = await fetchRecords(apiUrl, 'enfyra_route_permission', {
|
|
2856
|
-
route: { id: { _eq: routeId } },
|
|
2857
|
-
}, 'id,_id,route.id,role.id,role.name,allowedUsers.id,methods.*', 1000);
|
|
2858
|
-
role = await resolveRole(apiUrl, { roleId: opts.roleId, roleName: opts.roleName });
|
|
2859
|
-
const allowedUserIds = (opts.allowedUserIds || []).map(String).sort();
|
|
2860
|
-
permission = permissions.find((candidate) => {
|
|
2861
|
-
const candidateRoleId = refId(candidate.role);
|
|
2862
|
-
const candidateUserIds = (candidate.allowedUsers || []).map((item) => String(refId(item))).sort();
|
|
2863
|
-
if (role && String(candidateRoleId) !== String(role.id))
|
|
2864
|
-
return false;
|
|
2865
|
-
if (!role && candidateRoleId !== null && candidateRoleId !== undefined)
|
|
2866
|
-
return false;
|
|
2867
|
-
return allowedUserIds.length === candidateUserIds.length
|
|
2868
|
-
&& allowedUserIds.every((value, index) => value === candidateUserIds[index]);
|
|
2869
|
-
}) || null;
|
|
2870
|
-
permissionMethods = methodNamesFromRecords(permission?.methods || [], methodIdNameMap);
|
|
2871
|
-
permissionMissingMethods = permissionMethods.includes(methodName) ? [] : [methodName];
|
|
2872
|
-
}
|
|
2873
|
-
}
|
|
2874
|
-
const smokeTestRequested = opts.smokeTestQuery !== undefined || opts.smokeTestBody !== undefined;
|
|
2875
|
-
const steps = [
|
|
2876
|
-
route
|
|
2877
|
-
? step(routeNeedsUpdate ? 'pending' : 'completed', 'sync_route', 'Ensure route method and public access', {
|
|
2878
|
-
routeId,
|
|
2879
|
-
availableMethods,
|
|
2880
|
-
publicMethods,
|
|
2881
|
-
desiredAccess: access,
|
|
2882
|
-
})
|
|
2883
|
-
: step('pending', 'create_route', 'Create custom route', {
|
|
2884
|
-
desiredAccess: access,
|
|
2885
|
-
}),
|
|
2886
|
-
handler
|
|
2887
|
-
? step(handlerNeedsOverwrite ? (opts.overwrite ? 'pending' : 'blocked') : 'completed', 'save_handler', 'Create or update route handler', {
|
|
2888
|
-
handlerId: getId(handler),
|
|
2889
|
-
reason: handlerNeedsOverwrite && !opts.overwrite ? 'Existing handler differs. Re-run with overwrite=true to update it.' : undefined,
|
|
2890
|
-
})
|
|
2891
|
-
: step(route && methodAvailable ? 'pending' : 'waiting', 'save_handler', 'Create route handler', {
|
|
2892
|
-
reason: !route ? 'Route must exist first.' : methodAvailable ? undefined : 'Route method must be available first.',
|
|
2893
|
-
}),
|
|
2894
|
-
];
|
|
2895
|
-
if (opts.roleName || opts.roleId || opts.allowedUserIds?.length) {
|
|
2896
|
-
steps.push(access === 'public'
|
|
2897
|
-
? step('skipped', 'ensure_route_access', 'Ensure authenticated route access', {
|
|
2898
|
-
reason: 'Method is public, so route permission is not required for anonymous access.',
|
|
2899
|
-
})
|
|
2900
|
-
: step(permissionMissingMethods.length ? (route ? 'pending' : 'waiting') : 'completed', 'ensure_route_access', 'Ensure authenticated route access', {
|
|
2901
|
-
permissionId: getId(permission),
|
|
2902
|
-
role,
|
|
2903
|
-
allowedUserIds: opts.allowedUserIds || [],
|
|
2904
|
-
methods: permissionMethods,
|
|
2905
|
-
missingMethods: permissionMissingMethods,
|
|
2906
|
-
}));
|
|
2907
|
-
}
|
|
2908
|
-
if (smokeTestRequested) {
|
|
2909
|
-
const blockers = steps.filter((item) => ['pending', 'waiting', 'blocked'].includes(item.status));
|
|
2910
|
-
steps.push(step(blockers.length ? 'waiting' : 'pending', 'smoke_test', 'Smoke-test the endpoint', {
|
|
2911
|
-
reason: blockers.length ? 'Endpoint must be ready before smoke test.' : undefined,
|
|
2912
|
-
}));
|
|
2913
|
-
}
|
|
2914
|
-
const firstRunnable = steps.find((item) => item.status === 'pending') || null;
|
|
2915
|
-
const blocked = steps.find((item) => item.status === 'blocked') || null;
|
|
2916
|
-
const pendingAckParams = firstRunnable
|
|
2917
|
-
? [
|
|
2918
|
-
'globalRulesAckKey',
|
|
2919
|
-
...(firstRunnable.id === 'save_handler' ? ['knowledgeAckKey'] : []),
|
|
2920
|
-
]
|
|
2921
|
-
: [];
|
|
2922
|
-
const nextSteps = blocked
|
|
2923
|
-
? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, overwrite: true }, reason: blocked.reason }]
|
|
2924
|
-
: firstRunnable
|
|
2925
|
-
? [{
|
|
2926
|
-
tool: 'api_endpoint_workflow',
|
|
2927
|
-
input: { path: normalizedPath, method: methodName, apply: true },
|
|
2928
|
-
stepId: firstRunnable.id,
|
|
2929
|
-
requiredAckParams: pendingAckParams,
|
|
2930
|
-
requiresKnowledgeAck: pendingAckParams.length
|
|
2931
|
-
? `Pass ${pendingAckParams.join(' and ')} from get_enfyra_required_knowledge when applying this step.`
|
|
2932
|
-
: undefined,
|
|
2933
|
-
}]
|
|
2934
|
-
: [];
|
|
2935
|
-
return {
|
|
2936
|
-
endpoint: {
|
|
2937
|
-
path: normalizedPath,
|
|
2938
|
-
method: methodName,
|
|
2939
|
-
anonymousAccess: access,
|
|
2940
|
-
routeId,
|
|
2941
|
-
handlerId: getId(handler),
|
|
2942
|
-
},
|
|
2943
|
-
methodId,
|
|
2944
|
-
methodMap,
|
|
2945
|
-
methodIdNameMap,
|
|
2946
|
-
route,
|
|
2947
|
-
handler,
|
|
2948
|
-
role,
|
|
2949
|
-
scriptValidation,
|
|
2950
|
-
contractReview,
|
|
2951
|
-
steps,
|
|
2952
|
-
firstRunnable,
|
|
2953
|
-
blocked,
|
|
2954
|
-
nextSteps,
|
|
2955
|
-
};
|
|
2956
|
-
}
|
|
2957
|
-
async function applyApiEndpointWorkflowStep(apiUrl, state, opts, stepId) {
|
|
2958
|
-
const selectedStep = stepId
|
|
2959
|
-
? state.steps.find((item) => item.id === stepId)
|
|
2960
|
-
: state.firstRunnable;
|
|
2961
|
-
if (!selectedStep)
|
|
2962
|
-
return { action: 'noop', reason: 'No runnable step remains.' };
|
|
2963
|
-
if (selectedStep.status !== 'pending') {
|
|
2964
|
-
throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
|
|
2965
|
-
}
|
|
2966
|
-
const endpoint = state.endpoint;
|
|
2967
|
-
if (selectedStep.id === 'create_route') {
|
|
2968
|
-
const result = await fetchAPI(apiUrl, '/enfyra_route', {
|
|
2969
|
-
method: 'POST',
|
|
2970
|
-
body: JSON.stringify({
|
|
2971
|
-
path: endpoint.path,
|
|
2972
|
-
description: opts.description,
|
|
2973
|
-
isEnabled: true,
|
|
2974
|
-
availableMethods: [{ id: state.methodId }],
|
|
2975
|
-
publicMethods: endpoint.anonymousAccess === 'public' ? [{ id: state.methodId }] : [],
|
|
2976
|
-
}),
|
|
2977
|
-
});
|
|
2978
|
-
return { action: 'route_created', result, routeReload: await reloadRoutes(apiUrl) };
|
|
2979
|
-
}
|
|
2980
|
-
if (selectedStep.id === 'sync_route') {
|
|
2981
|
-
const availableMethods = methodNamesFromRecords(state.route.availableMethods, state.methodIdNameMap);
|
|
2982
|
-
const publicMethods = methodNamesFromRecords(state.route.publicMethods, state.methodIdNameMap);
|
|
2983
|
-
const finalAvailable = uniqueMethodNames([...availableMethods, endpoint.method]);
|
|
2984
|
-
const finalPublic = endpoint.anonymousAccess === 'public'
|
|
2985
|
-
? uniqueMethodNames([...publicMethods, endpoint.method])
|
|
2986
|
-
: publicMethods.filter((method) => method !== endpoint.method);
|
|
2987
|
-
const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(endpoint.routeId))}`, {
|
|
2988
|
-
method: 'PATCH',
|
|
2989
|
-
body: JSON.stringify({
|
|
2990
|
-
isEnabled: true,
|
|
2991
|
-
availableMethods: resolveMethodRefs(state.methodMap, finalAvailable),
|
|
2992
|
-
publicMethods: resolveMethodRefs(state.methodMap, finalPublic),
|
|
2993
|
-
...(opts.description !== undefined ? { description: opts.description } : {}),
|
|
2994
|
-
}),
|
|
2995
|
-
});
|
|
2996
|
-
return { action: 'route_synced', result, routeReload: await reloadRoutes(apiUrl) };
|
|
2997
|
-
}
|
|
2998
|
-
if (selectedStep.id === 'save_handler') {
|
|
2999
|
-
assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
|
|
3000
|
-
if (!endpoint.routeId)
|
|
3001
|
-
throw new Error('Route must exist before saving handler.');
|
|
3002
|
-
const body = {
|
|
3003
|
-
sourceCode: opts.sourceCode,
|
|
3004
|
-
scriptLanguage: opts.scriptLanguage || 'javascript',
|
|
3005
|
-
...(opts.timeout !== undefined ? { timeout: opts.timeout } : {}),
|
|
3006
|
-
};
|
|
3007
|
-
if (state.handler) {
|
|
3008
|
-
const result = await fetchAPI(apiUrl, `/enfyra_route_handler/${encodeURIComponent(String(getId(state.handler)))}`, {
|
|
3009
|
-
method: 'PATCH',
|
|
3010
|
-
body: JSON.stringify(body),
|
|
3011
|
-
});
|
|
3012
|
-
return { action: 'handler_updated', result, routeReload: await reloadRoutes(apiUrl) };
|
|
3013
|
-
}
|
|
3014
|
-
const result = await fetchAPI(apiUrl, '/enfyra_route_handler', {
|
|
3015
|
-
method: 'POST',
|
|
3016
|
-
body: JSON.stringify({
|
|
3017
|
-
route: { id: endpoint.routeId },
|
|
3018
|
-
method: { id: state.methodId },
|
|
3019
|
-
...body,
|
|
3020
|
-
}),
|
|
3021
|
-
});
|
|
3022
|
-
return { action: 'handler_created', result, routeReload: await reloadRoutes(apiUrl) };
|
|
3023
|
-
}
|
|
3024
|
-
if (selectedStep.id === 'ensure_route_access') {
|
|
3025
|
-
assertOneScope(opts);
|
|
3026
|
-
const role = state.role || await resolveRole(apiUrl, { roleId: opts.roleId, roleName: opts.roleName });
|
|
3027
|
-
const existing = state.steps.find((item) => item.id === 'ensure_route_access')?.permissionId
|
|
3028
|
-
? await findRecord(apiUrl, 'enfyra_route_permission', { id: { _eq: state.steps.find((item) => item.id === 'ensure_route_access').permissionId } }, 'id,_id,methods.*')
|
|
3029
|
-
: null;
|
|
3030
|
-
const existingMethods = methodNamesFromRecords(existing?.methods || [], state.methodIdNameMap);
|
|
3031
|
-
const finalMethods = uniqueMethodNames([...existingMethods, endpoint.method]);
|
|
3032
|
-
const body = {
|
|
3033
|
-
isEnabled: true,
|
|
3034
|
-
description: opts.routePermissionDescription,
|
|
3035
|
-
methods: resolveMethodRefs(state.methodMap, finalMethods),
|
|
3036
|
-
...(role ? { role: { id: role.id } } : {}),
|
|
3037
|
-
...(opts.allowedUserIds?.length ? { allowedUsers: opts.allowedUserIds.map((id) => ({ id })) } : {}),
|
|
3038
|
-
};
|
|
3039
|
-
const result = existing
|
|
3040
|
-
? await fetchAPI(apiUrl, `/enfyra_route_permission/${encodeURIComponent(String(getId(existing)))}`, {
|
|
3041
|
-
method: 'PATCH',
|
|
3042
|
-
body: JSON.stringify(body),
|
|
3043
|
-
})
|
|
3044
|
-
: await fetchAPI(apiUrl, '/enfyra_route_permission', {
|
|
3045
|
-
method: 'POST',
|
|
3046
|
-
body: JSON.stringify({
|
|
3047
|
-
route: { id: endpoint.routeId },
|
|
3048
|
-
...body,
|
|
3049
|
-
}),
|
|
3050
|
-
});
|
|
3051
|
-
return { action: existing ? 'route_access_updated' : 'route_access_created', result, routeReload: await reloadRoutes(apiUrl) };
|
|
3052
|
-
}
|
|
3053
|
-
if (selectedStep.id === 'smoke_test') {
|
|
3054
|
-
const query = parseJsonObjectArg('smokeTestQuery', opts.smokeTestQuery, {});
|
|
3055
|
-
const queryParams = new URLSearchParams();
|
|
3056
|
-
for (const [key, value] of Object.entries(query)) {
|
|
3057
|
-
if (value !== undefined && value !== null)
|
|
3058
|
-
queryParams.set(key, String(value));
|
|
3059
|
-
}
|
|
3060
|
-
const body = opts.smokeTestBody === undefined ? undefined : parseJsonObjectArg('smokeTestBody', opts.smokeTestBody, {});
|
|
3061
|
-
const smokePath = `${endpoint.path}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
|
3062
|
-
const result = await fetchAPI(apiUrl, smokePath, {
|
|
3063
|
-
method: endpoint.method,
|
|
3064
|
-
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
3065
|
-
});
|
|
3066
|
-
return { action: 'smoke_test_passed', result };
|
|
3067
|
-
}
|
|
3068
|
-
throw new Error(`Unsupported workflow step: ${selectedStep.id}`);
|
|
3069
|
-
}
|
|
3070
|
-
async function runApiEndpointWorkflow(apiUrl, opts) {
|
|
3071
|
-
let state = await resolveApiEndpointWorkflowState(apiUrl, opts);
|
|
3072
|
-
const operations = [];
|
|
3073
|
-
let completedEphemeralStepId = null;
|
|
3074
|
-
if (opts.apply || opts.applyAll) {
|
|
3075
|
-
assertGlobalRulesAck(opts.globalRulesAckKey);
|
|
3076
|
-
if (opts.applyAll && state.steps.some((item) => item.id === 'save_handler' && ['pending', 'waiting'].includes(item.status))) {
|
|
3077
|
-
assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
|
|
3078
|
-
}
|
|
3079
|
-
const maxSteps = opts.applyAll ? 10 : 1;
|
|
3080
|
-
for (let i = 0; i < maxSteps; i += 1) {
|
|
3081
|
-
if (state.blocked || !state.firstRunnable)
|
|
3082
|
-
break;
|
|
3083
|
-
const operation = await applyApiEndpointWorkflowStep(apiUrl, state, opts, opts.stepId);
|
|
3084
|
-
operations.push(operation);
|
|
3085
|
-
if (state.firstRunnable.id === 'smoke_test') {
|
|
3086
|
-
completedEphemeralStepId = 'smoke_test';
|
|
3087
|
-
break;
|
|
3088
|
-
}
|
|
3089
|
-
if (!opts.applyAll)
|
|
3090
|
-
break;
|
|
3091
|
-
state = await resolveApiEndpointWorkflowState(apiUrl, opts);
|
|
3092
|
-
}
|
|
3093
|
-
}
|
|
3094
|
-
const latestState = operations.length ? await resolveApiEndpointWorkflowState(apiUrl, opts) : state;
|
|
3095
|
-
const latestSteps = completedEphemeralStepId
|
|
3096
|
-
? latestState.steps.map((item) => (item.id === completedEphemeralStepId
|
|
3097
|
-
? { ...item, status: 'completed', result: 'passed' }
|
|
3098
|
-
: item))
|
|
3099
|
-
: latestState.steps;
|
|
3100
|
-
const nextSteps = completedEphemeralStepId
|
|
3101
|
-
? latestState.nextSteps.filter((item) => item.stepId !== completedEphemeralStepId)
|
|
3102
|
-
: latestState.nextSteps;
|
|
3103
|
-
return {
|
|
3104
|
-
action: operations.length ? 'api_endpoint_workflow_advanced' : 'api_endpoint_workflow_planned',
|
|
3105
|
-
endpoint: latestState.endpoint,
|
|
3106
|
-
scriptValidation: latestState.scriptValidation,
|
|
3107
|
-
contractReview: latestState.contractReview,
|
|
3108
|
-
steps: latestSteps,
|
|
3109
|
-
operations: operations.map(summarizeWorkflowOperation),
|
|
3110
|
-
complete: latestSteps.every((item) => ['completed', 'skipped'].includes(item.status)),
|
|
3111
|
-
nextSteps,
|
|
3112
|
-
cleanupHints: latestState.endpoint.routeId
|
|
3113
|
-
? [
|
|
3114
|
-
`Use delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, confirm: false }) to preview route-owned handlers, hooks, guards, and permissions before cleanup.`,
|
|
3115
|
-
`Then call delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, expectedPath: ${JSON.stringify(latestState.endpoint.path)}, confirm: true }) when the route contract is no longer needed.`,
|
|
3116
|
-
]
|
|
3117
|
-
: [],
|
|
3118
|
-
};
|
|
3119
|
-
}
|
|
3120
|
-
async function resolveExtensionWorkflowState(apiUrl, opts) {
|
|
3121
|
-
const type = opts.type || 'page';
|
|
3122
|
-
if (type === 'page' && opts.menuId && (opts.menuLabel || opts.menuPath)) {
|
|
3123
|
-
throw new Error('Provide menuId or menuLabel/menuPath for page extension workflow, not both.');
|
|
3124
|
-
}
|
|
3125
|
-
if (type !== 'page' && (opts.menuId || opts.menuLabel || opts.menuPath)) {
|
|
3126
|
-
throw new Error('Menu fields are only valid for page extensions.');
|
|
3127
|
-
}
|
|
3128
|
-
const validation = await validateExtensionCode(apiUrl, opts.code, opts.name);
|
|
3129
|
-
const existingExtension = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: opts.name } }, 'id,_id,name,type,menu.id,description,isEnabled,version,code');
|
|
3130
|
-
let menu = null;
|
|
3131
|
-
if (type === 'page' && opts.menuId) {
|
|
3132
|
-
menu = await findRecord(apiUrl, 'enfyra_menu', { id: { _eq: opts.menuId } }, 'id,_id,label,path,type,order,isEnabled');
|
|
3133
|
-
if (!menu)
|
|
3134
|
-
throw new Error(`Menu not found: ${opts.menuId}`);
|
|
3135
|
-
}
|
|
3136
|
-
else if (type === 'page' && (opts.menuPath || opts.menuLabel)) {
|
|
3137
|
-
const normalizedPath = opts.menuPath ? normalizeRestPath(opts.menuPath) : undefined;
|
|
3138
|
-
menu = normalizedPath
|
|
3139
|
-
? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,label,path,type,order,isEnabled')
|
|
3140
|
-
: await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: opts.menuLabel } }, 'id,_id,label,path,type,order,isEnabled');
|
|
3141
|
-
}
|
|
3142
|
-
const menuId = opts.menuId || getId(menu);
|
|
3143
|
-
const steps = [];
|
|
3144
|
-
steps.push(step('completed', 'validate_extension', 'Validate extension code', { validation }));
|
|
3145
|
-
if (type === 'page') {
|
|
3146
|
-
if (menuId) {
|
|
3147
|
-
const menuNeedsUpdate = Boolean(menu && ((opts.menuLabel !== undefined && menu.label !== opts.menuLabel)
|
|
3148
|
-
|| (opts.menuPath !== undefined && menu.path !== normalizeRestPath(opts.menuPath))
|
|
3149
|
-
|| (opts.menuType !== undefined && menu.type !== opts.menuType)
|
|
3150
|
-
|| (opts.menuOrder !== undefined && Number(menu.order || 0) !== Number(opts.menuOrder))
|
|
3151
|
-
|| (opts.menuIsEnabled !== undefined && Boolean(menu.isEnabled) !== Boolean(opts.menuIsEnabled))));
|
|
3152
|
-
steps.push(step(menuNeedsUpdate ? 'pending' : 'completed', 'ensure_menu', 'Ensure page menu', {
|
|
3153
|
-
menuId,
|
|
3154
|
-
menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : { id: menuId },
|
|
3155
|
-
}));
|
|
3156
|
-
}
|
|
3157
|
-
else if (opts.menuLabel) {
|
|
3158
|
-
steps.push(step('pending', 'ensure_menu', 'Create page menu', {
|
|
3159
|
-
reason: 'No existing menu matched; ensure_menu will create it.',
|
|
3160
|
-
}));
|
|
3161
|
-
}
|
|
3162
|
-
else {
|
|
3163
|
-
steps.push(step('blocked', 'ensure_menu', 'Create or select page menu', {
|
|
3164
|
-
reason: 'Page extensions require menuId or menuLabel. Provide menuId for an existing menu or menuLabel/menuPath to create/update one.',
|
|
3165
|
-
}));
|
|
3166
|
-
}
|
|
3167
|
-
}
|
|
3168
|
-
const effectiveMenuId = type === 'page' ? menuId : undefined;
|
|
3169
|
-
const saveStatus = steps.some((item) => ['blocked', 'waiting'].includes(item.status))
|
|
3170
|
-
? 'waiting'
|
|
3171
|
-
: extensionMatches(existingExtension, { ...opts, type }, effectiveMenuId)
|
|
3172
|
-
? 'completed'
|
|
3173
|
-
: 'pending';
|
|
3174
|
-
steps.push(step(saveStatus, 'save_extension', `Ensure ${type} extension`, {
|
|
3175
|
-
extensionId: getId(existingExtension),
|
|
3176
|
-
currentType: existingExtension?.type || null,
|
|
3177
|
-
desiredType: type,
|
|
3178
|
-
menuId: effectiveMenuId || null,
|
|
3179
|
-
reason: saveStatus === 'waiting' ? 'Menu must exist before saving page extension.' : undefined,
|
|
3180
|
-
}));
|
|
3181
|
-
const firstRunnable = steps.find((item) => item.status === 'pending') || null;
|
|
3182
|
-
const blocked = steps.find((item) => item.status === 'blocked') || null;
|
|
3183
|
-
return {
|
|
3184
|
-
extension: {
|
|
3185
|
-
name: opts.name,
|
|
3186
|
-
type,
|
|
3187
|
-
id: getId(existingExtension),
|
|
3188
|
-
menuId: effectiveMenuId || null,
|
|
3189
|
-
},
|
|
3190
|
-
validation,
|
|
3191
|
-
existingExtension: existingExtension ? {
|
|
3192
|
-
id: getId(existingExtension),
|
|
3193
|
-
name: existingExtension.name,
|
|
3194
|
-
type: existingExtension.type,
|
|
3195
|
-
menuId: refId(existingExtension.menu) || null,
|
|
3196
|
-
} : null,
|
|
3197
|
-
menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : null,
|
|
3198
|
-
steps,
|
|
3199
|
-
firstRunnable,
|
|
3200
|
-
blocked,
|
|
3201
|
-
nextSteps: blocked
|
|
3202
|
-
? [{ tool: 'extension_workflow', input: { name: opts.name, type }, reason: blocked.reason }]
|
|
3203
|
-
: firstRunnable
|
|
3204
|
-
? [{
|
|
3205
|
-
tool: 'extension_workflow',
|
|
3206
|
-
input: { name: opts.name, type, apply: true, stepId: firstRunnable.id },
|
|
3207
|
-
stepId: firstRunnable.id,
|
|
3208
|
-
requiresKnowledgeAck: 'globalRulesAckKey and extensionAckKey from get_enfyra_required_knowledge',
|
|
3209
|
-
}]
|
|
3210
|
-
: [],
|
|
3211
|
-
};
|
|
3212
|
-
}
|
|
3213
|
-
async function applyExtensionWorkflowStep(apiUrl, state, opts, stepId) {
|
|
3214
|
-
const selectedStep = stepId
|
|
3215
|
-
? state.steps.find((item) => item.id === stepId)
|
|
3216
|
-
: state.firstRunnable;
|
|
3217
|
-
if (!selectedStep)
|
|
3218
|
-
return { action: 'noop', reason: 'No runnable step remains.' };
|
|
3219
|
-
if (selectedStep.status !== 'pending') {
|
|
3220
|
-
throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
|
|
3221
|
-
}
|
|
3222
|
-
const type = opts.type || 'page';
|
|
3223
|
-
if (selectedStep.id === 'ensure_menu') {
|
|
3224
|
-
if (type !== 'page')
|
|
3225
|
-
throw new Error('ensure_menu step is only valid for page extensions.');
|
|
3226
|
-
if (!opts.menuLabel && !opts.menuId)
|
|
3227
|
-
throw new Error('menuLabel or menuId is required for ensure_menu.');
|
|
3228
|
-
return {
|
|
3229
|
-
action: 'menu_ensured',
|
|
3230
|
-
menu: await ensureMenu(apiUrl, {
|
|
3231
|
-
label: opts.menuLabel || state.menu?.label || opts.name,
|
|
3232
|
-
path: opts.menuPath || state.menu?.path,
|
|
3233
|
-
icon: opts.menuIcon,
|
|
3234
|
-
type: opts.menuType,
|
|
3235
|
-
order: opts.menuOrder,
|
|
3236
|
-
permission: opts.menuPermission,
|
|
3237
|
-
description: opts.menuDescription,
|
|
3238
|
-
isEnabled: opts.menuIsEnabled,
|
|
3239
|
-
globalRulesAckKey: opts.globalRulesAckKey,
|
|
3240
|
-
}),
|
|
3241
|
-
};
|
|
3242
|
-
}
|
|
3243
|
-
if (selectedStep.id === 'save_extension') {
|
|
3244
|
-
let menuId = opts.menuId || state.extension.menuId;
|
|
3245
|
-
if (type === 'page' && !menuId) {
|
|
3246
|
-
const freshState = await resolveExtensionWorkflowState(apiUrl, opts);
|
|
3247
|
-
menuId = freshState.extension.menuId;
|
|
3248
|
-
}
|
|
3249
|
-
if (type === 'page' && !menuId)
|
|
3250
|
-
throw new Error('Page extension menu is missing. Apply ensure_menu first.');
|
|
3251
|
-
return {
|
|
3252
|
-
action: `${type}_extension_ensured`,
|
|
3253
|
-
extension: await ensureExtension(apiUrl, {
|
|
3254
|
-
name: opts.name,
|
|
3255
|
-
type,
|
|
3256
|
-
code: opts.code,
|
|
3257
|
-
menuId,
|
|
3258
|
-
description: opts.description,
|
|
3259
|
-
isEnabled: opts.isEnabled,
|
|
3260
|
-
version: opts.version,
|
|
3261
|
-
globalRulesAckKey: opts.globalRulesAckKey,
|
|
3262
|
-
extensionKnowledgeAckKey: opts.extensionKnowledgeAckKey,
|
|
3263
|
-
}),
|
|
3264
|
-
};
|
|
3265
|
-
}
|
|
3266
|
-
throw new Error(`Unsupported extension workflow step: ${selectedStep.id}`);
|
|
3267
|
-
}
|
|
3268
|
-
async function runExtensionWorkflow(apiUrl, opts) {
|
|
3269
|
-
let state = await resolveExtensionWorkflowState(apiUrl, opts);
|
|
3270
|
-
const operations = [];
|
|
3271
|
-
if (opts.apply || opts.applyAll) {
|
|
3272
|
-
assertGlobalRulesAck(opts.globalRulesAckKey);
|
|
3273
|
-
assertExtensionKnowledgeAck(opts.extensionKnowledgeAckKey);
|
|
3274
|
-
const maxSteps = opts.applyAll ? 5 : 1;
|
|
3275
|
-
for (let i = 0; i < maxSteps; i += 1) {
|
|
3276
|
-
if (state.blocked || !state.firstRunnable)
|
|
3277
|
-
break;
|
|
3278
|
-
operations.push(await applyExtensionWorkflowStep(apiUrl, state, opts, opts.stepId));
|
|
3279
|
-
if (!opts.applyAll)
|
|
3280
|
-
break;
|
|
3281
|
-
state = await resolveExtensionWorkflowState(apiUrl, opts);
|
|
3282
|
-
}
|
|
3283
|
-
}
|
|
3284
|
-
const latestState = operations.length ? await resolveExtensionWorkflowState(apiUrl, opts) : state;
|
|
3285
|
-
return {
|
|
3286
|
-
action: operations.length ? 'extension_workflow_advanced' : 'extension_workflow_planned',
|
|
3287
|
-
extension: latestState.extension,
|
|
3288
|
-
validation: latestState.validation,
|
|
3289
|
-
menu: latestState.menu,
|
|
3290
|
-
existingExtension: latestState.existingExtension,
|
|
3291
|
-
steps: latestState.steps,
|
|
3292
|
-
operations,
|
|
3293
|
-
complete: latestState.steps.every((item) => ['completed', 'skipped'].includes(item.status)),
|
|
3294
|
-
nextSteps: latestState.nextSteps,
|
|
3295
|
-
guidance: [
|
|
3296
|
-
'Call get_extension_theme_contract before generating or reviewing extension UI.',
|
|
3297
|
-
'For high-contract UI/runtime code, call build_extension_ui after extension acknowledgement before patching raw Vue: drawer, modal, page shell, permission gate, empty state, resource list, form editor, widget, menu notification, account panel item, tabs, upload modal, api usage, notify, runtime review, theme classes, theme review, or full review.',
|
|
3298
|
-
'Use build_extension_ui kind=api_usage, notify, theme_classes, runtime_review, theme_review, or review instead of hand-writing those contracts from memory.',
|
|
3299
|
-
'Extension validation rejects common field controls without class="w-full" unless intentionally marked data-compact or data-inline.',
|
|
3300
|
-
'PermissionGate renders the permitted slot directly and is UX-only; backend permissions and owner checks remain authoritative.',
|
|
3301
|
-
'For menu/account-panel notifications, use counts only when the signal source already owns an exact count; otherwise use a dot/chip for new attention.',
|
|
3302
|
-
'Do not fetch destination domain lists solely to decorate the shell; destination pages own domain fetching after click.',
|
|
3303
|
-
'Unrestricted menu permission is null, not {}. Empty permission objects are normalized to null by ensure_menu.',
|
|
3304
|
-
],
|
|
3305
|
-
};
|
|
3306
|
-
}
|
|
1
|
+
export { applyExtensionCodePatches, assertFixedFlowStepConfigIsStatic, buildExtensionAccountPanelSnippet, buildExtensionApiUsageSnippet, buildExtensionConfirmSnippet, buildExtensionDrawerSnippet, buildExtensionEmptyStateSnippet, buildExtensionFormEditorSnippet, buildExtensionMenuNotificationSnippet, buildExtensionModalSnippet, buildExtensionNotifySnippet, buildExtensionPageShellSnippet, buildExtensionPatchDiffArtifact, buildExtensionPermissionGateSnippet, buildExtensionResourceGridSnippet, buildExtensionResourceListSnippet, buildExtensionTabsSnippet, buildExtensionUiSnippet, buildExtensionUploadModalSnippet, buildExtensionWidgetSnippet, buildExtensionRuntimeVerification, reviewExtensionRuntimeContract, reviewExtensionThemeContract, reviewExtensionUiContract, summarizeWorkflowOperation, validateExtensionCode, validateExtensionCodeLocally, verifyExtensionRuntime, } from './platform-operation-logic.js';
|
|
2
|
+
import { registerPlatformExtensionTools } from './platform-extension-tools.js';
|
|
3
|
+
import { registerPlatformFlowTools } from './platform-flow-tools.js';
|
|
4
|
+
import { registerPlatformPolicyTools } from './platform-policy-tools.js';
|
|
5
|
+
import { registerPlatformResourceTools } from './platform-resource-tools.js';
|
|
6
|
+
import { registerPlatformRouteTools } from './platform-route-tools.js';
|
|
7
|
+
import { registerPlatformWebsocketTools } from './platform-websocket-tools.js';
|
|
3307
8
|
export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
color: z.string().optional().describe('Optional component-supported color. Usually omit and let the managed action choose intent.'),
|
|
3315
|
-
variant: z.string().optional().describe('Optional component-supported variant. Usually omit and let the managed action choose intent.'),
|
|
3316
|
-
tone: z.string().optional().describe('Optional action tone when supported by the shell component.'),
|
|
3317
|
-
onClick: z.string().describe('Raw Vue expression or function reference for the click handler, e.g. saveNote or () => (open = false).'),
|
|
3318
|
-
});
|
|
3319
|
-
const extensionHeaderActionSchema = z.object({
|
|
3320
|
-
id: z.string().describe('Stable action id.'),
|
|
3321
|
-
label: z.string().optional().describe('Action label.'),
|
|
3322
|
-
icon: z.string().optional().describe('Icon name such as lucide:plus or lucide:refresh-cw.'),
|
|
3323
|
-
color: z.string().optional().default('neutral').describe('Nuxt UI color. Use primary only for the single main scope action; otherwise neutral.'),
|
|
3324
|
-
variant: z.string().optional().default('outline').describe('Nuxt UI variant. Use solid for the main scope action; otherwise outline/ghost.'),
|
|
3325
|
-
loading: z.string().optional().describe('Raw Vue expression/ref name for loading state.'),
|
|
3326
|
-
disabled: z.string().optional().describe('Raw Vue expression/ref name for disabled state.'),
|
|
3327
|
-
to: z.string().optional().describe('Route path for visible navigation actions.'),
|
|
3328
|
-
onClick: z.string().optional().describe('Script callback expression or handler reference. Prefer a handler name; bare ref assignments must use ref.value, e.g. () => (modalOpen.value = true).'),
|
|
3329
|
-
order: z.number().optional().describe('Sort order in the shell header action area.'),
|
|
3330
|
-
side: z.enum(['left', 'right']).optional().describe('Optional shell side.'),
|
|
3331
|
-
});
|
|
3332
|
-
server.tool('validate_dynamic_script', [
|
|
3333
|
-
'Validate Enfyra dynamic script code before saving it to any script-backed metadata record.',
|
|
3334
|
-
'Use this before create/update of handlers, hooks, flow steps, websocket scripts, OAuth provisioning scripts, or bootstrap scripts when the user is iterating on code.',
|
|
3335
|
-
'This calls the same server compiler contract used by Enfyra, but does not save anything.',
|
|
3336
|
-
].join(' '), {
|
|
3337
|
-
sourceCode: z.string().describe('Raw dynamic script sourceCode.'),
|
|
3338
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language to validate.'),
|
|
3339
|
-
}, async ({ sourceCode, scriptLanguage }) => jsonText({
|
|
3340
|
-
action: 'dynamic_script_validated',
|
|
3341
|
-
validation: await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage),
|
|
3342
|
-
}));
|
|
3343
|
-
server.tool('validate_extension_code', [
|
|
3344
|
-
'Validate Enfyra admin extension code before saving it to enfyra_extension.',
|
|
3345
|
-
'Use this only when the user explicitly wants a validation-only check. For normal edits, use update_extension_code or ensure_*_extension so successful validation saves in the same tool call.',
|
|
3346
|
-
'This calls /enfyra_extension/preview and does not save anything.',
|
|
3347
|
-
'Call get_extension_theme_contract first when generating or reviewing UI.',
|
|
3348
|
-
].join(' '), {
|
|
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.'),
|
|
3350
|
-
name: z.string().optional().describe('Optional extension name/id used by the preview compiler.'),
|
|
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({
|
|
3353
|
-
action: 'extension_code_validated',
|
|
3354
|
-
validation: await validateExtensionCode(ENFYRA_API_URL, code, name, { uiPattern }),
|
|
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)));
|
|
3366
|
-
server.tool('update_extension_code', [
|
|
3367
|
-
'Business operation: update an existing Enfyra admin extension code by id or name.',
|
|
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.',
|
|
3369
|
-
'Use this instead of validate_extension_code followed by update_record when editing an existing page/widget/global extension.',
|
|
3370
|
-
'Call get_extension_theme_contract first when generating or reviewing UI.',
|
|
3371
|
-
].join(' '), {
|
|
3372
|
-
id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
|
|
3373
|
-
name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
|
|
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.'),
|
|
3375
|
-
description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
|
|
3376
|
-
isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
|
|
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.'),
|
|
3380
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3381
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3382
|
-
}, async (input) => jsonText(await updateExtensionCode(ENFYRA_API_URL, input)));
|
|
3383
|
-
server.tool('patch_extension_code', [
|
|
3384
|
-
'Focused operation: patch an existing Enfyra admin extension code by exact search/replace.',
|
|
3385
|
-
'Use this for small UI fixes instead of rewriting the whole Vue SFC.',
|
|
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.',
|
|
3387
|
-
'Default searchMode="exact"; use searchMode="whitespace" only when indentation/newline variation is the problem.',
|
|
3388
|
-
'Default replaceAll=false requires exactly one match; set replaceAll=true only after preview confirms the match count.',
|
|
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.',
|
|
3390
|
-
'Default apply=false returns a preview and nextStep input.',
|
|
3391
|
-
].join(' '), {
|
|
3392
|
-
id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
|
|
3393
|
-
name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
|
|
3394
|
-
search: z.string().optional().describe('Single-patch search fragment. Exact by default; JSON strings can contain \\n for multiline fragments. Omit when using patches.'),
|
|
3395
|
-
replace: z.string().optional().describe('Single-patch replacement code fragment. Required when search is provided. Omit when using patches.'),
|
|
3396
|
-
searchMode: z.enum(['exact', 'whitespace']).optional().default('exact').describe('Single-patch matching mode. exact is safest. whitespace treats each run of whitespace in search as flexible whitespace.'),
|
|
3397
|
-
replaceAll: z.boolean().optional().default(false).describe('Single-patch replace-all mode. false requires exactly one match; true replaces every match after previewing the count.'),
|
|
3398
|
-
patches: z.array(z.object({
|
|
3399
|
-
search: z.string().describe('Patch search fragment. JSON strings can contain \\n for multiline fragments.'),
|
|
3400
|
-
replace: z.string().describe('Patch replacement fragment.'),
|
|
3401
|
-
searchMode: z.enum(['exact', 'whitespace']).optional().default('exact').describe('Patch matching mode. Use whitespace only for indentation/newline variation.'),
|
|
3402
|
-
replaceAll: z.boolean().optional().default(false).describe('Patch replace-all mode. false requires exactly one match for this patch.'),
|
|
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.'),
|
|
3404
|
-
expectedSha256: z.string().optional().describe('SHA-256 of current extension code from preview/inspect. Required when apply=true and rejects stale patches.'),
|
|
3405
|
-
apply: z.boolean().optional().default(false).describe('Preview by default. Set true to validate and save.'),
|
|
3406
|
-
description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
|
|
3407
|
-
isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
|
|
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.'),
|
|
3410
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3411
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3412
|
-
}, async (input) => jsonText(await patchExtensionCode(ENFYRA_API_URL, input)));
|
|
3413
|
-
server.tool('get_extension_theme_contract', 'Return the concise Enfyra admin extension UI/theme/security contract. Call before writing or reviewing extension UI.', {}, async () => jsonText(getExtensionThemeContract()));
|
|
3414
|
-
server.tool('get_theme_class_reference', [
|
|
3415
|
-
'Return the authoritative Enfyra theme & color class reference: class -> CSS variable -> Nuxt UI semantic color -> intent.',
|
|
3416
|
-
'Call this whenever you need the exact eapp-* class name or the Nuxt UI color mapping for shell, system page, or dynamic extension UI.',
|
|
3417
|
-
'Source of truth: documents/app/theme-color-contract.md.',
|
|
3418
|
-
].join(' '), {}, async () => jsonText(getThemeClassReference()));
|
|
3419
|
-
server.tool('build_extension_ui', [
|
|
3420
|
-
'Lazy gateway for Enfyra admin extension UI builders.',
|
|
3421
|
-
'Use this after get_enfyra_required_knowledge(scope="extension") when a high-contract extension UI snippet is needed.',
|
|
3422
|
-
'It keeps guided startup small by dispatching drawer, modal, page_shell, permission_gate, empty_state, resource_list, resource_grid, form_editor, widget, menu_notification, account_panel_item, tabs, upload_modal, api_usage, notify, confirm, runtime_review, theme_classes, theme_review, or review internally instead of exposing every builder tool up front.',
|
|
3423
|
-
].join(' '), {
|
|
3424
|
-
kind: z.enum([
|
|
3425
|
-
'drawer',
|
|
3426
|
-
'modal',
|
|
3427
|
-
'page_shell',
|
|
3428
|
-
'permission_gate',
|
|
3429
|
-
'empty_state',
|
|
3430
|
-
'resource_list',
|
|
3431
|
-
'resource_grid',
|
|
3432
|
-
'form_editor',
|
|
3433
|
-
'widget',
|
|
3434
|
-
'menu_notification',
|
|
3435
|
-
'account_panel_item',
|
|
3436
|
-
'tabs',
|
|
3437
|
-
'upload_modal',
|
|
3438
|
-
'api_usage',
|
|
3439
|
-
'notify',
|
|
3440
|
-
'confirm',
|
|
3441
|
-
'runtime_review',
|
|
3442
|
-
'theme_classes',
|
|
3443
|
-
'theme_review',
|
|
3444
|
-
'review',
|
|
3445
|
-
]).describe('Which extension UI contract builder/reviewer to run.'),
|
|
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.'),
|
|
3447
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3448
|
-
}, async ({ kind, input, extensionKnowledgeAckKey }) => {
|
|
3449
|
-
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
3450
|
-
return jsonText(buildExtensionUiSnippet(kind, input));
|
|
3451
|
-
});
|
|
3452
|
-
server.tool('build_extension_api_usage', [
|
|
3453
|
-
'Generate a contract-safe useApi snippet for Enfyra admin extensions.',
|
|
3454
|
-
'Use this instead of writing useApi calls from memory so route paths, execute({ id, body }), query/body objects, and mutation handlers follow the app composable contract.',
|
|
3455
|
-
'The tool returns code only; apply it with patch_extension_code or update_extension_code and then validate/save normally.',
|
|
3456
|
-
].join(' '), {
|
|
3457
|
-
operation: z.enum(['list', 'find_one', 'create', 'update', 'delete', 'batch_update', 'batch_delete']).default('list').describe('API usage pattern to generate. Reads use the base route with query objects; mutations append ids through execute options.'),
|
|
3458
|
-
resource: z.string().default('items').describe('Resource variable base name, e.g. notes, projects, messages.'),
|
|
3459
|
-
path: z.string().optional().describe('Base API route path such as /notes. Do not include /:id; the builder strips a trailing /:id if provided.'),
|
|
3460
|
-
query: z.record(z.any()).optional().describe('Static Enfyra query object. Use this with sort for filter/page/limit reads; do not JSON.stringify it or put sort arrays inside it.'),
|
|
3461
|
-
queryExpression: z.string().optional().describe('Raw Vue expression for reactive query state. Do not JSON.stringify and do not use it to construct sort values; use structured sort instead.'),
|
|
3462
|
-
queryName: z.string().optional().describe('Variable name for the generated computed query when query is provided.'),
|
|
3463
|
-
sort: z.array(z.object({
|
|
3464
|
-
field: z.string().min(1).describe('Metadata field or supported aggregate sort expression.'),
|
|
3465
|
-
direction: z.enum(['asc', 'desc']).default('asc').describe('Enfyra sort direction.'),
|
|
3466
|
-
})).optional().describe('Structured sort order. The builder emits one Enfyra REST sort string, for example [{ field: "isPinned", direction: "desc" }, { field: "updatedAt", direction: "desc" }] becomes "-isPinned,-updatedAt".'),
|
|
3467
|
-
bodyExpression: z.string().optional().describe('Raw Vue expression for default body object/computed when useful. Do not JSON.stringify.'),
|
|
3468
|
-
errorContext: z.string().optional().describe('Safe error context label for useApi error reporting.'),
|
|
3469
|
-
responseName: z.string().optional().describe('Optional data ref variable name.'),
|
|
3470
|
-
pendingName: z.string().optional().describe('Optional pending ref variable name.'),
|
|
3471
|
-
errorName: z.string().optional().describe('Optional error ref variable name.'),
|
|
3472
|
-
executeName: z.string().optional().describe('Optional execute alias name.'),
|
|
3473
|
-
refreshName: z.string().optional().describe('Optional refresh alias name.'),
|
|
3474
|
-
rowsName: z.string().optional().describe('Optional computed rows variable for list/find_one operations.'),
|
|
3475
|
-
handlerName: z.string().optional().describe('Optional generated handler function name for mutations.'),
|
|
3476
|
-
recordName: z.string().optional().describe('Record parameter name for update/delete handlers.'),
|
|
3477
|
-
payloadName: z.string().optional().describe('Payload parameter name for create handlers.'),
|
|
3478
|
-
bodyName: z.string().optional().describe('Body parameter name for update/batch_update handlers.'),
|
|
3479
|
-
idsName: z.string().optional().describe('Ids parameter name for batch handlers.'),
|
|
3480
|
-
idExpression: z.string().optional().describe('Raw id expression for update/delete handlers. Defaults to record.id.'),
|
|
3481
|
-
autoLoad: z.boolean().optional().default(true).describe('For reads, generate onMounted(() => execute()).'),
|
|
3482
|
-
onErrorExpression: z.string().optional().describe('Raw onError handler expression when custom handling is needed.'),
|
|
3483
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
3484
|
-
}, async ({ extensionKnowledgeAckKey, ...input }) => {
|
|
3485
|
-
assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
|
|
3486
|
-
return jsonText(buildExtensionApiUsageSnippet(input));
|
|
3487
|
-
});
|
|
3488
|
-
server.tool('build_extension_drawer', [
|
|
3489
|
-
'Generate a contract-safe CommonDrawer Vue snippet for Enfyra admin extensions.',
|
|
3490
|
-
'Use this before writing or patching drawer/editing workflows so the model does not have to remember CommonDrawer slots, footer action props, full-width fields, or button type rules.',
|
|
3491
|
-
'The tool returns code only; apply it with patch_extension_code or update_extension_code and then validate/save normally.',
|
|
3492
|
-
].join(' '), {
|
|
3493
|
-
model: z.string().optional().default('drawerOpen').describe('Vue state variable used with v-model.'),
|
|
3494
|
-
title: z.string().optional().describe('Static drawer title.'),
|
|
3495
|
-
titleExpression: z.string().optional().describe('Raw Vue expression for a dynamic title.'),
|
|
3496
|
-
direction: z.enum(['right', 'left', 'top', 'bottom']).optional().default('right').describe('Drawer direction.'),
|
|
3497
|
-
nested: z.boolean().optional().default(false).describe('Set true when rendering a drawer inside another modal/drawer.'),
|
|
3498
|
-
body: z.string().describe('Vue template body content for #body. UInput/UTextarea/select controls are normalized to w-full; native buttons get type="button".'),
|
|
3499
|
-
cancelAction: z.union([extensionFooterActionSchema, z.literal(false)]).optional().describe('Cancel action object. Omit for a default Cancel that closes the model; false disables cancelAction.'),
|
|
3500
|
-
primaryAction: extensionFooterActionSchema.optional().describe('Primary action. Editing/create drawers should wire Save/Create here.'),
|
|
3501
|
-
dangerAction: extensionFooterActionSchema.optional().describe('Danger action. Destructive edit drawers should wire Delete here.'),
|
|
3502
|
-
footerHint: z.string().optional().describe('Optional footer hint text when supported by CommonDrawer.'),
|
|
3503
|
-
}, async (input) => jsonText(buildExtensionDrawerSnippet(input)));
|
|
3504
|
-
server.tool('build_extension_modal', [
|
|
3505
|
-
'Generate a contract-safe CommonModal/UModal Vue snippet for Enfyra admin extensions.',
|
|
3506
|
-
'Use this before writing or patching confirmation/edit modals so the model does not have to remember v-model:open, slots, final action props, full-width fields, or button type rules.',
|
|
3507
|
-
'The tool returns code only; apply it with patch_extension_code or update_extension_code and then validate/save normally.',
|
|
3508
|
-
].join(' '), {
|
|
3509
|
-
model: z.string().optional().default('modalOpen').describe('Vue state variable used with v-model:open.'),
|
|
3510
|
-
title: z.string().optional().describe('Static modal title.'),
|
|
3511
|
-
titleExpression: z.string().optional().describe('Raw Vue expression for a dynamic title.'),
|
|
3512
|
-
alias: z.enum(['CommonModal', 'UModal']).optional().default('CommonModal').describe('Use UModal only when preserving an existing UModal tag is useful; dynamic extensions resolve it to CommonModal.'),
|
|
3513
|
-
body: z.string().describe('Vue template body content for #body. UInput/UTextarea/select controls are normalized to w-full; native buttons get type="button".'),
|
|
3514
|
-
cancelAction: z.union([extensionFooterActionSchema, z.literal(false)]).optional().describe('Cancel action object. Omit for a default Cancel that closes the model; false disables cancelAction.'),
|
|
3515
|
-
primaryAction: extensionFooterActionSchema.optional().describe('Primary final action for non-destructive submit/confirm flows.'),
|
|
3516
|
-
dangerAction: extensionFooterActionSchema.optional().describe('Danger final action for destructive confirmation flows.'),
|
|
3517
|
-
footerHint: z.string().optional().describe('Optional footer hint text when supported by CommonModal.'),
|
|
3518
|
-
}, async (input) => jsonText(buildExtensionModalSnippet(input)));
|
|
3519
|
-
server.tool('review_extension_ui_contract', [
|
|
3520
|
-
'Review an Enfyra extension Vue snippet for common modal/drawer contract mistakes.',
|
|
3521
|
-
'Use this before patching or saving generated extension UI when CommonDrawer, CommonModal, UModal, UInput, UTextarea, USelect, or native buttons are involved.',
|
|
3522
|
-
'This is a static contract review, not a compiler validation; still validate the final SFC before saving.',
|
|
3523
|
-
].join(' '), {
|
|
3524
|
-
code: z.preprocess(normalizeEscapedVueSource, z.string()).describe('Vue SFC or template snippet to review.'),
|
|
3525
|
-
}, async ({ code }) => jsonText(reviewExtensionUiContract(code)));
|
|
3526
|
-
server.tool('build_extension_page_shell', [
|
|
3527
|
-
'Generate page-header and shell-header-action script setup code for Enfyra page extensions.',
|
|
3528
|
-
'Use this so generated page extensions register shell chrome through usePageHeaderRegistry/useHeaderActionRegistry instead of rendering duplicate local headers.',
|
|
3529
|
-
].join(' '), {
|
|
3530
|
-
title: z.string().optional().describe('Static page title.'),
|
|
3531
|
-
titleExpression: z.string().optional().describe('Raw Vue expression for a dynamic title.'),
|
|
3532
|
-
description: z.string().optional().describe('Optional page description.'),
|
|
3533
|
-
leadingIcon: z.string().optional().describe('Optional page header icon.'),
|
|
3534
|
-
gradient: z.enum(['none', 'purple', 'blue', 'cyan']).optional().default('none').describe('Generated operational extensions should usually use none.'),
|
|
3535
|
-
variant: z.enum(['default', 'minimal', 'stats-focus']).optional().default('minimal').describe('Page header variant.'),
|
|
3536
|
-
headerActions: z.array(extensionHeaderActionSchema).optional().describe('Optional shell header actions registered through useHeaderActionRegistry.'),
|
|
3537
|
-
}, async (input) => jsonText(buildExtensionPageShellSnippet(input)));
|
|
3538
|
-
server.tool('build_extension_permission_gate', [
|
|
3539
|
-
'Generate a PermissionGate wrapper snippet for Enfyra admin extension UI.',
|
|
3540
|
-
'Use this when a visible button/block/list needs operator UX gating; backend route permissions and owner checks still remain authoritative.',
|
|
3541
|
-
].join(' '), {
|
|
3542
|
-
route: z.string().optional().describe('API route path to gate against, e.g. /notes.'),
|
|
3543
|
-
methods: z.array(z.string()).optional().describe('HTTP methods for the route condition. Defaults to GET when route is provided.'),
|
|
3544
|
-
condition: z.string().optional().describe('Raw Vue permission condition expression. Overrides route/methods when provided.'),
|
|
3545
|
-
body: z.string().describe('Vue template content to render inside PermissionGate. Field controls are normalized to w-full.'),
|
|
3546
|
-
}, async (input) => jsonText(buildExtensionPermissionGateSnippet(input)));
|
|
3547
|
-
server.tool('build_extension_empty_state', [
|
|
3548
|
-
'Generate an EmptyState snippet for Enfyra admin extensions.',
|
|
3549
|
-
'Use this for app-matched empty/error/no-results states instead of hand-rolled blank panels.',
|
|
3550
|
-
].join(' '), {
|
|
3551
|
-
title: z.string().optional().describe('Empty state title.'),
|
|
3552
|
-
description: z.string().optional().describe('Empty state description.'),
|
|
3553
|
-
icon: z.string().optional().describe('Icon name. Defaults to lucide:inbox.'),
|
|
3554
|
-
size: z.enum(['sm', 'md', 'lg']).optional().default('sm').describe('Empty state size.'),
|
|
3555
|
-
variant: z.enum(['outline', 'naked', 'soft', 'subtle', 'solid']).optional().default('naked').describe('Use naked inside existing panels/lists.'),
|
|
3556
|
-
action: extensionFooterActionSchema.optional().describe('Optional primary empty-state action.'),
|
|
3557
|
-
}, async (input) => jsonText(buildExtensionEmptyStateSnippet(input)));
|
|
3558
|
-
server.tool('build_extension_resource_list', [
|
|
3559
|
-
'Generate a CommonResourceListFrame/CommonResourceListItem snippet for Enfyra admin extensions.',
|
|
3560
|
-
'Use this for operational list pages so loading, empty state, pagination placement, row chrome, icons, stats, and row actions follow the app contract.',
|
|
3561
|
-
].join(' '), {
|
|
3562
|
-
itemsExpression: z.string().optional().default('items').describe('Vue expression for the row array, e.g. notes.'),
|
|
3563
|
-
itemName: z.string().optional().default('item').describe('Loop variable name.'),
|
|
3564
|
-
keyExpression: z.string().optional().describe('Vue expression for :key. Defaults to item.id.'),
|
|
3565
|
-
titleExpression: z.string().optional().describe('Vue expression for item title. Defaults to item.title || "Untitled".'),
|
|
3566
|
-
descriptionExpression: z.string().optional().describe('Vue expression for item description. Defaults to item.description.'),
|
|
3567
|
-
icon: z.string().optional().describe('Static row icon when iconExpression is omitted.'),
|
|
3568
|
-
iconExpression: z.string().optional().describe('Vue expression for row icon.'),
|
|
3569
|
-
loadingExpression: z.string().optional().default('pending').describe('Vue expression for frame loading.'),
|
|
3570
|
-
totalExpression: z.string().optional().describe('Vue expression for total rows.'),
|
|
3571
|
-
itemsPerPageExpression: z.string().optional().describe('Vue expression for items per page; use 0 to hide pagination.'),
|
|
3572
|
-
statsExpression: z.string().optional().describe('Vue expression returning ResourceListStat[] for each row.'),
|
|
3573
|
-
actionsExpression: z.string().optional().describe('Vue expression returning ResourceListAction[] for each row.'),
|
|
3574
|
-
topBadgeExpression: z.string().optional().describe('Vue expression returning a ResourceListTopBadge for each row.'),
|
|
3575
|
-
onClick: z.string().optional().describe('Raw Vue expression called for row click, e.g. openEdit(item).'),
|
|
3576
|
-
emptyTitle: z.string().optional().describe('Empty title.'),
|
|
3577
|
-
emptyDescription: z.string().optional().describe('Empty description.'),
|
|
3578
|
-
emptyIcon: z.string().optional().describe('Empty icon.'),
|
|
3579
|
-
}, async (input) => jsonText(buildExtensionResourceListSnippet(input)));
|
|
3580
|
-
server.tool('build_extension_resource_grid', [
|
|
3581
|
-
'Generate a constrained responsive CommonResourceListFrame card grid for Enfyra admin extensions.',
|
|
3582
|
-
'Use this for workboards, catalogs, dashboards, and other card collections so generated pages do not become full-width horizontal strips.',
|
|
3583
|
-
'The tool owns the page constraint, plain list-frame chrome, md/two-column and xl/three-column breakpoints, semantic card surface, loading/empty frame, and stable card height.',
|
|
3584
|
-
].join(' '), {
|
|
3585
|
-
itemsExpression: z.string().optional().default('items').describe('Vue expression for the card array, e.g. notes.'),
|
|
3586
|
-
itemName: z.string().optional().default('item').describe('Loop variable name.'),
|
|
3587
|
-
keyExpression: z.string().optional().describe('Vue expression for :key. Defaults to item.id.'),
|
|
3588
|
-
cardBody: z.string().optional().describe('Card body Vue template. Defaults to semantic title/description. Fields and native buttons are normalized.'),
|
|
3589
|
-
loadingExpression: z.string().optional().default('pending').describe('Vue expression for frame loading.'),
|
|
3590
|
-
totalExpression: z.string().optional().describe('Vue expression for total cards.'),
|
|
3591
|
-
itemsPerPageExpression: z.string().optional().describe('Vue expression for items per page; use 0 to hide pagination.'),
|
|
3592
|
-
emptyTitle: z.string().optional().describe('Empty state title.'),
|
|
3593
|
-
emptyDescription: z.string().optional().describe('Empty state description.'),
|
|
3594
|
-
emptyIcon: z.string().optional().describe('Empty state icon.'),
|
|
3595
|
-
constrained: z.boolean().optional().default(true).describe('Wrap in eapp-page-constrained-wide. Disable only for intentional full-bleed surfaces.'),
|
|
3596
|
-
}, async (input) => jsonText(buildExtensionResourceGridSnippet(input)));
|
|
3597
|
-
server.tool('build_extension_form_editor', [
|
|
3598
|
-
'Generate a FormEditor/FormEditorLazy snippet for Enfyra table-backed extension forms.',
|
|
3599
|
-
'Use this instead of hand-writing UInput/UTextarea fields when the form maps directly to a table record.',
|
|
3600
|
-
].join(' '), {
|
|
3601
|
-
tableName: z.string().optional().describe('Static table name.'),
|
|
3602
|
-
tableNameExpression: z.string().optional().describe('Raw Vue expression for dynamic table name.'),
|
|
3603
|
-
model: z.string().optional().default('form').describe('Record state variable for v-model.'),
|
|
3604
|
-
errors: z.string().optional().default('errors').describe('Errors state variable for v-model:errors.'),
|
|
3605
|
-
mode: z.enum(['create', 'update']).optional().describe('Optional fixed form mode.'),
|
|
3606
|
-
loadingExpression: z.string().optional().describe('Raw Vue expression/ref for loading.'),
|
|
3607
|
-
layout: z.enum(['stack', 'grid']).optional().describe('Form layout.'),
|
|
3608
|
-
includes: z.array(z.string()).optional().describe('Fields to include. Prefer explicit includes for focused generated forms.'),
|
|
3609
|
-
excluded: z.array(z.string()).optional().describe('Fields to exclude. compiledCode is always excluded by FormEditor.'),
|
|
3610
|
-
sectionsExpression: z.string().optional().describe('Raw Vue expression for FormEditorSection[].'),
|
|
3611
|
-
fieldMapExpression: z.string().optional().describe('Raw Vue expression for fieldMap overrides.'),
|
|
3612
|
-
virtualFieldsExpression: z.string().optional().describe('Raw Vue expression for virtual fields.'),
|
|
3613
|
-
currentRecordIdExpression: z.string().optional().describe('Raw Vue expression for current record id.'),
|
|
3614
|
-
hasChangedHandler: z.string().optional().describe('Handler expression for @has-changed.'),
|
|
3615
|
-
virtualFieldEmitHandler: z.string().optional().describe('Handler expression for @virtual-field-emit.'),
|
|
3616
|
-
lazy: z.boolean().optional().default(true).describe('Use FormEditorLazy by default.'),
|
|
3617
|
-
}, async (input) => jsonText(buildExtensionFormEditorSnippet(input)));
|
|
3618
|
-
server.tool('build_extension_widget', [
|
|
3619
|
-
'Generate a Widget snippet for reusing a widget extension inside an Enfyra page extension.',
|
|
3620
|
-
'Use this so agents pass numeric widget ids and keep prop/event ownership explicit.',
|
|
3621
|
-
].join(' '), {
|
|
3622
|
-
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.'),
|
|
3623
|
-
props: z.record(z.string()).optional().describe('Map of prop name to raw Vue expression.'),
|
|
3624
|
-
events: z.record(z.string()).optional().describe('Map of event name to handler expression.'),
|
|
3625
|
-
}, async (input) => jsonText(buildExtensionWidgetSnippet(input)));
|
|
3626
|
-
server.tool('build_extension_menu_notification', [
|
|
3627
|
-
'Generate useMenuNotificationRegistry registration code for a global extension.',
|
|
3628
|
-
'Use this for sidebar menu count chips or dot notifications without mutating enfyra_menu records.',
|
|
3629
|
-
].join(' '), {
|
|
3630
|
-
id: z.string().optional().describe('Stable notification id.'),
|
|
3631
|
-
targetId: z.union([z.string(), z.number()]).optional().describe('Target menu id.'),
|
|
3632
|
-
path: z.string().optional().describe('Target menu path.'),
|
|
3633
|
-
route: z.string().optional().describe('Target route path.'),
|
|
3634
|
-
value: z.union([z.string(), z.number()]).optional().describe('Static count/chip value. Omit with valueExpression for a dot.'),
|
|
3635
|
-
valueExpression: z.string().optional().describe('Raw Vue expression for count/chip value. Omit value for a dot-only notification.'),
|
|
3636
|
-
color: z.enum(['primary', 'success', 'warning', 'error', 'info', 'neutral']).optional().default('primary').describe('Chip/dot color intent.'),
|
|
3637
|
-
title: z.string().optional().describe('Optional tooltip/title.'),
|
|
3638
|
-
order: z.number().optional().describe('Sort order when multiple notifications target the same menu.'),
|
|
3639
|
-
}, async (input) => jsonText(buildExtensionMenuNotificationSnippet(input)));
|
|
3640
|
-
server.tool('build_extension_account_panel_item', [
|
|
3641
|
-
'Generate useAccountPanelRegistry registration code for a global extension.',
|
|
3642
|
-
'Use this for data-driven account panel rows instead of drawing full custom sidebar/account UI.',
|
|
3643
|
-
].join(' '), {
|
|
3644
|
-
id: z.string().optional().describe('Stable account panel item id.'),
|
|
3645
|
-
order: z.number().optional().describe('Display order.'),
|
|
3646
|
-
label: z.string().optional().describe('Row label.'),
|
|
3647
|
-
description: z.string().optional().describe('Row description.'),
|
|
3648
|
-
icon: z.string().optional().describe('Leading icon.'),
|
|
3649
|
-
count: z.union([z.string(), z.number()]).optional().describe('Static notification chip value.'),
|
|
3650
|
-
countExpression: z.string().optional().describe('Raw Vue expression for notification chip value.'),
|
|
3651
|
-
badge: z.union([z.string(), z.number()]).optional().describe('Legacy static badge value. Prefer count.'),
|
|
3652
|
-
badgeExpression: z.string().optional().describe('Raw Vue expression for badge. Prefer countExpression.'),
|
|
3653
|
-
badgeColor: z.enum(['primary', 'neutral', 'info', 'error', 'warning', 'success']).optional().describe('Chip color.'),
|
|
3654
|
-
trailingIcon: z.string().optional().describe('Trailing icon.'),
|
|
3655
|
-
expandedExpression: z.string().optional().describe('Raw Vue expression controlling expanded state.'),
|
|
3656
|
-
contentComponent: z.string().optional().describe('Raw component reference for inline expanded content.'),
|
|
3657
|
-
contentPropsExpression: z.string().optional().describe('Raw Vue expression for content props.'),
|
|
3658
|
-
onClick: z.string().optional().describe('Direct action handler expression.'),
|
|
3659
|
-
onToggle: z.string().optional().describe('Expandable row toggle handler expression.'),
|
|
3660
|
-
}, async (input) => jsonText(buildExtensionAccountPanelSnippet(input)));
|
|
3661
|
-
server.tool('build_extension_tabs', [
|
|
3662
|
-
'Generate a UTabs snippet for Enfyra extension page sections.',
|
|
3663
|
-
'Use this instead of custom tab bars so app-wide tab chrome owns active indicators, focus rings, spacing, and theme contrast.',
|
|
3664
|
-
].join(' '), {
|
|
3665
|
-
model: z.string().optional().default('activeTab').describe('Active tab model variable.'),
|
|
3666
|
-
itemsExpression: z.string().optional().default('tabs').describe('Raw Vue expression for tab items.'),
|
|
3667
|
-
body: z.string().optional().describe('Vue template body for #content="{ item }".'),
|
|
3668
|
-
}, async (input) => jsonText(buildExtensionTabsSnippet(input)));
|
|
3669
|
-
server.tool('build_extension_upload_modal', [
|
|
3670
|
-
'Generate a CommonUploadModal snippet and upload-progress companion snippet for Enfyra extensions.',
|
|
3671
|
-
'Use this for file upload UI so progress, selected-file rows, and x-enfyra-upload-id wiring follow the app contract.',
|
|
3672
|
-
].join(' '), {
|
|
3673
|
-
model: z.string().optional().default('showUploadModal').describe('Modal open state variable.'),
|
|
3674
|
-
title: z.string().optional().describe('Upload modal title.'),
|
|
3675
|
-
accept: z.string().optional().default('*/*').describe('Accepted mime/extensions.'),
|
|
3676
|
-
multiple: z.boolean().optional().default(true).describe('Allow multiple files.'),
|
|
3677
|
-
maxSizeExpression: z.string().optional().describe('Raw Vue expression for max file size.'),
|
|
3678
|
-
loadingExpression: z.string().optional().describe('Raw Vue expression/ref for upload pending state.'),
|
|
3679
|
-
uploadProgressExpression: z.string().optional().describe('Raw Vue expression/ref for aggregate upload progress.'),
|
|
3680
|
-
fileProgressExpression: z.string().optional().describe('Raw Vue expression for per-row progress map.'),
|
|
3681
|
-
dragText: z.string().optional().describe('Drag/drop text.'),
|
|
3682
|
-
acceptText: z.string().optional().describe('Accept/help text.'),
|
|
3683
|
-
uploadText: z.string().optional().describe('Upload action text.'),
|
|
3684
|
-
uploadingText: z.string().optional().describe('Uploading action text.'),
|
|
3685
|
-
uploadHandler: z.string().optional().default('handleUpload').describe('@upload handler expression.'),
|
|
3686
|
-
errorHandler: z.string().optional().describe('@error handler expression.'),
|
|
3687
|
-
headerContent: z.string().optional().describe('Optional #header-content template, e.g. storage selector. Fields are normalized to w-full.'),
|
|
3688
|
-
}, async (input) => jsonText(buildExtensionUploadModalSnippet(input)));
|
|
3689
|
-
server.tool('extension_workflow', [
|
|
3690
|
-
'Step-by-step workflow for creating or updating Enfyra admin page, global, or widget extensions.',
|
|
3691
|
-
'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.',
|
|
3692
|
-
'With apply=false it validates code, reads live menu/extension state, and returns pending steps.',
|
|
3693
|
-
'With apply=true it applies exactly the next pending step. With applyAll=true it advances all currently safe pending steps.',
|
|
3694
|
-
'Call get_extension_theme_contract before generating or reviewing UI.',
|
|
3695
|
-
].join(' '), {
|
|
3696
|
-
name: z.string().describe('Extension unique name.'),
|
|
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.'),
|
|
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.'),
|
|
3699
|
-
menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
|
|
3700
|
-
menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
|
|
3701
|
-
menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
|
|
3702
|
-
menuIcon: z.string().optional().describe('Optional menu icon name.'),
|
|
3703
|
-
menuType: z.enum(['Menu', 'Dropdown Menu']).optional().describe('Menu type. Omit to preserve an existing menu value or use the platform default for a new menu.'),
|
|
3704
|
-
menuOrder: z.number().optional().describe('Menu display order. Omit to preserve an existing menu value or use the platform default for a new menu.'),
|
|
3705
|
-
menuPermission: z.string().optional().describe('Optional menu permission JSON object. Omit for unrestricted menus; empty objects are normalized to null.'),
|
|
3706
|
-
menuDescription: z.string().optional().describe('Optional menu admin note.'),
|
|
3707
|
-
menuIsEnabled: z.boolean().optional().describe('Enable the menu. Omit to preserve an existing menu value or use the platform default for a new menu.'),
|
|
3708
|
-
description: z.string().optional().describe('Extension description.'),
|
|
3709
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
3710
|
-
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
3711
|
-
apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step. When true, always pass globalRulesAckKey; also pass knowledgeAckKey when saving handler sourceCode.'),
|
|
3712
|
-
applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes. When true, always pass globalRulesAckKey and pass knowledgeAckKey if handler sourceCode may be saved.'),
|
|
3713
|
-
stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
|
|
3714
|
-
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
3715
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required when apply/applyAll saves extension code. Use extensionAckKey from get_enfyra_required_knowledge.'),
|
|
3716
|
-
}, async (input) => jsonText(await runExtensionWorkflow(ENFYRA_API_URL, input)));
|
|
3717
|
-
server.tool('set_table_graphql', 'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.', {
|
|
3718
|
-
tableName: z.string().describe('Table name, alias, or id.'),
|
|
3719
|
-
isEnabled: z.boolean().describe('Desired GraphQL enabled state for the table.'),
|
|
3720
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3721
|
-
}, async ({ tableName, isEnabled, globalRulesAckKey }) => {
|
|
3722
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
3723
|
-
const catalog = await fetchTableCatalog(ENFYRA_API_URL);
|
|
3724
|
-
const table = resolveTableCatalogEntry(catalog, tableName);
|
|
3725
|
-
if (!table)
|
|
3726
|
-
throw new Error(`Table not found: ${tableName}`);
|
|
3727
|
-
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_graphql', { table: { id: { _eq: getId(table) } } }, 'id,_id,table.id,isEnabled');
|
|
3728
|
-
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_graphql', existing, {
|
|
3729
|
-
table: { id: getId(table) },
|
|
3730
|
-
isEnabled,
|
|
3731
|
-
});
|
|
3732
|
-
const graphqlReload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/graphql');
|
|
3733
|
-
return jsonText({
|
|
3734
|
-
action: 'table_graphql_set',
|
|
3735
|
-
table: { id: getId(table), name: table.name },
|
|
3736
|
-
graphql: { id: operation.id, isEnabled },
|
|
3737
|
-
operation,
|
|
3738
|
-
graphqlReload,
|
|
3739
|
-
});
|
|
3740
|
-
});
|
|
3741
|
-
server.tool('add_route_methods', 'Business operation: add HTTP methods to an existing route.', {
|
|
3742
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
3743
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3744
|
-
methods: z.array(z.string()).min(1).describe('HTTP method names to add.'),
|
|
3745
|
-
isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
|
|
3746
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3747
|
-
}, async ({ path, routeId, methods, isEnabled, globalRulesAckKey }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
|
|
3748
|
-
path,
|
|
3749
|
-
routeId,
|
|
3750
|
-
methods,
|
|
3751
|
-
mode: 'merge',
|
|
3752
|
-
isEnabled,
|
|
3753
|
-
globalRulesAckKey,
|
|
3754
|
-
})));
|
|
3755
|
-
server.tool('replace_route_methods', 'Business operation: replace an existing route availableMethods list exactly.', {
|
|
3756
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
3757
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3758
|
-
methods: z.array(z.string()).min(1).describe('Exact HTTP method names for availableMethods.'),
|
|
3759
|
-
isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
|
|
3760
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3761
|
-
}, async ({ path, routeId, methods, isEnabled, globalRulesAckKey }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
|
|
3762
|
-
path,
|
|
3763
|
-
routeId,
|
|
3764
|
-
methods,
|
|
3765
|
-
mode: 'replace',
|
|
3766
|
-
isEnabled,
|
|
3767
|
-
globalRulesAckKey,
|
|
3768
|
-
})));
|
|
3769
|
-
server.tool('remove_route_methods', 'Business operation: remove HTTP methods from an existing route.', {
|
|
3770
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
3771
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3772
|
-
methods: z.array(z.string()).min(1).describe('HTTP method names to remove.'),
|
|
3773
|
-
isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
|
|
3774
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3775
|
-
}, async ({ path, routeId, methods, isEnabled, globalRulesAckKey }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
|
|
3776
|
-
path,
|
|
3777
|
-
routeId,
|
|
3778
|
-
methods,
|
|
3779
|
-
mode: 'remove',
|
|
3780
|
-
isEnabled,
|
|
3781
|
-
globalRulesAckKey,
|
|
3782
|
-
})));
|
|
3783
|
-
server.tool('enable_route', 'Business operation: enable an existing route. Enabled routes are registered at runtime; disabled routes return 404.', {
|
|
3784
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
3785
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3786
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3787
|
-
}, async ({ path, routeId, globalRulesAckKey }) => jsonText(await setRouteEnabled(ENFYRA_API_URL, {
|
|
3788
|
-
path,
|
|
3789
|
-
routeId,
|
|
3790
|
-
isEnabled: true,
|
|
3791
|
-
globalRulesAckKey,
|
|
3792
|
-
})));
|
|
3793
|
-
server.tool('disable_route', 'Business operation: disable an existing route without deleting metadata. Disabled routes are not registered at runtime and return 404.', {
|
|
3794
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
3795
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3796
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3797
|
-
}, async ({ path, routeId, globalRulesAckKey }) => jsonText(await setRouteEnabled(ENFYRA_API_URL, {
|
|
3798
|
-
path,
|
|
3799
|
-
routeId,
|
|
3800
|
-
isEnabled: false,
|
|
3801
|
-
globalRulesAckKey,
|
|
3802
|
-
})));
|
|
3803
|
-
server.tool('delete_route', 'Business operation: preview-first delete for a route and its route-owned handlers, hooks, guards, and permissions. Use only when a route contract is retired.', {
|
|
3804
|
-
path: z.string().optional().describe('Route path, e.g. /old-endpoint. Use either path or routeId.'),
|
|
3805
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3806
|
-
expectedPath: z.string().optional().describe('Optional safety check. When confirm=true, pass the path returned by the preview.'),
|
|
3807
|
-
confirm: z.boolean().optional().default(false).describe('false returns a dependency preview only; true deletes the route and related route-owned records.'),
|
|
3808
|
-
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
3809
|
-
}, async (input) => jsonText(await deleteRoute(ENFYRA_API_URL, input)));
|
|
3810
|
-
server.tool('public_route_methods', 'Business operation: make existing route methods public/anonymous.', {
|
|
3811
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
3812
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3813
|
-
methods: z.array(z.string()).min(1).describe('HTTP method names to make public. They must already be available on the route.'),
|
|
3814
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3815
|
-
}, async ({ path, routeId, methods, globalRulesAckKey }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
|
|
3816
|
-
path,
|
|
3817
|
-
routeId,
|
|
3818
|
-
methods,
|
|
3819
|
-
mode: 'merge',
|
|
3820
|
-
globalRulesAckKey,
|
|
3821
|
-
})));
|
|
3822
|
-
server.tool('private_route_methods', 'Business operation: make specific public route methods private again.', {
|
|
3823
|
-
path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
|
|
3824
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
3825
|
-
methods: z.array(z.string()).min(1).describe('HTTP method names to remove from publicMethods.'),
|
|
3826
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3827
|
-
}, async ({ path, routeId, methods, globalRulesAckKey }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
|
|
3828
|
-
path,
|
|
3829
|
-
routeId,
|
|
3830
|
-
methods,
|
|
3831
|
-
mode: 'remove',
|
|
3832
|
-
globalRulesAckKey,
|
|
3833
|
-
})));
|
|
3834
|
-
server.tool('api_endpoint_workflow', [
|
|
3835
|
-
'Step-by-step workflow for creating or updating a custom REST endpoint.',
|
|
3836
|
-
'Use this when an LLM is building or changing endpoint behavior and should follow live nextSteps instead of guessing raw metadata mutations.',
|
|
3837
|
-
'With apply=false it validates sourceCode, blocks canonical-route collisions, reviews explicit repository metadata/security boundaries, reads live route/handler/access state, and returns pending steps.',
|
|
3838
|
-
'With apply=true it applies only the next pending step, then returns a fresh plan. With applyAll=true it advances all currently safe pending steps.',
|
|
3839
|
-
].join(' '), {
|
|
3840
|
-
path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
|
|
3841
|
-
method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
|
|
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.'),
|
|
3843
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
3844
|
-
anonymousAccess: z.enum(['public', 'private']).optional().default('private').describe('public adds the method to publicMethods; private removes this method from publicMethods.'),
|
|
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.'),
|
|
3846
|
-
roleId: z.union([z.string(), z.number()]).optional().describe('Optional role id for authenticated route permission.'),
|
|
3847
|
-
roleName: z.string().optional().describe('Optional role name for authenticated route permission, e.g. user.'),
|
|
3848
|
-
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Optional user id scope for authenticated route permission.'),
|
|
3849
|
-
routePermissionDescription: z.string().optional().describe('Optional admin note for created/updated route permission.'),
|
|
3850
|
-
description: z.string().optional().describe('Route description.'),
|
|
3851
|
-
timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
|
|
3852
|
-
overwrite: z.boolean().optional().default(false).describe('Required to update an existing handler whose sourceCode/scriptLanguage/timeout differs.'),
|
|
3853
|
-
smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test, e.g. {"a":"1","b":"2"}.'),
|
|
3854
|
-
smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test.'),
|
|
3855
|
-
apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
|
|
3856
|
-
applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
|
|
3857
|
-
stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
|
|
3858
|
-
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
3859
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply/applyAll reaches the save_handler step. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
|
|
3860
|
-
}, async (input) => jsonText(await runApiEndpointWorkflow(ENFYRA_API_URL, input)));
|
|
3861
|
-
server.tool('create_api_endpoint', [
|
|
3862
|
-
'Business operation: create or update a custom REST endpoint with a handler in one safe operation.',
|
|
3863
|
-
'Prefer api_endpoint_workflow when route access, role/user permissions, overwrite decisions, or multi-step planning matter.',
|
|
3864
|
-
'Use this one-shot helper only when the endpoint contract is already clear and no authenticated route-permission step is needed in the same operation, such as a simple public webhook or private admin-only utility that will be granted separately.',
|
|
3865
|
-
'It creates the route without mainTableId, ensures the method is available, validates sourceCode, creates or overwrites the route handler, optionally makes the method public, reloads routes, and can smoke-test the endpoint.',
|
|
3866
|
-
'For sourceCode, call discover_script_contexts first. Use #secure.table_name or @REPOS.secure.table_name for explicit user-facing table access; reserve #table_name/@REPOS.table_name for intentional trusted internal access.',
|
|
3867
|
-
'Use table/schema tools separately when the user needs persisted data. This tool is for custom behavior endpoints.',
|
|
3868
|
-
].join(' '), {
|
|
3869
|
-
path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
|
|
3870
|
-
method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
|
|
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.'),
|
|
3872
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
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.'),
|
|
3874
|
-
description: z.string().optional().describe('Route description.'),
|
|
3875
|
-
timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
|
|
3876
|
-
overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
|
|
3877
|
-
smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test after save, e.g. {"a":"1","b":"2"}.'),
|
|
3878
|
-
smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test after save.'),
|
|
3879
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
3880
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
|
|
3881
|
-
}, async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody, globalRulesAckKey, knowledgeAckKey }) => {
|
|
3882
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
3883
|
-
assertDynamicCodeKnowledgeAck(knowledgeAckKey);
|
|
3884
|
-
const normalizedPath = normalizeRestPath(path);
|
|
3885
|
-
const methodName = normalizeMethodName(method);
|
|
3886
|
-
assertDynamicEndpointContract(reviewDynamicEndpointContract({
|
|
3887
|
-
routeKind: 'custom',
|
|
3888
|
-
method: methodName,
|
|
3889
|
-
sourceCode,
|
|
3890
|
-
}));
|
|
3891
|
-
const [{ methodMap, methodIdNameMap }, routes, scriptValidation, contractReview] = await Promise.all([
|
|
3892
|
-
getMethodContext(ENFYRA_API_URL),
|
|
3893
|
-
fetchAll(ENFYRA_API_URL, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name'),
|
|
3894
|
-
validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
|
|
3895
|
-
sourceCode,
|
|
3896
|
-
scriptLanguage,
|
|
3897
|
-
}),
|
|
3898
|
-
reviewCustomEndpointSource(ENFYRA_API_URL, methodName, sourceCode),
|
|
3899
|
-
]);
|
|
3900
|
-
const methodId = methodMap[methodName];
|
|
3901
|
-
if (!methodId)
|
|
3902
|
-
throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
|
|
3903
|
-
let route = routes.find((item) => item.path === normalizedPath);
|
|
3904
|
-
assertCustomEndpointRoute(route);
|
|
3905
|
-
let routeAction = 'existing';
|
|
3906
|
-
if (!route) {
|
|
3907
|
-
const createRouteResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route', {
|
|
3908
|
-
method: 'POST',
|
|
3909
|
-
body: JSON.stringify({
|
|
3910
|
-
path: normalizedPath,
|
|
3911
|
-
description,
|
|
3912
|
-
isEnabled: true,
|
|
3913
|
-
availableMethods: [{ id: methodId }],
|
|
3914
|
-
publicMethods: makePublic ? [{ id: methodId }] : [],
|
|
3915
|
-
}),
|
|
3916
|
-
});
|
|
3917
|
-
route = firstDataRecord(createRouteResult);
|
|
3918
|
-
routeAction = 'created';
|
|
3919
|
-
}
|
|
3920
|
-
else {
|
|
3921
|
-
const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
|
|
3922
|
-
const publicMethods = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
|
|
3923
|
-
const finalAvailable = uniqueMethodNames([...availableMethods, methodName]);
|
|
3924
|
-
const finalPublic = makePublic ? uniqueMethodNames([...publicMethods, methodName]) : publicMethods;
|
|
3925
|
-
const patchRouteResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
|
|
3926
|
-
method: 'PATCH',
|
|
3927
|
-
body: JSON.stringify({
|
|
3928
|
-
availableMethods: resolveMethodRefs(methodMap, finalAvailable),
|
|
3929
|
-
publicMethods: resolveMethodRefs(methodMap, finalPublic.filter((item) => finalAvailable.includes(item))),
|
|
3930
|
-
...(description !== undefined ? { description } : {}),
|
|
3931
|
-
}),
|
|
3932
|
-
});
|
|
3933
|
-
route = firstDataRecord(patchRouteResult) || route;
|
|
3934
|
-
routeAction = 'updated';
|
|
3935
|
-
}
|
|
3936
|
-
const routeId = getId(route);
|
|
3937
|
-
const existingHandler = await findHandler(ENFYRA_API_URL, routeId, methodId);
|
|
3938
|
-
let handlerResult;
|
|
3939
|
-
let handlerAction;
|
|
3940
|
-
if (existingHandler) {
|
|
3941
|
-
if (!overwrite) {
|
|
3942
|
-
throw new Error(`Handler already exists for ${methodName} ${normalizedPath} with id ${getId(existingHandler)}. Re-run with overwrite=true to update it.`);
|
|
3943
|
-
}
|
|
3944
|
-
handlerAction = 'updated';
|
|
3945
|
-
const body = { sourceCode, scriptLanguage };
|
|
3946
|
-
if (timeout !== undefined)
|
|
3947
|
-
body.timeout = timeout;
|
|
3948
|
-
handlerResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route_handler/${encodeURIComponent(String(getId(existingHandler)))}`, {
|
|
3949
|
-
method: 'PATCH',
|
|
3950
|
-
body: JSON.stringify(body),
|
|
3951
|
-
});
|
|
3952
|
-
}
|
|
3953
|
-
else {
|
|
3954
|
-
handlerAction = 'created';
|
|
3955
|
-
const body = {
|
|
3956
|
-
route: { id: routeId },
|
|
3957
|
-
method: { id: methodId },
|
|
3958
|
-
sourceCode,
|
|
3959
|
-
scriptLanguage,
|
|
3960
|
-
};
|
|
3961
|
-
if (timeout !== undefined)
|
|
3962
|
-
body.timeout = timeout;
|
|
3963
|
-
handlerResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_handler', {
|
|
3964
|
-
method: 'POST',
|
|
3965
|
-
body: JSON.stringify(body),
|
|
3966
|
-
});
|
|
3967
|
-
}
|
|
3968
|
-
const routeReload = await reloadRoutes(ENFYRA_API_URL);
|
|
3969
|
-
let smokeTest = null;
|
|
3970
|
-
if (smokeTestQuery !== undefined || smokeTestBody !== undefined) {
|
|
3971
|
-
const query = smokeTestQuery ? JSON.parse(smokeTestQuery) : {};
|
|
3972
|
-
if (!query || typeof query !== 'object' || Array.isArray(query))
|
|
3973
|
-
throw new Error('smokeTestQuery must be a JSON object.');
|
|
3974
|
-
const queryParams = new URLSearchParams();
|
|
3975
|
-
for (const [key, value] of Object.entries(query)) {
|
|
3976
|
-
if (value !== undefined && value !== null)
|
|
3977
|
-
queryParams.set(key, String(value));
|
|
3978
|
-
}
|
|
3979
|
-
const body = smokeTestBody ? JSON.parse(smokeTestBody) : undefined;
|
|
3980
|
-
const smokePath = `${normalizedPath}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
|
3981
|
-
smokeTest = await fetchAPI(ENFYRA_API_URL, smokePath, {
|
|
3982
|
-
method: methodName,
|
|
3983
|
-
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
3984
|
-
});
|
|
3985
|
-
}
|
|
3986
|
-
const savedHandler = firstDataRecord(handlerResult);
|
|
3987
|
-
return {
|
|
3988
|
-
content: [{
|
|
3989
|
-
type: 'text',
|
|
3990
|
-
text: JSON.stringify({
|
|
3991
|
-
action: 'api_endpoint_ready',
|
|
3992
|
-
endpoint: {
|
|
3993
|
-
path: normalizedPath,
|
|
3994
|
-
method: methodName,
|
|
3995
|
-
public: makePublic,
|
|
3996
|
-
routeId,
|
|
3997
|
-
handlerId: getId(savedHandler) || getId(existingHandler),
|
|
3998
|
-
},
|
|
3999
|
-
routeAction,
|
|
4000
|
-
handlerAction,
|
|
4001
|
-
scriptValidation,
|
|
4002
|
-
contractReview,
|
|
4003
|
-
routeReload,
|
|
4004
|
-
smokeTest,
|
|
4005
|
-
usage: {
|
|
4006
|
-
restPath: `${ENFYRA_API_URL.replace(/\/$/, '')}${normalizedPath}`,
|
|
4007
|
-
auth: makePublic ? 'anonymous allowed for this method' : 'Bearer auth and route access are required unless another guard bypass applies',
|
|
4008
|
-
},
|
|
4009
|
-
}, null, 2),
|
|
4010
|
-
}],
|
|
4011
|
-
};
|
|
4012
|
-
});
|
|
4013
|
-
server.tool('ensure_column_rule', 'Business operation: create or update a column validation rule. It resolves table/column ids and avoids duplicate rules for the same column+ruleType.', {
|
|
4014
|
-
tableName: z.string().describe('Table name, alias, or id.'),
|
|
4015
|
-
columnName: z.string().describe('Column name or id.'),
|
|
4016
|
-
ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type.'),
|
|
4017
|
-
value: z.string().optional().describe('Rule config JSON object, usually {"v": ...}.'),
|
|
4018
|
-
message: z.string().optional().describe('Custom validation error message.'),
|
|
4019
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4020
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable the rule.'),
|
|
4021
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4022
|
-
}, async ({ tableName, columnName, ruleType, value, message, description, isEnabled, globalRulesAckKey }) => {
|
|
4023
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
4024
|
-
const table = await fetchTableMetadataByRef(ENFYRA_API_URL, tableName);
|
|
4025
|
-
const column = resolveColumn(table, columnName);
|
|
4026
|
-
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_column_rule', {
|
|
4027
|
-
column: { id: { _eq: getId(column) } },
|
|
4028
|
-
ruleType: { _eq: ruleType },
|
|
4029
|
-
}, 'id,_id,column.id,ruleType');
|
|
4030
|
-
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_column_rule', existing, {
|
|
4031
|
-
column: { id: getId(column) },
|
|
4032
|
-
ruleType,
|
|
4033
|
-
value: parseJsonObjectArg('value', value, null),
|
|
4034
|
-
message,
|
|
4035
|
-
description,
|
|
4036
|
-
isEnabled,
|
|
4037
|
-
});
|
|
4038
|
-
return jsonText({
|
|
4039
|
-
action: 'column_rule_ensured',
|
|
4040
|
-
table: { id: getId(table), name: table.name },
|
|
4041
|
-
column: { id: getId(column), name: column.name },
|
|
4042
|
-
ruleType,
|
|
4043
|
-
operation,
|
|
4044
|
-
});
|
|
4045
|
-
});
|
|
4046
|
-
server.tool('ensure_field_permission', 'Business operation: create or update one field permission. It resolves table field ids, enforces exactly one column/relation target, and enforces a role/user scope.', {
|
|
4047
|
-
tableName: z.string().describe('Table name, alias, or id.'),
|
|
4048
|
-
columnName: z.string().optional().describe('Column name/id to protect. Use exactly one of columnName or relationName.'),
|
|
4049
|
-
relationName: z.string().optional().describe('Relation propertyName/id to protect. Use exactly one of columnName or relationName.'),
|
|
4050
|
-
action: z.enum(['read', 'create', 'update']).optional().default('read').describe('Field action.'),
|
|
4051
|
-
effect: z.enum(['allow', 'deny']).optional().default('allow').describe('Permission effect.'),
|
|
4052
|
-
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope.'),
|
|
4053
|
-
roleName: z.string().optional().describe('Role name scope.'),
|
|
4054
|
-
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Direct user id scope.'),
|
|
4055
|
-
condition: z.string().optional().describe('Condition JSON object using field permission DSL.'),
|
|
4056
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4057
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable the permission.'),
|
|
4058
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4059
|
-
}, async ({ tableName, columnName, relationName, action, effect, roleId, roleName, allowedUserIds, condition, description, isEnabled, globalRulesAckKey }) => {
|
|
4060
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
4061
|
-
if (!!columnName === !!relationName)
|
|
4062
|
-
throw new Error('Provide exactly one of columnName or relationName.');
|
|
4063
|
-
assertOneScope({ roleId, roleName, allowedUserIds });
|
|
4064
|
-
const [table, role] = await Promise.all([
|
|
4065
|
-
fetchTableMetadataByRef(ENFYRA_API_URL, tableName),
|
|
4066
|
-
resolveRole(ENFYRA_API_URL, { roleId, roleName }),
|
|
4067
|
-
]);
|
|
4068
|
-
const field = columnName ? resolveColumn(table, columnName) : resolveRelation(table, relationName);
|
|
4069
|
-
const filter = {
|
|
4070
|
-
action: { _eq: action },
|
|
4071
|
-
effect: { _eq: effect },
|
|
4072
|
-
...(columnName ? { column: { id: { _eq: getId(field) } } } : { relation: { id: { _eq: getId(field) } } }),
|
|
4073
|
-
...(role ? { role: { id: { _eq: role.id } } } : {}),
|
|
4074
|
-
};
|
|
4075
|
-
const existing = role
|
|
4076
|
-
? await findRecord(ENFYRA_API_URL, 'enfyra_field_permission', filter, 'id,_id,column.id,relation.id,role.id,action,effect')
|
|
4077
|
-
: null;
|
|
4078
|
-
const body = {
|
|
4079
|
-
action,
|
|
4080
|
-
effect,
|
|
4081
|
-
isEnabled,
|
|
4082
|
-
description,
|
|
4083
|
-
condition: parseJsonObjectArg('condition', condition, null),
|
|
4084
|
-
...(columnName ? { column: { id: getId(field) } } : { relation: { id: getId(field) } }),
|
|
4085
|
-
...(role ? { role: { id: role.id } } : {}),
|
|
4086
|
-
...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
|
|
4087
|
-
};
|
|
4088
|
-
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_field_permission', existing, body);
|
|
4089
|
-
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/metadata');
|
|
4090
|
-
return jsonText({
|
|
4091
|
-
action: 'field_permission_ensured',
|
|
4092
|
-
table: { id: getId(table), name: table.name },
|
|
4093
|
-
field: { id: getId(field), name: columnName ? field.name : field.propertyName, kind: columnName ? 'column' : 'relation' },
|
|
4094
|
-
scope: { role, allowedUserIds: allowedUserIds || [] },
|
|
4095
|
-
operation,
|
|
4096
|
-
reload,
|
|
4097
|
-
});
|
|
4098
|
-
});
|
|
4099
|
-
server.tool('ensure_route_rate_limit', 'Business operation: create or update a route rate-limit guard through the Enfyra guard engine. Prefer this over pre-hooks or raw guard JSON for request throttling.', {
|
|
4100
|
-
name: z.string().optional().describe('Optional guard name. Defaults to a stable name based on path, methods, and scope.'),
|
|
4101
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id.'),
|
|
4102
|
-
path: z.string().optional().describe('Route path to protect, e.g. /newsletter_signup.'),
|
|
4103
|
-
methods: z.array(z.string()).default(['POST']).describe('HTTP method names to protect.'),
|
|
4104
|
-
scope: z.enum(['ip', 'user', 'route']).default('ip').describe('Rate-limit key scope. Use ip for public/pre-auth routes, user for authenticated users, route for a shared route-wide limit.'),
|
|
4105
|
-
maxRequests: z.number().int().positive().describe('Allowed request count per window.'),
|
|
4106
|
-
perSeconds: z.number().int().positive().describe('Window length in seconds.'),
|
|
4107
|
-
position: z.enum(['pre_auth', 'post_auth']).optional().describe('Optional override. Defaults to pre_auth for ip/route and post_auth for user.'),
|
|
4108
|
-
priority: z.number().optional().default(0).describe('Lower runs earlier.'),
|
|
4109
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable the guard. Defaults true.'),
|
|
4110
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4111
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4112
|
-
}, async ({ name, routeId, path, methods, scope, maxRequests, perSeconds, position, priority, isEnabled, description, globalRulesAckKey }) => {
|
|
4113
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
4114
|
-
if (path && routeId)
|
|
4115
|
-
throw new Error('Provide path or routeId, not both.');
|
|
4116
|
-
const resolvedPosition = position || (scope === 'user' ? 'post_auth' : 'pre_auth');
|
|
4117
|
-
if (scope === 'user' && resolvedPosition === 'pre_auth') {
|
|
4118
|
-
throw new Error('User-scoped rate limits require post_auth because user identity is unavailable before auth.');
|
|
4119
|
-
}
|
|
4120
|
-
const { route } = await resolveRoute(ENFYRA_API_URL, { path, routeId });
|
|
4121
|
-
const { methodMap } = await getMethodContext(ENFYRA_API_URL);
|
|
4122
|
-
const methodNames = uniqueMethodNames(methods?.length ? methods : ['POST']);
|
|
4123
|
-
const ruleType = scope === 'user' ? 'rate_limit_by_user' : scope === 'route' ? 'rate_limit_by_route' : 'rate_limit_by_ip';
|
|
4124
|
-
const guardName = name || `Rate limit ${scope} ${route.path} ${methodNames.join('_')}`;
|
|
4125
|
-
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_guard', { name: { _eq: guardName } }, 'id,_id,name');
|
|
4126
|
-
const guardBody = {
|
|
4127
|
-
name: guardName,
|
|
4128
|
-
position: resolvedPosition,
|
|
4129
|
-
combinator: 'and',
|
|
4130
|
-
priority,
|
|
4131
|
-
isGlobal: false,
|
|
4132
|
-
isEnabled,
|
|
4133
|
-
description: description || `Rate-limit ${methodNames.join(', ')} ${route.path} by ${scope}.`,
|
|
4134
|
-
route: { id: getId(route) },
|
|
4135
|
-
methods: resolveMethodRefs(methodMap, methodNames),
|
|
4136
|
-
};
|
|
4137
|
-
const guardOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_guard', existing, guardBody);
|
|
4138
|
-
const guardId = guardOperation.id || getId(existing);
|
|
4139
|
-
const existingRules = await fetchRecords(ENFYRA_API_URL, 'enfyra_guard_rule', { guard: { id: { _eq: guardId } } }, 'id,_id,isEnabled');
|
|
4140
|
-
const disabledRules = [];
|
|
4141
|
-
for (const rule of existingRules) {
|
|
4142
|
-
disabledRules.push(await fetchAPI(ENFYRA_API_URL, `/enfyra_guard_rule/${encodeURIComponent(String(getId(rule)))}`, {
|
|
4143
|
-
method: 'PATCH',
|
|
4144
|
-
body: JSON.stringify({ isEnabled: false }),
|
|
4145
|
-
}));
|
|
4146
|
-
}
|
|
4147
|
-
const rule = await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
|
|
4148
|
-
method: 'POST',
|
|
4149
|
-
body: JSON.stringify({
|
|
4150
|
-
type: ruleType,
|
|
4151
|
-
config: { maxRequests, perSeconds },
|
|
4152
|
-
priority: 0,
|
|
4153
|
-
isEnabled: true,
|
|
4154
|
-
description: `${maxRequests} request${maxRequests === 1 ? '' : 's'} per ${perSeconds} seconds by ${scope}.`,
|
|
4155
|
-
guard: { id: guardId },
|
|
4156
|
-
}),
|
|
4157
|
-
});
|
|
4158
|
-
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/guards');
|
|
4159
|
-
return jsonText({
|
|
4160
|
-
action: 'route_rate_limit_ensured',
|
|
4161
|
-
route: { id: getId(route), path: route.path },
|
|
4162
|
-
methods: methodNames,
|
|
4163
|
-
guard: { id: guardId, name: guardName, position: resolvedPosition, isEnabled },
|
|
4164
|
-
rule: { type: ruleType, config: { maxRequests, perSeconds }, result: rule },
|
|
4165
|
-
disabledRuleCount: disabledRules.length,
|
|
4166
|
-
reload,
|
|
4167
|
-
next: 'Call inspect_route({ path }) to confirm the guard is attached, then test behavior through the actual REST route if doing so will not consume a production rate-limit bucket.',
|
|
4168
|
-
});
|
|
4169
|
-
});
|
|
4170
|
-
server.tool('ensure_guard', 'Advanced business operation: create or update a custom request guard tree and optional guard rules. For simple request throttling use ensure_route_rate_limit instead.', {
|
|
4171
|
-
name: z.string().describe('Guard name. Existing guard with this name is updated unless guardId is provided.'),
|
|
4172
|
-
guardId: z.union([z.string(), z.number()]).optional().describe('Optional existing guard id.'),
|
|
4173
|
-
position: z.enum(['pre_auth', 'post_auth']).optional().default('pre_auth').describe('Guard position.'),
|
|
4174
|
-
routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id.'),
|
|
4175
|
-
path: z.string().optional().describe('Optional route path.'),
|
|
4176
|
-
methods: z.array(z.string()).optional().describe('HTTP method names.'),
|
|
4177
|
-
combinator: z.enum(['and', 'or']).optional().default('and').describe('Rule combinator.'),
|
|
4178
|
-
priority: z.number().optional().default(0).describe('Lower runs earlier.'),
|
|
4179
|
-
isGlobal: z.boolean().optional().default(false).describe('Apply globally.'),
|
|
4180
|
-
isEnabled: z.boolean().optional().default(false).describe('Enable guard. Defaults false to avoid lockout.'),
|
|
4181
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4182
|
-
rules: z.string().optional().describe('Rules JSON array: [{type, config, priority, isEnabled, description, userIds}].'),
|
|
4183
|
-
rulesMode: z.enum(['append', 'replace', 'none']).optional().default('append').describe('append creates rules, replace disables existing rules first, none leaves rules unchanged.'),
|
|
4184
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4185
|
-
}, async ({ name, guardId, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules, rulesMode, globalRulesAckKey }) => {
|
|
4186
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
4187
|
-
if (path && routeId)
|
|
4188
|
-
throw new Error('Provide path or routeId, not both.');
|
|
4189
|
-
const ruleInputs = parseJsonArrayArg('rules', rules, []);
|
|
4190
|
-
if (position === 'pre_auth') {
|
|
4191
|
-
const invalid = ruleInputs.filter((rule) => rule.type === 'rate_limit_by_user' || (Array.isArray(rule.userIds) && rule.userIds.length));
|
|
4192
|
-
if (invalid.length)
|
|
4193
|
-
throw new Error('pre_auth guards cannot use user-based rules or userIds. Use post_auth.');
|
|
4194
|
-
}
|
|
4195
|
-
let route = null;
|
|
4196
|
-
if (!isGlobal && (routeId || path)) {
|
|
4197
|
-
route = (await resolveRoute(ENFYRA_API_URL, { path, routeId })).route;
|
|
4198
|
-
}
|
|
4199
|
-
const { methodMap } = await getMethodContext(ENFYRA_API_URL);
|
|
4200
|
-
const existing = guardId
|
|
4201
|
-
? await findRecord(ENFYRA_API_URL, 'enfyra_guard', { id: { _eq: guardId } }, 'id,_id,name')
|
|
4202
|
-
: await findRecord(ENFYRA_API_URL, 'enfyra_guard', { name: { _eq: name } }, 'id,_id,name');
|
|
4203
|
-
const guardBody = {
|
|
4204
|
-
name,
|
|
4205
|
-
position,
|
|
4206
|
-
combinator,
|
|
4207
|
-
priority,
|
|
4208
|
-
isGlobal,
|
|
4209
|
-
isEnabled,
|
|
4210
|
-
description,
|
|
4211
|
-
...(route ? { route: { id: getId(route) } } : {}),
|
|
4212
|
-
...(methods?.length ? { methods: resolveMethodRefs(methodMap, methods) } : {}),
|
|
4213
|
-
};
|
|
4214
|
-
const guardOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_guard', existing, guardBody);
|
|
4215
|
-
const resolvedGuardId = guardOperation.id || getId(existing);
|
|
4216
|
-
const existingRules = rulesMode === 'replace'
|
|
4217
|
-
? await fetchRecords(ENFYRA_API_URL, 'enfyra_guard_rule', { guard: { id: { _eq: resolvedGuardId } } }, 'id,_id,isEnabled')
|
|
4218
|
-
: [];
|
|
4219
|
-
const disabledRules = [];
|
|
4220
|
-
for (const rule of existingRules) {
|
|
4221
|
-
disabledRules.push(await fetchAPI(ENFYRA_API_URL, `/enfyra_guard_rule/${encodeURIComponent(String(getId(rule)))}`, {
|
|
4222
|
-
method: 'PATCH',
|
|
4223
|
-
body: JSON.stringify({ isEnabled: false }),
|
|
4224
|
-
}));
|
|
4225
|
-
}
|
|
4226
|
-
const createdRules = [];
|
|
4227
|
-
if (rulesMode !== 'none') {
|
|
4228
|
-
for (const rule of ruleInputs) {
|
|
4229
|
-
createdRules.push(await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
|
|
4230
|
-
method: 'POST',
|
|
4231
|
-
body: JSON.stringify({
|
|
4232
|
-
type: rule.type,
|
|
4233
|
-
config: rule.config,
|
|
4234
|
-
priority: rule.priority ?? 0,
|
|
4235
|
-
isEnabled: rule.isEnabled ?? true,
|
|
4236
|
-
description: rule.description,
|
|
4237
|
-
guard: { id: resolvedGuardId },
|
|
4238
|
-
...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
|
|
4239
|
-
}),
|
|
4240
|
-
}));
|
|
4241
|
-
}
|
|
4242
|
-
}
|
|
4243
|
-
const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/guards');
|
|
4244
|
-
return jsonText({
|
|
4245
|
-
action: 'guard_ensured',
|
|
4246
|
-
guard: { id: resolvedGuardId, name, route: route ? route.path : null, isGlobal },
|
|
4247
|
-
guardOperation,
|
|
4248
|
-
disabledRuleCount: disabledRules.length,
|
|
4249
|
-
createdRuleCount: createdRules.length,
|
|
4250
|
-
reload,
|
|
4251
|
-
});
|
|
4252
|
-
});
|
|
4253
|
-
server.tool('ensure_websocket_gateway', 'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler sourceCode is validated before save.', {
|
|
4254
|
-
path: z.string().describe('Gateway namespace/path, e.g. /chat.'),
|
|
4255
|
-
sourceCode: z.string().optional().describe('Optional connection handler dynamic script sourceCode.'),
|
|
4256
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
|
|
4257
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
|
|
4258
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4259
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4260
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when sourceCode is provided. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
|
|
4261
|
-
}, async ({ path, sourceCode, scriptLanguage, isEnabled, description, globalRulesAckKey, knowledgeAckKey }) => {
|
|
4262
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
4263
|
-
assertDynamicCodeKnowledgeAckIf(sourceCode !== undefined, knowledgeAckKey);
|
|
4264
|
-
const normalizedPath = normalizeRestPath(path);
|
|
4265
|
-
const validation = sourceCode === undefined
|
|
4266
|
-
? { validated: false, reason: 'no sourceCode' }
|
|
4267
|
-
: await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
|
|
4268
|
-
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizedPath } }, 'id,_id,path');
|
|
4269
|
-
const body = {
|
|
4270
|
-
path: normalizedPath,
|
|
4271
|
-
isEnabled,
|
|
4272
|
-
description,
|
|
4273
|
-
...(sourceCode !== undefined ? { sourceCode, scriptLanguage } : {}),
|
|
4274
|
-
};
|
|
4275
|
-
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket', existing, body);
|
|
4276
|
-
const reload = naturalPartialReload('Websocket metadata writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
|
|
4277
|
-
return jsonText({ action: 'websocket_gateway_ensured', gateway: { id: operation.id, path: normalizedPath }, validation, operation, reload });
|
|
4278
|
-
});
|
|
4279
|
-
server.tool('ensure_websocket_event', 'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates sourceCode before save.', {
|
|
4280
|
-
gatewayPath: z.string().optional().describe('Gateway path, e.g. /chat. Use gatewayPath or gatewayId.'),
|
|
4281
|
-
gatewayId: z.union([z.string(), z.number()]).optional().describe('Gateway id. Use gatewayPath or gatewayId.'),
|
|
4282
|
-
eventName: z.string().describe('Socket event name.'),
|
|
4283
|
-
sourceCode: z.string().describe('Event handler dynamic script sourceCode.'),
|
|
4284
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
4285
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
|
|
4286
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4287
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4288
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
|
|
4289
|
-
}, async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description, globalRulesAckKey, knowledgeAckKey }) => {
|
|
4290
|
-
assertGlobalRulesAck(globalRulesAckKey);
|
|
4291
|
-
assertDynamicCodeKnowledgeAck(knowledgeAckKey);
|
|
4292
|
-
if (!gatewayPath && !gatewayId)
|
|
4293
|
-
throw new Error('Provide gatewayPath or gatewayId.');
|
|
4294
|
-
if (gatewayPath && gatewayId)
|
|
4295
|
-
throw new Error('Provide gatewayPath or gatewayId, not both.');
|
|
4296
|
-
const gateway = gatewayId
|
|
4297
|
-
? await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { id: { _eq: gatewayId } }, 'id,_id,path')
|
|
4298
|
-
: await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizeRestPath(gatewayPath) } }, 'id,_id,path');
|
|
4299
|
-
if (!gateway)
|
|
4300
|
-
throw new Error(`Websocket gateway not found: ${gatewayId || gatewayPath}`);
|
|
4301
|
-
const validation = await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
|
|
4302
|
-
const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket_event', {
|
|
4303
|
-
gateway: { id: { _eq: getId(gateway) } },
|
|
4304
|
-
eventName: { _eq: eventName },
|
|
4305
|
-
}, 'id,_id,eventName,gateway.id');
|
|
4306
|
-
const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket_event', existing, {
|
|
4307
|
-
gateway: { id: getId(gateway) },
|
|
4308
|
-
eventName,
|
|
4309
|
-
sourceCode,
|
|
4310
|
-
scriptLanguage,
|
|
4311
|
-
isEnabled,
|
|
4312
|
-
description,
|
|
4313
|
-
});
|
|
4314
|
-
const reload = naturalPartialReload('Websocket event writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
|
|
4315
|
-
return jsonText({ action: 'websocket_event_ensured', gateway: { id: getId(gateway), path: gateway.path }, eventName, validation, operation, reload });
|
|
4316
|
-
});
|
|
4317
|
-
server.tool('flow_workflow', [
|
|
4318
|
-
'Workflow front door for creating or updating an Enfyra flow and its steps in one guided path.',
|
|
4319
|
-
'For a fully specified, non-destructive flow, use apply=true to create/update the flow and all steps sequentially in one call. Use apply=false only when step types or risk need review.',
|
|
4320
|
-
'Prefer this over choosing individual ensure_*_flow_step tools in guided mode.',
|
|
4321
|
-
'Fixed query/create/update/delete/http/sleep/trigger/log config is static in current ESV and does not interpolate @FLOW_PAYLOAD/@FLOW_LAST/@FLOW; use a focused script step for runtime values.',
|
|
4322
|
-
].join(' '), {
|
|
4323
|
-
name: z.string().describe('Flow name. Existing flow with this name is updated.'),
|
|
4324
|
-
triggerType: z.enum(['manual', 'schedule']).optional().default('manual').describe('manual for API/admin/hook/child flow usage, schedule for cron/time-based flows.'),
|
|
4325
|
-
triggerConfig: z.union([z.record(z.any()), z.string()]).optional().describe('Trigger config object or JSON string. Required for scheduled flows.'),
|
|
4326
|
-
steps: z.array(z.union([
|
|
4327
|
-
z.string(),
|
|
4328
|
-
z.object({
|
|
4329
|
-
key: z.string().optional().describe('Stable step key. Generated from intent when omitted.'),
|
|
4330
|
-
name: z.string().optional().describe('Human label. Defaults from intent.'),
|
|
4331
|
-
intent: z.string().optional().describe('Plain-language step intent. Used to choose a fixed step type when type is omitted.'),
|
|
4332
|
-
type: z.enum(['query', 'create', 'update', 'delete', 'http', 'condition', 'sleep', 'trigger_flow', 'log', 'script']).optional().describe('Explicit step type. Omit to let the workflow choose from intent.'),
|
|
4333
|
-
config: z.union([z.record(z.any()), z.string()]).optional().describe('Step config object or JSON string. Fixed-step config is static and cannot contain @FLOW_PAYLOAD/@FLOW_LAST/@FLOW. Use a focused script step for runtime values.'),
|
|
4334
|
-
sourceCode: z.string().optional().describe('Only for script or condition steps. Use fixed step types when possible.'),
|
|
4335
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript'),
|
|
4336
|
-
order: z.number().optional().describe('Step order. Defaults to index * 10.'),
|
|
4337
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4338
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4339
|
-
}),
|
|
4340
|
-
])).min(1).max(30).describe('Ordered step intents/definitions. Keep one business operation per step.'),
|
|
4341
|
-
timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
|
|
4342
|
-
maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
|
|
4343
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
|
|
4344
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4345
|
-
apply: z.boolean().optional().default(false).describe('false returns plan only; true applies flow and steps sequentially.'),
|
|
4346
|
-
globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
|
|
4347
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply=true and any script/condition step has sourceCode.'),
|
|
4348
|
-
}, async (input) => jsonText(await runFlowWorkflow(ENFYRA_API_URL, input)));
|
|
4349
|
-
server.tool('ensure_manual_flow', 'Business operation: create or update a manually triggered Enfyra flow. Use this when the flow is run by API, admin action, another flow, or hook.', {
|
|
4350
|
-
name: z.string().describe('Flow name. Existing flow with this name is updated.'),
|
|
4351
|
-
timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
|
|
4352
|
-
maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
|
|
4353
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
|
|
4354
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4355
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4356
|
-
}, async ({ name, timeout, maxExecutions, isEnabled, description, globalRulesAckKey }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
|
|
4357
|
-
name,
|
|
4358
|
-
triggerType: 'manual',
|
|
4359
|
-
triggerConfig: {},
|
|
4360
|
-
timeout,
|
|
4361
|
-
maxExecutions,
|
|
4362
|
-
isEnabled,
|
|
4363
|
-
description,
|
|
4364
|
-
globalRulesAckKey,
|
|
4365
|
-
})));
|
|
4366
|
-
server.tool('ensure_scheduled_flow', 'Business operation: create or update a scheduled Enfyra flow. Use this only for cron/time-based flows.', {
|
|
4367
|
-
name: z.string().describe('Flow name. Existing flow with this name is updated.'),
|
|
4368
|
-
triggerConfig: z.string().describe('Schedule config JSON object.'),
|
|
4369
|
-
timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
|
|
4370
|
-
maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
|
|
4371
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
|
|
4372
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4373
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4374
|
-
}, async ({ name, triggerConfig, timeout, maxExecutions, isEnabled, description, globalRulesAckKey }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
|
|
4375
|
-
name,
|
|
4376
|
-
triggerType: 'schedule',
|
|
4377
|
-
triggerConfig,
|
|
4378
|
-
timeout,
|
|
4379
|
-
maxExecutions,
|
|
4380
|
-
isEnabled,
|
|
4381
|
-
description,
|
|
4382
|
-
globalRulesAckKey,
|
|
4383
|
-
})));
|
|
4384
|
-
server.tool('choose_flow_step_tool', 'Dry-run helper: choose the most specific Enfyra flow step tool for one intended step before mutating flow metadata.', {
|
|
4385
|
-
intent: z.string().describe('Plain-language description of what this one flow step should do.'),
|
|
4386
|
-
}, async ({ intent }) => {
|
|
4387
|
-
const recommendation = chooseFlowStepTool(intent);
|
|
4388
|
-
return jsonText({
|
|
4389
|
-
action: 'flow_step_tool_recommended',
|
|
4390
|
-
intent,
|
|
4391
|
-
recommendation,
|
|
4392
|
-
availableStepTools: FLOW_STEP_TOOL_GUIDANCE,
|
|
4393
|
-
nextSteps: [
|
|
4394
|
-
`Call ${recommendation.tool} with a stable key and order.`,
|
|
4395
|
-
'Use ensure_script_flow_step only when the atomic tools cannot express the behavior.',
|
|
4396
|
-
'After saving script or condition steps, use test_flow_step before relying on the flow.',
|
|
4397
|
-
],
|
|
4398
|
-
});
|
|
4399
|
-
});
|
|
4400
|
-
server.tool('plan_flow_steps', 'Dry-run helper: choose the ordered Enfyra flow step tools for a whole flow plan before mutating flow metadata.', {
|
|
4401
|
-
steps: z.array(z.union([
|
|
4402
|
-
z.string(),
|
|
4403
|
-
z.object({
|
|
4404
|
-
key: z.string().optional().describe('Stable step key. Generated when omitted.'),
|
|
4405
|
-
name: z.string().optional().describe('Human label. Defaults from key.'),
|
|
4406
|
-
intent: z.string().describe('Plain-language description of this step.'),
|
|
4407
|
-
}),
|
|
4408
|
-
])).min(1).max(30).describe('Ordered step intents. Use this before ensure_*_flow_step calls when a flow has multiple steps.'),
|
|
4409
|
-
}, async ({ steps }) => {
|
|
4410
|
-
const plan = planFlowSteps(steps);
|
|
4411
|
-
return jsonText({
|
|
4412
|
-
action: 'flow_steps_planned',
|
|
4413
|
-
stepCount: plan.length,
|
|
4414
|
-
plan,
|
|
4415
|
-
nextSteps: [
|
|
4416
|
-
'Create or update the flow with ensure_manual_flow or ensure_scheduled_flow first.',
|
|
4417
|
-
'Call each planned ensure_*_flow_step in order, adding flowName or flowId plus table/query/config details.',
|
|
4418
|
-
'Use ensure_script_flow_step only for steps where the plan chose script because fixed step types are insufficient.',
|
|
4419
|
-
'Use test_flow_step for script/condition/high-risk steps before triggering the full flow.',
|
|
4420
|
-
],
|
|
4421
|
-
});
|
|
4422
|
-
});
|
|
4423
|
-
server.tool('ensure_script_flow_step', 'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.', {
|
|
4424
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4425
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4426
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4427
|
-
sourceCode: z.string().describe('Script sourceCode.'),
|
|
4428
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4429
|
-
config: z.string().optional().describe('Step config JSON object.'),
|
|
4430
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
4431
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4432
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4433
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4434
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
|
|
4435
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4436
|
-
...input,
|
|
4437
|
-
type: 'script',
|
|
4438
|
-
})));
|
|
4439
|
-
server.tool('ensure_condition_flow_step', 'Business operation: create or update one condition flow step. Use this for dynamic conditional branching instead of choosing type=condition manually.', {
|
|
4440
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4441
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4442
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4443
|
-
sourceCode: z.string().describe('Condition sourceCode.'),
|
|
4444
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4445
|
-
config: z.string().optional().describe('Step config JSON object.'),
|
|
4446
|
-
scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
|
|
4447
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4448
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4449
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4450
|
-
knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
|
|
4451
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4452
|
-
...input,
|
|
4453
|
-
type: 'condition',
|
|
4454
|
-
})));
|
|
4455
|
-
server.tool('ensure_query_flow_step', 'Business operation: create or update one query flow step. Use this for repository/query-style flow steps instead of choosing type=query manually.', {
|
|
4456
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4457
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4458
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4459
|
-
config: z.string().describe('Step config JSON object.'),
|
|
4460
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4461
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4462
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4463
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4464
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4465
|
-
...input,
|
|
4466
|
-
type: 'query',
|
|
4467
|
-
})));
|
|
4468
|
-
server.tool('ensure_http_flow_step', 'Business operation: create or update one HTTP flow step. Use this for outbound HTTP calls instead of choosing type=http manually.', {
|
|
4469
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4470
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4471
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4472
|
-
config: z.string().describe('Step config JSON object.'),
|
|
4473
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4474
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4475
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4476
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4477
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4478
|
-
...input,
|
|
4479
|
-
type: 'http',
|
|
4480
|
-
})));
|
|
4481
|
-
server.tool('ensure_create_flow_step', 'Business operation: create or update one create-record flow step. Use this for a single table insert instead of writing script code.', {
|
|
4482
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4483
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4484
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4485
|
-
config: z.string().describe('Step config JSON object: { "table": "...", "data": { ... } }.'),
|
|
4486
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4487
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4488
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4489
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4490
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4491
|
-
...input,
|
|
4492
|
-
type: 'create',
|
|
4493
|
-
})));
|
|
4494
|
-
server.tool('ensure_update_flow_step', 'Business operation: create or update one update-record flow step. Use this for a single table update by id instead of writing script code.', {
|
|
4495
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4496
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4497
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4498
|
-
config: z.string().describe('Step config JSON object: { "table": "...", "id": "...", "data": { ... } }.'),
|
|
4499
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4500
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4501
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4502
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4503
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4504
|
-
...input,
|
|
4505
|
-
type: 'update',
|
|
4506
|
-
})));
|
|
4507
|
-
server.tool('ensure_delete_flow_step', 'Business operation: create or update one delete-record flow step. Use this for a single table delete by id instead of writing script code.', {
|
|
4508
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4509
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4510
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4511
|
-
config: z.string().describe('Step config JSON object: { "table": "...", "id": "..." }.'),
|
|
4512
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4513
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4514
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4515
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4516
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4517
|
-
...input,
|
|
4518
|
-
type: 'delete',
|
|
4519
|
-
})));
|
|
4520
|
-
server.tool('ensure_sleep_flow_step', 'Business operation: create or update one sleep/wait flow step. Use this for delays instead of choosing type=sleep manually.', {
|
|
4521
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4522
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4523
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4524
|
-
config: z.string().describe('Step config JSON object.'),
|
|
4525
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4526
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4527
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4528
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4529
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4530
|
-
...input,
|
|
4531
|
-
type: 'sleep',
|
|
4532
|
-
})));
|
|
4533
|
-
server.tool('ensure_log_flow_step', 'Business operation: create or update one log flow step. Use this for lightweight execution diagnostics instead of script code.', {
|
|
4534
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4535
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4536
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4537
|
-
config: z.string().describe('Step config JSON object: { "message": "..." }.'),
|
|
4538
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4539
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4540
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4541
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4542
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4543
|
-
...input,
|
|
4544
|
-
type: 'log',
|
|
4545
|
-
})));
|
|
4546
|
-
server.tool('ensure_trigger_flow_step', 'Business operation: create or update one child-flow trigger step. Use this for flow-to-flow orchestration instead of choosing type=trigger_flow manually.', {
|
|
4547
|
-
flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
|
|
4548
|
-
flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
|
|
4549
|
-
key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
|
|
4550
|
-
config: z.string().describe('Step config JSON object.'),
|
|
4551
|
-
order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
|
|
4552
|
-
timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
|
|
4553
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
|
|
4554
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4555
|
-
}, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
|
|
4556
|
-
...input,
|
|
4557
|
-
type: 'trigger_flow',
|
|
4558
|
-
})));
|
|
4559
|
-
server.tool('ensure_menu', 'Business operation: create or update one admin menu item. Use this instead of raw enfyra_menu CRUD.', {
|
|
4560
|
-
label: z.string().describe('Menu label.'),
|
|
4561
|
-
path: z.string().optional().describe('Admin app route path for leaf menu items, e.g. /reports.'),
|
|
4562
|
-
icon: z.string().optional().describe('Menu icon name.'),
|
|
4563
|
-
type: z.enum(['Menu', 'Dropdown Menu']).optional().default('Menu').describe('Menu type.'),
|
|
4564
|
-
order: z.number().optional().default(0).describe('Display order.'),
|
|
4565
|
-
permission: z.string().optional().describe('Menu permission JSON object. Omit to preserve existing permissions on update; new menus default to null. Empty objects are normalized to null.'),
|
|
4566
|
-
description: z.string().optional().describe('Admin note.'),
|
|
4567
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable menu.'),
|
|
4568
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4569
|
-
}, async (input) => jsonText({
|
|
4570
|
-
action: 'menu_ensured',
|
|
4571
|
-
menu: await ensureMenu(ENFYRA_API_URL, input),
|
|
4572
|
-
}));
|
|
4573
|
-
server.tool('reorder_menus', [
|
|
4574
|
-
'Business operation: reorder Enfyra admin menus and optionally move menus under a new parent.',
|
|
4575
|
-
'Uses the server /admin/menu/reorder route introduced in Enfyra 2.2.6 instead of PATCHing each enfyra_menu record.',
|
|
4576
|
-
'The server validates duplicate ids, non-negative integer order, dropdown-only parents, /data child restrictions, system menu parent locks, cycle prevention, persistence, and menu cache invalidation.',
|
|
4577
|
-
].join(' '), {
|
|
4578
|
-
updates: z.array(z.object({
|
|
4579
|
-
id: z.union([z.string(), z.number()]).describe('Menu id to reorder.'),
|
|
4580
|
-
order: z.number().int().nonnegative().describe('Sibling order index. Must be a non-negative integer.'),
|
|
4581
|
-
parent: z.union([z.string(), z.number(), z.null()]).optional().describe('New parent menu id, or null for a root menu. Parent must be a Dropdown Menu.'),
|
|
4582
|
-
})).min(1).describe('Menu order/parent updates, usually the changed siblings from drag-and-drop.'),
|
|
4583
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4584
|
-
}, async (input) => jsonText(await reorderMenus(ENFYRA_API_URL, input)));
|
|
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.', {
|
|
4586
|
-
name: z.string().describe('Extension unique name.'),
|
|
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.'),
|
|
4588
|
-
menuId: z.union([z.string(), z.number()]).describe('Existing menu id for this page extension.'),
|
|
4589
|
-
description: z.string().optional().describe('Extension description.'),
|
|
4590
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
4591
|
-
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
4592
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4593
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
4594
|
-
}, async (input) => jsonText({
|
|
4595
|
-
action: 'page_extension_ensured',
|
|
4596
|
-
extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
|
|
4597
|
-
}));
|
|
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.', {
|
|
4599
|
-
name: z.string().describe('Extension unique name.'),
|
|
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.'),
|
|
4601
|
-
description: z.string().optional().describe('Extension description.'),
|
|
4602
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
4603
|
-
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
4604
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4605
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
4606
|
-
}, async (input) => jsonText({
|
|
4607
|
-
action: 'global_extension_ensured',
|
|
4608
|
-
extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'global' }),
|
|
4609
|
-
}));
|
|
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.', {
|
|
4611
|
-
name: z.string().describe('Extension unique name.'),
|
|
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.'),
|
|
4613
|
-
description: z.string().optional().describe('Extension description.'),
|
|
4614
|
-
isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
|
|
4615
|
-
version: z.string().optional().default('1.0.0').describe('Extension version.'),
|
|
4616
|
-
globalRulesAckKey: globalRulesAckParam(z),
|
|
4617
|
-
extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
|
|
4618
|
-
}, async (input) => jsonText({
|
|
4619
|
-
action: 'widget_extension_ensured',
|
|
4620
|
-
extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'widget' }),
|
|
4621
|
-
}));
|
|
9
|
+
registerPlatformExtensionTools(server, ENFYRA_API_URL);
|
|
10
|
+
registerPlatformRouteTools(server, ENFYRA_API_URL);
|
|
11
|
+
registerPlatformPolicyTools(server, ENFYRA_API_URL);
|
|
12
|
+
registerPlatformWebsocketTools(server, ENFYRA_API_URL);
|
|
13
|
+
registerPlatformFlowTools(server, ENFYRA_API_URL);
|
|
14
|
+
registerPlatformResourceTools(server, ENFYRA_API_URL);
|
|
4622
15
|
}
|
|
4623
16
|
//# sourceMappingURL=platform-operation-tools.js.map
|