@fiodos/cli 0.1.23 → 0.1.26

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 CHANGED
@@ -94,10 +94,19 @@ const { collectFiles } = require('./collect');
94
94
  const { resolveAnalysisRoot } = require('./resolveAnalysisRoot');
95
95
  const { analyzeWithAI, correctActionWiring, generateGuardianWithAI, DEFAULT_MODEL, resolveModel } = require('./aiAnalyze');
96
96
  const { verifyManifest } = require('./verify');
97
- const { wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResult } = require('./wireWeb');
98
- const { wireReactNativeOrb, reportReactNativeWire } = require('./wireReactNative');
97
+ const {
98
+ wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResult,
99
+ connectOrbSession, reportSessionConnectResult,
100
+ connectOrbScreen, reportScreenConnectResult,
101
+ } = require('./wireWeb');
102
+ const { wireReactNativeOrb, reportReactNativeWire, connectRnSession } = require('./wireReactNative');
103
+ const {
104
+ isShopifyThemeRoot, fetchStorefrontSnapshot, wireShopifyTheme, reportShopifyWire,
105
+ } = require('./shopifyTheme');
99
106
  const { wireFlutterOrb, writeFlutterEnv, reportFlutterWire } = require('./wireFlutter');
100
107
  const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
108
+ const { wireSession, reportSessionResult } = require('./wireSession');
109
+ const { wireScreen, reportScreenResult } = require('./wireScreen');
101
110
  const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
102
111
  const { scoreSurface, computeSurface } = require('./scoreSurface');
103
112
  const { buildRunRecord, writeChangeRegistry } = require('./changeRegistry');
@@ -113,6 +122,10 @@ function arg(flag, fallback) {
113
122
  const VALUE_FLAGS = new Set([
114
123
  '--out', '--model', '--max-input-tokens', '--platform', '--api-key', '--api-url',
115
124
  '--spec', '--analysis-root',
125
+ // shopify (theme repo mode): the store's public domain for the catalog snapshot
126
+ '--store',
127
+ // from-odata (closed-software mode)
128
+ '--app-id', '--app-name', '--app-description', '--auth-ref', '--entities',
116
129
  ]);
117
130
 
118
131
  /**
@@ -186,9 +199,11 @@ function askProceed() {
186
199
  });
187
200
  }
188
201
 
189
- /** Spinner copy: "website" for web targets, "app" for mobile/native. */
202
+ /** Spinner copy: "website" for web targets, "store" for Shopify, "app" for mobile/native. */
190
203
  function surfaceNoun(platform) {
191
- return platform === 'web' ? 'website' : 'app';
204
+ if (platform === 'web') return 'website';
205
+ if (platform === 'shopify') return 'store';
206
+ return 'app';
192
207
  }
193
208
 
194
209
  function orbMountSpinnerLabel(platform) {
@@ -251,6 +266,56 @@ async function withSpinner(label, work) {
251
266
  }
252
267
 
253
268
  async function main() {
269
+ // ── Closed-software mode: `fiodos from-odata <metadata.xml|url>` ────────────
270
+ // Generates the manifest from a third-party system's OData $metadata (schema,
271
+ // not source code), so the consent gate about code analysis does not apply.
272
+ {
273
+ const pos = positionalArgs();
274
+ if (pos[0] === 'from-odata') {
275
+ const target = pos[1];
276
+ if (!target) {
277
+ console.error('Usage: fiodos from-odata <metadata.xml | https://…/$metadata> --app-id <id> --app-name <name> [--entities a,b] [--auth-ref name] [--out dir] [--publish --api-key fyd_…]');
278
+ process.exit(1);
279
+ }
280
+ const { runFromOData } = require('./odata');
281
+ const safeName = path.basename(target).replace(/[^a-z0-9_-]/gi, '_') || 'odata';
282
+ const outDirOData = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
283
+ const entitiesArg = arg('--entities', '');
284
+ await runFromOData({
285
+ target,
286
+ outDir: outDirOData,
287
+ opts: {
288
+ appId: arg('--app-id', `odata.${safeName}`),
289
+ appName: arg('--app-name', 'Third-party system'),
290
+ appDescription: arg('--app-description', ''),
291
+ authRef: arg('--auth-ref', 'default'),
292
+ entities: entitiesArg ? entitiesArg.split(',').map((s) => s.trim()).filter(Boolean) : [],
293
+ publish: process.argv.includes('--publish'),
294
+ },
295
+ io: {
296
+ logUser,
297
+ logDev,
298
+ publish: (manifest, meta) => publishManifest(manifest, meta),
299
+ },
300
+ });
301
+ return;
302
+ }
303
+ }
304
+
305
+ // ── Rewire-only mode: `fiodos rewire <dir>` or `fiodos <dir> --wire-only` ──
306
+ // Re-runs ONLY the handler wiring (verify → correct → retry, same loop as the
307
+ // install) from the saved artifacts of the LAST analysis. No re-analysis, no
308
+ // quota, no manifest publish — the repair path when some action ended up
309
+ // "manual" (Context/dashboard point developers here).
310
+ {
311
+ const pos = positionalArgs();
312
+ if (pos[0] === 'rewire' || process.argv.includes('--wire-only')) {
313
+ const target = pos[0] === 'rewire' ? pos[1] || '.' : pos[0] || '.';
314
+ await runRewireOnly(target);
315
+ return;
316
+ }
317
+ }
318
+
254
319
  // Consent gate: confirm before doing anything. `yes` continues; `no` (or any
255
320
  // other answer) cancels instantly — no analysis is run, the command just ends.
256
321
  const proceed = await askProceed();
@@ -287,7 +352,7 @@ async function main() {
287
352
  fs.mkdirSync(outDir, { recursive: true });
288
353
 
289
354
  const platformArg = (arg('--platform', '') || '').toLowerCase();
290
- const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android'];
355
+ const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android', 'shopify'];
291
356
  const forcedPlatform = KNOWN_PLATFORMS.includes(platformArg) ? platformArg : null;
292
357
 
293
358
  // ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
@@ -327,6 +392,24 @@ async function main() {
327
392
 
328
393
  // ── 2. Collect source files (rule-based, budget-aware) ────────────────────
329
394
  const { included, omitted, estimatedTokens } = collectFiles(analysisRoot, { maxInputTokens, platform });
395
+
396
+ // Shopify: the theme alone doesn't say WHAT the store sells. Fetch a PUBLIC
397
+ // storefront snapshot (collections + product sample) from the store's own
398
+ // endpoints and include it as synthetic FILE blocks (_fyodos/storefront/*)
399
+ // so the analysis personalizes routes/bubbles/examples to the REAL catalog
400
+ // — and so evidence verification has concrete files to point at. Fail-open:
401
+ // an unreachable store still gets the universal surface file.
402
+ if (platform === 'shopify') {
403
+ const storeDomain = arg('--store', process.env.FYODOS_SHOPIFY_STORE || '');
404
+ if (!storeDomain) {
405
+ logUser('Tip: add --store your-store.com so the analysis reads your live catalog (collections & products).');
406
+ }
407
+ const snapshot = await withSpinner('Reading your store\u2019s public catalog', () =>
408
+ fetchStorefrontSnapshot(storeDomain, { log: logDev }),
409
+ );
410
+ included.push(...snapshot.files);
411
+ }
412
+
330
413
  fs.writeFileSync(path.join(outDir, 'files-sent.json'), JSON.stringify({
331
414
  included: included.map((f) => f.rel),
332
415
  omitted,
@@ -353,11 +436,23 @@ async function main() {
353
436
  let provenance;
354
437
  let evidence = {};
355
438
  let wiring = {};
439
+ // The AI's "session" block: WHERE the app's auth/session state lives, so the
440
+ // orb's isAuthenticated/getUserId are wired automatically (no manual step).
441
+ let sessionWiring = null;
442
+ // The AI's "screen" block: whether screens are URL-driven or CLIENT STATE,
443
+ // and where that state lives — so the orb knows which screen the user is on
444
+ // even when the URL never changes (see wireScreen.js).
445
+ let screenWiring = null;
356
446
  let analysisMeta = { engine: 'auto-manifest-v3-ai-first', model: null, costUSD: 0 };
357
447
  // Signed proof returned by the hosted-analysis proxy: it tells the publish
358
448
  // step the quota was already consumed at /v1/developer/analyze (no double
359
449
  // count). Null in own-key / --no-llm mode.
360
450
  let analysisToken = null;
451
+ // Spec-requested content resets declared by the analysis AI: which dashboard
452
+ // context overrides (bubblePrompt/description/examples) the developer's
453
+ // --spec EXPLICITLY asked to regenerate, so the backend clears those (and
454
+ // ONLY those) masking overrides on publish. Null when the spec asked nothing.
455
+ let specResets = null;
361
456
 
362
457
  if (!useLLM) {
363
458
  // Static-only fallback: routes with mechanical naming, no actions.
@@ -410,6 +505,14 @@ async function main() {
410
505
  const proposed = parsed.manifest || {};
411
506
  evidence = parsed.evidence || {};
412
507
  wiring = parsed.wiring || {};
508
+ sessionWiring = parsed.session || null;
509
+ screenWiring = parsed.screen || null;
510
+ // Only meaningful when the developer passed --spec; the backend sanitizes
511
+ // (context fields only, real intents only) before touching any override.
512
+ specResets = (userSpec && parsed.specResets && typeof parsed.specResets === 'object')
513
+ ? parsed.specResets
514
+ : null;
515
+ if (specResets) logDev(`[ai] specResets=${JSON.stringify(specResets)}`);
413
516
  logDev(`[ai] model=${model} proposed routes=${(proposed.routes || []).length} ` +
414
517
  `actions=${(proposed.actions || []).length} in ${(elapsedMs / 1000).toFixed(1)}s`);
415
518
  logDev(`[ai] tokens in=${usage.prompt_tokens} out=${usage.completion_tokens} cost=$${costUSD.toFixed(4)}`);
@@ -418,7 +521,7 @@ async function main() {
418
521
  // Actions are always verified against real code. Routes are verified only
419
522
  // when there is filesystem ground truth (mobile); for web they are kept as
420
523
  // AI-proposed and flagged unverified in provenance.
421
- ({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes }));
524
+ ({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes, platform }));
422
525
  ensureBackRoute(manifest, provenance);
423
526
  const droppedActions = provenance.actions.filter((a) => !a.included).length;
424
527
  const droppedRoutes = provenance.routes.filter((r) => !r.included).length;
@@ -427,7 +530,7 @@ async function main() {
427
530
  logUser('Analysis complete');
428
531
 
429
532
  fs.writeFileSync(path.join(outDir, 'evidence.json'), JSON.stringify({
430
- evidence, wiring, uncertain: parsed.uncertain || [],
533
+ evidence, wiring, session: sessionWiring, screen: screenWiring, uncertain: parsed.uncertain || [],
431
534
  }, null, 2));
432
535
  fs.writeFileSync(path.join(outDir, 'usage.json'), JSON.stringify({
433
536
  model, elapsedMs, usage,
@@ -524,6 +627,14 @@ async function main() {
524
627
  disabledRouteIntents: surfaceScore.disabledRouteIntents || [],
525
628
  disabledActionIntents: surfaceScore.disabledActionIntents || [],
526
629
  },
630
+ // Present ONLY when the developer's --spec explicitly asked to regenerate
631
+ // previously-edited content (e.g. "rewrite all bubble prompts"): the backend
632
+ // clears the matching dashboard overrides so the fresh values become live.
633
+ ...(specResets ? { specResets } : {}),
634
+ // Opt-out of the backend's continuity guard (which keeps intent ids and
635
+ // URL paths stable across re-analyses so dashboard customizations never
636
+ // break). Only for developers who explicitly want a clean slate.
637
+ ...(process.argv.includes('--fresh-intents') ? { freshIntents: true } : {}),
527
638
  };
528
639
 
529
640
  // Informative, non-blocking summary (the dashboard is where scope is managed).
@@ -552,6 +663,8 @@ async function main() {
552
663
  let orbWireResult = null;
553
664
  let handlerWireResult = null;
554
665
  let registryWireResult = null;
666
+ let sessionWireResult = null;
667
+ let screenWireResult = null;
555
668
  const { runPostWireTest } = require('./postWireTest');
556
669
  const selfCorrect = !process.argv.includes('--no-self-correct');
557
670
  // Corrections follow the same key model as the analysis: hosted (proxy)
@@ -653,6 +766,92 @@ async function main() {
653
766
  report(() => reportRegistriesResult(connected, fyodosTermColors(), { quiet: !isVerbose() }));
654
767
  }
655
768
 
769
+ // Third pass — SESSION wiring. Connect the orb to the app's real auth
770
+ // state so the agent KNOWS whether the user is signed in (requiresAuth
771
+ // gate) and keys conversation memory per user. Fully automatic: same AI
772
+ // proposal + mechanical verification + reversible edits + build safety
773
+ // net as handler wiring. NO manual step.
774
+ // · consent: the handler-wiring "yes" covers code edits, so we reuse
775
+ // it. If the developer said NO to handler wiring, we respect that
776
+ // and skip session edits too.
777
+ if (sessionWiring && !process.argv.includes('--no-orb-wire')) {
778
+ const handlerDeclined =
779
+ handlerResult &&
780
+ (handlerResult.status === 'declined' || handlerResult.status === 'declined-noninteractive');
781
+ if (!handlerDeclined) {
782
+ // Only an APPLIED handler wiring proves the developer said "yes" to
783
+ // code edits in every path; anything else makes wireSession ask its
784
+ // own single [yes/no] (or decline on a non-TTY) — never edit silently.
785
+ const handlerConsented =
786
+ handlerResult &&
787
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
788
+ const sessionResult = await wireSession(analysisRoot, {
789
+ session: sessionWiring,
790
+ colors: fyodosTermColors(),
791
+ quiet: !isVerbose(),
792
+ assumeYes: assumeYes || Boolean(handlerConsented),
793
+ noWire,
794
+ framework: 'web',
795
+ appName: manifest.appName,
796
+ runTest: !process.argv.includes('--no-wire-test'),
797
+ testRunner: runPostWireTest,
798
+ });
799
+ sessionWireResult = sessionResult;
800
+ report(() => reportSessionResult(sessionResult, fyodosTermColors(), { quiet: !isVerbose() }));
801
+ if (
802
+ orbMounted &&
803
+ (sessionResult.status === 'applied' || sessionResult.status === 'applied-untested')
804
+ ) {
805
+ const connectedSession = connectOrbSession(analysisRoot, {
806
+ sessionFileAbs: sessionResult.sessionFile,
807
+ });
808
+ report(() =>
809
+ reportSessionConnectResult(connectedSession, fyodosTermColors(), { quiet: !isVerbose() }),
810
+ );
811
+ }
812
+ }
813
+ }
814
+
815
+ // Fourth pass — SCREEN-TRUTH wiring. For apps whose screens are CLIENT
816
+ // STATE (a section switcher) instead of URLs, connect the active-screen
817
+ // state to the orb so it truly knows where the user is: per-screen
818
+ // bubble, "you are already there" detection and screen-scoped context.
819
+ // Same consent/verification/revert model as session wiring.
820
+ if (screenWiring && !process.argv.includes('--no-orb-wire')) {
821
+ const handlerDeclined =
822
+ handlerResult &&
823
+ (handlerResult.status === 'declined' || handlerResult.status === 'declined-noninteractive');
824
+ if (!handlerDeclined) {
825
+ const handlerConsented =
826
+ handlerResult &&
827
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
828
+ const screenResult = await wireScreen(analysisRoot, {
829
+ screen: screenWiring,
830
+ colors: fyodosTermColors(),
831
+ quiet: !isVerbose(),
832
+ assumeYes: assumeYes || Boolean(handlerConsented),
833
+ noWire,
834
+ framework: 'web',
835
+ appName: manifest.appName,
836
+ runTest: !process.argv.includes('--no-wire-test'),
837
+ testRunner: runPostWireTest,
838
+ });
839
+ screenWireResult = screenResult;
840
+ report(() => reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() }));
841
+ if (
842
+ orbMounted &&
843
+ (screenResult.status === 'applied' || screenResult.status === 'applied-untested')
844
+ ) {
845
+ const connectedScreen = connectOrbScreen(analysisRoot, {
846
+ screenFileAbs: screenResult.screenFile,
847
+ });
848
+ report(() =>
849
+ reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() }),
850
+ );
851
+ }
852
+ }
853
+ }
854
+
656
855
  // Finalize the install proof: now that the (slow) action wiring is done,
657
856
  // tell the dashboard the terminal status so its "actions connected" tick
658
857
  // turns green for real — never before wiring actually finished. Only do
@@ -667,6 +866,7 @@ async function main() {
667
866
  actionsStatus: status,
668
867
  actionsTotal: total,
669
868
  actionsDone: done,
869
+ actions: actionsDetailFromHandlerResult(manifest, handlerResult),
670
870
  });
671
871
  }
672
872
  };
@@ -720,6 +920,8 @@ async function main() {
720
920
  orbResult: orbWireResult,
721
921
  handlerResult: handlerWireResult,
722
922
  registryResult: registryWireResult,
923
+ sessionResult: sessionWireResult,
924
+ screenResult: screenWireResult,
723
925
  envResult: envWriteResult,
724
926
  });
725
927
  const paths = writeChangeRegistry(analysisRoot, record);
@@ -728,6 +930,66 @@ async function main() {
728
930
  logDev(`[change-registry] could not write: ${e.message}`);
729
931
  }
730
932
  }
933
+ } else if (platform === 'shopify' && publishing) {
934
+ // ── Shopify: publish + embed the orb in layout/theme.liquid ───────────────
935
+ // No code wiring: the manifest's commerce actions carry declarative
936
+ // `execution` blocks that the embed turns into handlers at runtime
937
+ // (buildApiActionRegistries, {baseUrl} → the store's own origin). The one
938
+ // edit is the theme.liquid snippet (idempotent markers); Shopify's GitHub
939
+ // sync deploys it when the developer pushes.
940
+ loadAppEnv(analysisRoot);
941
+ const { apiKey, apiUrl } = resolveApiTarget();
942
+ const noWire = process.argv.includes('--no-wire');
943
+ let orbWireResult = null;
944
+
945
+ await withSpinner('Publishing to your project', async ({ setLabel, pause, resume }) => {
946
+ await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
947
+ if (!noWire && !process.argv.includes('--no-orb-wire')) {
948
+ setLabel(orbMountSpinnerLabel(platform));
949
+ orbWireResult = wireShopifyTheme(analysisRoot, { apiKey, apiUrl, noWire });
950
+ pause();
951
+ reportShopifyWire(orbWireResult, fyodosTermColors(), { quiet: !isVerbose() });
952
+ resume();
953
+ // Install proof only when the snippet is genuinely in the theme. All
954
+ // actions are execution-declared (nothing left to wire), so the
955
+ // terminal status is 'done' immediately — the dashboard tick then
956
+ // waits only for the runtime heartbeat once the theme is pushed.
957
+ if (
958
+ apiKey &&
959
+ (orbWireResult.status === 'added' || orbWireResult.status === 'already')
960
+ ) {
961
+ const proof = await postOrbWired({
962
+ apiKey,
963
+ apiUrl,
964
+ file: orbWireResult.file || '',
965
+ platform: 'shopify',
966
+ actionsStatus: 'done',
967
+ });
968
+ if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
969
+ logDev(`[orb-wired] shopify install proof not recorded (${proof.status || proof.reason}); dashboard will rely on the runtime ping`);
970
+ }
971
+ }
972
+ }
973
+ });
974
+ logUser('Published to your project');
975
+ if (orbWireResult && (orbWireResult.status === 'added' || orbWireResult.status === 'already')) {
976
+ logUser('Commit & push this theme repo — Shopify\u2019s GitHub sync deploys the orb to your live store.');
977
+ }
978
+
979
+ if (!noWire) {
980
+ try {
981
+ const record = buildRunRecord(analysisRoot, {
982
+ appName: manifest.appName,
983
+ manifest,
984
+ publishMeta,
985
+ orbResult: orbWireResult,
986
+ });
987
+ const paths = writeChangeRegistry(analysisRoot, record);
988
+ logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
989
+ } catch (e) {
990
+ logDev(`[change-registry] could not write: ${e.message}`);
991
+ }
992
+ }
731
993
  } else if (platform === 'mobile' && publishing) {
732
994
  // ── Mobile (Expo / React Native): mirror the web pipeline, adapted ─────────
733
995
  // Same single command that publishes also mounts the orb (via the SDK's
@@ -739,6 +1001,7 @@ async function main() {
739
1001
  const noWire = process.argv.includes('--no-wire');
740
1002
  let orbWireResult = null;
741
1003
  let envWriteResult = null;
1004
+ let sessionWireResult = null;
742
1005
 
743
1006
  await withSpinner('Publishing to your project', async ({ setLabel, pause, resume }) => {
744
1007
  await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
@@ -771,6 +1034,42 @@ async function main() {
771
1034
  logDev(`[orb-wired] mobile install proof not recorded (${proof.status || proof.reason}); dashboard will rely on the runtime ping`);
772
1035
  }
773
1036
  }
1037
+
1038
+ // SESSION wiring (auth awareness) — same automatic pass as web. RN has
1039
+ // no cheap full-build check, so the safety net is the mechanical symbol
1040
+ // verification + the esbuild parse check on every edited file.
1041
+ if (
1042
+ sessionWiring &&
1043
+ (orbWireResult.status === 'added' || orbWireResult.status === 'already')
1044
+ ) {
1045
+ const sessionResult = await wireSession(analysisRoot, {
1046
+ session: sessionWiring,
1047
+ colors: fyodosTermColors(),
1048
+ quiet: !isVerbose(),
1049
+ assumeYes: true, // the orb-mount consent already covered code edits on mobile
1050
+ noWire,
1051
+ framework: 'mobile',
1052
+ appName: manifest.appName,
1053
+ runTest: false,
1054
+ });
1055
+ sessionWireResult = sessionResult;
1056
+ pause();
1057
+ reportSessionResult(sessionResult, fyodosTermColors(), { quiet: !isVerbose() });
1058
+ resume();
1059
+ if (sessionResult.status === 'applied' || sessionResult.status === 'applied-untested') {
1060
+ const connectedSession = connectRnSession(analysisRoot, {
1061
+ sessionFileAbs: sessionResult.sessionFile,
1062
+ colors: fyodosTermColors(),
1063
+ });
1064
+ if (connectedSession.status === 'connected') {
1065
+ pause();
1066
+ console.error(`${fyodosTermColors().cyan}◉${fyodosTermColors().reset} ${fyodosTermColors().blue}Fiodos${fyodosTermColors().reset} · session connected to the orb mount (${connectedSession.file})`);
1067
+ resume();
1068
+ } else {
1069
+ logDev(`[session] RN mount connection: ${connectedSession.status}${connectedSession.reason ? ` (${connectedSession.reason})` : ''}`);
1070
+ }
1071
+ }
1072
+ }
774
1073
  }
775
1074
  });
776
1075
  logUser('Published to your project');
@@ -805,6 +1104,7 @@ async function main() {
805
1104
  manifest,
806
1105
  publishMeta,
807
1106
  orbResult: orbWireResult,
1107
+ sessionResult: sessionWireResult,
808
1108
  envResult: envWriteResult,
809
1109
  });
810
1110
  const paths = writeChangeRegistry(analysisRoot, record);
@@ -980,6 +1280,9 @@ function detectPlatform(appRoot, appDir) {
980
1280
  return found;
981
1281
  };
982
1282
 
1283
+ // Shopify theme repos (GitHub-synced Liquid themes) are unmistakable and
1284
+ // have no package.json framework signal — check them first.
1285
+ if (isShopifyThemeRoot(appRoot)) return 'shopify';
983
1286
  if (has('pubspec.yaml')) return 'flutter';
984
1287
  if (has('Package.swift') || hasAnyExt('.xcodeproj') || (has('Sources') && hasAnyExt('.swift'))) return 'ios';
985
1288
  if ((has('settings.gradle.kts') || has('settings.gradle') || has('build.gradle.kts') || has('build.gradle')) && hasAnyExt('.kt')) {
@@ -1377,6 +1680,21 @@ async function publishManifest(manifest, meta, analysisToken = null, opts = {})
1377
1680
  if (!opts.skipSuccessLog) {
1378
1681
  logUser('Published to your project');
1379
1682
  }
1683
+ if (body.overridesCleared > 0) {
1684
+ logUser(
1685
+ `Your specifications asked to regenerate previously-edited content: ` +
1686
+ `${body.overridesCleared} dashboard override(s) were cleared so the new values are live`,
1687
+ );
1688
+ }
1689
+ // Continuity guard feedback: the backend kept the previous intent ids /
1690
+ // repaired degraded route paths, so dashboard customizations keep working.
1691
+ if (body.intentsPreserved > 0 || body.pathsRepaired > 0) {
1692
+ logUser(
1693
+ `Continuity with your previous analysis: ${body.intentsPreserved || 0} intent id(s) kept stable` +
1694
+ (body.pathsRepaired ? `, ${body.pathsRepaired} route path(s) repaired` : '') +
1695
+ ` — your dashboard customizations stay intact (use --fresh-intents to opt out)`,
1696
+ );
1697
+ }
1380
1698
  logDev(`[publish] OK — ${body.routes} routes, ${body.actions} actions received at ${body.receivedAt}`);
1381
1699
 
1382
1700
  // Self-check: confirm the orb will find this manifest (silent unless --verbose).
@@ -1397,6 +1715,7 @@ async function postOrbWired({
1397
1715
  actionsStatus = 'pending',
1398
1716
  actionsTotal = 0,
1399
1717
  actionsDone = 0,
1718
+ actions = null,
1400
1719
  }) {
1401
1720
  if (!apiKey) return { ok: false, reason: 'no-api-key' };
1402
1721
  try {
@@ -1409,6 +1728,11 @@ async function postOrbWired({
1409
1728
  actionsStatus,
1410
1729
  actionsTotal,
1411
1730
  actionsDone,
1731
+ // Per-action wiring detail (intent/status/reason). Powers the backend's
1732
+ // "wired or nonexistent" invariant (unwired actions never reach the
1733
+ // agent prompt) and the dashboard/Context wiring diagnosis. Older
1734
+ // backends ignore the extra field.
1735
+ ...(Array.isArray(actions) ? { actions } : {}),
1412
1736
  }),
1413
1737
  });
1414
1738
  if (res.ok) {
@@ -1423,6 +1747,210 @@ async function postOrbWired({
1423
1747
  }
1424
1748
  }
1425
1749
 
1750
+ /**
1751
+ * Rewire-only mode (`fiodos rewire <dir>` / `--wire-only`): re-run JUST the
1752
+ * handler wiring from the saved artifacts of the last analysis (manifest.json +
1753
+ * evidence.json in the analysis output dir). The full verify→diagnose→correct→
1754
+ * retry loop runs again — including AI corrections — so an action that fell to
1755
+ * "manual" in the install gets fresh attempts WITHOUT a re-analysis (no quota,
1756
+ * no manifest publish, no orb re-mount). Ends by re-posting the per-action
1757
+ * wiring status so the dashboard/Context see the repaired state.
1758
+ */
1759
+ async function runRewireOnly(target) {
1760
+ const inputRoot = path.resolve(target || '.');
1761
+ const safeName = path.basename(inputRoot).replace(/[^a-z0-9_-]/gi, '_') || 'app';
1762
+ const outDir = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
1763
+
1764
+ const manifestPath = path.join(outDir, 'manifest.json');
1765
+ const evidencePath = path.join(outDir, 'evidence.json');
1766
+ if (!fs.existsSync(manifestPath) || !fs.existsSync(evidencePath)) {
1767
+ logUser('No saved analysis found for this project — run a full analysis first:');
1768
+ logUser(` npx fiodos analyze ${target || '.'} --publish`);
1769
+ logUser(`(looked in ${outDir}; pass --out <dir> if you analyzed with a custom output dir)`);
1770
+ process.exitCode = 1;
1771
+ return;
1772
+ }
1773
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1774
+ const ev = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));
1775
+ const evidence = ev.evidence || {};
1776
+ const wiring = ev.wiring || {};
1777
+
1778
+ const scope = resolveAnalysisRoot(inputRoot, 'web');
1779
+ const analysisRoot = scope.root;
1780
+ loadAppEnv(analysisRoot);
1781
+ const { apiKey, apiUrl } = resolveApiTarget();
1782
+ const model = resolveModel(arg('--model', '') || DEFAULT_MODEL);
1783
+ const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
1784
+ const { runPostWireTest } = require('./postWireTest');
1785
+ const { included } = collectFiles(analysisRoot, { maxInputTokens: Number(arg('--max-input-tokens', '150000')), platform: 'web' });
1786
+
1787
+ const selfCorrect = !process.argv.includes('--no-self-correct');
1788
+ const correctorUseProxy = !process.argv.includes('--own-ai-key');
1789
+ const corrector = selfCorrect
1790
+ ? async (args) => {
1791
+ const { wiring: fixed } = await correctActionWiring({
1792
+ ...args, model, platform: 'web', useProxy: correctorUseProxy, apiKey, apiUrl,
1793
+ });
1794
+ return fixed;
1795
+ }
1796
+ : null;
1797
+
1798
+ logUser(`Rewiring actions from the saved analysis (${manifest.actions.length} action(s), no re-analysis)…`);
1799
+ let handlerResult = null;
1800
+ await withSpinner(wiringSpinnerLabel('web'), async ({ pause, resume }) => {
1801
+ if (!assumeYes) pause();
1802
+ handlerResult = await wireHandlers(analysisRoot, {
1803
+ manifest,
1804
+ evidence,
1805
+ wiring,
1806
+ appName: manifest.appName,
1807
+ colors: fyodosTermColors(),
1808
+ assumeYes,
1809
+ noWire: false,
1810
+ quiet: !isVerbose(),
1811
+ runTest: !process.argv.includes('--no-wire-test'),
1812
+ revertOnFailure: true,
1813
+ testRunner: runPostWireTest,
1814
+ corrector,
1815
+ files: included,
1816
+ maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
1817
+ });
1818
+ if (!assumeYes) resume();
1819
+ pause();
1820
+ reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
1821
+ resume();
1822
+
1823
+ const handlerApplied =
1824
+ handlerResult &&
1825
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested') &&
1826
+ (handlerResult.autoCount || 0) > 0 &&
1827
+ handlerResult.registryFile;
1828
+ if (handlerApplied) {
1829
+ const connected = connectOrbRegistries(analysisRoot, {
1830
+ registryFileAbs: handlerResult.registryFile,
1831
+ });
1832
+ pause();
1833
+ reportRegistriesResult(connected, fyodosTermColors(), { quiet: !isVerbose() });
1834
+ resume();
1835
+ }
1836
+ });
1837
+
1838
+ // Session + screen wiring also replay from the saved analysis (same consent
1839
+ // model as the main flow: an APPLIED handler wiring proves the "yes").
1840
+ const handlerConsented =
1841
+ handlerResult &&
1842
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
1843
+ if (ev.session) {
1844
+ const sessionResult = await wireSession(analysisRoot, {
1845
+ session: ev.session,
1846
+ colors: fyodosTermColors(),
1847
+ quiet: !isVerbose(),
1848
+ assumeYes: assumeYes || Boolean(handlerConsented),
1849
+ framework: 'web',
1850
+ appName: manifest.appName,
1851
+ runTest: !process.argv.includes('--no-wire-test'),
1852
+ testRunner: runPostWireTest,
1853
+ });
1854
+ reportSessionResult(sessionResult, fyodosTermColors(), { quiet: !isVerbose() });
1855
+ if (sessionResult.status === 'applied' || sessionResult.status === 'applied-untested') {
1856
+ const connectedSession = connectOrbSession(analysisRoot, {
1857
+ sessionFileAbs: sessionResult.sessionFile,
1858
+ });
1859
+ reportSessionConnectResult(connectedSession, fyodosTermColors(), { quiet: !isVerbose() });
1860
+ }
1861
+ }
1862
+ if (ev.screen) {
1863
+ const screenResult = await wireScreen(analysisRoot, {
1864
+ screen: ev.screen,
1865
+ colors: fyodosTermColors(),
1866
+ quiet: !isVerbose(),
1867
+ assumeYes: assumeYes || Boolean(handlerConsented),
1868
+ framework: 'web',
1869
+ appName: manifest.appName,
1870
+ runTest: !process.argv.includes('--no-wire-test'),
1871
+ testRunner: runPostWireTest,
1872
+ });
1873
+ reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() });
1874
+ if (screenResult.status === 'applied' || screenResult.status === 'applied-untested') {
1875
+ const connectedScreen = connectOrbScreen(analysisRoot, {
1876
+ screenFileAbs: screenResult.screenFile,
1877
+ });
1878
+ reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() });
1879
+ }
1880
+ }
1881
+
1882
+ if (apiKey && handlerResult) {
1883
+ const { status, total, done } = actionsWiredFromHandlerResult(handlerResult);
1884
+ await postOrbWired({
1885
+ apiKey,
1886
+ apiUrl,
1887
+ file: (handlerResult.registryRel || ''),
1888
+ actionsStatus: status,
1889
+ actionsTotal: total,
1890
+ actionsDone: done,
1891
+ actions: actionsDetailFromHandlerResult(manifest, handlerResult),
1892
+ });
1893
+ }
1894
+ const wired = (handlerResult && handlerResult.autoCount) || 0;
1895
+ const manual = (handlerResult && handlerResult.manualCount) || 0;
1896
+ logUser(`Rewire finished: ${wired} action(s) wired, ${manual} still need attention.`);
1897
+ }
1898
+
1899
+ /**
1900
+ * Per-action wiring detail for the backend (POST /v1/developer/orb-wired).
1901
+ * One entry per manifest action:
1902
+ * { intent, handler, status: 'wired'|'manual'|'link', reason?, attempts? }
1903
+ * 'wired' → verified and installed (the action really runs);
1904
+ * 'manual' → could not be wired/verified — the backend EXCLUDES it from the
1905
+ * agent surface ("wired or nonexistent" invariant) and the
1906
+ * dashboard/Context can show and repair it;
1907
+ * 'link' → navigation/link action, needs no handler (always available).
1908
+ * Returns null when wiring did not actually run (declined/skipped): with no
1909
+ * verdict the backend must not filter anything.
1910
+ */
1911
+ function actionsDetailFromHandlerResult(manifest, handlerResult) {
1912
+ const r = handlerResult || {};
1913
+ const ranStatuses = new Set(['applied', 'applied-untested', 'manual-only', 'reverted', 'failed']);
1914
+ if (!ranStatuses.has(r.status)) return null;
1915
+ const plan = r.plan || {};
1916
+ const autoByIntent = new Map((plan.auto || []).map((e) => [e.intent, e]));
1917
+ const reviewByIntent = new Map((plan.review || plan.manual || []).map((e) => [e.intent, e]));
1918
+ const verifByIntent = new Map((r.verification || []).map((v) => [v.intent, v]));
1919
+ const wholesaleFailed = r.status === 'reverted' || r.status === 'failed';
1920
+
1921
+ const detail = [];
1922
+ for (const a of (manifest && manifest.actions) || []) {
1923
+ const intent = a.intent;
1924
+ if (a.kind === 'link') {
1925
+ detail.push({ intent, handler: '', status: 'link' });
1926
+ continue;
1927
+ }
1928
+ const verif = verifByIntent.get(intent);
1929
+ if (!wholesaleFailed && autoByIntent.has(intent)) {
1930
+ detail.push({
1931
+ intent,
1932
+ handler: a.handler || intent,
1933
+ status: 'wired',
1934
+ ...(verif && verif.level ? { level: verif.level } : {}),
1935
+ ...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
1936
+ });
1937
+ continue;
1938
+ }
1939
+ const rev = reviewByIntent.get(intent);
1940
+ const reason = wholesaleFailed
1941
+ ? `wiring ${r.status}: the combined install did not pass the build safety net`
1942
+ : String((rev && rev.reason) || (verif && verif.error) || 'could not be wired automatically');
1943
+ detail.push({
1944
+ intent,
1945
+ handler: a.handler || intent,
1946
+ status: 'manual',
1947
+ reason: reason.split('|')[0].replace(/\s+/g, ' ').trim().slice(0, 300),
1948
+ ...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
1949
+ });
1950
+ }
1951
+ return detail;
1952
+ }
1953
+
1426
1954
  // Map a wireHandlers() result to the terminal action-wiring status the backend
1427
1955
  // records, plus how many actions ended up wired. Drives the onboarding's third
1428
1956
  // "actions connected" tick so it only goes green once wiring truly finished.