@fiodos/cli 0.1.23 → 0.1.25
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/package.json +4 -3
- package/src/changeRegistry.js +55 -1
- package/src/index.js +169 -3
- package/src/odata.js +529 -0
- package/src/wireHandlers.js +22 -0
- package/src/wireReactNative.js +52 -0
- package/src/wireSession.js +619 -0
- package/src/wireWeb.js +102 -0
- package/src/wireWebMount.js +87 -8
package/src/odata.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* OData/CSDL connector — generates a Fiodos manifest DRAFT from a closed
|
|
4
|
+
* third-party system's $metadata document (Dynamics 365 / Dataverse, SAP OData,
|
|
5
|
+
* any OData v4 service) instead of source code.
|
|
6
|
+
*
|
|
7
|
+
* This is the "closed software" analysis mode: the system's own schema is the
|
|
8
|
+
* source of truth (Microsoft documents $metadata as exactly that), so there is
|
|
9
|
+
* nothing to hallucinate — every generated action maps 1:1 to an Action/
|
|
10
|
+
* Function/EntitySet that exists in the document. NO source code and NO LLM
|
|
11
|
+
* are involved; the output is a mechanical draft the integrator curates in the
|
|
12
|
+
* dashboard (same "draft for human review" doctrine as the code analyzer).
|
|
13
|
+
*
|
|
14
|
+
* Execution model: each generated action carries a declarative `execution`
|
|
15
|
+
* block (ApiExecutionSpec of @fiodos/core). At runtime the embedded orb turns
|
|
16
|
+
* those into real calls via buildApiActionRegistries — the client writes no
|
|
17
|
+
* handler code. Credentials NEVER appear here: execution.authRef names a
|
|
18
|
+
* runtime-registered auth provider.
|
|
19
|
+
*
|
|
20
|
+
* Usage (wired from index.js):
|
|
21
|
+
* fiodos from-odata <metadata.xml | https://org/api/data/v9.2/$metadata>
|
|
22
|
+
* --app-id com.acme.dynamics --app-name "Acme Dynamics"
|
|
23
|
+
* [--entities accounts,contacts] CRUD skeletons for these entity sets
|
|
24
|
+
* [--auth-ref dataverse] auth provider name (default 'default')
|
|
25
|
+
* [--out <dir>] [--publish --api-key fyd_… [--api-url …]]
|
|
26
|
+
*
|
|
27
|
+
* Parsing note: CSDL is machine-generated, well-formed XML; this module uses a
|
|
28
|
+
* small tag scanner (no XML dependency) that extracts only the subset needed
|
|
29
|
+
* (EntityType/EntitySet/Action/Function). Anything it cannot understand is
|
|
30
|
+
* SKIPPED and recorded in provenance — never guessed.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
|
|
36
|
+
// ── tiny CSDL tag scanner ─────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** Attribute string → { name: value } (handles single/double quotes). */
|
|
39
|
+
function parseAttrs(tag) {
|
|
40
|
+
const attrs = {};
|
|
41
|
+
const re = /([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
|
|
42
|
+
let m;
|
|
43
|
+
while ((m = re.exec(tag))) attrs[m[1]] = m[3] !== undefined ? m[3] : m[4];
|
|
44
|
+
return attrs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract all elements named `tag` as { attrs, inner }. Handles both block
|
|
49
|
+
* (`<Tag …>…</Tag>`) and self-closing (`<Tag …/>`) forms.
|
|
50
|
+
*/
|
|
51
|
+
function extractElements(xml, tag) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const re = new RegExp(`<${tag}\\b([^>]*?)(/>|>([\\s\\S]*?)</${tag}>)`, 'g');
|
|
54
|
+
let m;
|
|
55
|
+
while ((m = re.exec(xml))) {
|
|
56
|
+
out.push({ attrs: parseAttrs(m[1] || ''), inner: m[3] || '' });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse the CSDL subset we need. Returns null when it doesn't look like CSDL. */
|
|
62
|
+
function parseCsdl(xml) {
|
|
63
|
+
const schemas = extractElements(xml, 'Schema');
|
|
64
|
+
if (!schemas.length) return null;
|
|
65
|
+
// Dataverse uses one namespace (Microsoft.Dynamics.CRM); SAP splits schemas.
|
|
66
|
+
// We take the first namespace that declares operations/types as primary.
|
|
67
|
+
const namespace =
|
|
68
|
+
(schemas.find((s) => /<(Action|Function|EntityType)\b/.test(s.inner)) || schemas[0])
|
|
69
|
+
.attrs.Namespace || '';
|
|
70
|
+
|
|
71
|
+
const entityTypes = {};
|
|
72
|
+
for (const et of extractElements(xml, 'EntityType')) {
|
|
73
|
+
const name = et.attrs.Name;
|
|
74
|
+
if (!name) continue;
|
|
75
|
+
const keyRefs = extractElements(et.inner, 'PropertyRef').map((k) => k.attrs.Name);
|
|
76
|
+
const properties = extractElements(et.inner, 'Property').map((p) => ({
|
|
77
|
+
name: p.attrs.Name,
|
|
78
|
+
type: p.attrs.Type || 'Edm.String',
|
|
79
|
+
nullable: p.attrs.Nullable !== 'false',
|
|
80
|
+
}));
|
|
81
|
+
entityTypes[name] = { name, keys: keyRefs.filter(Boolean), properties };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const entitySets = extractElements(xml, 'EntitySet')
|
|
85
|
+
.map((es) => ({ name: es.attrs.Name, entityType: es.attrs.EntityType || '' }))
|
|
86
|
+
.filter((es) => es.name);
|
|
87
|
+
|
|
88
|
+
const readOps = (tag) =>
|
|
89
|
+
extractElements(xml, tag).map((op) => ({
|
|
90
|
+
name: op.attrs.Name,
|
|
91
|
+
isBound: op.attrs.IsBound === 'true',
|
|
92
|
+
parameters: extractElements(op.inner, 'Parameter').map((p) => ({
|
|
93
|
+
name: p.attrs.Name,
|
|
94
|
+
type: p.attrs.Type || 'Edm.String',
|
|
95
|
+
nullable: p.attrs.Nullable !== 'false',
|
|
96
|
+
})),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
namespace,
|
|
101
|
+
entityTypes,
|
|
102
|
+
entitySets,
|
|
103
|
+
actions: readOps('Action').filter((a) => a.name),
|
|
104
|
+
functions: readOps('Function').filter((f) => f.name),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Edm → manifest mapping helpers ───────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const EDM_NUMBER = new Set([
|
|
111
|
+
'Edm.Int16', 'Edm.Int32', 'Edm.Int64', 'Edm.Decimal', 'Edm.Double', 'Edm.Single', 'Edm.Byte',
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
function edmToParamType(edm) {
|
|
115
|
+
if (edm === 'Edm.Boolean') return 'boolean';
|
|
116
|
+
if (EDM_NUMBER.has(edm)) return 'number';
|
|
117
|
+
return 'string'; // String, Guid, DateTimeOffset, enums… all travel as strings
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True for Edm primitives we can ask the LLM to fill; entity refs are not. */
|
|
121
|
+
function isPrimitiveEdm(edm) {
|
|
122
|
+
return typeof edm === 'string' && edm.startsWith('Edm.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function snake(name) {
|
|
126
|
+
return name
|
|
127
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
128
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
129
|
+
.toLowerCase()
|
|
130
|
+
.replace(/^_+|_+$/g, '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function humanize(name) {
|
|
134
|
+
return name
|
|
135
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
136
|
+
.replace(/[_-]+/g, ' ')
|
|
137
|
+
.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Heuristic: operations whose name suggests destruction get double confirmation. */
|
|
141
|
+
function isDestructiveName(name) {
|
|
142
|
+
return /delete|remove|cancel|close|deactivate|terminate|revoke/i.test(name);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** EntitySet whose EntityType matches `ns.TypeName` (bound-operation targets). */
|
|
146
|
+
function entitySetForType(model, qualifiedType) {
|
|
147
|
+
const bare = qualifiedType.replace(/^Collection\(|\)$/g, '');
|
|
148
|
+
return model.entitySets.find(
|
|
149
|
+
(es) => es.entityType === bare || es.entityType.endsWith(`.${bare.split('.').pop()}`),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── manifest generation ──────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function baseAction({ intent, label, handler, confirm, destructive, description }) {
|
|
156
|
+
const action = {
|
|
157
|
+
intent,
|
|
158
|
+
label,
|
|
159
|
+
category: 'api',
|
|
160
|
+
handler,
|
|
161
|
+
requireConfirmation: confirm,
|
|
162
|
+
parameters: {},
|
|
163
|
+
examples: [label.toLowerCase(), `please ${label.toLowerCase()}`],
|
|
164
|
+
...(description ? { description } : {}),
|
|
165
|
+
};
|
|
166
|
+
if (confirm) {
|
|
167
|
+
action.confirmationMessageTemplate = `Do you want to ${label.toLowerCase()}?`;
|
|
168
|
+
if (destructive) {
|
|
169
|
+
action.doubleConfirmation = true;
|
|
170
|
+
action.voiceConfirmation = 'strict';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return action;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** OData Action (write) → manifest action with execution template. */
|
|
177
|
+
function actionFromODataAction(op, model, authRef, skipped) {
|
|
178
|
+
const ns = model.namespace;
|
|
179
|
+
let url;
|
|
180
|
+
const parameters = {};
|
|
181
|
+
const body = {};
|
|
182
|
+
let opParams = op.parameters;
|
|
183
|
+
|
|
184
|
+
if (op.isBound) {
|
|
185
|
+
const binding = op.parameters[0];
|
|
186
|
+
if (!binding) {
|
|
187
|
+
skipped.push({ kind: 'Action', name: op.name, reason: 'bound action without binding parameter' });
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
opParams = op.parameters.slice(1);
|
|
191
|
+
const set = entitySetForType(model, binding.type);
|
|
192
|
+
if (!set) {
|
|
193
|
+
skipped.push({ kind: 'Action', name: op.name, reason: `no EntitySet found for binding type ${binding.type}` });
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (binding.type.startsWith('Collection(')) {
|
|
197
|
+
url = `{baseUrl}/${set.name}/${ns}.${op.name}`;
|
|
198
|
+
} else {
|
|
199
|
+
parameters.targetId = {
|
|
200
|
+
type: 'string',
|
|
201
|
+
required: true,
|
|
202
|
+
description: `ID (GUID) of the target record in ${set.name}`,
|
|
203
|
+
};
|
|
204
|
+
url = `{baseUrl}/${set.name}({targetId})/${ns}.${op.name}`;
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
url = `{baseUrl}/${op.name}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const p of opParams) {
|
|
211
|
+
if (!p.name) continue;
|
|
212
|
+
if (!isPrimitiveEdm(p.type)) {
|
|
213
|
+
// Entity-typed parameters need integrator curation (JSON with @odata.type);
|
|
214
|
+
// a mechanical draft must not invent that shape.
|
|
215
|
+
skipped.push({ kind: 'ActionParameter', name: `${op.name}.${p.name}`, reason: `non-primitive type ${p.type} — curate manually` });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
parameters[p.name] = {
|
|
219
|
+
type: edmToParamType(p.type),
|
|
220
|
+
required: !p.nullable,
|
|
221
|
+
description: `${humanize(p.name)} (${p.type})`,
|
|
222
|
+
};
|
|
223
|
+
body[p.name] = `{${p.name}}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const label = humanize(op.name);
|
|
227
|
+
const action = baseAction({
|
|
228
|
+
intent: snake(op.name),
|
|
229
|
+
label,
|
|
230
|
+
handler: `api:${op.name}`,
|
|
231
|
+
confirm: true, // OData Actions change data by definition — always confirm
|
|
232
|
+
destructive: isDestructiveName(op.name),
|
|
233
|
+
description: `OData action ${model.namespace}.${op.name}`,
|
|
234
|
+
});
|
|
235
|
+
action.parameters = parameters;
|
|
236
|
+
action.execution = {
|
|
237
|
+
kind: 'http',
|
|
238
|
+
method: 'POST',
|
|
239
|
+
url,
|
|
240
|
+
...(Object.keys(body).length ? { body } : {}),
|
|
241
|
+
...(authRef ? { authRef } : {}),
|
|
242
|
+
};
|
|
243
|
+
return action;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** OData Function (read) → manifest action (GET, no confirmation). */
|
|
247
|
+
function actionFromODataFunction(op, model, authRef, skipped) {
|
|
248
|
+
if (op.isBound) {
|
|
249
|
+
skipped.push({ kind: 'Function', name: op.name, reason: 'bound functions not auto-generated — curate manually' });
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const parameters = {};
|
|
253
|
+
const segments = [];
|
|
254
|
+
for (const p of op.parameters) {
|
|
255
|
+
if (!p.name) continue;
|
|
256
|
+
if (!isPrimitiveEdm(p.type)) {
|
|
257
|
+
skipped.push({ kind: 'Function', name: op.name, reason: `non-primitive parameter ${p.name} (${p.type})` });
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
parameters[p.name] = {
|
|
261
|
+
type: edmToParamType(p.type),
|
|
262
|
+
required: !p.nullable,
|
|
263
|
+
description: `${humanize(p.name)} (${p.type})`,
|
|
264
|
+
};
|
|
265
|
+
// OData literal syntax: strings quoted, other primitives bare.
|
|
266
|
+
segments.push(p.type === 'Edm.String' ? `${p.name}='{${p.name}}'` : `${p.name}={${p.name}}`);
|
|
267
|
+
}
|
|
268
|
+
const label = humanize(op.name);
|
|
269
|
+
const action = baseAction({
|
|
270
|
+
intent: snake(op.name),
|
|
271
|
+
label,
|
|
272
|
+
handler: `api:${op.name}`,
|
|
273
|
+
confirm: false,
|
|
274
|
+
destructive: false,
|
|
275
|
+
description: `OData function ${model.namespace}.${op.name} (read-only)`,
|
|
276
|
+
});
|
|
277
|
+
action.parameters = parameters;
|
|
278
|
+
action.execution = {
|
|
279
|
+
kind: 'http',
|
|
280
|
+
method: 'GET',
|
|
281
|
+
url: `{baseUrl}/${op.name}(${segments.join(',')})`,
|
|
282
|
+
...(authRef ? { authRef } : {}),
|
|
283
|
+
};
|
|
284
|
+
return action;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const MAX_CRUD_PROPS = 8;
|
|
288
|
+
|
|
289
|
+
/** CRUD skeletons for one requested entity set (create/update/delete/get). */
|
|
290
|
+
function crudActionsForSet(set, model, authRef, skipped) {
|
|
291
|
+
const typeName = set.entityType.split('.').pop();
|
|
292
|
+
const type = model.entityTypes[typeName];
|
|
293
|
+
if (!type) {
|
|
294
|
+
skipped.push({ kind: 'EntitySet', name: set.name, reason: `EntityType ${set.entityType} not found in metadata` });
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
const keySet = new Set(type.keys);
|
|
298
|
+
const writable = type.properties
|
|
299
|
+
.filter((p) => p.name && isPrimitiveEdm(p.type) && !keySet.has(p.name) && !p.name.startsWith('_'))
|
|
300
|
+
.slice(0, MAX_CRUD_PROPS);
|
|
301
|
+
const propParams = (required) =>
|
|
302
|
+
Object.fromEntries(
|
|
303
|
+
writable.map((p) => [
|
|
304
|
+
p.name,
|
|
305
|
+
{
|
|
306
|
+
type: edmToParamType(p.type),
|
|
307
|
+
required: required && !p.nullable,
|
|
308
|
+
description: `${humanize(p.name)} (${p.type})`,
|
|
309
|
+
},
|
|
310
|
+
]),
|
|
311
|
+
);
|
|
312
|
+
const propBody = Object.fromEntries(writable.map((p) => [p.name, `{${p.name}}`]));
|
|
313
|
+
const idParam = {
|
|
314
|
+
id: { type: 'string', required: true, description: `ID (GUID) of the ${typeName} record` },
|
|
315
|
+
};
|
|
316
|
+
const label = humanize(set.name);
|
|
317
|
+
|
|
318
|
+
const create = baseAction({
|
|
319
|
+
intent: `create_${snake(typeName)}`,
|
|
320
|
+
label: `Create a ${humanize(typeName).toLowerCase()}`,
|
|
321
|
+
handler: `api:create_${set.name}`,
|
|
322
|
+
confirm: true,
|
|
323
|
+
destructive: false,
|
|
324
|
+
description: `Create a record in ${label}`,
|
|
325
|
+
});
|
|
326
|
+
create.parameters = propParams(true);
|
|
327
|
+
create.execution = {
|
|
328
|
+
kind: 'http', method: 'POST', url: `{baseUrl}/${set.name}`,
|
|
329
|
+
body: propBody, ...(authRef ? { authRef } : {}),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const update = baseAction({
|
|
333
|
+
intent: `update_${snake(typeName)}`,
|
|
334
|
+
label: `Update a ${humanize(typeName).toLowerCase()}`,
|
|
335
|
+
handler: `api:update_${set.name}`,
|
|
336
|
+
confirm: true,
|
|
337
|
+
destructive: false,
|
|
338
|
+
description: `Update a record in ${label}`,
|
|
339
|
+
});
|
|
340
|
+
update.parameters = { ...idParam, ...propParams(false) };
|
|
341
|
+
update.execution = {
|
|
342
|
+
kind: 'http', method: 'PATCH', url: `{baseUrl}/${set.name}({id})`,
|
|
343
|
+
body: propBody, ...(authRef ? { authRef } : {}),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const del = baseAction({
|
|
347
|
+
intent: `delete_${snake(typeName)}`,
|
|
348
|
+
label: `Delete a ${humanize(typeName).toLowerCase()}`,
|
|
349
|
+
handler: `api:delete_${set.name}`,
|
|
350
|
+
confirm: true,
|
|
351
|
+
destructive: true,
|
|
352
|
+
description: `Delete a record from ${label} (irreversible)`,
|
|
353
|
+
});
|
|
354
|
+
del.parameters = idParam;
|
|
355
|
+
del.execution = {
|
|
356
|
+
kind: 'http', method: 'DELETE', url: `{baseUrl}/${set.name}({id})`,
|
|
357
|
+
...(authRef ? { authRef } : {}),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const get = baseAction({
|
|
361
|
+
intent: `get_${snake(typeName)}`,
|
|
362
|
+
label: `Look up a ${humanize(typeName).toLowerCase()}`,
|
|
363
|
+
handler: `api:get_${set.name}`,
|
|
364
|
+
confirm: false,
|
|
365
|
+
destructive: false,
|
|
366
|
+
description: `Read one record from ${label}`,
|
|
367
|
+
});
|
|
368
|
+
get.parameters = idParam;
|
|
369
|
+
get.execution = {
|
|
370
|
+
kind: 'http', method: 'GET', url: `{baseUrl}/${set.name}({id})`,
|
|
371
|
+
...(authRef ? { authRef } : {}),
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Update body params that stay optional must not break on missing values, so
|
|
375
|
+
// the update template only ships properties the integrator confirms. The
|
|
376
|
+
// mechanical draft keeps them but flags it for curation.
|
|
377
|
+
skipped.push({
|
|
378
|
+
kind: 'Curation',
|
|
379
|
+
name: set.name,
|
|
380
|
+
reason: `update_${snake(typeName)} sends all ${writable.length} template properties — trim unused ones in the dashboard before publishing`,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return [create, update, del, get];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Build the AppManifest draft from a CSDL document.
|
|
388
|
+
* Returns { manifest, provenance }.
|
|
389
|
+
*/
|
|
390
|
+
function buildManifestFromCsdl(xml, opts) {
|
|
391
|
+
const model = parseCsdl(xml);
|
|
392
|
+
if (!model) {
|
|
393
|
+
throw new Error('The document does not look like an OData CSDL $metadata (no <Schema> found)');
|
|
394
|
+
}
|
|
395
|
+
const skipped = [];
|
|
396
|
+
const authRef = opts.authRef || 'default';
|
|
397
|
+
const actions = [];
|
|
398
|
+
|
|
399
|
+
for (const op of model.actions) {
|
|
400
|
+
const a = actionFromODataAction(op, model, authRef, skipped);
|
|
401
|
+
if (a) actions.push(a);
|
|
402
|
+
}
|
|
403
|
+
for (const op of model.functions) {
|
|
404
|
+
const a = actionFromODataFunction(op, model, authRef, skipped);
|
|
405
|
+
if (a) actions.push(a);
|
|
406
|
+
}
|
|
407
|
+
const requestedSets = (opts.entities || [])
|
|
408
|
+
.map((name) => model.entitySets.find((es) => es.name.toLowerCase() === name.toLowerCase()))
|
|
409
|
+
.filter(Boolean);
|
|
410
|
+
for (const set of requestedSets) {
|
|
411
|
+
actions.push(...crudActionsForSet(set, model, authRef, skipped));
|
|
412
|
+
}
|
|
413
|
+
for (const name of opts.entities || []) {
|
|
414
|
+
if (!model.entitySets.some((es) => es.name.toLowerCase() === name.toLowerCase())) {
|
|
415
|
+
skipped.push({ kind: 'EntitySet', name, reason: 'requested with --entities but not present in metadata' });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// De-duplicate intents (overloaded bound actions produce name collisions).
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
const unique = [];
|
|
422
|
+
for (const a of actions) {
|
|
423
|
+
if (seen.has(a.intent)) {
|
|
424
|
+
skipped.push({ kind: 'Duplicate', name: a.intent, reason: 'duplicate intent (overloaded operation) — kept first definition' });
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
seen.add(a.intent);
|
|
428
|
+
unique.push(a);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const manifest = {
|
|
432
|
+
appId: opts.appId,
|
|
433
|
+
appName: opts.appName,
|
|
434
|
+
appDescription:
|
|
435
|
+
opts.appDescription ||
|
|
436
|
+
`${opts.appName} — actions generated from the system's OData schema (${model.namespace}).`,
|
|
437
|
+
appType: 'third-party system (OData)',
|
|
438
|
+
appFlow:
|
|
439
|
+
'The agent operates over a closed third-party system through its OData API: ' +
|
|
440
|
+
'read operations answer questions; write operations always ask for confirmation first.',
|
|
441
|
+
version: '1.0.0-odata-draft',
|
|
442
|
+
routes: [],
|
|
443
|
+
actions: unique,
|
|
444
|
+
policies: {
|
|
445
|
+
requireConfirmationForDestructive: true,
|
|
446
|
+
allowedWithoutAuth: [],
|
|
447
|
+
contextRetentionTurns: 3,
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const provenance = {
|
|
452
|
+
source: 'odata-metadata',
|
|
453
|
+
namespace: model.namespace,
|
|
454
|
+
counts: {
|
|
455
|
+
csdlActions: model.actions.length,
|
|
456
|
+
csdlFunctions: model.functions.length,
|
|
457
|
+
csdlEntitySets: model.entitySets.length,
|
|
458
|
+
generatedActions: unique.length,
|
|
459
|
+
},
|
|
460
|
+
authRef,
|
|
461
|
+
skipped,
|
|
462
|
+
note:
|
|
463
|
+
'Mechanical draft generated from the OData $metadata — no source code, no LLM. ' +
|
|
464
|
+
'Curate in the dashboard: disable what the orb should not expose, refine labels/examples, ' +
|
|
465
|
+
'and review every confirmation level before publishing.',
|
|
466
|
+
};
|
|
467
|
+
return { manifest, provenance };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Load the metadata document from a local path or an http(s) URL. */
|
|
471
|
+
async function loadMetadata(target, extraHeaders) {
|
|
472
|
+
if (/^https?:\/\//i.test(target)) {
|
|
473
|
+
const res = await fetch(target, { headers: { Accept: 'application/xml', ...extraHeaders } });
|
|
474
|
+
if (!res.ok) throw new Error(`GET ${target} failed: HTTP ${res.status}`);
|
|
475
|
+
return await res.text();
|
|
476
|
+
}
|
|
477
|
+
return fs.readFileSync(path.resolve(target), 'utf8');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* CLI entrypoint for `fiodos from-odata …` (wired from index.js).
|
|
482
|
+
* `io` injects the CLI's own logging + publish so this module stays testable.
|
|
483
|
+
*/
|
|
484
|
+
async function runFromOData({ target, outDir, opts, io }) {
|
|
485
|
+
const { logUser, logDev, publish } = io;
|
|
486
|
+
logUser('Reading OData $metadata (schema only — no source code involved)…');
|
|
487
|
+
const xml = await loadMetadata(target, opts.headers || {});
|
|
488
|
+
const { manifest, provenance } = buildManifestFromCsdl(xml, opts);
|
|
489
|
+
|
|
490
|
+
// Same final gate as the code analyzer: the real @fiodos/core validator.
|
|
491
|
+
let validation = { valid: true, errors: [] };
|
|
492
|
+
try {
|
|
493
|
+
const { validateManifest } = require('@fiodos/core');
|
|
494
|
+
validation = validateManifest(manifest);
|
|
495
|
+
} catch (e) {
|
|
496
|
+
logDev(`[from-odata] core validator unavailable: ${e.message}`);
|
|
497
|
+
}
|
|
498
|
+
provenance.coreValidation = validation;
|
|
499
|
+
|
|
500
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
501
|
+
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
502
|
+
fs.writeFileSync(path.join(outDir, 'provenance.json'), JSON.stringify(provenance, null, 2));
|
|
503
|
+
|
|
504
|
+
logUser(`Draft ready: ${manifest.actions.length} action(s) from ${provenance.namespace}`);
|
|
505
|
+
if (provenance.skipped.length) {
|
|
506
|
+
logUser(`${provenance.skipped.length} item(s) need manual curation — see provenance.json`);
|
|
507
|
+
}
|
|
508
|
+
logDev(`[from-odata] output → ${outDir}`);
|
|
509
|
+
|
|
510
|
+
if (!validation.valid) {
|
|
511
|
+
logUser('Draft has validation errors — fix before publishing:');
|
|
512
|
+
for (const err of validation.errors) logUser(` · ${err}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (opts.publish) {
|
|
516
|
+
if (!validation.valid) {
|
|
517
|
+
throw new Error('Refusing to publish an invalid manifest (see errors above)');
|
|
518
|
+
}
|
|
519
|
+
await publish(manifest, {
|
|
520
|
+
source: 'odata-metadata',
|
|
521
|
+
generatedAt: new Date().toISOString(),
|
|
522
|
+
routesIncluded: 0,
|
|
523
|
+
actionsIncluded: manifest.actions.length,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return { manifest, provenance };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
module.exports = { parseCsdl, buildManifestFromCsdl, runFromOData };
|
package/src/wireHandlers.js
CHANGED
|
@@ -1854,4 +1854,26 @@ module.exports = {
|
|
|
1854
1854
|
findExportedFunction,
|
|
1855
1855
|
findStoreMethod,
|
|
1856
1856
|
mapParams,
|
|
1857
|
+
// shared with wireSession (same verification + injection machinery)
|
|
1858
|
+
fileHasSymbol,
|
|
1859
|
+
freeIdentifiers,
|
|
1860
|
+
declaredLocals,
|
|
1861
|
+
detachedInstanceReceiver,
|
|
1862
|
+
countOccurrences,
|
|
1863
|
+
readFileSafe,
|
|
1864
|
+
normRel,
|
|
1865
|
+
relImport,
|
|
1866
|
+
detectTsEsm,
|
|
1867
|
+
resolveFiodosDirRel,
|
|
1868
|
+
askYesNo,
|
|
1869
|
+
JS_KEYWORDS,
|
|
1870
|
+
SAFE_GLOBALS,
|
|
1871
|
+
GENERATED_FILE_DIRECTIVES,
|
|
1872
|
+
INJECT_ESLINT_DISABLE,
|
|
1873
|
+
INJECT_ESLINT_ENABLE,
|
|
1874
|
+
EDIT_START,
|
|
1875
|
+
EDIT_END,
|
|
1876
|
+
BRIDGE_BASENAME,
|
|
1877
|
+
insertImportBlock,
|
|
1878
|
+
insertRegisterBlock,
|
|
1857
1879
|
};
|
package/src/wireReactNative.js
CHANGED
|
@@ -165,6 +165,7 @@ function unwireOrb(source) {
|
|
|
165
165
|
if (/FYODOS:ORB/.test(line)) return true; // `// …` or `{/* … */}` markers
|
|
166
166
|
if (/^import\s+\{?\s*FiodosExpoAgent\b/.test(t)) return true;
|
|
167
167
|
if (/^import\s+fyodosManifest\b/.test(t)) return true;
|
|
168
|
+
if (/^import\s+\{\s*fyodosIsAuthenticated\b/.test(t)) return true; // session pass import
|
|
168
169
|
if (/^<FiodosExpoAgent\b[^]*>$/.test(t)) return true; // our single-line open tag
|
|
169
170
|
if (/^<\/FiodosExpoAgent>$/.test(t)) return true;
|
|
170
171
|
return false;
|
|
@@ -258,6 +259,56 @@ function wireReactNativeOrb(appRoot, { manifest, colors = {}, noWire = false } =
|
|
|
258
259
|
return { status: 'added', file: layout.rel };
|
|
259
260
|
}
|
|
260
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Connect the generated SESSION module to the wired <FiodosExpoAgent> tag:
|
|
264
|
+
* adds `isAuthenticated={fyodosIsAuthenticated}` and `getUserId={fyodosGetUserId}`
|
|
265
|
+
* plus the import. Mirrors the web mount session pass. Idempotent; never
|
|
266
|
+
* overrides a developer-wired isAuthenticated; validated with the same parse
|
|
267
|
+
* safety net as the orb wrap.
|
|
268
|
+
*/
|
|
269
|
+
function connectRnSession(appRoot, { sessionFileAbs, colors = {} } = {}) {
|
|
270
|
+
if (!sessionFileAbs || !fs.existsSync(sessionFileAbs)) return { status: 'no-session-file' };
|
|
271
|
+
const layout = findRootLayout(appRoot);
|
|
272
|
+
if (!layout) return { status: 'not-mounted' };
|
|
273
|
+
|
|
274
|
+
let source;
|
|
275
|
+
try {
|
|
276
|
+
source = fs.readFileSync(layout.abs, 'utf8');
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return { status: 'skipped', reason: `read-failed: ${e.message}` };
|
|
279
|
+
}
|
|
280
|
+
const tagRe = /<FiodosExpoAgent\b[^>]*>/;
|
|
281
|
+
const m = source.match(tagRe);
|
|
282
|
+
if (!m) return { status: 'not-mounted' };
|
|
283
|
+
const tag = m[0];
|
|
284
|
+
if (/isAuthenticated=\{fyodosIsAuthenticated\}/.test(tag)) return { status: 'already', file: layout.rel };
|
|
285
|
+
if (/\bisAuthenticated=/.test(tag)) return { status: 'dev-wired', file: layout.rel };
|
|
286
|
+
|
|
287
|
+
let specifier = path.relative(path.dirname(layout.abs), sessionFileAbs).split(path.sep).join('/');
|
|
288
|
+
specifier = specifier.replace(/\.(tsx?|jsx?)$/, '');
|
|
289
|
+
if (!specifier.startsWith('.')) specifier = `./${specifier}`;
|
|
290
|
+
|
|
291
|
+
const newTag = tag.replace(
|
|
292
|
+
/>$/,
|
|
293
|
+
' isAuthenticated={fyodosIsAuthenticated} getUserId={fyodosGetUserId}>',
|
|
294
|
+
);
|
|
295
|
+
let next = source.replace(tag, newTag);
|
|
296
|
+
const importLine = `import { fyodosIsAuthenticated, fyodosGetUserId } from '${specifier}';`;
|
|
297
|
+
if (!next.includes(importLine)) {
|
|
298
|
+
next = insertImportsAfterLastImport(next, importLine);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!parsesAsTsx(next, layout.rel)) {
|
|
302
|
+
return { status: 'skipped', file: layout.rel, reason: 'session props would not parse — left untouched' };
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
fs.writeFileSync(layout.abs, next);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
return { status: 'skipped', reason: `write-failed: ${e.message}` };
|
|
308
|
+
}
|
|
309
|
+
return { status: 'connected', file: layout.rel };
|
|
310
|
+
}
|
|
311
|
+
|
|
261
312
|
function reportReactNativeWire(result, colors = {}, { quiet = false } = {}) {
|
|
262
313
|
const { blue, cyan, dim, reset } = colors;
|
|
263
314
|
if (!result) return;
|
|
@@ -271,6 +322,7 @@ function reportReactNativeWire(result, colors = {}, { quiet = false } = {}) {
|
|
|
271
322
|
module.exports = {
|
|
272
323
|
wireReactNativeOrb,
|
|
273
324
|
reportReactNativeWire,
|
|
325
|
+
connectRnSession,
|
|
274
326
|
// exported for tests
|
|
275
327
|
buildWiredLayout,
|
|
276
328
|
unwireOrb,
|