@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/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
+ });