@fiodos/cli 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/aiAnalyze.js +81 -5
- package/src/index.js +239 -1
- package/src/postWireTest.js +25 -1
- package/src/resolveAnalysisRoot.js +48 -4
- package/src/verify.js +83 -1
- package/src/wireFlutter.js +170 -0
- package/src/wireReactNative.js +217 -0
- package/src/wireWeb.js +59 -4
- package/src/writeEnv.js +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
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/aiAnalyze.js
CHANGED
|
@@ -51,7 +51,7 @@ function resolveModel(model) {
|
|
|
51
51
|
// focus the analysis — which folder/app, what to emphasize or ignore — but never
|
|
52
52
|
// override the system rules, invent actions, skip verification or change the
|
|
53
53
|
// output schema). When empty, the prompt is byte-identical to a spec-less run.
|
|
54
|
-
const MAX_USER_SPEC_CHARS =
|
|
54
|
+
const MAX_USER_SPEC_CHARS = 2500;
|
|
55
55
|
|
|
56
56
|
function sanitizeUserSpec(raw) {
|
|
57
57
|
if (raw == null) return '';
|
|
@@ -88,9 +88,10 @@ AppManifest (all fields required unless noted):
|
|
|
88
88
|
"routes": [{
|
|
89
89
|
"intent": string (snake_case, UNIQUE across routes AND actions),
|
|
90
90
|
"label": string, "route": string (opaque route string for the app's navigation adapter — an expo-router href, a URL path, or "BACK"),
|
|
91
|
-
"description": string (one line:
|
|
91
|
+
"description": string (one DISTINCTIVE line: what THIS screen shows and what the user does there, phrased so it can NOT be confused with sibling screens — name the concrete content/entities unique to it. e.g. "Lists the registered people/accounts; from here you open and manage each individual user" — NOT a generic "a screen of the app"),
|
|
92
|
+
"bubblePrompt": string (SHORT user-facing call-to-action shown in a small "thought bubble" next to the assistant orb WHEN THE USER IS ON THIS SCREEN. One friendly question/offer in the app's language, max ~6 words, ending naturally (often a "?"). Distinct per screen and tied to what the user does HERE — e.g. a cart screen: "¿Finalizar tu compra?"; a users list: "¿Buscar un usuario?"; a login screen: "¿Quieres iniciar sesión?". NOT a description, NOT the label verbatim. Optional — omit if nothing natural fits),
|
|
92
93
|
"actionIntents": string[] (OPTIONAL: intents of the actions a user performs FROM this screen — the action handlers actually wired into THIS screen's component/page. Use exact action intent ids from the "actions" array; [] if none. Advisory only, never gates anything),
|
|
93
|
-
"examples": string[] (
|
|
94
|
+
"examples": string[] (5-8 natural voice phrases a user might say to reach this screen, REQUIRED non-empty. Include SYNONYMS and PARAPHRASES that do NOT contain the literal label word, so the agent recognizes indirect requests — e.g. for a "Users" screen: "ver usuarios", "la pantalla donde vemos a las personas", "muéstrame las cuentas", "lista de gente registrada", "quiénes están dados de alta". Vary the verb and the noun; cover the obvious phrasing AND at least two oblique ones. Avoid near-duplicates that differ only in a word)
|
|
94
95
|
}],
|
|
95
96
|
"actions": [{
|
|
96
97
|
"intent": string (snake_case, UNIQUE across routes AND actions),
|
|
@@ -106,7 +107,7 @@ AppManifest (all fields required unless noted):
|
|
|
106
107
|
"confirmationMessageTemplate": string (REQUIRED if requireConfirmation true),
|
|
107
108
|
"parameters": { "<name>": {"type": "string"|"number"|"boolean", "required": boolean, "description": string (non-empty)} },
|
|
108
109
|
"contextRequirements": {"requiresVisibleContent"?: bool, "requiresAuth"?: bool} (optional; set requiresAuth: true for actions that need a signed-in user — see the REQUIRES-AUTH rule below),
|
|
109
|
-
"examples": string[] (
|
|
110
|
+
"examples": string[] (5-8 natural voice phrases a user might say to trigger this action, REQUIRED non-empty. Include SYNONYMS and PARAPHRASES that do NOT rely on the literal label word, so indirect requests are recognized — e.g. for "Empty cart": "vacía el carrito", "quita todo lo que tengo", "borra todos los productos de la cesta". Vary verb and phrasing; avoid near-duplicates)
|
|
110
111
|
}],
|
|
111
112
|
"policies": {"requireConfirmationForDestructive": true, "allowedWithoutAuth": string[], "contextRetentionTurns": 5}
|
|
112
113
|
}
|
|
@@ -217,8 +218,17 @@ This is the context the agent reads to understand the app, so it can reason inst
|
|
|
217
218
|
- relatedIntents: other manifest intents this naturally flows into or from (e.g. add-to-cart → checkout). Use the exact intent ids; [] if none.
|
|
218
219
|
- route description: what the screen displays and what can be done there.
|
|
219
220
|
- route actionIntents: for each screen, the intents of the actions whose handlers are actually wired into THAT screen's component/page (what the user can DO there). Use exact action intent ids; omit or [] when the screen is purely informational/navigational. This is "what you can do here" context — it never restricts the agent.
|
|
221
|
+
- route bubblePrompt: a SHORT, friendly CTA (in the app's language, ~6 words max) the assistant shows beside the orb on THAT screen to invite the user to act — e.g. login screen → "¿Quieres iniciar sesión?", cart → "¿Finalizar tu compra?", users list → "¿Buscar un usuario?". Make it distinct per screen and grounded in what the user does there; it is end-user copy, not a description. Omit when nothing natural fits.
|
|
220
222
|
- appType/appFlow: the app category and its main end-to-end flow.
|
|
221
|
-
|
|
223
|
+
|
|
224
|
+
DISTINCTIVENESS (this is what prevents the agent picking the wrong screen/action):
|
|
225
|
+
- Two screens are EASILY confused when their descriptions are generic. Write each route's description so it names the concrete entities/content unique to THAT screen and could not be copy-pasted onto a sibling. Bad (confusable): "Dashboard screen of the app." Good: "Aggregate overview with KPI cards (totals of users, projects, revenue); it does NOT list individual records." vs "Lists individual user records with per-user detail."
|
|
226
|
+
- EXAMPLES are how the agent maps indirect speech to the right intent. For every route AND action, after the obvious phrasing add at least TWO oblique phrasings that a real user would say WITHOUT the label word (descriptive references to the content, e.g. "where we see the people" for a Users screen, "the page with the totals/summary" for an Overview screen). This directly reduces cross-screen confusion. Make examples DISTINCT across sibling screens — never give two screens an example that would fit both.
|
|
227
|
+
|
|
228
|
+
SENSITIVE ACTIONS — EXTRA DETAIL (still static, no per-turn change):
|
|
229
|
+
For any action with requireConfirmation true (delete/remove, logout, place/cancel orders, spend money, irreversible changes), make description, preconditions and effect noticeably MORE detailed than for normal actions: spell out exactly what is affected, what is irreversible, and the precise post-state. This richer manual lives in the static prompt so the agent reasons carefully about the stakes; it does NOT change the confirmation flow (the engine still enforces it).
|
|
230
|
+
|
|
231
|
+
Be precise and DISTINCTIVE. Descriptions stay one tight line each (no padding), but examples should be VARIED and cover several real phrasings (synonyms/paraphrases are wanted, near-duplicate filler is not).
|
|
222
232
|
|
|
223
233
|
Also return EVIDENCE for every route and action: the file (exact relative path as given in the FILE headers) and symbol (function/property name) in the provided code that justifies it. Evidence is verified mechanically afterwards: an action whose file or symbol does not exist is dropped, and unverifiable items count against you.
|
|
224
234
|
|
|
@@ -354,12 +364,75 @@ function buildUserPrompt(appMeta, files, omitted, staticRoutes, surface = null,
|
|
|
354
364
|
);
|
|
355
365
|
}
|
|
356
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Extract the FIRST complete, balanced JSON object from `text`, scanning from
|
|
369
|
+
* the first `{` to its matching `}` while respecting string literals and escape
|
|
370
|
+
* sequences (so braces inside string values don't throw the counter off).
|
|
371
|
+
* Returns the substring, or null when no balanced object is found.
|
|
372
|
+
*
|
|
373
|
+
* This is what makes parsing robust to a model that emits trailing prose (or a
|
|
374
|
+
* second brace-bearing sentence) after an otherwise valid JSON object — the
|
|
375
|
+
* cause of "Unexpected non-whitespace character after JSON at position N".
|
|
376
|
+
*/
|
|
377
|
+
function extractBalancedJsonObject(text) {
|
|
378
|
+
const start = text.indexOf('{');
|
|
379
|
+
if (start < 0) return null;
|
|
380
|
+
let depth = 0;
|
|
381
|
+
let inString = false;
|
|
382
|
+
let escaped = false;
|
|
383
|
+
for (let i = start; i < text.length; i += 1) {
|
|
384
|
+
const ch = text[i];
|
|
385
|
+
if (escaped) {
|
|
386
|
+
escaped = false;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (ch === '\\') {
|
|
390
|
+
if (inString) escaped = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === '"') {
|
|
394
|
+
inString = !inString;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (inString) continue;
|
|
398
|
+
if (ch === '{') {
|
|
399
|
+
depth += 1;
|
|
400
|
+
} else if (ch === '}') {
|
|
401
|
+
depth -= 1;
|
|
402
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
357
408
|
function parseJsonContent(raw) {
|
|
358
409
|
let trimmed = (raw || '').trim();
|
|
359
410
|
if (!trimmed) return {};
|
|
411
|
+
// Strip a leading/trailing markdown code fence if the model wrapped its JSON.
|
|
360
412
|
if (trimmed.startsWith('```')) {
|
|
361
413
|
trimmed = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
|
|
362
414
|
}
|
|
415
|
+
// 1) Happy path: the response is exactly one JSON value.
|
|
416
|
+
try {
|
|
417
|
+
return JSON.parse(trimmed);
|
|
418
|
+
} catch {
|
|
419
|
+
/* fall through to tolerant extraction */
|
|
420
|
+
}
|
|
421
|
+
// 2) Tolerant path: take the FIRST balanced {…} object and ignore anything
|
|
422
|
+
// the model appended after it (trailing prose, a second sentence, a stray
|
|
423
|
+
// fence). A brace-aware scan beats indexOf('{')+lastIndexOf('}'), which
|
|
424
|
+
// OVER-captures when the trailing text itself contains braces — the exact
|
|
425
|
+
// failure mode ("Unexpected non-whitespace character after JSON").
|
|
426
|
+
const balanced = extractBalancedJsonObject(trimmed);
|
|
427
|
+
if (balanced) {
|
|
428
|
+
try {
|
|
429
|
+
return JSON.parse(balanced);
|
|
430
|
+
} catch {
|
|
431
|
+
/* fall through to the legacy widest-slice as a last resort */
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// 3) Last resort: widest {…} slice (previous behavior) so we never regress on
|
|
435
|
+
// inputs the old path could handle.
|
|
363
436
|
const start = trimmed.indexOf('{');
|
|
364
437
|
const end = trimmed.lastIndexOf('}');
|
|
365
438
|
if (start < 0 || end <= start) throw new Error('Model response is not JSON');
|
|
@@ -580,4 +653,7 @@ module.exports = {
|
|
|
580
653
|
sanitizeUserSpec,
|
|
581
654
|
MAX_USER_SPEC_CHARS,
|
|
582
655
|
manifestSchemaDoc,
|
|
656
|
+
// Exported so the response parser is unit-testable without a live AI call.
|
|
657
|
+
parseJsonContent,
|
|
658
|
+
extractBalancedJsonObject,
|
|
583
659
|
};
|
package/src/index.js
CHANGED
|
@@ -94,6 +94,8 @@ const { resolveAnalysisRoot } = require('./resolveAnalysisRoot');
|
|
|
94
94
|
const { analyzeWithAI, correctActionWiring, DEFAULT_MODEL, resolveModel } = require('./aiAnalyze');
|
|
95
95
|
const { verifyManifest } = require('./verify');
|
|
96
96
|
const { wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResult } = require('./wireWeb');
|
|
97
|
+
const { wireReactNativeOrb, reportReactNativeWire } = require('./wireReactNative');
|
|
98
|
+
const { wireFlutterOrb, writeFlutterEnv, reportFlutterWire } = require('./wireFlutter');
|
|
97
99
|
const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
|
|
98
100
|
const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
|
|
99
101
|
const { scoreSurface, computeSurface } = require('./scoreSurface');
|
|
@@ -678,7 +680,173 @@ async function main() {
|
|
|
678
680
|
logDev(`[change-registry] could not write: ${e.message}`);
|
|
679
681
|
}
|
|
680
682
|
}
|
|
683
|
+
} else if (platform === 'mobile' && publishing) {
|
|
684
|
+
// ── Mobile (Expo / React Native): mirror the web pipeline, adapted ─────────
|
|
685
|
+
// Same single command that publishes also mounts the orb (via the SDK's
|
|
686
|
+
// one-line <FiodosExpoAgent/>), records the install proof so the dashboard
|
|
687
|
+
// connection screen completes, writes the EXPO_PUBLIC_* env, and prints the
|
|
688
|
+
// EAS production step — exactly like web, just for an app.
|
|
689
|
+
loadAppEnv(analysisRoot);
|
|
690
|
+
const { apiKey, apiUrl } = resolveApiTarget();
|
|
691
|
+
const noWire = process.argv.includes('--no-wire');
|
|
692
|
+
let orbWireResult = null;
|
|
693
|
+
let envWriteResult = null;
|
|
694
|
+
|
|
695
|
+
await withSpinner('Publishing to your project', async ({ setLabel, pause, resume }) => {
|
|
696
|
+
await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
|
|
697
|
+
if (!noWire && !process.argv.includes('--no-orb-wire')) {
|
|
698
|
+
setLabel(orbMountSpinnerLabel(platform));
|
|
699
|
+
orbWireResult = wireReactNativeOrb(analysisRoot, {
|
|
700
|
+
manifest,
|
|
701
|
+
colors: fyodosTermColors(),
|
|
702
|
+
noWire,
|
|
703
|
+
});
|
|
704
|
+
pause();
|
|
705
|
+
reportReactNativeWire(orbWireResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
706
|
+
resume();
|
|
707
|
+
// Install proof: only when the orb is genuinely wired into the code. If
|
|
708
|
+
// we could only print a snippet, we DON'T post — the dashboard then
|
|
709
|
+
// waits for the runtime heartbeat (FiodosProvider's reportOrbSeen) so a
|
|
710
|
+
// green tick always reflects a real mount, never a guess.
|
|
711
|
+
if (
|
|
712
|
+
apiKey &&
|
|
713
|
+
(orbWireResult.status === 'added' || orbWireResult.status === 'already')
|
|
714
|
+
) {
|
|
715
|
+
const proof = await postOrbWired({
|
|
716
|
+
apiKey,
|
|
717
|
+
apiUrl,
|
|
718
|
+
file: orbWireResult.file || '',
|
|
719
|
+
platform: 'mobile',
|
|
720
|
+
actionsStatus: 'done',
|
|
721
|
+
});
|
|
722
|
+
if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
|
|
723
|
+
logDev(`[orb-wired] mobile install proof not recorded (${proof.status || proof.reason}); dashboard will rely on the runtime ping`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
logUser('Published to your project');
|
|
729
|
+
|
|
730
|
+
// API key in the app environment (EXPO_PUBLIC_*) — same consent flow as web.
|
|
731
|
+
if (!process.argv.includes('--no-write-env')) {
|
|
732
|
+
try {
|
|
733
|
+
envWriteResult = await offerWriteEnv({
|
|
734
|
+
appRoot: analysisRoot,
|
|
735
|
+
apiKey,
|
|
736
|
+
apiUrl,
|
|
737
|
+
defaultApiUrl: DEFAULT_API_URL,
|
|
738
|
+
assumeYes: process.argv.includes('--write-env-yes'),
|
|
739
|
+
log: logUser,
|
|
740
|
+
logDev,
|
|
741
|
+
});
|
|
742
|
+
if (envWriteResult) printEnvManualReminder(envWriteResult, { logUser, logDev });
|
|
743
|
+
} catch (e) {
|
|
744
|
+
logDev(`[write-env] skipped: ${e.message}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Production (EAS) reminder for the build-time public key. A CLI cannot (and
|
|
749
|
+
// must not) authenticate into the user's Expo account, so we print the exact
|
|
750
|
+
// step instead — mirroring the web deploy-provider reminder.
|
|
751
|
+
if (apiKey) printExpoProductionReminder({ apiKey, appRoot: analysisRoot });
|
|
752
|
+
|
|
753
|
+
if (!noWire) {
|
|
754
|
+
try {
|
|
755
|
+
const record = buildRunRecord(analysisRoot, {
|
|
756
|
+
appName: manifest.appName,
|
|
757
|
+
manifest,
|
|
758
|
+
publishMeta,
|
|
759
|
+
orbResult: orbWireResult,
|
|
760
|
+
envResult: envWriteResult,
|
|
761
|
+
});
|
|
762
|
+
const paths = writeChangeRegistry(analysisRoot, record);
|
|
763
|
+
logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
|
|
764
|
+
} catch (e) {
|
|
765
|
+
logDev(`[change-registry] could not write: ${e.message}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
} else if (platform === 'flutter' && publishing) {
|
|
769
|
+
// ── Flutter: mirror the web/RN pipeline, adapted to Dart ──────────────────
|
|
770
|
+
// The Flutter SDK's one-line FiodosAgent self-fetches the published manifest
|
|
771
|
+
// by API key, so we don't write a manifest file. We publish, wrap lib/main's
|
|
772
|
+
// home: with FiodosAgent, record the install proof, write the dart-define env
|
|
773
|
+
// file, and print the run + production commands.
|
|
774
|
+
loadAppEnv(analysisRoot);
|
|
775
|
+
const { apiKey, apiUrl } = resolveApiTarget();
|
|
776
|
+
const noWire = process.argv.includes('--no-wire');
|
|
777
|
+
let orbWireResult = null;
|
|
778
|
+
let envWriteResult = null;
|
|
779
|
+
|
|
780
|
+
await withSpinner('Publishing to your project', async ({ setLabel, pause, resume }) => {
|
|
781
|
+
await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
|
|
782
|
+
if (!noWire && !process.argv.includes('--no-orb-wire')) {
|
|
783
|
+
setLabel(orbMountSpinnerLabel(platform));
|
|
784
|
+
orbWireResult = wireFlutterOrb(analysisRoot, { colors: fyodosTermColors(), noWire });
|
|
785
|
+
pause();
|
|
786
|
+
reportFlutterWire(orbWireResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
787
|
+
resume();
|
|
788
|
+
// Install proof only when the orb is genuinely wired into main.dart. If
|
|
789
|
+
// we could only print a snippet, we DON'T post — the dashboard then waits
|
|
790
|
+
// for the runtime heartbeat (FiodosAgent posts orb-seen on warmUp).
|
|
791
|
+
if (
|
|
792
|
+
apiKey &&
|
|
793
|
+
(orbWireResult.status === 'added' || orbWireResult.status === 'already')
|
|
794
|
+
) {
|
|
795
|
+
const proof = await postOrbWired({
|
|
796
|
+
apiKey,
|
|
797
|
+
apiUrl,
|
|
798
|
+
file: orbWireResult.file || '',
|
|
799
|
+
platform: 'flutter',
|
|
800
|
+
actionsStatus: 'done',
|
|
801
|
+
});
|
|
802
|
+
if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
|
|
803
|
+
logDev(`[orb-wired] flutter install proof not recorded (${proof.status || proof.reason}); dashboard will rely on the runtime ping`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
logUser('Published to your project');
|
|
809
|
+
|
|
810
|
+
// Dart-define env file (git-ignored) so `flutter run` has the key without
|
|
811
|
+
// hardcoding the secret in committed source.
|
|
812
|
+
if (apiKey && !process.argv.includes('--no-write-env')) {
|
|
813
|
+
try {
|
|
814
|
+
envWriteResult = writeFlutterEnv(analysisRoot, {
|
|
815
|
+
apiKey,
|
|
816
|
+
apiUrl,
|
|
817
|
+
defaultApiUrl: DEFAULT_API_URL,
|
|
818
|
+
});
|
|
819
|
+
if (envWriteResult && envWriteResult.written) {
|
|
820
|
+
logUser(`API key saved to ${envWriteResult.file} (git-ignored).`);
|
|
821
|
+
if (envWriteResult.gitignore && envWriteResult.gitignore.changed) {
|
|
822
|
+
logUser(`Added ${envWriteResult.gitignore.pattern} to .gitignore so your key is never pushed to GitHub.`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
} catch (e) {
|
|
826
|
+
logDev(`[write-env] flutter skipped: ${e.message}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (apiKey) printFlutterRunReminder({ apiKey, envFile: envWriteResult && envWriteResult.file });
|
|
831
|
+
|
|
832
|
+
if (!noWire) {
|
|
833
|
+
try {
|
|
834
|
+
const record = buildRunRecord(analysisRoot, {
|
|
835
|
+
appName: manifest.appName,
|
|
836
|
+
manifest,
|
|
837
|
+
publishMeta,
|
|
838
|
+
orbResult: orbWireResult,
|
|
839
|
+
envResult: envWriteResult,
|
|
840
|
+
});
|
|
841
|
+
const paths = writeChangeRegistry(analysisRoot, record);
|
|
842
|
+
logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
|
|
843
|
+
} catch (e) {
|
|
844
|
+
logDev(`[change-registry] could not write: ${e.message}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
681
847
|
} else if (publishing) {
|
|
848
|
+
// Native (iOS / Android): the orb ships through the native SDK — there is no
|
|
849
|
+
// JS/Dart entrypoint to wire here, so we only publish the manifest.
|
|
682
850
|
loadAppEnv(analysisRoot);
|
|
683
851
|
await withSpinner('Publishing to your project', () =>
|
|
684
852
|
publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true }),
|
|
@@ -687,6 +855,52 @@ async function main() {
|
|
|
687
855
|
}
|
|
688
856
|
}
|
|
689
857
|
|
|
858
|
+
/**
|
|
859
|
+
* How to run a Flutter app with the orb key (dev) and ship it (production).
|
|
860
|
+
* Flutter has no auto-loaded .env: the compile-time key is passed via
|
|
861
|
+
* --dart-define. We never touch the user's build pipeline — we print the exact
|
|
862
|
+
* commands, mirroring the web deploy reminder and the RN EAS reminder.
|
|
863
|
+
*/
|
|
864
|
+
function printFlutterRunReminder({ apiKey, envFile }) {
|
|
865
|
+
const file = envFile || 'fyodos.env.json';
|
|
866
|
+
logUser('Run your app with the orb — pass the key as a compile-time define (2 steps)');
|
|
867
|
+
console.log(' Step 1 — Dev run (reads the git-ignored env file we just wrote):');
|
|
868
|
+
console.log(` flutter run --dart-define-from-file=${file}`);
|
|
869
|
+
console.log('');
|
|
870
|
+
console.log(' Step 2 — Production build — pass the same define to your release build / CI:');
|
|
871
|
+
console.log(` flutter build apk --dart-define-from-file=${file}`);
|
|
872
|
+
console.log(` (or --dart-define=FYODOS_API_KEY=${apiKey})`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Production (EAS) step for Expo: EXPO_PUBLIC_* vars are inlined by Metro from
|
|
877
|
+
* the build environment. Local `.env` covers `expo start`, but cloud EAS builds
|
|
878
|
+
* (which never receive a git-ignored .env) need the key set in EAS env so the
|
|
879
|
+
* orb is not invisible in TestFlight / Play builds. We never touch the user's
|
|
880
|
+
* Expo account — we print the exact command + dashboard path.
|
|
881
|
+
*/
|
|
882
|
+
function printExpoProductionReminder({ apiKey, appRoot }) {
|
|
883
|
+
const usesEas = (() => {
|
|
884
|
+
try {
|
|
885
|
+
return fs.existsSync(path.join(appRoot, 'eas.json'));
|
|
886
|
+
} catch {
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
})();
|
|
890
|
+
logUser('Make the orb appear in PRODUCTION builds — add the key to EAS (2 steps)');
|
|
891
|
+
console.log(' Step 1 — Copy your orb key (this exact value):');
|
|
892
|
+
console.log(` ${apiKey}`);
|
|
893
|
+
console.log('');
|
|
894
|
+
console.log(' Step 2 — Set it in your EAS build environment:');
|
|
895
|
+
console.log(` eas env:create --name EXPO_PUBLIC_FYODOS_API_KEY --value ${apiKey} --environment production`);
|
|
896
|
+
console.log(' (or expo.dev → your project → Environment variables, then rebuild)');
|
|
897
|
+
if (!usesEas) {
|
|
898
|
+
console.log('');
|
|
899
|
+
console.log(' No eas.json yet? `expo start` already reads your local .env;');
|
|
900
|
+
console.log(' add the EAS variable when you set up cloud builds.');
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
690
904
|
/**
|
|
691
905
|
* Resolve the target platform from package.json dependencies — the only signal
|
|
692
906
|
* that reliably tells a Next.js App Router web app (which has an app/ dir) apart
|
|
@@ -739,9 +953,32 @@ function detectPlatform(appRoot, appDir) {
|
|
|
739
953
|
) {
|
|
740
954
|
return 'web';
|
|
741
955
|
}
|
|
956
|
+
// The Fiodos SDK the developer installed states their target platform — the
|
|
957
|
+
// strongest intent signal in a mixed monorepo whose root has no framework dep
|
|
958
|
+
// of its own (e.g. EVO: root has @fiodos/react-native, the real Expo app lives
|
|
959
|
+
// in mobile/mobile/). Without this we'd wrongly default a mobile repo to 'web'
|
|
960
|
+
// and run the web orb wiring against a sibling Next.js app.
|
|
961
|
+
const sdkPlatform = fiodosSdkPlatform(deps);
|
|
962
|
+
if (sdkPlatform) return sdkPlatform;
|
|
742
963
|
return appDir ? 'mobile' : 'web';
|
|
743
964
|
}
|
|
744
965
|
|
|
966
|
+
/**
|
|
967
|
+
* Platform implied by the installed Fiodos SDK. Returns 'mobile' | 'web' | null
|
|
968
|
+
* (null when both or neither are present — no unambiguous signal).
|
|
969
|
+
*/
|
|
970
|
+
function fiodosSdkPlatform(deps) {
|
|
971
|
+
if (!deps) return null;
|
|
972
|
+
const mobile = Boolean(deps['@fiodos/react-native']);
|
|
973
|
+
const web = Boolean(
|
|
974
|
+
deps['@fiodos/react'] || deps['@fiodos/vue'] || deps['@fiodos/svelte'] ||
|
|
975
|
+
deps['@fiodos/angular'] || deps['@fiodos/web-core'],
|
|
976
|
+
);
|
|
977
|
+
if (mobile && !web) return 'mobile';
|
|
978
|
+
if (web && !mobile) return 'web';
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
|
|
745
982
|
function readAppMeta(appRoot) {
|
|
746
983
|
const meta = { name: path.basename(appRoot), description: '', dependencies: [] };
|
|
747
984
|
const pkgPath = path.join(appRoot, 'package.json');
|
|
@@ -956,6 +1193,7 @@ async function postOrbWired({
|
|
|
956
1193
|
apiKey,
|
|
957
1194
|
apiUrl,
|
|
958
1195
|
file,
|
|
1196
|
+
platform = 'web',
|
|
959
1197
|
actionsStatus = 'pending',
|
|
960
1198
|
actionsTotal = 0,
|
|
961
1199
|
actionsDone = 0,
|
|
@@ -967,7 +1205,7 @@ async function postOrbWired({
|
|
|
967
1205
|
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
|
968
1206
|
body: JSON.stringify({
|
|
969
1207
|
file: file || '',
|
|
970
|
-
platform
|
|
1208
|
+
platform,
|
|
971
1209
|
actionsStatus,
|
|
972
1210
|
actionsTotal,
|
|
973
1211
|
actionsDone,
|
package/src/postWireTest.js
CHANGED
|
@@ -103,6 +103,30 @@ function tail(s, n = 60) {
|
|
|
103
103
|
return lines.slice(-n).join('\n').trim();
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Compact build failure for the terminal — not the full next/vite log. */
|
|
107
|
+
function summarizeBuildOutput(raw, maxLines = 4) {
|
|
108
|
+
const lines = String(raw || '').split('\n');
|
|
109
|
+
const picked = [];
|
|
110
|
+
const push = (line) => {
|
|
111
|
+
const t = line.trim();
|
|
112
|
+
if (!t || picked.includes(t)) return;
|
|
113
|
+
if (/telemetry|Learn more:/i.test(t)) return;
|
|
114
|
+
if (/^▲ Next\.js/i.test(t)) return;
|
|
115
|
+
picked.push(t);
|
|
116
|
+
};
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
if (
|
|
119
|
+
/Module not found|Can't resolve|Failed to compile|error TS\d+|SyntaxError|ENOENT/i.test(line)
|
|
120
|
+
) {
|
|
121
|
+
push(line);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (picked.length === 0) {
|
|
125
|
+
for (const line of lines.slice(-maxLines)) push(line);
|
|
126
|
+
}
|
|
127
|
+
return picked.slice(0, maxLines).join('\n').trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
106
130
|
async function runPostWireTest(appRoot, opts = {}) {
|
|
107
131
|
const timeoutMs = opts.timeoutMs || 240000;
|
|
108
132
|
if (!fs.existsSync(path.join(appRoot, 'node_modules'))) {
|
|
@@ -136,4 +160,4 @@ async function runPostWireTest(appRoot, opts = {}) {
|
|
|
136
160
|
return { ok: res.status === 0, stage: chosen.stage, command: chosen.label, output };
|
|
137
161
|
}
|
|
138
162
|
|
|
139
|
-
module.exports = { runPostWireTest, chooseCommand };
|
|
163
|
+
module.exports = { runPostWireTest, chooseCommand, summarizeBuildOutput, readPkg };
|
|
@@ -51,12 +51,49 @@ function platformDepsMatch(deps, platform) {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* How "complete" / runnable a candidate app folder looks. A monorepo can nest a
|
|
56
|
+
* thin source wrapper (e.g. mobile/) around the REAL runnable app (mobile/mobile/
|
|
57
|
+
* with app.json, the expo dep, native ios/android dirs). Conventional-name
|
|
58
|
+
* matching alone would pick the wrapper; this score lets the real app win.
|
|
59
|
+
*/
|
|
60
|
+
function appCompleteness(dir, platform) {
|
|
61
|
+
let score = 0;
|
|
62
|
+
const has = (rel) => fs.existsSync(path.join(dir, rel));
|
|
63
|
+
if (has('app') || has('src') || has('pages')) score += 2;
|
|
64
|
+
let pkg = {};
|
|
65
|
+
try {
|
|
66
|
+
pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore */
|
|
69
|
+
}
|
|
70
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
71
|
+
const scripts = pkg.scripts || {};
|
|
72
|
+
if (pkg.main) score += 2;
|
|
73
|
+
if (platform === 'mobile') {
|
|
74
|
+
if (has('app.json') || has('app.config.js') || has('app.config.ts')) score += 4;
|
|
75
|
+
if (deps.expo) score += 4;
|
|
76
|
+
if (has('ios') || has('android')) score += 2;
|
|
77
|
+
if (scripts.start || scripts.ios || scripts.android) score += 1;
|
|
78
|
+
} else if (platform === 'web') {
|
|
79
|
+
if (
|
|
80
|
+
has('next.config.js') || has('next.config.ts') || has('next.config.mjs') ||
|
|
81
|
+
has('vite.config.js') || has('vite.config.ts') || has('index.html') ||
|
|
82
|
+
has('angular.json') || has('svelte.config.js')
|
|
83
|
+
) {
|
|
84
|
+
score += 4;
|
|
85
|
+
}
|
|
86
|
+
if (scripts.build || scripts.dev) score += 1;
|
|
87
|
+
}
|
|
88
|
+
return score;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tryCandidate(appRoot, rel, platform, nameScore) {
|
|
55
92
|
const dir = path.join(appRoot, rel);
|
|
56
93
|
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null;
|
|
57
94
|
const deps = readDeps(path.join(dir, 'package.json'));
|
|
58
95
|
if (!platformDepsMatch(deps, platform)) return null;
|
|
59
|
-
return { root: dir, rel,
|
|
96
|
+
return { root: dir, rel, nameScore, complete: appCompleteness(dir, platform), reason: rel };
|
|
60
97
|
}
|
|
61
98
|
|
|
62
99
|
/**
|
|
@@ -112,8 +149,15 @@ function resolveAnalysisRoot(appRoot, platform) {
|
|
|
112
149
|
return { root: abs, scoped: false, reason: 'no sub-app detected' };
|
|
113
150
|
}
|
|
114
151
|
|
|
115
|
-
// Prefer
|
|
116
|
-
|
|
152
|
+
// Prefer the most complete/runnable app first (so a real nested app beats a
|
|
153
|
+
// thin same-named wrapper), then the conventional-name confidence, then the
|
|
154
|
+
// shorter path (closer to root).
|
|
155
|
+
candidates.sort(
|
|
156
|
+
(a, b) =>
|
|
157
|
+
b.complete - a.complete ||
|
|
158
|
+
b.nameScore - a.nameScore ||
|
|
159
|
+
a.rel.length - b.rel.length,
|
|
160
|
+
);
|
|
117
161
|
const best = candidates[0];
|
|
118
162
|
return {
|
|
119
163
|
root: best.root,
|
package/src/verify.js
CHANGED
|
@@ -31,6 +31,63 @@ function fileHasIdentifier(content, identifier) {
|
|
|
31
31
|
return re.test(content);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Upper bound on examples kept per route/action. The enriched prompt asks the
|
|
36
|
+
* model for 5-8 varied phrasings; this cap protects the cacheable system prompt
|
|
37
|
+
* from a runaway list while leaving generous room for real synonyms.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_EXAMPLES = 10;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sanitize a manifest item's `examples` array WITHOUT ever dropping the item:
|
|
43
|
+
* trims, removes empties, deduplicates case-insensitively (first spelling wins),
|
|
44
|
+
* and caps the count. Fail-open: when the model returned nothing usable, falls
|
|
45
|
+
* back to a single phrase derived from the label/intent so the core validator's
|
|
46
|
+
* "non-empty examples" rule still passes (today's contract, never weakened).
|
|
47
|
+
*/
|
|
48
|
+
function sanitizeExamples(raw, fallbackLabel) {
|
|
49
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const cleaned = [];
|
|
52
|
+
for (const entry of list) {
|
|
53
|
+
if (typeof entry !== 'string') continue;
|
|
54
|
+
const phrase = entry.replace(/\s+/g, ' ').trim();
|
|
55
|
+
if (!phrase) continue;
|
|
56
|
+
const key = phrase.toLowerCase();
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
cleaned.push(phrase);
|
|
60
|
+
if (cleaned.length >= MAX_EXAMPLES) break;
|
|
61
|
+
}
|
|
62
|
+
if (cleaned.length === 0) {
|
|
63
|
+
const fallback = String(fallbackLabel || '').replace(/\s+/g, ' ').trim();
|
|
64
|
+
if (fallback) cleaned.push(fallback);
|
|
65
|
+
}
|
|
66
|
+
return cleaned;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Max length (chars) of a route's `bubblePrompt` (the short orb CTA). The prompt
|
|
71
|
+
* asks for ~6 words; this is a generous hard cap so a runaway sentence can never
|
|
72
|
+
* bloat the bubble UI or the served manifest.
|
|
73
|
+
*/
|
|
74
|
+
const MAX_BUBBLE_PROMPT = 80;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sanitize a route's `bubblePrompt` (fail-open, item never dropped): trims,
|
|
78
|
+
* collapses whitespace, strips wrapping quotes, and caps the length. Returns
|
|
79
|
+
* undefined when there is nothing usable (the SDK then derives a fallback from
|
|
80
|
+
* the label/examples), so a bad value never sticks.
|
|
81
|
+
*/
|
|
82
|
+
function sanitizeBubblePrompt(raw) {
|
|
83
|
+
if (typeof raw !== 'string') return undefined;
|
|
84
|
+
let s = raw.replace(/\s+/g, ' ').trim();
|
|
85
|
+
s = s.replace(/^["'«»“”]+|["'«»“”]+$/g, '').trim();
|
|
86
|
+
if (!s) return undefined;
|
|
87
|
+
if (s.length > MAX_BUBBLE_PROMPT) s = s.slice(0, MAX_BUBBLE_PROMPT).trim();
|
|
88
|
+
return s || undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
34
91
|
/** Normalize an expo-router href for comparison: strip (groups), trailing /index. */
|
|
35
92
|
function normalizeHref(href) {
|
|
36
93
|
return ('/' + String(href || '')
|
|
@@ -197,8 +254,33 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
197
254
|
else delete route.actionIntents;
|
|
198
255
|
}
|
|
199
256
|
|
|
257
|
+
// Examples hygiene (fail-open): trim, dedupe and cap the enriched phrasings on
|
|
258
|
+
// every surviving route and action. Never drops the item; falls back to the
|
|
259
|
+
// label so the core validator's non-empty-examples rule still holds.
|
|
260
|
+
for (const route of dedupedRoutes) {
|
|
261
|
+
if (route.route === 'BACK') continue;
|
|
262
|
+
route.examples = sanitizeExamples(route.examples, route.label || route.intent);
|
|
263
|
+
let bubble = sanitizeBubblePrompt(route.bubblePrompt);
|
|
264
|
+
if (!bubble) {
|
|
265
|
+
try {
|
|
266
|
+
const { deriveBubblePromptFromContext, resolveMessages } = require('@fiodos/core');
|
|
267
|
+
bubble = sanitizeBubblePrompt(
|
|
268
|
+
deriveBubblePromptFromContext(route, resolveMessages('en'), {
|
|
269
|
+
locale: 'en',
|
|
270
|
+
actions: dedupedActions,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
} catch {
|
|
274
|
+
/* core unavailable — leave bubble unset */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (bubble) route.bubblePrompt = bubble;
|
|
278
|
+
else delete route.bubblePrompt;
|
|
279
|
+
}
|
|
280
|
+
|
|
200
281
|
// Schema coercion the model sometimes needs (same as the old engine).
|
|
201
282
|
for (const action of dedupedActions) {
|
|
283
|
+
action.examples = sanitizeExamples(action.examples, action.label || action.intent);
|
|
202
284
|
for (const spec of Object.values(action.parameters || {})) {
|
|
203
285
|
if (spec.type === 'integer' || spec.type === 'float') spec.type = 'number';
|
|
204
286
|
else if (!['string', 'number', 'boolean'].includes(spec.type)) spec.type = 'string';
|
|
@@ -217,4 +299,4 @@ function verifyManifest(manifest, evidence, sentFiles, staticRoutes, opts = {})
|
|
|
217
299
|
};
|
|
218
300
|
}
|
|
219
301
|
|
|
220
|
-
module.exports = { verifyManifest, normalizeHref };
|
|
302
|
+
module.exports = { verifyManifest, normalizeHref, sanitizeExamples, MAX_EXAMPLES };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireFlutter — auto-mount the Fiodos orb in a Flutter app, mirroring the web
|
|
3
|
+
* and React Native orb auto-mounts (wireWeb.js / wireReactNative.js) adapted to
|
|
4
|
+
* Dart / Flutter.
|
|
5
|
+
*
|
|
6
|
+
* The Flutter SDK already exposes a true one-line drop-in, FiodosAgent, that
|
|
7
|
+
* self-fetches the published manifest by API key, fetches the dashboard orb
|
|
8
|
+
* theme, posts the orb-seen heartbeat, and renders the themed orb. So there is
|
|
9
|
+
* no manifest file to write (unlike RN): we only
|
|
10
|
+
* 1. add `import 'package:fiodos_flutter/fiodos_flutter.dart';`, and
|
|
11
|
+
* 2. wrap the app's `home:` widget with FiodosAgent (reading the key from a
|
|
12
|
+
* compile-time --dart-define, never hardcoding the secret in source).
|
|
13
|
+
*
|
|
14
|
+
* Edits are idempotent and reversible (FYODOS:ORB markers). The orb must live
|
|
15
|
+
* INSIDE MaterialApp/CupertinoApp (it needs a Directionality/Material ancestor),
|
|
16
|
+
* which is exactly where `home:` is — so we wrap there, not at runApp(). When the
|
|
17
|
+
* `home:` value is not a simple widget expression we DON'T touch the file: we
|
|
18
|
+
* print a short snippet instead (like the web/RN paths), and the caller then
|
|
19
|
+
* skips the install proof so the dashboard waits for the runtime heartbeat.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const { ensureEnvGitIgnored } = require('./writeEnv');
|
|
27
|
+
|
|
28
|
+
const IMPORT_START = '// FYODOS:ORB:START (auto-generated — safe to remove)';
|
|
29
|
+
const IMPORT_END = '// FYODOS:ORB:END';
|
|
30
|
+
const SDK_IMPORT = "import 'package:fiodos_flutter/fiodos_flutter.dart';";
|
|
31
|
+
|
|
32
|
+
const MAIN_CANDIDATES = ['lib/main.dart'];
|
|
33
|
+
|
|
34
|
+
function findMainDart(appRoot) {
|
|
35
|
+
for (const rel of MAIN_CANDIDATES) {
|
|
36
|
+
const abs = path.join(appRoot, rel);
|
|
37
|
+
if (fs.existsSync(abs)) return { abs, rel };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function insertImportAfterLastImport(source, block) {
|
|
43
|
+
const lines = source.split('\n');
|
|
44
|
+
let lastImport = -1;
|
|
45
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
46
|
+
if (/^\s*import\s+['"]/.test(lines[i])) lastImport = i;
|
|
47
|
+
}
|
|
48
|
+
if (lastImport === -1) return `${block}\n${source}`;
|
|
49
|
+
lines.splice(lastImport + 1, 0, block);
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Match the `home:` value when it is a single, simple widget constructor — the
|
|
55
|
+
* common case (e.g. `home: const TodoListScreen()`, `home: MyHomePage(title: 'x')`).
|
|
56
|
+
* We deliberately do NOT try to parse arbitrarily nested expressions: a complex
|
|
57
|
+
* home (Builder, ternaries, nested constructors with parens) falls back to the
|
|
58
|
+
* snippet, never to a risky edit. Group 1 = the whole widget expr (with const).
|
|
59
|
+
*/
|
|
60
|
+
const HOME_RE = /home:\s*((?:const\s+)?[A-Za-z_][\w.]*\([^()]*\))/;
|
|
61
|
+
|
|
62
|
+
/** Build the wired main.dart, or null when home: is not a simple widget. */
|
|
63
|
+
function buildWiredMain(source) {
|
|
64
|
+
if (!HOME_RE.test(source)) return null;
|
|
65
|
+
const wrapped = source.replace(HOME_RE, (full, widget) =>
|
|
66
|
+
'home: FiodosAgent(\n' +
|
|
67
|
+
" apiKey: const String.fromEnvironment('FYODOS_API_KEY'),\n" +
|
|
68
|
+
" baseUrl: const String.fromEnvironment('FYODOS_API_URL', defaultValue: kFiodosDefaultApiUrl),\n" +
|
|
69
|
+
` child: ${widget},\n` +
|
|
70
|
+
' )',
|
|
71
|
+
);
|
|
72
|
+
const importBlock = `${IMPORT_START}\n${SDK_IMPORT}\n${IMPORT_END}`;
|
|
73
|
+
return insertImportAfterLastImport(wrapped, importBlock);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Copy-paste snippet shown when we cannot safely auto-wrap. */
|
|
77
|
+
function mountSnippet() {
|
|
78
|
+
return (
|
|
79
|
+
`${SDK_IMPORT}\n\n` +
|
|
80
|
+
`// Wrap your app's home widget (inside MaterialApp) with FiodosAgent:\n` +
|
|
81
|
+
`home: FiodosAgent(\n` +
|
|
82
|
+
` apiKey: const String.fromEnvironment('FYODOS_API_KEY'),\n` +
|
|
83
|
+
` child: const MyHomePage(),\n` +
|
|
84
|
+
`),`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function printSnippet(colors, rel, reason) {
|
|
89
|
+
const { blue, cyan, dim, reset } = colors;
|
|
90
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}add the orb to ${rel || 'lib/main.dart'}${reason ? ` (${reason})` : ''}:${reset}`);
|
|
91
|
+
console.error(mountSnippet());
|
|
92
|
+
console.error(`${dim}Then re-run analyze — the mount becomes automatic once home: is a simple widget.${reset}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Auto-mount the orb into a Flutter app.
|
|
97
|
+
* @returns {{status:'added'|'already'|'printed'|'skipped', file?:string, reason?:string}}
|
|
98
|
+
*/
|
|
99
|
+
function wireFlutterOrb(appRoot, { colors = {}, noWire = false } = {}) {
|
|
100
|
+
const main = findMainDart(appRoot);
|
|
101
|
+
if (!main) {
|
|
102
|
+
printSnippet(colors, null, 'no lib/main.dart found');
|
|
103
|
+
return { status: 'printed', reason: 'no-main' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let source;
|
|
107
|
+
try {
|
|
108
|
+
source = fs.readFileSync(main.abs, 'utf8');
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { status: 'skipped', reason: `read-failed: ${e.message}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (source.includes('FiodosAgent(') || source.includes('FYODOS:ORB')) {
|
|
114
|
+
return { status: 'already', file: main.rel };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (noWire) return { status: 'skipped', reason: 'no-wire' };
|
|
118
|
+
|
|
119
|
+
const wired = buildWiredMain(source);
|
|
120
|
+
if (!wired) {
|
|
121
|
+
printSnippet(colors, main.rel, 'home: is not a simple widget');
|
|
122
|
+
return { status: 'printed', file: main.rel, reason: 'unrecognized-home' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
fs.writeFileSync(main.abs, wired);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return { status: 'skipped', reason: `write-failed: ${e.message}` };
|
|
129
|
+
}
|
|
130
|
+
return { status: 'added', file: main.rel };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Flutter env: there is no auto-loaded `.env`. The compile-time keys are passed
|
|
135
|
+
* with --dart-define; the convention for a checked-in run command is a
|
|
136
|
+
* dart-define-from-file JSON. We write a git-ignored `fyodos.env.json` holding
|
|
137
|
+
* the publishable key + URL so `flutter run --dart-define-from-file=fyodos.env.json`
|
|
138
|
+
* works locally, and the secret never lands in committed source.
|
|
139
|
+
* @returns {{file:string, written:boolean, gitignore:object}}
|
|
140
|
+
*/
|
|
141
|
+
function writeFlutterEnv(appRoot, { apiKey, apiUrl, defaultApiUrl }) {
|
|
142
|
+
if (!apiKey) return { file: null, written: false, gitignore: null };
|
|
143
|
+
const rel = 'fyodos.env.json';
|
|
144
|
+
const abs = path.join(appRoot, rel);
|
|
145
|
+
const payload = { FYODOS_API_KEY: apiKey };
|
|
146
|
+
if (apiUrl && apiUrl !== defaultApiUrl) payload.FYODOS_API_URL = apiUrl;
|
|
147
|
+
fs.writeFileSync(abs, `${JSON.stringify(payload, null, 2)}\n`);
|
|
148
|
+
const gitignore = ensureEnvGitIgnored(appRoot, rel);
|
|
149
|
+
return { file: rel, written: true, gitignore };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function reportFlutterWire(result, colors = {}, { quiet = false } = {}) {
|
|
153
|
+
const { blue, cyan, dim, reset } = colors;
|
|
154
|
+
if (!result) return;
|
|
155
|
+
if (result.status === 'added') {
|
|
156
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · orb mounted in ${result.file}`);
|
|
157
|
+
} else if (result.status === 'already' && !quiet) {
|
|
158
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}orb already mounted in ${result.file}${reset}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
wireFlutterOrb,
|
|
164
|
+
writeFlutterEnv,
|
|
165
|
+
reportFlutterWire,
|
|
166
|
+
// exported for tests
|
|
167
|
+
buildWiredMain,
|
|
168
|
+
findMainDart,
|
|
169
|
+
HOME_RE,
|
|
170
|
+
};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wireReactNative — auto-mount the Fiodos orb in an Expo Router app, mirroring
|
|
3
|
+
* the web orb auto-mount (wireWeb.js) but adapted to React Native.
|
|
4
|
+
*
|
|
5
|
+
* Web drops a one-line <FiodosAgent/> into the app entry. The RN counterpart is
|
|
6
|
+
* the SDK's <FiodosExpoAgent/> (from '@fiodos/react-native/expo'), which wires
|
|
7
|
+
* the provider + floating layer + Expo adapters + published orb design behind a
|
|
8
|
+
* single component. So here we:
|
|
9
|
+
* 1. write the published manifest to `fyodos/manifest.json`, and
|
|
10
|
+
* 2. wrap the Expo Router root layout's returned tree with <FiodosExpoAgent>.
|
|
11
|
+
*
|
|
12
|
+
* Edits are idempotent and reversible (FYODOS:ORB markers). When the layout is
|
|
13
|
+
* not in the safe, recognized shape we DON'T touch it — we print a short snippet
|
|
14
|
+
* instead (exactly like the web path), and the caller then skips the install
|
|
15
|
+
* proof so the dashboard waits for the runtime heartbeat rather than lying.
|
|
16
|
+
*/
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const ORB_START = '{/* FYODOS:ORB:START (auto-generated — safe to remove) */}';
|
|
23
|
+
const ORB_END = '{/* FYODOS:ORB:END */}';
|
|
24
|
+
const IMPORT_START = '// FYODOS:ORB:START (auto-generated — safe to remove)';
|
|
25
|
+
const IMPORT_END = '// FYODOS:ORB:END';
|
|
26
|
+
|
|
27
|
+
const LAYOUT_CANDIDATES = [
|
|
28
|
+
'app/_layout.tsx',
|
|
29
|
+
'app/_layout.jsx',
|
|
30
|
+
'src/app/_layout.tsx',
|
|
31
|
+
'src/app/_layout.jsx',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function findRootLayout(appRoot) {
|
|
35
|
+
for (const rel of LAYOUT_CANDIDATES) {
|
|
36
|
+
const abs = path.join(appRoot, rel);
|
|
37
|
+
if (fs.existsSync(abs)) return { abs, rel };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Write the published manifest the SDK mounts from. Returns the rel path. */
|
|
43
|
+
function writeManifestFile(appRoot, manifest) {
|
|
44
|
+
const dir = path.join(appRoot, 'fyodos');
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
const file = path.join(dir, 'manifest.json');
|
|
47
|
+
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
48
|
+
return path.relative(appRoot, file);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Import path (POSIX) from the layout file to fyodos/manifest.json. */
|
|
52
|
+
function manifestImportSpecifier(layoutAbs, appRoot) {
|
|
53
|
+
const manifestAbs = path.join(appRoot, 'fyodos', 'manifest.json');
|
|
54
|
+
let rel = path.relative(path.dirname(layoutAbs), manifestAbs);
|
|
55
|
+
rel = rel.split(path.sep).join('/');
|
|
56
|
+
if (!rel.startsWith('.')) rel = `./${rel}`;
|
|
57
|
+
return rel;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function insertImportsAfterLastImport(source, block) {
|
|
61
|
+
const lines = source.split('\n');
|
|
62
|
+
let lastImport = -1;
|
|
63
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
64
|
+
if (/^\s*import\b/.test(lines[i])) lastImport = i;
|
|
65
|
+
}
|
|
66
|
+
if (lastImport === -1) return `${block}\n${source}`;
|
|
67
|
+
lines.splice(lastImport + 1, 0, block);
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Find the parenthesized expression of the FIRST `return (` and return its inner
|
|
73
|
+
* span [start,end) (exclusive of the wrapping parens). null when the return is
|
|
74
|
+
* not the safe `return ( … )` shape we auto-wrap.
|
|
75
|
+
*/
|
|
76
|
+
function findReturnParenSpan(source) {
|
|
77
|
+
const m = /return\s*\(/.exec(source);
|
|
78
|
+
if (!m) return null;
|
|
79
|
+
const open = m.index + m[0].length - 1; // index of '('
|
|
80
|
+
let depth = 0;
|
|
81
|
+
for (let i = open; i < source.length; i += 1) {
|
|
82
|
+
const ch = source[i];
|
|
83
|
+
if (ch === '(') depth += 1;
|
|
84
|
+
else if (ch === ')') {
|
|
85
|
+
depth -= 1;
|
|
86
|
+
if (depth === 0) return { innerStart: open + 1, innerEnd: i, openIdx: open, closeIdx: i };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Detect indentation (spaces) of the line containing index. */
|
|
93
|
+
function indentAt(source, index) {
|
|
94
|
+
const lineStart = source.lastIndexOf('\n', index - 1) + 1;
|
|
95
|
+
const m = /^[ \t]*/.exec(source.slice(lineStart));
|
|
96
|
+
return m ? m[0] : '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Produce the wired layout source, or null when the layout is not auto-wrappable.
|
|
101
|
+
*/
|
|
102
|
+
function buildWiredLayout(source, manifestSpecifier) {
|
|
103
|
+
const span = findReturnParenSpan(source);
|
|
104
|
+
if (!span) return null;
|
|
105
|
+
const inner = source.slice(span.innerStart, span.innerEnd);
|
|
106
|
+
const baseIndent = indentAt(source, span.openIdx);
|
|
107
|
+
const innerIndent = `${baseIndent} `;
|
|
108
|
+
|
|
109
|
+
// Re-indent the existing tree one level deeper so it nests cleanly.
|
|
110
|
+
const nested = inner
|
|
111
|
+
.replace(/^\n/, '')
|
|
112
|
+
.split('\n')
|
|
113
|
+
.map((l) => (l.trim() ? ` ${l}` : l))
|
|
114
|
+
.join('\n');
|
|
115
|
+
|
|
116
|
+
const wrapped =
|
|
117
|
+
`\n${innerIndent}${ORB_START}\n` +
|
|
118
|
+
`${innerIndent}<FiodosExpoAgent manifest={fyodosManifest as never}>\n` +
|
|
119
|
+
`${nested}\n` +
|
|
120
|
+
`${innerIndent}</FiodosExpoAgent>\n` +
|
|
121
|
+
`${innerIndent}${ORB_END}\n${baseIndent}`;
|
|
122
|
+
|
|
123
|
+
const withWrap = source.slice(0, span.innerStart) + wrapped + source.slice(span.innerEnd);
|
|
124
|
+
|
|
125
|
+
const importBlock =
|
|
126
|
+
`${IMPORT_START}\n` +
|
|
127
|
+
`import { FiodosExpoAgent } from '@fiodos/react-native/expo';\n` +
|
|
128
|
+
`import fyodosManifest from '${manifestSpecifier}';\n` +
|
|
129
|
+
`${IMPORT_END}`;
|
|
130
|
+
|
|
131
|
+
return insertImportsAfterLastImport(withWrap, importBlock);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The copy-paste snippet shown when we can't safely auto-wrap. */
|
|
135
|
+
function mountSnippet(manifestSpecifier) {
|
|
136
|
+
return (
|
|
137
|
+
`import { FiodosExpoAgent } from '@fiodos/react-native/expo';\n` +
|
|
138
|
+
`import fyodosManifest from '${manifestSpecifier}';\n\n` +
|
|
139
|
+
`// Wrap your root layout's returned tree:\n` +
|
|
140
|
+
`<FiodosExpoAgent manifest={fyodosManifest as never}>\n` +
|
|
141
|
+
` {/* your existing <Stack/> / <Slot/> tree */}\n` +
|
|
142
|
+
`</FiodosExpoAgent>`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function printSnippet(colors, layoutRel, manifestSpecifier, reason) {
|
|
147
|
+
const { blue, cyan, dim, reset } = colors;
|
|
148
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}add the orb to ${layoutRel || 'app/_layout.tsx'}${reason ? ` (${reason})` : ''}:${reset}`);
|
|
149
|
+
console.error(mountSnippet(manifestSpecifier));
|
|
150
|
+
console.error(`${dim}Then re-run analyze — the mount becomes automatic once the layout matches.${reset}\n`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Auto-mount the orb into an Expo Router app.
|
|
155
|
+
* @returns {{status:'added'|'already'|'printed'|'skipped', file?:string, reason?:string}}
|
|
156
|
+
*/
|
|
157
|
+
function wireReactNativeOrb(appRoot, { manifest, colors = {}, noWire = false } = {}) {
|
|
158
|
+
const layout = findRootLayout(appRoot);
|
|
159
|
+
const manifestSpecifier = layout
|
|
160
|
+
? manifestImportSpecifier(layout.abs, appRoot)
|
|
161
|
+
: './fyodos/manifest.json';
|
|
162
|
+
|
|
163
|
+
if (!layout) {
|
|
164
|
+
if (!noWire && manifest) writeManifestFile(appRoot, manifest);
|
|
165
|
+
printSnippet(colors, null, manifestSpecifier, 'no Expo Router app/_layout found');
|
|
166
|
+
return { status: 'printed', reason: 'no-layout' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let source;
|
|
170
|
+
try {
|
|
171
|
+
source = fs.readFileSync(layout.abs, 'utf8');
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { status: 'skipped', reason: `read-failed: ${e.message}` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (source.includes('FiodosExpoAgent') || source.includes('FYODOS:ORB')) {
|
|
177
|
+
if (!noWire && manifest) writeManifestFile(appRoot, manifest);
|
|
178
|
+
return { status: 'already', file: layout.rel };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (noWire) return { status: 'skipped', reason: 'no-wire' };
|
|
182
|
+
|
|
183
|
+
if (manifest) writeManifestFile(appRoot, manifest);
|
|
184
|
+
const wired = buildWiredLayout(source, manifestSpecifier);
|
|
185
|
+
if (!wired) {
|
|
186
|
+
printSnippet(colors, layout.rel, manifestSpecifier, "layout has no plain `return ( … )`");
|
|
187
|
+
return { status: 'printed', file: layout.rel, reason: 'unrecognized-layout' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
fs.writeFileSync(layout.abs, wired);
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return { status: 'skipped', reason: `write-failed: ${e.message}` };
|
|
194
|
+
}
|
|
195
|
+
return { status: 'added', file: layout.rel };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reportReactNativeWire(result, colors = {}, { quiet = false } = {}) {
|
|
199
|
+
const { blue, cyan, dim, reset } = colors;
|
|
200
|
+
if (!result) return;
|
|
201
|
+
if (result.status === 'added') {
|
|
202
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · orb mounted in ${result.file}`);
|
|
203
|
+
} else if (result.status === 'already' && !quiet) {
|
|
204
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}orb already mounted in ${result.file}${reset}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
wireReactNativeOrb,
|
|
210
|
+
reportReactNativeWire,
|
|
211
|
+
// exported for tests
|
|
212
|
+
buildWiredLayout,
|
|
213
|
+
findReturnParenSpan,
|
|
214
|
+
writeManifestFile,
|
|
215
|
+
manifestImportSpecifier,
|
|
216
|
+
findRootLayout,
|
|
217
|
+
};
|
package/src/wireWeb.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const readline = require('readline');
|
|
16
|
-
const { runPostWireTest } = require('./postWireTest');
|
|
16
|
+
const { runPostWireTest, summarizeBuildOutput, readPkg } = require('./postWireTest');
|
|
17
17
|
const {
|
|
18
18
|
assessMount,
|
|
19
19
|
describePlan,
|
|
@@ -173,8 +173,49 @@ function askYesNo(question) {
|
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
const WEB_SDK_BY_FRAMEWORK = {
|
|
177
|
+
vue: '@fiodos/vue',
|
|
178
|
+
svelte: '@fiodos/svelte',
|
|
179
|
+
sveltekit: '@fiodos/svelte',
|
|
180
|
+
angular: '@fiodos/angular',
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function expectedWebSdk(framework) {
|
|
184
|
+
return WEB_SDK_BY_FRAMEWORK[framework] || '@fiodos/react';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Detects the one misconfiguration that silently breaks the build: the developer
|
|
189
|
+
* installed the MOBILE SDK (@fiodos/react-native) in a web app. The mount writes
|
|
190
|
+
* `import { FiodosAgent } from '@fiodos/react'` (or the framework SDK), so the
|
|
191
|
+
* bundler then dies on "Can't resolve". We catch it up front and print a short,
|
|
192
|
+
* actionable note instead of dumping the whole build log.
|
|
193
|
+
*
|
|
194
|
+
* If no Fiodos SDK is installed at all we stay silent and let the mount proceed
|
|
195
|
+
* (the dashboard install step / post-wire build covers that path), so this never
|
|
196
|
+
* blocks a clean integration that simply hasn't run npm install yet.
|
|
197
|
+
*/
|
|
198
|
+
function webSdkMissing(appRoot, framework) {
|
|
199
|
+
const pkg = readPkg(appRoot);
|
|
200
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
201
|
+
const expected = expectedWebSdk(framework);
|
|
202
|
+
if (deps[expected] || deps['@fiodos/web-core']) return null;
|
|
203
|
+
if (deps['@fiodos/react-native']) {
|
|
204
|
+
return (
|
|
205
|
+
`installed @fiodos/react-native, but this web app needs ${expected} — ` +
|
|
206
|
+
`run npm install ${expected} @fiodos/core`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
176
212
|
function printSnippet(target, framework, colors, appRoot, reason) {
|
|
177
213
|
const { blue, cyan, dim, reset } = colors;
|
|
214
|
+
if (reason && reason.includes('@fiodos/react-native')) {
|
|
215
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}${reason}${reset}`);
|
|
216
|
+
console.error(`${dim}Then re-run analyze — the orb mount will be automatic.${reset}\n`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
178
219
|
const ts = !target || target.ext === '.tsx';
|
|
179
220
|
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · ${dim}add the orb to your web app (copy this):${reset}`);
|
|
180
221
|
if (reason) {
|
|
@@ -252,6 +293,12 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
252
293
|
return { status: 'printed', reason: plan.reason };
|
|
253
294
|
}
|
|
254
295
|
|
|
296
|
+
const sdkGap = webSdkMissing(appRoot, framework);
|
|
297
|
+
if (sdkGap) {
|
|
298
|
+
printSnippet(target, framework, colors, appRoot, sdkGap);
|
|
299
|
+
return { status: 'printed', reason: sdkGap };
|
|
300
|
+
}
|
|
301
|
+
|
|
255
302
|
if (!assumeYes) {
|
|
256
303
|
printMountPreview(plan, colors);
|
|
257
304
|
}
|
|
@@ -313,7 +360,12 @@ async function wireWebOrb(appRoot, opts = {}) {
|
|
|
313
360
|
if (baseline.ok) {
|
|
314
361
|
// Built before, fails now → the mount is the regression. Keep it reverted.
|
|
315
362
|
printSnippet(target, framework, colors, appRoot, `mount caused a build regression — reverted (${test.command})`);
|
|
316
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
status: 'reverted',
|
|
365
|
+
file: plan.rel,
|
|
366
|
+
test,
|
|
367
|
+
reason: summarizeBuildOutput(test.output),
|
|
368
|
+
};
|
|
317
369
|
}
|
|
318
370
|
// It was already not building (or not verifiable) before us → keep the orb.
|
|
319
371
|
applyMount(plan);
|
|
@@ -348,8 +400,11 @@ function reportWireResult(result, colors = {}, opts = {}) {
|
|
|
348
400
|
console.error(`${tag} · ${dim || ''}automatic mount not possible${result.reason ? `: ${result.reason}` : ''}. Paste the snippet above.${reset || ''}`);
|
|
349
401
|
break;
|
|
350
402
|
case 'reverted':
|
|
351
|
-
console.error(`${tag} · ✗ mount reverted — app would not build
|
|
352
|
-
if (result.reason)
|
|
403
|
+
console.error(`${tag} · ✗ mount reverted — app would not build.`);
|
|
404
|
+
if (result.reason) {
|
|
405
|
+
console.error(`${dim}${result.reason}${reset}`);
|
|
406
|
+
}
|
|
407
|
+
console.error(`${tag} · ${dim || ''}Fix the issue above, then re-run analyze or paste the snippet.${reset || ''}`);
|
|
353
408
|
break;
|
|
354
409
|
case 'failed':
|
|
355
410
|
console.error(`${tag} · ✗ could not safely edit ${result.file}. Paste the snippet above.`);
|
package/src/writeEnv.js
CHANGED
|
@@ -60,6 +60,18 @@ function resolveEnvPlan(appRoot) {
|
|
|
60
60
|
if (deps['@angular/core'] || has('angular.json')) {
|
|
61
61
|
return { framework: 'angular', file: null, candidates: [], keyVar: null, urlVar: null };
|
|
62
62
|
}
|
|
63
|
+
// Expo / React Native: the orb reads EXPO_PUBLIC_* (Expo inlines these at
|
|
64
|
+
// build time and exposes them via process.env). A plain `.env` at the app root
|
|
65
|
+
// is the convention; we still ensure it is git-ignored like any secret file.
|
|
66
|
+
if (deps.expo || deps['expo-router'] || deps['react-native']) {
|
|
67
|
+
return {
|
|
68
|
+
framework: 'expo',
|
|
69
|
+
file: '.env',
|
|
70
|
+
candidates: ['.env.local', '.env.development.local', '.env.development', '.env'],
|
|
71
|
+
keyVar: 'EXPO_PUBLIC_FYODOS_API_KEY',
|
|
72
|
+
urlVar: 'EXPO_PUBLIC_FYODOS_API_URL',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
63
75
|
if (
|
|
64
76
|
deps['@sveltejs/kit'] ||
|
|
65
77
|
deps.svelte ||
|