@fiodos/cli 0.1.22 → 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.
@@ -49,7 +49,10 @@ const REGISTRY_EXPORT = 'fyodosGeneratedRegistries';
49
49
  const GENERATED_MARKER = 'GENERATED by Fiodos — handler wiring';
50
50
  // Markers wrapping every line Fiodos inserts INTO the user's own files, so the
51
51
  // edits are idempotent (re-runs strip+rewrite) and trivially reversible.
52
- const EDIT_START = '// FYODOS:BRIDGE:START (auto-generado por Fiodos seguro de eliminar)';
52
+ // The strip regex matches any text after START so blocks written by older CLI
53
+ // versions (which localized the parenthetical) are still cleaned on re-runs.
54
+ const EDIT_START_PREFIX = '// FYODOS:BRIDGE:START';
55
+ const EDIT_START = `${EDIT_START_PREFIX} (generated by Fiodos — safe to remove)`;
53
56
  const EDIT_END = '// FYODOS:BRIDGE:END';
54
57
 
55
58
  // Directives that make OUR generated files (handlers.generated, bridge) invisible
@@ -449,7 +452,7 @@ function buildAiEntry({ appRoot, fyodosDirRel, base, aiW, manifestParams }) {
449
452
  for (const id of freeIdentifiers(call)) {
450
453
  if (JS_KEYWORDS.has(id) || importedNames.has(id) || SAFE_GLOBALS.has(id) || locals.has(id)) continue;
451
454
  return {
452
- reason: `the AI's expression references '${id}', which is neither imported nor a known global/local variable; not wired to avoid calling something that doesn't exist`,
455
+ reason: `the proposed call references '${id}', which could not be verified in your code wire this action manually`,
453
456
  };
454
457
  }
455
458
 
@@ -491,14 +494,14 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
491
494
  if (!anchor) return { reason: `the AI did not provide an insertion anchor in '${file}'` };
492
495
 
493
496
  const occ = countOccurrences(content, anchor);
494
- if (occ === 0) return { reason: `the insertion anchor does not appear in '${file}'; cannot place the bridge safely` };
495
- if (occ > 1) return { reason: `the insertion anchor appears ${occ} times in '${file}'; it is ambiguous, not inserted blindly` };
497
+ if (occ === 0) return { reason: `no safe insertion point was found in '${file}' wire this action manually` };
498
+ if (occ > 1) return { reason: `the insertion point in '${file}' is ambiguous wire this action manually` };
496
499
 
497
500
  // requiredSymbols must really exist.
498
501
  const required = Array.isArray(aiW.requiredSymbols) ? aiW.requiredSymbols : [];
499
502
  for (const r of required) {
500
503
  if (!fileHasSymbol(appRoot, r && r.file, r && r.name)) {
501
- return { reason: `the verifier did not find symbol '${r && r.name}' in '${normRel(r && r.file)}' (bridge not verifiable)` };
504
+ return { reason: `symbol '${r && r.name}' was not found in '${normRel(r && r.file)}' wire this action manually` };
502
505
  }
503
506
  }
504
507
 
@@ -534,7 +537,7 @@ function buildAiBridgeEntry({ appRoot, fyodosDirRel, base, aiW }) {
534
537
  if (id === 'args' || JS_KEYWORDS.has(id) || SAFE_GLOBALS.has(id) || locals.has(id) || importedNames.has(id)) continue;
535
538
  if (fileHasSymbol(appRoot, file, id)) continue;
536
539
  return {
537
- reason: `the bridge references '${id}', which is not in the component '${file}' scope and is not imported; not wired to avoid breaking the code`,
540
+ reason: `references '${id}', which is not available in '${file}' wire this action manually`,
538
541
  };
539
542
  }
540
543
 
@@ -582,9 +585,7 @@ function buildMechanicalEntry({ appRoot, fyodosDirRel, base, manifestParams }) {
582
585
  const store = exported ? null : findStoreMethod(content, handler);
583
586
  if (!exported && !store) {
584
587
  return {
585
- reason:
586
- `'${handler}' was not found as an exported function or a store method in '${evFile}'. ` +
587
- 'It may live in a component, a class or a hook/context. Not wired blindly.',
588
+ reason: `'${handler}' in '${evFile}' could not be reached safely from a generated module — wire it manually`,
588
589
  };
589
590
  }
590
591
 
@@ -740,36 +741,82 @@ function buildRegistryModule(plan, opts = {}) {
740
741
  // ActionRegistries so `<FiodosAgent registries={...}/>` type-checks. In
741
742
  // particular idempotencyCheckers must match IdempotencyChecker —
742
743
  // `(context) => Promise<{ alreadyApplied; message? }>` — not a boolean.
744
+ // Handlers accept the engine's optional (context, intent) trailing args so a
745
+ // handler shared by several manifest actions can dispatch on the intent.
743
746
  const typeDefs = ts
744
747
  ? 'type FiodosActionResult = { success: boolean; data?: Record<string, unknown>; error?: string };\n' +
745
748
  'type FiodosRegistries = {\n' +
746
- ' handlers: Record<string, (params: Record<string, any>) => Promise<FiodosActionResult>>;\n' +
749
+ ' handlers: Record<string, (params: Record<string, any>, context?: Record<string, any>, intent?: string) => Promise<FiodosActionResult>>;\n' +
747
750
  ' idempotencyCheckers: Record<string, (context: Record<string, any>) => Promise<{ alreadyApplied: boolean; message?: string }>>;\n' +
748
751
  '};\n'
749
752
  : '';
750
753
  const resultType = ts ? ': Promise<FiodosActionResult>' : '';
751
754
  const paramsType = ts ? ': Record<string, any>' : '';
755
+ const intentType = ts ? ': string | undefined' : '';
756
+
757
+ // Shared runner: one place normalizes any wired call into an action result,
758
+ // so each handler below stays a one-liner. `unknown` keeps the truthiness
759
+ // test legal even when the wired call returns void (e.g. db.delete(...)).
760
+ const runHelper = ts
761
+ ? 'async function __fyodosRun(call: () => unknown): Promise<FiodosActionResult> {\n' +
762
+ ' try {\n' +
763
+ ' const result: unknown = await Promise.resolve(call());\n' +
764
+ " return { success: true, data: (result && typeof result === 'object') ? result as Record<string, unknown> : { result } };\n" +
765
+ ' } catch (err) {\n' +
766
+ ' return { success: false, error: err instanceof Error ? err.message : String(err) };\n' +
767
+ ' }\n' +
768
+ '}\n'
769
+ : 'async function __fyodosRun(call) {\n' +
770
+ ' try {\n' +
771
+ ' const result = await Promise.resolve(call());\n' +
772
+ " return { success: true, data: (result && typeof result === 'object') ? result : { result } };\n" +
773
+ ' } catch (err) {\n' +
774
+ ' return { success: false, error: err instanceof Error ? err.message : String(err) };\n' +
775
+ ' }\n' +
776
+ '}\n';
777
+
778
+ const entryTakesParams = (e) =>
779
+ e.usesParams != null ? e.usesParams : /\bparams\b/.test(e.callExpr);
780
+
781
+ // Group auto entries by handler name: several manifest actions may share one
782
+ // handler (e.g. three section jumps calling scrollToSection with different
783
+ // arguments). An object literal keeps only the LAST duplicate key, which
784
+ // would silently break the others — so shared handlers dispatch on the
785
+ // intent the engine passes as the third argument instead.
786
+ const byHandler = new Map();
787
+ for (const e of plan.auto) {
788
+ if (!byHandler.has(e.handler)) byHandler.set(e.handler, []);
789
+ byHandler.get(e.handler).push(e);
790
+ }
752
791
 
753
- const handlerBlocks = plan.auto.map((e) => {
754
- const takesParams = e.usesParams != null ? e.usesParams : /\bparams\b/.test(e.callExpr);
792
+ const handlerBlocks = [];
793
+ for (const [handlerName, group] of byHandler) {
794
+ const distinctCalls = new Set(group.map((e) => e.callExpr));
795
+ if (group.length === 1 || distinctCalls.size === 1) {
796
+ const e = group[0];
797
+ const arg = entryTakesParams(e) ? `params${paramsType}` : `_params${paramsType}`;
798
+ handlerBlocks.push(
799
+ ` ${handlerName}: async (${arg})${resultType} => __fyodosRun(() => ${e.callExpr}),`,
800
+ );
801
+ continue;
802
+ }
803
+ const takesParams = group.some(entryTakesParams);
755
804
  const arg = takesParams ? `params${paramsType}` : `_params${paramsType}`;
756
- // `: unknown` lets the truthiness test below be legal even when the wired
757
- // call returns void (e.g. db.delete(...)) — otherwise TS errors on `result &&`.
758
- const resultDecl = ts ? 'const result: unknown' : 'const result';
759
- return (
760
- ` ${e.handler}: async (${arg})${resultType} => {\n` +
761
- ` try {\n` +
762
- ` ${resultDecl} = await Promise.resolve(${e.callExpr});\n` +
763
- ` return { success: true, data: (result && typeof result === 'object') ? result as Record<string, unknown> : { result } };\n` +
764
- ` } catch (err) {\n` +
765
- ` return { success: false, error: err instanceof Error ? err.message : String(err) };\n` +
805
+ const cases = group
806
+ .slice(1)
807
+ .map((e) => ` case ${JSON.stringify(e.intent)}: return __fyodosRun(() => ${e.callExpr});`)
808
+ .join('\n');
809
+ handlerBlocks.push(
810
+ ` ${handlerName}: async (${arg}, _context${ts ? ': Record<string, any> | undefined' : ''}, intent${intentType})${resultType} => {\n` +
811
+ ` switch (intent) {\n` +
812
+ `${cases}\n` +
813
+ ` default: return __fyodosRun(() => ${group[0].callExpr});\n` +
766
814
  ` }\n` +
767
- ` },`
815
+ ` },`,
768
816
  );
769
- });
817
+ }
770
818
 
771
- // Plain-JS variant: drop the `as` cast.
772
- const handlerText = (ts ? handlerBlocks.join('\n') : handlerBlocks.join('\n').replace(/ as Record<string, unknown>/g, ''));
819
+ const handlerText = handlerBlocks.join('\n');
773
820
 
774
821
  // List each handler that needs manual wiring once. Skip any handler already
775
822
  // auto-wired above (the same handler name can back both an auto-wired and a
@@ -794,6 +841,7 @@ function buildRegistryModule(plan, opts = {}) {
794
841
  `${header}\n` +
795
842
  `${importLines.join('\n')}\n\n` +
796
843
  (typeDefs ? `${typeDefs}\n` : '') +
844
+ `${runHelper}\n` +
797
845
  `${decl}\n` +
798
846
  ` handlers: {\n` +
799
847
  `${handlerText}\n` +
@@ -955,7 +1003,7 @@ function confLabel(e) {
955
1003
  function buildHandlerDoc(plan, ctx = {}) {
956
1004
  const {
957
1005
  appName = 'your app', framework = 'web', registryRel = '', mountSnippet = '',
958
- bridgeRel = '', edits = [],
1006
+ bridgeRel = '', edits = [], verification = [],
959
1007
  } = ctx;
960
1008
  const review = plan.review || plan.manual || [];
961
1009
  const bridgeEntries = plan.auto.filter((e) => e.kind === 'bridge');
@@ -993,6 +1041,17 @@ function buildHandlerDoc(plan, ctx = {}) {
993
1041
  L.push('```');
994
1042
  L.push('');
995
1043
 
1044
+ if (verification.length) {
1045
+ L.push('## Automatic verification');
1046
+ L.push('');
1047
+ L.push('Every action below was verified in your project before being written; anything that could not be verified was left for manual wiring instead of being installed broken.');
1048
+ L.push('');
1049
+ for (const v of verification) {
1050
+ L.push(`- \`${v.intent}\`: ${v.status === 'ready' ? 'verified and wired' : 'could not be verified — see manual wiring below'}`);
1051
+ }
1052
+ L.push('');
1053
+ }
1054
+
996
1055
  if (plan.auto.length) {
997
1056
  L.push(`## Wired automatically (${plan.auto.length})`);
998
1057
  L.push('');
@@ -1021,13 +1080,18 @@ function buildHandlerDoc(plan, ctx = {}) {
1021
1080
  L.push('| Action | Handler | Reason |');
1022
1081
  L.push('| --- | --- | --- |');
1023
1082
  for (const e of review) {
1024
- // Keep the reason to a clean one-liner: collapse whitespace, drop the
1025
- // npm/compiler dump after the first `|`, and cap length (no leaked paths).
1026
- const reason = String(e.reason || '')
1083
+ // Keep the reason to a clean one-liner: collapse whitespace and drop the
1084
+ // npm/compiler dump after the first `|` (no leaked paths). When capping
1085
+ // the length, cut at a word boundary and mark the cut with an ellipsis.
1086
+ let reason = String(e.reason || '')
1027
1087
  .split('|')[0]
1028
1088
  .replace(/\s+/g, ' ')
1029
- .trim()
1030
- .slice(0, 160);
1089
+ .trim();
1090
+ if (reason.length > 160) {
1091
+ const cut = reason.slice(0, 160);
1092
+ const atWord = cut.lastIndexOf(' ');
1093
+ reason = `${(atWord > 100 ? cut.slice(0, atWord) : cut).trimEnd()}…`;
1094
+ }
1031
1095
  L.push(`| \`${e.intent}\` | \`${e.handler}\` | ${reason} |`);
1032
1096
  }
1033
1097
  L.push('');
@@ -1049,7 +1113,7 @@ function buildHandlerDoc(plan, ctx = {}) {
1049
1113
 
1050
1114
  /** Remove any previously-inserted Fiodos blocks (idempotence across re-runs). */
1051
1115
  function stripFiodosBlocks(content) {
1052
- const re = new RegExp(`\\n?${esc(EDIT_START)}[\\s\\S]*?${esc(EDIT_END)}\\n?`, 'g');
1116
+ const re = new RegExp(`\\n?${esc(EDIT_START_PREFIX)}[^\\n]*[\\s\\S]*?${esc(EDIT_END)}\\n?`, 'g');
1053
1117
  return content.replace(re, '\n');
1054
1118
  }
1055
1119
 
@@ -1436,7 +1500,7 @@ async function wireHandlers(appRoot, opts = {}) {
1436
1500
  `import { ${REGISTRY_EXPORT} } from '${registryImport}';\n` +
1437
1501
  `\n` +
1438
1502
  `<FiodosAgent\n` +
1439
- ` apiKey={/* tu API key */}\n` +
1503
+ ` apiKey={/* your API key */}\n` +
1440
1504
  ` registries={${REGISTRY_EXPORT}}\n` +
1441
1505
  `/>`;
1442
1506
 
@@ -1790,4 +1854,26 @@ module.exports = {
1790
1854
  findExportedFunction,
1791
1855
  findStoreMethod,
1792
1856
  mapParams,
1857
+ // shared with wireSession (same verification + injection machinery)
1858
+ fileHasSymbol,
1859
+ freeIdentifiers,
1860
+ declaredLocals,
1861
+ detachedInstanceReceiver,
1862
+ countOccurrences,
1863
+ readFileSafe,
1864
+ normRel,
1865
+ relImport,
1866
+ detectTsEsm,
1867
+ resolveFiodosDirRel,
1868
+ askYesNo,
1869
+ JS_KEYWORDS,
1870
+ SAFE_GLOBALS,
1871
+ GENERATED_FILE_DIRECTIVES,
1872
+ INJECT_ESLINT_DISABLE,
1873
+ INJECT_ESLINT_ENABLE,
1874
+ EDIT_START,
1875
+ EDIT_END,
1876
+ BRIDGE_BASENAME,
1877
+ insertImportBlock,
1878
+ insertRegisterBlock,
1793
1879
  };
@@ -165,6 +165,7 @@ function unwireOrb(source) {
165
165
  if (/FYODOS:ORB/.test(line)) return true; // `// …` or `{/* … */}` markers
166
166
  if (/^import\s+\{?\s*FiodosExpoAgent\b/.test(t)) return true;
167
167
  if (/^import\s+fyodosManifest\b/.test(t)) return true;
168
+ if (/^import\s+\{\s*fyodosIsAuthenticated\b/.test(t)) return true; // session pass import
168
169
  if (/^<FiodosExpoAgent\b[^]*>$/.test(t)) return true; // our single-line open tag
169
170
  if (/^<\/FiodosExpoAgent>$/.test(t)) return true;
170
171
  return false;
@@ -258,6 +259,56 @@ function wireReactNativeOrb(appRoot, { manifest, colors = {}, noWire = false } =
258
259
  return { status: 'added', file: layout.rel };
259
260
  }
260
261
 
262
+ /**
263
+ * Connect the generated SESSION module to the wired <FiodosExpoAgent> tag:
264
+ * adds `isAuthenticated={fyodosIsAuthenticated}` and `getUserId={fyodosGetUserId}`
265
+ * plus the import. Mirrors the web mount session pass. Idempotent; never
266
+ * overrides a developer-wired isAuthenticated; validated with the same parse
267
+ * safety net as the orb wrap.
268
+ */
269
+ function connectRnSession(appRoot, { sessionFileAbs, colors = {} } = {}) {
270
+ if (!sessionFileAbs || !fs.existsSync(sessionFileAbs)) return { status: 'no-session-file' };
271
+ const layout = findRootLayout(appRoot);
272
+ if (!layout) return { status: 'not-mounted' };
273
+
274
+ let source;
275
+ try {
276
+ source = fs.readFileSync(layout.abs, 'utf8');
277
+ } catch (e) {
278
+ return { status: 'skipped', reason: `read-failed: ${e.message}` };
279
+ }
280
+ const tagRe = /<FiodosExpoAgent\b[^>]*>/;
281
+ const m = source.match(tagRe);
282
+ if (!m) return { status: 'not-mounted' };
283
+ const tag = m[0];
284
+ if (/isAuthenticated=\{fyodosIsAuthenticated\}/.test(tag)) return { status: 'already', file: layout.rel };
285
+ if (/\bisAuthenticated=/.test(tag)) return { status: 'dev-wired', file: layout.rel };
286
+
287
+ let specifier = path.relative(path.dirname(layout.abs), sessionFileAbs).split(path.sep).join('/');
288
+ specifier = specifier.replace(/\.(tsx?|jsx?)$/, '');
289
+ if (!specifier.startsWith('.')) specifier = `./${specifier}`;
290
+
291
+ const newTag = tag.replace(
292
+ />$/,
293
+ ' isAuthenticated={fyodosIsAuthenticated} getUserId={fyodosGetUserId}>',
294
+ );
295
+ let next = source.replace(tag, newTag);
296
+ const importLine = `import { fyodosIsAuthenticated, fyodosGetUserId } from '${specifier}';`;
297
+ if (!next.includes(importLine)) {
298
+ next = insertImportsAfterLastImport(next, importLine);
299
+ }
300
+
301
+ if (!parsesAsTsx(next, layout.rel)) {
302
+ return { status: 'skipped', file: layout.rel, reason: 'session props would not parse — left untouched' };
303
+ }
304
+ try {
305
+ fs.writeFileSync(layout.abs, next);
306
+ } catch (e) {
307
+ return { status: 'skipped', reason: `write-failed: ${e.message}` };
308
+ }
309
+ return { status: 'connected', file: layout.rel };
310
+ }
311
+
261
312
  function reportReactNativeWire(result, colors = {}, { quiet = false } = {}) {
262
313
  const { blue, cyan, dim, reset } = colors;
263
314
  if (!result) return;
@@ -271,6 +322,7 @@ function reportReactNativeWire(result, colors = {}, { quiet = false } = {}) {
271
322
  module.exports = {
272
323
  wireReactNativeOrb,
273
324
  reportReactNativeWire,
325
+ connectRnSession,
274
326
  // exported for tests
275
327
  buildWiredLayout,
276
328
  unwireOrb,