@fiodos/cli 0.1.25 → 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 +1 -1
- package/src/aiAnalyze.js +6 -4
- package/src/changeRegistry.js +55 -1
- package/src/collect.js +16 -2
- package/src/index.js +367 -5
- package/src/shopifyTheme.js +300 -0
- package/src/verify.js +124 -1
- package/src/wireHandlers.js +93 -7
- package/src/wireScreen.js +562 -0
- package/src/wireSession.js +147 -11
- package/src/wireWeb.js +115 -2
- package/src/wireWebMount.js +98 -14
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireScreen — automatic SCREEN-TRUTH wiring for apps whose screens are NOT
|
|
3
|
+
* URLs. NO MANUAL STEP.
|
|
4
|
+
*
|
|
5
|
+
* WHY: the orb resolves "where is the user right now" from
|
|
6
|
+
* `navigation.getCurrentRoute()` — on the web that is the pathname. Many apps
|
|
7
|
+
* switch screens through CLIENT STATE instead (a `useState` section switcher,
|
|
8
|
+
* tabs, a dashboard shell): the URL never changes, so the orb is blind — the
|
|
9
|
+
* per-screen bubble never updates, "you are already there" detection fails and
|
|
10
|
+
* screen-scoped context is wrong. This module closes that gap exactly like
|
|
11
|
+
* session wiring closed the auth gap: the SAME AI that read the app's code
|
|
12
|
+
* says WHERE the active-screen state lives, a mechanical verifier confirms
|
|
13
|
+
* every referenced symbol exists, the edit is applied under the same consent,
|
|
14
|
+
* and a build check reverts it if anything regressed.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT PRODUCES:
|
|
17
|
+
* · `src/fyodos/screen.generated.<ext>` exporting:
|
|
18
|
+
* - fyodosGetCurrentScreen(): string | null (the app-reported active
|
|
19
|
+
* screen key, or null when unknown/stale)
|
|
20
|
+
* - fyodosGetCurrentRoute(): string | null (URL-shaped current route
|
|
21
|
+
* COMBINING pathname + reported screen — what the orb mount consumes
|
|
22
|
+
* as `getCurrentRoute`)
|
|
23
|
+
* · A tiny reversible registration in the component that OWNS the screen
|
|
24
|
+
* state (markers FYODOS:SCREEN:START/END): on every render it re-registers
|
|
25
|
+
* a getter closing over the CURRENT screen value, plus the pathname where
|
|
26
|
+
* the report was made — so a report from another page is mechanically
|
|
27
|
+
* detected as stale and ignored (truth of screen, never a stale claim).
|
|
28
|
+
*
|
|
29
|
+
* HONESTY:
|
|
30
|
+
* · Unknown beats wrong: when the owning component has not rendered (or the
|
|
31
|
+
* user left that page), the module returns the plain pathname — exactly
|
|
32
|
+
* today's behavior, never a stale screen claim.
|
|
33
|
+
* · Everything the AI proposed is verified mechanically before writing;
|
|
34
|
+
* unverifiable → an honest 'review' report, never a blind write.
|
|
35
|
+
*/
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
fileHasSymbol,
|
|
43
|
+
freeIdentifiers,
|
|
44
|
+
declaredLocals,
|
|
45
|
+
detachedInstanceReceiver,
|
|
46
|
+
countOccurrences,
|
|
47
|
+
readFileSafe,
|
|
48
|
+
normRel,
|
|
49
|
+
relImport,
|
|
50
|
+
detectTsEsm,
|
|
51
|
+
resolveFiodosDirRel,
|
|
52
|
+
askYesNo,
|
|
53
|
+
JS_KEYWORDS,
|
|
54
|
+
SAFE_GLOBALS,
|
|
55
|
+
GENERATED_FILE_DIRECTIVES,
|
|
56
|
+
INJECT_ESLINT_DISABLE,
|
|
57
|
+
INJECT_ESLINT_ENABLE,
|
|
58
|
+
BRIDGE_BASENAME,
|
|
59
|
+
buildBridgeModule,
|
|
60
|
+
insertImportBlock,
|
|
61
|
+
insertRegisterBlock,
|
|
62
|
+
deriveBridgeAnchor,
|
|
63
|
+
} = require('./wireHandlers');
|
|
64
|
+
|
|
65
|
+
const SCREEN_BASENAME = 'screen.generated';
|
|
66
|
+
const DOC_BASENAME = 'FYODOS_SCREEN.md';
|
|
67
|
+
const SCREEN_EXPORT = 'fyodosGetCurrentScreen';
|
|
68
|
+
const ROUTE_EXPORT = 'fyodosGetCurrentRoute';
|
|
69
|
+
const BRIDGE_SCREEN = '__fyodos_screen';
|
|
70
|
+
|
|
71
|
+
// Own markers: wireHandlers strips FYODOS:BRIDGE blocks and wireSession strips
|
|
72
|
+
// FYODOS:SESSION blocks — sharing either would make a re-run of one silently
|
|
73
|
+
// delete the other's registration in the same component.
|
|
74
|
+
const SCREEN_EDIT_START_PREFIX = '// FYODOS:SCREEN:START';
|
|
75
|
+
const SCREEN_EDIT_START = `${SCREEN_EDIT_START_PREFIX} (generated by Fiodos — safe to remove)`;
|
|
76
|
+
const SCREEN_EDIT_END = '// FYODOS:SCREEN:END';
|
|
77
|
+
|
|
78
|
+
function esc(s) {
|
|
79
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Remove previously-inserted screen blocks (idempotence across re-runs). */
|
|
83
|
+
function stripScreenBlocks(content) {
|
|
84
|
+
const re = new RegExp(
|
|
85
|
+
`\\n?${esc(SCREEN_EDIT_START_PREFIX)}[^\\n]*[\\s\\S]*?${esc(SCREEN_EDIT_END)}\\n?`,
|
|
86
|
+
'g',
|
|
87
|
+
);
|
|
88
|
+
return content.replace(re, '\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Mechanical verification of the AI's screen proposal ────────────────────────
|
|
92
|
+
|
|
93
|
+
function verifyImports({ appRoot, rawImports, importBaseDirRel }) {
|
|
94
|
+
const imports = [];
|
|
95
|
+
const importedNames = new Set();
|
|
96
|
+
for (const imp of Array.isArray(rawImports) ? rawImports : []) {
|
|
97
|
+
const name = String((imp && imp.name) || '').trim();
|
|
98
|
+
const from = normRel(imp && imp.from);
|
|
99
|
+
const kind = (imp && imp.kind) || 'named';
|
|
100
|
+
if (!name || !from) return { reason: 'a screen import is incomplete (missing name/from)' };
|
|
101
|
+
if (!fs.existsSync(path.join(appRoot, from))) {
|
|
102
|
+
return { reason: `the screen import points to a non-existent file: '${from}'` };
|
|
103
|
+
}
|
|
104
|
+
if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) {
|
|
105
|
+
return { reason: `named import '${name}' does not exist in '${from}'` };
|
|
106
|
+
}
|
|
107
|
+
imports.push({ kind, name, importPath: relImport(importBaseDirRel, from) });
|
|
108
|
+
importedNames.add(name);
|
|
109
|
+
}
|
|
110
|
+
return { imports, importedNames };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function verifyExpr({ expr, importedNames, extraOk = new Set() }) {
|
|
114
|
+
const detached = detachedInstanceReceiver(expr);
|
|
115
|
+
if (detached) {
|
|
116
|
+
return `the screen expression instantiates '${detached}' with 'new' and uses it as the receiver; a fresh instance is disconnected from the app's real screen state`;
|
|
117
|
+
}
|
|
118
|
+
const locals = declaredLocals(expr);
|
|
119
|
+
for (const id of freeIdentifiers(expr)) {
|
|
120
|
+
if (JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id) || importedNames.has(id) || locals.has(id) || extraOk.has(id)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
return `the screen expression references '${id}', which could not be verified in your code`;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Turn the AI's `screen` block into a verified plan, or { none } / { reason }.
|
|
130
|
+
* Plan shape: { bridge: { file, anchor, scope, imports, screenInvoke } }.
|
|
131
|
+
* (`kind: 'url'` — or no block at all — means screens ARE URLs and nothing
|
|
132
|
+
* needs wiring: today's pathname behavior is already the truth.)
|
|
133
|
+
*/
|
|
134
|
+
function prepareScreenPlan({ appRoot, screen, fyodosDirRel }) {
|
|
135
|
+
if (!screen || typeof screen !== 'object') {
|
|
136
|
+
return { none: true, reason: 'no screen block — screens are assumed URL-driven' };
|
|
137
|
+
}
|
|
138
|
+
if (screen.kind !== 'state') {
|
|
139
|
+
return { none: true, reason: String(screen.reason || 'screens are URL-driven') };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const required = Array.isArray(screen.requiredSymbols) ? screen.requiredSymbols : [];
|
|
143
|
+
for (const r of required) {
|
|
144
|
+
if (!fileHasSymbol(appRoot, r && r.file, r && r.name)) {
|
|
145
|
+
return {
|
|
146
|
+
reason: `the verifier did not find symbol '${r && r.name}' in '${normRel(r && r.file)}'`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const b = screen.bridge || {};
|
|
152
|
+
const file = normRel(b.file);
|
|
153
|
+
let anchor = String(b.anchor || '').trim();
|
|
154
|
+
const screenInvoke = String(b.screenInvoke || '').trim();
|
|
155
|
+
|
|
156
|
+
if (!file) return { reason: 'the AI marked state-driven screens but gave no component file' };
|
|
157
|
+
const content = readFileSafe(appRoot, file);
|
|
158
|
+
if (content == null) return { reason: `could not read the component '${file}'` };
|
|
159
|
+
if (!screenInvoke) return { reason: `no screen expression was provided for '${file}'` };
|
|
160
|
+
|
|
161
|
+
// Anchor ladder: exact-unique AI anchor, else derive one deterministically
|
|
162
|
+
// from the declaration of the symbol the invoke reads (same rescue as
|
|
163
|
+
// handler/session bridges). Never wire on an ambiguous insertion point.
|
|
164
|
+
const cleanContent = stripScreenBlocks(content);
|
|
165
|
+
const occ = anchor ? countOccurrences(cleanContent, anchor) : 0;
|
|
166
|
+
if (occ !== 1) {
|
|
167
|
+
const derived = deriveBridgeAnchor(cleanContent, screenInvoke);
|
|
168
|
+
if (derived) anchor = derived;
|
|
169
|
+
else if (!anchor || occ === 0) return { reason: `no safe insertion point was found in '${file}'` };
|
|
170
|
+
else return { reason: `the insertion anchor appears ${occ} times in '${file}'; it is ambiguous` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const v = verifyImports({ appRoot, rawImports: b.imports, importBaseDirRel: path.dirname(file) });
|
|
174
|
+
if (v.reason) return v;
|
|
175
|
+
|
|
176
|
+
const inFileOk = new Set();
|
|
177
|
+
for (const id of freeIdentifiers(screenInvoke)) {
|
|
178
|
+
if (fileHasSymbol(appRoot, file, id)) inFileOk.add(id);
|
|
179
|
+
}
|
|
180
|
+
const err = verifyExpr({ expr: screenInvoke, importedNames: v.importedNames, extraOk: inFileOk });
|
|
181
|
+
if (err) return { reason: `${err} — in '${file}'` };
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
plan: {
|
|
185
|
+
reason: String(screen.reason || ''),
|
|
186
|
+
bridge: {
|
|
187
|
+
file,
|
|
188
|
+
anchor,
|
|
189
|
+
scope: b.scope === 'class-field' ? 'class-field' : 'statement',
|
|
190
|
+
imports: v.imports,
|
|
191
|
+
screenInvoke,
|
|
192
|
+
importPathToBridge: relImport(path.dirname(file), path.join(fyodosDirRel, BRIDGE_BASENAME)),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Codegen ─────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
function importLine(imp, esm) {
|
|
201
|
+
if (!esm) return `const { ${imp.name} } = require('${imp.importPath}');`;
|
|
202
|
+
if (imp.kind === 'default') return `import ${imp.name} from '${imp.importPath}';`;
|
|
203
|
+
if (imp.kind === 'namespace') return `import * as ${imp.name} from '${imp.importPath}';`;
|
|
204
|
+
return `import { ${imp.name} } from '${imp.importPath}';`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The generated screen module. Contract with the SDK:
|
|
209
|
+
* - fyodosGetCurrentScreen(): the app-reported screen key, or null when the
|
|
210
|
+
* owning component has not rendered OR the report was made on a DIFFERENT
|
|
211
|
+
* page (stale) — unknown beats wrong.
|
|
212
|
+
* - fyodosGetCurrentRoute(): a URL-shaped route the orb consumes directly:
|
|
213
|
+
* pathname when no fresh screen report exists, pathname + screen key when
|
|
214
|
+
* one does (e.g. '/dashboard' + 'agente' → '/dashboard/agente', matching
|
|
215
|
+
* the manifest's route strings exactly).
|
|
216
|
+
*/
|
|
217
|
+
function buildScreenModule(opts = {}) {
|
|
218
|
+
const ts = opts.ts !== false;
|
|
219
|
+
const esm = opts.esm !== false;
|
|
220
|
+
const strRet = ts ? ': string | null' : '';
|
|
221
|
+
|
|
222
|
+
const L = [];
|
|
223
|
+
L.push(
|
|
224
|
+
`${GENERATED_FILE_DIRECTIVES}/** GENERATED by Fiodos — screen-truth wiring. Do not edit — re-run the Fiodos installer to regenerate. */`,
|
|
225
|
+
);
|
|
226
|
+
L.push('');
|
|
227
|
+
L.push(
|
|
228
|
+
esm
|
|
229
|
+
? `import { getFiodosBridge } from './${BRIDGE_BASENAME}';`
|
|
230
|
+
: `const { getFiodosBridge } = require('./${BRIDGE_BASENAME}');`,
|
|
231
|
+
);
|
|
232
|
+
L.push('');
|
|
233
|
+
L.push('/**');
|
|
234
|
+
L.push(' * The ACTIVE screen key the app itself reported (state-driven screens), or');
|
|
235
|
+
L.push(' * null when unknown. A report made on a DIFFERENT page than the current one');
|
|
236
|
+
L.push(' * is stale and ignored — the orb must never act on a screen claim that is');
|
|
237
|
+
L.push(' * no longer true.');
|
|
238
|
+
L.push(' */');
|
|
239
|
+
L.push(`export function ${SCREEN_EXPORT}()${strRet} {`);
|
|
240
|
+
L.push(' try {');
|
|
241
|
+
L.push(` const fn = getFiodosBridge()[${JSON.stringify(BRIDGE_SCREEN)}];`);
|
|
242
|
+
L.push(" if (typeof fn !== 'function') return null;");
|
|
243
|
+
L.push(' const s = fn();');
|
|
244
|
+
L.push(" if (!s || typeof s !== 'object') return null;");
|
|
245
|
+
L.push(" const value = s.value == null || s.value === '' ? null : String(s.value);");
|
|
246
|
+
L.push(' if (value == null) return null;');
|
|
247
|
+
L.push(" const here = typeof window !== 'undefined' && window.location ? window.location.pathname : null;");
|
|
248
|
+
L.push(' const at = s.path == null ? null : String(s.path);');
|
|
249
|
+
L.push(' if (at != null && here != null && at !== here) return null;');
|
|
250
|
+
L.push(' return value;');
|
|
251
|
+
L.push(' } catch {');
|
|
252
|
+
L.push(' return null;');
|
|
253
|
+
L.push(' }');
|
|
254
|
+
L.push('}');
|
|
255
|
+
L.push('');
|
|
256
|
+
L.push('/** URL-shaped current route for the orb: pathname + the reported screen key. */');
|
|
257
|
+
L.push(`export function ${ROUTE_EXPORT}()${strRet} {`);
|
|
258
|
+
L.push(' try {');
|
|
259
|
+
L.push(" const base = typeof window !== 'undefined' && window.location ? window.location.pathname : null;");
|
|
260
|
+
L.push(` const screen = ${SCREEN_EXPORT}();`);
|
|
261
|
+
L.push(' if (screen == null) {');
|
|
262
|
+
L.push(" return base == null ? null : base + (window.location.search || '');");
|
|
263
|
+
L.push(' }');
|
|
264
|
+
L.push(" const key = screen.replace(/^\\/+|\\/+$/g, '');");
|
|
265
|
+
L.push(" if (base == null || base === '' || base === '/') return '/' + key;");
|
|
266
|
+
L.push(" const trimmed = base.replace(/\\/+$/, '');");
|
|
267
|
+
L.push(" if (trimmed.toLowerCase().endsWith('/' + key.toLowerCase())) return trimmed;");
|
|
268
|
+
L.push(" return trimmed + '/' + key;");
|
|
269
|
+
L.push(' } catch {');
|
|
270
|
+
L.push(' return null;');
|
|
271
|
+
L.push(' }');
|
|
272
|
+
L.push('}');
|
|
273
|
+
L.push('');
|
|
274
|
+
if (!esm) L.push(`module.exports = { ${SCREEN_EXPORT}, ${ROUTE_EXPORT} };`);
|
|
275
|
+
let body = L.join('\n');
|
|
276
|
+
if (!esm) {
|
|
277
|
+
body = body.replace(/^export function /gm, 'function ');
|
|
278
|
+
}
|
|
279
|
+
return `${body}\n`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Plan the reversible edit inserted into the screen-owning component. */
|
|
283
|
+
function planScreenComponentEdit(appRoot, plan) {
|
|
284
|
+
const b = plan.bridge;
|
|
285
|
+
const content = readFileSafe(appRoot, b.file) || '';
|
|
286
|
+
|
|
287
|
+
// The getter is re-registered on EVERY render, so its closure always holds
|
|
288
|
+
// the CURRENT screen value; the IIFE captures the pathname at render time so
|
|
289
|
+
// the generated module can detect stale reports from another page.
|
|
290
|
+
const pathExpr =
|
|
291
|
+
"typeof window !== 'undefined' && window.location ? window.location.pathname : null";
|
|
292
|
+
const getter = `((p) => () => ({ value: (${b.screenInvoke}), path: p }))(${pathExpr})`;
|
|
293
|
+
const methods = [` ${JSON.stringify(BRIDGE_SCREEN)}: ${getter},`];
|
|
294
|
+
|
|
295
|
+
const importLines = [`import { registerFiodosBridge } from '${b.importPathToBridge}';`];
|
|
296
|
+
for (const imp of b.imports || []) importLines.push(importLine(imp, true));
|
|
297
|
+
|
|
298
|
+
const registerCall =
|
|
299
|
+
b.scope === 'class-field'
|
|
300
|
+
? `private __fyodosScreenBridge = registerFiodosBridge({\n${methods.join('\n')}\n});`
|
|
301
|
+
: `registerFiodosBridge({\n${methods.join('\n')}\n});`;
|
|
302
|
+
|
|
303
|
+
const anchorIdx = content.indexOf(b.anchor);
|
|
304
|
+
const lineStart = content.lastIndexOf('\n', anchorIdx) + 1;
|
|
305
|
+
const indent = (content.slice(lineStart, anchorIdx).match(/^\s*/) || [''])[0];
|
|
306
|
+
const registerBlock = `${SCREEN_EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${registerCall}\n${INJECT_ESLINT_ENABLE}\n${SCREEN_EDIT_END}`;
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
file: b.file,
|
|
310
|
+
anchor: b.anchor,
|
|
311
|
+
scope: b.scope,
|
|
312
|
+
importBlock: `${SCREEN_EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${importLines.join('\n')}\n${INJECT_ESLINT_ENABLE}\n${SCREEN_EDIT_END}`,
|
|
313
|
+
registerBlock: registerBlock
|
|
314
|
+
.split('\n')
|
|
315
|
+
.map((l) => (l ? indent + l : l))
|
|
316
|
+
.join('\n'),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** True when the (TS/JS/TSX/JSX) source still parses. Fail-open without esbuild. */
|
|
321
|
+
function parsesOk(source, file) {
|
|
322
|
+
if (file.endsWith('.vue') || file.endsWith('.svelte')) return true;
|
|
323
|
+
try {
|
|
324
|
+
const esbuild = require('esbuild');
|
|
325
|
+
const loader = /\.tsx$/.test(file) ? 'tsx' : /\.jsx$/.test(file) ? 'jsx' : /\.ts$/.test(file) ? 'ts' : 'js';
|
|
326
|
+
esbuild.transformSync(source, { loader });
|
|
327
|
+
return true;
|
|
328
|
+
} catch (e) {
|
|
329
|
+
if (e && e.code === 'MODULE_NOT_FOUND') return true;
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Apply the screen component edit (idempotent, snapshot for revert). */
|
|
335
|
+
function applyScreenComponentEdit(appRoot, edit) {
|
|
336
|
+
const abs = path.join(appRoot, normRel(edit.file));
|
|
337
|
+
const original = fs.readFileSync(abs, 'utf8');
|
|
338
|
+
let next = stripScreenBlocks(original);
|
|
339
|
+
next = insertImportBlock(next, edit.importBlock);
|
|
340
|
+
next = insertRegisterBlock(next, edit.file, edit.anchor, edit.registerBlock, edit.scope);
|
|
341
|
+
if (!parsesOk(next, edit.file)) {
|
|
342
|
+
throw new Error(`the screen registration would break '${edit.file}' (parse check failed)`);
|
|
343
|
+
}
|
|
344
|
+
fs.writeFileSync(abs, next);
|
|
345
|
+
return { file: edit.file, abs, original };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Document ────────────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
function buildScreenDoc(plan, ctx = {}) {
|
|
351
|
+
const { appName = 'your app', screenRel = '', editedFile = '', verified = false, verifyNote = '' } = ctx;
|
|
352
|
+
const L = [];
|
|
353
|
+
L.push('# Fiodos — screen-truth wiring (state-driven screens)');
|
|
354
|
+
L.push('');
|
|
355
|
+
L.push('Your app switches screens through CLIENT STATE (the URL does not change), so');
|
|
356
|
+
L.push('the orb cannot tell where the user is from the pathname alone. This wiring');
|
|
357
|
+
L.push('reports the ACTIVE screen to the orb automatically: the per-screen bubble,');
|
|
358
|
+
L.push('"you are already there" detection and screen-scoped context all work.');
|
|
359
|
+
L.push('');
|
|
360
|
+
L.push(`- App: \`${appName}\``);
|
|
361
|
+
L.push(`- Why: ${plan.reason || 'the active screen lives in component state'}`);
|
|
362
|
+
L.push(`- Creates \`${screenRel}\` — exports \`${SCREEN_EXPORT}()\` and \`${ROUTE_EXPORT}()\`.`);
|
|
363
|
+
if (editedFile) {
|
|
364
|
+
L.push(`- Edits \`${editedFile}\` (only reversible \`FYODOS:SCREEN\` blocks) to report the live screen.`);
|
|
365
|
+
}
|
|
366
|
+
L.push(`- Verification: ${verified ? 'build check passed' : verifyNote || 'compile check unavailable — applied with the mechanical symbol verification only'}.`);
|
|
367
|
+
L.push('');
|
|
368
|
+
L.push('## Honesty contract');
|
|
369
|
+
L.push('');
|
|
370
|
+
L.push('The report carries the pathname where it was made: when the user moves to a');
|
|
371
|
+
L.push('different page, the stale report is ignored and the orb falls back to the');
|
|
372
|
+
L.push('plain pathname — it never acts on a screen claim that is no longer true.');
|
|
373
|
+
L.push('');
|
|
374
|
+
L.push('Remove the generated file and the `FYODOS:SCREEN` blocks to revert entirely.');
|
|
375
|
+
L.push('');
|
|
376
|
+
return L.join('\n');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Main entry ──────────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Wire screen truth automatically. Options mirror wireSession. Returns
|
|
383
|
+
* { status, screenFile?, docPath?, editedFile?, reason? } with status in:
|
|
384
|
+
* 'applied' | 'applied-untested' | 'url-screens' | 'review' | 'declined' |
|
|
385
|
+
* 'reverted' | 'skipped'.
|
|
386
|
+
*/
|
|
387
|
+
async function wireScreen(appRoot, opts = {}) {
|
|
388
|
+
const {
|
|
389
|
+
screen,
|
|
390
|
+
colors = {},
|
|
391
|
+
quiet = false,
|
|
392
|
+
assumeYes = false,
|
|
393
|
+
noWire = false,
|
|
394
|
+
framework = 'web',
|
|
395
|
+
appName,
|
|
396
|
+
runTest = false,
|
|
397
|
+
testRunner = null,
|
|
398
|
+
ts: tsOpt,
|
|
399
|
+
esm: esmOpt,
|
|
400
|
+
} = opts;
|
|
401
|
+
const { blue, cyan, dim, reset } = colors;
|
|
402
|
+
const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
|
|
403
|
+
|
|
404
|
+
if (noWire) return { status: 'skipped', reason: 'no-wire' };
|
|
405
|
+
|
|
406
|
+
const fyodosDirRel = resolveFiodosDirRel(appRoot);
|
|
407
|
+
const fyodosDirAbs = path.join(appRoot, fyodosDirRel);
|
|
408
|
+
const detected = detectTsEsm(appRoot);
|
|
409
|
+
const ts = tsOpt != null ? tsOpt : detected.ts;
|
|
410
|
+
const esm = esmOpt != null ? esmOpt : detected.esm;
|
|
411
|
+
const ext = ts ? '.ts' : esm ? '.js' : '.cjs';
|
|
412
|
+
|
|
413
|
+
const prep = prepareScreenPlan({ appRoot, screen, fyodosDirRel });
|
|
414
|
+
if (prep.none) return { status: 'url-screens', reason: prep.reason };
|
|
415
|
+
if (!prep.plan) {
|
|
416
|
+
if (!quiet) {
|
|
417
|
+
console.error(`${tag} · ${dim || ''}Screen wiring left for review: ${prep.reason}${reset || ''}`);
|
|
418
|
+
}
|
|
419
|
+
return { status: 'review', reason: prep.reason };
|
|
420
|
+
}
|
|
421
|
+
const plan = prep.plan;
|
|
422
|
+
|
|
423
|
+
if (!assumeYes) {
|
|
424
|
+
if (!process.stdin.isTTY) return { status: 'declined', reason: 'non-interactive' };
|
|
425
|
+
const yes = await askYesNo(
|
|
426
|
+
`\n${tag} · Report the app's ACTIVE screen to the orb automatically (registers it from \`${plan.bridge.file}\`)? [yes/no] `,
|
|
427
|
+
);
|
|
428
|
+
if (!yes) return { status: 'declined' };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let baseline = null;
|
|
432
|
+
if (runTest && testRunner) {
|
|
433
|
+
try {
|
|
434
|
+
baseline = await testRunner(appRoot, { framework });
|
|
435
|
+
} catch (err) {
|
|
436
|
+
baseline = { ok: false, stage: 'runner', output: err && err.message };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
fs.mkdirSync(fyodosDirAbs, { recursive: true });
|
|
441
|
+
const screenAbs = path.join(fyodosDirAbs, `${SCREEN_BASENAME}${ext}`);
|
|
442
|
+
const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
|
|
443
|
+
const generated = [screenAbs];
|
|
444
|
+
let backups = [];
|
|
445
|
+
let bridgeCreatedByUs = false;
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
if (!fs.existsSync(bridgeAbs)) {
|
|
449
|
+
fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
|
|
450
|
+
bridgeCreatedByUs = true;
|
|
451
|
+
generated.push(bridgeAbs);
|
|
452
|
+
}
|
|
453
|
+
fs.writeFileSync(screenAbs, buildScreenModule({ ts, esm }));
|
|
454
|
+
const edit = planScreenComponentEdit(appRoot, plan);
|
|
455
|
+
backups = [applyScreenComponentEdit(appRoot, edit)];
|
|
456
|
+
} catch (err) {
|
|
457
|
+
for (const b of backups) fs.writeFileSync(b.abs, b.original);
|
|
458
|
+
for (const f of generated) {
|
|
459
|
+
try {
|
|
460
|
+
if (fs.existsSync(f)) fs.rmSync(f);
|
|
461
|
+
} catch {
|
|
462
|
+
/* best effort */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return { status: 'reverted', reason: err && err.message };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let verified = false;
|
|
469
|
+
let verifyNote = '';
|
|
470
|
+
if (runTest && testRunner) {
|
|
471
|
+
let after;
|
|
472
|
+
try {
|
|
473
|
+
after = await testRunner(appRoot, { framework });
|
|
474
|
+
} catch (err) {
|
|
475
|
+
after = { ok: false, stage: 'runner', output: err && err.message };
|
|
476
|
+
}
|
|
477
|
+
if (after && after.ok) {
|
|
478
|
+
verified = true;
|
|
479
|
+
} else if (baseline && baseline.ok) {
|
|
480
|
+
for (const b of backups) fs.writeFileSync(b.abs, b.original);
|
|
481
|
+
for (const f of generated) {
|
|
482
|
+
try {
|
|
483
|
+
if (fs.existsSync(f)) fs.rmSync(f);
|
|
484
|
+
} catch {
|
|
485
|
+
/* best effort */
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (!quiet) {
|
|
489
|
+
console.error(
|
|
490
|
+
`${tag} · ${dim || ''}Screen wiring reverted: the app stopped compiling with it applied.${reset || ''}`,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return { status: 'reverted', reason: 'compile regression', output: after && after.output };
|
|
494
|
+
} else {
|
|
495
|
+
verifyNote = 'the app did not compile BEFORE wiring either — applied; symbols were verified mechanically';
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const docAbs = path.join(fyodosDirAbs, DOC_BASENAME);
|
|
500
|
+
const screenRel = path.relative(appRoot, screenAbs).split(path.sep).join('/');
|
|
501
|
+
fs.writeFileSync(
|
|
502
|
+
docAbs,
|
|
503
|
+
buildScreenDoc(plan, {
|
|
504
|
+
appName: appName || path.basename(appRoot),
|
|
505
|
+
screenRel,
|
|
506
|
+
editedFile: plan.bridge.file,
|
|
507
|
+
verified,
|
|
508
|
+
verifyNote,
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
status: verified ? 'applied' : 'applied-untested',
|
|
514
|
+
screenFile: screenAbs,
|
|
515
|
+
docPath: docAbs,
|
|
516
|
+
editedFile: plan.bridge.file,
|
|
517
|
+
bridgeCreated: bridgeCreatedByUs,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function reportScreenResult(result, colors = {}, { quiet = false } = {}) {
|
|
522
|
+
if (!result) return;
|
|
523
|
+
const { blue, cyan, dim, reset } = colors;
|
|
524
|
+
const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
|
|
525
|
+
switch (result.status) {
|
|
526
|
+
case 'applied':
|
|
527
|
+
console.error(`${tag} · screen truth connected automatically — the orb now knows which screen the user is on even without URL changes`);
|
|
528
|
+
break;
|
|
529
|
+
case 'applied-untested':
|
|
530
|
+
console.error(`${tag} · screen truth connected (compile check unavailable)`);
|
|
531
|
+
break;
|
|
532
|
+
case 'review':
|
|
533
|
+
console.error(`${tag} · ${dim || ''}screen wiring left for review: ${result.reason}${reset || ''}`);
|
|
534
|
+
break;
|
|
535
|
+
case 'reverted':
|
|
536
|
+
console.error(`${tag} · ${dim || ''}screen wiring reverted (${result.reason})${reset || ''}`);
|
|
537
|
+
break;
|
|
538
|
+
case 'url-screens':
|
|
539
|
+
if (!quiet) console.error(`${tag} · ${dim || ''}screens are URL-driven — no screen wiring needed${reset || ''}`);
|
|
540
|
+
break;
|
|
541
|
+
default:
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
module.exports = {
|
|
547
|
+
wireScreen,
|
|
548
|
+
reportScreenResult,
|
|
549
|
+
// exported for tests
|
|
550
|
+
prepareScreenPlan,
|
|
551
|
+
buildScreenModule,
|
|
552
|
+
buildScreenDoc,
|
|
553
|
+
planScreenComponentEdit,
|
|
554
|
+
applyScreenComponentEdit,
|
|
555
|
+
stripScreenBlocks,
|
|
556
|
+
SCREEN_BASENAME,
|
|
557
|
+
SCREEN_EXPORT,
|
|
558
|
+
ROUTE_EXPORT,
|
|
559
|
+
BRIDGE_SCREEN,
|
|
560
|
+
SCREEN_EDIT_START,
|
|
561
|
+
SCREEN_EDIT_END,
|
|
562
|
+
};
|