@fiodos/cli 0.1.4 → 0.1.9

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.9",
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/ast.js CHANGED
@@ -73,8 +73,42 @@ function extractFromFile(filePath, appRoot) {
73
73
  imports: [],
74
74
  linkingCalls: [],
75
75
  defaultExportName: null,
76
+ // Internal navigation destinations referenced in this file (route strings):
77
+ // <Link href|to>, <a href>, router.push/replace, navigate(), redirect().
78
+ // Used by the surface-scoring layer to build the navigation reachability
79
+ // graph (which screens are reachable from the deployed entry). External
80
+ // links (http, mailto, tel, #anchors) are excluded.
81
+ navTargets: [],
82
+ // True when this file DEFINES the app's route table (react-router <Route>,
83
+ // createBrowserRouter, Angular RouterModule.forRoot/provideRouter, Vue
84
+ // createRouter). Such a file's links act as GLOBAL navigation (available
85
+ // from any screen), unlike a per-section sidebar.
86
+ routeHost: false,
87
+ // Soft signals that this file sits behind authentication (used ONLY to keep
88
+ // an orphaned cluster ACTIVE — never to disable). Identifiers/paths that
89
+ // commonly indicate an auth gate.
90
+ authSignals: [],
76
91
  };
77
92
 
93
+ // Collect an internal route string if it looks like an in-app path.
94
+ function pushNavTarget(value) {
95
+ if (!value || typeof value !== 'string') return;
96
+ const v = value.trim();
97
+ if (!v) return;
98
+ // Only same-app paths. Drop external/protocol/anchor/empty targets.
99
+ if (!v.startsWith('/')) return;
100
+ if (v.startsWith('//')) return; // protocol-relative external
101
+ out.navTargets.push(v);
102
+ }
103
+
104
+ // GATING constructs only (a redirect/block when unauthenticated) — NOT mere
105
+ // session reading (useUser/useSession): reading the user does not gate access,
106
+ // and counting it would wrongly protect public-but-orphaned pages from being
107
+ // disabled. Used solely to KEEP an orphaned cluster active (SaaS guard).
108
+ const AUTH_HINT_RE =
109
+ /(requireAuth|withAuth|ProtectedRoute|RequireAuth|AuthGuard|authGuard|ensureAuth|canActivate|redirectToLogin|RedirectToSignIn|SignedOut)/;
110
+ const AUTH_PATH_RE = /\/(login|signin|sign-in|sign_in)\b/i;
111
+
78
112
  function jsxAttr(attrs, names) {
79
113
  for (const attr of attrs.properties) {
80
114
  if (ts.isJsxAttribute(attr) && names.includes(attr.name.getText())) {
@@ -118,6 +152,10 @@ function extractFromFile(filePath, appRoot) {
118
152
  }
119
153
  }
120
154
  out.imports.push({ from: node.moduleSpecifier.text, names });
155
+ for (const n of names) {
156
+ if (AUTH_HINT_RE.test(n)) out.authSignals.push(n);
157
+ }
158
+ if (AUTH_PATH_RE.test(node.moduleSpecifier.text)) out.authSignals.push(node.moduleSpecifier.text);
121
159
  }
122
160
 
123
161
  // default export name (screen component)
@@ -157,6 +195,34 @@ function extractFromFile(filePath, appRoot) {
157
195
  }
158
196
  }
159
197
  out.navCalls.push({ verb, target, file: rel });
198
+ if (verb !== 'back' && verb !== 'goBack') pushNavTarget(target);
199
+ }
200
+
201
+ // Next.js / framework redirects: redirect('/x'), permanentRedirect('/x').
202
+ if (/^(redirect|permanentRedirect)$/.test(exprText) && node.arguments[0]) {
203
+ pushNavTarget(literalText(node.arguments[0]));
204
+ }
205
+ // react-router imperative: navigate('/x') / history.push('/x').
206
+ if (/(^|\.)navigate$/.test(exprText) && node.arguments[0]) {
207
+ pushNavTarget(literalText(node.arguments[0]));
208
+ }
209
+ // Bare push('/x') from a destructured useRouter()/useNavigate() hook.
210
+ if (exprText === 'push' && node.arguments[0]) {
211
+ pushNavTarget(literalText(node.arguments[0]));
212
+ }
213
+ // Route-table definitions → this file's links are GLOBAL navigation.
214
+ if (/^(createBrowserRouter|createHashRouter|createMemoryRouter|createRouter)$/.test(exprText)) {
215
+ out.routeHost = true;
216
+ }
217
+ if (/\.(forRoot|forChild)$/.test(exprText) || /^provideRouter$/.test(exprText)) {
218
+ out.routeHost = true;
219
+ }
220
+
221
+ // Auth gate by call: redirect('/login') etc. is captured above as a nav
222
+ // target; also flag it as an auth signal when the path looks like a gate.
223
+ if (/^(redirect|replace|push)$/.test(exprText.split('.').pop() || '') && node.arguments[0]) {
224
+ const t = literalText(node.arguments[0]);
225
+ if (t && AUTH_PATH_RE.test(t)) out.authSignals.push(t);
160
226
  }
161
227
 
162
228
  // Alert.alert(title, message?, buttons?)
@@ -217,6 +283,12 @@ function extractFromFile(filePath, appRoot) {
217
283
  if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
218
284
  const tag = node.tagName.getText();
219
285
  const attrs = node.attributes;
286
+ // Any element carrying an internal route via href/to (next/link <Link>,
287
+ // <a href>, react-router <Link to>/<Navigate to>/<Redirect to>).
288
+ const linkTarget = jsxAttr(attrs, ['href', 'to']);
289
+ if (linkTarget) pushNavTarget(linkTarget);
290
+ // react-router <Route> table → global navigation host.
291
+ if (tag === 'Route') out.routeHost = true;
220
292
  if (/Button/i.test(tag) || tag === 'TouchableOpacity' || tag === 'Pressable') {
221
293
  const title = jsxAttr(attrs, ['title', 'label', 'accessibilityLabel']);
222
294
  const handler = jsxAttrHandlerName(attrs, ['onPress']);
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/graph.js CHANGED
@@ -157,4 +157,4 @@ function mapScreens(routes, graph) {
157
157
  return { screens, stores };
158
158
  }
159
159
 
160
- module.exports = { buildGraph, mapScreens };
160
+ module.exports = { buildGraph, mapScreens, closure };
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,13 @@ 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');
99
+ const { scoreSurface } = require('./scoreSurface');
96
100
 
97
101
  function arg(flag, fallback) {
98
102
  const i = process.argv.indexOf(flag);
@@ -158,24 +162,49 @@ async function withSpinner(label, work) {
158
162
  const orbFrames = ['◴', '◷', '◶', '◵'];
159
163
  const brailleFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
160
164
  const { blue, cyan, dim, reset } = fyodosTermColors();
165
+ let currentLabel = label;
161
166
  let frame = 0;
167
+ let id = null;
162
168
  const start = Date.now();
163
- const tick = () => {
169
+
170
+ const render = () => {
164
171
  const sec = Math.floor((Date.now() - start) / 1000);
165
172
  const orb = orbFrames[frame % orbFrames.length];
166
173
  const tail = brailleFrames[frame % brailleFrames.length];
167
174
  const line =
168
- `${cyan}${orb}${reset} ${blue}Fiodos${reset} ${dim}${tail}${reset} ${label}… ${dim}${sec}s${reset}`;
175
+ `${cyan}${orb}${reset} ${blue}Fiodos${reset} ${dim}${tail}${reset} ${currentLabel}… ${dim}${sec}s${reset}`;
169
176
  process.stderr.write(`\r\x1b[K${line}`);
170
177
  frame += 1;
171
178
  };
172
- tick();
173
- const id = setInterval(tick, 140);
179
+
180
+ const clearLine = () => process.stderr.write('\r\x1b[K');
181
+
182
+ const startSpinner = () => {
183
+ render();
184
+ id = setInterval(render, 140);
185
+ };
186
+
187
+ const pause = () => {
188
+ if (id) {
189
+ clearInterval(id);
190
+ id = null;
191
+ clearLine();
192
+ }
193
+ };
194
+
195
+ const resume = () => {
196
+ if (!id) startSpinner();
197
+ };
198
+
199
+ const setLabel = (next) => {
200
+ currentLabel = next;
201
+ };
202
+
203
+ startSpinner();
174
204
  try {
175
- return await work();
205
+ return await work({ setLabel, pause, resume });
176
206
  } finally {
177
- clearInterval(id);
178
- process.stderr.write('\r\x1b[K');
207
+ pause();
179
208
  }
180
209
  }
181
210
 
@@ -187,7 +216,8 @@ async function main() {
187
216
  // Default output dir lives in the OS temp dir, NOT inside the installed
188
217
  // package (when run via npx, __dirname is under node_modules and writing
189
218
  // there pollutes the package). Operators can still override with --out.
190
- const safeName = path.basename(path.resolve(appRoot)).replace(/[^a-z0-9_-]/gi, '_') || 'app';
219
+ const inputRoot = path.resolve(appRoot);
220
+ const safeName = path.basename(inputRoot).replace(/[^a-z0-9_-]/gi, '_') || 'app';
191
221
  const outDir = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
192
222
  // Model precedence: explicit --model (operator override) > backend plan model
193
223
  // (resolved below from the analysis-quota pre-check) > DEFAULT_MODEL fallback.
@@ -197,30 +227,47 @@ async function main() {
197
227
  const useLLM = !process.argv.includes('--no-llm');
198
228
  fs.mkdirSync(outDir, { recursive: true });
199
229
 
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
230
  const platformArg = (arg('--platform', '') || '').toLowerCase();
206
231
  const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android'];
207
- const platform = KNOWN_PLATFORMS.includes(platformArg)
208
- ? platformArg
209
- : detectPlatform(appRoot, appDir);
232
+ const forcedPlatform = KNOWN_PLATFORMS.includes(platformArg) ? platformArg : null;
233
+
234
+ // ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
235
+ // Platform: auto-detected from package.json / native manifests, or forced with
236
+ // --platform. Web apps have no static route ground truth, so the AI proposes
237
+ // routes and we keep them unverified (honest flag).
238
+ const { appDir, routes: scannedRoutes } = scanRoutes(inputRoot);
239
+ const platform = forcedPlatform ?? detectPlatform(inputRoot, appDir);
240
+
241
+ // Monorepos: scope analysis to the sub-app for this platform (e.g. frontend/
242
+ // for Next, mobile/ for Expo) so the web orb never inherits mobile actions.
243
+ const explicitAnalysisRoot = arg('--analysis-root', '');
244
+ const scope = explicitAnalysisRoot
245
+ ? { root: path.resolve(explicitAnalysisRoot), scoped: true, reason: '--analysis-root' }
246
+ : resolveAnalysisRoot(inputRoot, platform);
247
+ const analysisRoot = scope.root;
248
+ if (scope.scoped) {
249
+ const rel = path.relative(inputRoot, analysisRoot) || '.';
250
+ logDev(
251
+ `Scoped to ${rel} (${platform} app) — analyzing this folder only, not the whole repo.`,
252
+ );
253
+ logDev(`[scope] input=${inputRoot} analysis=${analysisRoot} reason=${scope.reason}`);
254
+ }
255
+
256
+ const { appDir: scopedAppDir, routes: scopedRoutes } = scanRoutes(analysisRoot);
210
257
  // The expo-router filesystem scan ONLY describes a mobile app. A Next.js App
211
258
  // Router web app also has an app/ dir, so the scan would otherwise produce
212
259
  // bogus "routes" and misclassify the project; for web we ignore it and let
213
260
  // the AI propose routes (the documented web contract).
214
261
  // Only Expo/RN ('mobile') has a static route ground truth. Web AND the native
215
262
  // platforms (flutter/ios/android) have the AI infer routes (kept unverified).
216
- const routes = platform === 'mobile' ? scannedRoutes : [];
263
+ const routes = platform === 'mobile' ? scopedRoutes : [];
217
264
  const verifyRoutes = platform === 'mobile' && routes.length > 0;
218
- const appMeta = readAppMeta(appRoot);
265
+ const appMeta = readAppMeta(analysisRoot);
219
266
  logDev(`[static] platform=${platform} routes=${routes.length}` +
220
267
  (platform === 'web' ? ' (web: routes inferred by AI, not statically verified)' : ''));
221
268
 
222
269
  // ── 2. Collect source files (rule-based, budget-aware) ────────────────────
223
- const { included, omitted, estimatedTokens } = collectFiles(appRoot, { maxInputTokens, platform });
270
+ const { included, omitted, estimatedTokens } = collectFiles(analysisRoot, { maxInputTokens, platform });
224
271
  fs.writeFileSync(path.join(outDir, 'files-sent.json'), JSON.stringify({
225
272
  included: included.map((f) => f.rel),
226
273
  omitted,
@@ -244,14 +291,14 @@ async function main() {
244
291
  // is not acceptable for this app.
245
292
  ({ manifest, provenance } = routesOnlyManifest(appMeta, routes));
246
293
  logDev('[no-llm] static-only manifest (routes only, no actions)');
247
- logUser(`Static analysis complete — ${manifest.routes.length} routes`);
294
+ logUser('Static analysis complete');
248
295
  } else {
249
296
  // Hybrid AI-key model. DEFAULT: hosted analysis via the Fiodos backend
250
297
  // proxy (Fiodos's AI key, no key needed by the developer). With
251
298
  // --own-ai-key the analysis runs locally against the developer's own
252
299
  // provider key (privacy path: source code goes straight to the AI and
253
300
  // never reaches Fiodos). --no-llm (handled above) sends no code anywhere.
254
- loadAppEnv(path.resolve(appRoot));
301
+ loadAppEnv(analysisRoot);
255
302
  const { apiKey, apiUrl } = resolveApiTarget();
256
303
  const useProxy = !process.argv.includes('--own-ai-key');
257
304
 
@@ -276,10 +323,9 @@ async function main() {
276
323
  }
277
324
 
278
325
  // ── 3. AI reads the code ────────────────────────────────────────────────
279
- logUser('Analyzing your project — may take 1–2 min');
280
326
  const { parsed, usage, costUSD, elapsedMs, promptChars, model: usedModel, analysisToken: token } =
281
327
  await withSpinner(
282
- 'Analyzing your project with AI',
328
+ 'Analyzing your project may take 1–2 min',
283
329
  () => analyzeWithAI({
284
330
  appMeta, files: included, omitted, staticRoutes: routes, model, platform,
285
331
  useProxy, apiKey, apiUrl,
@@ -304,7 +350,7 @@ async function main() {
304
350
  const droppedRoutes = provenance.routes.filter((r) => !r.included).length;
305
351
  logDev(`[verify] kept routes=${manifest.routes.length} actions=${manifest.actions.length}` +
306
352
  ` | dropped: ${droppedRoutes} routes, ${droppedActions} actions (unproven evidence)`);
307
- logUser(`Analysis complete — ${manifest.routes.length} routes, ${manifest.actions.length} actions`);
353
+ logUser('Analysis complete');
308
354
 
309
355
  fs.writeFileSync(path.join(outDir, 'evidence.json'), JSON.stringify({
310
356
  evidence, wiring, uncertain: parsed.uncertain || [],
@@ -334,6 +380,34 @@ async function main() {
334
380
  // the rest 'allow'. Closed-by-default still applies regardless of this hint.
335
381
  annotateExternalAgentRecommendations(manifest);
336
382
 
383
+ // ── 4c. Product-surface scoring (conservative, default-OFF for clear noise).
384
+ // Existence is already proven; this asks the SEPARATE question "is this part
385
+ // of the live product surface the orb should expose by default?". Routes/
386
+ // actions whose screens are UNREACHABLE by navigation from the deployed entry
387
+ // (orphans / dead code / a secondary app mirrored in the repo) AND not behind
388
+ // auth are annotated low-confidence and published DISABLED-by-default — never
389
+ // dropped (no new hallucination risk) and never empties an auth-gated app.
390
+ // Anything uncertain stays ACTIVE (fail towards including). The on/off lives
391
+ // in the workspace's disabled-intent sets (reused infra); the developer flips
392
+ // it in the dashboard. Web-first; other platforms are not scored yet.
393
+ let surface = { disabledRouteIntents: [], disabledActionIntents: [], stats: { applied: false } };
394
+ try {
395
+ surface = scoreSurface({ manifest, evidence, included, analysisRoot, platform });
396
+ for (const r of manifest.routes) {
397
+ const s = surface.routes && surface.routes[r.intent];
398
+ if (s) { r.surfaceConfidence = s.confidence; r.surfaceReason = s.reason; }
399
+ }
400
+ for (const a of manifest.actions) {
401
+ const s = surface.actions && surface.actions[a.intent];
402
+ if (s) { a.surfaceConfidence = s.confidence; a.surfaceReason = s.reason; }
403
+ }
404
+ logDev(`[surface] ${JSON.stringify(surface.stats)}`);
405
+ } catch (e) {
406
+ // Scoring is best-effort: a failure must never block publishing. Fail OPEN
407
+ // (everything stays active) — consistent with the "fail towards including".
408
+ logDev(`[surface] skipped: ${e.message}`);
409
+ }
410
+
337
411
  // ── 5. Final gate: the real @fiodos/core validator ─────────────────────────
338
412
  // Loaded as a package dependency (not a monorepo path) so it resolves when
339
413
  // the CLI is installed from npm.
@@ -352,14 +426,34 @@ async function main() {
352
426
  logDev(`[done] manifest: ${manifest.routes.length} routes, ${manifest.actions.length} actions`);
353
427
  logDev(`[done] output → ${outDir}`);
354
428
 
355
- if (process.argv.includes('--publish')) {
356
- loadAppEnv(path.resolve(appRoot));
357
- await publishManifest(manifest, {
358
- ...analysisMeta,
359
- generatedAt: new Date().toISOString(),
360
- routesIncluded: manifest.routes.length,
361
- actionsIncluded: manifest.actions.length,
362
- }, analysisToken);
429
+ const publishing = process.argv.includes('--publish');
430
+ const publishMeta = {
431
+ ...analysisMeta,
432
+ generatedAt: new Date().toISOString(),
433
+ routesIncluded: manifest.routes.length,
434
+ actionsIncluded: manifest.actions.length,
435
+ // Conservative default-OFF set for low-confidence (orphan) routes/actions.
436
+ // The backend applies these to the workspace's disabled-intent sets ONLY on
437
+ // the first publish / when the developer has not customized scope (no
438
+ // clobbering manual choices). Empty when scoring did not apply.
439
+ scopeDefaults: {
440
+ disabledRouteIntents: surface.disabledRouteIntents || [],
441
+ disabledActionIntents: surface.disabledActionIntents || [],
442
+ },
443
+ };
444
+
445
+ // Informative, non-blocking summary (the dashboard is where scope is managed).
446
+ const offR = (surface.disabledRouteIntents || []).length;
447
+ const offA = (surface.disabledActionIntents || []).length;
448
+ if (surface.stats && surface.stats.applied && (offR || offA)) {
449
+ const onR = manifest.routes.length - offR;
450
+ const onA = manifest.actions.length - offA;
451
+ logUser(`Main surface (active): ${onR} route(s), ${onA} action(s)`);
452
+ logUser(
453
+ `Detected but off by default: ${offR} route(s), ${offA} action(s) — ` +
454
+ `not reachable from your app's home (likely internal/secondary screens)`,
455
+ );
456
+ logUser('Review or re-enable any of them in your dashboard → Agent configuration');
363
457
  }
364
458
 
365
459
  // ── 6. Web: offer to wire the orb into the app (consent-based) ─────────────
@@ -370,46 +464,8 @@ async function main() {
370
464
  const { apiKey, apiUrl } = resolveApiTarget();
371
465
  const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
372
466
  const noWire = process.argv.includes('--no-wire');
373
-
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
- // --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
392
- // isolation without modifying the app's entrypoint / package.json).
393
- if (!process.argv.includes('--no-orb-wire')) {
394
- const result = await wireWebOrb(path.resolve(appRoot), {
395
- apiUrl,
396
- colors: fyodosTermColors(),
397
- assumeYes,
398
- noWire,
399
- runTest: !process.argv.includes('--no-wire-test'),
400
- });
401
- reportWireResult(result, fyodosTermColors());
402
- }
403
-
404
- // ── 7. Web: SECOND consent — connect detected actions to real app functions.
405
- // The orb is mounted but the agent cannot DO anything until each manifest
406
- // action is bridged to the function that performs it. We prepare that wiring,
407
- // write a readable document into the project's Fiodos folder, and ask a
408
- // separate, informed [yes/no] before touching anything. Security is enforced
409
- // upstream by the engine, so generated handlers can never skip a confirmation.
467
+ let envWriteResult = null;
410
468
  const { runPostWireTest } = require('./postWireTest');
411
- // Corrector: when a wired action fails install-time verification, re-prompt the
412
- // AI with the concrete error to fix THAT action. Disable with --no-self-correct.
413
469
  const selfCorrect = !process.argv.includes('--no-self-correct');
414
470
  const corrector = selfCorrect
415
471
  ? async (args) => {
@@ -417,7 +473,7 @@ async function main() {
417
473
  return fixed;
418
474
  }
419
475
  : null;
420
- const handlerResult = await wireHandlers(path.resolve(appRoot), {
476
+ const handlerOpts = {
421
477
  manifest,
422
478
  evidence,
423
479
  wiring,
@@ -425,14 +481,87 @@ async function main() {
425
481
  colors: fyodosTermColors(),
426
482
  assumeYes,
427
483
  noWire,
484
+ quiet: !isVerbose(),
428
485
  runTest: !process.argv.includes('--no-wire-test'),
429
486
  revertOnFailure: true,
430
487
  testRunner: runPostWireTest,
431
488
  corrector,
432
489
  files: included,
433
490
  maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
434
- });
435
- reportHandlerResult(handlerResult, fyodosTermColors());
491
+ };
492
+
493
+ const runWebWire = async ({ setLabel, pause, resume }) => {
494
+ // --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
495
+ // isolation without modifying the app's entrypoint / package.json).
496
+ if (!process.argv.includes('--no-orb-wire')) {
497
+ setLabel('Mounting the orb in your app');
498
+ if (!assumeYes) pause();
499
+ const result = await wireWebOrb(analysisRoot, {
500
+ apiUrl,
501
+ colors: fyodosTermColors(),
502
+ assumeYes,
503
+ noWire,
504
+ runTest: !process.argv.includes('--no-wire-test'),
505
+ });
506
+ if (!assumeYes) resume();
507
+ reportWireResult(result, fyodosTermColors(), { quiet: !isVerbose() });
508
+ if (
509
+ publishing &&
510
+ apiKey &&
511
+ (result.status === 'added' || result.status === 'already')
512
+ ) {
513
+ await postOrbWired({ apiKey, apiUrl, file: result.file || '' });
514
+ }
515
+ }
516
+
517
+ setLabel('Wiring actions to your app');
518
+ if (!assumeYes) pause();
519
+ const handlerResult = await wireHandlers(analysisRoot, handlerOpts);
520
+ if (!assumeYes) resume();
521
+ reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
522
+ };
523
+
524
+ if (publishing) {
525
+ loadAppEnv(analysisRoot);
526
+ await withSpinner('Publishing to your project', async (spinner) => {
527
+ await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
528
+ await runWebWire(spinner);
529
+ });
530
+ logUser('Published to your project');
531
+ } else {
532
+ await withSpinner('Mounting the orb in your app', runWebWire);
533
+ }
534
+
535
+ // ── 8. API key in environment (LAST step — always asks; independent of --yes)
536
+ // --yes only skips orb/handler prompts. --write-env-yes skips this one (CI).
537
+ // If the developer declines, we show a copy-paste reminder here at the end.
538
+ if (publishing && !process.argv.includes('--no-write-env')) {
539
+ try {
540
+ envWriteResult = await offerWriteEnv({
541
+ appRoot: analysisRoot,
542
+ apiKey,
543
+ apiUrl,
544
+ defaultApiUrl: DEFAULT_API_URL,
545
+ assumeYes: process.argv.includes('--write-env-yes'),
546
+ log: logUser,
547
+ logDev,
548
+ });
549
+ if (
550
+ envWriteResult &&
551
+ (envWriteResult.status === 'declined by user' || envWriteResult.status === 'manual')
552
+ ) {
553
+ printEnvManualReminder(envWriteResult.writePlan, { logUser, logDev });
554
+ }
555
+ } catch (e) {
556
+ logDev(`[write-env] skipped: ${e.message}`);
557
+ }
558
+ }
559
+ } else if (publishing) {
560
+ loadAppEnv(analysisRoot);
561
+ await withSpinner('Publishing to your project', () =>
562
+ publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true }),
563
+ );
564
+ logUser('Published to your project');
436
565
  }
437
566
  }
438
567
 
@@ -653,7 +782,7 @@ function printQuotaBlocked(quota) {
653
782
  );
654
783
  }
655
784
 
656
- async function publishManifest(manifest, meta, analysisToken = null) {
785
+ async function publishManifest(manifest, meta, analysisToken = null, opts = {}) {
657
786
  const { apiKey, apiUrl } = resolveApiTarget();
658
787
  if (!apiKey) {
659
788
  console.error(
@@ -686,17 +815,13 @@ async function publishManifest(manifest, meta, analysisToken = null) {
686
815
  process.exit(1);
687
816
  }
688
817
  const body = await res.json();
689
- logUser(`Published to your project — ${body.routes} routes, ${body.actions} actions`);
818
+ if (!opts.skipSuccessLog) {
819
+ logUser('Published to your project');
820
+ }
690
821
  logDev(`[publish] OK — ${body.routes} routes, ${body.actions} actions received at ${body.receivedAt}`);
691
822
 
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.
823
+ // Self-check: confirm the orb will find this manifest (silent unless --verbose).
697
824
  await verifyOrbCanFetch({ apiKey, apiUrl });
698
-
699
- logUser('Open the dashboard → Agent settings → "Reload analysis" to review');
700
825
  }
701
826
 
702
827
  /**
@@ -705,6 +830,23 @@ async function publishManifest(manifest, meta, analysisToken = null) {
705
830
  * and URL their running app must use. Never hard-fails the publish (the publish
706
831
  * already succeeded); a failed re-fetch is surfaced as an actionable warning.
707
832
  */
833
+ async function postOrbWired({ apiKey, apiUrl, file }) {
834
+ if (!apiKey) return;
835
+ try {
836
+ const res = await fetch(`${apiUrl}/v1/developer/orb-wired`, {
837
+ method: 'POST',
838
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
839
+ body: JSON.stringify({ file: file || '', platform: 'web' }),
840
+ });
841
+ if (res.ok) {
842
+ logDev(`[orb-wired] install proof recorded (${file || 'web'})`);
843
+ } else {
844
+ logDev(`[orb-wired] could not record install proof: HTTP ${res.status}`);
845
+ }
846
+ } catch (e) {
847
+ logDev(`[orb-wired] skipped: ${e.message || e}`);
848
+ }
849
+ }
708
850
  async function verifyOrbCanFetch({ apiKey, apiUrl }) {
709
851
  try {
710
852
  const res = await fetch(`${apiUrl}/v1/client/manifest`, {
@@ -714,27 +856,15 @@ async function verifyOrbCanFetch({ apiKey, apiUrl }) {
714
856
  if (res.ok) {
715
857
  const data = await res.json().catch(() => null);
716
858
  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).',
859
+ logDev(
860
+ `[verify] manifest reachable with key ${apiKey.slice(0, 12)}… against ${apiUrl}`,
724
861
  );
725
862
  return true;
726
863
  }
727
864
  }
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
- );
865
+ logDev(`[verify] published but re-fetch failed: HTTP ${res.status}`);
732
866
  } catch (e) {
733
867
  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
868
  }
739
869
  return false;
740
870
  }