@fiodos/cli 0.1.0
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/LICENSE +77 -0
- package/README.md +128 -0
- package/package.json +30 -0
- package/src/aiAnalyze.js +469 -0
- package/src/ast.js +263 -0
- package/src/collect.js +115 -0
- package/src/graph.js +160 -0
- package/src/index.js +667 -0
- package/src/llm.js +144 -0
- package/src/loadEnv.js +81 -0
- package/src/patterns.js +28 -0
- package/src/postWireTest.js +91 -0
- package/src/renderProbe.js +333 -0
- package/src/renderProbeVue.js +136 -0
- package/src/routes.js +81 -0
- package/src/verify.js +172 -0
- package/src/verifyWire.js +215 -0
- package/src/wireHandlers.js +1789 -0
- package/src/wireWeb.js +295 -0
- package/src/wireWebMount.js +435 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fiodos auto-manifest engine v3 — AI-FIRST.
|
|
4
|
+
*
|
|
5
|
+
* The model reads the app's SOURCE CODE directly and proposes the manifest;
|
|
6
|
+
* the mechanical layer verifies its output afterwards. Inverted from v2,
|
|
7
|
+
* where a static candidate-detector gated what the model was allowed to see
|
|
8
|
+
* (and a 0-candidate scan blinded the whole analysis).
|
|
9
|
+
*
|
|
10
|
+
* Pipeline:
|
|
11
|
+
* 1. static — expo-router route scan (cheap existence facts, reused from v2)
|
|
12
|
+
* 2. collect — rule-based file selection with input budget (not analysis)
|
|
13
|
+
* 3. AI — ONE call: model reads the code, proposes manifest + evidence
|
|
14
|
+
* 4. verify — mechanical output check: file + symbol + route must exist;
|
|
15
|
+
* unproven claims are dropped, never published
|
|
16
|
+
* 5. gate — @fiodos/core validateManifest
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* node src/index.js <target-app-root> --out <output-dir> \
|
|
20
|
+
* [--model claude-opus-4-8] [--max-input-tokens 150000] [--no-llm] \
|
|
21
|
+
* [--publish [--api-key <fyodos-project-api-key>] [--api-url <backend-url>]] \
|
|
22
|
+
* [--no-wire | --yes]
|
|
23
|
+
* [--verbose]
|
|
24
|
+
*
|
|
25
|
+
* Output verbosity:
|
|
26
|
+
* By default the CLI shows only developer-facing progress (no token counts,
|
|
27
|
+
* cost, model id, API key prefix, or internal file paths). Pass --verbose
|
|
28
|
+
* for the full diagnostic log (for Fiodos operators debugging locally).
|
|
29
|
+
*
|
|
30
|
+
* Web orb wiring (web targets only):
|
|
31
|
+
* After analysis, a web target is OFFERED automatic wiring of <FiodosAgent/>
|
|
32
|
+
* into the app's root layout. It asks before editing your code; on "no" (or a
|
|
33
|
+
* non-interactive shell) it prints the ready-to-paste snippet instead.
|
|
34
|
+
* --yes accept the wiring without the prompt (CI / express installer)
|
|
35
|
+
* --no-wire skip wiring entirely (analysis/publish unaffected)
|
|
36
|
+
*
|
|
37
|
+
* AI key — HYBRID model:
|
|
38
|
+
* DEFAULT (hosted): the AI analysis runs on the Fiodos backend with FIODOS'S
|
|
39
|
+
* key; you need no AI key of your own and the cost is part of the plan.
|
|
40
|
+
* In this mode your source code travels to the Fiodos backend on its way to
|
|
41
|
+
* the AI provider (see PRIVACY).
|
|
42
|
+
* --own-ai-key: the AI call runs locally with YOUR provider key — the source
|
|
43
|
+
* code goes straight from this machine to the AI and never reaches Fiodos.
|
|
44
|
+
* ANTHROPIC_API_KEY — own-key analysis default (Claude); your key, your cost
|
|
45
|
+
* OPENAI_API_KEY — own-key analysis if you pass --model gpt-5 / gpt-4o / …
|
|
46
|
+
* --no-llm: static-only pass (routes, no actions); no code leaves this machine.
|
|
47
|
+
*
|
|
48
|
+
* Env (auto-loaded from tools/auto-manifest/.env and fyodos/backend/.env;
|
|
49
|
+
* shell exports take precedence):
|
|
50
|
+
* FYODOS_API_KEY — project key for hosted analysis + --publish (or the
|
|
51
|
+
* analyzed app's EXPO_PUBLIC_FYODOS_API_KEY, which wins)
|
|
52
|
+
*
|
|
53
|
+
* PRIVACY (honest, per mode):
|
|
54
|
+
* - The PUBLISH step only ever POSTs the resulting manifest.json + small
|
|
55
|
+
* metadata to the Fiodos backend — never source code.
|
|
56
|
+
* - The ANALYSIS (AI) step differs by mode:
|
|
57
|
+
* · default (hosted/proxy): your source code IS sent to the Fiodos
|
|
58
|
+
* backend, which relays it to the AI provider with Fiodos's key. Held
|
|
59
|
+
* in memory for the call only; never stored or logged.
|
|
60
|
+
* · --own-ai-key: your source code is sent to the AI provider directly
|
|
61
|
+
* from this machine and never touches the Fiodos backend.
|
|
62
|
+
* · --no-llm: no source code is sent anywhere (static routes only).
|
|
63
|
+
*
|
|
64
|
+
* Output:
|
|
65
|
+
* manifest.json — AppManifest (schema of @fiodos/core), verified
|
|
66
|
+
* provenance.json — per route/action: verification result or drop reason
|
|
67
|
+
* evidence.json — model's claimed evidence + declared uncertainties
|
|
68
|
+
* usage.json — REAL token usage + cost in USD for this analysis
|
|
69
|
+
* files-sent.json — exactly which files were included in the prompt
|
|
70
|
+
*/
|
|
71
|
+
'use strict';
|
|
72
|
+
|
|
73
|
+
const fs = require('fs');
|
|
74
|
+
const os = require('os');
|
|
75
|
+
const path = require('path');
|
|
76
|
+
const { loadEnv, loadAppEnv } = require('./loadEnv');
|
|
77
|
+
loadEnv();
|
|
78
|
+
|
|
79
|
+
const { scanRoutes } = require('./routes');
|
|
80
|
+
const { collectFiles } = require('./collect');
|
|
81
|
+
const { analyzeWithAI, correctActionWiring, DEFAULT_MODEL, resolveModel } = require('./aiAnalyze');
|
|
82
|
+
const { verifyManifest } = require('./verify');
|
|
83
|
+
const { wireWebOrb, reportWireResult } = require('./wireWeb');
|
|
84
|
+
const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
|
|
85
|
+
|
|
86
|
+
function arg(flag, fallback) {
|
|
87
|
+
const i = process.argv.indexOf(flag);
|
|
88
|
+
return i > -1 ? process.argv[i + 1] : fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Flags that consume the following token as their value. Used by the
|
|
92
|
+
// positional parser so a value like the path in `--out <dir>` is not mistaken
|
|
93
|
+
// for the target app root.
|
|
94
|
+
const VALUE_FLAGS = new Set([
|
|
95
|
+
'--out', '--model', '--max-input-tokens', '--platform', '--api-key', '--api-url',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Positional (non-flag) arguments, skipping flags and their values. Lets the
|
|
100
|
+
* CLI accept BOTH `fiodos analyze <dir> [flags]` (the form the dashboard prints)
|
|
101
|
+
* and the bare `fiodos <dir> [flags]`. Without this, the literal word "analyze"
|
|
102
|
+
* was read as the target directory (the published-CLI bug this fixes).
|
|
103
|
+
*/
|
|
104
|
+
function positionalArgs() {
|
|
105
|
+
const rest = process.argv.slice(2);
|
|
106
|
+
const out = [];
|
|
107
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
108
|
+
const tok = rest[i];
|
|
109
|
+
if (tok.startsWith('-')) {
|
|
110
|
+
if (VALUE_FLAGS.has(tok) && i + 1 < rest.length && !rest[i + 1].startsWith('-')) {
|
|
111
|
+
i += 1; // consume the flag's value
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
out.push(tok);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function fyodosTermColors() {
|
|
121
|
+
const on = Boolean(process.stderr.isTTY);
|
|
122
|
+
return {
|
|
123
|
+
blue: on ? '\x1b[38;5;75m' : '',
|
|
124
|
+
cyan: on ? '\x1b[38;5;117m' : '',
|
|
125
|
+
dim: on ? '\x1b[2m' : '',
|
|
126
|
+
reset: on ? '\x1b[0m' : '',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Internal diagnostics (tokens, cost, paths…) — only with --verbose. */
|
|
131
|
+
function isVerbose() {
|
|
132
|
+
return process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function logDev(...args) {
|
|
136
|
+
if (isVerbose()) console.log(...args);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Branded one-liner for the developer running the CLI (not internal ops). */
|
|
140
|
+
function logUser(line) {
|
|
141
|
+
const { blue, cyan, reset } = fyodosTermColors();
|
|
142
|
+
console.log(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Branded terminal spinner (Fiodos orb + colors when stderr is a TTY). */
|
|
146
|
+
async function withSpinner(label, work) {
|
|
147
|
+
const orbFrames = ['◴', '◷', '◶', '◵'];
|
|
148
|
+
const brailleFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
149
|
+
const { blue, cyan, dim, reset } = fyodosTermColors();
|
|
150
|
+
let frame = 0;
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
const tick = () => {
|
|
153
|
+
const sec = Math.floor((Date.now() - start) / 1000);
|
|
154
|
+
const orb = orbFrames[frame % orbFrames.length];
|
|
155
|
+
const tail = brailleFrames[frame % brailleFrames.length];
|
|
156
|
+
const line =
|
|
157
|
+
`${cyan}${orb}${reset} ${blue}Fiodos${reset} ${dim}${tail}${reset} ${label}… ${dim}${sec}s${reset}`;
|
|
158
|
+
process.stderr.write(`\r\x1b[K${line}`);
|
|
159
|
+
frame += 1;
|
|
160
|
+
};
|
|
161
|
+
tick();
|
|
162
|
+
const id = setInterval(tick, 140);
|
|
163
|
+
try {
|
|
164
|
+
return await work();
|
|
165
|
+
} finally {
|
|
166
|
+
clearInterval(id);
|
|
167
|
+
process.stderr.write('\r\x1b[K');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function main() {
|
|
172
|
+
// Accept an optional leading `analyze` subcommand: `fiodos analyze <dir>`.
|
|
173
|
+
let positionals = positionalArgs();
|
|
174
|
+
if (positionals[0] === 'analyze') positionals = positionals.slice(1);
|
|
175
|
+
const appRoot = positionals[0] || '.';
|
|
176
|
+
// Default output dir lives in the OS temp dir, NOT inside the installed
|
|
177
|
+
// package (when run via npx, __dirname is under node_modules and writing
|
|
178
|
+
// there pollutes the package). Operators can still override with --out.
|
|
179
|
+
const safeName = path.basename(path.resolve(appRoot)).replace(/[^a-z0-9_-]/gi, '_') || 'app';
|
|
180
|
+
const outDir = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
|
|
181
|
+
// Model precedence: explicit --model (operator override) > backend plan model
|
|
182
|
+
// (resolved below from the analysis-quota pre-check) > DEFAULT_MODEL fallback.
|
|
183
|
+
const explicitModel = arg('--model', '');
|
|
184
|
+
let model = resolveModel(explicitModel || DEFAULT_MODEL);
|
|
185
|
+
const maxInputTokens = Number(arg('--max-input-tokens', '150000'));
|
|
186
|
+
const useLLM = !process.argv.includes('--no-llm');
|
|
187
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
188
|
+
|
|
189
|
+
// ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
|
|
190
|
+
// Platform: auto-detected from the presence of an expo-router app/ dir, or
|
|
191
|
+
// forced with --platform web|mobile. Web apps have no static route ground
|
|
192
|
+
// truth, so the AI proposes routes and we keep them unverified (honest flag).
|
|
193
|
+
const { appDir, routes: scannedRoutes } = scanRoutes(appRoot);
|
|
194
|
+
const platformArg = (arg('--platform', '') || '').toLowerCase();
|
|
195
|
+
const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android'];
|
|
196
|
+
const platform = KNOWN_PLATFORMS.includes(platformArg)
|
|
197
|
+
? platformArg
|
|
198
|
+
: detectPlatform(appRoot, appDir);
|
|
199
|
+
// The expo-router filesystem scan ONLY describes a mobile app. A Next.js App
|
|
200
|
+
// Router web app also has an app/ dir, so the scan would otherwise produce
|
|
201
|
+
// bogus "routes" and misclassify the project; for web we ignore it and let
|
|
202
|
+
// the AI propose routes (the documented web contract).
|
|
203
|
+
// Only Expo/RN ('mobile') has a static route ground truth. Web AND the native
|
|
204
|
+
// platforms (flutter/ios/android) have the AI infer routes (kept unverified).
|
|
205
|
+
const routes = platform === 'mobile' ? scannedRoutes : [];
|
|
206
|
+
const verifyRoutes = platform === 'mobile' && routes.length > 0;
|
|
207
|
+
const appMeta = readAppMeta(appRoot);
|
|
208
|
+
logDev(`[static] platform=${platform} routes=${routes.length}` +
|
|
209
|
+
(platform === 'web' ? ' (web: routes inferred by AI, not statically verified)' : ''));
|
|
210
|
+
|
|
211
|
+
// ── 2. Collect source files (rule-based, budget-aware) ────────────────────
|
|
212
|
+
const { included, omitted, estimatedTokens } = collectFiles(appRoot, { maxInputTokens, platform });
|
|
213
|
+
fs.writeFileSync(path.join(outDir, 'files-sent.json'), JSON.stringify({
|
|
214
|
+
included: included.map((f) => f.rel),
|
|
215
|
+
omitted,
|
|
216
|
+
}, null, 2));
|
|
217
|
+
logDev(`[collect] files=${included.length} (~${estimatedTokens} tokens)` +
|
|
218
|
+
(omitted.length ? ` omitted=${omitted.length} (over budget)` : ''));
|
|
219
|
+
|
|
220
|
+
let manifest;
|
|
221
|
+
let provenance;
|
|
222
|
+
let evidence = {};
|
|
223
|
+
let wiring = {};
|
|
224
|
+
let analysisMeta = { engine: 'auto-manifest-v3-ai-first', model: null, costUSD: 0 };
|
|
225
|
+
// Signed proof returned by the hosted-analysis proxy: it tells the publish
|
|
226
|
+
// step the quota was already consumed at /v1/developer/analyze (no double
|
|
227
|
+
// count). Null in own-key / --no-llm mode.
|
|
228
|
+
let analysisToken = null;
|
|
229
|
+
|
|
230
|
+
if (!useLLM) {
|
|
231
|
+
// Static-only fallback: routes with mechanical naming, no actions.
|
|
232
|
+
// Useful as a free smoke pass or when sending code to an AI provider
|
|
233
|
+
// is not acceptable for this app.
|
|
234
|
+
({ manifest, provenance } = routesOnlyManifest(appMeta, routes));
|
|
235
|
+
logDev('[no-llm] static-only manifest (routes only, no actions)');
|
|
236
|
+
logUser(`Static analysis complete — ${manifest.routes.length} routes`);
|
|
237
|
+
} else {
|
|
238
|
+
// Hybrid AI-key model. DEFAULT: hosted analysis via the Fiodos backend
|
|
239
|
+
// proxy (Fiodos's AI key, no key needed by the developer). With
|
|
240
|
+
// --own-ai-key the analysis runs locally against the developer's own
|
|
241
|
+
// provider key (privacy path: source code goes straight to the AI and
|
|
242
|
+
// never reaches Fiodos). --no-llm (handled above) sends no code anywhere.
|
|
243
|
+
loadAppEnv(path.resolve(appRoot));
|
|
244
|
+
const { apiKey, apiUrl } = resolveApiTarget();
|
|
245
|
+
const useProxy = !process.argv.includes('--own-ai-key');
|
|
246
|
+
|
|
247
|
+
// ── 2b. Plan quota pre-check (before spending any AI tokens) ─────────────
|
|
248
|
+
// Ask the backend whether this project's plan still allows an analysis. The
|
|
249
|
+
// Free plan includes exactly one; paid plans allow several. Both the proxy
|
|
250
|
+
// and the publish step re-check server-side, so this is a courtesy to fail
|
|
251
|
+
// fast (and, in own-key mode, to avoid wasting the developer's tokens).
|
|
252
|
+
if (apiKey) {
|
|
253
|
+
const quota = await fetchAnalysisQuota({ apiKey, apiUrl });
|
|
254
|
+
if (quota && quota.allowed === false) {
|
|
255
|
+
printQuotaBlocked(quota);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Tier-based model: the backend tells us which model this plan is
|
|
259
|
+
// entitled to. The user never sees or sets it. An explicit --model
|
|
260
|
+
// (operator override) always wins. In proxy mode the server forces the
|
|
261
|
+
// model regardless; this only matters for the own-key path.
|
|
262
|
+
if (!explicitModel && quota && quota.model) {
|
|
263
|
+
model = resolveModel(quota.model);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── 3. AI reads the code ────────────────────────────────────────────────
|
|
268
|
+
logUser('Analyzing your project — may take 1–2 min');
|
|
269
|
+
const { parsed, usage, costUSD, elapsedMs, promptChars, model: usedModel, analysisToken: token } =
|
|
270
|
+
await withSpinner(
|
|
271
|
+
'Analyzing your project with AI',
|
|
272
|
+
() => analyzeWithAI({
|
|
273
|
+
appMeta, files: included, omitted, staticRoutes: routes, model, platform,
|
|
274
|
+
useProxy, apiKey, apiUrl,
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
if (usedModel) model = usedModel;
|
|
278
|
+
analysisToken = token || null;
|
|
279
|
+
const proposed = parsed.manifest || {};
|
|
280
|
+
evidence = parsed.evidence || {};
|
|
281
|
+
wiring = parsed.wiring || {};
|
|
282
|
+
logDev(`[ai] model=${model} proposed routes=${(proposed.routes || []).length} ` +
|
|
283
|
+
`actions=${(proposed.actions || []).length} in ${(elapsedMs / 1000).toFixed(1)}s`);
|
|
284
|
+
logDev(`[ai] tokens in=${usage.prompt_tokens} out=${usage.completion_tokens} cost=$${costUSD.toFixed(4)}`);
|
|
285
|
+
|
|
286
|
+
// ── 4. Mechanical verification of the AI's claims ───────────────────────
|
|
287
|
+
// Actions are always verified against real code. Routes are verified only
|
|
288
|
+
// when there is filesystem ground truth (mobile); for web they are kept as
|
|
289
|
+
// AI-proposed and flagged unverified in provenance.
|
|
290
|
+
({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes }));
|
|
291
|
+
ensureBackRoute(manifest, provenance);
|
|
292
|
+
const droppedActions = provenance.actions.filter((a) => !a.included).length;
|
|
293
|
+
const droppedRoutes = provenance.routes.filter((r) => !r.included).length;
|
|
294
|
+
logDev(`[verify] kept routes=${manifest.routes.length} actions=${manifest.actions.length}` +
|
|
295
|
+
` | dropped: ${droppedRoutes} routes, ${droppedActions} actions (unproven evidence)`);
|
|
296
|
+
logUser(`Analysis complete — ${manifest.routes.length} routes, ${manifest.actions.length} actions`);
|
|
297
|
+
|
|
298
|
+
fs.writeFileSync(path.join(outDir, 'evidence.json'), JSON.stringify({
|
|
299
|
+
evidence, wiring, uncertain: parsed.uncertain || [],
|
|
300
|
+
}, null, 2));
|
|
301
|
+
fs.writeFileSync(path.join(outDir, 'usage.json'), JSON.stringify({
|
|
302
|
+
model, elapsedMs, usage,
|
|
303
|
+
costUSD: Number(costUSD.toFixed(4)),
|
|
304
|
+
promptChars, filesSent: included.length, filesOmitted: omitted.length,
|
|
305
|
+
}, null, 2));
|
|
306
|
+
|
|
307
|
+
analysisMeta = {
|
|
308
|
+
engine: 'auto-manifest-v3-ai-first',
|
|
309
|
+
model,
|
|
310
|
+
platform,
|
|
311
|
+
routeVerification: verifyRoutes ? 'filesystem' : 'ai-proposed',
|
|
312
|
+
appKind: parsed.appKind || 'unknown',
|
|
313
|
+
costUSD: Number(costUSD.toFixed(4)),
|
|
314
|
+
droppedActions,
|
|
315
|
+
droppedRoutes,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── 4b. Agent-to-agent: suggest which actions to restrict for EXTERNAL agents.
|
|
320
|
+
// Advisory only (the developer decides in the dashboard; nothing is gated by
|
|
321
|
+
// this). Sensitive/irreversible actions — those requiring confirmation, or
|
|
322
|
+
// whose label/intent reads as destructive/financial — are flagged 'restrict';
|
|
323
|
+
// the rest 'allow'. Closed-by-default still applies regardless of this hint.
|
|
324
|
+
annotateExternalAgentRecommendations(manifest);
|
|
325
|
+
|
|
326
|
+
// ── 5. Final gate: the real @fiodos/core validator ─────────────────────────
|
|
327
|
+
// Loaded as a package dependency (not a monorepo path) so it resolves when
|
|
328
|
+
// the CLI is installed from npm.
|
|
329
|
+
try {
|
|
330
|
+
const { validateManifest } = require('@fiodos/core');
|
|
331
|
+
const validation = validateManifest(manifest);
|
|
332
|
+
provenance.coreValidation = validation;
|
|
333
|
+
logDev(`[validate] @fiodos/core validateManifest → valid=${validation.valid}` +
|
|
334
|
+
(validation.errors?.length ? ` errors=${JSON.stringify(validation.errors)}` : ''));
|
|
335
|
+
} catch (e) {
|
|
336
|
+
provenance.coreValidation = { skipped: `@fiodos/core validateManifest unavailable: ${e.message}` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
340
|
+
fs.writeFileSync(path.join(outDir, 'provenance.json'), JSON.stringify(provenance, null, 2));
|
|
341
|
+
logDev(`[done] manifest: ${manifest.routes.length} routes, ${manifest.actions.length} actions`);
|
|
342
|
+
logDev(`[done] output → ${outDir}`);
|
|
343
|
+
|
|
344
|
+
if (process.argv.includes('--publish')) {
|
|
345
|
+
loadAppEnv(path.resolve(appRoot));
|
|
346
|
+
await publishManifest(manifest, {
|
|
347
|
+
...analysisMeta,
|
|
348
|
+
generatedAt: new Date().toISOString(),
|
|
349
|
+
routesIncluded: manifest.routes.length,
|
|
350
|
+
actionsIncluded: manifest.actions.length,
|
|
351
|
+
}, analysisToken);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── 6. Web: offer to wire the orb into the app (consent-based) ─────────────
|
|
355
|
+
// The same command the developer already runs also drops <FiodosAgent/> into
|
|
356
|
+
// their app, so the onboarding never needs a separate manual step. Mobile is
|
|
357
|
+
// wired by its own installer and is intentionally left untouched here.
|
|
358
|
+
if (platform === 'web') {
|
|
359
|
+
const { apiUrl } = resolveApiTarget();
|
|
360
|
+
const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
|
|
361
|
+
const noWire = process.argv.includes('--no-wire');
|
|
362
|
+
// --no-orb-wire skips ONLY the orb mount (useful to test handler wiring in
|
|
363
|
+
// isolation without modifying the app's entrypoint / package.json).
|
|
364
|
+
if (!process.argv.includes('--no-orb-wire')) {
|
|
365
|
+
const result = await wireWebOrb(path.resolve(appRoot), {
|
|
366
|
+
apiUrl,
|
|
367
|
+
colors: fyodosTermColors(),
|
|
368
|
+
assumeYes,
|
|
369
|
+
noWire,
|
|
370
|
+
});
|
|
371
|
+
reportWireResult(result, fyodosTermColors());
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── 7. Web: SECOND consent — connect detected actions to real app functions.
|
|
375
|
+
// The orb is mounted but the agent cannot DO anything until each manifest
|
|
376
|
+
// action is bridged to the function that performs it. We prepare that wiring,
|
|
377
|
+
// write a readable document into the project's Fiodos folder, and ask a
|
|
378
|
+
// separate, informed [yes/no] before touching anything. Security is enforced
|
|
379
|
+
// upstream by the engine, so generated handlers can never skip a confirmation.
|
|
380
|
+
const { runPostWireTest } = require('./postWireTest');
|
|
381
|
+
// Corrector: when a wired action fails install-time verification, re-prompt the
|
|
382
|
+
// AI with the concrete error to fix THAT action. Disable with --no-self-correct.
|
|
383
|
+
const selfCorrect = !process.argv.includes('--no-self-correct');
|
|
384
|
+
const corrector = selfCorrect
|
|
385
|
+
? async (args) => {
|
|
386
|
+
const { wiring: fixed } = await correctActionWiring({ ...args, model, platform });
|
|
387
|
+
return fixed;
|
|
388
|
+
}
|
|
389
|
+
: null;
|
|
390
|
+
const handlerResult = await wireHandlers(path.resolve(appRoot), {
|
|
391
|
+
manifest,
|
|
392
|
+
evidence,
|
|
393
|
+
wiring,
|
|
394
|
+
appName: manifest.appName,
|
|
395
|
+
colors: fyodosTermColors(),
|
|
396
|
+
assumeYes,
|
|
397
|
+
noWire,
|
|
398
|
+
runTest: !process.argv.includes('--no-wire-test'),
|
|
399
|
+
revertOnFailure: true,
|
|
400
|
+
testRunner: runPostWireTest,
|
|
401
|
+
corrector,
|
|
402
|
+
files: included,
|
|
403
|
+
maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
|
|
404
|
+
});
|
|
405
|
+
reportHandlerResult(handlerResult, fyodosTermColors());
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Resolve the target platform from package.json dependencies — the only signal
|
|
411
|
+
* that reliably tells a Next.js App Router web app (which has an app/ dir) apart
|
|
412
|
+
* from an expo-router mobile app (which also has an app/ dir). Falls back to the
|
|
413
|
+
* old app/-dir heuristic only when no framework dependency is recognizable.
|
|
414
|
+
*/
|
|
415
|
+
function detectPlatform(appRoot, appDir) {
|
|
416
|
+
// Native projects are recognized by their build manifests (no package.json).
|
|
417
|
+
const has = (rel) => fs.existsSync(path.join(appRoot, rel));
|
|
418
|
+
const hasAnyExt = (ext) => {
|
|
419
|
+
// Shallow check: any source of this extension within a few levels.
|
|
420
|
+
let found = false;
|
|
421
|
+
const walk = (dir, depth) => {
|
|
422
|
+
if (found || depth > 12) return;
|
|
423
|
+
let entries = [];
|
|
424
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
425
|
+
for (const e of entries) {
|
|
426
|
+
if (found) return;
|
|
427
|
+
if (e.isDirectory()) {
|
|
428
|
+
if (!['node_modules', '.git', 'build', '.gradle', '.dart_tool', '.build', 'Pods'].includes(e.name)) {
|
|
429
|
+
walk(path.join(dir, e.name), depth + 1);
|
|
430
|
+
}
|
|
431
|
+
} else if (e.name.endsWith(ext)) {
|
|
432
|
+
found = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
walk(appRoot, 0);
|
|
437
|
+
return found;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (has('pubspec.yaml')) return 'flutter';
|
|
441
|
+
if (has('Package.swift') || hasAnyExt('.xcodeproj') || (has('Sources') && hasAnyExt('.swift'))) return 'ios';
|
|
442
|
+
if ((has('settings.gradle.kts') || has('settings.gradle') || has('build.gradle.kts') || has('build.gradle')) && hasAnyExt('.kt')) {
|
|
443
|
+
return 'android';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let deps = {};
|
|
447
|
+
try {
|
|
448
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'));
|
|
449
|
+
deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
450
|
+
} catch {
|
|
451
|
+
/* no package.json → fall through to the directory heuristic */
|
|
452
|
+
}
|
|
453
|
+
if (deps.expo || deps['expo-router'] || deps['react-native']) return 'mobile';
|
|
454
|
+
if (
|
|
455
|
+
deps.next || deps.vite || deps['@vitejs/plugin-react'] || deps['react-scripts'] ||
|
|
456
|
+
deps['@angular/core'] || deps['@angular-devkit/build-angular'] ||
|
|
457
|
+
deps['@sveltejs/kit'] || deps.svelte || deps.vue || deps['@vue/cli-service']
|
|
458
|
+
) {
|
|
459
|
+
return 'web';
|
|
460
|
+
}
|
|
461
|
+
return appDir ? 'mobile' : 'web';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function readAppMeta(appRoot) {
|
|
465
|
+
const meta = { name: path.basename(appRoot), description: '', dependencies: [] };
|
|
466
|
+
const pkgPath = path.join(appRoot, 'package.json');
|
|
467
|
+
if (fs.existsSync(pkgPath)) {
|
|
468
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
469
|
+
meta.name = pkg.name || meta.name;
|
|
470
|
+
meta.description = pkg.description || '';
|
|
471
|
+
meta.dependencies = Object.keys(pkg.dependencies || {});
|
|
472
|
+
}
|
|
473
|
+
for (const cfg of ['app.json', 'app.config.ts', 'app.config.js']) {
|
|
474
|
+
const p = path.join(appRoot, cfg);
|
|
475
|
+
if (!fs.existsSync(p)) continue;
|
|
476
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
477
|
+
const nameMatch = text.match(/APP_NAME\s*=\s*["']([^"']+)["']/) || text.match(/"name"\s*:\s*"([^"]+)"/);
|
|
478
|
+
if (nameMatch) meta.name = nameMatch[1];
|
|
479
|
+
const bundleMatch = text.match(/BUNDLE_IDENTIFIER\s*=\s*["']([^"']+)["']/) || text.match(/"bundleIdentifier"\s*:\s*"([^"]+)"/);
|
|
480
|
+
if (bundleMatch) meta.bundleId = bundleMatch[1];
|
|
481
|
+
}
|
|
482
|
+
// Native projects have no package.json — read their own manifests for a name.
|
|
483
|
+
const pubspec = path.join(appRoot, 'pubspec.yaml');
|
|
484
|
+
if (fs.existsSync(pubspec)) {
|
|
485
|
+
const text = fs.readFileSync(pubspec, 'utf8');
|
|
486
|
+
const n = text.match(/^name:\s*([A-Za-z0-9_\-]+)/m);
|
|
487
|
+
const d = text.match(/^description:\s*["']?([^"'\n]+)/m);
|
|
488
|
+
if (n) meta.name = n[1];
|
|
489
|
+
if (d && !meta.description) meta.description = d[1].trim().slice(0, 300);
|
|
490
|
+
}
|
|
491
|
+
const pkgSwift = path.join(appRoot, 'Package.swift');
|
|
492
|
+
if (fs.existsSync(pkgSwift)) {
|
|
493
|
+
const n = fs.readFileSync(pkgSwift, 'utf8').match(/name:\s*"([^"]+)"/);
|
|
494
|
+
if (n) meta.name = n[1];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const readme = path.join(appRoot, 'README.md');
|
|
498
|
+
if (!meta.description && fs.existsSync(readme)) {
|
|
499
|
+
const lines = fs.readFileSync(readme, 'utf8').split('\n').filter((l) => l.trim() && !l.startsWith('#'));
|
|
500
|
+
meta.description = (lines[0] || '').replace(/^[*\-\s]+/, '').slice(0, 300);
|
|
501
|
+
}
|
|
502
|
+
return meta;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** --no-llm: routes from the filesystem scan with mechanical naming. */
|
|
506
|
+
function routesOnlyManifest(appMeta, routes) {
|
|
507
|
+
const provenance = { engine: 'auto-manifest-v3-ai-first (static-only)', routes: [], actions: [], notes: [] };
|
|
508
|
+
const manifestRoutes = [];
|
|
509
|
+
const seen = new Set();
|
|
510
|
+
for (const r of routes) {
|
|
511
|
+
if (r.isDynamic) continue;
|
|
512
|
+
const slug = (r.urlPath === '/' ? 'home' : r.urlPath.replace(/^\//, '').replace(/[^a-z0-9]+/gi, '_')).toLowerCase();
|
|
513
|
+
const intent = `open_${slug}`;
|
|
514
|
+
if (seen.has(intent)) continue;
|
|
515
|
+
seen.add(intent);
|
|
516
|
+
const label = slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
517
|
+
manifestRoutes.push({
|
|
518
|
+
intent, label, route: r.routePath,
|
|
519
|
+
examples: [`open ${label.toLowerCase()}`, `go to ${label.toLowerCase()}`, `show ${label.toLowerCase()}`],
|
|
520
|
+
});
|
|
521
|
+
provenance.routes.push({ intent, included: true, verification: 'static (filesystem scan, mechanical naming)' });
|
|
522
|
+
}
|
|
523
|
+
const manifest = {
|
|
524
|
+
appId: appMeta.bundleId || `auto.${appMeta.name.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`,
|
|
525
|
+
appName: appMeta.name,
|
|
526
|
+
appDescription: appMeta.description || 'auto-detected app (static-only pass)',
|
|
527
|
+
version: '0.1.0-auto-v3',
|
|
528
|
+
routes: manifestRoutes,
|
|
529
|
+
actions: [],
|
|
530
|
+
policies: { requireConfirmationForDestructive: true, allowedWithoutAuth: [], contextRetentionTurns: 5 },
|
|
531
|
+
};
|
|
532
|
+
ensureBackRoute(manifest, provenance);
|
|
533
|
+
return { manifest, provenance };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function ensureBackRoute(manifest, provenance) {
|
|
537
|
+
if (!manifest.routes.some((r) => r.route === 'BACK')) {
|
|
538
|
+
manifest.routes.push({
|
|
539
|
+
intent: 'back', label: 'Back', route: 'BACK',
|
|
540
|
+
examples: ['go back', 'back', 'previous screen'],
|
|
541
|
+
});
|
|
542
|
+
provenance.routes.push({ intent: 'back', included: true, verification: 'static (SDK convention, added mechanically)' });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Words that, in an action label/intent, signal a sensitive or irreversible
|
|
547
|
+
// operation worth keeping human-only by default (agent-to-agent).
|
|
548
|
+
const RESTRICT_HINTS = [
|
|
549
|
+
'delete', 'remove', 'cancel', 'pay', 'payment', 'purchase', 'buy', 'order',
|
|
550
|
+
'checkout', 'transfer', 'withdraw', 'refund', 'logout', 'sign out', 'deactivate',
|
|
551
|
+
'close account', 'address', 'password', 'subscribe', 'unsubscribe', 'send money',
|
|
552
|
+
'borrar', 'eliminar', 'cancelar', 'pagar', 'pago', 'comprar', 'pedido',
|
|
553
|
+
'transferir', 'retirar', 'reembolso', 'cerrar sesión', 'dirección', 'contraseña',
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Annotate each action with `externalAgentRecommendation` ('restrict' | 'allow').
|
|
558
|
+
* Advisory only: the dashboard surfaces it as a suggestion and the developer
|
|
559
|
+
* decides. An action that requires confirmation, or whose label/intent matches a
|
|
560
|
+
* sensitive keyword, is recommended for restriction.
|
|
561
|
+
*/
|
|
562
|
+
function annotateExternalAgentRecommendations(manifest) {
|
|
563
|
+
for (const action of manifest.actions || []) {
|
|
564
|
+
const haystack = `${action.intent || ''} ${action.label || ''}`.toLowerCase();
|
|
565
|
+
const looksSensitive =
|
|
566
|
+
action.requireConfirmation === true ||
|
|
567
|
+
RESTRICT_HINTS.some((w) => haystack.includes(w));
|
|
568
|
+
action.externalAgentRecommendation = looksSensitive ? 'restrict' : 'allow';
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* POST the resulting manifest (and only the manifest + small metadata) to the
|
|
574
|
+
* developer's Fiodos project. Authenticated with the project API key shown in
|
|
575
|
+
* the dashboard. Never sends source files, evidence or usage logs.
|
|
576
|
+
*/
|
|
577
|
+
// Production Fiodos backend. The published CLI defaults here so the dashboard's
|
|
578
|
+
// `npx @fiodos/cli analyze . --publish --api-key …` (no --api-url) reaches prod.
|
|
579
|
+
// Local dev overrides with --api-url http://localhost:8000 or FYODOS_API_URL
|
|
580
|
+
// (or the analyzed app's VITE/NEXT/EXPO_PUBLIC_FYODOS_API_URL via loadAppEnv).
|
|
581
|
+
const DEFAULT_API_URL = 'https://api.fyodos.com';
|
|
582
|
+
|
|
583
|
+
/** Resolve the Fiodos backend target (project API key + URL) from flags/env. */
|
|
584
|
+
function resolveApiTarget() {
|
|
585
|
+
const apiKey = arg('--api-key', process.env.FYODOS_API_KEY || '');
|
|
586
|
+
const apiUrl = (arg('--api-url', process.env.FYODOS_API_URL || DEFAULT_API_URL) || '').replace(/\/$/, '');
|
|
587
|
+
return { apiKey, apiUrl };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Ask the backend, BEFORE calling the AI, for this project's analysis quota and
|
|
592
|
+
* the model its plan is entitled to. Returns the full quota object, or null on
|
|
593
|
+
* missing key / unreachable backend (the publish step is the real gate, so we
|
|
594
|
+
* never hard-fail the analysis on a transient pre-check error). The caller reads
|
|
595
|
+
* `allowed` (to stop early when the quota is exhausted) and `model` (tier-based
|
|
596
|
+
* cost control: free→Haiku, pro→Sonnet, advanced/enterprise→Opus).
|
|
597
|
+
*/
|
|
598
|
+
async function fetchAnalysisQuota({ apiKey, apiUrl }) {
|
|
599
|
+
if (!apiKey) return null;
|
|
600
|
+
try {
|
|
601
|
+
const res = await fetch(`${apiUrl}/v1/developer/analysis/quota`, {
|
|
602
|
+
headers: { 'x-api-key': apiKey },
|
|
603
|
+
});
|
|
604
|
+
if (!res.ok) return null;
|
|
605
|
+
return await res.json();
|
|
606
|
+
} catch {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Branded message shown when the plan's analysis quota is exhausted. */
|
|
612
|
+
function printQuotaBlocked(quota) {
|
|
613
|
+
const { blue, cyan, dim, reset } = fyodosTermColors();
|
|
614
|
+
const limit = quota.limit == null ? '∞' : quota.limit;
|
|
615
|
+
console.error(`\n${cyan}◉${reset} ${blue}Fiodos${reset} · analysis unavailable`);
|
|
616
|
+
console.error(
|
|
617
|
+
`${dim}Your plan (${quota.planId}) includes ${limit} analysis run(s) and you have already used them ` +
|
|
618
|
+
`(${quota.used}/${limit}).${reset}`,
|
|
619
|
+
);
|
|
620
|
+
console.error(
|
|
621
|
+
`${dim}Upgrade your plan in the Fiodos dashboard to analyze again. ` +
|
|
622
|
+
`No AI analysis was consumed.${reset}\n`,
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function publishManifest(manifest, meta, analysisToken = null) {
|
|
627
|
+
const { apiKey, apiUrl } = resolveApiTarget();
|
|
628
|
+
if (!apiKey) {
|
|
629
|
+
console.error(
|
|
630
|
+
'[publish] missing project API key — add FYODOS_API_KEY or FYODOS_CLIENT_KEYS to backend/.env, or pass --api-key',
|
|
631
|
+
);
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
logDev(`[publish] POST ${apiUrl}/v1/developer/manifest (manifest + metadata only — no source code)`);
|
|
635
|
+
logDev(`[publish] project API key: ${apiKey.slice(0, 12)}…`);
|
|
636
|
+
// In proxy mode, forward the signed analysis token so the backend knows the
|
|
637
|
+
// quota was already consumed at the analyze step (no double count). Stripped
|
|
638
|
+
// server-side before the meta is persisted.
|
|
639
|
+
const payloadMeta = analysisToken ? { ...meta, analysisToken } : meta;
|
|
640
|
+
const res = await fetch(`${apiUrl}/v1/developer/manifest`, {
|
|
641
|
+
method: 'POST',
|
|
642
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
|
643
|
+
body: JSON.stringify({ manifest, meta: payloadMeta }),
|
|
644
|
+
});
|
|
645
|
+
if (!res.ok) {
|
|
646
|
+
if (res.status === 429) {
|
|
647
|
+
const detail = await res.json().catch(() => null);
|
|
648
|
+
const q = detail && detail.detail ? detail.detail : detail;
|
|
649
|
+
if (q && q.planId) {
|
|
650
|
+
printQuotaBlocked({ planId: q.planId, used: q.used, limit: q.limit });
|
|
651
|
+
process.exit(1);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const text = await res.text().catch(() => '');
|
|
655
|
+
console.error(`[publish] FAILED ${res.status}: ${text || res.statusText}`);
|
|
656
|
+
process.exit(1);
|
|
657
|
+
}
|
|
658
|
+
const body = await res.json();
|
|
659
|
+
logUser(`Published to your project — ${body.routes} routes, ${body.actions} actions`);
|
|
660
|
+
logDev(`[publish] OK — ${body.routes} routes, ${body.actions} actions received at ${body.receivedAt}`);
|
|
661
|
+
logUser('Open the dashboard → Agent settings → "Reload analysis" to review');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
main().catch((e) => {
|
|
665
|
+
console.error(e);
|
|
666
|
+
process.exit(1);
|
|
667
|
+
});
|