@fiodos/cli 0.1.3 → 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 +1 -1
- package/src/aiAnalyze.js +2 -2
- package/src/collect.js +6 -1
- package/src/index.js +105 -66
- package/src/resolveAnalysisRoot.js +125 -0
- package/src/wireHandlers.js +37 -52
- package/src/wireWeb.js +7 -12
- package/src/writeEnv.js +156 -93
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
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: [
|
|
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
|
|
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,
|
|
43
|
-
* (.env.local for Next, .env for Vite)
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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' ?
|
|
237
|
+
const routes = platform === 'mobile' ? scopedRoutes : [];
|
|
217
238
|
const verifyRoutes = platform === 'mobile' && routes.length > 0;
|
|
218
|
-
const appMeta = readAppMeta(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
729
|
+
logUser('Published to your project');
|
|
690
730
|
logDev(`[publish] OK — ${body.routes} routes, ${body.actions} actions received at ${body.receivedAt}`);
|
|
691
731
|
|
|
692
|
-
//
|
|
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
|
-
|
|
718
|
-
|
|
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
|
-
|
|
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 };
|
package/src/wireHandlers.js
CHANGED
|
@@ -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
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,24 +21,27 @@ function readJsonSafe(file) {
|
|
|
30
21
|
}
|
|
31
22
|
|
|
32
23
|
/**
|
|
33
|
-
* Decide which env
|
|
34
|
-
* null when there is no safe .env convention to write (Angular, or unknown).
|
|
24
|
+
* Decide which env files + variable names the detected framework uses.
|
|
35
25
|
*/
|
|
36
26
|
function resolveEnvPlan(appRoot) {
|
|
37
27
|
const pkg = readJsonSafe(path.join(appRoot, 'package.json'));
|
|
38
28
|
const has = (rel) => fs.existsSync(path.join(appRoot, rel));
|
|
39
29
|
if (!pkg && !has('angular.json')) {
|
|
40
|
-
return { framework: null, file: null, keyVar: null, urlVar: null };
|
|
30
|
+
return { framework: null, file: null, candidates: [], keyVar: null, urlVar: null };
|
|
41
31
|
}
|
|
42
32
|
const deps = pkg ? { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } : {};
|
|
43
33
|
|
|
44
34
|
if (deps.next) {
|
|
45
|
-
return {
|
|
35
|
+
return {
|
|
36
|
+
framework: 'next',
|
|
37
|
+
file: '.env.local',
|
|
38
|
+
candidates: ['.env.development.local', '.env.local', '.env.development', '.env'],
|
|
39
|
+
keyVar: 'NEXT_PUBLIC_FYODOS_API_KEY',
|
|
40
|
+
urlVar: 'NEXT_PUBLIC_FYODOS_API_URL',
|
|
41
|
+
};
|
|
46
42
|
}
|
|
47
43
|
if (deps['@angular/core'] || has('angular.json')) {
|
|
48
|
-
|
|
49
|
-
// never write a file blindly — the caller prints manual guidance instead.
|
|
50
|
-
return { framework: 'angular', file: null, keyVar: null, urlVar: null };
|
|
44
|
+
return { framework: 'angular', file: null, candidates: [], keyVar: null, urlVar: null };
|
|
51
45
|
}
|
|
52
46
|
if (
|
|
53
47
|
deps['@sveltejs/kit'] ||
|
|
@@ -58,12 +52,28 @@ function resolveEnvPlan(appRoot) {
|
|
|
58
52
|
deps['@vitejs/plugin-react'] ||
|
|
59
53
|
deps['react-scripts']
|
|
60
54
|
) {
|
|
61
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
framework: 'vite',
|
|
57
|
+
file: '.env',
|
|
58
|
+
candidates: ['.env.development.local', '.env.development', '.env.local', '.env'],
|
|
59
|
+
keyVar: 'VITE_FYODOS_API_KEY',
|
|
60
|
+
urlVar: 'VITE_FYODOS_API_URL',
|
|
61
|
+
};
|
|
62
62
|
}
|
|
63
|
-
return { framework: null, file: null, keyVar: null, urlVar: null };
|
|
63
|
+
return { framework: null, file: null, candidates: [], keyVar: null, urlVar: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function findEffectiveVar(appRoot, candidates, name) {
|
|
67
|
+
for (const rel of candidates) {
|
|
68
|
+
const filePath = path.join(appRoot, rel);
|
|
69
|
+
if (!fs.existsSync(filePath)) continue;
|
|
70
|
+
const lines = fs.readFileSync(filePath, 'utf8').replace(/\n$/, '').split('\n');
|
|
71
|
+
const value = readEnvVar(lines, name);
|
|
72
|
+
if (value !== undefined) return { file: rel, value };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
/** Find the value of VAR in the parsed lines, or undefined if not present. */
|
|
67
77
|
function readEnvVar(lines, name) {
|
|
68
78
|
const re = new RegExp(`^\\s*${name}\\s*=(.*)$`);
|
|
69
79
|
for (const line of lines) {
|
|
@@ -77,7 +87,6 @@ function stripQuotes(v) {
|
|
|
77
87
|
return v.replace(/^['"]/, '').replace(/['"]$/, '');
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
/** Upsert VAR=value: replace the existing line, or append a new one. */
|
|
81
90
|
function upsertEnvVar(lines, name, value) {
|
|
82
91
|
const re = new RegExp(`^\\s*${name}\\s*=`);
|
|
83
92
|
const idx = lines.findIndex((l) => re.test(l));
|
|
@@ -123,108 +132,162 @@ function isGitIgnored(appRoot, fileName) {
|
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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).
|
|
128
137
|
*/
|
|
129
|
-
|
|
130
|
-
if (!apiKey) return
|
|
138
|
+
function computeEnvWritePlan(appRoot, apiKey, apiUrl, defaultApiUrl) {
|
|
139
|
+
if (!apiKey) return null;
|
|
131
140
|
|
|
132
141
|
const plan = resolveEnvPlan(appRoot);
|
|
133
|
-
if (!plan.file) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
};
|
|
146
159
|
}
|
|
147
160
|
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
const currentUrl = readEnvVar(lines, plan.urlVar);
|
|
155
|
-
|
|
156
|
-
// Only write a URL var when it differs from the public default (the SDK
|
|
157
|
-
// already defaults to production). But if a STALE url var exists and differs
|
|
158
|
-
// from what we published against, we must offer to fix it — that is exactly
|
|
159
|
-
// the localhost-vs-production mismatch that hides the orb.
|
|
161
|
+
const candidates = plan.candidates && plan.candidates.length ? plan.candidates : [plan.file];
|
|
162
|
+
const effectiveKey = findEffectiveVar(appRoot, candidates, plan.keyVar);
|
|
163
|
+
const effectiveUrl = findEffectiveVar(appRoot, candidates, plan.urlVar);
|
|
164
|
+
const currentKey = effectiveKey ? effectiveKey.value : undefined;
|
|
165
|
+
const currentUrl = effectiveUrl ? effectiveUrl.value : undefined;
|
|
166
|
+
const targetRel = (effectiveKey && effectiveKey.file) || (effectiveUrl && effectiveUrl.file) || plan.file;
|
|
160
167
|
const wantUrl = apiUrl && apiUrl !== defaultApiUrl ? apiUrl : null;
|
|
161
168
|
const urlNeedsFix = currentUrl !== undefined && currentUrl !== apiUrl;
|
|
162
|
-
|
|
163
169
|
const keyMatches = currentKey === apiKey;
|
|
164
170
|
const urlOk = !urlNeedsFix && (wantUrl ? currentUrl === wantUrl : true);
|
|
171
|
+
|
|
165
172
|
if (keyMatches && urlOk) {
|
|
166
|
-
|
|
167
|
-
return 'already aligned';
|
|
173
|
+
return { kind: 'aligned', targetRel, plan };
|
|
168
174
|
}
|
|
169
175
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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:`,
|
|
177
206
|
);
|
|
207
|
+
console.log(` fyodosApiKey: '${writePlan.apiKey}'`);
|
|
208
|
+
console.log(` fyodosApiUrl: '${writePlan.apiUrl}'`);
|
|
209
|
+
return;
|
|
178
210
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
// public default; the var is misleading. Offer to align it explicitly.
|
|
188
|
-
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;
|
|
189
219
|
}
|
|
190
220
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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}`);
|
|
194
227
|
}
|
|
228
|
+
logDev(`[write-env] manual reminder for ${rel}`);
|
|
229
|
+
}
|
|
195
230
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
);
|
|
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 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const targetLabel =
|
|
244
|
+
writePlan.kind === 'manual-angular'
|
|
245
|
+
? writePlan.file
|
|
246
|
+
: writePlan.kind === 'manual-unknown'
|
|
247
|
+
? 'your .env file'
|
|
248
|
+
: writePlan.targetRel;
|
|
202
249
|
|
|
203
250
|
let proceed = assumeYes;
|
|
204
251
|
if (!proceed) {
|
|
205
252
|
proceed = await askYesNo(
|
|
206
|
-
`◉ Fiodos ·
|
|
253
|
+
`◉ Fiodos · Add your API key to ${targetLabel}? [yes/no] `,
|
|
207
254
|
);
|
|
208
255
|
}
|
|
256
|
+
|
|
209
257
|
if (!proceed) {
|
|
210
|
-
|
|
211
|
-
|
|
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 };
|
|
212
264
|
}
|
|
213
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
|
+
|
|
214
272
|
let out = lines.slice();
|
|
215
|
-
if (!keyMatches) out = upsertEnvVar(out, plan.keyVar,
|
|
273
|
+
if (!keyMatches) out = upsertEnvVar(out, plan.keyVar, key);
|
|
216
274
|
if (wantUrl) {
|
|
217
275
|
out = upsertEnvVar(out, plan.urlVar, wantUrl);
|
|
218
276
|
} else if (urlNeedsFix) {
|
|
219
|
-
out = upsertEnvVar(out, plan.urlVar,
|
|
277
|
+
out = upsertEnvVar(out, plan.urlVar, url);
|
|
220
278
|
}
|
|
221
279
|
fs.writeFileSync(filePath, `${out.join('\n')}\n`);
|
|
222
|
-
log(
|
|
280
|
+
log(`API key saved to ${targetRel}.`);
|
|
223
281
|
|
|
224
|
-
if (!isGitIgnored(appRoot,
|
|
225
|
-
|
|
282
|
+
if (!isGitIgnored(appRoot, targetRel)) {
|
|
283
|
+
devLog(`Reminder: add ${targetRel} to .gitignore so you don't commit your API key.`);
|
|
226
284
|
}
|
|
227
|
-
return 'written';
|
|
285
|
+
return { status: 'written', writePlan };
|
|
228
286
|
}
|
|
229
287
|
|
|
230
|
-
module.exports = {
|
|
288
|
+
module.exports = {
|
|
289
|
+
offerWriteEnv,
|
|
290
|
+
resolveEnvPlan,
|
|
291
|
+
computeEnvWritePlan,
|
|
292
|
+
printEnvManualReminder,
|
|
293
|
+
};
|