@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.
@@ -0,0 +1,755 @@
1
+ /**
2
+ * wireSession — automatic session (auth) wiring. NO MANUAL STEP.
3
+ *
4
+ * WHY: actions marked `requiresAuth` are gated by the SDK on the host's
5
+ * `isAuthenticated()`. Until now connecting it was "the only manual step"
6
+ * documented in FYODOS_ORB_MOUNT.md — and the step people skip (we skipped it
7
+ * in our own dashboard). Without it the agent cannot know the session state
8
+ * and every requiresAuth action fails closed. This module closes that gap the
9
+ * same way handler wiring closed the `registries` gap: the SAME AI that read
10
+ * the app's code says WHERE the session lives, a mechanical verifier confirms
11
+ * every referenced symbol really exists, the wiring is applied under the same
12
+ * consent, and a build check reverts it if anything regressed.
13
+ *
14
+ * WHAT IT PRODUCES:
15
+ * · `src/fyodos/session.generated.<ext>` exporting two functions consumed by
16
+ * the orb mount:
17
+ * - fyodosIsAuthenticated(): boolean | null (null = UNKNOWN → the
18
+ * engine fails closed with an HONEST message, never "you are signed out")
19
+ * - fyodosGetUserId(): string | null (keys conversation memory
20
+ * per user; hashed locally by the SDK, never sent anywhere)
21
+ * · MODULE strategy: the generated file imports the app's own auth source
22
+ * (exported store/function) and reads it directly.
23
+ * · BRIDGE strategy: the session lives in component state (React context,
24
+ * hook, provider). We insert a tiny reversible registration block into
25
+ * that component (markers FYODOS:SESSION:START/END — separate from the
26
+ * handler FYODOS:BRIDGE blocks so neither strips the other) and the
27
+ * generated file reads the live values through the shared bridge module.
28
+ *
29
+ * SECURITY / HONESTY:
30
+ * · No secrets are read or embedded — only a boolean "is there a session?"
31
+ * and an opaque user id string the app already holds.
32
+ * · Everything the AI proposed is mechanically verified (files, anchors,
33
+ * symbols) before writing; unverifiable → honest "review" report, never a
34
+ * blind write.
35
+ * · All edits are reversible (markers) and reverted automatically when the
36
+ * app stops compiling.
37
+ */
38
+ 'use strict';
39
+
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+
43
+ const {
44
+ fileHasSymbol,
45
+ freeIdentifiers,
46
+ declaredLocals,
47
+ detachedInstanceReceiver,
48
+ countOccurrences,
49
+ readFileSafe,
50
+ normRel,
51
+ relImport,
52
+ detectTsEsm,
53
+ resolveFiodosDirRel,
54
+ askYesNo,
55
+ JS_KEYWORDS,
56
+ SAFE_GLOBALS,
57
+ GENERATED_FILE_DIRECTIVES,
58
+ INJECT_ESLINT_DISABLE,
59
+ INJECT_ESLINT_ENABLE,
60
+ BRIDGE_BASENAME,
61
+ buildBridgeModule,
62
+ insertImportBlock,
63
+ insertRegisterBlock,
64
+ deriveBridgeAnchor,
65
+ } = require('./wireHandlers');
66
+
67
+ const SESSION_BASENAME = 'session.generated';
68
+ const DOC_BASENAME = 'FYODOS_SESSION.md';
69
+ const IS_AUTH_EXPORT = 'fyodosIsAuthenticated';
70
+ const USER_ID_EXPORT = 'fyodosGetUserId';
71
+ const CAPS_EXPORT = 'fyodosGetSessionCapabilities';
72
+ const BRIDGE_IS_AUTH = '__fyodos_is_authenticated';
73
+ const BRIDGE_USER_ID = '__fyodos_user_id';
74
+ const BRIDGE_CAPABILITIES = '__fyodos_capabilities';
75
+
76
+ // Capability flags are short snake_case names ("has_payment_method"). Anything
77
+ // else is dropped: a malformed name could never match the manifest's
78
+ // requiredCapabilities anyway, so wiring it would be dead weight.
79
+ const CAPABILITY_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
80
+
81
+ // Session edits carry their OWN markers: wireHandlers' stripFiodosBlocks
82
+ // removes every FYODOS:BRIDGE block in a file, so if the session registration
83
+ // shared that marker, re-running handler wiring on the same component would
84
+ // silently delete the session bridge (and vice versa).
85
+ const SESSION_EDIT_START_PREFIX = '// FYODOS:SESSION:START';
86
+ const SESSION_EDIT_START = `${SESSION_EDIT_START_PREFIX} (generated by Fiodos — safe to remove)`;
87
+ const SESSION_EDIT_END = '// FYODOS:SESSION:END';
88
+
89
+ function esc(s) {
90
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ }
92
+
93
+ /** Remove previously-inserted session blocks (idempotence across re-runs). */
94
+ function stripSessionBlocks(content) {
95
+ const re = new RegExp(
96
+ `\\n?${esc(SESSION_EDIT_START_PREFIX)}[^\\n]*[\\s\\S]*?${esc(SESSION_EDIT_END)}\\n?`,
97
+ 'g',
98
+ );
99
+ return content.replace(re, '\n');
100
+ }
101
+
102
+ // ── Mechanical verification of the AI's session proposal ───────────────────────
103
+
104
+ function verifyImports({ appRoot, rawImports, importBaseDirRel }) {
105
+ const imports = [];
106
+ const importedNames = new Set();
107
+ for (const imp of Array.isArray(rawImports) ? rawImports : []) {
108
+ const name = String((imp && imp.name) || '').trim();
109
+ const from = normRel(imp && imp.from);
110
+ const kind = (imp && imp.kind) || 'named';
111
+ if (!name || !from) return { reason: 'a session import is incomplete (missing name/from)' };
112
+ if (!fs.existsSync(path.join(appRoot, from))) {
113
+ return { reason: `the session import points to a non-existent file: '${from}'` };
114
+ }
115
+ if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) {
116
+ return { reason: `named import '${name}' does not exist in '${from}'` };
117
+ }
118
+ imports.push({ kind, name, importPath: relImport(importBaseDirRel, from) });
119
+ importedNames.add(name);
120
+ }
121
+ return { imports, importedNames };
122
+ }
123
+
124
+ function verifyExpr({ expr, importedNames, extraOk = new Set(), what }) {
125
+ const detached = detachedInstanceReceiver(expr);
126
+ if (detached) {
127
+ return `the ${what} expression instantiates '${detached}' with 'new' and uses it as the receiver; a fresh instance is disconnected from the app's real session state`;
128
+ }
129
+ const locals = declaredLocals(expr);
130
+ for (const id of freeIdentifiers(expr)) {
131
+ if (JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id) || importedNames.has(id) || locals.has(id) || extraOk.has(id)) {
132
+ continue;
133
+ }
134
+ return `the ${what} expression references '${id}', which could not be verified in your code`;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Verify the AI's fine-grained session capabilities (Eje B audience map:
141
+ * "has_payment_method", "is_admin"…) INDIVIDUALLY: a hallucinated capability
142
+ * is dropped with an honest note while the rest of the session wiring still
143
+ * applies — one bad flag must never cost the whole auth awareness.
144
+ * Returns { caps: [{ name, expr }], notes: [reason...] }.
145
+ */
146
+ function verifyCapabilities({ rawCapabilities, exprKey, importedNames, extraOk = new Set() }) {
147
+ const caps = [];
148
+ const notes = [];
149
+ const seen = new Set();
150
+ for (const c of Array.isArray(rawCapabilities) ? rawCapabilities : []) {
151
+ const name = String((c && c.name) || '').trim();
152
+ const expr = String((c && c[exprKey]) || '').trim();
153
+ if (!CAPABILITY_NAME_RE.test(name)) {
154
+ notes.push(`capability '${name || '(unnamed)'}' dropped: not a short snake_case name`);
155
+ continue;
156
+ }
157
+ if (seen.has(name)) continue;
158
+ if (!expr) {
159
+ notes.push(`capability '${name}' dropped: no '${exprKey}' expression was provided`);
160
+ continue;
161
+ }
162
+ const err = verifyExpr({ expr, importedNames, extraOk, what: `capability '${name}'` });
163
+ if (err) {
164
+ notes.push(`capability '${name}' dropped: ${err}`);
165
+ continue;
166
+ }
167
+ seen.add(name);
168
+ caps.push({ name, expr });
169
+ }
170
+ return { caps: caps.slice(0, 8), notes };
171
+ }
172
+
173
+ /**
174
+ * Turn the AI's `session` block into a verified plan, or { reason }.
175
+ * Plan shapes:
176
+ * { strategy:'module', imports, isAuthExpr, userIdExpr|null, capabilities, capabilityNotes }
177
+ * { strategy:'bridge', bridge:{ file, anchor, scope, imports, isAuthInvoke, userIdInvoke|null,
178
+ * capabilities }, capabilityNotes }
179
+ */
180
+ function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
181
+ if (!session || typeof session !== 'object') return { reason: 'no session block' };
182
+ if (session.hasLogin === false || session.strategy === 'none') {
183
+ return { none: true, reason: String(session.reason || 'the app has no login') };
184
+ }
185
+
186
+ // Every requiredSymbol must really exist wherever the AI says it does.
187
+ const required = Array.isArray(session.requiredSymbols) ? session.requiredSymbols : [];
188
+ for (const r of required) {
189
+ if (!fileHasSymbol(appRoot, r && r.file, r && r.name)) {
190
+ return {
191
+ reason: `the verifier did not find symbol '${r && r.name}' in '${normRel(r && r.file)}'`,
192
+ };
193
+ }
194
+ }
195
+
196
+ if (session.strategy === 'bridge') {
197
+ const b = session.bridge || {};
198
+ const file = normRel(b.file);
199
+ let anchor = String(b.anchor || '').trim();
200
+ const isAuthInvoke = String(b.isAuthenticatedInvoke || '').trim();
201
+ const userIdInvoke = String(b.userIdInvoke || '').trim() || null;
202
+
203
+ if (!file) return { reason: 'the AI marked a session bridge but gave no component file' };
204
+ const content = readFileSafe(appRoot, file);
205
+ if (content == null) return { reason: `could not read the component '${file}'` };
206
+ if (!isAuthInvoke) return { reason: `no isAuthenticated expression was provided for '${file}'` };
207
+ // The anchor must be unique in the file WITHOUT counting our own previous
208
+ // insertions (idempotent re-runs). When the AI's anchor is missing or
209
+ // ambiguous, derive a deterministic one from the declaration of the symbol
210
+ // the invoke reads (same rescue as handler bridges) instead of giving up —
211
+ // safety is still guaranteed by the compile check + revert below.
212
+ const cleanContent = stripSessionBlocks(content);
213
+ const occ = anchor ? countOccurrences(cleanContent, anchor) : 0;
214
+ if (occ !== 1) {
215
+ const derived = deriveBridgeAnchor(cleanContent, isAuthInvoke);
216
+ if (derived) anchor = derived;
217
+ else if (!anchor || occ === 0) return { reason: `no safe insertion point was found in '${file}'` };
218
+ else return { reason: `the insertion anchor appears ${occ} times in '${file}'; it is ambiguous` };
219
+ }
220
+
221
+ const v = verifyImports({ appRoot, rawImports: b.imports, importBaseDirRel: path.dirname(file) });
222
+ if (v.reason) return v;
223
+
224
+ // Identifiers must be in-file, imported, args-free locals or safe globals.
225
+ const inFileOk = new Set();
226
+ const capExprs = (Array.isArray(session.capabilities) ? session.capabilities : [])
227
+ .map((c) => String((c && c.invoke) || '').trim())
228
+ .filter(Boolean);
229
+ for (const expr of [isAuthInvoke, userIdInvoke, ...capExprs].filter(Boolean)) {
230
+ for (const id of freeIdentifiers(expr)) {
231
+ if (fileHasSymbol(appRoot, file, id)) inFileOk.add(id);
232
+ }
233
+ }
234
+ for (const expr of [isAuthInvoke, userIdInvoke].filter(Boolean)) {
235
+ const err = verifyExpr({ expr, importedNames: v.importedNames, extraOk: inFileOk, what: 'session' });
236
+ if (err) return { reason: `${err} — in '${file}'` };
237
+ }
238
+ const capsV = verifyCapabilities({
239
+ rawCapabilities: session.capabilities,
240
+ exprKey: 'invoke',
241
+ importedNames: v.importedNames,
242
+ extraOk: inFileOk,
243
+ });
244
+
245
+ return {
246
+ plan: {
247
+ strategy: 'bridge',
248
+ reason: String(session.reason || ''),
249
+ capabilityNotes: capsV.notes,
250
+ bridge: {
251
+ file,
252
+ anchor,
253
+ scope: b.scope === 'class-field' ? 'class-field' : 'statement',
254
+ imports: v.imports,
255
+ isAuthInvoke,
256
+ userIdInvoke,
257
+ capabilities: capsV.caps,
258
+ importPathToBridge: relImport(path.dirname(file), path.join(fyodosDirRel, BRIDGE_BASENAME)),
259
+ },
260
+ },
261
+ };
262
+ }
263
+
264
+ // MODULE strategy (default): the generated file reads the session directly.
265
+ const isAuthExpr = String(session.isAuthenticatedCall || '').trim();
266
+ const userIdExpr = String(session.userIdCall || '').trim() || null;
267
+ if (!isAuthExpr) return { reason: 'the AI did not provide an isAuthenticated expression' };
268
+
269
+ const v = verifyImports({ appRoot, rawImports: session.imports, importBaseDirRel: fyodosDirRel });
270
+ if (v.reason) return v;
271
+ for (const expr of [isAuthExpr, userIdExpr].filter(Boolean)) {
272
+ const err = verifyExpr({ expr, importedNames: v.importedNames, what: 'session' });
273
+ if (err) return { reason: err };
274
+ }
275
+ const capsV = verifyCapabilities({
276
+ rawCapabilities: session.capabilities,
277
+ exprKey: 'call',
278
+ importedNames: v.importedNames,
279
+ });
280
+
281
+ return {
282
+ plan: {
283
+ strategy: 'module',
284
+ reason: String(session.reason || ''),
285
+ imports: v.imports,
286
+ isAuthExpr,
287
+ userIdExpr,
288
+ capabilities: capsV.caps,
289
+ capabilityNotes: capsV.notes,
290
+ },
291
+ };
292
+ }
293
+
294
+ // ── Codegen ─────────────────────────────────────────────────────────────────────
295
+
296
+ function importLine(imp, esm) {
297
+ if (!esm) return `const { ${imp.name} } = require('${imp.importPath}');`;
298
+ if (imp.kind === 'default') return `import ${imp.name} from '${imp.importPath}';`;
299
+ if (imp.kind === 'namespace') return `import * as ${imp.name} from '${imp.importPath}';`;
300
+ return `import { ${imp.name} } from '${imp.importPath}';`;
301
+ }
302
+
303
+ /**
304
+ * The generated session module. Tri-state contract with the SDK:
305
+ * `null` = UNKNOWN (never claim "signed out" — the engine answers honestly
306
+ * and warns the developer); boolean = the app's real verdict.
307
+ */
308
+ function buildSessionModule(plan, opts = {}) {
309
+ const ts = opts.ts !== false;
310
+ const esm = opts.esm !== false;
311
+ const boolRet = ts ? ': boolean | null' : '';
312
+ const strRet = ts ? ': string | null' : '';
313
+ const capsRet = ts ? ': Record<string, boolean> | null' : '';
314
+ const capsDecl = ts ? ': Record<string, boolean>' : '';
315
+
316
+ const L = [];
317
+ L.push(
318
+ `${GENERATED_FILE_DIRECTIVES}/** GENERATED by Fiodos — session wiring. Do not edit — re-run the Fiodos installer to regenerate. */`,
319
+ );
320
+ L.push('');
321
+
322
+ if (plan.strategy === 'bridge') {
323
+ L.push(
324
+ esm
325
+ ? `import { getFiodosBridge } from './${BRIDGE_BASENAME}';`
326
+ : `const { getFiodosBridge } = require('./${BRIDGE_BASENAME}');`,
327
+ );
328
+ L.push('');
329
+ L.push('/**');
330
+ L.push(' * Tri-state session check consumed by the orb (requiresAuth gate):');
331
+ L.push(' * true = signed in, false = signed out, null = UNKNOWN (the component');
332
+ L.push(' * that registers the live session has not rendered yet). On null the');
333
+ L.push(' * Fiodos engine fails closed with an honest message — it NEVER tells');
334
+ L.push(' * the user "you need to be signed in" when it cannot know.');
335
+ L.push(' */');
336
+ L.push(`export function ${IS_AUTH_EXPORT}()${boolRet} {`);
337
+ L.push(' try {');
338
+ L.push(` const fn = getFiodosBridge()[${JSON.stringify(BRIDGE_IS_AUTH)}];`);
339
+ L.push(" if (typeof fn !== 'function') return null;");
340
+ L.push(' return Boolean(fn());');
341
+ L.push(' } catch {');
342
+ L.push(' return null;');
343
+ L.push(' }');
344
+ L.push('}');
345
+ L.push('');
346
+ L.push('/** Stable end-user id (hashed locally by the SDK) or null when signed out/unknown. */');
347
+ L.push(`export function ${USER_ID_EXPORT}()${strRet} {`);
348
+ L.push(' try {');
349
+ L.push(` const fn = getFiodosBridge()[${JSON.stringify(BRIDGE_USER_ID)}];`);
350
+ L.push(" if (typeof fn !== 'function') return null;");
351
+ L.push(' const v = fn();');
352
+ L.push(" return v == null || v === '' ? null : String(v);");
353
+ L.push(' } catch {');
354
+ L.push(' return null;');
355
+ L.push(' }');
356
+ L.push('}');
357
+ L.push('');
358
+ L.push('/**');
359
+ L.push(' * Fine-grained session capability flags (audience map: has_payment_method,');
360
+ L.push(' * is_admin…). null = UNKNOWN — the orb then filters NOTHING (fail-open).');
361
+ L.push(' * Boolean flags only; never counts, names or any user data.');
362
+ L.push(' */');
363
+ L.push(`export function ${CAPS_EXPORT}()${capsRet} {`);
364
+ L.push(' try {');
365
+ L.push(` const fn = getFiodosBridge()[${JSON.stringify(BRIDGE_CAPABILITIES)}];`);
366
+ L.push(" if (typeof fn !== 'function') return null;");
367
+ L.push(' const raw = fn();');
368
+ L.push(" if (!raw || typeof raw !== 'object') return null;");
369
+ L.push(` const caps${capsDecl} = {};`);
370
+ L.push(' for (const k of Object.keys(raw)) {');
371
+ L.push(" if (typeof raw[k] === 'boolean') caps[k] = raw[k];");
372
+ L.push(' }');
373
+ L.push(' return Object.keys(caps).length > 0 ? caps : null;');
374
+ L.push(' } catch {');
375
+ L.push(' return null;');
376
+ L.push(' }');
377
+ L.push('}');
378
+ } else {
379
+ for (const imp of plan.imports || []) L.push(importLine(imp, esm));
380
+ if ((plan.imports || []).length) L.push('');
381
+ L.push('/**');
382
+ L.push(' * Tri-state session check consumed by the orb (requiresAuth gate):');
383
+ L.push(' * true = signed in, false = signed out, null = UNKNOWN (read failed).');
384
+ L.push(' */');
385
+ L.push(`export function ${IS_AUTH_EXPORT}()${boolRet} {`);
386
+ L.push(' try {');
387
+ L.push(` return Boolean(${plan.isAuthExpr});`);
388
+ L.push(' } catch {');
389
+ L.push(' return null;');
390
+ L.push(' }');
391
+ L.push('}');
392
+ L.push('');
393
+ L.push('/** Stable end-user id (hashed locally by the SDK) or null when signed out/unknown. */');
394
+ L.push(`export function ${USER_ID_EXPORT}()${strRet} {`);
395
+ if (plan.userIdExpr) {
396
+ L.push(' try {');
397
+ L.push(` const v = ${plan.userIdExpr};`);
398
+ L.push(" return v == null || v === '' ? null : String(v);");
399
+ L.push(' } catch {');
400
+ L.push(' return null;');
401
+ L.push(' }');
402
+ } else {
403
+ L.push(' return null;');
404
+ }
405
+ L.push('}');
406
+ L.push('');
407
+ L.push('/**');
408
+ L.push(' * Fine-grained session capability flags (audience map: has_payment_method,');
409
+ L.push(' * is_admin…). null = UNKNOWN — the orb then filters NOTHING (fail-open).');
410
+ L.push(' * Boolean flags only; never counts, names or any user data.');
411
+ L.push(' */');
412
+ L.push(`export function ${CAPS_EXPORT}()${capsRet} {`);
413
+ const moduleCaps = Array.isArray(plan.capabilities) ? plan.capabilities : [];
414
+ if (moduleCaps.length > 0) {
415
+ L.push(` const caps${capsDecl} = {};`);
416
+ for (const c of moduleCaps) {
417
+ // Each flag is isolated: one throwing read stays UNKNOWN (omitted)
418
+ // instead of blanking every other capability.
419
+ L.push(' try {');
420
+ L.push(` caps[${JSON.stringify(c.name)}] = Boolean(${c.expr});`);
421
+ L.push(' } catch {');
422
+ L.push(' /* unknown — omitted (fail-open) */');
423
+ L.push(' }');
424
+ }
425
+ L.push(' return Object.keys(caps).length > 0 ? caps : null;');
426
+ } else {
427
+ L.push(' return null;');
428
+ }
429
+ L.push('}');
430
+ }
431
+ L.push('');
432
+ if (!esm) L.push(`module.exports = { ${IS_AUTH_EXPORT}, ${USER_ID_EXPORT}, ${CAPS_EXPORT} };`);
433
+ let body = L.join('\n');
434
+ if (!esm) {
435
+ // CommonJS build: `export function` is invalid — rewrite to plain functions.
436
+ body = body.replace(/^export function /gm, 'function ');
437
+ }
438
+ return `${body}\n`;
439
+ }
440
+
441
+ /** Plan the reversible edit inserted into the session-owning component. */
442
+ function planSessionComponentEdit(appRoot, plan) {
443
+ if (plan.strategy !== 'bridge') return null;
444
+ const b = plan.bridge;
445
+ const content = readFileSafe(appRoot, b.file) || '';
446
+
447
+ const methods = [` ${JSON.stringify(BRIDGE_IS_AUTH)}: () => (${b.isAuthInvoke}),`];
448
+ if (b.userIdInvoke) methods.push(` ${JSON.stringify(BRIDGE_USER_ID)}: () => (${b.userIdInvoke}),`);
449
+ const caps = Array.isArray(b.capabilities) ? b.capabilities : [];
450
+ if (caps.length > 0) {
451
+ const entries = caps.map((c) => `${JSON.stringify(c.name)}: Boolean(${c.expr})`).join(', ');
452
+ methods.push(` ${JSON.stringify(BRIDGE_CAPABILITIES)}: () => ({ ${entries} }),`);
453
+ }
454
+
455
+ const importLines = [`import { registerFiodosBridge } from '${b.importPathToBridge}';`];
456
+ for (const imp of b.imports || []) importLines.push(importLine(imp, true));
457
+
458
+ const registerCall =
459
+ b.scope === 'class-field'
460
+ ? `private __fyodosSessionBridge = registerFiodosBridge({\n${methods.join('\n')}\n});`
461
+ : `registerFiodosBridge({\n${methods.join('\n')}\n});`;
462
+
463
+ const anchorIdx = content.indexOf(b.anchor);
464
+ const lineStart = content.lastIndexOf('\n', anchorIdx) + 1;
465
+ const indent = (content.slice(lineStart, anchorIdx).match(/^\s*/) || [''])[0];
466
+ const registerBlock = `${SESSION_EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${registerCall}\n${INJECT_ESLINT_ENABLE}\n${SESSION_EDIT_END}`;
467
+
468
+ return {
469
+ file: b.file,
470
+ anchor: b.anchor,
471
+ scope: b.scope,
472
+ importBlock: `${SESSION_EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${importLines.join('\n')}\n${INJECT_ESLINT_ENABLE}\n${SESSION_EDIT_END}`,
473
+ registerBlock: registerBlock
474
+ .split('\n')
475
+ .map((l) => (l ? indent + l : l))
476
+ .join('\n'),
477
+ };
478
+ }
479
+
480
+ /** True when the (TS/JS/TSX/JSX) source still parses. Fail-open without esbuild. */
481
+ function parsesOk(source, file) {
482
+ if (file.endsWith('.vue') || file.endsWith('.svelte')) return true; // SFCs: not parseable standalone
483
+ try {
484
+ const esbuild = require('esbuild');
485
+ const loader = /\.tsx$/.test(file) ? 'tsx' : /\.jsx$/.test(file) ? 'jsx' : /\.ts$/.test(file) ? 'ts' : 'js';
486
+ esbuild.transformSync(source, { loader });
487
+ return true;
488
+ } catch (e) {
489
+ if (e && e.code === 'MODULE_NOT_FOUND') return true;
490
+ return false;
491
+ }
492
+ }
493
+
494
+ /** Apply the session component edit (idempotent, snapshot for revert). */
495
+ function applySessionComponentEdit(appRoot, edit) {
496
+ const abs = path.join(appRoot, normRel(edit.file));
497
+ const original = fs.readFileSync(abs, 'utf8');
498
+ let next = stripSessionBlocks(original);
499
+ next = insertImportBlock(next, edit.importBlock);
500
+ next = insertRegisterBlock(next, edit.file, edit.anchor, edit.registerBlock, edit.scope);
501
+ // Last-line defense: never leave a component that does not even PARSE. The
502
+ // full build check (when available) still runs after this.
503
+ if (!parsesOk(next, edit.file)) {
504
+ throw new Error(`the session registration would break '${edit.file}' (parse check failed)`);
505
+ }
506
+ fs.writeFileSync(abs, next);
507
+ return { file: edit.file, abs, original };
508
+ }
509
+
510
+ // ── Document ────────────────────────────────────────────────────────────────────
511
+
512
+ function buildSessionDoc(plan, ctx = {}) {
513
+ const { appName = 'your app', sessionRel = '', editedFile = '', verified = false, verifyNote = '' } = ctx;
514
+ const L = [];
515
+ L.push('# Fiodos — session wiring (auth awareness)');
516
+ L.push('');
517
+ L.push('Connects the orb to your app\'s REAL session state, automatically. This is what');
518
+ L.push('lets the agent know whether the user is signed in (actions marked `requiresAuth`)');
519
+ L.push('and keep each user\'s conversation memory private on shared devices (`getUserId`,');
520
+ L.push('hashed locally — never sent anywhere).');
521
+ L.push('');
522
+ L.push(`- App: \`${appName}\``);
523
+ L.push(`- Strategy: \`${plan.strategy}\`${plan.reason ? ` — ${plan.reason}` : ''}`);
524
+ L.push(`- Creates \`${sessionRel}\` — exports \`${IS_AUTH_EXPORT}()\`, \`${USER_ID_EXPORT}()\` and \`${CAPS_EXPORT}()\`.`);
525
+ if (editedFile) {
526
+ L.push(`- Edits \`${editedFile}\` (only reversible \`FYODOS:SESSION\` blocks) to register the live session.`);
527
+ }
528
+ const planCaps =
529
+ plan.strategy === 'bridge'
530
+ ? (plan.bridge && plan.bridge.capabilities) || []
531
+ : plan.capabilities || [];
532
+ if (planCaps.length > 0) {
533
+ L.push(
534
+ `- Capability flags (audience map): ${planCaps.map((c) => `\`${c.name}\``).join(', ')} — boolean-only, read live from the same session source. Actions whose \`requiredCapabilities\` report false are hidden from that user's orb.`,
535
+ );
536
+ }
537
+ for (const note of plan.capabilityNotes || []) {
538
+ L.push(`- Capability left out: ${note}.`);
539
+ }
540
+ L.push(`- Verification: ${verified ? 'build check passed' : verifyNote || 'compile check unavailable — applied with the mechanical symbol verification only'}.`);
541
+ L.push('');
542
+ L.push('## Honesty contract (tri-state)');
543
+ L.push('');
544
+ L.push(`\`${IS_AUTH_EXPORT}()\` returns \`true\` (signed in), \`false\` (signed out) or \`null\``);
545
+ L.push('(UNKNOWN — e.g. the registering component has not rendered yet). On `null` the');
546
+ L.push('Fiodos engine blocks the gated action with an honest "can\'t do that from here"');
547
+ L.push('message; it never claims the user is signed out when it cannot know.');
548
+ L.push('');
549
+ L.push('## Security');
550
+ L.push('');
551
+ L.push('- Only a boolean session verdict and an opaque user id the app already holds are read.');
552
+ L.push('- No tokens, credentials or secrets are referenced or embedded.');
553
+ L.push('- Remove the generated file and the `FYODOS:SESSION` blocks to revert entirely.');
554
+ L.push('');
555
+ return L.join('\n');
556
+ }
557
+
558
+ // ── Main entry ──────────────────────────────────────────────────────────────────
559
+
560
+ /**
561
+ * Wire the session automatically. Options:
562
+ * session — the AI's `session` block from the analysis.
563
+ * assumeYes — apply without asking (piggybacks on the handler-wiring consent).
564
+ * runTest / testRunner — same build safety net as handler wiring.
565
+ * Returns { status, sessionFile?, docPath?, editedFile?, reason? } with status in:
566
+ * 'applied' | 'applied-untested' | 'no-session' | 'review' | 'declined' |
567
+ * 'reverted' | 'skipped'.
568
+ */
569
+ async function wireSession(appRoot, opts = {}) {
570
+ const {
571
+ session,
572
+ colors = {},
573
+ quiet = false,
574
+ assumeYes = false,
575
+ noWire = false,
576
+ framework = 'web',
577
+ appName,
578
+ runTest = false,
579
+ testRunner = null,
580
+ ts: tsOpt,
581
+ esm: esmOpt,
582
+ } = opts;
583
+ const { blue, cyan, dim, reset } = colors;
584
+ const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
585
+
586
+ if (noWire) return { status: 'skipped', reason: 'no-wire' };
587
+ if (!session) return { status: 'no-session', reason: 'the analysis returned no session block' };
588
+
589
+ const fyodosDirRel = resolveFiodosDirRel(appRoot);
590
+ const fyodosDirAbs = path.join(appRoot, fyodosDirRel);
591
+ const detected = detectTsEsm(appRoot);
592
+ const ts = tsOpt != null ? tsOpt : detected.ts;
593
+ const esm = esmOpt != null ? esmOpt : detected.esm;
594
+ const ext = ts ? '.ts' : esm ? '.js' : '.cjs';
595
+
596
+ const prep = prepareSessionPlan({ appRoot, session, fyodosDirRel });
597
+ if (prep.none) return { status: 'no-session', reason: prep.reason };
598
+ if (!prep.plan) {
599
+ if (!quiet) {
600
+ console.error(`${tag} · ${dim || ''}Session wiring left for review: ${prep.reason}${reset || ''}`);
601
+ }
602
+ return { status: 'review', reason: prep.reason };
603
+ }
604
+ const plan = prep.plan;
605
+
606
+ // Consent. Normally the handler-wiring consent already covered code edits
607
+ // (assumeYes=true from the caller); ask only when it did not.
608
+ if (!assumeYes) {
609
+ if (!process.stdin.isTTY) return { status: 'declined', reason: 'non-interactive' };
610
+ const target = plan.strategy === 'bridge' ? ` (registers it from \`${plan.bridge.file}\`)` : '';
611
+ const yes = await askYesNo(
612
+ `\n${tag} · Connect the orb to your app's session state automatically${target}? [yes/no] `,
613
+ );
614
+ if (!yes) return { status: 'declined' };
615
+ }
616
+
617
+ // Baseline BEFORE touching anything.
618
+ let baseline = null;
619
+ if (runTest && testRunner) {
620
+ try {
621
+ baseline = await testRunner(appRoot, { framework });
622
+ } catch (err) {
623
+ baseline = { ok: false, stage: 'runner', output: err && err.message };
624
+ }
625
+ }
626
+
627
+ fs.mkdirSync(fyodosDirAbs, { recursive: true });
628
+ const sessionAbs = path.join(fyodosDirAbs, `${SESSION_BASENAME}${ext}`);
629
+ const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
630
+ const generated = [sessionAbs];
631
+ let backups = [];
632
+ let bridgeCreatedByUs = false;
633
+
634
+ try {
635
+ if (plan.strategy === 'bridge' && !fs.existsSync(bridgeAbs)) {
636
+ fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
637
+ bridgeCreatedByUs = true;
638
+ generated.push(bridgeAbs);
639
+ }
640
+ fs.writeFileSync(sessionAbs, buildSessionModule(plan, { ts, esm }));
641
+ const edit = planSessionComponentEdit(appRoot, plan);
642
+ if (edit) backups = [applySessionComponentEdit(appRoot, edit)];
643
+ } catch (err) {
644
+ for (const b of backups) fs.writeFileSync(b.abs, b.original);
645
+ for (const f of generated) {
646
+ try {
647
+ if (fs.existsSync(f)) fs.rmSync(f);
648
+ } catch {
649
+ /* best effort */
650
+ }
651
+ }
652
+ return { status: 'reverted', reason: err && err.message };
653
+ }
654
+
655
+ // Build safety net: if the app compiled before and stops compiling now,
656
+ // revert the session wiring entirely — never leave the app broken.
657
+ let verified = false;
658
+ let verifyNote = '';
659
+ if (runTest && testRunner) {
660
+ let after;
661
+ try {
662
+ after = await testRunner(appRoot, { framework });
663
+ } catch (err) {
664
+ after = { ok: false, stage: 'runner', output: err && err.message };
665
+ }
666
+ if (after && after.ok) {
667
+ verified = true;
668
+ } else if (baseline && baseline.ok) {
669
+ for (const b of backups) fs.writeFileSync(b.abs, b.original);
670
+ for (const f of generated) {
671
+ try {
672
+ if (fs.existsSync(f)) fs.rmSync(f);
673
+ } catch {
674
+ /* best effort */
675
+ }
676
+ }
677
+ if (!quiet) {
678
+ console.error(
679
+ `${tag} · ${dim || ''}Session wiring reverted: the app stopped compiling with it applied.${reset || ''}`,
680
+ );
681
+ }
682
+ return { status: 'reverted', reason: 'compile regression', output: after && after.output };
683
+ } else {
684
+ verifyNote = 'the app did not compile BEFORE wiring either — applied; symbols were verified mechanically';
685
+ }
686
+ }
687
+
688
+ const docAbs = path.join(fyodosDirAbs, DOC_BASENAME);
689
+ const sessionRel = path.relative(appRoot, sessionAbs).split(path.sep).join('/');
690
+ fs.writeFileSync(
691
+ docAbs,
692
+ buildSessionDoc(plan, {
693
+ appName: appName || path.basename(appRoot),
694
+ sessionRel,
695
+ editedFile: plan.strategy === 'bridge' ? plan.bridge.file : '',
696
+ verified,
697
+ verifyNote,
698
+ }),
699
+ );
700
+
701
+ return {
702
+ status: verified ? 'applied' : 'applied-untested',
703
+ sessionFile: sessionAbs,
704
+ docPath: docAbs,
705
+ editedFile: plan.strategy === 'bridge' ? plan.bridge.file : '',
706
+ strategy: plan.strategy,
707
+ bridgeCreated: bridgeCreatedByUs,
708
+ };
709
+ }
710
+
711
+ function reportSessionResult(result, colors = {}, { quiet = false } = {}) {
712
+ if (!result) return;
713
+ const { blue, cyan, dim, reset } = colors;
714
+ const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
715
+ switch (result.status) {
716
+ case 'applied':
717
+ console.error(`${tag} · session connected automatically (${result.strategy}) — the agent now knows when the user is signed in`);
718
+ break;
719
+ case 'applied-untested':
720
+ console.error(`${tag} · session connected (${result.strategy}; compile check unavailable)`);
721
+ break;
722
+ case 'review':
723
+ console.error(`${tag} · ${dim || ''}session left for review: ${result.reason}${reset || ''}`);
724
+ break;
725
+ case 'reverted':
726
+ console.error(`${tag} · ${dim || ''}session wiring reverted (${result.reason})${reset || ''}`);
727
+ break;
728
+ case 'no-session':
729
+ if (!quiet) console.error(`${tag} · ${dim || ''}no login detected — session wiring not needed${reset || ''}`);
730
+ break;
731
+ default:
732
+ break;
733
+ }
734
+ }
735
+
736
+ module.exports = {
737
+ wireSession,
738
+ reportSessionResult,
739
+ // exported for tests
740
+ prepareSessionPlan,
741
+ buildSessionModule,
742
+ buildSessionDoc,
743
+ planSessionComponentEdit,
744
+ applySessionComponentEdit,
745
+ stripSessionBlocks,
746
+ SESSION_BASENAME,
747
+ IS_AUTH_EXPORT,
748
+ USER_ID_EXPORT,
749
+ CAPS_EXPORT,
750
+ BRIDGE_IS_AUTH,
751
+ BRIDGE_USER_ID,
752
+ BRIDGE_CAPABILITIES,
753
+ SESSION_EDIT_START,
754
+ SESSION_EDIT_END,
755
+ };