@fiodos/cli 0.1.18 → 0.1.20
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 +1 -1
- package/src/aiAnalyze.js +83 -5
- package/src/changeRegistry.js +534 -0
- package/src/index.js +51 -0
- package/src/postWireTest.js +25 -1
- package/src/resolveAnalysisRoot.js +48 -4
- package/src/verify.js +101 -1
- package/src/wireWeb.js +72 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
package/src/aiAnalyze.js
CHANGED
|
@@ -51,7 +51,7 @@ function resolveModel(model) {
|
|
|
51
51
|
// focus the analysis — which folder/app, what to emphasize or ignore — but never
|
|
52
52
|
// override the system rules, invent actions, skip verification or change the
|
|
53
53
|
// output schema). When empty, the prompt is byte-identical to a spec-less run.
|
|
54
|
-
const MAX_USER_SPEC_CHARS =
|
|
54
|
+
const MAX_USER_SPEC_CHARS = 2500;
|
|
55
55
|
|
|
56
56
|
function sanitizeUserSpec(raw) {
|
|
57
57
|
if (raw == null) return '';
|
|
@@ -88,8 +88,10 @@ AppManifest (all fields required unless noted):
|
|
|
88
88
|
"routes": [{
|
|
89
89
|
"intent": string (snake_case, UNIQUE across routes AND actions),
|
|
90
90
|
"label": string, "route": string (opaque route string for the app's navigation adapter — an expo-router href, a URL path, or "BACK"),
|
|
91
|
-
"description": string (one line:
|
|
92
|
-
"
|
|
91
|
+
"description": string (one DISTINCTIVE line: what THIS screen shows and what the user does there, phrased so it can NOT be confused with sibling screens — name the concrete content/entities unique to it. e.g. "Lists the registered people/accounts; from here you open and manage each individual user" — NOT a generic "a screen of the app"),
|
|
92
|
+
"bubblePrompt": string (SHORT user-facing call-to-action shown in a small "thought bubble" next to the assistant orb WHEN THE USER IS ON THIS SCREEN. One friendly question/offer in the app's language, max ~6 words, ending naturally (often a "?"). Distinct per screen and tied to what the user does HERE — e.g. a cart screen: "¿Finalizar tu compra?"; a users list: "¿Buscar un usuario?"; a login screen: "¿Quieres iniciar sesión?". NOT a description, NOT the label verbatim. Optional — omit if nothing natural fits),
|
|
93
|
+
"actionIntents": string[] (OPTIONAL: intents of the actions a user performs FROM this screen — the action handlers actually wired into THIS screen's component/page. Use exact action intent ids from the "actions" array; [] if none. Advisory only, never gates anything),
|
|
94
|
+
"examples": string[] (5-8 natural voice phrases a user might say to reach this screen, REQUIRED non-empty. Include SYNONYMS and PARAPHRASES that do NOT contain the literal label word, so the agent recognizes indirect requests — e.g. for a "Users" screen: "ver usuarios", "la pantalla donde vemos a las personas", "muéstrame las cuentas", "lista de gente registrada", "quiénes están dados de alta". Vary the verb and the noun; cover the obvious phrasing AND at least two oblique ones. Avoid near-duplicates that differ only in a word)
|
|
93
95
|
}],
|
|
94
96
|
"actions": [{
|
|
95
97
|
"intent": string (snake_case, UNIQUE across routes AND actions),
|
|
@@ -105,7 +107,7 @@ AppManifest (all fields required unless noted):
|
|
|
105
107
|
"confirmationMessageTemplate": string (REQUIRED if requireConfirmation true),
|
|
106
108
|
"parameters": { "<name>": {"type": "string"|"number"|"boolean", "required": boolean, "description": string (non-empty)} },
|
|
107
109
|
"contextRequirements": {"requiresVisibleContent"?: bool, "requiresAuth"?: bool} (optional; set requiresAuth: true for actions that need a signed-in user — see the REQUIRES-AUTH rule below),
|
|
108
|
-
"examples": string[] (
|
|
110
|
+
"examples": string[] (5-8 natural voice phrases a user might say to trigger this action, REQUIRED non-empty. Include SYNONYMS and PARAPHRASES that do NOT rely on the literal label word, so indirect requests are recognized — e.g. for "Empty cart": "vacía el carrito", "quita todo lo que tengo", "borra todos los productos de la cesta". Vary verb and phrasing; avoid near-duplicates)
|
|
109
111
|
}],
|
|
110
112
|
"policies": {"requireConfirmationForDestructive": true, "allowedWithoutAuth": string[], "contextRetentionTurns": 5}
|
|
111
113
|
}
|
|
@@ -215,8 +217,18 @@ This is the context the agent reads to understand the app, so it can reason inst
|
|
|
215
217
|
- effect: the observable outcome after it runs (state change, navigation, message).
|
|
216
218
|
- relatedIntents: other manifest intents this naturally flows into or from (e.g. add-to-cart → checkout). Use the exact intent ids; [] if none.
|
|
217
219
|
- route description: what the screen displays and what can be done there.
|
|
220
|
+
- route actionIntents: for each screen, the intents of the actions whose handlers are actually wired into THAT screen's component/page (what the user can DO there). Use exact action intent ids; omit or [] when the screen is purely informational/navigational. This is "what you can do here" context — it never restricts the agent.
|
|
221
|
+
- route bubblePrompt: a SHORT, friendly CTA (in the app's language, ~6 words max) the assistant shows beside the orb on THAT screen to invite the user to act — e.g. login screen → "¿Quieres iniciar sesión?", cart → "¿Finalizar tu compra?", users list → "¿Buscar un usuario?". Make it distinct per screen and grounded in what the user does there; it is end-user copy, not a description. Omit when nothing natural fits.
|
|
218
222
|
- appType/appFlow: the app category and its main end-to-end flow.
|
|
219
|
-
|
|
223
|
+
|
|
224
|
+
DISTINCTIVENESS (this is what prevents the agent picking the wrong screen/action):
|
|
225
|
+
- Two screens are EASILY confused when their descriptions are generic. Write each route's description so it names the concrete entities/content unique to THAT screen and could not be copy-pasted onto a sibling. Bad (confusable): "Dashboard screen of the app." Good: "Aggregate overview with KPI cards (totals of users, projects, revenue); it does NOT list individual records." vs "Lists individual user records with per-user detail."
|
|
226
|
+
- EXAMPLES are how the agent maps indirect speech to the right intent. For every route AND action, after the obvious phrasing add at least TWO oblique phrasings that a real user would say WITHOUT the label word (descriptive references to the content, e.g. "where we see the people" for a Users screen, "the page with the totals/summary" for an Overview screen). This directly reduces cross-screen confusion. Make examples DISTINCT across sibling screens — never give two screens an example that would fit both.
|
|
227
|
+
|
|
228
|
+
SENSITIVE ACTIONS — EXTRA DETAIL (still static, no per-turn change):
|
|
229
|
+
For any action with requireConfirmation true (delete/remove, logout, place/cancel orders, spend money, irreversible changes), make description, preconditions and effect noticeably MORE detailed than for normal actions: spell out exactly what is affected, what is irreversible, and the precise post-state. This richer manual lives in the static prompt so the agent reasons carefully about the stakes; it does NOT change the confirmation flow (the engine still enforces it).
|
|
230
|
+
|
|
231
|
+
Be precise and DISTINCTIVE. Descriptions stay one tight line each (no padding), but examples should be VARIED and cover several real phrasings (synonyms/paraphrases are wanted, near-duplicate filler is not).
|
|
220
232
|
|
|
221
233
|
Also return EVIDENCE for every route and action: the file (exact relative path as given in the FILE headers) and symbol (function/property name) in the provided code that justifies it. Evidence is verified mechanically afterwards: an action whose file or symbol does not exist is dropped, and unverifiable items count against you.
|
|
222
234
|
|
|
@@ -352,12 +364,75 @@ function buildUserPrompt(appMeta, files, omitted, staticRoutes, surface = null,
|
|
|
352
364
|
);
|
|
353
365
|
}
|
|
354
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Extract the FIRST complete, balanced JSON object from `text`, scanning from
|
|
369
|
+
* the first `{` to its matching `}` while respecting string literals and escape
|
|
370
|
+
* sequences (so braces inside string values don't throw the counter off).
|
|
371
|
+
* Returns the substring, or null when no balanced object is found.
|
|
372
|
+
*
|
|
373
|
+
* This is what makes parsing robust to a model that emits trailing prose (or a
|
|
374
|
+
* second brace-bearing sentence) after an otherwise valid JSON object — the
|
|
375
|
+
* cause of "Unexpected non-whitespace character after JSON at position N".
|
|
376
|
+
*/
|
|
377
|
+
function extractBalancedJsonObject(text) {
|
|
378
|
+
const start = text.indexOf('{');
|
|
379
|
+
if (start < 0) return null;
|
|
380
|
+
let depth = 0;
|
|
381
|
+
let inString = false;
|
|
382
|
+
let escaped = false;
|
|
383
|
+
for (let i = start; i < text.length; i += 1) {
|
|
384
|
+
const ch = text[i];
|
|
385
|
+
if (escaped) {
|
|
386
|
+
escaped = false;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (ch === '\\') {
|
|
390
|
+
if (inString) escaped = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === '"') {
|
|
394
|
+
inString = !inString;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (inString) continue;
|
|
398
|
+
if (ch === '{') {
|
|
399
|
+
depth += 1;
|
|
400
|
+
} else if (ch === '}') {
|
|
401
|
+
depth -= 1;
|
|
402
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
355
408
|
function parseJsonContent(raw) {
|
|
356
409
|
let trimmed = (raw || '').trim();
|
|
357
410
|
if (!trimmed) return {};
|
|
411
|
+
// Strip a leading/trailing markdown code fence if the model wrapped its JSON.
|
|
358
412
|
if (trimmed.startsWith('```')) {
|
|
359
413
|
trimmed = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
|
|
360
414
|
}
|
|
415
|
+
// 1) Happy path: the response is exactly one JSON value.
|
|
416
|
+
try {
|
|
417
|
+
return JSON.parse(trimmed);
|
|
418
|
+
} catch {
|
|
419
|
+
/* fall through to tolerant extraction */
|
|
420
|
+
}
|
|
421
|
+
// 2) Tolerant path: take the FIRST balanced {…} object and ignore anything
|
|
422
|
+
// the model appended after it (trailing prose, a second sentence, a stray
|
|
423
|
+
// fence). A brace-aware scan beats indexOf('{')+lastIndexOf('}'), which
|
|
424
|
+
// OVER-captures when the trailing text itself contains braces — the exact
|
|
425
|
+
// failure mode ("Unexpected non-whitespace character after JSON").
|
|
426
|
+
const balanced = extractBalancedJsonObject(trimmed);
|
|
427
|
+
if (balanced) {
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(balanced);
|
|
430
|
+
} catch {
|
|
431
|
+
/* fall through to the legacy widest-slice as a last resort */
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// 3) Last resort: widest {…} slice (previous behavior) so we never regress on
|
|
435
|
+
// inputs the old path could handle.
|
|
361
436
|
const start = trimmed.indexOf('{');
|
|
362
437
|
const end = trimmed.lastIndexOf('}');
|
|
363
438
|
if (start < 0 || end <= start) throw new Error('Model response is not JSON');
|
|
@@ -578,4 +653,7 @@ module.exports = {
|
|
|
578
653
|
sanitizeUserSpec,
|
|
579
654
|
MAX_USER_SPEC_CHARS,
|
|
580
655
|
manifestSchemaDoc,
|
|
656
|
+
// Exported so the response parser is unit-testable without a live AI call.
|
|
657
|
+
parseJsonContent,
|
|
658
|
+
extractBalancedJsonObject,
|
|
581
659
|
};
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* changeRegistry — transparent, per-file record of every change the Fiodos
|
|
3
|
+
* installer makes in the client's repo.
|
|
4
|
+
*
|
|
5
|
+
* On each install / re-analysis run we write:
|
|
6
|
+
* · `.fyodos/changes/<runId>.json` — structured snapshot of THIS run (machine-readable)
|
|
7
|
+
* · `.fyodos/changes/index.json` — chronological index of all runs
|
|
8
|
+
* · `src/fyodos/FYODOS_CHANGES.md` — human-readable log (APPENDS; history is never lost)
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: never embed API keys, secrets, or env values — only file paths, variable
|
|
11
|
+
* names, and descriptions of what was touched.
|
|
12
|
+
*/
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const CLI_VERSION = require('../package.json').version;
|
|
19
|
+
|
|
20
|
+
function normRel(appRoot, absOrRel) {
|
|
21
|
+
const rel = path.isAbsolute(absOrRel) ? path.relative(appRoot, absOrRel) : absOrRel;
|
|
22
|
+
return String(rel || '').replace(/\\/g, '/');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveFyodosDirRel(appRoot) {
|
|
26
|
+
if (fs.existsSync(path.join(appRoot, 'src'))) return 'src/fyodos';
|
|
27
|
+
return 'fyodos';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeRunId() {
|
|
31
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJsonSafe(file) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function uniquePaths(list) {
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const item of list) {
|
|
46
|
+
const key = item.path;
|
|
47
|
+
if (!key || seen.has(key)) continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
out.push(item);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Phase summarizers ────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function summarizeOrb(result) {
|
|
57
|
+
if (!result) return { status: 'skipped', applied: false };
|
|
58
|
+
const base = { status: result.status, mountFile: result.file || null };
|
|
59
|
+
switch (result.status) {
|
|
60
|
+
case 'added':
|
|
61
|
+
return {
|
|
62
|
+
...base,
|
|
63
|
+
applied: true,
|
|
64
|
+
strategy: result.planSummary?.strategy || null,
|
|
65
|
+
steps: result.planSummary?.steps || [],
|
|
66
|
+
files: result.planSummary?.files || [],
|
|
67
|
+
created: result.planSummary?.created || [],
|
|
68
|
+
revert:
|
|
69
|
+
'Remove the blocks between `FYODOS:ORB:START` and `FYODOS:ORB:END` (and the Fiodos import). See `FYODOS_ORB_MOUNT.md`.',
|
|
70
|
+
};
|
|
71
|
+
case 'already':
|
|
72
|
+
return {
|
|
73
|
+
...base,
|
|
74
|
+
applied: false,
|
|
75
|
+
note: 'The orb was already mounted from a previous run; no orb files were changed.',
|
|
76
|
+
};
|
|
77
|
+
case 'declined':
|
|
78
|
+
return {
|
|
79
|
+
...base,
|
|
80
|
+
applied: false,
|
|
81
|
+
note: 'You declined the automatic orb mount; your source files were not modified.',
|
|
82
|
+
};
|
|
83
|
+
case 'declined-noninteractive':
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
applied: false,
|
|
87
|
+
note: 'Non-interactive terminal: orb mount was skipped. Re-run with --yes to apply.',
|
|
88
|
+
};
|
|
89
|
+
case 'printed':
|
|
90
|
+
case 'reverted':
|
|
91
|
+
case 'failed':
|
|
92
|
+
return {
|
|
93
|
+
...base,
|
|
94
|
+
applied: false,
|
|
95
|
+
reason: result.reason || result.error || null,
|
|
96
|
+
note: 'Automatic orb mount did not complete; see the CLI output for the manual snippet.',
|
|
97
|
+
};
|
|
98
|
+
case 'skipped':
|
|
99
|
+
return { ...base, applied: false, note: 'Orb wiring was skipped (--no-wire / --no-orb-wire).' };
|
|
100
|
+
default:
|
|
101
|
+
return { ...base, applied: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function summarizeHandlers(result, appRoot) {
|
|
106
|
+
if (!result) return { status: 'skipped', applied: false };
|
|
107
|
+
const base = {
|
|
108
|
+
status: result.status,
|
|
109
|
+
autoCount: result.autoCount || 0,
|
|
110
|
+
manualCount: result.manualCount || 0,
|
|
111
|
+
docPath: result.docPath && appRoot ? normRel(appRoot, result.docPath) : null,
|
|
112
|
+
};
|
|
113
|
+
const applied = result.status === 'applied' || result.status === 'applied-untested';
|
|
114
|
+
|
|
115
|
+
if (applied) {
|
|
116
|
+
const bridgeEdits = (result.editedFiles || []).map((f) => ({
|
|
117
|
+
path: normRel('', f),
|
|
118
|
+
what:
|
|
119
|
+
'Added reversible `FYODOS:BRIDGE:START` / `FYODOS:BRIDGE:END` blocks (import + registerFiodosBridge call).',
|
|
120
|
+
intents: (result.plan?.auto || [])
|
|
121
|
+
.filter((e) => e.kind === 'bridge' && e.bridge?.file === f)
|
|
122
|
+
.map((e) => e.intent),
|
|
123
|
+
revert: 'Delete the `FYODOS:BRIDGE:START` … `FYODOS:BRIDGE:END` blocks in this file.',
|
|
124
|
+
}));
|
|
125
|
+
return {
|
|
126
|
+
...base,
|
|
127
|
+
applied: true,
|
|
128
|
+
registryFile: result.registryFile ? normRel('', result.registryFile) : null,
|
|
129
|
+
bridgeFile: result.bridgeFile ? normRel('', result.bridgeFile) : null,
|
|
130
|
+
editedFiles: bridgeEdits,
|
|
131
|
+
wiredIntents: (result.plan?.auto || []).map((e) => e.intent),
|
|
132
|
+
manualIntents: (result.plan?.manual || result.plan?.review || []).map((e) => e.intent),
|
|
133
|
+
revert:
|
|
134
|
+
'Delete generated files under `src/fyodos/` (handlers.generated.*, bridge.*) and remove `FYODOS:BRIDGE` blocks from your components. See `FYODOS_HANDLERS.md`.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const docAlways =
|
|
139
|
+
result.docPath &&
|
|
140
|
+
['declined', 'declined-noninteractive', 'manual-only', 'applied', 'applied-untested', 'reverted'].includes(
|
|
141
|
+
result.status,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
applied: false,
|
|
147
|
+
docWritten: Boolean(docAlways),
|
|
148
|
+
note:
|
|
149
|
+
result.status === 'declined'
|
|
150
|
+
? 'You declined handler wiring; only the documentation file was written.'
|
|
151
|
+
: result.status === 'declined-noninteractive'
|
|
152
|
+
? 'Non-interactive: handler wiring skipped. Documentation was written; re-run with --wire-yes to apply.'
|
|
153
|
+
: result.status === 'manual-only'
|
|
154
|
+
? 'No actions could be wired automatically; see FYODOS_HANDLERS.md for manual steps.'
|
|
155
|
+
: result.status === 'no-actions'
|
|
156
|
+
? 'Manifest has no actions; handler wiring was not needed.'
|
|
157
|
+
: result.status === 'skipped'
|
|
158
|
+
? 'Handler wiring skipped (--no-wire).'
|
|
159
|
+
: result.status === 'reverted'
|
|
160
|
+
? 'Wiring was reverted because the post-install build failed.'
|
|
161
|
+
: null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function summarizeRegistries(result) {
|
|
166
|
+
if (!result) return { status: 'skipped', applied: false };
|
|
167
|
+
const base = { status: result.status, file: result.file || null };
|
|
168
|
+
switch (result.status) {
|
|
169
|
+
case 'connected':
|
|
170
|
+
return {
|
|
171
|
+
...base,
|
|
172
|
+
applied: true,
|
|
173
|
+
what: 'Added `registries={fyodosGeneratedRegistries}` to `<FiodosAgent />` inside the existing `FYODOS:ORB` block.',
|
|
174
|
+
revert: 'Remove the `registries={…}` prop and its import from the orb mount file.',
|
|
175
|
+
};
|
|
176
|
+
case 'already':
|
|
177
|
+
return { ...base, applied: false, note: 'Registries were already connected.' };
|
|
178
|
+
case 'no-registry':
|
|
179
|
+
case 'no-target':
|
|
180
|
+
case 'not-mounted':
|
|
181
|
+
case 'unsupported-framework':
|
|
182
|
+
return { ...base, applied: false, note: 'Registry connection was not applicable for this framework/state.' };
|
|
183
|
+
default:
|
|
184
|
+
return { ...base, applied: false };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function summarizeEnv(result) {
|
|
189
|
+
if (!result) return { status: 'skipped', applied: false };
|
|
190
|
+
const wp = result.writePlan;
|
|
191
|
+
const base = { status: result.status };
|
|
192
|
+
if (result.status === 'written' && wp) {
|
|
193
|
+
return {
|
|
194
|
+
...base,
|
|
195
|
+
applied: true,
|
|
196
|
+
envFile: wp.targetRel || wp.file || null,
|
|
197
|
+
variables: [wp.plan?.keyVar, wp.plan?.urlVar].filter(Boolean),
|
|
198
|
+
gitignoreUpdated: true,
|
|
199
|
+
note: 'API key and URL were written to your local env file (values are NOT recorded here). `.gitignore` was updated if needed.',
|
|
200
|
+
revert: `Remove the Fiodos lines from ${wp.targetRel || 'your env file'}.`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (result.status === 'already aligned') {
|
|
204
|
+
return { ...base, applied: false, note: 'Local env already matched the published project key.' };
|
|
205
|
+
}
|
|
206
|
+
if (result.status === 'declined by user') {
|
|
207
|
+
return { ...base, applied: false, note: 'You declined writing the API key to your local env file.' };
|
|
208
|
+
}
|
|
209
|
+
if (result.status === 'manual') {
|
|
210
|
+
return {
|
|
211
|
+
...base,
|
|
212
|
+
applied: false,
|
|
213
|
+
note: wp?.kind === 'manual-angular' ? 'Angular env must be set manually in environment.ts.' : 'Env write requires manual setup for this framework.',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { ...base, applied: false };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function collectFileEntries(appRoot, ctx) {
|
|
220
|
+
const { orbResult, handlerResult, registryResult, envResult } = ctx;
|
|
221
|
+
const created = [];
|
|
222
|
+
const modified = [];
|
|
223
|
+
const docs = [];
|
|
224
|
+
|
|
225
|
+
const fyodosDir = resolveFyodosDirRel(appRoot);
|
|
226
|
+
|
|
227
|
+
// Orb
|
|
228
|
+
if (orbResult?.status === 'added' && orbResult.planSummary) {
|
|
229
|
+
for (const f of orbResult.planSummary.created || []) {
|
|
230
|
+
created.push({
|
|
231
|
+
path: f,
|
|
232
|
+
phase: 'orb',
|
|
233
|
+
what: 'Created by the orb mount installer.',
|
|
234
|
+
revert: 'Delete this file if it was created solely for Fiodos (see FYODOS_ORB_MOUNT.md).',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
for (const f of orbResult.planSummary.files || []) {
|
|
238
|
+
if ((orbResult.planSummary.created || []).includes(f)) continue;
|
|
239
|
+
modified.push({
|
|
240
|
+
path: f,
|
|
241
|
+
phase: 'orb',
|
|
242
|
+
what: 'Injected `<FiodosAgent />` (or bootstrap equivalent) between `FYODOS:ORB:START` / `FYODOS:ORB:END` markers.',
|
|
243
|
+
revert: 'Remove the `FYODOS:ORB:START` … `FYODOS:ORB:END` block and the Fiodos import.',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
docs.push({
|
|
247
|
+
path: `${fyodosDir}/FYODOS_ORB_MOUNT.md`,
|
|
248
|
+
phase: 'orb',
|
|
249
|
+
what: 'Human-readable orb mount summary (strategy, files, auth props).',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Handlers — doc is written even when wiring is declined
|
|
254
|
+
if (handlerResult?.docPath) {
|
|
255
|
+
docs.push({
|
|
256
|
+
path: normRel(appRoot, handlerResult.docPath),
|
|
257
|
+
phase: 'handlers',
|
|
258
|
+
what: 'Action wiring plan: auto-wired actions, manual steps, security notes.',
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (
|
|
262
|
+
handlerResult &&
|
|
263
|
+
(handlerResult.status === 'applied' || handlerResult.status === 'applied-untested')
|
|
264
|
+
) {
|
|
265
|
+
if (handlerResult.registryFile) {
|
|
266
|
+
created.push({
|
|
267
|
+
path: normRel(appRoot, handlerResult.registryFile),
|
|
268
|
+
phase: 'handlers',
|
|
269
|
+
what: 'Generated handler registry (`handlers.generated.*`). Regenerated on each apply.',
|
|
270
|
+
revert: 'Safe to delete; re-run the installer to regenerate.',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (handlerResult.bridgeFile) {
|
|
274
|
+
created.push({
|
|
275
|
+
path: normRel(appRoot, handlerResult.bridgeFile),
|
|
276
|
+
phase: 'handlers',
|
|
277
|
+
what: 'Bridge module for component-registered live functions.',
|
|
278
|
+
revert: 'Safe to delete together with handlers.generated.*.',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
for (const edit of summarizeHandlers(handlerResult).editedFiles || []) {
|
|
282
|
+
modified.push({ path: edit.path, phase: 'handlers', what: edit.what, revert: edit.revert, intents: edit.intents });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Registry connection
|
|
287
|
+
if (registryResult?.status === 'connected' && registryResult.file) {
|
|
288
|
+
modified.push({
|
|
289
|
+
path: registryResult.file,
|
|
290
|
+
phase: 'registries',
|
|
291
|
+
what: 'Connected `registries={fyodosGeneratedRegistries}` to the mounted orb.',
|
|
292
|
+
revert: 'Remove the registries prop and its import from the orb mount.',
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Env (never record values)
|
|
297
|
+
if (envResult?.status === 'written' && envResult.writePlan?.targetRel) {
|
|
298
|
+
modified.push({
|
|
299
|
+
path: envResult.writePlan.targetRel,
|
|
300
|
+
phase: 'env',
|
|
301
|
+
what: `Upserted ${envResult.writePlan.plan?.keyVar || 'FYODOS_API_KEY'} (and URL var if applicable). Values not logged here.`,
|
|
302
|
+
revert: 'Remove the Fiodos env lines from this file.',
|
|
303
|
+
});
|
|
304
|
+
modified.push({
|
|
305
|
+
path: '.gitignore',
|
|
306
|
+
phase: 'env',
|
|
307
|
+
what: 'Ensured the env file is git-ignored so secrets are not pushed.',
|
|
308
|
+
revert: 'Optional: remove the Fiodos gitignore comment if you revert everything.',
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// This registry itself
|
|
313
|
+
docs.push({
|
|
314
|
+
path: `${fyodosDir}/FYODOS_CHANGES.md`,
|
|
315
|
+
phase: 'registry',
|
|
316
|
+
what: 'This cumulative change log (appended each run).',
|
|
317
|
+
});
|
|
318
|
+
docs.push({
|
|
319
|
+
path: '.fyodos/changes/',
|
|
320
|
+
phase: 'registry',
|
|
321
|
+
what: 'Per-run JSON snapshots and index (machine-readable history).',
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
created: uniquePaths(created),
|
|
326
|
+
modified: uniquePaths(modified),
|
|
327
|
+
docs: uniquePaths(docs),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function computeDelta(appRoot, current) {
|
|
332
|
+
const indexPath = path.join(appRoot, '.fyodos', 'changes', 'index.json');
|
|
333
|
+
const index = readJsonSafe(indexPath);
|
|
334
|
+
if (!index?.runs?.length) return null;
|
|
335
|
+
|
|
336
|
+
const prevPath = path.join(appRoot, '.fyodos', 'changes', index.runs[0].jsonFile);
|
|
337
|
+
const prev = readJsonSafe(prevPath);
|
|
338
|
+
if (!prev) return null;
|
|
339
|
+
|
|
340
|
+
const prevIntents = new Set(prev.phases?.handlers?.wiredIntents || []);
|
|
341
|
+
const currIntents = new Set(current.phases?.handlers?.wiredIntents || []);
|
|
342
|
+
const addedIntents = [...currIntents].filter((i) => !prevIntents.has(i));
|
|
343
|
+
const removedIntents = [...prevIntents].filter((i) => !currIntents.has(i));
|
|
344
|
+
|
|
345
|
+
const prevFiles = new Set([
|
|
346
|
+
...(prev.files?.created || []).map((f) => f.path),
|
|
347
|
+
...(prev.files?.modified || []).map((f) => f.path),
|
|
348
|
+
]);
|
|
349
|
+
const currFiles = new Set([
|
|
350
|
+
...(current.files?.created || []).map((f) => f.path),
|
|
351
|
+
...(current.files?.modified || []).map((f) => f.path),
|
|
352
|
+
]);
|
|
353
|
+
const newFiles = [...currFiles].filter((f) => !prevFiles.has(f));
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
previousRunId: prev.runId,
|
|
357
|
+
previousTimestamp: prev.timestamp,
|
|
358
|
+
actionsAdded: addedIntents,
|
|
359
|
+
actionsRemoved: removedIntents,
|
|
360
|
+
newOrModifiedFiles: newFiles,
|
|
361
|
+
orbFirstInstall: prev.phases?.orb?.applied !== true && current.phases?.orb?.applied === true,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Build a structured record for one installer run from the phase results.
|
|
367
|
+
*/
|
|
368
|
+
function buildRunRecord(appRoot, ctx) {
|
|
369
|
+
const record = {
|
|
370
|
+
runId: makeRunId(),
|
|
371
|
+
timestamp: new Date().toISOString(),
|
|
372
|
+
cliVersion: CLI_VERSION,
|
|
373
|
+
appName: ctx.appName || path.basename(appRoot),
|
|
374
|
+
manifest: {
|
|
375
|
+
actionCount: (ctx.manifest?.actions || []).length,
|
|
376
|
+
routeCount: (ctx.manifest?.routes || []).length,
|
|
377
|
+
generatedAt: ctx.publishMeta?.generatedAt || null,
|
|
378
|
+
},
|
|
379
|
+
phases: {
|
|
380
|
+
orb: summarizeOrb(ctx.orbResult),
|
|
381
|
+
handlers: summarizeHandlers(ctx.handlerResult, appRoot),
|
|
382
|
+
registries: summarizeRegistries(ctx.registryResult),
|
|
383
|
+
env: summarizeEnv(ctx.envResult),
|
|
384
|
+
},
|
|
385
|
+
files: collectFileEntries(appRoot, ctx),
|
|
386
|
+
};
|
|
387
|
+
record.delta = computeDelta(appRoot, record);
|
|
388
|
+
return record;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function renderHeader() {
|
|
392
|
+
return [
|
|
393
|
+
'# Fiodos — change log',
|
|
394
|
+
'',
|
|
395
|
+
'Every time the Fiodos installer runs (initial install or re-analysis), an entry is **appended** below.',
|
|
396
|
+
'This is your transparency record: what Fiodos created or modified in your repo, file by file.',
|
|
397
|
+
'',
|
|
398
|
+
'- **Revert orb changes:** remove `FYODOS:ORB:START` … `FYODOS:ORB:END` blocks (see `FYODOS_ORB_MOUNT.md`).',
|
|
399
|
+
'- **Revert handler wiring:** delete `handlers.generated.*`, `bridge.*`, and `FYODOS:BRIDGE` blocks (see `FYODOS_HANDLERS.md`).',
|
|
400
|
+
'- **Machine-readable history:** `.fyodos/changes/` (one JSON file per run).',
|
|
401
|
+
'',
|
|
402
|
+
'No API keys or secrets appear in this log.',
|
|
403
|
+
'',
|
|
404
|
+
'---',
|
|
405
|
+
].join('\n');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderFileList(title, items) {
|
|
409
|
+
if (!items?.length) return [];
|
|
410
|
+
const L = [`### ${title}`, ''];
|
|
411
|
+
for (const f of items) {
|
|
412
|
+
L.push(`- \`${f.path}\`${f.intents?.length ? ` — actions: ${f.intents.map((i) => `\`${i}\``).join(', ')}` : ''}`);
|
|
413
|
+
if (f.what) L.push(` - ${f.what}`);
|
|
414
|
+
if (f.revert) L.push(` - Revert: ${f.revert}`);
|
|
415
|
+
}
|
|
416
|
+
L.push('');
|
|
417
|
+
return L;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function renderRunSection(record) {
|
|
421
|
+
const L = [];
|
|
422
|
+
L.push(`## Run ${record.timestamp}`);
|
|
423
|
+
L.push('');
|
|
424
|
+
L.push(`- CLI: \`@fiodos/cli@${record.cliVersion}\``);
|
|
425
|
+
L.push(`- App: \`${record.appName}\``);
|
|
426
|
+
L.push(`- Manifest: ${record.manifest.actionCount} action(s), ${record.manifest.routeCount} route(s)`);
|
|
427
|
+
L.push(`- Run id: \`${record.runId}\``);
|
|
428
|
+
L.push('');
|
|
429
|
+
|
|
430
|
+
if (record.delta) {
|
|
431
|
+
L.push('### Changes vs previous run');
|
|
432
|
+
L.push('');
|
|
433
|
+
if (record.delta.actionsAdded.length) {
|
|
434
|
+
L.push(`- Actions newly wired: ${record.delta.actionsAdded.map((i) => `\`${i}\``).join(', ')}`);
|
|
435
|
+
}
|
|
436
|
+
if (record.delta.actionsRemoved.length) {
|
|
437
|
+
L.push(`- Actions no longer wired: ${record.delta.actionsRemoved.map((i) => `\`${i}\``).join(', ')}`);
|
|
438
|
+
}
|
|
439
|
+
if (record.delta.newOrModifiedFiles.length) {
|
|
440
|
+
L.push(`- Files touched this run: ${record.delta.newOrModifiedFiles.map((f) => `\`${f}\``).join(', ')}`);
|
|
441
|
+
}
|
|
442
|
+
if (!record.delta.actionsAdded.length && !record.delta.actionsRemoved.length && !record.delta.newOrModifiedFiles.length) {
|
|
443
|
+
L.push('- No file changes compared to the previous run (re-analysis only updated the manifest/backend).');
|
|
444
|
+
}
|
|
445
|
+
L.push('');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const phases = [
|
|
449
|
+
['Orb mount', record.phases.orb],
|
|
450
|
+
['Action handlers', record.phases.handlers],
|
|
451
|
+
['Registry connection', record.phases.registries],
|
|
452
|
+
['Environment', record.phases.env],
|
|
453
|
+
];
|
|
454
|
+
for (const [label, ph] of phases) {
|
|
455
|
+
if (!ph || ph.status === 'skipped') continue;
|
|
456
|
+
L.push(`### ${label}`);
|
|
457
|
+
L.push('');
|
|
458
|
+
L.push(`- Status: \`${ph.status}\`${ph.applied ? ' (applied)' : ''}`);
|
|
459
|
+
if (ph.strategy) L.push(`- Strategy: \`${ph.strategy}\``);
|
|
460
|
+
if (ph.steps?.length) {
|
|
461
|
+
L.push('- Steps:');
|
|
462
|
+
for (const s of ph.steps) L.push(` - ${s}`);
|
|
463
|
+
}
|
|
464
|
+
if (ph.note) L.push(`- ${ph.note}`);
|
|
465
|
+
if (ph.revert) L.push(`- Revert: ${ph.revert}`);
|
|
466
|
+
L.push('');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
L.push(...renderFileList('Files created', record.files.created));
|
|
470
|
+
L.push(...renderFileList('Files modified', record.files.modified));
|
|
471
|
+
L.push(...renderFileList('Documentation generated', record.files.docs));
|
|
472
|
+
|
|
473
|
+
L.push('---');
|
|
474
|
+
return L.join('\n');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function briefSummary(record) {
|
|
478
|
+
const parts = [];
|
|
479
|
+
if (record.phases.orb.applied) parts.push('orb mounted');
|
|
480
|
+
if (record.phases.handlers.applied) parts.push(`${record.phases.handlers.autoCount} handlers wired`);
|
|
481
|
+
if (record.phases.registries.applied) parts.push('registries connected');
|
|
482
|
+
if (record.phases.env.applied) parts.push('env written');
|
|
483
|
+
const touched = record.files.created.length + record.files.modified.length;
|
|
484
|
+
if (!parts.length) parts.push('docs/analysis only');
|
|
485
|
+
return `${parts.join(', ')} · ${touched} file(s) touched`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Persist the run record: JSON snapshot + append to FYODOS_CHANGES.md + update index.
|
|
490
|
+
*/
|
|
491
|
+
function writeChangeRegistry(appRoot, runRecord) {
|
|
492
|
+
const changesDir = path.join(appRoot, '.fyodos', 'changes');
|
|
493
|
+
fs.mkdirSync(changesDir, { recursive: true });
|
|
494
|
+
|
|
495
|
+
const jsonAbs = path.join(changesDir, `${runRecord.runId}.json`);
|
|
496
|
+
fs.writeFileSync(jsonAbs, `${JSON.stringify(runRecord, null, 2)}\n`);
|
|
497
|
+
|
|
498
|
+
const fyodosDir = resolveFyodosDirRel(appRoot);
|
|
499
|
+
const mdAbs = path.join(appRoot, fyodosDir, 'FYODOS_CHANGES.md');
|
|
500
|
+
fs.mkdirSync(path.dirname(mdAbs), { recursive: true });
|
|
501
|
+
|
|
502
|
+
const section = renderRunSection(runRecord);
|
|
503
|
+
if (fs.existsSync(mdAbs)) {
|
|
504
|
+
const existing = fs.readFileSync(mdAbs, 'utf8').trimEnd();
|
|
505
|
+
fs.writeFileSync(mdAbs, `${existing}\n\n${section}\n`);
|
|
506
|
+
} else {
|
|
507
|
+
fs.writeFileSync(mdAbs, `${renderHeader()}\n\n${section}\n`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const indexAbs = path.join(changesDir, 'index.json');
|
|
511
|
+
const index = readJsonSafe(indexAbs) || { runs: [] };
|
|
512
|
+
index.runs.unshift({
|
|
513
|
+
runId: runRecord.runId,
|
|
514
|
+
timestamp: runRecord.timestamp,
|
|
515
|
+
cliVersion: runRecord.cliVersion,
|
|
516
|
+
jsonFile: `${runRecord.runId}.json`,
|
|
517
|
+
summary: briefSummary(runRecord),
|
|
518
|
+
});
|
|
519
|
+
fs.writeFileSync(indexAbs, `${JSON.stringify(index, null, 2)}\n`);
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
mdRel: normRel(appRoot, mdAbs),
|
|
523
|
+
jsonRel: normRel(appRoot, jsonAbs),
|
|
524
|
+
indexRel: normRel(appRoot, indexAbs),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
module.exports = {
|
|
529
|
+
buildRunRecord,
|
|
530
|
+
writeChangeRegistry,
|
|
531
|
+
resolveFyodosDirRel,
|
|
532
|
+
renderRunSection,
|
|
533
|
+
briefSummary,
|
|
534
|
+
};
|
package/src/index.js
CHANGED
|
@@ -97,6 +97,7 @@ const { wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResu
|
|
|
97
97
|
const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
|
|
98
98
|
const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
|
|
99
99
|
const { scoreSurface, computeSurface } = require('./scoreSurface');
|
|
100
|
+
const { buildRunRecord, writeChangeRegistry } = require('./changeRegistry');
|
|
100
101
|
|
|
101
102
|
function arg(flag, fallback) {
|
|
102
103
|
const i = process.argv.indexOf(flag);
|
|
@@ -503,6 +504,9 @@ async function main() {
|
|
|
503
504
|
const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
|
|
504
505
|
const noWire = process.argv.includes('--no-wire');
|
|
505
506
|
let envWriteResult = null;
|
|
507
|
+
let orbWireResult = null;
|
|
508
|
+
let handlerWireResult = null;
|
|
509
|
+
let registryWireResult = null;
|
|
506
510
|
const { runPostWireTest } = require('./postWireTest');
|
|
507
511
|
const selfCorrect = !process.argv.includes('--no-self-correct');
|
|
508
512
|
const corrector = selfCorrect
|
|
@@ -549,6 +553,7 @@ async function main() {
|
|
|
549
553
|
noWire,
|
|
550
554
|
runTest: !process.argv.includes('--no-wire-test'),
|
|
551
555
|
});
|
|
556
|
+
orbWireResult = result;
|
|
552
557
|
if (!assumeYes) resume();
|
|
553
558
|
report(() => reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
554
559
|
orbMounted = result.status === 'added' || result.status === 'already';
|
|
@@ -576,6 +581,7 @@ async function main() {
|
|
|
576
581
|
setLabel(wiringSpinnerLabel(platform));
|
|
577
582
|
if (!assumeYes) pause();
|
|
578
583
|
const handlerResult = await wireHandlers(analysisRoot, handlerOpts);
|
|
584
|
+
handlerWireResult = handlerResult;
|
|
579
585
|
if (!assumeYes) resume();
|
|
580
586
|
report(() => reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
581
587
|
|
|
@@ -593,6 +599,7 @@ async function main() {
|
|
|
593
599
|
const connected = connectOrbRegistries(analysisRoot, {
|
|
594
600
|
registryFileAbs: handlerResult.registryFile,
|
|
595
601
|
});
|
|
602
|
+
registryWireResult = connected;
|
|
596
603
|
report(() => reportRegistriesResult(connected, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
597
604
|
}
|
|
598
605
|
|
|
@@ -650,6 +657,27 @@ async function main() {
|
|
|
650
657
|
logDev(`[write-env] skipped: ${e.message}`);
|
|
651
658
|
}
|
|
652
659
|
}
|
|
660
|
+
|
|
661
|
+
// ── Change registry (transparency): record every file Fiodos touched ───────
|
|
662
|
+
// Appends to FYODOS_CHANGES.md and writes .fyodos/changes/<runId>.json.
|
|
663
|
+
// Runs after orb + handler + env so the record reflects the full pass.
|
|
664
|
+
if (!noWire) {
|
|
665
|
+
try {
|
|
666
|
+
const record = buildRunRecord(analysisRoot, {
|
|
667
|
+
appName: manifest.appName,
|
|
668
|
+
manifest,
|
|
669
|
+
publishMeta,
|
|
670
|
+
orbResult: orbWireResult,
|
|
671
|
+
handlerResult: handlerWireResult,
|
|
672
|
+
registryResult: registryWireResult,
|
|
673
|
+
envResult: envWriteResult,
|
|
674
|
+
});
|
|
675
|
+
const paths = writeChangeRegistry(analysisRoot, record);
|
|
676
|
+
logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
|
|
677
|
+
} catch (e) {
|
|
678
|
+
logDev(`[change-registry] could not write: ${e.message}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
653
681
|
} else if (publishing) {
|
|
654
682
|
loadAppEnv(analysisRoot);
|
|
655
683
|
await withSpinner('Publishing to your project', () =>
|
|
@@ -711,9 +739,32 @@ function detectPlatform(appRoot, appDir) {
|
|
|
711
739
|
) {
|
|
712
740
|
return 'web';
|
|
713
741
|
}
|
|
742
|
+
// The Fiodos SDK the developer installed states their target platform — the
|
|
743
|
+
// strongest intent signal in a mixed monorepo whose root has no framework dep
|
|
744
|
+
// of its own (e.g. EVO: root has @fiodos/react-native, the real Expo app lives
|
|
745
|
+
// in mobile/mobile/). Without this we'd wrongly default a mobile repo to 'web'
|
|
746
|
+
// and run the web orb wiring against a sibling Next.js app.
|
|
747
|
+
const sdkPlatform = fiodosSdkPlatform(deps);
|
|
748
|
+
if (sdkPlatform) return sdkPlatform;
|
|
714
749
|
return appDir ? 'mobile' : 'web';
|
|
715
750
|
}
|
|
716
751
|
|
|
752
|
+
/**
|
|
753
|
+
* Platform implied by the installed Fiodos SDK. Returns 'mobile' | 'web' | null
|
|
754
|
+
* (null when both or neither are present — no unambiguous signal).
|
|
755
|
+
*/
|
|
756
|
+
function fiodosSdkPlatform(deps) {
|
|
757
|
+
if (!deps) return null;
|
|
758
|
+
const mobile = Boolean(deps['@fiodos/react-native']);
|
|
759
|
+
const web = Boolean(
|
|
760
|
+
deps['@fiodos/react'] || deps['@fiodos/vue'] || deps['@fiodos/svelte'] ||
|
|
761
|
+
deps['@fiodos/angular'] || deps['@fiodos/web-core'],
|
|
762
|
+
);
|
|
763
|
+
if (mobile && !web) return 'mobile';
|
|
764
|
+
if (web && !mobile) return 'web';
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
717
768
|
function readAppMeta(appRoot) {
|
|
718
769
|
const meta = { name: path.basename(appRoot), description: '', dependencies: [] };
|
|
719
770
|
const pkgPath = path.join(appRoot, 'package.json');
|
package/src/postWireTest.js
CHANGED
|
@@ -103,6 +103,30 @@ function tail(s, n = 60) {
|
|
|
103
103
|
return lines.slice(-n).join('\n').trim();
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Compact build failure for the terminal — not the full next/vite log. */
|
|
107
|
+
function summarizeBuildOutput(raw, maxLines = 4) {
|
|
108
|
+
const lines = String(raw || '').split('\n');
|
|
109
|
+
const picked = [];
|
|
110
|
+
const push = (line) => {
|
|
111
|
+
const t = line.trim();
|
|
112
|
+
if (!t || picked.includes(t)) return;
|
|
113
|
+
if (/telemetry|Learn more:/i.test(t)) return;
|
|
114
|
+
if (/^▲ Next\.js/i.test(t)) return;
|
|
115
|
+
picked.push(t);
|
|
116
|
+
};
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (
|
|
119
|
+
/Module not found|Can't resolve|Failed to compile|error TS\d+|SyntaxError|ENOENT/i.test(line)
|
|
120
|
+
) {
|
|
121
|
+
push(line);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (picked.length === 0) {
|
|
125
|
+
for (const line of lines.slice(-maxLines)) push(line);
|
|
126
|
+
}
|
|
127
|
+
return picked.slice(0, maxLines).join('\n').trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
106
130
|
async function runPostWireTest(appRoot, opts = {}) {
|
|
107
131
|
const timeoutMs = opts.timeoutMs || 240000;
|
|
108
132
|
if (!fs.existsSync(path.join(appRoot, 'node_modules'))) {
|
|
@@ -136,4 +160,4 @@ async function runPostWireTest(appRoot, opts = {}) {
|
|
|
136
160
|
return { ok: res.status === 0, stage: chosen.stage, command: chosen.label, output };
|
|
137
161
|
}
|
|
138
162
|
|
|
139
|
-
module.exports = { runPostWireTest, chooseCommand };
|
|
163
|
+
module.exports = { runPostWireTest, chooseCommand, summarizeBuildOutput, readPkg };
|
|
@@ -51,12 +51,49 @@ function platformDepsMatch(deps, platform) {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* How "complete" / runnable a candidate app folder looks. A monorepo can nest a
|
|
56
|
+
* thin source wrapper (e.g. mobile/) around the REAL runnable app (mobile/mobile/
|
|
57
|
+
* with app.json, the expo dep, native ios/android dirs). Conventional-name
|
|
58
|
+
* matching alone would pick the wrapper; this score lets the real app win.
|
|
59
|
+
*/
|
|
60
|
+
function appCompleteness(dir, platform) {
|
|
61
|
+
let score = 0;
|
|
62
|
+
const has = (rel) => fs.existsSync(path.join(dir, rel));
|
|
63
|
+
if (has('app') || has('src') || has('pages')) score += 2;
|
|
64
|
+
let pkg = {};
|
|
65
|
+
try {
|
|
66
|
+
pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
71
|
+
const scripts = pkg.scripts || {};
|
|
72
|
+
if (pkg.main) score += 2;
|
|
73
|
+
if (platform === 'mobile') {
|
|
74
|
+
if (has('app.json') || has('app.config.js') || has('app.config.ts')) score += 4;
|
|
75
|
+
if (deps.expo) score += 4;
|
|
76
|
+
if (has('ios') || has('android')) score += 2;
|
|
77
|
+
if (scripts.start || scripts.ios || scripts.android) score += 1;
|
|
78
|
+
} else if (platform === 'web') {
|
|
79
|
+
if (
|
|
80
|
+
has('next.config.js') || has('next.config.ts') || has('next.config.mjs') ||
|
|
81
|
+
has('vite.config.js') || has('vite.config.ts') || has('index.html') ||
|
|
82
|
+
has('angular.json') || has('svelte.config.js')
|
|
83
|
+
) {
|
|
84
|
+
score += 4;
|
|
85
|
+
}
|
|
86
|
+
if (scripts.build || scripts.dev) score += 1;
|
|
87
|
+
}
|
|
88
|
+
return score;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tryCandidate(appRoot, rel, platform, nameScore) {
|
|
55
92
|
const dir = path.join(appRoot, rel);
|
|
56
93
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null;
|
|
57
94
|
const deps = readDeps(path.join(dir, 'package.json'));
|
|
58
95
|
if (!platformDepsMatch(deps, platform)) return null;
|
|
59
|
-
return { root: dir, rel,
|
|
96
|
+
return { root: dir, rel, nameScore, complete: appCompleteness(dir, platform), reason: rel };
|
|
60
97
|
}
|
|
61
98
|
|
|
62
99
|
/**
|
|
@@ -112,8 +149,15 @@ function resolveAnalysisRoot(appRoot, platform) {
|
|
|
112
149
|
return { root: abs, scoped: false, reason: 'no sub-app detected' };
|
|
113
150
|
}
|
|
114
151
|
|
|
115
|
-
// Prefer
|
|
116
|
-
|
|
152
|
+
// Prefer the most complete/runnable app first (so a real nested app beats a
|
|
153
|
+
// thin same-named wrapper), then the conventional-name confidence, then the
|
|
154
|
+
// shorter path (closer to root).
|
|
155
|
+
candidates.sort(
|
|
156
|
+
(a, b) =>
|
|
157
|
+
b.complete - a.complete ||
|
|
158
|
+
b.nameScore - a.nameScore ||
|
|
159
|
+
a.rel.length - b.rel.length,
|
|
160
|
+
);
|
|
117
161
|
const best = candidates[0];
|
|
118
162
|
return {
|
|
119
163
|
root: best.root,
|
package/src/verify.js
CHANGED
|
@@ -31,6 +31,63 @@ function fileHasIdentifier(content, identifier) {
|
|
|
31
31
|
return re.test(content);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Upper bound on examples kept per route/action. The enriched prompt asks the
|
|
36
|
+
* model for 5-8 varied phrasings; this cap protects the cacheable system prompt
|
|
37
|
+
* from a runaway list while leaving generous room for real synonyms.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_EXAMPLES = 10;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sanitize a manifest item's `examples` array WITHOUT ever dropping the item:
|
|
43
|
+
* trims, removes empties, deduplicates case-insensitively (first spelling wins),
|
|
44
|
+
* and caps the count. Fail-open: when the model returned nothing usable, falls
|
|
45
|
+
* back to a single phrase derived from the label/intent so the core validator's
|
|
46
|
+
* "non-empty examples" rule still passes (today's contract, never weakened).
|
|
47
|
+
*/
|
|
48
|
+
function sanitizeExamples(raw, fallbackLabel) {
|
|
49
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const cleaned = [];
|
|
52
|
+
for (const entry of list) {
|
|
53
|
+
if (typeof entry !== 'string') continue;
|
|
54
|
+
const phrase = entry.replace(/\s+/g, ' ').trim();
|
|
55
|
+
if (!phrase) continue;
|
|
56
|
+
const key = phrase.toLowerCase();
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
cleaned.push(phrase);
|
|
60
|
+
if (cleaned.length >= MAX_EXAMPLES) break;
|
|
61
|
+
}
|
|
62
|
+
if (cleaned.length === 0) {
|
|
63
|
+
const fallback = String(fallbackLabel || '').replace(/\s+/g, ' ').trim();
|
|
64
|
+
if (fallback) cleaned.push(fallback);
|
|
65
|
+
}
|
|
66
|
+
return cleaned;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Max length (chars) of a route's `bubblePrompt` (the short orb CTA). The prompt
|
|
71
|
+
* asks for ~6 words; this is a generous hard cap so a runaway sentence can never
|
|
72
|
+
* bloat the bubble UI or the served manifest.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_BUBBLE_PROMPT = 80;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sanitize a route's `bubblePrompt` (fail-open, item never dropped): trims,
|
|
78
|
+
* collapses whitespace, strips wrapping quotes, and caps the length. Returns
|
|
79
|
+
* undefined when there is nothing usable (the SDK then derives a fallback from
|
|
80
|
+
* the label/examples), so a bad value never sticks.
|
|
81
|
+
*/
|
|
82
|
+
function sanitizeBubblePrompt(raw) {
|
|
83
|
+
if (typeof raw !== 'string') return undefined;
|
|
84
|
+
let s = raw.replace(/\s+/g, ' ').trim();
|
|
85
|
+
s = s.replace(/^["'«»“”]+|["'«»“”]+$/g, '').trim();
|
|
86
|
+
if (!s) return undefined;
|
|
87
|
+
if (s.length > MAX_BUBBLE_PROMPT) s = s.slice(0, MAX_BUBBLE_PROMPT).trim();
|
|
88
|
+
return s || undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
/** Normalize an expo-router href for comparison: strip (groups), trailing /index. */
|
|
35
92
|
function normalizeHref(href) {
|
|
36
93
|
return ('/' + String(href || '')
|
|
@@ -179,8 +236,51 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
179
236
|
return true;
|
|
180
237
|
});
|
|
181
238
|
|
|
239
|
+
// Layer 2 — "what you can do here": keep each route's actionIntents fail-open.
|
|
240
|
+
// Only intents that survived action verification stay (a dangling intent
|
|
241
|
+
// pointing at a dropped/hallucinated action is silently removed, deduped).
|
|
242
|
+
// Empty/invalid → the field is removed entirely (Layer 1 still works).
|
|
243
|
+
const keptActionIntents = new Set(dedupedActions.map((a) => a.intent));
|
|
244
|
+
for (const route of dedupedRoutes) {
|
|
245
|
+
if (!Array.isArray(route.actionIntents)) {
|
|
246
|
+
delete route.actionIntents;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const seenIntent = new Set();
|
|
250
|
+
const cleaned = route.actionIntents.filter(
|
|
251
|
+
(i) => typeof i === 'string' && keptActionIntents.has(i) && !seenIntent.has(i) && seenIntent.add(i),
|
|
252
|
+
);
|
|
253
|
+
if (cleaned.length > 0) route.actionIntents = cleaned;
|
|
254
|
+
else delete route.actionIntents;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Examples hygiene (fail-open): trim, dedupe and cap the enriched phrasings on
|
|
258
|
+
// every surviving route and action. Never drops the item; falls back to the
|
|
259
|
+
// label so the core validator's non-empty-examples rule still holds.
|
|
260
|
+
for (const route of dedupedRoutes) {
|
|
261
|
+
if (route.route === 'BACK') continue;
|
|
262
|
+
route.examples = sanitizeExamples(route.examples, route.label || route.intent);
|
|
263
|
+
let bubble = sanitizeBubblePrompt(route.bubblePrompt);
|
|
264
|
+
if (!bubble) {
|
|
265
|
+
try {
|
|
266
|
+
const { deriveBubblePromptFromContext, resolveMessages } = require('@fiodos/core');
|
|
267
|
+
bubble = sanitizeBubblePrompt(
|
|
268
|
+
deriveBubblePromptFromContext(route, resolveMessages('en'), {
|
|
269
|
+
locale: 'en',
|
|
270
|
+
actions: dedupedActions,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
} catch {
|
|
274
|
+
/* core unavailable — leave bubble unset */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (bubble) route.bubblePrompt = bubble;
|
|
278
|
+
else delete route.bubblePrompt;
|
|
279
|
+
}
|
|
280
|
+
|
|
182
281
|
// Schema coercion the model sometimes needs (same as the old engine).
|
|
183
282
|
for (const action of dedupedActions) {
|
|
283
|
+
action.examples = sanitizeExamples(action.examples, action.label || action.intent);
|
|
184
284
|
for (const spec of Object.values(action.parameters || {})) {
|
|
185
285
|
if (spec.type === 'integer' || spec.type === 'float') spec.type = 'number';
|
|
186
286
|
else if (!['string', 'number', 'boolean'].includes(spec.type)) spec.type = 'string';
|
|
@@ -199,4 +299,4 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
199
299
|
};
|
|
200
300
|
}
|
|
201
301
|
|
|
202
|
-
module.exports = { verifyManifest, normalizeHref };
|
|
302
|
+
module.exports = { verifyManifest, normalizeHref, sanitizeExamples, MAX_EXAMPLES };
|
package/src/wireWeb.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const readline = require('readline');
|
|
16
|
-
const { runPostWireTest } = require('./postWireTest');
|
|
16
|
+
const { runPostWireTest, summarizeBuildOutput, readPkg } = require('./postWireTest');
|
|
17
17
|
const {
|
|
18
18
|
assessMount,
|
|
19
19
|
describePlan,
|
|
@@ -32,6 +32,16 @@ const {
|
|
|
32
32
|
WRAPPER_BASENAME,
|
|
33
33
|
} = require('./wireWebMount');
|
|
34
34
|
|
|
35
|
+
function planSummary(appRoot, plan, createdFiles) {
|
|
36
|
+
const rel = (f) => path.relative(appRoot, f).replace(/\\/g, '/');
|
|
37
|
+
return {
|
|
38
|
+
strategy: plan.strategy,
|
|
39
|
+
files: (plan.files || []).map(rel),
|
|
40
|
+
created: (createdFiles || []).map(rel),
|
|
41
|
+
steps: describePlan(plan),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
function readJsonSafe(file) {
|
|
36
46
|
try {
|
|
37
47
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -163,8 +173,49 @@ function askYesNo(question) {
|
|
|
163
173
|
});
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
const WEB_SDK_BY_FRAMEWORK = {
|
|
177
|
+
vue: '@fiodos/vue',
|
|
178
|
+
svelte: '@fiodos/svelte',
|
|
179
|
+
sveltekit: '@fiodos/svelte',
|
|
180
|
+
angular: '@fiodos/angular',
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function expectedWebSdk(framework) {
|
|
184
|
+
return WEB_SDK_BY_FRAMEWORK[framework] || '@fiodos/react';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Detects the one misconfiguration that silently breaks the build: the developer
|
|
189
|
+
* installed the MOBILE SDK (@fiodos/react-native) in a web app. The mount writes
|
|
190
|
+
* `import { FiodosAgent } from '@fiodos/react'` (or the framework SDK), so the
|
|
191
|
+
* bundler then dies on "Can't resolve". We catch it up front and print a short,
|
|
192
|
+
* actionable note instead of dumping the whole build log.
|
|
193
|
+
*
|
|
194
|
+
* If no Fiodos SDK is installed at all we stay silent and let the mount proceed
|
|
195
|
+
* (the dashboard install step / post-wire build covers that path), so this never
|
|
196
|
+
* blocks a clean integration that simply hasn't run npm install yet.
|
|
197
|
+
*/
|
|
198
|
+
function webSdkMissing(appRoot, framework) {
|
|
199
|
+
const pkg = readPkg(appRoot);
|
|
200
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
201
|
+
const expected = expectedWebSdk(framework);
|
|
202
|
+
if (deps[expected] || deps['@fiodos/web-core']) return null;
|
|
203
|
+
if (deps['@fiodos/react-native']) {
|
|
204
|
+
return (
|
|
205
|
+
`installed @fiodos/react-native, but this web app needs ${expected} — ` +
|
|
206
|
+
`run npm install ${expected} @fiodos/core`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
166
212
|
function printSnippet(target, framework, colors, appRoot, reason) {
|
|
167
213
|
const { blue, cyan, dim, reset } = colors;
|
|
214
|
+
if (reason && reason.includes('@fiodos/react-native')) {
|
|
215
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}${reason}${reset}`);
|
|
216
|
+
console.error(`${dim}Then re-run analyze — the orb mount will be automatic.${reset}\n`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
168
219
|
const ts = !target || target.ext === '.tsx';
|
|
169
220
|
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}add the orb to your web app (copy this):${reset}`);
|
|
170
221
|
if (reason) {
|
|
@@ -242,6 +293,12 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
242
293
|
return { status: 'printed', reason: plan.reason };
|
|
243
294
|
}
|
|
244
295
|
|
|
296
|
+
const sdkGap = webSdkMissing(appRoot, framework);
|
|
297
|
+
if (sdkGap) {
|
|
298
|
+
printSnippet(target, framework, colors, appRoot, sdkGap);
|
|
299
|
+
return { status: 'printed', reason: sdkGap };
|
|
300
|
+
}
|
|
301
|
+
|
|
245
302
|
if (!assumeYes) {
|
|
246
303
|
printMountPreview(plan, colors);
|
|
247
304
|
}
|
|
@@ -295,7 +352,7 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
295
352
|
const test = await build();
|
|
296
353
|
if (test.ok || test.stage === 'skipped-no-deps') {
|
|
297
354
|
writeConsentDoc(appRoot, plan);
|
|
298
|
-
return { status: 'added', file: plan.rel, test };
|
|
355
|
+
return { status: 'added', file: plan.rel, test, planSummary: planSummary(appRoot, plan, createdFiles) };
|
|
299
356
|
}
|
|
300
357
|
// Build failed with the orb mounted — was the app building WITHOUT it?
|
|
301
358
|
revertAll();
|
|
@@ -303,17 +360,22 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
303
360
|
if (baseline.ok) {
|
|
304
361
|
// Built before, fails now → the mount is the regression. Keep it reverted.
|
|
305
362
|
printSnippet(target, framework, colors, appRoot, `mount caused a build regression — reverted (${test.command})`);
|
|
306
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
status: 'reverted',
|
|
365
|
+
file: plan.rel,
|
|
366
|
+
test,
|
|
367
|
+
reason: summarizeBuildOutput(test.output),
|
|
368
|
+
};
|
|
307
369
|
}
|
|
308
370
|
// It was already not building (or not verifiable) before us → keep the orb.
|
|
309
371
|
applyMount(plan);
|
|
310
372
|
verifyMounted(plan);
|
|
311
373
|
writeConsentDoc(appRoot, plan);
|
|
312
|
-
return { status: 'added', file: plan.rel, test, preexistingFailure: true };
|
|
374
|
+
return { status: 'added', file: plan.rel, test, preexistingFailure: true, planSummary: planSummary(appRoot, plan, createdFiles) };
|
|
313
375
|
}
|
|
314
376
|
|
|
315
377
|
writeConsentDoc(appRoot, plan);
|
|
316
|
-
return { status: 'added', file: plan.rel };
|
|
378
|
+
return { status: 'added', file: plan.rel, planSummary: planSummary(appRoot, plan, createdFiles) };
|
|
317
379
|
} catch (err) {
|
|
318
380
|
revertAll();
|
|
319
381
|
printSnippet(target, framework, colors, appRoot, err.message || String(err));
|
|
@@ -338,8 +400,11 @@ function reportWireResult(result, colors = {}, opts = {}) {
|
|
|
338
400
|
console.error(`${tag} · ${dim || ''}automatic mount not possible${result.reason ? `: ${result.reason}` : ''}. Paste the snippet above.${reset || ''}`);
|
|
339
401
|
break;
|
|
340
402
|
case 'reverted':
|
|
341
|
-
console.error(`${tag} · ✗ mount reverted — app would not build
|
|
342
|
-
if (result.reason)
|
|
403
|
+
console.error(`${tag} · ✗ mount reverted — app would not build.`);
|
|
404
|
+
if (result.reason) {
|
|
405
|
+
console.error(`${dim}${result.reason}${reset}`);
|
|
406
|
+
}
|
|
407
|
+
console.error(`${tag} · ${dim || ''}Fix the issue above, then re-run analyze or paste the snippet.${reset || ''}`);
|
|
343
408
|
break;
|
|
344
409
|
case 'failed':
|
|
345
410
|
console.error(`${tag} · ✗ could not safely edit ${result.file}. Paste the snippet above.`);
|