@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 +1 -1
- package/src/aiAnalyze.js +2 -2
- package/src/ast.js +72 -0
- package/src/collect.js +6 -1
- package/src/graph.js +1 -1
- package/src/index.js +234 -104
- package/src/resolveAnalysisRoot.js +125 -0
- package/src/scoreSurface.js +405 -0
- package/src/wireHandlers.js +37 -52
- package/src/wireWeb.js +7 -12
- package/src/writeEnv.js +121 -104
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/cli",
|
|
3
|
-
"version": "0.1.
|
|
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: [
|
|
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
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,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
|
-
|
|
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} ${
|
|
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
|
-
|
|
173
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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' ?
|
|
263
|
+
const routes = platform === 'mobile' ? scopedRoutes : [];
|
|
217
264
|
const verifyRoutes = platform === 'mobile' && routes.length > 0;
|
|
218
|
-
const appMeta = readAppMeta(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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).',
|
|
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
|
-
|
|
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
|
}
|