@fiodos/cli 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/changeRegistry.js +55 -1
- package/src/index.js +169 -3
- package/src/odata.js +529 -0
- package/src/wireHandlers.js +22 -0
- package/src/wireReactNative.js +52 -0
- package/src/wireSession.js +619 -0
- package/src/wireWeb.js +102 -0
- package/src/wireWebMount.js +87 -8
|
@@ -0,0 +1,619 @@
|
|
|
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
|
+
} = require('./wireHandlers');
|
|
65
|
+
|
|
66
|
+
const SESSION_BASENAME = 'session.generated';
|
|
67
|
+
const DOC_BASENAME = 'FYODOS_SESSION.md';
|
|
68
|
+
const IS_AUTH_EXPORT = 'fyodosIsAuthenticated';
|
|
69
|
+
const USER_ID_EXPORT = 'fyodosGetUserId';
|
|
70
|
+
const BRIDGE_IS_AUTH = '__fyodos_is_authenticated';
|
|
71
|
+
const BRIDGE_USER_ID = '__fyodos_user_id';
|
|
72
|
+
|
|
73
|
+
// Session edits carry their OWN markers: wireHandlers' stripFiodosBlocks
|
|
74
|
+
// removes every FYODOS:BRIDGE block in a file, so if the session registration
|
|
75
|
+
// shared that marker, re-running handler wiring on the same component would
|
|
76
|
+
// silently delete the session bridge (and vice versa).
|
|
77
|
+
const SESSION_EDIT_START_PREFIX = '// FYODOS:SESSION:START';
|
|
78
|
+
const SESSION_EDIT_START = `${SESSION_EDIT_START_PREFIX} (generated by Fiodos — safe to remove)`;
|
|
79
|
+
const SESSION_EDIT_END = '// FYODOS:SESSION:END';
|
|
80
|
+
|
|
81
|
+
function esc(s) {
|
|
82
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Remove previously-inserted session blocks (idempotence across re-runs). */
|
|
86
|
+
function stripSessionBlocks(content) {
|
|
87
|
+
const re = new RegExp(
|
|
88
|
+
`\\n?${esc(SESSION_EDIT_START_PREFIX)}[^\\n]*[\\s\\S]*?${esc(SESSION_EDIT_END)}\\n?`,
|
|
89
|
+
'g',
|
|
90
|
+
);
|
|
91
|
+
return content.replace(re, '\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Mechanical verification of the AI's session proposal ───────────────────────
|
|
95
|
+
|
|
96
|
+
function verifyImports({ appRoot, rawImports, importBaseDirRel }) {
|
|
97
|
+
const imports = [];
|
|
98
|
+
const importedNames = new Set();
|
|
99
|
+
for (const imp of Array.isArray(rawImports) ? rawImports : []) {
|
|
100
|
+
const name = String((imp && imp.name) || '').trim();
|
|
101
|
+
const from = normRel(imp && imp.from);
|
|
102
|
+
const kind = (imp && imp.kind) || 'named';
|
|
103
|
+
if (!name || !from) return { reason: 'a session import is incomplete (missing name/from)' };
|
|
104
|
+
if (!fs.existsSync(path.join(appRoot, from))) {
|
|
105
|
+
return { reason: `the session import points to a non-existent file: '${from}'` };
|
|
106
|
+
}
|
|
107
|
+
if (kind === 'named' && !fileHasSymbol(appRoot, from, name)) {
|
|
108
|
+
return { reason: `named import '${name}' does not exist in '${from}'` };
|
|
109
|
+
}
|
|
110
|
+
imports.push({ kind, name, importPath: relImport(importBaseDirRel, from) });
|
|
111
|
+
importedNames.add(name);
|
|
112
|
+
}
|
|
113
|
+
return { imports, importedNames };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function verifyExpr({ expr, importedNames, extraOk = new Set(), what }) {
|
|
117
|
+
const detached = detachedInstanceReceiver(expr);
|
|
118
|
+
if (detached) {
|
|
119
|
+
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`;
|
|
120
|
+
}
|
|
121
|
+
const locals = declaredLocals(expr);
|
|
122
|
+
for (const id of freeIdentifiers(expr)) {
|
|
123
|
+
if (JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id) || importedNames.has(id) || locals.has(id) || extraOk.has(id)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
return `the ${what} expression references '${id}', which could not be verified in your code`;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Turn the AI's `session` block into a verified plan, or { reason }.
|
|
133
|
+
* Plan shapes:
|
|
134
|
+
* { strategy:'module', imports, isAuthExpr, userIdExpr|null }
|
|
135
|
+
* { strategy:'bridge', bridge:{ file, anchor, scope, imports, isAuthInvoke, userIdInvoke|null } }
|
|
136
|
+
*/
|
|
137
|
+
function prepareSessionPlan({ appRoot, session, fyodosDirRel }) {
|
|
138
|
+
if (!session || typeof session !== 'object') return { reason: 'no session block' };
|
|
139
|
+
if (session.hasLogin === false || session.strategy === 'none') {
|
|
140
|
+
return { none: true, reason: String(session.reason || 'the app has no login') };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Every requiredSymbol must really exist wherever the AI says it does.
|
|
144
|
+
const required = Array.isArray(session.requiredSymbols) ? session.requiredSymbols : [];
|
|
145
|
+
for (const r of required) {
|
|
146
|
+
if (!fileHasSymbol(appRoot, r && r.file, r && r.name)) {
|
|
147
|
+
return {
|
|
148
|
+
reason: `the verifier did not find symbol '${r && r.name}' in '${normRel(r && r.file)}'`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (session.strategy === 'bridge') {
|
|
154
|
+
const b = session.bridge || {};
|
|
155
|
+
const file = normRel(b.file);
|
|
156
|
+
const anchor = String(b.anchor || '').trim();
|
|
157
|
+
const isAuthInvoke = String(b.isAuthenticatedInvoke || '').trim();
|
|
158
|
+
const userIdInvoke = String(b.userIdInvoke || '').trim() || null;
|
|
159
|
+
|
|
160
|
+
if (!file) return { reason: 'the AI marked a session bridge but gave no component file' };
|
|
161
|
+
const content = readFileSafe(appRoot, file);
|
|
162
|
+
if (content == null) return { reason: `could not read the component '${file}'` };
|
|
163
|
+
if (!isAuthInvoke) return { reason: `no isAuthenticated expression was provided for '${file}'` };
|
|
164
|
+
if (!anchor) return { reason: `no insertion anchor was provided in '${file}'` };
|
|
165
|
+
// The anchor must be unique in the file WITHOUT counting our own previous
|
|
166
|
+
// insertions (idempotent re-runs).
|
|
167
|
+
const occ = countOccurrences(stripSessionBlocks(content), anchor);
|
|
168
|
+
if (occ === 0) return { reason: `no safe insertion point was found in '${file}'` };
|
|
169
|
+
if (occ > 1) return { reason: `the insertion anchor appears ${occ} times in '${file}'; it is ambiguous` };
|
|
170
|
+
|
|
171
|
+
const v = verifyImports({ appRoot, rawImports: b.imports, importBaseDirRel: path.dirname(file) });
|
|
172
|
+
if (v.reason) return v;
|
|
173
|
+
|
|
174
|
+
// Identifiers must be in-file, imported, args-free locals or safe globals.
|
|
175
|
+
const inFileOk = new Set();
|
|
176
|
+
for (const expr of [isAuthInvoke, userIdInvoke].filter(Boolean)) {
|
|
177
|
+
for (const id of freeIdentifiers(expr)) {
|
|
178
|
+
if (fileHasSymbol(appRoot, file, id)) inFileOk.add(id);
|
|
179
|
+
}
|
|
180
|
+
const err = verifyExpr({ expr, importedNames: v.importedNames, extraOk: inFileOk, what: 'session' });
|
|
181
|
+
if (err) return { reason: `${err} — in '${file}'` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
plan: {
|
|
186
|
+
strategy: 'bridge',
|
|
187
|
+
reason: String(session.reason || ''),
|
|
188
|
+
bridge: {
|
|
189
|
+
file,
|
|
190
|
+
anchor,
|
|
191
|
+
scope: b.scope === 'class-field' ? 'class-field' : 'statement',
|
|
192
|
+
imports: v.imports,
|
|
193
|
+
isAuthInvoke,
|
|
194
|
+
userIdInvoke,
|
|
195
|
+
importPathToBridge: relImport(path.dirname(file), path.join(fyodosDirRel, BRIDGE_BASENAME)),
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// MODULE strategy (default): the generated file reads the session directly.
|
|
202
|
+
const isAuthExpr = String(session.isAuthenticatedCall || '').trim();
|
|
203
|
+
const userIdExpr = String(session.userIdCall || '').trim() || null;
|
|
204
|
+
if (!isAuthExpr) return { reason: 'the AI did not provide an isAuthenticated expression' };
|
|
205
|
+
|
|
206
|
+
const v = verifyImports({ appRoot, rawImports: session.imports, importBaseDirRel: fyodosDirRel });
|
|
207
|
+
if (v.reason) return v;
|
|
208
|
+
for (const expr of [isAuthExpr, userIdExpr].filter(Boolean)) {
|
|
209
|
+
const err = verifyExpr({ expr, importedNames: v.importedNames, what: 'session' });
|
|
210
|
+
if (err) return { reason: err };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
plan: {
|
|
215
|
+
strategy: 'module',
|
|
216
|
+
reason: String(session.reason || ''),
|
|
217
|
+
imports: v.imports,
|
|
218
|
+
isAuthExpr,
|
|
219
|
+
userIdExpr,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Codegen ─────────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
function importLine(imp, esm) {
|
|
227
|
+
if (!esm) return `const { ${imp.name} } = require('${imp.importPath}');`;
|
|
228
|
+
if (imp.kind === 'default') return `import ${imp.name} from '${imp.importPath}';`;
|
|
229
|
+
if (imp.kind === 'namespace') return `import * as ${imp.name} from '${imp.importPath}';`;
|
|
230
|
+
return `import { ${imp.name} } from '${imp.importPath}';`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The generated session module. Tri-state contract with the SDK:
|
|
235
|
+
* `null` = UNKNOWN (never claim "signed out" — the engine answers honestly
|
|
236
|
+
* and warns the developer); boolean = the app's real verdict.
|
|
237
|
+
*/
|
|
238
|
+
function buildSessionModule(plan, opts = {}) {
|
|
239
|
+
const ts = opts.ts !== false;
|
|
240
|
+
const esm = opts.esm !== false;
|
|
241
|
+
const boolRet = ts ? ': boolean | null' : '';
|
|
242
|
+
const strRet = ts ? ': string | null' : '';
|
|
243
|
+
|
|
244
|
+
const L = [];
|
|
245
|
+
L.push(
|
|
246
|
+
`${GENERATED_FILE_DIRECTIVES}/** GENERATED by Fiodos — session wiring. Do not edit — re-run the Fiodos installer to regenerate. */`,
|
|
247
|
+
);
|
|
248
|
+
L.push('');
|
|
249
|
+
|
|
250
|
+
if (plan.strategy === 'bridge') {
|
|
251
|
+
L.push(
|
|
252
|
+
esm
|
|
253
|
+
? `import { getFiodosBridge } from './${BRIDGE_BASENAME}';`
|
|
254
|
+
: `const { getFiodosBridge } = require('./${BRIDGE_BASENAME}');`,
|
|
255
|
+
);
|
|
256
|
+
L.push('');
|
|
257
|
+
L.push('/**');
|
|
258
|
+
L.push(' * Tri-state session check consumed by the orb (requiresAuth gate):');
|
|
259
|
+
L.push(' * true = signed in, false = signed out, null = UNKNOWN (the component');
|
|
260
|
+
L.push(' * that registers the live session has not rendered yet). On null the');
|
|
261
|
+
L.push(' * Fiodos engine fails closed with an honest message — it NEVER tells');
|
|
262
|
+
L.push(' * the user "you need to be signed in" when it cannot know.');
|
|
263
|
+
L.push(' */');
|
|
264
|
+
L.push(`export function ${IS_AUTH_EXPORT}()${boolRet} {`);
|
|
265
|
+
L.push(' try {');
|
|
266
|
+
L.push(` const fn = getFiodosBridge()[${JSON.stringify(BRIDGE_IS_AUTH)}];`);
|
|
267
|
+
L.push(" if (typeof fn !== 'function') return null;");
|
|
268
|
+
L.push(' return Boolean(fn());');
|
|
269
|
+
L.push(' } catch {');
|
|
270
|
+
L.push(' return null;');
|
|
271
|
+
L.push(' }');
|
|
272
|
+
L.push('}');
|
|
273
|
+
L.push('');
|
|
274
|
+
L.push('/** Stable end-user id (hashed locally by the SDK) or null when signed out/unknown. */');
|
|
275
|
+
L.push(`export function ${USER_ID_EXPORT}()${strRet} {`);
|
|
276
|
+
L.push(' try {');
|
|
277
|
+
L.push(` const fn = getFiodosBridge()[${JSON.stringify(BRIDGE_USER_ID)}];`);
|
|
278
|
+
L.push(" if (typeof fn !== 'function') return null;");
|
|
279
|
+
L.push(' const v = fn();');
|
|
280
|
+
L.push(" return v == null || v === '' ? null : String(v);");
|
|
281
|
+
L.push(' } catch {');
|
|
282
|
+
L.push(' return null;');
|
|
283
|
+
L.push(' }');
|
|
284
|
+
L.push('}');
|
|
285
|
+
} else {
|
|
286
|
+
for (const imp of plan.imports || []) L.push(importLine(imp, esm));
|
|
287
|
+
if ((plan.imports || []).length) L.push('');
|
|
288
|
+
L.push('/**');
|
|
289
|
+
L.push(' * Tri-state session check consumed by the orb (requiresAuth gate):');
|
|
290
|
+
L.push(' * true = signed in, false = signed out, null = UNKNOWN (read failed).');
|
|
291
|
+
L.push(' */');
|
|
292
|
+
L.push(`export function ${IS_AUTH_EXPORT}()${boolRet} {`);
|
|
293
|
+
L.push(' try {');
|
|
294
|
+
L.push(` return Boolean(${plan.isAuthExpr});`);
|
|
295
|
+
L.push(' } catch {');
|
|
296
|
+
L.push(' return null;');
|
|
297
|
+
L.push(' }');
|
|
298
|
+
L.push('}');
|
|
299
|
+
L.push('');
|
|
300
|
+
L.push('/** Stable end-user id (hashed locally by the SDK) or null when signed out/unknown. */');
|
|
301
|
+
L.push(`export function ${USER_ID_EXPORT}()${strRet} {`);
|
|
302
|
+
if (plan.userIdExpr) {
|
|
303
|
+
L.push(' try {');
|
|
304
|
+
L.push(` const v = ${plan.userIdExpr};`);
|
|
305
|
+
L.push(" return v == null || v === '' ? null : String(v);");
|
|
306
|
+
L.push(' } catch {');
|
|
307
|
+
L.push(' return null;');
|
|
308
|
+
L.push(' }');
|
|
309
|
+
} else {
|
|
310
|
+
L.push(' return null;');
|
|
311
|
+
}
|
|
312
|
+
L.push('}');
|
|
313
|
+
}
|
|
314
|
+
L.push('');
|
|
315
|
+
if (!esm) L.push(`module.exports = { ${IS_AUTH_EXPORT}, ${USER_ID_EXPORT} };`);
|
|
316
|
+
let body = L.join('\n');
|
|
317
|
+
if (!esm) {
|
|
318
|
+
// CommonJS build: `export function` is invalid — rewrite to plain functions.
|
|
319
|
+
body = body.replace(/^export function /gm, 'function ');
|
|
320
|
+
}
|
|
321
|
+
return `${body}\n`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Plan the reversible edit inserted into the session-owning component. */
|
|
325
|
+
function planSessionComponentEdit(appRoot, plan) {
|
|
326
|
+
if (plan.strategy !== 'bridge') return null;
|
|
327
|
+
const b = plan.bridge;
|
|
328
|
+
const content = readFileSafe(appRoot, b.file) || '';
|
|
329
|
+
|
|
330
|
+
const methods = [` ${JSON.stringify(BRIDGE_IS_AUTH)}: () => (${b.isAuthInvoke}),`];
|
|
331
|
+
if (b.userIdInvoke) methods.push(` ${JSON.stringify(BRIDGE_USER_ID)}: () => (${b.userIdInvoke}),`);
|
|
332
|
+
|
|
333
|
+
const importLines = [`import { registerFiodosBridge } from '${b.importPathToBridge}';`];
|
|
334
|
+
for (const imp of b.imports || []) importLines.push(importLine(imp, true));
|
|
335
|
+
|
|
336
|
+
const registerCall =
|
|
337
|
+
b.scope === 'class-field'
|
|
338
|
+
? `private __fyodosSessionBridge = registerFiodosBridge({\n${methods.join('\n')}\n});`
|
|
339
|
+
: `registerFiodosBridge({\n${methods.join('\n')}\n});`;
|
|
340
|
+
|
|
341
|
+
const anchorIdx = content.indexOf(b.anchor);
|
|
342
|
+
const lineStart = content.lastIndexOf('\n', anchorIdx) + 1;
|
|
343
|
+
const indent = (content.slice(lineStart, anchorIdx).match(/^\s*/) || [''])[0];
|
|
344
|
+
const registerBlock = `${SESSION_EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${registerCall}\n${INJECT_ESLINT_ENABLE}\n${SESSION_EDIT_END}`;
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
file: b.file,
|
|
348
|
+
anchor: b.anchor,
|
|
349
|
+
scope: b.scope,
|
|
350
|
+
importBlock: `${SESSION_EDIT_START}\n${INJECT_ESLINT_DISABLE}\n${importLines.join('\n')}\n${INJECT_ESLINT_ENABLE}\n${SESSION_EDIT_END}`,
|
|
351
|
+
registerBlock: registerBlock
|
|
352
|
+
.split('\n')
|
|
353
|
+
.map((l) => (l ? indent + l : l))
|
|
354
|
+
.join('\n'),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** True when the (TS/JS/TSX/JSX) source still parses. Fail-open without esbuild. */
|
|
359
|
+
function parsesOk(source, file) {
|
|
360
|
+
if (file.endsWith('.vue') || file.endsWith('.svelte')) return true; // SFCs: not parseable standalone
|
|
361
|
+
try {
|
|
362
|
+
const esbuild = require('esbuild');
|
|
363
|
+
const loader = /\.tsx$/.test(file) ? 'tsx' : /\.jsx$/.test(file) ? 'jsx' : /\.ts$/.test(file) ? 'ts' : 'js';
|
|
364
|
+
esbuild.transformSync(source, { loader });
|
|
365
|
+
return true;
|
|
366
|
+
} catch (e) {
|
|
367
|
+
if (e && e.code === 'MODULE_NOT_FOUND') return true;
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Apply the session component edit (idempotent, snapshot for revert). */
|
|
373
|
+
function applySessionComponentEdit(appRoot, edit) {
|
|
374
|
+
const abs = path.join(appRoot, normRel(edit.file));
|
|
375
|
+
const original = fs.readFileSync(abs, 'utf8');
|
|
376
|
+
let next = stripSessionBlocks(original);
|
|
377
|
+
next = insertImportBlock(next, edit.importBlock);
|
|
378
|
+
next = insertRegisterBlock(next, edit.file, edit.anchor, edit.registerBlock, edit.scope);
|
|
379
|
+
// Last-line defense: never leave a component that does not even PARSE. The
|
|
380
|
+
// full build check (when available) still runs after this.
|
|
381
|
+
if (!parsesOk(next, edit.file)) {
|
|
382
|
+
throw new Error(`the session registration would break '${edit.file}' (parse check failed)`);
|
|
383
|
+
}
|
|
384
|
+
fs.writeFileSync(abs, next);
|
|
385
|
+
return { file: edit.file, abs, original };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Document ────────────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
function buildSessionDoc(plan, ctx = {}) {
|
|
391
|
+
const { appName = 'your app', sessionRel = '', editedFile = '', verified = false, verifyNote = '' } = ctx;
|
|
392
|
+
const L = [];
|
|
393
|
+
L.push('# Fiodos — session wiring (auth awareness)');
|
|
394
|
+
L.push('');
|
|
395
|
+
L.push('Connects the orb to your app\'s REAL session state, automatically. This is what');
|
|
396
|
+
L.push('lets the agent know whether the user is signed in (actions marked `requiresAuth`)');
|
|
397
|
+
L.push('and keep each user\'s conversation memory private on shared devices (`getUserId`,');
|
|
398
|
+
L.push('hashed locally — never sent anywhere).');
|
|
399
|
+
L.push('');
|
|
400
|
+
L.push(`- App: \`${appName}\``);
|
|
401
|
+
L.push(`- Strategy: \`${plan.strategy}\`${plan.reason ? ` — ${plan.reason}` : ''}`);
|
|
402
|
+
L.push(`- Creates \`${sessionRel}\` — exports \`${IS_AUTH_EXPORT}()\` and \`${USER_ID_EXPORT}()\`.`);
|
|
403
|
+
if (editedFile) {
|
|
404
|
+
L.push(`- Edits \`${editedFile}\` (only reversible \`FYODOS:SESSION\` blocks) to register the live session.`);
|
|
405
|
+
}
|
|
406
|
+
L.push(`- Verification: ${verified ? 'build check passed' : verifyNote || 'compile check unavailable — applied with the mechanical symbol verification only'}.`);
|
|
407
|
+
L.push('');
|
|
408
|
+
L.push('## Honesty contract (tri-state)');
|
|
409
|
+
L.push('');
|
|
410
|
+
L.push(`\`${IS_AUTH_EXPORT}()\` returns \`true\` (signed in), \`false\` (signed out) or \`null\``);
|
|
411
|
+
L.push('(UNKNOWN — e.g. the registering component has not rendered yet). On `null` the');
|
|
412
|
+
L.push('Fiodos engine blocks the gated action with an honest "can\'t do that from here"');
|
|
413
|
+
L.push('message; it never claims the user is signed out when it cannot know.');
|
|
414
|
+
L.push('');
|
|
415
|
+
L.push('## Security');
|
|
416
|
+
L.push('');
|
|
417
|
+
L.push('- Only a boolean session verdict and an opaque user id the app already holds are read.');
|
|
418
|
+
L.push('- No tokens, credentials or secrets are referenced or embedded.');
|
|
419
|
+
L.push('- Remove the generated file and the `FYODOS:SESSION` blocks to revert entirely.');
|
|
420
|
+
L.push('');
|
|
421
|
+
return L.join('\n');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── Main entry ──────────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Wire the session automatically. Options:
|
|
428
|
+
* session — the AI's `session` block from the analysis.
|
|
429
|
+
* assumeYes — apply without asking (piggybacks on the handler-wiring consent).
|
|
430
|
+
* runTest / testRunner — same build safety net as handler wiring.
|
|
431
|
+
* Returns { status, sessionFile?, docPath?, editedFile?, reason? } with status in:
|
|
432
|
+
* 'applied' | 'applied-untested' | 'no-session' | 'review' | 'declined' |
|
|
433
|
+
* 'reverted' | 'skipped'.
|
|
434
|
+
*/
|
|
435
|
+
async function wireSession(appRoot, opts = {}) {
|
|
436
|
+
const {
|
|
437
|
+
session,
|
|
438
|
+
colors = {},
|
|
439
|
+
quiet = false,
|
|
440
|
+
assumeYes = false,
|
|
441
|
+
noWire = false,
|
|
442
|
+
framework = 'web',
|
|
443
|
+
appName,
|
|
444
|
+
runTest = false,
|
|
445
|
+
testRunner = null,
|
|
446
|
+
ts: tsOpt,
|
|
447
|
+
esm: esmOpt,
|
|
448
|
+
} = opts;
|
|
449
|
+
const { blue, cyan, dim, reset } = colors;
|
|
450
|
+
const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
|
|
451
|
+
|
|
452
|
+
if (noWire) return { status: 'skipped', reason: 'no-wire' };
|
|
453
|
+
if (!session) return { status: 'no-session', reason: 'the analysis returned no session block' };
|
|
454
|
+
|
|
455
|
+
const fyodosDirRel = resolveFiodosDirRel(appRoot);
|
|
456
|
+
const fyodosDirAbs = path.join(appRoot, fyodosDirRel);
|
|
457
|
+
const detected = detectTsEsm(appRoot);
|
|
458
|
+
const ts = tsOpt != null ? tsOpt : detected.ts;
|
|
459
|
+
const esm = esmOpt != null ? esmOpt : detected.esm;
|
|
460
|
+
const ext = ts ? '.ts' : esm ? '.js' : '.cjs';
|
|
461
|
+
|
|
462
|
+
const prep = prepareSessionPlan({ appRoot, session, fyodosDirRel });
|
|
463
|
+
if (prep.none) return { status: 'no-session', reason: prep.reason };
|
|
464
|
+
if (!prep.plan) {
|
|
465
|
+
if (!quiet) {
|
|
466
|
+
console.error(`${tag} · ${dim || ''}Session wiring left for review: ${prep.reason}${reset || ''}`);
|
|
467
|
+
}
|
|
468
|
+
return { status: 'review', reason: prep.reason };
|
|
469
|
+
}
|
|
470
|
+
const plan = prep.plan;
|
|
471
|
+
|
|
472
|
+
// Consent. Normally the handler-wiring consent already covered code edits
|
|
473
|
+
// (assumeYes=true from the caller); ask only when it did not.
|
|
474
|
+
if (!assumeYes) {
|
|
475
|
+
if (!process.stdin.isTTY) return { status: 'declined', reason: 'non-interactive' };
|
|
476
|
+
const target = plan.strategy === 'bridge' ? ` (registers it from \`${plan.bridge.file}\`)` : '';
|
|
477
|
+
const yes = await askYesNo(
|
|
478
|
+
`\n${tag} · Connect the orb to your app's session state automatically${target}? [yes/no] `,
|
|
479
|
+
);
|
|
480
|
+
if (!yes) return { status: 'declined' };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Baseline BEFORE touching anything.
|
|
484
|
+
let baseline = null;
|
|
485
|
+
if (runTest && testRunner) {
|
|
486
|
+
try {
|
|
487
|
+
baseline = await testRunner(appRoot, { framework });
|
|
488
|
+
} catch (err) {
|
|
489
|
+
baseline = { ok: false, stage: 'runner', output: err && err.message };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
fs.mkdirSync(fyodosDirAbs, { recursive: true });
|
|
494
|
+
const sessionAbs = path.join(fyodosDirAbs, `${SESSION_BASENAME}${ext}`);
|
|
495
|
+
const bridgeAbs = path.join(fyodosDirAbs, `${BRIDGE_BASENAME}${ext}`);
|
|
496
|
+
const generated = [sessionAbs];
|
|
497
|
+
let backups = [];
|
|
498
|
+
let bridgeCreatedByUs = false;
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
if (plan.strategy === 'bridge' && !fs.existsSync(bridgeAbs)) {
|
|
502
|
+
fs.writeFileSync(bridgeAbs, buildBridgeModule({ ts, esm }));
|
|
503
|
+
bridgeCreatedByUs = true;
|
|
504
|
+
generated.push(bridgeAbs);
|
|
505
|
+
}
|
|
506
|
+
fs.writeFileSync(sessionAbs, buildSessionModule(plan, { ts, esm }));
|
|
507
|
+
const edit = planSessionComponentEdit(appRoot, plan);
|
|
508
|
+
if (edit) backups = [applySessionComponentEdit(appRoot, edit)];
|
|
509
|
+
} catch (err) {
|
|
510
|
+
for (const b of backups) fs.writeFileSync(b.abs, b.original);
|
|
511
|
+
for (const f of generated) {
|
|
512
|
+
try {
|
|
513
|
+
if (fs.existsSync(f)) fs.rmSync(f);
|
|
514
|
+
} catch {
|
|
515
|
+
/* best effort */
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return { status: 'reverted', reason: err && err.message };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Build safety net: if the app compiled before and stops compiling now,
|
|
522
|
+
// revert the session wiring entirely — never leave the app broken.
|
|
523
|
+
let verified = false;
|
|
524
|
+
let verifyNote = '';
|
|
525
|
+
if (runTest && testRunner) {
|
|
526
|
+
let after;
|
|
527
|
+
try {
|
|
528
|
+
after = await testRunner(appRoot, { framework });
|
|
529
|
+
} catch (err) {
|
|
530
|
+
after = { ok: false, stage: 'runner', output: err && err.message };
|
|
531
|
+
}
|
|
532
|
+
if (after && after.ok) {
|
|
533
|
+
verified = true;
|
|
534
|
+
} else if (baseline && baseline.ok) {
|
|
535
|
+
for (const b of backups) fs.writeFileSync(b.abs, b.original);
|
|
536
|
+
for (const f of generated) {
|
|
537
|
+
try {
|
|
538
|
+
if (fs.existsSync(f)) fs.rmSync(f);
|
|
539
|
+
} catch {
|
|
540
|
+
/* best effort */
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (!quiet) {
|
|
544
|
+
console.error(
|
|
545
|
+
`${tag} · ${dim || ''}Session wiring reverted: the app stopped compiling with it applied.${reset || ''}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return { status: 'reverted', reason: 'compile regression', output: after && after.output };
|
|
549
|
+
} else {
|
|
550
|
+
verifyNote = 'the app did not compile BEFORE wiring either — applied; symbols were verified mechanically';
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const docAbs = path.join(fyodosDirAbs, DOC_BASENAME);
|
|
555
|
+
const sessionRel = path.relative(appRoot, sessionAbs).split(path.sep).join('/');
|
|
556
|
+
fs.writeFileSync(
|
|
557
|
+
docAbs,
|
|
558
|
+
buildSessionDoc(plan, {
|
|
559
|
+
appName: appName || path.basename(appRoot),
|
|
560
|
+
sessionRel,
|
|
561
|
+
editedFile: plan.strategy === 'bridge' ? plan.bridge.file : '',
|
|
562
|
+
verified,
|
|
563
|
+
verifyNote,
|
|
564
|
+
}),
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
status: verified ? 'applied' : 'applied-untested',
|
|
569
|
+
sessionFile: sessionAbs,
|
|
570
|
+
docPath: docAbs,
|
|
571
|
+
editedFile: plan.strategy === 'bridge' ? plan.bridge.file : '',
|
|
572
|
+
strategy: plan.strategy,
|
|
573
|
+
bridgeCreated: bridgeCreatedByUs,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function reportSessionResult(result, colors = {}, { quiet = false } = {}) {
|
|
578
|
+
if (!result) return;
|
|
579
|
+
const { blue, cyan, dim, reset } = colors;
|
|
580
|
+
const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
|
|
581
|
+
switch (result.status) {
|
|
582
|
+
case 'applied':
|
|
583
|
+
console.error(`${tag} · session connected automatically (${result.strategy}) — the agent now knows when the user is signed in`);
|
|
584
|
+
break;
|
|
585
|
+
case 'applied-untested':
|
|
586
|
+
console.error(`${tag} · session connected (${result.strategy}; compile check unavailable)`);
|
|
587
|
+
break;
|
|
588
|
+
case 'review':
|
|
589
|
+
console.error(`${tag} · ${dim || ''}session left for review: ${result.reason}${reset || ''}`);
|
|
590
|
+
break;
|
|
591
|
+
case 'reverted':
|
|
592
|
+
console.error(`${tag} · ${dim || ''}session wiring reverted (${result.reason})${reset || ''}`);
|
|
593
|
+
break;
|
|
594
|
+
case 'no-session':
|
|
595
|
+
if (!quiet) console.error(`${tag} · ${dim || ''}no login detected — session wiring not needed${reset || ''}`);
|
|
596
|
+
break;
|
|
597
|
+
default:
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
module.exports = {
|
|
603
|
+
wireSession,
|
|
604
|
+
reportSessionResult,
|
|
605
|
+
// exported for tests
|
|
606
|
+
prepareSessionPlan,
|
|
607
|
+
buildSessionModule,
|
|
608
|
+
buildSessionDoc,
|
|
609
|
+
planSessionComponentEdit,
|
|
610
|
+
applySessionComponentEdit,
|
|
611
|
+
stripSessionBlocks,
|
|
612
|
+
SESSION_BASENAME,
|
|
613
|
+
IS_AUTH_EXPORT,
|
|
614
|
+
USER_ID_EXPORT,
|
|
615
|
+
BRIDGE_IS_AUTH,
|
|
616
|
+
BRIDGE_USER_ID,
|
|
617
|
+
SESSION_EDIT_START,
|
|
618
|
+
SESSION_EDIT_END,
|
|
619
|
+
};
|