@fiodos/cli 0.1.4 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiodos/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
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
@@ -96,9 +96,9 @@ const PLATFORM_GUIDES = {
96
96
  appKindLabel: 'a web app (React, Next.js, Vite, react-router, or similar)',
97
97
  screenWord: 'page/view/route',
98
98
  routeHintLine:
99
- 'No reliable static route list is available for web apps, so infer routes/views from the router setup, page/route components, and navigation calls (react-router <Route>, Next.js pages, navigate()/<Link>). Routes you propose are NOT statically verified, so only include ones you can actually see in the code.',
99
+ 'No reliable static route list is available for web apps, so infer routes/views from the router setup, page/route components, and navigation calls (react-router <Route>, Next.js pages, navigate()/<Link>). Routes you propose are NOT statically verified, so only include ones you can actually see in the code below.',
100
100
  actionHintLine:
101
- 'Actions are functions wired to UI: handlers inside components, store/context actions (Zustand, Redux, React Context), or service functions. Set "handler" to that real function name exactly.',
101
+ 'Actions are functions wired to UI IN THE SOURCE FILES PROVIDED BELOW: handlers inside components, store/context actions (Zustand, Redux, React Context), or service functions. Set "handler" to that real function name exactly. CRITICAL: the manifest is for THIS web deployment only — do NOT include routes or actions from mobile/native code that is not in the file list (even if you infer it exists elsewhere in the monorepo).',
102
102
  },
103
103
  mobile: {
104
104
  appKindLabel: 'a mobile app (Expo / React Native)',
package/src/collect.js CHANGED
@@ -21,7 +21,12 @@ const path = require('path');
21
21
  const PROFILES = {
22
22
  web: {
23
23
  exts: ['.ts', '.tsx', '.js', '.jsx', '.vue', '.svelte'],
24
- skipDirs: ['node_modules', 'assets', '.git', 'dist', 'build', '.expo', 'android', 'ios', 'coverage', '.next', 'web-build'],
24
+ skipDirs: [
25
+ 'node_modules', 'assets', '.git', 'dist', 'build', '.expo', 'android', 'ios', 'coverage',
26
+ '.next', 'web-build',
27
+ // Monorepo siblings: never mix mobile/native code into a web analysis.
28
+ 'mobile', 'native', 'flutter', 'react-native', 'expo',
29
+ ],
25
30
  },
26
31
  mobile: {
27
32
  exts: ['.ts', '.tsx', '.js', '.jsx'],
package/src/index.js CHANGED
@@ -31,7 +31,8 @@
31
31
  * After analysis, a web target is OFFERED automatic wiring of <FiodosAgent/>
32
32
  * into the app's root layout. It asks before editing your code; on "no" (or a
33
33
  * non-interactive shell) it prints the ready-to-paste snippet instead.
34
- * --yes accept the wiring/env without the prompt (CI / express)
34
+ * --yes accept orb/handler wiring without prompts (CI / express)
35
+ * --write-env-yes write the API key to .env without asking (CI only)
35
36
  * --no-wire skip wiring entirely (analysis/publish unaffected)
36
37
  * --no-write-env skip the offer to align the app's .env with the published
37
38
  * key/URL (see below)
@@ -39,10 +40,11 @@
39
40
  * Avoiding the silent "orb fetches the wrong backend" failure:
40
41
  * After --publish the CLI (1) re-fetches the manifest with the SAME key+URL to
41
42
  * confirm the orb will find it, printing the exact key/URL your app must use,
42
- * and (2) on web, OFFERS to write that key/URL into the app's env file
43
- * (.env.local for Next, .env for Vite) so the runtime matches what you
44
- * published. It never overwrites a differing value silently it warns and
45
- * asks first. Disable with --no-write-env.
43
+ * and (2) on web, ASKS at the END whether to write that key/URL into the
44
+ * app's env file (.env.local for Next, .env for Vite). If you say yes it
45
+ * writes; if you say no it prints the exact file path and lines to add.
46
+ * Independent of --yes (which only skips orb/handler prompts). CI: --write-env-yes.
47
+ * Disable entirely with --no-write-env.
46
48
  *
47
49
  * AI key — HYBRID model:
48
50
  * DEFAULT (hosted): the AI analysis runs on the Fiodos backend with FIODOS'S
@@ -88,11 +90,12 @@ loadEnv();
88
90
 
89
91
  const { scanRoutes } = require('./routes');
90
92
  const { collectFiles } = require('./collect');
93
+ const { resolveAnalysisRoot } = require('./resolveAnalysisRoot');
91
94
  const { analyzeWithAI, correctActionWiring, DEFAULT_MODEL, resolveModel } = require('./aiAnalyze');
92
95
  const { verifyManifest } = require('./verify');
93
96
  const { wireWebOrb, reportWireResult } = require('./wireWeb');
94
97
  const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
95
- const { offerWriteEnv } = require('./writeEnv');
98
+ const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
96
99
 
97
100
  function arg(flag, fallback) {
98
101
  const i = process.argv.indexOf(flag);
@@ -187,7 +190,8 @@ async function main() {
187
190
  // Default output dir lives in the OS temp dir, NOT inside the installed
188
191
  // package (when run via npx, __dirname is under node_modules and writing
189
192
  // there pollutes the package). Operators can still override with --out.
190
- const safeName = path.basename(path.resolve(appRoot)).replace(/[^a-z0-9_-]/gi, '_') || 'app';
193
+ const inputRoot = path.resolve(appRoot);
194
+ const safeName = path.basename(inputRoot).replace(/[^a-z0-9_-]/gi, '_') || 'app';
191
195
  const outDir = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
192
196
  // Model precedence: explicit --model (operator override) > backend plan model
193
197
  // (resolved below from the analysis-quota pre-check) > DEFAULT_MODEL fallback.
@@ -197,30 +201,47 @@ async function main() {
197
201
  const useLLM = !process.argv.includes('--no-llm');
198
202
  fs.mkdirSync(outDir, { recursive: true });
199
203
 
200
- // ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
201
- // Platform: auto-detected from the presence of an expo-router app/ dir, or
202
- // forced with --platform web|mobile. Web apps have no static route ground
203
- // truth, so the AI proposes routes and we keep them unverified (honest flag).
204
- const { appDir, routes: scannedRoutes } = scanRoutes(appRoot);
205
204
  const platformArg = (arg('--platform', '') || '').toLowerCase();
206
205
  const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android'];
207
- const platform = KNOWN_PLATFORMS.includes(platformArg)
208
- ? platformArg
209
- : detectPlatform(appRoot, appDir);
206
+ const forcedPlatform = KNOWN_PLATFORMS.includes(platformArg) ? platformArg : null;
207
+
208
+ // ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
209
+ // Platform: auto-detected from package.json / native manifests, or forced with
210
+ // --platform. Web apps have no static route ground truth, so the AI proposes
211
+ // routes and we keep them unverified (honest flag).
212
+ const { appDir, routes: scannedRoutes } = scanRoutes(inputRoot);
213
+ const platform = forcedPlatform ?? detectPlatform(inputRoot, appDir);
214
+
215
+ // Monorepos: scope analysis to the sub-app for this platform (e.g. frontend/
216
+ // for Next, mobile/ for Expo) so the web orb never inherits mobile actions.
217
+ const explicitAnalysisRoot = arg('--analysis-root', '');
218
+ const scope = explicitAnalysisRoot
219
+ ? { root: path.resolve(explicitAnalysisRoot), scoped: true, reason: '--analysis-root' }
220
+ : resolveAnalysisRoot(inputRoot, platform);
221
+ const analysisRoot = scope.root;
222
+ if (scope.scoped) {
223
+ const rel = path.relative(inputRoot, analysisRoot) || '.';
224
+ logDev(
225
+ `Scoped to ${rel} (${platform} app) — analyzing this folder only, not the whole repo.`,
226
+ );
227
+ logDev(`[scope] input=${inputRoot} analysis=${analysisRoot} reason=${scope.reason}`);
228
+ }
229
+
230
+ const { appDir: scopedAppDir, routes: scopedRoutes } = scanRoutes(analysisRoot);
210
231
  // The expo-router filesystem scan ONLY describes a mobile app. A Next.js App
211
232
  // Router web app also has an app/ dir, so the scan would otherwise produce
212
233
  // bogus "routes" and misclassify the project; for web we ignore it and let
213
234
  // the AI propose routes (the documented web contract).
214
235
  // Only Expo/RN ('mobile') has a static route ground truth. Web AND the native
215
236
  // platforms (flutter/ios/android) have the AI infer routes (kept unverified).
216
- const routes = platform === 'mobile' ? scannedRoutes : [];
237
+ const routes = platform === 'mobile' ? scopedRoutes : [];
217
238
  const verifyRoutes = platform === 'mobile' && routes.length > 0;
218
- const appMeta = readAppMeta(appRoot);
239
+ const appMeta = readAppMeta(analysisRoot);
219
240
  logDev(`[static] platform=${platform} routes=${routes.length}` +
220
241
  (platform === 'web' ? ' (web: routes inferred by AI, not statically verified)' : ''));
221
242
 
222
243
  // ── 2. Collect source files (rule-based, budget-aware) ────────────────────
223
- const { included, omitted, estimatedTokens } = collectFiles(appRoot, { maxInputTokens, platform });
244
+ const { included, omitted, estimatedTokens } = collectFiles(analysisRoot, { maxInputTokens, platform });
224
245
  fs.writeFileSync(path.join(outDir, 'files-sent.json'), JSON.stringify({
225
246
  included: included.map((f) => f.rel),
226
247
  omitted,
@@ -244,14 +265,14 @@ async function main() {
244
265
  // is not acceptable for this app.
245
266
  ({ manifest, provenance } = routesOnlyManifest(appMeta, routes));
246
267
  logDev('[no-llm] static-only manifest (routes only, no actions)');
247
- logUser(`Static analysis complete — ${manifest.routes.length} routes`);
268
+ logUser('Static analysis complete');
248
269
  } else {
249
270
  // Hybrid AI-key model. DEFAULT: hosted analysis via the Fiodos backend
250
271
  // proxy (Fiodos's AI key, no key needed by the developer). With
251
272
  // --own-ai-key the analysis runs locally against the developer's own
252
273
  // provider key (privacy path: source code goes straight to the AI and
253
274
  // never reaches Fiodos). --no-llm (handled above) sends no code anywhere.
254
- loadAppEnv(path.resolve(appRoot));
275
+ loadAppEnv(analysisRoot);
255
276
  const { apiKey, apiUrl } = resolveApiTarget();
256
277
  const useProxy = !process.argv.includes('--own-ai-key');
257
278
 
@@ -304,7 +325,7 @@ async function main() {
304
325
  const droppedRoutes = provenance.routes.filter((r) => !r.included).length;
305
326
  logDev(`[verify] kept routes=${manifest.routes.length} actions=${manifest.actions.length}` +
306
327
  ` | dropped: ${droppedRoutes} routes, ${droppedActions} actions (unproven evidence)`);
307
- logUser(`Analysis complete — ${manifest.routes.length} routes, ${manifest.actions.length} actions`);
328
+ logUser('Analysis complete');
308
329
 
309
330
  fs.writeFileSync(path.join(outDir, 'evidence.json'), JSON.stringify({
310
331
  evidence, wiring, uncertain: parsed.uncertain || [],
@@ -353,7 +374,7 @@ async function main() {
353
374
  logDev(`[done] output → ${outDir}`);
354
375
 
355
376
  if (process.argv.includes('--publish')) {
356
- loadAppEnv(path.resolve(appRoot));
377
+ loadAppEnv(analysisRoot);
357
378
  await publishManifest(manifest, {
358
379
  ...analysisMeta,
359
380
  generatedAt: new Date().toISOString(),
@@ -370,35 +391,28 @@ async function main() {
370
391
  const { apiKey, apiUrl } = resolveApiTarget();
371
392
  const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
372
393
  const noWire = process.argv.includes('--no-wire');
394
+ let envWriteResult = null;
373
395
 
374
- // ── 5b. Offer to align the app's .env with the published key/URL (consent).
375
- // Removes the second manual copy of the key — the root cause of the silent
376
- // "orb fetches the wrong backend" failure. Skipped with --no-write-env.
377
- if (process.argv.includes('--publish') && !process.argv.includes('--no-write-env')) {
378
- try {
379
- await offerWriteEnv({
380
- appRoot: path.resolve(appRoot),
381
- apiKey,
382
- apiUrl,
383
- defaultApiUrl: DEFAULT_API_URL,
384
- assumeYes,
385
- log: logUser,
386
- });
387
- } catch (e) {
388
- logDev(`[write-env] skipped: ${e.message}`);
389
- }
390
- }
391
396
  // --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
392
397
  // isolation without modifying the app's entrypoint / package.json).
393
398
  if (!process.argv.includes('--no-orb-wire')) {
394
- const result = await wireWebOrb(path.resolve(appRoot), {
399
+ const result = await wireWebOrb(analysisRoot, {
395
400
  apiUrl,
396
401
  colors: fyodosTermColors(),
397
402
  assumeYes,
398
403
  noWire,
399
404
  runTest: !process.argv.includes('--no-wire-test'),
400
405
  });
401
- reportWireResult(result, fyodosTermColors());
406
+ reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() });
407
+ // Tell the dashboard the orb is wired — no need to boot localhost for the
408
+ // onboarding checker to complete.
409
+ if (
410
+ process.argv.includes('--publish') &&
411
+ apiKey &&
412
+ (result.status === 'added' || result.status === 'already')
413
+ ) {
414
+ await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
415
+ }
402
416
  }
403
417
 
404
418
  // ── 7. Web: SECOND consent — connect detected actions to real app functions.
@@ -417,7 +431,7 @@ async function main() {
417
431
  return fixed;
418
432
  }
419
433
  : null;
420
- const handlerResult = await wireHandlers(path.resolve(appRoot), {
434
+ const handlerResult = await wireHandlers(analysisRoot, {
421
435
  manifest,
422
436
  evidence,
423
437
  wiring,
@@ -425,6 +439,7 @@ async function main() {
425
439
  colors: fyodosTermColors(),
426
440
  assumeYes,
427
441
  noWire,
442
+ quiet: !isVerbose(),
428
443
  runTest: !process.argv.includes('--no-wire-test'),
429
444
  revertOnFailure: true,
430
445
  testRunner: runPostWireTest,
@@ -432,7 +447,32 @@ async function main() {
432
447
  files: included,
433
448
  maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
434
449
  });
435
- reportHandlerResult(handlerResult, fyodosTermColors());
450
+ reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
451
+
452
+ // ── 8. API key in environment (LAST step — always asks; independent of --yes)
453
+ // --yes only skips orb/handler prompts. --write-env-yes skips this one (CI).
454
+ // If the developer declines, we show a copy-paste reminder here at the end.
455
+ if (process.argv.includes('--publish') && !process.argv.includes('--no-write-env')) {
456
+ try {
457
+ envWriteResult = await offerWriteEnv({
458
+ appRoot: analysisRoot,
459
+ apiKey,
460
+ apiUrl,
461
+ defaultApiUrl: DEFAULT_API_URL,
462
+ assumeYes: process.argv.includes('--write-env-yes'),
463
+ log: logUser,
464
+ logDev,
465
+ });
466
+ if (
467
+ envWriteResult &&
468
+ (envWriteResult.status === 'declined by user' || envWriteResult.status === 'manual')
469
+ ) {
470
+ printEnvManualReminder(envWriteResult.writePlan, { logUser, logDev });
471
+ }
472
+ } catch (e) {
473
+ logDev(`[write-env] skipped: ${e.message}`);
474
+ }
475
+ }
436
476
  }
437
477
  }
438
478
 
@@ -686,17 +726,11 @@ async function publishManifest(manifest, meta, analysisToken = null) {
686
726
  process.exit(1);
687
727
  }
688
728
  const body = await res.json();
689
- logUser(`Published to your project — ${body.routes} routes, ${body.actions} actions`);
729
+ logUser('Published to your project');
690
730
  logDev(`[publish] OK — ${body.routes} routes, ${body.actions} actions received at ${body.receivedAt}`);
691
731
 
692
- // ── Self-check: confirm the orb will actually FIND this manifest ───────────
693
- // Re-fetch it the way the SDK does at runtime (GET /v1/client/manifest with
694
- // the SAME key + URL). This catches the silent-failure setup BEFORE the
695
- // developer is left wondering why the orb never appears, and prints the exact
696
- // key/URL their app must use so they can compare with their .env.
732
+ // Self-check: confirm the orb will find this manifest (silent unless --verbose).
697
733
  await verifyOrbCanFetch({ apiKey, apiUrl });
698
-
699
- logUser('Open the dashboard → Agent settings → "Reload analysis" to review');
700
734
  }
701
735
 
702
736
  /**
@@ -705,6 +739,23 @@ async function publishManifest(manifest, meta, analysisToken = null) {
705
739
  * and URL their running app must use. Never hard-fails the publish (the publish
706
740
  * already succeeded); a failed re-fetch is surfaced as an actionable warning.
707
741
  */
742
+ async function postOrbWired({ apiKey, apiUrl, file }) {
743
+ if (!apiKey) return;
744
+ try {
745
+ const res = await fetch(`${apiUrl}/v1/developer/orb-wired`, {
746
+ method: 'POST',
747
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
748
+ body: JSON.stringify({ file: file || '', platform: 'web' }),
749
+ });
750
+ if (res.ok) {
751
+ logDev(`[orb-wired] install proof recorded (${file || 'web'})`);
752
+ } else {
753
+ logDev(`[orb-wired] could not record install proof: HTTP ${res.status}`);
754
+ }
755
+ } catch (e) {
756
+ logDev(`[orb-wired] skipped: ${e.message || e}`);
757
+ }
758
+ }
708
759
  async function verifyOrbCanFetch({ apiKey, apiUrl }) {
709
760
  try {
710
761
  const res = await fetch(`${apiUrl}/v1/client/manifest`, {
@@ -714,27 +765,15 @@ async function verifyOrbCanFetch({ apiKey, apiUrl }) {
714
765
  if (res.ok) {
715
766
  const data = await res.json().catch(() => null);
716
767
  if (data && data.manifest) {
717
- logUser(
718
- `✓ Verified: your orb will find this manifest using API key ${apiKey.slice(0, 12)}… ` +
719
- `against ${apiUrl}.`,
720
- );
721
- logUser(
722
- 'Make sure your app uses this SAME key and URL in its .env ' +
723
- '(NEXT_PUBLIC_FYODOS_API_KEY / VITE_FYODOS_API_KEY, and the API URL).',
768
+ logDev(
769
+ `[verify] manifest reachable with key ${apiKey.slice(0, 12)}… against ${apiUrl}`,
724
770
  );
725
771
  return true;
726
772
  }
727
773
  }
728
- logUser(
729
- `⚠︎ Published, but could not re-read the manifest from ${apiUrl} (HTTP ${res.status}). ` +
730
- 'Check that your app uses this same key and URL.',
731
- );
774
+ logDev(`[verify] published but re-fetch failed: HTTP ${res.status}`);
732
775
  } catch (e) {
733
776
  logDev(`[verify] re-fetch failed: ${e.message}`);
734
- logUser(
735
- `⚠︎ Published, but could not reach ${apiUrl} to verify. ` +
736
- 'Confirm your app points to this same URL and key.',
737
- );
738
777
  }
739
778
  return false;
740
779
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * resolveAnalysisRoot — pick the folder the CLI should actually analyze.
3
+ *
4
+ * Monorepos often contain BOTH a web app (Next/Vite) and a mobile app
5
+ * (Expo/RN) under one git root. Running `analyze .` from the repo root makes
6
+ * the AI read mobile screens too, so the web orb gets actions that do not
7
+ * exist on the website. When we can identify a single sub-app for the target
8
+ * platform, we scope collection/wiring to THAT folder only.
9
+ */
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const SKIP_SCAN = new Set([
16
+ 'node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'android', 'ios',
17
+ '.expo', 'web-build', '.venv', 'vendor', 'target', '.gradle', '.dart_tool',
18
+ ]);
19
+
20
+ /** Conventional subfolder names — checked first (higher confidence). */
21
+ const WEB_ROOT_NAMES = ['frontend', 'web', 'www', 'client', 'apps/web', 'packages/web'];
22
+ const MOBILE_ROOT_NAMES = ['mobile', 'app', 'apps/mobile', 'apps/app', 'packages/mobile', 'native'];
23
+
24
+ function readDeps(pkgPath) {
25
+ try {
26
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
27
+ return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function hasWebDeps(deps) {
34
+ if (!deps) return false;
35
+ return Boolean(
36
+ deps.next || deps.vite || deps['@vitejs/plugin-react'] || deps['react-scripts'] ||
37
+ deps['@angular/core'] || deps['@angular-devkit/build-angular'] ||
38
+ deps['@sveltejs/kit'] || deps.svelte || deps.vue || deps['@vue/cli-service'],
39
+ );
40
+ }
41
+
42
+ function hasMobileDeps(deps) {
43
+ if (!deps) return false;
44
+ return Boolean(deps.expo || deps['expo-router'] || deps['react-native']);
45
+ }
46
+
47
+ function platformDepsMatch(deps, platform) {
48
+ if (!deps) return false;
49
+ if (platform === 'web') return hasWebDeps(deps) && !hasMobileDeps(deps);
50
+ if (platform === 'mobile') return hasMobileDeps(deps) && !hasWebDeps(deps);
51
+ return false;
52
+ }
53
+
54
+ function tryCandidate(appRoot, rel, platform, score) {
55
+ const dir = path.join(appRoot, rel);
56
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null;
57
+ const deps = readDeps(path.join(dir, 'package.json'));
58
+ if (!platformDepsMatch(deps, platform)) return null;
59
+ return { root: dir, rel, score, reason: rel };
60
+ }
61
+
62
+ /**
63
+ * @returns {{ root: string, scoped: boolean, reason: string }}
64
+ */
65
+ function resolveAnalysisRoot(appRoot, platform) {
66
+ const abs = path.resolve(appRoot);
67
+ const rootDeps = readDeps(path.join(abs, 'package.json'));
68
+
69
+ // Root IS the target app (single-package repo).
70
+ if (platformDepsMatch(rootDeps, platform)) {
71
+ return { root: abs, scoped: false, reason: 'root package.json' };
72
+ }
73
+
74
+ const candidates = [];
75
+
76
+ const preferred = platform === 'web' ? WEB_ROOT_NAMES : MOBILE_ROOT_NAMES;
77
+ for (const name of preferred) {
78
+ const hit = tryCandidate(abs, name, platform, 20);
79
+ if (hit) candidates.push(hit);
80
+ }
81
+
82
+ // One level of immediate subdirs (frontend/, mobile/, apps/*, packages/*).
83
+ let entries = [];
84
+ try {
85
+ entries = fs.readdirSync(abs, { withFileTypes: true });
86
+ } catch {
87
+ return { root: abs, scoped: false, reason: 'unreadable root' };
88
+ }
89
+
90
+ for (const entry of entries) {
91
+ if (!entry.isDirectory() || SKIP_SCAN.has(entry.name)) continue;
92
+ const hit = tryCandidate(abs, entry.name, platform, 10);
93
+ if (hit) candidates.push(hit);
94
+
95
+ // apps/web, packages/web, etc.
96
+ const nestedRoot = path.join(abs, entry.name);
97
+ let nested = [];
98
+ try {
99
+ nested = fs.readdirSync(nestedRoot, { withFileTypes: true });
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const sub of nested) {
104
+ if (!sub.isDirectory() || SKIP_SCAN.has(sub.name)) continue;
105
+ const rel = path.join(entry.name, sub.name);
106
+ const deep = tryCandidate(abs, rel, platform, 8);
107
+ if (deep) candidates.push(deep);
108
+ }
109
+ }
110
+
111
+ if (!candidates.length) {
112
+ return { root: abs, scoped: false, reason: 'no sub-app detected' };
113
+ }
114
+
115
+ // Prefer higher score, then shorter path (closer to root).
116
+ candidates.sort((a, b) => b.score - a.score || a.rel.length - b.rel.length);
117
+ const best = candidates[0];
118
+ return {
119
+ root: best.root,
120
+ scoped: best.root !== abs,
121
+ reason: best.reason,
122
+ };
123
+ }
124
+
125
+ module.exports = { resolveAnalysisRoot, hasWebDeps, hasMobileDeps };
@@ -1360,10 +1360,10 @@ async function runAutoCorrectionLoop(ctx) {
1360
1360
  const {
1361
1361
  appRoot, fyodosDirRel, fyodosDirAbs, manifest, evidence, wiringMap,
1362
1362
  ts, esm, ext, testRunner, corrector, files, framework,
1363
- maxAttempts = 3, tag, dim, reset, registryRel,
1363
+ maxAttempts = 3, tag, dim, reset, registryRel, quiet = false,
1364
1364
  } = ctx;
1365
1365
 
1366
- const log = (m) => console.error(`${tag} · ${dim || ''}${m}${reset || ''}`);
1366
+ const log = quiet ? () => {} : (m) => console.error(`${tag} · ${dim || ''}${m}${reset || ''}`);
1367
1367
  log('Verifying and auto-correcting each action (may take a while)…');
1368
1368
  const baseline = await testRunner(appRoot, { framework });
1369
1369
 
@@ -1455,6 +1455,7 @@ async function wireHandlers(appRoot, opts = {}) {
1455
1455
  colors = {},
1456
1456
  assumeYes = false,
1457
1457
  noWire = false,
1458
+ quiet = false,
1458
1459
  framework = 'web',
1459
1460
  appName,
1460
1461
  registryExt: registryExtOpt,
@@ -1516,13 +1517,15 @@ async function wireHandlers(appRoot, opts = {}) {
1516
1517
  // to consent and let the loop decide what ends up wired.
1517
1518
  const loopEnabled = typeof corrector === 'function' && runTest && testRunner;
1518
1519
  if (!plan.auto.length && !loopEnabled) {
1519
- console.error(
1520
- `\n${tag} · ${dim || ''}Prepared the action wiring, but none of the ` +
1521
- `${plan.manual.length} action(s) can be connected with confidence.${reset || ''}`,
1522
- );
1523
- console.error(
1524
- `${tag} · See what is needed and how to do it by hand in: ${docRel}`,
1525
- );
1520
+ if (!quiet) {
1521
+ console.error(
1522
+ `\n${tag} · ${dim || ''}Prepared the action wiring, but none of the ` +
1523
+ `${plan.manual.length} action(s) can be connected with confidence.${reset || ''}`,
1524
+ );
1525
+ console.error(
1526
+ `${tag} · See what is needed and how to do it by hand in: ${docRel}`,
1527
+ );
1528
+ }
1526
1529
  return { status: 'manual-only', docPath: docAbs, autoCount: 0, manualCount: plan.manual.length, plan };
1527
1530
  }
1528
1531
 
@@ -1538,29 +1541,33 @@ async function wireHandlers(appRoot, opts = {}) {
1538
1541
  `${tag} · Apply these changes after reviewing? [yes/no] `;
1539
1542
 
1540
1543
  if (!assumeYes && !process.stdin.isTTY) {
1541
- console.error(prompt.trimEnd());
1542
- console.error(
1543
- `${tag} · ${dim || ''}Non-interactive terminal: leaving your code untouched. The document ` +
1544
- `is at ${docRel}. Re-run with --wire-yes to apply it.${reset || ''}`,
1545
- );
1544
+ if (!quiet) {
1545
+ console.error(prompt.trimEnd());
1546
+ console.error(
1547
+ `${tag} · ${dim || ''}Non-interactive terminal: leaving your code untouched. The document ` +
1548
+ `is at ${docRel}. Re-run with --wire-yes to apply it.${reset || ''}`,
1549
+ );
1550
+ }
1546
1551
  return { status: 'declined-noninteractive', docPath: docAbs, autoCount: plan.auto.length, manualCount: plan.manual.length, plan };
1547
1552
  }
1548
1553
 
1549
1554
  const yes = assumeYes || (await askYesNo(prompt));
1550
1555
  if (!yes) {
1551
- console.error(
1552
- `${tag} · ${dim || ''}Leaving your code untouched. The document stays at ${docRel} ` +
1553
- `in case you want to wire it by hand or re-run with --wire-yes.${reset || ''}`,
1554
- );
1556
+ if (!quiet) {
1557
+ console.error(
1558
+ `${tag} · ${dim || ''}Leaving your code untouched. The document stays at ${docRel} ` +
1559
+ `in case you want to wire it by hand or re-run with --wire-yes.${reset || ''}`,
1560
+ );
1561
+ }
1555
1562
  return { status: 'declined', docPath: docAbs, autoCount: plan.auto.length, manualCount: plan.manual.length, plan };
1556
1563
  }
1557
1564
 
1558
1565
  // 4) Baseline: BEFORE touching anything, check whether the app already builds.
1559
- // This lets us tell "we broke it" (revert) from "it was already broken"
1560
- // (don't revert a good wiring over a pre-existing failure).
1561
1566
  let baseline = null;
1562
1567
  if (runTest && testRunner) {
1563
- console.error(`${tag} · ${dim || ''}Checking the build baseline BEFORE wiring…${reset || ''}`);
1568
+ if (!quiet) {
1569
+ console.error(`${tag} · ${dim || ''}Checking the build baseline BEFORE wiring…${reset || ''}`);
1570
+ }
1564
1571
  try {
1565
1572
  baseline = await testRunner(appRoot, { framework });
1566
1573
  } catch (err) {
@@ -1579,7 +1586,7 @@ async function wireHandlers(appRoot, opts = {}) {
1579
1586
  const loop = await runAutoCorrectionLoop({
1580
1587
  appRoot, fyodosDirRel, fyodosDirAbs, manifest, evidence, wiringMap,
1581
1588
  ts, esm, ext, testRunner, corrector, files, framework, maxAttempts,
1582
- tag, dim, reset, registryRel, baseline,
1589
+ tag, dim, reset, registryRel, baseline, quiet,
1583
1590
  });
1584
1591
 
1585
1592
  const finalAuto = loop.ready;
@@ -1692,7 +1699,9 @@ async function wireHandlers(appRoot, opts = {}) {
1692
1699
  if (baseline && baseline.ok === false && baseline.stage !== 'skipped-no-deps') {
1693
1700
  return { status: 'applied-untested', ...baseResult, test: baseline, preexistingFailure: true };
1694
1701
  }
1695
- console.error(`${tag} · ${dim || ''}Checking the app still builds after wiring…${reset || ''}`);
1702
+ if (!quiet) {
1703
+ console.error(`${tag} · ${dim || ''}Checking the app still builds after wiring…${reset || ''}`);
1704
+ }
1696
1705
  let test;
1697
1706
  try {
1698
1707
  test = await testRunner(appRoot, { framework });
@@ -1710,50 +1719,26 @@ async function wireHandlers(appRoot, opts = {}) {
1710
1719
  return { status: 'applied', ...baseResult };
1711
1720
  }
1712
1721
 
1713
- function reportHandlerResult(result, colors = {}) {
1722
+ function reportHandlerResult(result, colors = {}, opts = {}) {
1723
+ const quiet = opts.quiet !== false;
1714
1724
  if (!result) return;
1715
1725
  const { blue, cyan, dim, reset } = colors;
1716
1726
  const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
1717
1727
  switch (result.status) {
1718
1728
  case 'applied':
1719
- case 'applied-untested': {
1720
- const rel = result.registryRel || result.registryFile;
1721
- console.error(`${tag} · ✓ Handler wiring applied → ${rel} (${result.autoCount} action(s)).`);
1722
- if (Array.isArray(result.verification) && result.verification.length) {
1723
- const ready = result.verification.filter((v) => v.status === 'ready');
1724
- const corrected = ready.filter((v) => v.attempts > 1).length;
1725
- const firstTry = ready.length - corrected;
1726
- const eff = ready.filter((v) => v.level === 'effect').length;
1727
- console.error(`${tag} · ${dim || ''}Verification: ${ready.length} action(s) ready (${firstTry} on first try, ${corrected} after auto-correction, ${eff} with real effect confirmed).${reset || ''}`);
1728
- }
1729
- if (result.editedFiles && result.editedFiles.length) {
1730
- console.error(`${tag} · ${dim || ''}Bridges added (reversible) in: ${result.editedFiles.join(', ')}.${reset || ''}`);
1731
- }
1732
- if (Array.isArray(result.dropped) && result.dropped.length) {
1733
- console.error(`${tag} · ${dim || ''}${result.dropped.length} action(s) could NOT be wired after retries (reverted, see the document): ${result.dropped.map((d) => d.intent).join(', ')}.${reset || ''}`);
1734
- } else if (result.manualCount) {
1735
- console.error(`${tag} · ${dim || ''}${result.manualCount} action(s) left for review (see the document).${reset || ''}`);
1736
- }
1737
- if (result.test && result.test.ok) {
1738
- console.error(`${tag} · ✓ Post-wiring build check OK (${result.test.stage}).`);
1739
- } else if (result.preexistingFailure) {
1740
- console.error(`${tag} · ${dim || ''}Note: your app was ALREADY not building BEFORE wiring (${result.test && result.test.stage}); not caused by Fiodos, so the wiring is kept but cannot be build-verified.${reset || ''}`);
1741
- } else if (result.status === 'applied-untested') {
1742
- console.error(`${tag} · ${dim || ''}Note: could not verify the build (${result.test && result.test.stage}); please check it.${reset || ''}`);
1743
- }
1744
- console.error(`${tag} · ${dim || ''}Enable it by passing registries={fyodosGeneratedRegistries} to <FiodosAgent/> (snippet in the document).${reset || ''}`);
1729
+ case 'applied-untested':
1730
+ if (quiet) return;
1745
1731
  break;
1746
- }
1747
1732
  case 'reverted':
1748
1733
  console.error(`${tag} · ✗ Post-wiring build check failed (${result.test && result.test.stage}) — reverted all changes, your app is untouched.`);
1749
1734
  console.error(`${tag} · ${dim || ''}Details in the document; nothing was modified in your code.${reset || ''}`);
1750
1735
  break;
1751
1736
  case 'manual-only':
1737
+ if (quiet) return;
1752
1738
  console.error(`${tag} · ${dim || ''}Wiring documented; apply it by hand following ${result.docPath}.${reset || ''}`);
1753
1739
  break;
1754
1740
  case 'declined':
1755
1741
  case 'declined-noninteractive':
1756
- // already reported inline
1757
1742
  break;
1758
1743
  case 'failed':
1759
1744
  console.error(`${tag} · ✗ Could not write the handler registry: ${result.error && result.error.message}`);
package/src/wireWeb.js CHANGED
@@ -222,7 +222,9 @@ async function wireWebOrb(appRoot, opts = {}) {
222
222
  return { status: 'printed', reason: plan.reason };
223
223
  }
224
224
 
225
- printMountPreview(plan, colors);
225
+ if (!assumeYes) {
226
+ printMountPreview(plan, colors);
227
+ }
226
228
 
227
229
  const yes = assumeYes || (await askYesNo(
228
230
  `${colors.cyan || ''}◉${colors.reset || ''} About to add the orb to ${plan.rel}. Proceed? [y/N] `,
@@ -286,22 +288,15 @@ async function wireWebOrb(appRoot, opts = {}) {
286
288
  }
287
289
  }
288
290
 
289
- function reportWireResult(result, colors = {}) {
291
+ function reportWireResult(result, colors = {}, opts = {}) {
292
+ const quiet = opts.quiet !== false;
293
+ if (!result) return;
290
294
  const { blue, cyan, dim, reset } = colors;
291
295
  const tag = `${cyan || ''}◉${reset || ''} ${blue || ''}Fiodos${reset || ''}`;
292
296
  switch (result.status) {
293
297
  case 'added':
294
- console.error(`${tag} · ✓ orb added to ${result.file}. Start your web app (e.g. \`npm run dev\`) to see it.`);
295
- if (result.preexistingFailure) {
296
- console.error(`${tag} · ${dim || ''}Note: your app was ALREADY not building before the orb was added (${result.test && result.test.stage}); not caused by Fiodos — the orb is kept so it appears once your build is fixed.${reset || ''}`);
297
- } else if (result.test && result.test.stage === 'skipped-no-deps') {
298
- console.error(`${tag} · ${dim || ''}build not verified (no node_modules) — run npm install && npm run build locally.${reset || ''}`);
299
- } else if (result.test && result.test.ok) {
300
- console.error(`${tag} · ✓ ${result.test.command} passed after mount.`);
301
- }
302
- break;
303
298
  case 'already':
304
- console.error(`${tag} · ✓ orb already mounted in ${result.file}. Nothing to do.`);
299
+ if (quiet) return;
305
300
  break;
306
301
  case 'declined':
307
302
  console.error(`${tag} · ${dim || ''}did not modify your code. Paste the snippet above to mount the orb.${reset || ''}`);
package/src/writeEnv.js CHANGED
@@ -1,19 +1,10 @@
1
1
  /**
2
- * writeEnv — optional, consent-based convenience that ties the app's RUNTIME
3
- * key/URL to the SAME key/URL the CLI just published with.
2
+ * writeEnv — consent-based API key / URL alignment for the app's environment.
4
3
  *
5
- * The #1 silent-failure cause is a mismatch: you publish with key A against
6
- * production, but your app runs with key B against localhost, so the orb fetches
7
- * its manifest from the wrong place, finds nothing, and renders invisibly. This
8
- * removes the second manual copy of the key by offering to write it into the
9
- * correct env file for the detected framework.
10
- *
11
- * SAFEGUARDS (mirrors the handler-wiring philosophy — never act blindly):
12
- * - Never overwrites silently: if the var exists and DIFFERS, it warns and asks.
13
- * - Always asks for consent (unless --yes), showing the exact file/var/value.
14
- * - Warns to keep the key out of git (suggests .gitignore) for committed files.
15
- * - On unknown framework / no package.json / Angular (no .env convention), it
16
- * does NOT write — it prints manual instructions instead.
4
+ * The orb must read the SAME key/URL the CLI published with. We always ASK
5
+ * before writing (independent of --yes, which only skips orb/handler prompts).
6
+ * If the developer declines, a copy-paste reminder is shown at the very END of
7
+ * the run after analysis, publish, orb mount and handler wiring are done.
17
8
  */
18
9
  'use strict';
19
10
 
@@ -30,12 +21,7 @@ function readJsonSafe(file) {
30
21
  }
31
22
 
32
23
  /**
33
- * Decide which env files + variable names the detected framework uses. Returns
34
- * `file: null` when there is no safe .env convention to write (Angular,
35
- * unknown). `candidates` is the list of env files the framework loads, ordered
36
- * HIGHEST precedence first — so we can fix the value WHERE THE APP ACTUALLY
37
- * READS IT instead of writing a second file that may or may not win. `file` is
38
- * the default file to create when no candidate already defines the vars.
24
+ * Decide which env files + variable names the detected framework uses.
39
25
  */
40
26
  function resolveEnvPlan(appRoot) {
41
27
  const pkg = readJsonSafe(path.join(appRoot, 'package.json'));
@@ -46,8 +32,6 @@ function resolveEnvPlan(appRoot) {
46
32
  const deps = pkg ? { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } : {};
47
33
 
48
34
  if (deps.next) {
49
- // Next dev precedence (first wins): .env.development.local > .env.local >
50
- // .env.development > .env.
51
35
  return {
52
36
  framework: 'next',
53
37
  file: '.env.local',
@@ -57,8 +41,6 @@ function resolveEnvPlan(appRoot) {
57
41
  };
58
42
  }
59
43
  if (deps['@angular/core'] || has('angular.json')) {
60
- // Angular has no .env convention (values live in environment.ts), so we
61
- // never write a file blindly — the caller prints manual guidance instead.
62
44
  return { framework: 'angular', file: null, candidates: [], keyVar: null, urlVar: null };
63
45
  }
64
46
  if (
@@ -70,8 +52,6 @@ function resolveEnvPlan(appRoot) {
70
52
  deps['@vitejs/plugin-react'] ||
71
53
  deps['react-scripts']
72
54
  ) {
73
- // Vite dev precedence (first wins): .env.development.local >
74
- // .env.development > .env.local > .env.
75
55
  return {
76
56
  framework: 'vite',
77
57
  file: '.env',
@@ -83,11 +63,6 @@ function resolveEnvPlan(appRoot) {
83
63
  return { framework: null, file: null, candidates: [], keyVar: null, urlVar: null };
84
64
  }
85
65
 
86
- /**
87
- * Scan the framework's candidate env files (highest precedence first) for VAR.
88
- * Returns { file, value } for the first (highest-precedence) file that defines
89
- * it — i.e. the value the running app actually reads — or null if none do.
90
- */
91
66
  function findEffectiveVar(appRoot, candidates, name) {
92
67
  for (const rel of candidates) {
93
68
  const filePath = path.join(appRoot, rel);
@@ -99,7 +74,6 @@ function findEffectiveVar(appRoot, candidates, name) {
99
74
  return null;
100
75
  }
101
76
 
102
- /** Find the value of VAR in the parsed lines, or undefined if not present. */
103
77
  function readEnvVar(lines, name) {
104
78
  const re = new RegExp(`^\\s*${name}\\s*=(.*)$`);
105
79
  for (const line of lines) {
@@ -113,7 +87,6 @@ function stripQuotes(v) {
113
87
  return v.replace(/^['"]/, '').replace(/['"]$/, '');
114
88
  }
115
89
 
116
- /** Upsert VAR=value: replace the existing line, or append a new one. */
117
90
  function upsertEnvVar(lines, name, value) {
118
91
  const re = new RegExp(`^\\s*${name}\\s*=`);
119
92
  const idx = lines.findIndex((l) => re.test(l));
@@ -159,118 +132,162 @@ function isGitIgnored(appRoot, fileName) {
159
132
  }
160
133
 
161
134
  /**
162
- * Offer to write key/URL to the app's env file. `log` is the branded logUser.
163
- * Returns a short status string for the final report.
135
+ * Compute where the key/URL would go and what would change no writes, no prompts.
136
+ * @returns {object|null} null when there is nothing to do (already aligned / no key).
164
137
  */
165
- async function offerWriteEnv({ appRoot, apiKey, apiUrl, defaultApiUrl, assumeYes, log }) {
166
- if (!apiKey) return 'skipped (no API key)';
138
+ function computeEnvWritePlan(appRoot, apiKey, apiUrl, defaultApiUrl) {
139
+ if (!apiKey) return null;
167
140
 
168
141
  const plan = resolveEnvPlan(appRoot);
169
- if (!plan.file) {
170
- if (plan.framework === 'angular') {
171
- log(
172
- 'Angular does not use .env: put these values in src/environments/environment.ts → ' +
173
- `fyodosApiKey: '${maskKey(apiKey)}', fyodosApiUrl: '${apiUrl}'`,
174
- );
175
- return 'manual (angular)';
176
- }
177
- log(
178
- 'Could not identify the framework with confidence; add by hand to your .env: ' +
179
- `(NEXT_PUBLIC_/VITE_)FYODOS_API_KEY=${maskKey(apiKey)} and the API URL ${apiUrl}`,
180
- );
181
- return 'manual (unknown framework)';
142
+ if (!plan.file && plan.framework !== 'angular') {
143
+ return {
144
+ kind: 'manual-unknown',
145
+ framework: plan.framework,
146
+ keyVar: 'NEXT_PUBLIC_FYODOS_API_KEY or VITE_FYODOS_API_KEY',
147
+ urlVar: 'NEXT_PUBLIC_FYODOS_API_URL or VITE_FYODOS_API_URL',
148
+ apiKey,
149
+ apiUrl,
150
+ };
151
+ }
152
+ if (plan.framework === 'angular') {
153
+ return {
154
+ kind: 'manual-angular',
155
+ file: 'src/environments/environment.ts',
156
+ apiKey,
157
+ apiUrl,
158
+ };
182
159
  }
183
160
 
184
- // Look across ALL the files the framework loads (not just the default one):
185
- // the app may already keep its Fiodos vars in .env while we would otherwise
186
- // create a separate .env.local — leaving the stale value the orb actually
187
- // reads untouched. Fix the value WHERE THE APP READS IT.
188
161
  const candidates = plan.candidates && plan.candidates.length ? plan.candidates : [plan.file];
189
162
  const effectiveKey = findEffectiveVar(appRoot, candidates, plan.keyVar);
190
163
  const effectiveUrl = findEffectiveVar(appRoot, candidates, plan.urlVar);
191
164
  const currentKey = effectiveKey ? effectiveKey.value : undefined;
192
165
  const currentUrl = effectiveUrl ? effectiveUrl.value : undefined;
193
-
194
- // Write in the highest-precedence file that already defines either var (so the
195
- // fix is the value the app loads). If none do, create the framework default.
196
166
  const targetRel = (effectiveKey && effectiveKey.file) || (effectiveUrl && effectiveUrl.file) || plan.file;
197
- const filePath = path.join(appRoot, targetRel);
198
- const exists = fs.existsSync(filePath);
199
- const raw = exists ? fs.readFileSync(filePath, 'utf8') : '';
200
- const lines = raw.length ? raw.replace(/\n$/, '').split('\n') : [];
201
-
202
- // Only write a URL var when it differs from the public default (the SDK
203
- // already defaults to production). But if a STALE url var exists and differs
204
- // from what we published against, we must offer to fix it — that is exactly
205
- // the localhost-vs-production mismatch that hides the orb.
206
167
  const wantUrl = apiUrl && apiUrl !== defaultApiUrl ? apiUrl : null;
207
168
  const urlNeedsFix = currentUrl !== undefined && currentUrl !== apiUrl;
208
-
209
169
  const keyMatches = currentKey === apiKey;
210
170
  const urlOk = !urlNeedsFix && (wantUrl ? currentUrl === wantUrl : true);
171
+
211
172
  if (keyMatches && urlOk) {
212
- log(`✓ Your ${targetRel} already uses the same key/URL as the publication. Nothing to change.`);
213
- return 'already aligned';
173
+ return { kind: 'aligned', targetRel, plan };
214
174
  }
215
175
 
216
- // Build the change description.
217
- const changes = [];
218
- if (!keyMatches) {
219
- changes.push(
220
- currentKey === undefined
221
- ? `add ${plan.keyVar}=${maskKey(apiKey)}`
222
- : `update ${plan.keyVar} (was ${maskKey(currentKey)}) → ${maskKey(apiKey)}`,
176
+ const envLines = [`${plan.keyVar}=${apiKey}`];
177
+ if (wantUrl) envLines.push(`${plan.urlVar}=${wantUrl}`);
178
+ else if (urlNeedsFix) envLines.push(`${plan.urlVar}=${apiUrl}`);
179
+
180
+ return {
181
+ kind: 'writable',
182
+ plan,
183
+ targetRel,
184
+ absPath: path.join(appRoot, targetRel),
185
+ envLines,
186
+ keyMatches,
187
+ urlOk,
188
+ wantUrl,
189
+ urlNeedsFix,
190
+ currentKey,
191
+ currentUrl,
192
+ apiKey,
193
+ apiUrl,
194
+ };
195
+ }
196
+
197
+ /** Branded reminder shown at the END when the developer declined the write. */
198
+ function printEnvManualReminder(writePlan, { logUser, logDev }) {
199
+ if (!writePlan || writePlan.kind === 'aligned') return;
200
+
201
+ logUser('Add your API key to your environment file (last step)');
202
+
203
+ if (writePlan.kind === 'manual-angular') {
204
+ console.log(
205
+ ` Angular: open ${writePlan.file} and set fyodosApiKey / fyodosApiUrl:`,
223
206
  );
207
+ console.log(` fyodosApiKey: '${writePlan.apiKey}'`);
208
+ console.log(` fyodosApiUrl: '${writePlan.apiUrl}'`);
209
+ return;
224
210
  }
225
- if (wantUrl && currentUrl !== wantUrl) {
226
- changes.push(
227
- currentUrl === undefined
228
- ? `add ${plan.urlVar}=${wantUrl}`
229
- : `update ${plan.urlVar} (was ${currentUrl}) → ${wantUrl}`,
230
- );
231
- } else if (urlNeedsFix && !wantUrl) {
232
- // The app points somewhere (e.g. localhost) but we published against the
233
- // public default; the var is misleading. Offer to align it explicitly.
234
- changes.push(`update ${plan.urlVar} (was ${currentUrl}) → ${apiUrl}`);
211
+
212
+ if (writePlan.kind === 'manual-unknown') {
213
+ console.log(' Add to your project .env (use NEXT_PUBLIC_ for Next, VITE_ for Vite):');
214
+ console.log(` ${writePlan.keyVar}=${writePlan.apiKey}`);
215
+ if (writePlan.apiUrl && writePlan.apiUrl !== 'https://api.fyodos.com') {
216
+ console.log(` ${writePlan.urlVar}=${writePlan.apiUrl}`);
217
+ }
218
+ return;
219
+ }
220
+
221
+ const rel = writePlan.targetRel;
222
+ const displayPath = writePlan.absPath;
223
+ console.log(` File: ${displayPath}`);
224
+ console.log(' Add these lines:');
225
+ for (const line of writePlan.envLines) {
226
+ console.log(` ${line}`);
235
227
  }
228
+ logDev(`[write-env] manual reminder for ${rel}`);
229
+ }
236
230
 
237
- if (!changes.length) {
238
- log(`✓ Your ${targetRel} is already aligned.`);
239
- return 'already aligned';
231
+ /**
232
+ * Ask consent and optionally write. Returns { status, writePlan }.
233
+ * `assumeYes` comes from --write-env-yes only (NOT global --yes).
234
+ */
235
+ async function offerWriteEnv({ appRoot, apiKey, apiUrl, defaultApiUrl, assumeYes, log, logDev: devLog }) {
236
+ const writePlan = computeEnvWritePlan(appRoot, apiKey, apiUrl, defaultApiUrl);
237
+ if (!writePlan) return { status: 'skipped (no API key)', writePlan: null };
238
+ if (writePlan.kind === 'aligned') {
239
+ log(`Your environment already uses the same API key as this project. Nothing to change.`);
240
+ return { status: 'already aligned', writePlan };
240
241
  }
241
242
 
242
- // Loud warning when we are about to CHANGE an existing, different value.
243
- const isOverwrite = (currentKey !== undefined && !keyMatches) || urlNeedsFix;
244
- log(
245
- `${isOverwrite ? '⚠︎ ' : ''}For the orb to find its manifest, your app should use the SAME ` +
246
- `key/URL you just published with. Proposed in ${targetRel}: ${changes.join('; ')}.`,
247
- );
243
+ const targetLabel =
244
+ writePlan.kind === 'manual-angular'
245
+ ? writePlan.file
246
+ : writePlan.kind === 'manual-unknown'
247
+ ? 'your .env file'
248
+ : writePlan.targetRel;
248
249
 
249
250
  let proceed = assumeYes;
250
251
  if (!proceed) {
251
252
  proceed = await askYesNo(
252
- `◉ Fiodos · Write/update ${targetRel} with the published key/URL? [yes/no] `,
253
+ `◉ Fiodos · Add your API key to ${targetLabel}? [yes/no] `,
253
254
  );
254
255
  }
256
+
255
257
  if (!proceed) {
256
- log(`Left your ${targetRel} untouched. Do it by hand: ${changes.join('; ')}.`);
257
- return 'declined by user';
258
+ return { status: 'declined by user', writePlan };
259
+ }
260
+
261
+ if (writePlan.kind === 'manual-angular' || writePlan.kind === 'manual-unknown') {
262
+ log('Could not write automatically for this framework — see the reminder at the end.');
263
+ return { status: 'manual', writePlan };
258
264
  }
259
265
 
266
+ const { plan, targetRel, keyMatches, wantUrl, urlNeedsFix, apiKey: key, apiUrl: url } = writePlan;
267
+ const filePath = writePlan.absPath;
268
+ const exists = fs.existsSync(filePath);
269
+ const raw = exists ? fs.readFileSync(filePath, 'utf8') : '';
270
+ const lines = raw.length ? raw.replace(/\n$/, '').split('\n') : [];
271
+
260
272
  let out = lines.slice();
261
- if (!keyMatches) out = upsertEnvVar(out, plan.keyVar, apiKey);
273
+ if (!keyMatches) out = upsertEnvVar(out, plan.keyVar, key);
262
274
  if (wantUrl) {
263
275
  out = upsertEnvVar(out, plan.urlVar, wantUrl);
264
276
  } else if (urlNeedsFix) {
265
- out = upsertEnvVar(out, plan.urlVar, apiUrl);
277
+ out = upsertEnvVar(out, plan.urlVar, url);
266
278
  }
267
279
  fs.writeFileSync(filePath, `${out.join('\n')}\n`);
268
- log(`✓ ${targetRel} updated (${changes.join('; ')}).`);
280
+ log(`API key saved to ${targetRel}.`);
269
281
 
270
282
  if (!isGitIgnored(appRoot, targetRel)) {
271
- log(`Reminder: add ${targetRel} to .gitignore so you don't commit your API key.`);
283
+ devLog(`Reminder: add ${targetRel} to .gitignore so you don't commit your API key.`);
272
284
  }
273
- return 'written';
285
+ return { status: 'written', writePlan };
274
286
  }
275
287
 
276
- module.exports = { offerWriteEnv, resolveEnvPlan };
288
+ module.exports = {
289
+ offerWriteEnv,
290
+ resolveEnvPlan,
291
+ computeEnvWritePlan,
292
+ printEnvManualReminder,
293
+ };