@fiodos/cli 0.1.14 → 0.1.15
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/index.js +93 -8
- package/src/wireHandlers.js +61 -197
- package/src/wireWeb.js +154 -9
- package/src/wireWebMount.js +205 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Fiodos CLI — analyzes your app's source code and generates the in-app voice-agent manifest, then wires the orb. Powers `npx @fiodos/cli analyze`.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
package/src/index.js
CHANGED
|
@@ -93,7 +93,7 @@ const { collectFiles } = require('./collect');
|
|
|
93
93
|
const { resolveAnalysisRoot } = require('./resolveAnalysisRoot');
|
|
94
94
|
const { analyzeWithAI, correctActionWiring, DEFAULT_MODEL, resolveModel } = require('./aiAnalyze');
|
|
95
95
|
const { verifyManifest } = require('./verify');
|
|
96
|
-
const { wireWebOrb, reportWireResult } = require('./wireWeb');
|
|
96
|
+
const { wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResult } = require('./wireWeb');
|
|
97
97
|
const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
|
|
98
98
|
const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
|
|
99
99
|
const { scoreSurface, computeSurface } = require('./scoreSurface');
|
|
@@ -526,6 +526,9 @@ async function main() {
|
|
|
526
526
|
|
|
527
527
|
// --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
|
|
528
528
|
// isolation without modifying the app's entrypoint / package.json).
|
|
529
|
+
let orbProofPosted = false;
|
|
530
|
+
let orbMounted = false;
|
|
531
|
+
let orbMountFile = '';
|
|
529
532
|
if (!process.argv.includes('--no-orb-wire')) {
|
|
530
533
|
setLabel(orbMountSpinnerLabel(platform));
|
|
531
534
|
if (!assumeYes) pause();
|
|
@@ -538,16 +541,22 @@ async function main() {
|
|
|
538
541
|
});
|
|
539
542
|
if (!assumeYes) resume();
|
|
540
543
|
report(() => reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
544
|
+
orbMounted = result.status === 'added' || result.status === 'already';
|
|
541
545
|
if (
|
|
542
546
|
publishing &&
|
|
543
547
|
apiKey &&
|
|
544
548
|
(result.status === 'added' || result.status === 'already')
|
|
545
549
|
) {
|
|
546
550
|
// Records the install proof so the dashboard onboarding orb check can
|
|
547
|
-
// complete without booting the app.
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
+
// complete without booting the app. We post 'pending' here: the orb is
|
|
552
|
+
// mounted, but action wiring (below) has NOT happened yet — so the
|
|
553
|
+
// dashboard must keep showing "installing" until we post the terminal
|
|
554
|
+
// status after wireHandlers. If the backend can't record it (e.g. an
|
|
555
|
+
// outdated deployment without the endpoint), keep it as a dev-only
|
|
556
|
+
// trace — it is not actionable noise for the end developer.
|
|
557
|
+
orbMountFile = result.file || '';
|
|
558
|
+
const proof = await postOrbWired({ apiKey, apiUrl, file: orbMountFile, actionsStatus: 'pending' });
|
|
559
|
+
orbProofPosted = true;
|
|
551
560
|
if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
|
|
552
561
|
logDev(`[orb-wired] install proof not recorded (status=${proof.status || proof.reason}); dashboard check will rely on a runtime ping`);
|
|
553
562
|
}
|
|
@@ -559,6 +568,40 @@ async function main() {
|
|
|
559
568
|
const handlerResult = await wireHandlers(analysisRoot, handlerOpts);
|
|
560
569
|
if (!assumeYes) resume();
|
|
561
570
|
report(() => reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
571
|
+
|
|
572
|
+
// Second pass — connect the generated registry to the mounted orb so it
|
|
573
|
+
// EXECUTES actions, not just navigates. Removes the manual "paste
|
|
574
|
+
// registries={…}" step. Only when the orb is mounted AND there are
|
|
575
|
+
// auto-wired handlers (autoCount > 0): nav-only / all-manual apps don't
|
|
576
|
+
// need a registry and are left untouched. Idempotent + reversible.
|
|
577
|
+
const handlerApplied =
|
|
578
|
+
handlerResult &&
|
|
579
|
+
(handlerResult.status === 'applied' || handlerResult.status === 'applied-untested') &&
|
|
580
|
+
(handlerResult.autoCount || 0) > 0 &&
|
|
581
|
+
handlerResult.registryFile;
|
|
582
|
+
if (orbMounted && handlerApplied && !process.argv.includes('--no-orb-wire')) {
|
|
583
|
+
const connected = connectOrbRegistries(analysisRoot, {
|
|
584
|
+
registryFileAbs: handlerResult.registryFile,
|
|
585
|
+
});
|
|
586
|
+
report(() => reportRegistriesResult(connected, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Finalize the install proof: now that the (slow) action wiring is done,
|
|
590
|
+
// tell the dashboard the terminal status so its "actions connected" tick
|
|
591
|
+
// turns green for real — never before wiring actually finished. Only do
|
|
592
|
+
// this when we already posted the mount proof, so --no-orb-wire and
|
|
593
|
+
// runtime-only flows don't get a spurious install record.
|
|
594
|
+
if (orbProofPosted) {
|
|
595
|
+
const { status, total, done } = actionsWiredFromHandlerResult(handlerResult);
|
|
596
|
+
await postOrbWired({
|
|
597
|
+
apiKey,
|
|
598
|
+
apiUrl,
|
|
599
|
+
file: orbMountFile,
|
|
600
|
+
actionsStatus: status,
|
|
601
|
+
actionsTotal: total,
|
|
602
|
+
actionsDone: done,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
562
605
|
};
|
|
563
606
|
|
|
564
607
|
if (publishing) {
|
|
@@ -870,16 +913,29 @@ async function publishManifest(manifest, meta, analysisToken = null, opts = {})
|
|
|
870
913
|
* and URL their running app must use. Never hard-fails the publish (the publish
|
|
871
914
|
* already succeeded); a failed re-fetch is surfaced as an actionable warning.
|
|
872
915
|
*/
|
|
873
|
-
async function postOrbWired({
|
|
916
|
+
async function postOrbWired({
|
|
917
|
+
apiKey,
|
|
918
|
+
apiUrl,
|
|
919
|
+
file,
|
|
920
|
+
actionsStatus = 'pending',
|
|
921
|
+
actionsTotal = 0,
|
|
922
|
+
actionsDone = 0,
|
|
923
|
+
}) {
|
|
874
924
|
if (!apiKey) return { ok: false, reason: 'no-api-key' };
|
|
875
925
|
try {
|
|
876
926
|
const res = await fetch(`${apiUrl}/v1/developer/orb-wired`, {
|
|
877
927
|
method: 'POST',
|
|
878
928
|
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
|
879
|
-
body: JSON.stringify({
|
|
929
|
+
body: JSON.stringify({
|
|
930
|
+
file: file || '',
|
|
931
|
+
platform: 'web',
|
|
932
|
+
actionsStatus,
|
|
933
|
+
actionsTotal,
|
|
934
|
+
actionsDone,
|
|
935
|
+
}),
|
|
880
936
|
});
|
|
881
937
|
if (res.ok) {
|
|
882
|
-
logDev(`[orb-wired] install proof recorded (${file || 'web'})`);
|
|
938
|
+
logDev(`[orb-wired] install proof recorded (${file || 'web'}, actions=${actionsStatus} ${actionsDone}/${actionsTotal})`);
|
|
883
939
|
return { ok: true };
|
|
884
940
|
}
|
|
885
941
|
logDev(`[orb-wired] could not record install proof: HTTP ${res.status}`);
|
|
@@ -889,6 +945,35 @@ async function postOrbWired({ apiKey, apiUrl, file }) {
|
|
|
889
945
|
return { ok: false, reason: e.message || String(e) };
|
|
890
946
|
}
|
|
891
947
|
}
|
|
948
|
+
|
|
949
|
+
// Map a wireHandlers() result to the terminal action-wiring status the backend
|
|
950
|
+
// records, plus how many actions ended up wired. Drives the onboarding's third
|
|
951
|
+
// "actions connected" tick so it only goes green once wiring truly finished.
|
|
952
|
+
function actionsWiredFromHandlerResult(handlerResult) {
|
|
953
|
+
const r = handlerResult || {};
|
|
954
|
+
const total = Number(r.autoCount || 0) + Number(r.manualCount || 0);
|
|
955
|
+
const done = Number(r.autoCount || 0);
|
|
956
|
+
let status;
|
|
957
|
+
switch (r.status) {
|
|
958
|
+
case 'no-actions':
|
|
959
|
+
status = 'none';
|
|
960
|
+
break;
|
|
961
|
+
case 'skipped':
|
|
962
|
+
status = 'skipped';
|
|
963
|
+
break;
|
|
964
|
+
case 'reverted':
|
|
965
|
+
case 'failed':
|
|
966
|
+
status = 'failed';
|
|
967
|
+
break;
|
|
968
|
+
case 'applied':
|
|
969
|
+
case 'applied-untested':
|
|
970
|
+
status = r.partial ? 'partial' : 'complete';
|
|
971
|
+
break;
|
|
972
|
+
default:
|
|
973
|
+
status = 'complete';
|
|
974
|
+
}
|
|
975
|
+
return { status, total, done };
|
|
976
|
+
}
|
|
892
977
|
async function verifyOrbCanFetch({ apiKey, apiUrl }) {
|
|
893
978
|
try {
|
|
894
979
|
const res = await fetch(`${apiUrl}/v1/client/manifest`, {
|
package/src/wireHandlers.js
CHANGED
|
@@ -736,13 +736,15 @@ function buildRegistryModule(plan, opts = {}) {
|
|
|
736
736
|
}
|
|
737
737
|
|
|
738
738
|
// Self-contained types (no @fyodos import) so the file typechecks even before
|
|
739
|
-
// the SDK is installed; the shape
|
|
740
|
-
// ActionRegistries
|
|
739
|
+
// the SDK is installed; the shape MUST stay assignable to @fiodos/core's
|
|
740
|
+
// ActionRegistries so `<FiodosAgent registries={...}/>` type-checks. In
|
|
741
|
+
// particular idempotencyCheckers must match IdempotencyChecker —
|
|
742
|
+
// `(context) => Promise<{ alreadyApplied; message? }>` — not a boolean.
|
|
741
743
|
const typeDefs = ts
|
|
742
744
|
? 'type FiodosActionResult = { success: boolean; data?: Record<string, unknown>; error?: string };\n' +
|
|
743
745
|
'type FiodosRegistries = {\n' +
|
|
744
746
|
' handlers: Record<string, (params: Record<string, any>) => Promise<FiodosActionResult>>;\n' +
|
|
745
|
-
' idempotencyCheckers: Record<string, (
|
|
747
|
+
' idempotencyCheckers: Record<string, (context: Record<string, any>) => Promise<{ alreadyApplied: boolean; message?: string }>>;\n' +
|
|
746
748
|
'};\n'
|
|
747
749
|
: '';
|
|
748
750
|
const resultType = ts ? ': Promise<FiodosActionResult>' : '';
|
|
@@ -751,14 +753,11 @@ function buildRegistryModule(plan, opts = {}) {
|
|
|
751
753
|
const handlerBlocks = plan.auto.map((e) => {
|
|
752
754
|
const takesParams = e.usesParams != null ? e.usesParams : /\bparams\b/.test(e.callExpr);
|
|
753
755
|
const arg = takesParams ? `params${paramsType}` : `_params${paramsType}`;
|
|
754
|
-
const confNote = e.requireConfirmation
|
|
755
|
-
? '\n // Confirmation action: the Fiodos engine already asked for confirmation BEFORE calling here.'
|
|
756
|
-
: '';
|
|
757
756
|
// `: unknown` lets the truthiness test below be legal even when the wired
|
|
758
757
|
// call returns void (e.g. db.delete(...)) — otherwise TS errors on `result &&`.
|
|
759
758
|
const resultDecl = ts ? 'const result: unknown' : 'const result';
|
|
760
759
|
return (
|
|
761
|
-
` ${e.handler}: async (${arg})${resultType} => {
|
|
760
|
+
` ${e.handler}: async (${arg})${resultType} => {\n` +
|
|
762
761
|
` try {\n` +
|
|
763
762
|
` ${resultDecl} = await Promise.resolve(${e.callExpr});\n` +
|
|
764
763
|
` return { success: true, data: (result && typeof result === 'object') ? result as Record<string, unknown> : { result } };\n` +
|
|
@@ -772,20 +771,19 @@ function buildRegistryModule(plan, opts = {}) {
|
|
|
772
771
|
// Plain-JS variant: drop the `as` cast.
|
|
773
772
|
const handlerText = (ts ? handlerBlocks.join('\n') : handlerBlocks.join('\n').replace(/ as Record<string, unknown>/g, ''));
|
|
774
773
|
|
|
775
|
-
|
|
776
|
-
|
|
774
|
+
// List each handler that needs manual wiring once. Skip any handler already
|
|
775
|
+
// auto-wired above (the same handler name can back both an auto-wired and a
|
|
776
|
+
// manual intent — listing it as "manual" would contradict the wired entry).
|
|
777
|
+
const autoHandlers = new Set(plan.auto.map((e) => e.handler));
|
|
778
|
+
const manualHandlers = [...new Set(plan.manual.map((e) => e.handler))].filter(
|
|
779
|
+
(h) => !autoHandlers.has(h),
|
|
780
|
+
);
|
|
781
|
+
const manualNotes = manualHandlers.map(
|
|
782
|
+
(h) => ` // ${h}: needs manual wiring — see ${DOC_BASENAME}`,
|
|
777
783
|
);
|
|
778
784
|
|
|
779
785
|
const header =
|
|
780
|
-
|
|
781
|
-
` * ${GENERATED_MARKER}.\n` +
|
|
782
|
-
` * Do not edit by hand: re-run the Fiodos installer to regenerate it.\n` +
|
|
783
|
-
` * Read ${DOC_BASENAME} (same folder) to see what it wires and why.\n` +
|
|
784
|
-
` *\n` +
|
|
785
|
-
` * SECURITY: this file only CALLS your functions. It never decides whether a\n` +
|
|
786
|
-
` * sensitive action runs — the Fiodos engine applies the manifest confirmations\n` +
|
|
787
|
-
` * (requireConfirmation / voiceConfirmation) BEFORE invoking any handler here.\n` +
|
|
788
|
-
` */\n`;
|
|
786
|
+
`/** ${GENERATED_MARKER}. Do not edit — re-run the Fiodos installer to regenerate. */\n`;
|
|
789
787
|
|
|
790
788
|
const decl = esm
|
|
791
789
|
? `export const ${REGISTRY_EXPORT}${ts ? ': FiodosRegistries' : ''} = {`
|
|
@@ -801,8 +799,6 @@ function buildRegistryModule(plan, opts = {}) {
|
|
|
801
799
|
`${handlerText}\n` +
|
|
802
800
|
(manualNotes.length ? `${manualNotes.join('\n')}\n` : '') +
|
|
803
801
|
` },\n` +
|
|
804
|
-
` // Idempotency checkers read your app state; implement them by hand if\n` +
|
|
805
|
-
` // an action declares idempotencyCheck (see ${DOC_BASENAME}).\n` +
|
|
806
802
|
` idempotencyCheckers: {},\n` +
|
|
807
803
|
`};\n` +
|
|
808
804
|
(esm ? '' : `\nmodule.exports = { ${REGISTRY_EXPORT} };\n`);
|
|
@@ -821,12 +817,7 @@ function buildBridgeModule(opts = {}) {
|
|
|
821
817
|
const esm = opts.esm !== false;
|
|
822
818
|
const header =
|
|
823
819
|
`${GENERATED_FILE_DIRECTIVES}` +
|
|
824
|
-
|
|
825
|
-
` * ${GENERATED_MARKER} (bridge holder).\n` +
|
|
826
|
-
` * Do not edit by hand. Your component registers its real functions here and the\n` +
|
|
827
|
-
` * generated handlers invoke them. This is what connects the agent to the state\n` +
|
|
828
|
-
` * living inside your components, without creating disconnected instances.\n` +
|
|
829
|
-
` */\n`;
|
|
820
|
+
`/** ${GENERATED_MARKER} (bridge holder). Do not edit — re-run the Fiodos installer to regenerate. */\n`;
|
|
830
821
|
if (ts) {
|
|
831
822
|
return (
|
|
832
823
|
`${header}\n` +
|
|
@@ -964,134 +955,48 @@ function confLabel(e) {
|
|
|
964
955
|
function buildHandlerDoc(plan, ctx = {}) {
|
|
965
956
|
const {
|
|
966
957
|
appName = 'your app', framework = 'web', registryRel = '', mountSnippet = '',
|
|
967
|
-
bridgeRel = '', edits = [],
|
|
958
|
+
bridgeRel = '', edits = [],
|
|
968
959
|
} = ctx;
|
|
969
|
-
const L = [];
|
|
970
|
-
L.push('# Fiodos action wiring (handler wiring)');
|
|
971
|
-
L.push('');
|
|
972
|
-
L.push(
|
|
973
|
-
'This document describes **exactly** what Fiodos will do to connect the ' +
|
|
974
|
-
'actions detected in your app with the real functions that run them. ' +
|
|
975
|
-
'**Nothing is applied until you accept in the terminal.**',
|
|
976
|
-
);
|
|
977
|
-
L.push('');
|
|
978
960
|
const review = plan.review || plan.manual || [];
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
L
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
L.push(`- Actions that need review: **${review.length}**`);
|
|
961
|
+
const bridgeEntries = plan.auto.filter((e) => e.kind === 'bridge');
|
|
962
|
+
const confirmed = plan.entries.filter((e) => e.requireConfirmation);
|
|
963
|
+
const L = [];
|
|
964
|
+
|
|
965
|
+
L.push('# Fiodos — action wiring');
|
|
985
966
|
L.push('');
|
|
986
|
-
L.push('
|
|
967
|
+
L.push('Connects each action in your manifest to the real function in your app that runs it.');
|
|
987
968
|
L.push('');
|
|
988
|
-
L.push(
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
'really exists in your code before writing anything. If the AI proposes something ' +
|
|
993
|
-
'the verifier cannot find, that action is left **for review**, never wired blindly.',
|
|
994
|
-
);
|
|
969
|
+
L.push(`- App: \`${appName}\``);
|
|
970
|
+
L.push(`- Framework: \`${framework}\``);
|
|
971
|
+
L.push(`- Wired automatically: ${plan.auto.length}`);
|
|
972
|
+
L.push(`- Need manual wiring: ${review.length}`);
|
|
995
973
|
L.push('');
|
|
996
|
-
if (Array.isArray(verification) && verification.length) {
|
|
997
|
-
L.push('## Automatic verification (real effect + retries)');
|
|
998
|
-
L.push('');
|
|
999
|
-
L.push(
|
|
1000
|
-
'After your "yes", the install **does not trust** that the wiring compiles: for each ' +
|
|
1001
|
-
'action it runs a **wire → verify → diagnose → correct** loop with no ' +
|
|
1002
|
-
'human intervention. Verification checks (1) that the project **still ' +
|
|
1003
|
-
'builds** with that action wired and, when it is safe to simulate, (2) that ' +
|
|
1004
|
-
'invoking the handler **changes the real state** (the task is added, etc.). If an ' +
|
|
1005
|
-
'action fails, the AI receives the **concrete error** and retries with corrected ' +
|
|
1006
|
-
'wiring (up to several attempts). An action is only marked **ready** when it passes ' +
|
|
1007
|
-
'verification; if it is not achieved after the retries, **only that action is ' +
|
|
1008
|
-
'reverted** and marked below as "could not be wired" — never left faking it.',
|
|
1009
|
-
);
|
|
1010
|
-
L.push('');
|
|
1011
|
-
L.push('| Action (intent) | Result | Attempts | Verification | Detail |');
|
|
1012
|
-
L.push('| --- | --- | --- | --- | --- |');
|
|
1013
|
-
for (const v of verification) {
|
|
1014
|
-
const res = v.status === 'ready' ? '✅ ready' : '❌ could not';
|
|
1015
|
-
let lvl = '—';
|
|
1016
|
-
if (v.status === 'ready') {
|
|
1017
|
-
lvl = v.level === 'effect' ? 'real effect (UI)'
|
|
1018
|
-
: v.level === 'unverifiable' ? '⚠️ effect NOT verifiable (review by hand)'
|
|
1019
|
-
: 'compilation';
|
|
1020
|
-
}
|
|
1021
|
-
const detail = String(v.detail || v.error || '').replace(/\|/g, '\\|').slice(0, 160);
|
|
1022
|
-
L.push(`| \`${v.intent}\` | ${res} | ${v.attempts} | ${lvl} | ${detail} |`);
|
|
1023
|
-
}
|
|
1024
|
-
L.push('');
|
|
1025
|
-
L.push('_"real effect (UI)" = the real component was mounted in a test DOM (jsdom) with ' +
|
|
1026
|
-
'the framework\'s standard tooling (React: react-dom; Vue: vue + @vue/compiler-sfc), ' +
|
|
1027
|
-
'the wired handler was invoked and the **change in the UI/state was observed** (the task appears, ' +
|
|
1028
|
-
'the item toggles, etc.). For the data layer (Dexie/store) the effect is observed by running ' +
|
|
1029
|
-
'the action against a real instance. "compilation" = it compiles and the real symbol exists and is ' +
|
|
1030
|
-
'invoked, but the effect could not be safely simulated here. "⚠️ effect NOT verifiable" = ' +
|
|
1031
|
-
'the wiring was applied but the real effect could not be confirmed automatically (component not ' +
|
|
1032
|
-
'mountable in isolation, or a sensitive action that is not fired) — **review/test by hand**. ' +
|
|
1033
|
-
'Sensitive actions (with confirmation) are **never** fired in the test._');
|
|
1034
|
-
L.push('');
|
|
1035
|
-
}
|
|
1036
974
|
|
|
1037
|
-
L.push('##
|
|
1038
|
-
L.push('');
|
|
1039
|
-
L.push(
|
|
1040
|
-
'The orb is already mounted, but the agent cannot **run** actions until ' +
|
|
1041
|
-
'each manifest action is connected to your app\'s real function. ' +
|
|
1042
|
-
'This wiring is that bridge: it translates each action (e.g. `addToCart`) into a ' +
|
|
1043
|
-
'call to your function (e.g. `useShop.getState().addToCart(...)`).',
|
|
1044
|
-
);
|
|
1045
|
-
L.push('');
|
|
1046
|
-
const bridgeEntries = plan.auto.filter((e) => e.kind === 'bridge');
|
|
1047
|
-
L.push('## Which files are created / edited');
|
|
975
|
+
L.push('## Files');
|
|
1048
976
|
L.push('');
|
|
1049
|
-
L.push(`-
|
|
977
|
+
L.push(`- Creates \`${registryRel}\` — the handler registry.`);
|
|
1050
978
|
if (bridgeEntries.length && bridgeRel) {
|
|
1051
|
-
L.push(`-
|
|
979
|
+
L.push(`- Creates \`${bridgeRel}\` — bridge where your components register live functions.`);
|
|
1052
980
|
}
|
|
1053
981
|
if (edits.length) {
|
|
1054
|
-
L.push('-
|
|
1055
|
-
|
|
1056
|
-
for (const ed of edits) L.push(` - \`${ed.file}\` — registers: ${ed.intents.map((i) => `\`${i}\``).join(', ')}`);
|
|
1057
|
-
} else {
|
|
1058
|
-
L.push('- **No existing file of yours is edited** (all actions were ' +
|
|
1059
|
-
'reachable from a module).');
|
|
982
|
+
L.push('- Edits (only reversible `FYODOS:BRIDGE` blocks):');
|
|
983
|
+
for (const ed of edits) L.push(` - \`${ed.file}\` — ${ed.intents.map((i) => `\`${i}\``).join(', ')}`);
|
|
1060
984
|
}
|
|
1061
|
-
L.push('- To enable it, pass the generated registry to `<FiodosAgent/>` (see "How to enable it").');
|
|
1062
985
|
L.push('');
|
|
1063
986
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
L.push(`### \`${ed.file}\``);
|
|
1073
|
-
L.push('');
|
|
1074
|
-
L.push('After the last `import` line, this is added:');
|
|
1075
|
-
L.push('');
|
|
1076
|
-
L.push('```ts');
|
|
1077
|
-
L.push(ed.importBlock);
|
|
1078
|
-
L.push('```');
|
|
1079
|
-
L.push('');
|
|
1080
|
-
L.push(ed.file.endsWith('.vue')
|
|
1081
|
-
? 'Right before `</script>`, this is added:'
|
|
1082
|
-
: `Next to the anchor \`${(ed.anchor || '').trim().slice(0, 80)}\`, this is added:`);
|
|
1083
|
-
L.push('');
|
|
1084
|
-
L.push('```ts');
|
|
1085
|
-
L.push(ed.registerBlock.replace(/^\s+/gm, (s) => s)); // keep as-is
|
|
1086
|
-
L.push('```');
|
|
1087
|
-
L.push('');
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
987
|
+
L.push('## Enable it');
|
|
988
|
+
L.push('');
|
|
989
|
+
L.push('Pass the generated registry to your orb:');
|
|
990
|
+
L.push('');
|
|
991
|
+
L.push('```tsx');
|
|
992
|
+
L.push(mountSnippet || `import { ${REGISTRY_EXPORT} } from './${registryRel.split('/').pop()}';\n\n<FiodosAgent registries={${REGISTRY_EXPORT}} />`);
|
|
993
|
+
L.push('```');
|
|
994
|
+
L.push('');
|
|
1090
995
|
|
|
1091
996
|
if (plan.auto.length) {
|
|
1092
|
-
L.push(`##
|
|
997
|
+
L.push(`## Wired automatically (${plan.auto.length})`);
|
|
1093
998
|
L.push('');
|
|
1094
|
-
L.push('| Action
|
|
999
|
+
L.push('| Action | Runs | Where | Params | Type | Confirmation |');
|
|
1095
1000
|
L.push('| --- | --- | --- | --- | --- | --- |');
|
|
1096
1001
|
for (const e of plan.auto) {
|
|
1097
1002
|
const expr = e.kind === 'bridge' ? e.bridge.invoke : e.callExpr;
|
|
@@ -1100,84 +1005,43 @@ function buildHandlerDoc(plan, ctx = {}) {
|
|
|
1100
1005
|
const params = e.manifestParams.length
|
|
1101
1006
|
? e.manifestParams.map((p) => `${p.name}${p.required ? '*' : ''}`).join(', ')
|
|
1102
1007
|
: '—';
|
|
1103
|
-
const kind = e.kind === 'bridge'
|
|
1104
|
-
? 'bridge (edits your component)'
|
|
1105
|
-
: (e.source === 'ai' ? 'module (AI)' : 'module (mechanical)');
|
|
1008
|
+
const kind = e.kind === 'bridge' ? 'bridge' : 'module';
|
|
1106
1009
|
L.push(`| \`${e.intent}\` | ${call} | ${where} | ${params} | ${kind} | ${confLabel(e)} |`);
|
|
1107
1010
|
}
|
|
1108
1011
|
L.push('');
|
|
1109
|
-
L.push('
|
|
1110
|
-
'code and **confirmed by the verifier** (the symbols exist). Parameters ' +
|
|
1111
|
-
'are mapped **by name**, never by position._');
|
|
1012
|
+
L.push('`*` = required parameter. Parameters are mapped by name.');
|
|
1112
1013
|
L.push('');
|
|
1113
1014
|
}
|
|
1114
1015
|
|
|
1115
1016
|
if (review.length) {
|
|
1116
|
-
L.push(`##
|
|
1017
|
+
L.push(`## Needs manual wiring (${review.length})`);
|
|
1117
1018
|
L.push('');
|
|
1118
|
-
L.push(
|
|
1119
|
-
'For these actions, neither did the AI propose **verifiable** wiring nor did the ' +
|
|
1120
|
-
'mechanical fallback detector find the function safely. Wiring them wrong would be ' +
|
|
1121
|
-
'worse than leaving them unwired, so they are marked for review and we explain why:',
|
|
1122
|
-
);
|
|
1019
|
+
L.push(`Add a handler in \`${registryRel}\` that calls your function and returns \`{ success: true }\` (or \`{ success: false, message }\`).`);
|
|
1123
1020
|
L.push('');
|
|
1021
|
+
L.push('| Action | Handler | Reason |');
|
|
1022
|
+
L.push('| --- | --- | --- |');
|
|
1124
1023
|
for (const e of review) {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
'`{ success: true }` (or `{ success: false, message }`).',
|
|
1134
|
-
);
|
|
1135
|
-
L.push('');
|
|
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 || '')
|
|
1027
|
+
.split('|')[0]
|
|
1028
|
+
.replace(/\s+/g, ' ')
|
|
1029
|
+
.trim()
|
|
1030
|
+
.slice(0, 160);
|
|
1031
|
+
L.push(`| \`${e.intent}\` | \`${e.handler}\` | ${reason} |`);
|
|
1136
1032
|
}
|
|
1033
|
+
L.push('');
|
|
1137
1034
|
}
|
|
1138
1035
|
|
|
1139
|
-
const confirmed = plan.entries.filter((e) => e.requireConfirmation);
|
|
1140
1036
|
L.push('## Security');
|
|
1141
1037
|
L.push('');
|
|
1142
|
-
L.push(
|
|
1143
|
-
|
|
1144
|
-
'sensitive action runs: the Fiodos engine applies the manifest confirmations ' +
|
|
1145
|
-
'(`requireConfirmation` / `voiceConfirmation`) **before** invoking ' +
|
|
1146
|
-
'the handler. It is impossible for the auto-wiring to skip a confirmation.',
|
|
1147
|
-
);
|
|
1038
|
+
L.push('- The wiring only calls your functions; the Fiodos engine enforces manifest confirmations (`requireConfirmation` / `voiceConfirmation`) before any handler runs.');
|
|
1039
|
+
L.push('- No secrets are read or embedded — only function names and the parameters the manifest declares.');
|
|
1148
1040
|
if (confirmed.length) {
|
|
1149
|
-
L.push(
|
|
1150
|
-
for (const e of confirmed) {
|
|
1151
|
-
L.push(` - \`${e.intent}\` — ${confLabel(e)}.`);
|
|
1152
|
-
}
|
|
1153
|
-
} else {
|
|
1154
|
-
L.push('- None of the detected actions require confirmation in the manifest.');
|
|
1041
|
+
L.push(`- Confirmation-protected actions: ${confirmed.map((e) => `\`${e.intent}\``).join(', ')}.`);
|
|
1155
1042
|
}
|
|
1156
|
-
L.push('- The wiring does not read, embed or expose secrets; it only references function');
|
|
1157
|
-
L.push(' names and the parameters the manifest already declares.');
|
|
1158
1043
|
L.push('');
|
|
1159
1044
|
|
|
1160
|
-
L.push('## How to enable it');
|
|
1161
|
-
L.push('');
|
|
1162
|
-
if (mountSnippet) {
|
|
1163
|
-
L.push('Pass the generated registry to your `<FiodosAgent/>`:');
|
|
1164
|
-
L.push('');
|
|
1165
|
-
L.push('```tsx');
|
|
1166
|
-
L.push(mountSnippet);
|
|
1167
|
-
L.push('```');
|
|
1168
|
-
} else {
|
|
1169
|
-
L.push('Import `' + REGISTRY_EXPORT + '` from the generated file and pass it as ' +
|
|
1170
|
-
'`registries` to your `<FiodosAgent/>`.');
|
|
1171
|
-
}
|
|
1172
|
-
L.push('');
|
|
1173
|
-
L.push('## If you said "no"');
|
|
1174
|
-
L.push('');
|
|
1175
|
-
L.push(
|
|
1176
|
-
'Your code was not touched. You can implement the wiring yourself following ' +
|
|
1177
|
-
'this document, or re-run the installer to accept the auto-wiring ' +
|
|
1178
|
-
'(`--wire-yes` to accept without prompting).',
|
|
1179
|
-
);
|
|
1180
|
-
L.push('');
|
|
1181
1045
|
return L.join('\n');
|
|
1182
1046
|
}
|
|
1183
1047
|
|
package/src/wireWeb.js
CHANGED
|
@@ -24,6 +24,10 @@ const {
|
|
|
24
24
|
writeConsentDoc,
|
|
25
25
|
agentJsx,
|
|
26
26
|
envExpr,
|
|
27
|
+
registryRelImport,
|
|
28
|
+
addRegistriesToMountSource,
|
|
29
|
+
addRegistriesToBootstrapSource,
|
|
30
|
+
buildEntryBootstrapBlock,
|
|
27
31
|
IMPORT_NAME,
|
|
28
32
|
WRAPPER_BASENAME,
|
|
29
33
|
} = require('./wireWebMount');
|
|
@@ -60,17 +64,24 @@ function firstExisting(appRoot, rels) {
|
|
|
60
64
|
|
|
61
65
|
function detectTarget(appRoot, framework = detectFramework(appRoot)) {
|
|
62
66
|
if (framework === 'vue') {
|
|
63
|
-
|
|
67
|
+
// Prefer the entry module (deterministic anchor → bootstrap injection). Fall
|
|
68
|
+
// back to the SFC <template> strategy only when there is no entry file.
|
|
69
|
+
const entry = firstExisting(appRoot, ['src/main.ts', 'src/main.js']);
|
|
70
|
+
if (entry) return { kind: 'vue-entry', file: entry, ext: path.extname(entry), framework };
|
|
71
|
+
const f = firstExisting(appRoot, ['src/App.vue']);
|
|
64
72
|
return f ? { kind: 'vue', file: f, ext: path.extname(f), framework } : { kind: 'vue', file: null, framework };
|
|
65
73
|
}
|
|
66
74
|
if (framework === 'sveltekit') {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
]);
|
|
70
|
-
|
|
75
|
+
// SvelteKit owns the bootstrap (no src/main.ts), but src/hooks.client.ts runs
|
|
76
|
+
// once in the browser — the ideal, template-free spot. Create it if missing.
|
|
77
|
+
const existing = firstExisting(appRoot, ['src/hooks.client.ts', 'src/hooks.client.js']);
|
|
78
|
+
const file = existing || path.join(appRoot, 'src/hooks.client.ts');
|
|
79
|
+
return { kind: 'sveltekit-hooks', file, ext: path.extname(file), framework };
|
|
71
80
|
}
|
|
72
81
|
if (framework === 'svelte') {
|
|
73
|
-
const
|
|
82
|
+
const entry = firstExisting(appRoot, ['src/main.ts', 'src/main.js']);
|
|
83
|
+
if (entry) return { kind: 'svelte-entry', file: entry, ext: path.extname(entry), framework };
|
|
84
|
+
const f = firstExisting(appRoot, ['src/App.svelte']);
|
|
74
85
|
return f ? { kind: 'svelte', file: f, ext: '.svelte', framework } : { kind: 'svelte', file: null, framework };
|
|
75
86
|
}
|
|
76
87
|
if (framework === 'angular') {
|
|
@@ -160,6 +171,15 @@ function printSnippet(target, framework, colors, appRoot, reason) {
|
|
|
160
171
|
console.error(`${dim}Reason: ${reason}${reset}\n`);
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
// Entry-file bootstrap frameworks: the safe fallback is the same one-shot call
|
|
175
|
+
// we would have auto-injected into the entry module — no <template> edit.
|
|
176
|
+
const entryKind = target && /(-entry|-hooks)$/.test(target.kind || '');
|
|
177
|
+
if (entryKind) {
|
|
178
|
+
const where = path.relative(appRoot, target.file);
|
|
179
|
+
console.error(`${dim}In ${where}, after your imports:${reset}\n`);
|
|
180
|
+
console.error(buildEntryBootstrapBlock());
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
163
183
|
if (framework === 'vue') {
|
|
164
184
|
const where = target && target.file ? path.relative(appRoot, target.file) : 'src/App.vue';
|
|
165
185
|
console.error(`${dim}In ${where}:${reset}\n`);
|
|
@@ -243,10 +263,23 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
243
263
|
};
|
|
244
264
|
|
|
245
265
|
const backups = backupFiles(plan.files);
|
|
266
|
+
// Files the installer creates from scratch (e.g. SvelteKit src/hooks.client.ts)
|
|
267
|
+
// are not in `backups`; a revert must DELETE them, not leave an orphan behind.
|
|
268
|
+
const createdFiles = plan.files.filter((f) => !(f in backups));
|
|
269
|
+
const revertAll = () => {
|
|
270
|
+
revertFiles(backups);
|
|
271
|
+
for (const f of createdFiles) {
|
|
272
|
+
try {
|
|
273
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
274
|
+
} catch {
|
|
275
|
+
/* best-effort */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
246
279
|
try {
|
|
247
280
|
applyMount(plan);
|
|
248
281
|
if (!verifyMounted(plan)) {
|
|
249
|
-
|
|
282
|
+
revertAll();
|
|
250
283
|
printSnippet(target, framework, colors, appRoot, 'post-edit verification failed — FiodosAgent not found in edited files');
|
|
251
284
|
return { status: 'failed', file: plan.rel };
|
|
252
285
|
}
|
|
@@ -265,7 +298,7 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
265
298
|
return { status: 'added', file: plan.rel, test };
|
|
266
299
|
}
|
|
267
300
|
// Build failed with the orb mounted — was the app building WITHOUT it?
|
|
268
|
-
|
|
301
|
+
revertAll();
|
|
269
302
|
const baseline = await build();
|
|
270
303
|
if (baseline.ok) {
|
|
271
304
|
// Built before, fails now → the mount is the regression. Keep it reverted.
|
|
@@ -282,7 +315,7 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
282
315
|
writeConsentDoc(appRoot, plan);
|
|
283
316
|
return { status: 'added', file: plan.rel };
|
|
284
317
|
} catch (err) {
|
|
285
|
-
|
|
318
|
+
revertAll();
|
|
286
319
|
printSnippet(target, framework, colors, appRoot, err.message || String(err));
|
|
287
320
|
return { status: 'failed', file: plan.rel, error: err };
|
|
288
321
|
}
|
|
@@ -317,9 +350,121 @@ function reportWireResult(result, colors = {}, opts = {}) {
|
|
|
317
350
|
}
|
|
318
351
|
}
|
|
319
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Second pass (after handler wiring): connect the generated registry to the
|
|
355
|
+
* already-mounted orb so it EXECUTES actions, not just navigates. This is what
|
|
356
|
+
* removes the manual "paste registries={…}" step. Idempotent and reversible:
|
|
357
|
+
* the prop lives inside the FYODOS:ORB block. React-family mounts only —
|
|
358
|
+
* Vue/Svelte/Angular SDKs receive handlers differently and are left to the doc.
|
|
359
|
+
* Nav-only apps (no auto-wired handlers) never reach here (the caller gates on
|
|
360
|
+
* autoCount > 0), so they are not given a needless registries prop.
|
|
361
|
+
*/
|
|
362
|
+
function connectOrbRegistries(appRoot, opts = {}) {
|
|
363
|
+
const { registryFileAbs, exportName = 'fyodosGeneratedRegistries' } = opts;
|
|
364
|
+
if (!registryFileAbs || !fs.existsSync(registryFileAbs)) return { status: 'no-registry' };
|
|
365
|
+
|
|
366
|
+
const framework = detectFramework(appRoot);
|
|
367
|
+
const target = detectTarget(appRoot, framework);
|
|
368
|
+
if (!target || !target.file) return { status: 'no-target' };
|
|
369
|
+
|
|
370
|
+
// Entry-file bootstrap (Vue / Svelte / SvelteKit): the registries go INTO the
|
|
371
|
+
// createFiodosAgent({…}) call we injected into the entry module — same safe,
|
|
372
|
+
// idempotent, reversible block. No manual "pass registries" step anywhere.
|
|
373
|
+
const bootstrapKinds = new Set(['vue-entry', 'svelte-entry', 'sveltekit-hooks']);
|
|
374
|
+
if (bootstrapKinds.has(target.kind)) {
|
|
375
|
+
let source;
|
|
376
|
+
try {
|
|
377
|
+
source = fs.readFileSync(target.file, 'utf8');
|
|
378
|
+
} catch {
|
|
379
|
+
return { status: 'not-mounted' };
|
|
380
|
+
}
|
|
381
|
+
const importPath = registryRelImport(target.file, registryFileAbs);
|
|
382
|
+
const fileRel = path.relative(appRoot, target.file);
|
|
383
|
+
const backups = backupFiles([target.file]);
|
|
384
|
+
try {
|
|
385
|
+
const res = addRegistriesToBootstrapSource(source, importPath);
|
|
386
|
+
if (!res.changed) {
|
|
387
|
+
return { status: res.reason === 'already' ? 'already' : 'not-mounted', file: fileRel };
|
|
388
|
+
}
|
|
389
|
+
fs.writeFileSync(target.file, res.source);
|
|
390
|
+
return { status: 'connected', file: fileRel };
|
|
391
|
+
} catch (e) {
|
|
392
|
+
revertFiles(backups);
|
|
393
|
+
return { status: 'failed', file: fileRel, error: e };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Only React-family mounts accept a `registries={...}` prop.
|
|
398
|
+
const reactKinds = new Set(['vite', 'next-pages', 'next-app']);
|
|
399
|
+
if (!reactKinds.has(target.kind)) return { status: 'unsupported-framework', framework };
|
|
400
|
+
|
|
401
|
+
// <FiodosAgent/> lives in the client wrapper for the Next App Router, in the
|
|
402
|
+
// entry/root file otherwise.
|
|
403
|
+
let mountFile = target.file;
|
|
404
|
+
if (target.kind === 'next-app') {
|
|
405
|
+
const dir = path.dirname(target.file);
|
|
406
|
+
mountFile =
|
|
407
|
+
[path.join(dir, `${WRAPPER_BASENAME}.tsx`), path.join(dir, `${WRAPPER_BASENAME}.jsx`)].find((f) =>
|
|
408
|
+
fs.existsSync(f),
|
|
409
|
+
) || null;
|
|
410
|
+
if (!mountFile) return { status: 'not-mounted' };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let source;
|
|
414
|
+
try {
|
|
415
|
+
source = fs.readFileSync(mountFile, 'utf8');
|
|
416
|
+
} catch {
|
|
417
|
+
return { status: 'not-mounted' };
|
|
418
|
+
}
|
|
419
|
+
if (!/<FiodosAgent\b/.test(source)) return { status: 'not-mounted' };
|
|
420
|
+
|
|
421
|
+
const importPath = registryRelImport(mountFile, registryFileAbs);
|
|
422
|
+
const fileRel = path.relative(appRoot, mountFile);
|
|
423
|
+
const backups = backupFiles([mountFile]);
|
|
424
|
+
try {
|
|
425
|
+
const res = addRegistriesToMountSource(source, exportName, importPath);
|
|
426
|
+
if (!res.changed) {
|
|
427
|
+
return { status: res.reason === 'already' ? 'already' : 'not-mounted', file: fileRel };
|
|
428
|
+
}
|
|
429
|
+
fs.writeFileSync(mountFile, res.source);
|
|
430
|
+
return { status: 'connected', file: fileRel };
|
|
431
|
+
} catch (e) {
|
|
432
|
+
revertFiles(backups);
|
|
433
|
+
return { status: 'failed', file: fileRel, error: e };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function reportRegistriesResult(result, colors = {}, opts = {}) {
|
|
438
|
+
const quiet = opts.quiet !== false;
|
|
439
|
+
if (!result) return;
|
|
440
|
+
const { blue, cyan, dim, reset } = colors;
|
|
441
|
+
const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
|
|
442
|
+
switch (result.status) {
|
|
443
|
+
case 'connected':
|
|
444
|
+
console.error(`${tag} · ${dim || ''}connected actions to the orb (registries=) in ${result.file}${reset || ''}`);
|
|
445
|
+
break;
|
|
446
|
+
case 'already':
|
|
447
|
+
if (quiet) return;
|
|
448
|
+
console.error(`${tag} · ${dim || ''}orb already wired to actions in ${result.file}${reset || ''}`);
|
|
449
|
+
break;
|
|
450
|
+
case 'unsupported-framework':
|
|
451
|
+
console.error(`${tag} · ${dim || ''}pass the generated registry to your orb manually (see FYODOS_HANDLERS.md)${reset || ''}`);
|
|
452
|
+
break;
|
|
453
|
+
case 'failed':
|
|
454
|
+
console.error(`${tag} · ✗ could not auto-connect actions to the orb${result.file ? ` in ${result.file}` : ''}. See FYODOS_HANDLERS.md.`);
|
|
455
|
+
break;
|
|
456
|
+
default:
|
|
457
|
+
// not-mounted / no-registry / no-target: silent. The orb still navigates,
|
|
458
|
+
// and the doc shows how to connect actions by hand if needed.
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
320
463
|
module.exports = {
|
|
321
464
|
wireWebOrb,
|
|
322
465
|
reportWireResult,
|
|
466
|
+
connectOrbRegistries,
|
|
467
|
+
reportRegistriesResult,
|
|
323
468
|
detectFramework,
|
|
324
469
|
detectTarget,
|
|
325
470
|
};
|
package/src/wireWebMount.js
CHANGED
|
@@ -60,6 +60,75 @@ function insertImportAfterLastImport(source, importLine) {
|
|
|
60
60
|
return lines.join('\n');
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Insert a multi-line block right after the last top-level import. The block may
|
|
64
|
+
// itself contain import statements — placed after the existing ones they stay
|
|
65
|
+
// valid top-level imports (ESM hoists them anyway). Used by the entry-file
|
|
66
|
+
// bootstrap so the orb starts before the app mounts.
|
|
67
|
+
function insertBlockAfterLastImport(source, block) {
|
|
68
|
+
const lines = source.split('\n');
|
|
69
|
+
let lastImport = -1;
|
|
70
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
71
|
+
if (/^\s*import\b/.test(lines[i])) lastImport = i;
|
|
72
|
+
}
|
|
73
|
+
if (lastImport === -1) return `${block}\n\n${source}`;
|
|
74
|
+
lines.splice(lastImport + 1, 0, '', block);
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Entry-file bootstrap (Vue / Svelte / SvelteKit) ───────────────────────────
|
|
79
|
+
// Instead of editing a framework <template> (no deterministic anchor, fragile),
|
|
80
|
+
// we inject a one-shot createFiodosAgent() call into the app's entry module
|
|
81
|
+
// (src/main.ts, hooks.client.ts). The orb mounts itself to document.body, so it
|
|
82
|
+
// never needs to live in the component tree. The whole call lives between
|
|
83
|
+
// FYODOS:ORB markers → idempotent (re-runs strip & rewrite) and reversible.
|
|
84
|
+
const BOOTSTRAP_ALIAS = '__fiodosCreateAgent';
|
|
85
|
+
const BOOTSTRAP_REGISTRY_EXPORT = 'fyodosGeneratedRegistries';
|
|
86
|
+
|
|
87
|
+
function buildEntryBootstrapBlock(opts = {}) {
|
|
88
|
+
const { registryImportPath } = opts;
|
|
89
|
+
const lines = [];
|
|
90
|
+
lines.push(ORB_SCRIPT_START);
|
|
91
|
+
lines.push(`import { createFiodosAgent as ${BOOTSTRAP_ALIAS} } from '@fiodos/web-core';`);
|
|
92
|
+
if (registryImportPath) {
|
|
93
|
+
lines.push(`import { ${BOOTSTRAP_REGISTRY_EXPORT} } from '${registryImportPath}';`);
|
|
94
|
+
}
|
|
95
|
+
lines.push(`${BOOTSTRAP_ALIAS}({`);
|
|
96
|
+
lines.push(' apiKey: import.meta.env.VITE_FYODOS_API_KEY,');
|
|
97
|
+
lines.push(' baseUrl: import.meta.env.VITE_FYODOS_API_URL,');
|
|
98
|
+
if (registryImportPath) lines.push(` registries: ${BOOTSTRAP_REGISTRY_EXPORT},`);
|
|
99
|
+
lines.push(' mount: true,');
|
|
100
|
+
lines.push('});');
|
|
101
|
+
lines.push(ORB_SCRIPT_END);
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function injectEntryBootstrap(plan, opts = {}) {
|
|
106
|
+
const file = plan.target.file;
|
|
107
|
+
let source = fs.existsSync(file) ? readFile(file) : '';
|
|
108
|
+
source = stripMarkedRegion(source, ORB_SCRIPT_START, ORB_SCRIPT_END).replace(/\n{3,}$/, '\n');
|
|
109
|
+
const block = buildEntryBootstrapBlock(opts);
|
|
110
|
+
let next;
|
|
111
|
+
if (!source.trim()) {
|
|
112
|
+
next = `${block}\n`;
|
|
113
|
+
} else {
|
|
114
|
+
next = insertBlockAfterLastImport(source, block);
|
|
115
|
+
}
|
|
116
|
+
writeFile(file, next.endsWith('\n') ? next : `${next}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Idempotently rewrite an existing entry-bootstrap block to carry the generated
|
|
120
|
+
// registries (second pass, after handler wiring). Returns { changed, source, reason? }.
|
|
121
|
+
function addRegistriesToBootstrapSource(source, registryImportPath) {
|
|
122
|
+
if (!source.includes(ORB_SCRIPT_START)) return { changed: false, source, reason: 'not-mounted' };
|
|
123
|
+
if (/\n\s*registries:\s/.test(source.slice(source.indexOf(ORB_SCRIPT_START)))) {
|
|
124
|
+
return { changed: false, source, reason: 'already' };
|
|
125
|
+
}
|
|
126
|
+
const stripped = stripMarkedRegion(source, ORB_SCRIPT_START, ORB_SCRIPT_END).replace(/\n{3,}$/, '\n');
|
|
127
|
+
const block = buildEntryBootstrapBlock({ registryImportPath });
|
|
128
|
+
const next = insertBlockAfterLastImport(stripped.trimEnd(), block);
|
|
129
|
+
return { changed: true, source: next.endsWith('\n') ? next : `${next}\n` };
|
|
130
|
+
}
|
|
131
|
+
|
|
63
132
|
function envExpr(framework, kind, ts) {
|
|
64
133
|
if (framework === 'vite' || framework === 'vue' || framework === 'svelte' || framework === 'sveltekit') {
|
|
65
134
|
if (kind === 'key') {
|
|
@@ -87,6 +156,42 @@ function isAlreadyMounted(source) {
|
|
|
87
156
|
return /FiodosAgent|fyodos-agent|FYODOS:ORB:START/.test(source);
|
|
88
157
|
}
|
|
89
158
|
|
|
159
|
+
// Import specifier (no extension, normalized slashes) from the file holding the
|
|
160
|
+
// orb mount to the generated registry module.
|
|
161
|
+
function registryRelImport(fromFile, registryAbs) {
|
|
162
|
+
let rel = path
|
|
163
|
+
.relative(path.dirname(fromFile), registryAbs)
|
|
164
|
+
.replace(/\\/g, '/')
|
|
165
|
+
.replace(/\.(tsx?|jsx?|mjs|cjs)$/, '');
|
|
166
|
+
if (!rel.startsWith('.')) rel = `./${rel}`;
|
|
167
|
+
return rel;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Idempotently add `registries={EXPORT}` to the mounted <FiodosAgent/> and
|
|
171
|
+
// import EXPORT from the generated registry. React-family mounts only. The prop
|
|
172
|
+
// lands INSIDE the FYODOS:ORB block, so removing that block reverts it; the
|
|
173
|
+
// import follows the existing FiodosAgent import convention (added once,
|
|
174
|
+
// harmless if the block is later removed). Returns { changed, source, reason? }.
|
|
175
|
+
function addRegistriesToMountSource(source, exportName, importPath) {
|
|
176
|
+
if (new RegExp(`registries=\\{${escapeRe(exportName)}\\}`).test(source)) {
|
|
177
|
+
return { changed: false, source, reason: 'already' };
|
|
178
|
+
}
|
|
179
|
+
const tagRe = /([ \t]*)<FiodosAgent\b[\s\S]*?\/>/;
|
|
180
|
+
const m = source.match(tagRe);
|
|
181
|
+
if (!m) return { changed: false, source, reason: 'not-mounted' };
|
|
182
|
+
const tag = m[0];
|
|
183
|
+
const baseIndent = m[1] || '';
|
|
184
|
+
const propMatch = tag.match(/\n([ \t]*)(?:apiKey|api-key|apiUrl|baseUrl)/);
|
|
185
|
+
const propIndent = propMatch ? propMatch[1] : `${baseIndent} `;
|
|
186
|
+
const newTag = tag.replace(/\n?[ \t]*\/>$/, `\n${propIndent}registries={${exportName}}\n${baseIndent}/>`);
|
|
187
|
+
let next = source.replace(tag, newTag);
|
|
188
|
+
const importLine = `import { ${exportName} } from '${importPath}';`;
|
|
189
|
+
if (!next.includes(importLine)) {
|
|
190
|
+
next = insertImportAfterLastImport(next, importLine);
|
|
191
|
+
}
|
|
192
|
+
return { changed: true, source: next };
|
|
193
|
+
}
|
|
194
|
+
|
|
90
195
|
/**
|
|
91
196
|
* Assess whether we can inject safely. Returns { ok, reason?, strategy, files, rel }.
|
|
92
197
|
*/
|
|
@@ -95,6 +200,19 @@ function assessMount(target, framework, appRoot) {
|
|
|
95
200
|
return { ok: false, reason: 'could not locate a framework entry file (e.g. src/main.tsx, App.vue, +layout.svelte)' };
|
|
96
201
|
}
|
|
97
202
|
const rel = path.relative(appRoot, target.file);
|
|
203
|
+
|
|
204
|
+
// Entry-file bootstrap (Vue / Svelte / SvelteKit). The orb is injected into the
|
|
205
|
+
// app's entry module, not a <template>. For SvelteKit hooks the file may not
|
|
206
|
+
// exist yet — we create it (safe: a brand-new client hook only adds our block).
|
|
207
|
+
const entryKinds = new Set(['vue-entry', 'svelte-entry', 'sveltekit-hooks']);
|
|
208
|
+
if (entryKinds.has(target.kind)) {
|
|
209
|
+
const exists = fs.existsSync(target.file);
|
|
210
|
+
if (exists && isAlreadyMounted(readFile(target.file))) {
|
|
211
|
+
return { ok: false, reason: 'orb already present', already: true, rel };
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, strategy: 'entry-bootstrap', files: [target.file], rel, framework, target, createIfMissing: !exists };
|
|
214
|
+
}
|
|
215
|
+
|
|
98
216
|
const source = readFile(target.file);
|
|
99
217
|
if (isAlreadyMounted(source)) {
|
|
100
218
|
return { ok: false, reason: 'orb already present', already: true, rel };
|
|
@@ -105,14 +223,22 @@ function assessMount(target, framework, appRoot) {
|
|
|
105
223
|
if (!source.includes('</body>')) {
|
|
106
224
|
return { ok: false, reason: `${rel} has no </body> — cannot mount the orb wrapper safely` };
|
|
107
225
|
}
|
|
108
|
-
|
|
226
|
+
// The client wrapper we create lives next to the layout. Listing it in
|
|
227
|
+
// `files` means a revert DELETES it (it isn't in the backups) instead of
|
|
228
|
+
// leaving an orphan that imports @fiodos/react.
|
|
229
|
+
const ts = target.ext === '.tsx';
|
|
230
|
+
const wrapperFile = path.join(path.dirname(target.file), `${WRAPPER_BASENAME}${ts ? '.tsx' : '.jsx'}`);
|
|
231
|
+
return { ok: true, strategy: 'next-app', files: [target.file, wrapperFile], rel, framework, target };
|
|
109
232
|
}
|
|
110
233
|
case 'next-pages':
|
|
111
234
|
case 'vite': {
|
|
112
235
|
if (/createRoot\s*\(/.test(source) && /\.render\s*\(/.test(source)) {
|
|
113
236
|
return { ok: true, strategy: 'vite-main', files: [target.file], rel, framework, target };
|
|
114
237
|
}
|
|
115
|
-
|
|
238
|
+
// Accept any component that returns JSX — `return (` OR a bare `return <…/>`
|
|
239
|
+
// (the create-next-app default `_app.tsx`). We wrap the returned element in
|
|
240
|
+
// a fragment so the orb sits beside it without breaking adjacent-JSX rules.
|
|
241
|
+
if (/return\s*[<(]/.test(source)) {
|
|
116
242
|
return { ok: true, strategy: 'react-return', files: [target.file], rel, framework, target };
|
|
117
243
|
}
|
|
118
244
|
return { ok: false, reason: `${rel} is not a recognizable React entry (no createRoot().render() or JSX return)` };
|
|
@@ -163,8 +289,13 @@ function resolveAngularTemplate(tsFile, tsSource) {
|
|
|
163
289
|
}
|
|
164
290
|
|
|
165
291
|
function describePlan(plan) {
|
|
166
|
-
const
|
|
292
|
+
const action = plan.createIfMissing ? 'Will create' : 'Will edit';
|
|
293
|
+
const lines = [`${action}: ${plan.files.map((f) => path.basename(f)).join(', ')}`];
|
|
167
294
|
switch (plan.strategy) {
|
|
295
|
+
case 'entry-bootstrap':
|
|
296
|
+
lines.push('Add createFiodosAgent({ apiKey, baseUrl, mount }) after the imports');
|
|
297
|
+
lines.push('Orb self-mounts to document.body (no <template> edit, marked FYODOS:ORB:*)');
|
|
298
|
+
break;
|
|
168
299
|
case 'next-app':
|
|
169
300
|
lines.push(`Create ${WRAPPER_BASENAME} client wrapper next to layout`);
|
|
170
301
|
lines.push(`Import wrapper and mount before </body>`);
|
|
@@ -262,6 +393,57 @@ function injectViteMain(plan) {
|
|
|
262
393
|
writeFile(target.file, source);
|
|
263
394
|
}
|
|
264
395
|
|
|
396
|
+
// Wrap the component's last `return <jsx>` (with or without parentheses) in a
|
|
397
|
+
// fragment that holds the original element AND the orb. Wrapping ANY single
|
|
398
|
+
// returned expression as `<>{expr}{orb}</>` is always valid JSX, so this is the
|
|
399
|
+
// universal, adjacent-JSX-safe fallback when there is no closing-tag anchor to
|
|
400
|
+
// slot into (e.g. the create-next-app default `return <Component {...pageProps} />`).
|
|
401
|
+
// Returns the new source, or null when no returnable JSX expression is found.
|
|
402
|
+
function wrapLastReturnInFragment(source, orbJsx) {
|
|
403
|
+
const kw = /\breturn\b/g;
|
|
404
|
+
let m;
|
|
405
|
+
let retIdx = -1;
|
|
406
|
+
while ((m = kw.exec(source))) retIdx = m.index;
|
|
407
|
+
if (retIdx === -1) return null;
|
|
408
|
+
|
|
409
|
+
let i = retIdx + 'return'.length;
|
|
410
|
+
while (i < source.length && /\s/.test(source[i])) i += 1;
|
|
411
|
+
if (source[i] !== '(' && source[i] !== '<') return null;
|
|
412
|
+
const exprStart = i;
|
|
413
|
+
|
|
414
|
+
// Scan to the end of this one expression: stop at an unmatched ')' or '}'
|
|
415
|
+
// (the wrapping paren / function body) or a top-level ';'.
|
|
416
|
+
let round = 0;
|
|
417
|
+
let curly = 0;
|
|
418
|
+
let end = source.length;
|
|
419
|
+
for (let j = exprStart; j < source.length; j += 1) {
|
|
420
|
+
const c = source[j];
|
|
421
|
+
if (c === '(') round += 1;
|
|
422
|
+
else if (c === ')') {
|
|
423
|
+
if (round === 0) { end = j; break; }
|
|
424
|
+
round -= 1;
|
|
425
|
+
} else if (c === '{') curly += 1;
|
|
426
|
+
else if (c === '}') {
|
|
427
|
+
if (curly === 0) { end = j; break; }
|
|
428
|
+
curly -= 1;
|
|
429
|
+
} else if (c === ';' && round === 0 && curly === 0) { end = j; break; }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let expr = source.slice(exprStart, end).trim();
|
|
433
|
+
if (expr.startsWith('(') && expr.endsWith(')')) expr = expr.slice(1, -1).trim();
|
|
434
|
+
if (!expr) return null;
|
|
435
|
+
|
|
436
|
+
const ind = ' ';
|
|
437
|
+
const orbIndented = orbJsx
|
|
438
|
+
.split('\n')
|
|
439
|
+
.map((l) => (l ? `${ind} ${l}` : l))
|
|
440
|
+
.join('\n');
|
|
441
|
+
const wrapped =
|
|
442
|
+
`(\n${ind}<>\n${ind} ${expr}\n${orbIndented}\n${ind}</>\n${ind.slice(2)})`;
|
|
443
|
+
|
|
444
|
+
return source.slice(0, exprStart) + wrapped + source.slice(end);
|
|
445
|
+
}
|
|
446
|
+
|
|
265
447
|
function injectReactReturn(plan) {
|
|
266
448
|
const { target, framework } = plan;
|
|
267
449
|
const ts = target.ext === '.tsx';
|
|
@@ -281,12 +463,11 @@ function injectReactReturn(plan) {
|
|
|
281
463
|
return;
|
|
282
464
|
}
|
|
283
465
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
writeFile(target.file, source);
|
|
466
|
+
// No closing-tag anchor (e.g. `return <Component {...pageProps} />`): wrap the
|
|
467
|
+
// returned element + orb in a fragment so adjacent JSX is always valid.
|
|
468
|
+
const wrapped = wrapLastReturnInFragment(source, orbBlock);
|
|
469
|
+
if (wrapped == null) throw new Error('no returnable JSX expression found');
|
|
470
|
+
writeFile(target.file, wrapped);
|
|
290
471
|
}
|
|
291
472
|
|
|
292
473
|
function injectVueSfc(plan) {
|
|
@@ -363,6 +544,9 @@ function injectAngularComponent(plan) {
|
|
|
363
544
|
|
|
364
545
|
function applyMount(plan) {
|
|
365
546
|
switch (plan.strategy) {
|
|
547
|
+
case 'entry-bootstrap':
|
|
548
|
+
injectEntryBootstrap(plan);
|
|
549
|
+
break;
|
|
366
550
|
case 'next-app':
|
|
367
551
|
injectNextApp(plan);
|
|
368
552
|
break;
|
|
@@ -398,18 +582,17 @@ function writeConsentDoc(appRoot, plan) {
|
|
|
398
582
|
const dir = path.join(appRoot, 'src', 'fyodos');
|
|
399
583
|
fs.mkdirSync(dir, { recursive: true });
|
|
400
584
|
const doc = [
|
|
401
|
-
'# Fiodos orb mount
|
|
585
|
+
'# Fiodos — orb mount',
|
|
402
586
|
'',
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
...plan.files.map((f) => `- ${path.relative(appRoot, f)}`),
|
|
587
|
+
'Mounts `<FiodosAgent />` in your app. The orb connects to the Fiodos backend',
|
|
588
|
+
'with your project API key and fetches its manifest and appearance on its own;',
|
|
589
|
+
'nothing else to paste.',
|
|
407
590
|
'',
|
|
408
|
-
|
|
409
|
-
|
|
591
|
+
`- Strategy: \`${plan.strategy}\``,
|
|
592
|
+
`- Files: ${plan.files.map((f) => `\`${path.relative(appRoot, f)}\``).join(', ')}`,
|
|
410
593
|
'',
|
|
411
|
-
'
|
|
412
|
-
|
|
594
|
+
'Edits sit between `FYODOS:ORB:START` / `FYODOS:ORB:END` markers. Remove those',
|
|
595
|
+
'blocks (and the Fiodos import) to revert.',
|
|
413
596
|
'',
|
|
414
597
|
].join('\n');
|
|
415
598
|
writeFile(path.join(dir, 'FYODOS_ORB_MOUNT.md'), doc);
|
|
@@ -430,6 +613,10 @@ module.exports = {
|
|
|
430
613
|
revertFiles,
|
|
431
614
|
writeConsentDoc,
|
|
432
615
|
isAlreadyMounted,
|
|
616
|
+
registryRelImport,
|
|
617
|
+
addRegistriesToMountSource,
|
|
618
|
+
addRegistriesToBootstrapSource,
|
|
619
|
+
buildEntryBootstrapBlock,
|
|
433
620
|
IMPORT_NAME,
|
|
434
621
|
WRAPPER_BASENAME,
|
|
435
622
|
};
|