@fiodos/cli 0.1.23 → 0.1.26
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/aiAnalyze.js +6 -4
- package/src/changeRegistry.js +109 -1
- package/src/collect.js +16 -2
- package/src/index.js +535 -7
- package/src/odata.js +529 -0
- package/src/shopifyTheme.js +300 -0
- package/src/verify.js +124 -1
- package/src/wireHandlers.js +115 -7
- package/src/wireReactNative.js +52 -0
- package/src/wireScreen.js +562 -0
- package/src/wireSession.js +755 -0
- package/src/wireWeb.js +215 -0
- package/src/wireWebMount.js +171 -8
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify theme support — the closed-platform branch of the analyze pipeline.
|
|
3
|
+
*
|
|
4
|
+
* A Shopify store's "codebase" is its Liquid theme (GitHub-synced via
|
|
5
|
+
* Shopify's native integration), and its live catalog is PUBLIC JSON
|
|
6
|
+
* (/collections.json, /products.json). This module:
|
|
7
|
+
*
|
|
8
|
+
* 1. detects a theme repo (layout/theme.liquid + templates/),
|
|
9
|
+
* 2. fetches a public storefront snapshot and exposes it as synthetic FILE
|
|
10
|
+
* blocks (_fyodos/storefront/*.json) so the analyzer can personalize
|
|
11
|
+
* routes/bubbles/examples to the REAL catalog — and so evidence
|
|
12
|
+
* verification has concrete files to point at,
|
|
13
|
+
* 3. injects the embed snippet into layout/theme.liquid (idempotent
|
|
14
|
+
* FYODOS:ORB markers, same pattern as every other wire step), including
|
|
15
|
+
* a Liquid-rendered screen context so the agent knows the visible
|
|
16
|
+
* product's variant id (what add_to_cart needs).
|
|
17
|
+
*
|
|
18
|
+
* No wiring/session/screen blocks apply here: commerce actions carry
|
|
19
|
+
* declarative `execution` templates resolved by buildApiActionRegistries in
|
|
20
|
+
* the embed at runtime ({baseUrl} → the store's own origin).
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
/** CDN URL of the self-contained embed bundle (global `Fiodos`). Keep in sync
|
|
28
|
+
* with dashboard/src/lib/shopifyInstall.ts. */
|
|
29
|
+
const FIODOS_EMBED_CDN_URL =
|
|
30
|
+
'https://unpkg.com/@fiodos/web-core@latest/dist/embed/fiodos-embed.js';
|
|
31
|
+
|
|
32
|
+
const ORB_START = '<!-- FYODOS:ORB:START (Fiodos agent — safe to remove) -->';
|
|
33
|
+
const ORB_END = '<!-- FYODOS:ORB:END -->';
|
|
34
|
+
|
|
35
|
+
/** Repo-relative folder of the synthetic storefront snapshot files. */
|
|
36
|
+
const SNAPSHOT_DIR = '_fyodos/storefront';
|
|
37
|
+
|
|
38
|
+
/** A Shopify theme repo is unmistakable: layout/theme.liquid is the layout
|
|
39
|
+
* every Online Store 2.0 theme must have, plus templates/ or sections/. */
|
|
40
|
+
function isShopifyThemeRoot(appRoot) {
|
|
41
|
+
const has = (rel) => {
|
|
42
|
+
try {
|
|
43
|
+
return fs.existsSync(path.join(appRoot, rel));
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return (
|
|
49
|
+
has('layout/theme.liquid') &&
|
|
50
|
+
(has('templates') || has('sections') || has('config/settings_schema.json'))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Store name from the theme's settings_schema.json (theme_info), best-effort. */
|
|
55
|
+
function readThemeMeta(appRoot) {
|
|
56
|
+
const meta = {};
|
|
57
|
+
try {
|
|
58
|
+
const raw = fs.readFileSync(path.join(appRoot, 'config', 'settings_schema.json'), 'utf8');
|
|
59
|
+
const schema = JSON.parse(raw);
|
|
60
|
+
const info = Array.isArray(schema)
|
|
61
|
+
? schema.find((s) => s && (s.name === 'theme_info' || s.theme_name))
|
|
62
|
+
: null;
|
|
63
|
+
if (info && info.theme_name) meta.themeName = String(info.theme_name);
|
|
64
|
+
} catch {
|
|
65
|
+
/* not required */
|
|
66
|
+
}
|
|
67
|
+
return meta;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeStoreDomain(raw) {
|
|
71
|
+
const s = String(raw || '').trim();
|
|
72
|
+
if (!s) return '';
|
|
73
|
+
return s
|
|
74
|
+
.replace(/^https?:\/\//i, '')
|
|
75
|
+
.replace(/\/.*$/, '')
|
|
76
|
+
.toLowerCase();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fetchJson(url, { timeoutMs = 8000 } = {}) {
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(url, {
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
headers: { accept: 'application/json' },
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
88
|
+
return await res.json();
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stripHtml(html) {
|
|
95
|
+
return String(html || '')
|
|
96
|
+
.replace(/<[^>]*>/g, ' ')
|
|
97
|
+
.replace(/\s+/g, ' ')
|
|
98
|
+
.trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The universal storefront surface, written into the snapshot so evidence
|
|
102
|
+
* ("_fyodos/storefront/storefront.json", symbol "shopify_storefront") is
|
|
103
|
+
* backed by a real file among the ones sent to the model. */
|
|
104
|
+
function universalSurfaceDoc(storeDomain) {
|
|
105
|
+
return {
|
|
106
|
+
shopify_storefront: true,
|
|
107
|
+
store: storeDomain || '(unknown — no --store domain provided)',
|
|
108
|
+
universalRoutes: ['/', '/collections/all', '/search', '/cart'],
|
|
109
|
+
universalActions: [
|
|
110
|
+
{ intent: 'search_products', handler: 'shopifySearchProducts', api: '{baseUrl}/search/suggest.json' },
|
|
111
|
+
{ intent: 'add_to_cart', handler: 'shopifyAddToCart', api: '{baseUrl}/cart/add.js' },
|
|
112
|
+
{ intent: 'get_cart', handler: 'shopifyGetCart', api: '{baseUrl}/cart.js' },
|
|
113
|
+
{ intent: 'update_cart_item', handler: 'shopifyUpdateCartItem', api: '{baseUrl}/cart/change.js' },
|
|
114
|
+
{ intent: 'go_checkout', kind: 'link', navTarget: '/checkout' },
|
|
115
|
+
],
|
|
116
|
+
note:
|
|
117
|
+
'Universal Shopify storefront surface — present on every Shopify store ' +
|
|
118
|
+
'regardless of theme. Ajax Cart API + Predictive Search, anonymous access.',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fetch the public storefront snapshot for a store and return it as synthetic
|
|
124
|
+
* collection entries ({ rel, content, tier, chars }) ready to append to the
|
|
125
|
+
* collector's `included` list.
|
|
126
|
+
*
|
|
127
|
+
* Fail-open: any fetch error still yields storefront.json (the universal
|
|
128
|
+
* surface), so the analysis and its evidence contract always work — the
|
|
129
|
+
* manifest just loses catalog personalization when the store is unreachable.
|
|
130
|
+
*/
|
|
131
|
+
async function fetchStorefrontSnapshot(storeRaw, { log = () => {} } = {}) {
|
|
132
|
+
const store = normalizeStoreDomain(storeRaw);
|
|
133
|
+
const files = [];
|
|
134
|
+
const pushFile = (name, data) => {
|
|
135
|
+
const content = JSON.stringify(data, null, 2);
|
|
136
|
+
files.push({ rel: `${SNAPSHOT_DIR}/${name}`, content, tier: 0, chars: content.length });
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const warnings = [];
|
|
140
|
+
let collections = [];
|
|
141
|
+
let products = [];
|
|
142
|
+
if (store) {
|
|
143
|
+
const base = `https://${store}`;
|
|
144
|
+
try {
|
|
145
|
+
const data = await fetchJson(`${base}/collections.json?limit=250`);
|
|
146
|
+
collections = (data.collections || []).map((c) => ({
|
|
147
|
+
handle: c.handle,
|
|
148
|
+
title: c.title,
|
|
149
|
+
description: stripHtml(c.body_html).slice(0, 200),
|
|
150
|
+
productsCount: c.products_count,
|
|
151
|
+
updatedAt: c.updated_at,
|
|
152
|
+
}));
|
|
153
|
+
} catch (e) {
|
|
154
|
+
warnings.push(`collections.json: ${e.message}`);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const data = await fetchJson(`${base}/products.json?limit=50`);
|
|
158
|
+
products = (data.products || []).map((p) => ({
|
|
159
|
+
handle: p.handle,
|
|
160
|
+
title: p.title,
|
|
161
|
+
productType: p.product_type,
|
|
162
|
+
vendor: p.vendor,
|
|
163
|
+
tags: p.tags,
|
|
164
|
+
description: stripHtml(p.body_html).slice(0, 200),
|
|
165
|
+
priceRange: (() => {
|
|
166
|
+
const prices = (p.variants || []).map((v) => Number(v.price)).filter((n) => !Number.isNaN(n));
|
|
167
|
+
if (!prices.length) return undefined;
|
|
168
|
+
return { min: Math.min(...prices), max: Math.max(...prices) };
|
|
169
|
+
})(),
|
|
170
|
+
variantSample: (p.variants || []).slice(0, 3).map((v) => ({ id: v.id, title: v.title, price: v.price })),
|
|
171
|
+
}));
|
|
172
|
+
} catch (e) {
|
|
173
|
+
warnings.push(`products.json: ${e.message}`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
warnings.push('no store domain provided (--store) — catalog personalization limited to theme files');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
pushFile('storefront.json', {
|
|
180
|
+
...universalSurfaceDoc(store),
|
|
181
|
+
fetchedAt: new Date().toISOString(),
|
|
182
|
+
collectionsCount: collections.length,
|
|
183
|
+
productSampleCount: products.length,
|
|
184
|
+
...(warnings.length ? { warnings } : {}),
|
|
185
|
+
});
|
|
186
|
+
if (collections.length) pushFile('collections.json', { collections });
|
|
187
|
+
if (products.length) pushFile('products.json', { products });
|
|
188
|
+
|
|
189
|
+
for (const w of warnings) log(`[shopify] snapshot warning: ${w}`);
|
|
190
|
+
log(
|
|
191
|
+
`[shopify] storefront snapshot: ${collections.length} collections, ` +
|
|
192
|
+
`${products.length} product sample${store ? ` (${store})` : ''}`,
|
|
193
|
+
);
|
|
194
|
+
return { files, store, collections, products, warnings };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The theme.liquid block: loads the embed, fetches the published manifest and
|
|
199
|
+
* mounts the orb with API-wired registries. Mirrors the dashboard's
|
|
200
|
+
* buildShopifySnippet, plus a Liquid-rendered PRODUCT screen context so the
|
|
201
|
+
* agent knows the visible variant id (what add_to_cart needs by voice).
|
|
202
|
+
*/
|
|
203
|
+
function buildShopifySnippet({ apiKey, apiUrl }) {
|
|
204
|
+
const key = apiKey || '<your-api-key>';
|
|
205
|
+
const url = String(apiUrl || '').replace(/\/$/, '');
|
|
206
|
+
return [
|
|
207
|
+
ORB_START,
|
|
208
|
+
`<script src="${FIODOS_EMBED_CDN_URL}" defer></script>`,
|
|
209
|
+
'<script>',
|
|
210
|
+
" {% if template contains 'product' and product %}",
|
|
211
|
+
' {% capture fyodos_screen_text %}The customer is viewing the product page of: {{ product.title }} (price {{ product.price | money | strip_html }}). Add-to-cart variantId: {{ product.selected_or_first_available_variant.id }}.{% endcapture %}',
|
|
212
|
+
" window.__fyodosScreen = { kind: 'product', text: {{ fyodos_screen_text | json }} };",
|
|
213
|
+
' {% endif %}',
|
|
214
|
+
" window.addEventListener('DOMContentLoaded', function () {",
|
|
215
|
+
` var FYODOS_API_KEY = '${key}';`,
|
|
216
|
+
` var FYODOS_API_URL = '${url}';`,
|
|
217
|
+
" fetch(FYODOS_API_URL + '/v1/client/manifest', { headers: { 'x-api-key': FYODOS_API_KEY } })",
|
|
218
|
+
' .then(function (r) { return r.json(); })',
|
|
219
|
+
' .then(function (data) {',
|
|
220
|
+
' if (!data || !data.manifest || !window.Fiodos) return;',
|
|
221
|
+
' var agent = Fiodos.createFiodosAgent({',
|
|
222
|
+
' baseUrl: FYODOS_API_URL,',
|
|
223
|
+
' apiKey: FYODOS_API_KEY,',
|
|
224
|
+
' manifest: data.manifest,',
|
|
225
|
+
' registries: Fiodos.buildApiActionRegistries(data.manifest, {',
|
|
226
|
+
' baseUrl: window.location.origin,',
|
|
227
|
+
' }),',
|
|
228
|
+
" localeDetection: 'document',",
|
|
229
|
+
' });',
|
|
230
|
+
' if (window.__fyodosScreen) agent.setScreenContext(window.__fyodosScreen);',
|
|
231
|
+
' });',
|
|
232
|
+
' });',
|
|
233
|
+
'</script>',
|
|
234
|
+
ORB_END,
|
|
235
|
+
].join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function escapeRe(s) {
|
|
239
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Inject (or refresh) the snippet in layout/theme.liquid, right before
|
|
244
|
+
* </body>. Idempotent via the FYODOS:ORB markers: an existing block is
|
|
245
|
+
* replaced in place, so re-running the CLI updates the snippet instead of
|
|
246
|
+
* duplicating it. Returns { status: 'added'|'already'|'skipped'|'failed',
|
|
247
|
+
* file, reason? }.
|
|
248
|
+
*/
|
|
249
|
+
function wireShopifyTheme(appRoot, { apiKey, apiUrl, noWire = false } = {}) {
|
|
250
|
+
const relFile = path.join('layout', 'theme.liquid');
|
|
251
|
+
const file = path.join(appRoot, relFile);
|
|
252
|
+
if (!fs.existsSync(file)) {
|
|
253
|
+
return { status: 'failed', file: relFile, reason: 'layout/theme.liquid not found' };
|
|
254
|
+
}
|
|
255
|
+
const snippet = buildShopifySnippet({ apiKey, apiUrl });
|
|
256
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
257
|
+
|
|
258
|
+
const markerRe = new RegExp(`${escapeRe(ORB_START)}[\\s\\S]*?${escapeRe(ORB_END)}`);
|
|
259
|
+
if (markerRe.test(source)) {
|
|
260
|
+
const existing = source.match(markerRe)[0];
|
|
261
|
+
if (existing === snippet) return { status: 'already', file: relFile };
|
|
262
|
+
if (noWire) return { status: 'skipped', file: relFile, reason: '--no-wire' };
|
|
263
|
+
fs.writeFileSync(file, source.replace(markerRe, snippet));
|
|
264
|
+
return { status: 'added', file: relFile, refreshed: true };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (noWire) return { status: 'skipped', file: relFile, reason: '--no-wire' };
|
|
268
|
+
const bodyClose = source.lastIndexOf('</body>');
|
|
269
|
+
const next = bodyClose === -1
|
|
270
|
+
? `${source}\n${snippet}\n`
|
|
271
|
+
: `${source.slice(0, bodyClose)}${snippet}\n ${source.slice(bodyClose)}`;
|
|
272
|
+
fs.writeFileSync(file, next);
|
|
273
|
+
return { status: 'added', file: relFile };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function reportShopifyWire(result, colors = {}, { quiet = false } = {}) {
|
|
277
|
+
const { blue = '', cyan = '', reset = '' } = colors;
|
|
278
|
+
const say = (line) => console.error(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
|
|
279
|
+
if (result.status === 'added') {
|
|
280
|
+
say(`orb embedded in ${result.file}${result.refreshed ? ' (snippet refreshed)' : ''} — commit & push to deploy via Shopify's GitHub sync`);
|
|
281
|
+
} else if (result.status === 'already') {
|
|
282
|
+
say(`orb already embedded in ${result.file}`);
|
|
283
|
+
} else if (result.status === 'failed') {
|
|
284
|
+
say(`could not embed the orb: ${result.reason}`);
|
|
285
|
+
} else if (!quiet) {
|
|
286
|
+
say(`orb embed skipped (${result.reason || result.status})`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = {
|
|
291
|
+
isShopifyThemeRoot,
|
|
292
|
+
readThemeMeta,
|
|
293
|
+
fetchStorefrontSnapshot,
|
|
294
|
+
buildShopifySnippet,
|
|
295
|
+
wireShopifyTheme,
|
|
296
|
+
reportShopifyWire,
|
|
297
|
+
normalizeStoreDomain,
|
|
298
|
+
FIODOS_EMBED_CDN_URL,
|
|
299
|
+
SNAPSHOT_DIR,
|
|
300
|
+
};
|
package/src/verify.js
CHANGED
|
@@ -31,6 +31,40 @@ function fileHasIdentifier(content, identifier) {
|
|
|
31
31
|
return re.test(content);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const EXECUTION_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Structural check of a declarative `execution` block (closed platforms).
|
|
38
|
+
* Mirrors @fiodos/core validateExecution: http kind, known method, non-empty
|
|
39
|
+
* url, and every {placeholder} resolvable (baseUrl or a declared parameter).
|
|
40
|
+
* Returns a problem string, or null when the block is sound.
|
|
41
|
+
*/
|
|
42
|
+
function validateExecutionShape(action) {
|
|
43
|
+
const exec = action.execution;
|
|
44
|
+
if (!exec || typeof exec !== 'object') return 'missing execution object';
|
|
45
|
+
if (exec.kind !== 'http') return `kind '${exec.kind}' is not 'http'`;
|
|
46
|
+
if (!EXECUTION_METHODS.has(exec.method)) return `invalid method '${exec.method}'`;
|
|
47
|
+
if (typeof exec.url !== 'string' || !exec.url.trim()) return 'empty url';
|
|
48
|
+
if (!String(action.handler || '').trim()) return 'function action has no handler name';
|
|
49
|
+
const declared = new Set(Object.keys(action.parameters || {}));
|
|
50
|
+
const placeholders = new Set();
|
|
51
|
+
const collect = (value) => {
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
for (const m of value.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) placeholders.add(m[1]);
|
|
54
|
+
} else if (value && typeof value === 'object') {
|
|
55
|
+
for (const v of Object.values(value)) collect(v);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
collect(exec.url);
|
|
59
|
+
collect(exec.body);
|
|
60
|
+
for (const name of placeholders) {
|
|
61
|
+
if (name !== 'baseUrl' && !declared.has(name)) {
|
|
62
|
+
return `execution references undeclared parameter '{${name}}'`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
/**
|
|
35
69
|
* Upper bound on examples kept per route/action. The enriched prompt asks the
|
|
36
70
|
* model for 5-8 varied phrasings; this cap protects the cacheable system prompt
|
|
@@ -160,12 +194,48 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
160
194
|
});
|
|
161
195
|
}
|
|
162
196
|
|
|
197
|
+
const isShopify = opts.platform === 'shopify';
|
|
163
198
|
const actions = [];
|
|
164
199
|
for (const action of manifest.actions || []) {
|
|
165
200
|
const ev = evidence.actions?.[action.intent] || {};
|
|
166
201
|
const evFile = String(ev.file || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
167
202
|
const content = byRel.get(evFile);
|
|
168
203
|
|
|
204
|
+
// Shopify (closed platform): the ground truth for API actions is the
|
|
205
|
+
// platform itself, not app code. An action carrying a declarative
|
|
206
|
+
// `execution` block is verified STRUCTURALLY (http kind + method + url and
|
|
207
|
+
// every {placeholder} declared) — a synthetic handler name can never match
|
|
208
|
+
// an identifier in a .liquid file, and requiring it would drop the whole
|
|
209
|
+
// universal commerce set. The universal "/checkout" link is likewise
|
|
210
|
+
// platform-guaranteed (themes rarely hardcode that href).
|
|
211
|
+
if (isShopify && action.kind !== 'link' && action.execution) {
|
|
212
|
+
const problem = validateExecutionShape(action);
|
|
213
|
+
if (problem) {
|
|
214
|
+
provenance.actions.push({
|
|
215
|
+
intent: action.intent, included: false,
|
|
216
|
+
reason: `invalid execution block — ${problem} — DROPPED`,
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
actions.push(action);
|
|
221
|
+
provenance.actions.push({
|
|
222
|
+
intent: action.intent, included: true,
|
|
223
|
+
verification: 'declarative execution (Shopify storefront API — platform-guaranteed)',
|
|
224
|
+
evidence: ev,
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (isShopify && action.kind === 'link' && String(action.navTarget || '').trim() === '/checkout') {
|
|
229
|
+
action.handler = '';
|
|
230
|
+
actions.push(action);
|
|
231
|
+
provenance.actions.push({
|
|
232
|
+
intent: action.intent, included: true,
|
|
233
|
+
verification: 'universal Shopify checkout link (platform-guaranteed)',
|
|
234
|
+
evidence: ev,
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
169
239
|
if (!content) {
|
|
170
240
|
provenance.actions.push({
|
|
171
241
|
intent: action.intent, included: false,
|
|
@@ -254,6 +324,59 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
254
324
|
else delete route.actionIntents;
|
|
255
325
|
}
|
|
256
326
|
|
|
327
|
+
// Escort target: keep each action's showcaseRoute fail-open. It must point
|
|
328
|
+
// at a SURVIVING, navigable route intent (never BACK); anything else —
|
|
329
|
+
// hallucinated ids, dropped routes, non-strings — is silently removed so a
|
|
330
|
+
// wrong escort can never ship (a wrong screen is worse than no escort).
|
|
331
|
+
const navigableRouteIntents = new Set(
|
|
332
|
+
dedupedRoutes.filter((r) => r.route !== 'BACK').map((r) => r.intent),
|
|
333
|
+
);
|
|
334
|
+
for (const action of dedupedActions) {
|
|
335
|
+
if (action.showcaseRoute === undefined) continue;
|
|
336
|
+
if (typeof action.showcaseRoute !== 'string' || !navigableRouteIntents.has(action.showcaseRoute)) {
|
|
337
|
+
provenance.notes.push(
|
|
338
|
+
`action '${action.intent}': showcaseRoute '${String(action.showcaseRoute)}' does not match a navigable route — removed`,
|
|
339
|
+
);
|
|
340
|
+
delete action.showcaseRoute;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Audience map (fail-open): only the two known values survive; anything else
|
|
345
|
+
// is dropped so an invalid claim can never HIDE a capability from a user.
|
|
346
|
+
// 'public' is the implicit default, so it is normalized away (absent = public).
|
|
347
|
+
// requiredCapabilities become a deduped list of short snake_case names; a
|
|
348
|
+
// malformed list is dropped entirely (a bogus capability must never hide an
|
|
349
|
+
// action behind a flag no session will ever report).
|
|
350
|
+
for (const item of [...dedupedRoutes, ...dedupedActions]) {
|
|
351
|
+
if (item.audience !== undefined && item.audience !== 'authenticated') {
|
|
352
|
+
if (item.audience !== 'public') {
|
|
353
|
+
provenance.notes.push(
|
|
354
|
+
`'${item.intent}': audience '${String(item.audience)}' is not public/authenticated — removed`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
delete item.audience;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const action of dedupedActions) {
|
|
361
|
+
if (action.requiredCapabilities === undefined) continue;
|
|
362
|
+
const caps = Array.isArray(action.requiredCapabilities)
|
|
363
|
+
? [
|
|
364
|
+
...new Set(
|
|
365
|
+
action.requiredCapabilities
|
|
366
|
+
.filter((c) => typeof c === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(c.trim()))
|
|
367
|
+
.map((c) => c.trim()),
|
|
368
|
+
),
|
|
369
|
+
].slice(0, 8)
|
|
370
|
+
: [];
|
|
371
|
+
if (caps.length > 0) action.requiredCapabilities = caps;
|
|
372
|
+
else {
|
|
373
|
+
provenance.notes.push(
|
|
374
|
+
`action '${action.intent}': requiredCapabilities had no valid snake_case names — removed`,
|
|
375
|
+
);
|
|
376
|
+
delete action.requiredCapabilities;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
257
380
|
// Examples hygiene (fail-open): trim, dedupe and cap the enriched phrasings on
|
|
258
381
|
// every surviving route and action. Never drops the item; falls back to the
|
|
259
382
|
// label so the core validator's non-empty-examples rule still holds.
|
|
@@ -299,4 +422,4 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
299
422
|
};
|
|
300
423
|
}
|
|
301
424
|
|
|
302
|
-
module.exports = { verifyManifest, normalizeHref, sanitizeExamples, MAX_EXAMPLES };
|
|
425
|
+
module.exports = { verifyManifest, normalizeHref, sanitizeExamples, validateExecutionShape, MAX_EXAMPLES };
|
package/src/wireHandlers.js
CHANGED
|
@@ -467,13 +467,79 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
|
|
|
467
467
|
};
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
/**
|
|
471
|
+
* MECHANICAL ANCHOR RESCUE — the fix for the dominant wiring failure class
|
|
472
|
+
* ("the insertion point in '<file>' is ambiguous / not found").
|
|
473
|
+
*
|
|
474
|
+
* The AI's anchor only needs to locate the RIGHT COMPONENT SCOPE: the actual
|
|
475
|
+
* insertion goes before the nearest JSX `return` (see insertRegisterBlock), so
|
|
476
|
+
* when the AI picks a line that appears several times in a big file we do not
|
|
477
|
+
* have to give up. We derive a deterministic anchor ourselves: the line where
|
|
478
|
+
* one of the identifiers the `invoke` expression CALLS/READS is DECLARED
|
|
479
|
+
* (`const [x, setX] = useState(...)`, `function foo(`, `const foo =`, a class
|
|
480
|
+
* field, …). Declarations are unique per scope in real code, and anchoring on
|
|
481
|
+
* the declaration guarantees the registration lands in the scope where the
|
|
482
|
+
* symbol is visible. Correctness is still gated by the same compile-regression
|
|
483
|
+
* + effect-probe verification as every other wiring — this only replaces the
|
|
484
|
+
* "reject" with a better, mechanical candidate.
|
|
485
|
+
*/
|
|
486
|
+
function deriveBridgeAnchor(content, invoke) {
|
|
487
|
+
const src = stripLiterals(String(invoke || ''));
|
|
488
|
+
// Identifiers the invoke CALLS first (most specific), then everything it reads.
|
|
489
|
+
const ordered = [];
|
|
490
|
+
const pushId = (id) => {
|
|
491
|
+
if (!id || id === 'args' || id === 'this') return;
|
|
492
|
+
if (JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id)) return;
|
|
493
|
+
if (!ordered.includes(id)) ordered.push(id);
|
|
494
|
+
};
|
|
495
|
+
let m;
|
|
496
|
+
const callRe = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
|
|
497
|
+
while ((m = callRe.exec(src))) pushId(m[1]);
|
|
498
|
+
for (const id of freeIdentifiers(invoke)) pushId(id);
|
|
499
|
+
|
|
500
|
+
for (const id of ordered) {
|
|
501
|
+
// Full LINES that look like a declaration of `id` in the file.
|
|
502
|
+
const lineRe = new RegExp(
|
|
503
|
+
`^[ \\t]*(?:export\\s+)?(?:const|let|var|function|async\\s+function|private|public|protected|readonly)\\b[^\\n]*(?:^|[^.\\w$])${esc(id)}(?![\\w$])[^\\n]*$`,
|
|
504
|
+
'gm',
|
|
505
|
+
);
|
|
506
|
+
const decls = [];
|
|
507
|
+
while ((m = lineRe.exec(content))) decls.push(m[0]);
|
|
508
|
+
// Exactly ONE declaration line, and that line must be unique as a substring
|
|
509
|
+
// (so indexOf-based insertion is deterministic and idempotent).
|
|
510
|
+
if (decls.length === 1 && countOccurrences(content, decls[0]) === 1) {
|
|
511
|
+
return decls[0];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* True when an anchor that appears several times is HARMLESS: every occurrence
|
|
519
|
+
* resolves to the same actual insertion point (the nearest JSX `return`), so
|
|
520
|
+
* the ambiguity cannot change where the registration lands.
|
|
521
|
+
*/
|
|
522
|
+
function ambiguousAnchorSameInsertion(content, anchor) {
|
|
523
|
+
const points = new Set();
|
|
524
|
+
let idx = content.indexOf(anchor);
|
|
525
|
+
while (idx !== -1) {
|
|
526
|
+
points.add(nearestJsxReturn(content, idx));
|
|
527
|
+
if (points.size > 1) return false;
|
|
528
|
+
idx = content.indexOf(anchor, idx + anchor.length);
|
|
529
|
+
}
|
|
530
|
+
// A single resolved point that exists (-1 = no JSX return found → not safe).
|
|
531
|
+
return points.size === 1 && !points.has(-1);
|
|
532
|
+
}
|
|
533
|
+
|
|
470
534
|
/**
|
|
471
535
|
* Verify the AI's BRIDGE proposal for ONE action. A bridge edits the user's own
|
|
472
536
|
* component (under consent) to register the live, in-scope function/instance so
|
|
473
537
|
* the standalone registry can reach component-local state. The verifier confirms
|
|
474
538
|
* the edit can be applied SAFELY and that nothing is fabricated:
|
|
475
|
-
* · the component file exists and the anchor
|
|
476
|
-
*
|
|
539
|
+
* · the component file exists and the anchor resolves to ONE unambiguous
|
|
540
|
+
* insertion point — when the AI's anchor is missing/ambiguous, a mechanical
|
|
541
|
+
* anchor is derived from the declaration of the invoked symbol (see
|
|
542
|
+
* deriveBridgeAnchor) instead of giving up,
|
|
477
543
|
* · every requiredSymbol exists in the code,
|
|
478
544
|
* · `invoke` does not create a detached `new Service()` instance,
|
|
479
545
|
* · every identifier `invoke` reads is `args`/`this`, a safe global, an import
|
|
@@ -483,7 +549,7 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
|
|
|
483
549
|
function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
|
|
484
550
|
const b = aiW.bridge || {};
|
|
485
551
|
const file = normRel(b.file);
|
|
486
|
-
|
|
552
|
+
let anchor = String(b.anchor || '').trim();
|
|
487
553
|
const invoke = String(b.invoke || '').trim();
|
|
488
554
|
const why = aiW.bridgeReason ? ` (${aiW.bridgeReason})` : '';
|
|
489
555
|
|
|
@@ -491,11 +557,29 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
|
|
|
491
557
|
const content = readFileSafe(appRoot, file);
|
|
492
558
|
if (content == null) return { reason: `could not read the component '${file}' to apply the bridge` };
|
|
493
559
|
if (!invoke) return { reason: `the AI did not provide the bridge 'invoke' expression for '${file}'` };
|
|
494
|
-
if (!anchor) return { reason: `the AI did not provide an insertion anchor in '${file}'` };
|
|
495
560
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
561
|
+
// Anchor resolution ladder: exact-unique AI anchor → harmless-ambiguous AI
|
|
562
|
+
// anchor → mechanically derived declaration anchor → review. The old behavior
|
|
563
|
+
// (reject on 0/>1 occurrences) left real, wireable actions "manual" whenever
|
|
564
|
+
// the AI picked a repeated line in a big file.
|
|
565
|
+
let anchorDerived = false;
|
|
566
|
+
const occ = anchor ? countOccurrences(content, anchor) : 0;
|
|
567
|
+
if (occ !== 1) {
|
|
568
|
+
const scope = b.scope === 'class-field' ? 'class-field' : 'statement';
|
|
569
|
+
if (occ > 1 && scope === 'statement' && ambiguousAnchorSameInsertion(content, anchor)) {
|
|
570
|
+
// Keep the AI anchor: every occurrence lands on the same insertion point.
|
|
571
|
+
} else {
|
|
572
|
+
const derived = deriveBridgeAnchor(content, invoke);
|
|
573
|
+
if (derived) {
|
|
574
|
+
anchor = derived;
|
|
575
|
+
anchorDerived = true;
|
|
576
|
+
} else if (!anchor || occ === 0) {
|
|
577
|
+
return { reason: `no safe insertion point was found in '${file}' — wire this action manually` };
|
|
578
|
+
} else {
|
|
579
|
+
return { reason: `the insertion point in '${file}' is ambiguous — wire this action manually` };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
499
583
|
|
|
500
584
|
// requiredSymbols must really exist.
|
|
501
585
|
const required = Array.isArray(aiW.requiredSymbols) ? aiW.requiredSymbols : [];
|
|
@@ -1854,4 +1938,28 @@ module.exports = {
|
|
|
1854
1938
|
findExportedFunction,
|
|
1855
1939
|
findStoreMethod,
|
|
1856
1940
|
mapParams,
|
|
1941
|
+
deriveBridgeAnchor,
|
|
1942
|
+
ambiguousAnchorSameInsertion,
|
|
1943
|
+
// shared with wireSession (same verification + injection machinery)
|
|
1944
|
+
fileHasSymbol,
|
|
1945
|
+
freeIdentifiers,
|
|
1946
|
+
declaredLocals,
|
|
1947
|
+
detachedInstanceReceiver,
|
|
1948
|
+
countOccurrences,
|
|
1949
|
+
readFileSafe,
|
|
1950
|
+
normRel,
|
|
1951
|
+
relImport,
|
|
1952
|
+
detectTsEsm,
|
|
1953
|
+
resolveFiodosDirRel,
|
|
1954
|
+
askYesNo,
|
|
1955
|
+
JS_KEYWORDS,
|
|
1956
|
+
SAFE_GLOBALS,
|
|
1957
|
+
GENERATED_FILE_DIRECTIVES,
|
|
1958
|
+
INJECT_ESLINT_DISABLE,
|
|
1959
|
+
INJECT_ESLINT_ENABLE,
|
|
1960
|
+
EDIT_START,
|
|
1961
|
+
EDIT_END,
|
|
1962
|
+
BRIDGE_BASENAME,
|
|
1963
|
+
insertImportBlock,
|
|
1964
|
+
insertRegisterBlock,
|
|
1857
1965
|
};
|
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,
|