@fiodos/cli 0.1.25 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/aiAnalyze.js +6 -4
- package/src/changeRegistry.js +55 -1
- package/src/collect.js +16 -2
- package/src/index.js +404 -8
- package/src/postWireTest.js +27 -6
- package/src/shopifyTheme.js +339 -0
- package/src/verify.js +124 -1
- package/src/verifyWire.js +10 -0
- package/src/wireHandlers.js +216 -28
- package/src/wireScreen.js +562 -0
- package/src/wireSession.js +147 -11
- package/src/wireWeb.js +115 -2
- package/src/wireWebMount.js +98 -14
package/src/index.js
CHANGED
|
@@ -97,11 +97,16 @@ const { verifyManifest } = require('./verify');
|
|
|
97
97
|
const {
|
|
98
98
|
wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResult,
|
|
99
99
|
connectOrbSession, reportSessionConnectResult,
|
|
100
|
+
connectOrbScreen, reportScreenConnectResult,
|
|
100
101
|
} = require('./wireWeb');
|
|
101
102
|
const { wireReactNativeOrb, reportReactNativeWire, connectRnSession } = require('./wireReactNative');
|
|
103
|
+
const {
|
|
104
|
+
isShopifyThemeRoot, fetchStorefrontSnapshot, wireShopifyTheme, reportShopifyWire,
|
|
105
|
+
} = require('./shopifyTheme');
|
|
102
106
|
const { wireFlutterOrb, writeFlutterEnv, reportFlutterWire } = require('./wireFlutter');
|
|
103
107
|
const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
|
|
104
108
|
const { wireSession, reportSessionResult } = require('./wireSession');
|
|
109
|
+
const { wireScreen, reportScreenResult } = require('./wireScreen');
|
|
105
110
|
const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
|
|
106
111
|
const { scoreSurface, computeSurface } = require('./scoreSurface');
|
|
107
112
|
const { buildRunRecord, writeChangeRegistry } = require('./changeRegistry');
|
|
@@ -117,6 +122,8 @@ function arg(flag, fallback) {
|
|
|
117
122
|
const VALUE_FLAGS = new Set([
|
|
118
123
|
'--out', '--model', '--max-input-tokens', '--platform', '--api-key', '--api-url',
|
|
119
124
|
'--spec', '--analysis-root',
|
|
125
|
+
// shopify (theme repo mode): the store's public domain for the catalog snapshot
|
|
126
|
+
'--store',
|
|
120
127
|
// from-odata (closed-software mode)
|
|
121
128
|
'--app-id', '--app-name', '--app-description', '--auth-ref', '--entities',
|
|
122
129
|
]);
|
|
@@ -168,6 +175,11 @@ function logUser(line) {
|
|
|
168
175
|
console.log(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
|
|
169
176
|
}
|
|
170
177
|
|
|
178
|
+
/** Human-readable pointer to the per-run change registry written in the repo. */
|
|
179
|
+
function changeLogUserMessage(paths) {
|
|
180
|
+
return `We saved a summary of every file we changed — see ${paths.mdRel} (past runs in .fyodos/changes/)`;
|
|
181
|
+
}
|
|
182
|
+
|
|
171
183
|
/**
|
|
172
184
|
* Ask the developer to confirm before the analysis runs. `yes` continues; any
|
|
173
185
|
* other answer (or `no`) cancels instantly — the command ends without analyzing.
|
|
@@ -192,9 +204,11 @@ function askProceed() {
|
|
|
192
204
|
});
|
|
193
205
|
}
|
|
194
206
|
|
|
195
|
-
/** Spinner copy: "website" for web targets, "app" for mobile/native. */
|
|
207
|
+
/** Spinner copy: "website" for web targets, "store" for Shopify, "app" for mobile/native. */
|
|
196
208
|
function surfaceNoun(platform) {
|
|
197
|
-
|
|
209
|
+
if (platform === 'web') return 'website';
|
|
210
|
+
if (platform === 'shopify') return 'store';
|
|
211
|
+
return 'app';
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
function orbMountSpinnerLabel(platform) {
|
|
@@ -293,6 +307,20 @@ async function main() {
|
|
|
293
307
|
}
|
|
294
308
|
}
|
|
295
309
|
|
|
310
|
+
// ── Rewire-only mode: `fiodos rewire <dir>` or `fiodos <dir> --wire-only` ──
|
|
311
|
+
// Re-runs ONLY the handler wiring (verify → correct → retry, same loop as the
|
|
312
|
+
// install) from the saved artifacts of the LAST analysis. No re-analysis, no
|
|
313
|
+
// quota, no manifest publish — the repair path when some action ended up
|
|
314
|
+
// "manual" (Context/dashboard point developers here).
|
|
315
|
+
{
|
|
316
|
+
const pos = positionalArgs();
|
|
317
|
+
if (pos[0] === 'rewire' || process.argv.includes('--wire-only')) {
|
|
318
|
+
const target = pos[0] === 'rewire' ? pos[1] || '.' : pos[0] || '.';
|
|
319
|
+
await runRewireOnly(target);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
296
324
|
// Consent gate: confirm before doing anything. `yes` continues; `no` (or any
|
|
297
325
|
// other answer) cancels instantly — no analysis is run, the command just ends.
|
|
298
326
|
const proceed = await askProceed();
|
|
@@ -329,7 +357,7 @@ async function main() {
|
|
|
329
357
|
fs.mkdirSync(outDir, { recursive: true });
|
|
330
358
|
|
|
331
359
|
const platformArg = (arg('--platform', '') || '').toLowerCase();
|
|
332
|
-
const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android'];
|
|
360
|
+
const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android', 'shopify'];
|
|
333
361
|
const forcedPlatform = KNOWN_PLATFORMS.includes(platformArg) ? platformArg : null;
|
|
334
362
|
|
|
335
363
|
// ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
|
|
@@ -369,6 +397,24 @@ async function main() {
|
|
|
369
397
|
|
|
370
398
|
// ── 2. Collect source files (rule-based, budget-aware) ────────────────────
|
|
371
399
|
const { included, omitted, estimatedTokens } = collectFiles(analysisRoot, { maxInputTokens, platform });
|
|
400
|
+
|
|
401
|
+
// Shopify: the theme alone doesn't say WHAT the store sells. Fetch a PUBLIC
|
|
402
|
+
// storefront snapshot (collections + product sample) from the store's own
|
|
403
|
+
// endpoints and include it as synthetic FILE blocks (_fyodos/storefront/*)
|
|
404
|
+
// so the analysis personalizes routes/bubbles/examples to the REAL catalog
|
|
405
|
+
// — and so evidence verification has concrete files to point at. Fail-open:
|
|
406
|
+
// an unreachable store still gets the universal surface file.
|
|
407
|
+
if (platform === 'shopify') {
|
|
408
|
+
const storeDomain = arg('--store', process.env.FYODOS_SHOPIFY_STORE || '');
|
|
409
|
+
if (!storeDomain) {
|
|
410
|
+
logUser('Tip: add --store your-store.com so the analysis reads your live catalog (collections & products).');
|
|
411
|
+
}
|
|
412
|
+
const snapshot = await withSpinner('Reading your store\u2019s public catalog', () =>
|
|
413
|
+
fetchStorefrontSnapshot(storeDomain, { log: logDev }),
|
|
414
|
+
);
|
|
415
|
+
included.push(...snapshot.files);
|
|
416
|
+
}
|
|
417
|
+
|
|
372
418
|
fs.writeFileSync(path.join(outDir, 'files-sent.json'), JSON.stringify({
|
|
373
419
|
included: included.map((f) => f.rel),
|
|
374
420
|
omitted,
|
|
@@ -398,6 +444,10 @@ async function main() {
|
|
|
398
444
|
// The AI's "session" block: WHERE the app's auth/session state lives, so the
|
|
399
445
|
// orb's isAuthenticated/getUserId are wired automatically (no manual step).
|
|
400
446
|
let sessionWiring = null;
|
|
447
|
+
// The AI's "screen" block: whether screens are URL-driven or CLIENT STATE,
|
|
448
|
+
// and where that state lives — so the orb knows which screen the user is on
|
|
449
|
+
// even when the URL never changes (see wireScreen.js).
|
|
450
|
+
let screenWiring = null;
|
|
401
451
|
let analysisMeta = { engine: 'auto-manifest-v3-ai-first', model: null, costUSD: 0 };
|
|
402
452
|
// Signed proof returned by the hosted-analysis proxy: it tells the publish
|
|
403
453
|
// step the quota was already consumed at /v1/developer/analyze (no double
|
|
@@ -461,6 +511,7 @@ async function main() {
|
|
|
461
511
|
evidence = parsed.evidence || {};
|
|
462
512
|
wiring = parsed.wiring || {};
|
|
463
513
|
sessionWiring = parsed.session || null;
|
|
514
|
+
screenWiring = parsed.screen || null;
|
|
464
515
|
// Only meaningful when the developer passed --spec; the backend sanitizes
|
|
465
516
|
// (context fields only, real intents only) before touching any override.
|
|
466
517
|
specResets = (userSpec && parsed.specResets && typeof parsed.specResets === 'object')
|
|
@@ -475,7 +526,7 @@ async function main() {
|
|
|
475
526
|
// Actions are always verified against real code. Routes are verified only
|
|
476
527
|
// when there is filesystem ground truth (mobile); for web they are kept as
|
|
477
528
|
// AI-proposed and flagged unverified in provenance.
|
|
478
|
-
({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes }));
|
|
529
|
+
({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes, platform }));
|
|
479
530
|
ensureBackRoute(manifest, provenance);
|
|
480
531
|
const droppedActions = provenance.actions.filter((a) => !a.included).length;
|
|
481
532
|
const droppedRoutes = provenance.routes.filter((r) => !r.included).length;
|
|
@@ -484,7 +535,7 @@ async function main() {
|
|
|
484
535
|
logUser('Analysis complete');
|
|
485
536
|
|
|
486
537
|
fs.writeFileSync(path.join(outDir, 'evidence.json'), JSON.stringify({
|
|
487
|
-
evidence, wiring, session: sessionWiring, uncertain: parsed.uncertain || [],
|
|
538
|
+
evidence, wiring, session: sessionWiring, screen: screenWiring, uncertain: parsed.uncertain || [],
|
|
488
539
|
}, null, 2));
|
|
489
540
|
fs.writeFileSync(path.join(outDir, 'usage.json'), JSON.stringify({
|
|
490
541
|
model, elapsedMs, usage,
|
|
@@ -618,6 +669,7 @@ async function main() {
|
|
|
618
669
|
let handlerWireResult = null;
|
|
619
670
|
let registryWireResult = null;
|
|
620
671
|
let sessionWireResult = null;
|
|
672
|
+
let screenWireResult = null;
|
|
621
673
|
const { runPostWireTest } = require('./postWireTest');
|
|
622
674
|
const selfCorrect = !process.argv.includes('--no-self-correct');
|
|
623
675
|
// Corrections follow the same key model as the analysis: hosted (proxy)
|
|
@@ -765,6 +817,46 @@ async function main() {
|
|
|
765
817
|
}
|
|
766
818
|
}
|
|
767
819
|
|
|
820
|
+
// Fourth pass — SCREEN-TRUTH wiring. For apps whose screens are CLIENT
|
|
821
|
+
// STATE (a section switcher) instead of URLs, connect the active-screen
|
|
822
|
+
// state to the orb so it truly knows where the user is: per-screen
|
|
823
|
+
// bubble, "you are already there" detection and screen-scoped context.
|
|
824
|
+
// Same consent/verification/revert model as session wiring.
|
|
825
|
+
if (screenWiring && !process.argv.includes('--no-orb-wire')) {
|
|
826
|
+
const handlerDeclined =
|
|
827
|
+
handlerResult &&
|
|
828
|
+
(handlerResult.status === 'declined' || handlerResult.status === 'declined-noninteractive');
|
|
829
|
+
if (!handlerDeclined) {
|
|
830
|
+
const handlerConsented =
|
|
831
|
+
handlerResult &&
|
|
832
|
+
(handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
|
|
833
|
+
const screenResult = await wireScreen(analysisRoot, {
|
|
834
|
+
screen: screenWiring,
|
|
835
|
+
colors: fyodosTermColors(),
|
|
836
|
+
quiet: !isVerbose(),
|
|
837
|
+
assumeYes: assumeYes || Boolean(handlerConsented),
|
|
838
|
+
noWire,
|
|
839
|
+
framework: 'web',
|
|
840
|
+
appName: manifest.appName,
|
|
841
|
+
runTest: !process.argv.includes('--no-wire-test'),
|
|
842
|
+
testRunner: runPostWireTest,
|
|
843
|
+
});
|
|
844
|
+
screenWireResult = screenResult;
|
|
845
|
+
report(() => reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() }));
|
|
846
|
+
if (
|
|
847
|
+
orbMounted &&
|
|
848
|
+
(screenResult.status === 'applied' || screenResult.status === 'applied-untested')
|
|
849
|
+
) {
|
|
850
|
+
const connectedScreen = connectOrbScreen(analysisRoot, {
|
|
851
|
+
screenFileAbs: screenResult.screenFile,
|
|
852
|
+
});
|
|
853
|
+
report(() =>
|
|
854
|
+
reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() }),
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
768
860
|
// Finalize the install proof: now that the (slow) action wiring is done,
|
|
769
861
|
// tell the dashboard the terminal status so its "actions connected" tick
|
|
770
862
|
// turns green for real — never before wiring actually finished. Only do
|
|
@@ -779,6 +871,7 @@ async function main() {
|
|
|
779
871
|
actionsStatus: status,
|
|
780
872
|
actionsTotal: total,
|
|
781
873
|
actionsDone: done,
|
|
874
|
+
actions: actionsDetailFromHandlerResult(manifest, handlerResult),
|
|
782
875
|
});
|
|
783
876
|
}
|
|
784
877
|
};
|
|
@@ -833,10 +926,100 @@ async function main() {
|
|
|
833
926
|
handlerResult: handlerWireResult,
|
|
834
927
|
registryResult: registryWireResult,
|
|
835
928
|
sessionResult: sessionWireResult,
|
|
929
|
+
screenResult: screenWireResult,
|
|
836
930
|
envResult: envWriteResult,
|
|
837
931
|
});
|
|
838
932
|
const paths = writeChangeRegistry(analysisRoot, record);
|
|
839
|
-
logUser(
|
|
933
|
+
logUser(changeLogUserMessage(paths));
|
|
934
|
+
} catch (e) {
|
|
935
|
+
logDev(`[change-registry] could not write: ${e.message}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
} else if (platform === 'shopify' && publishing) {
|
|
939
|
+
// ── Shopify: publish + embed the orb in layout/theme.liquid ───────────────
|
|
940
|
+
// No code wiring: the manifest's commerce actions carry declarative
|
|
941
|
+
// `execution` blocks that the embed turns into handlers at runtime
|
|
942
|
+
// (buildApiActionRegistries, {baseUrl} → the store's own origin). The one
|
|
943
|
+
// edit is the theme.liquid snippet (idempotent markers); Shopify's GitHub
|
|
944
|
+
// sync deploys it when the developer pushes.
|
|
945
|
+
loadAppEnv(analysisRoot);
|
|
946
|
+
const { apiKey, apiUrl } = resolveApiTarget();
|
|
947
|
+
const noWire = process.argv.includes('--no-wire');
|
|
948
|
+
let orbWireResult = null;
|
|
949
|
+
|
|
950
|
+
await withSpinner('Publishing to your project', async ({ setLabel, pause, resume }) => {
|
|
951
|
+
await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
|
|
952
|
+
if (!noWire && !process.argv.includes('--no-orb-wire')) {
|
|
953
|
+
setLabel(orbMountSpinnerLabel(platform));
|
|
954
|
+
orbWireResult = wireShopifyTheme(analysisRoot, { apiKey, apiUrl, noWire });
|
|
955
|
+
pause();
|
|
956
|
+
reportShopifyWire(orbWireResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
957
|
+
resume();
|
|
958
|
+
// Install proof only when the snippet is genuinely in the theme. All
|
|
959
|
+
// actions are execution-declared (nothing left to wire), so the
|
|
960
|
+
// terminal status is 'done' immediately — the dashboard tick then
|
|
961
|
+
// waits only for the runtime heartbeat once the theme is pushed.
|
|
962
|
+
if (
|
|
963
|
+
apiKey &&
|
|
964
|
+
(orbWireResult.status === 'added' || orbWireResult.status === 'already')
|
|
965
|
+
) {
|
|
966
|
+
const proof = await postOrbWired({
|
|
967
|
+
apiKey,
|
|
968
|
+
apiUrl,
|
|
969
|
+
file: orbWireResult.file || '',
|
|
970
|
+
platform: 'shopify',
|
|
971
|
+
actionsStatus: 'done',
|
|
972
|
+
});
|
|
973
|
+
if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
|
|
974
|
+
logDev(`[orb-wired] shopify install proof not recorded (${proof.status || proof.reason}); dashboard will rely on the runtime ping`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
logUser('Published to your project');
|
|
980
|
+
if (orbWireResult && (orbWireResult.status === 'added' || orbWireResult.status === 'already')) {
|
|
981
|
+
logUser('Commit & push this theme repo.');
|
|
982
|
+
// Shopify's GitHub sync only auto-updates the storefront when the
|
|
983
|
+
// CONNECTED theme is already the LIVE one. A brand-new theme connected
|
|
984
|
+
// from GitHub stays a draft until published ONCE by hand — every push
|
|
985
|
+
// after that keeps it live automatically. Without this line the orb can
|
|
986
|
+
// be fully wired and pushed and still never appear to real customers,
|
|
987
|
+
// with nothing in the CLI output explaining why.
|
|
988
|
+
logUser(
|
|
989
|
+
'If this is already your LIVE theme, that push alone puts the orb on your store. ' +
|
|
990
|
+
'If it\u2019s still a DRAFT (check the Shopify admin theme badge), publish it ONCE — ' +
|
|
991
|
+
'Online Store \u2192 Themes \u2192 \u22ef \u2192 Publish \u2014 then every future push stays live automatically.',
|
|
992
|
+
);
|
|
993
|
+
// New/trial Shopify stores are password-protected by default, and a
|
|
994
|
+
// password-protected storefront renders layout/password.liquid INSTEAD
|
|
995
|
+
// of layout/theme.liquid on every page — so without also wiring that
|
|
996
|
+
// layout (done above when the theme has one) the orb would be
|
|
997
|
+
// correctly installed and published yet invisible behind the password
|
|
998
|
+
// gate. Surfacing this explicitly since it is the #1 "installed
|
|
999
|
+
// correctly but nothing shows up" report on a fresh store.
|
|
1000
|
+
if (orbWireResult.password) {
|
|
1001
|
+
logUser(
|
|
1002
|
+
orbWireResult.password.status === 'failed'
|
|
1003
|
+
? `Heads up: your store looks password-protected but the orb could not be added to ${orbWireResult.password.file} (${orbWireResult.password.reason}) — it will be invisible until you remove the password or fix that layout.`
|
|
1004
|
+
: 'Your theme has a password page (layout/password.liquid) — the orb is embedded there too, so it still shows up while your store is password-protected.',
|
|
1005
|
+
);
|
|
1006
|
+
} else {
|
|
1007
|
+
logUser(
|
|
1008
|
+
'Heads up: if your store is still password-protected (common on new/trial stores) and your theme has NO separate layout/password.liquid, the orb won\u2019t show on the password page — remove the password (Online Store \u2192 Preferences) to test, or check under a real domain/plan.',
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (!noWire) {
|
|
1014
|
+
try {
|
|
1015
|
+
const record = buildRunRecord(analysisRoot, {
|
|
1016
|
+
appName: manifest.appName,
|
|
1017
|
+
manifest,
|
|
1018
|
+
publishMeta,
|
|
1019
|
+
orbResult: orbWireResult,
|
|
1020
|
+
});
|
|
1021
|
+
const paths = writeChangeRegistry(analysisRoot, record);
|
|
1022
|
+
logUser(changeLogUserMessage(paths));
|
|
840
1023
|
} catch (e) {
|
|
841
1024
|
logDev(`[change-registry] could not write: ${e.message}`);
|
|
842
1025
|
}
|
|
@@ -959,7 +1142,7 @@ async function main() {
|
|
|
959
1142
|
envResult: envWriteResult,
|
|
960
1143
|
});
|
|
961
1144
|
const paths = writeChangeRegistry(analysisRoot, record);
|
|
962
|
-
logUser(
|
|
1145
|
+
logUser(changeLogUserMessage(paths));
|
|
963
1146
|
} catch (e) {
|
|
964
1147
|
logDev(`[change-registry] could not write: ${e.message}`);
|
|
965
1148
|
}
|
|
@@ -1038,7 +1221,7 @@ async function main() {
|
|
|
1038
1221
|
envResult: envWriteResult,
|
|
1039
1222
|
});
|
|
1040
1223
|
const paths = writeChangeRegistry(analysisRoot, record);
|
|
1041
|
-
logUser(
|
|
1224
|
+
logUser(changeLogUserMessage(paths));
|
|
1042
1225
|
} catch (e) {
|
|
1043
1226
|
logDev(`[change-registry] could not write: ${e.message}`);
|
|
1044
1227
|
}
|
|
@@ -1131,6 +1314,9 @@ function detectPlatform(appRoot, appDir) {
|
|
|
1131
1314
|
return found;
|
|
1132
1315
|
};
|
|
1133
1316
|
|
|
1317
|
+
// Shopify theme repos (GitHub-synced Liquid themes) are unmistakable and
|
|
1318
|
+
// have no package.json framework signal — check them first.
|
|
1319
|
+
if (isShopifyThemeRoot(appRoot)) return 'shopify';
|
|
1134
1320
|
if (has('pubspec.yaml')) return 'flutter';
|
|
1135
1321
|
if (has('Package.swift') || hasAnyExt('.xcodeproj') || (has('Sources') && hasAnyExt('.swift'))) return 'ios';
|
|
1136
1322
|
if ((has('settings.gradle.kts') || has('settings.gradle') || has('build.gradle.kts') || has('build.gradle')) && hasAnyExt('.kt')) {
|
|
@@ -1563,6 +1749,7 @@ async function postOrbWired({
|
|
|
1563
1749
|
actionsStatus = 'pending',
|
|
1564
1750
|
actionsTotal = 0,
|
|
1565
1751
|
actionsDone = 0,
|
|
1752
|
+
actions = null,
|
|
1566
1753
|
}) {
|
|
1567
1754
|
if (!apiKey) return { ok: false, reason: 'no-api-key' };
|
|
1568
1755
|
try {
|
|
@@ -1575,6 +1762,11 @@ async function postOrbWired({
|
|
|
1575
1762
|
actionsStatus,
|
|
1576
1763
|
actionsTotal,
|
|
1577
1764
|
actionsDone,
|
|
1765
|
+
// Per-action wiring detail (intent/status/reason). Powers the backend's
|
|
1766
|
+
// "wired or nonexistent" invariant (unwired actions never reach the
|
|
1767
|
+
// agent prompt) and the dashboard/Context wiring diagnosis. Older
|
|
1768
|
+
// backends ignore the extra field.
|
|
1769
|
+
...(Array.isArray(actions) ? { actions } : {}),
|
|
1578
1770
|
}),
|
|
1579
1771
|
});
|
|
1580
1772
|
if (res.ok) {
|
|
@@ -1589,6 +1781,210 @@ async function postOrbWired({
|
|
|
1589
1781
|
}
|
|
1590
1782
|
}
|
|
1591
1783
|
|
|
1784
|
+
/**
|
|
1785
|
+
* Rewire-only mode (`fiodos rewire <dir>` / `--wire-only`): re-run JUST the
|
|
1786
|
+
* handler wiring from the saved artifacts of the last analysis (manifest.json +
|
|
1787
|
+
* evidence.json in the analysis output dir). The full verify→diagnose→correct→
|
|
1788
|
+
* retry loop runs again — including AI corrections — so an action that fell to
|
|
1789
|
+
* "manual" in the install gets fresh attempts WITHOUT a re-analysis (no quota,
|
|
1790
|
+
* no manifest publish, no orb re-mount). Ends by re-posting the per-action
|
|
1791
|
+
* wiring status so the dashboard/Context see the repaired state.
|
|
1792
|
+
*/
|
|
1793
|
+
async function runRewireOnly(target) {
|
|
1794
|
+
const inputRoot = path.resolve(target || '.');
|
|
1795
|
+
const safeName = path.basename(inputRoot).replace(/[^a-z0-9_-]/gi, '_') || 'app';
|
|
1796
|
+
const outDir = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
|
|
1797
|
+
|
|
1798
|
+
const manifestPath = path.join(outDir, 'manifest.json');
|
|
1799
|
+
const evidencePath = path.join(outDir, 'evidence.json');
|
|
1800
|
+
if (!fs.existsSync(manifestPath) || !fs.existsSync(evidencePath)) {
|
|
1801
|
+
logUser('No saved analysis found for this project — run a full analysis first:');
|
|
1802
|
+
logUser(` npx fiodos analyze ${target || '.'} --publish`);
|
|
1803
|
+
logUser(`(looked in ${outDir}; pass --out <dir> if you analyzed with a custom output dir)`);
|
|
1804
|
+
process.exitCode = 1;
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1808
|
+
const ev = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));
|
|
1809
|
+
const evidence = ev.evidence || {};
|
|
1810
|
+
const wiring = ev.wiring || {};
|
|
1811
|
+
|
|
1812
|
+
const scope = resolveAnalysisRoot(inputRoot, 'web');
|
|
1813
|
+
const analysisRoot = scope.root;
|
|
1814
|
+
loadAppEnv(analysisRoot);
|
|
1815
|
+
const { apiKey, apiUrl } = resolveApiTarget();
|
|
1816
|
+
const model = resolveModel(arg('--model', '') || DEFAULT_MODEL);
|
|
1817
|
+
const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
|
|
1818
|
+
const { runPostWireTest } = require('./postWireTest');
|
|
1819
|
+
const { included } = collectFiles(analysisRoot, { maxInputTokens: Number(arg('--max-input-tokens', '150000')), platform: 'web' });
|
|
1820
|
+
|
|
1821
|
+
const selfCorrect = !process.argv.includes('--no-self-correct');
|
|
1822
|
+
const correctorUseProxy = !process.argv.includes('--own-ai-key');
|
|
1823
|
+
const corrector = selfCorrect
|
|
1824
|
+
? async (args) => {
|
|
1825
|
+
const { wiring: fixed } = await correctActionWiring({
|
|
1826
|
+
...args, model, platform: 'web', useProxy: correctorUseProxy, apiKey, apiUrl,
|
|
1827
|
+
});
|
|
1828
|
+
return fixed;
|
|
1829
|
+
}
|
|
1830
|
+
: null;
|
|
1831
|
+
|
|
1832
|
+
logUser(`Rewiring actions from the saved analysis (${manifest.actions.length} action(s), no re-analysis)…`);
|
|
1833
|
+
let handlerResult = null;
|
|
1834
|
+
await withSpinner(wiringSpinnerLabel('web'), async ({ pause, resume }) => {
|
|
1835
|
+
if (!assumeYes) pause();
|
|
1836
|
+
handlerResult = await wireHandlers(analysisRoot, {
|
|
1837
|
+
manifest,
|
|
1838
|
+
evidence,
|
|
1839
|
+
wiring,
|
|
1840
|
+
appName: manifest.appName,
|
|
1841
|
+
colors: fyodosTermColors(),
|
|
1842
|
+
assumeYes,
|
|
1843
|
+
noWire: false,
|
|
1844
|
+
quiet: !isVerbose(),
|
|
1845
|
+
runTest: !process.argv.includes('--no-wire-test'),
|
|
1846
|
+
revertOnFailure: true,
|
|
1847
|
+
testRunner: runPostWireTest,
|
|
1848
|
+
corrector,
|
|
1849
|
+
files: included,
|
|
1850
|
+
maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
|
|
1851
|
+
});
|
|
1852
|
+
if (!assumeYes) resume();
|
|
1853
|
+
pause();
|
|
1854
|
+
reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
1855
|
+
resume();
|
|
1856
|
+
|
|
1857
|
+
const handlerApplied =
|
|
1858
|
+
handlerResult &&
|
|
1859
|
+
(handlerResult.status === 'applied' || handlerResult.status === 'applied-untested') &&
|
|
1860
|
+
(handlerResult.autoCount || 0) > 0 &&
|
|
1861
|
+
handlerResult.registryFile;
|
|
1862
|
+
if (handlerApplied) {
|
|
1863
|
+
const connected = connectOrbRegistries(analysisRoot, {
|
|
1864
|
+
registryFileAbs: handlerResult.registryFile,
|
|
1865
|
+
});
|
|
1866
|
+
pause();
|
|
1867
|
+
reportRegistriesResult(connected, fyodosTermColors(), { quiet: !isVerbose() });
|
|
1868
|
+
resume();
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
|
|
1872
|
+
// Session + screen wiring also replay from the saved analysis (same consent
|
|
1873
|
+
// model as the main flow: an APPLIED handler wiring proves the "yes").
|
|
1874
|
+
const handlerConsented =
|
|
1875
|
+
handlerResult &&
|
|
1876
|
+
(handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
|
|
1877
|
+
if (ev.session) {
|
|
1878
|
+
const sessionResult = await wireSession(analysisRoot, {
|
|
1879
|
+
session: ev.session,
|
|
1880
|
+
colors: fyodosTermColors(),
|
|
1881
|
+
quiet: !isVerbose(),
|
|
1882
|
+
assumeYes: assumeYes || Boolean(handlerConsented),
|
|
1883
|
+
framework: 'web',
|
|
1884
|
+
appName: manifest.appName,
|
|
1885
|
+
runTest: !process.argv.includes('--no-wire-test'),
|
|
1886
|
+
testRunner: runPostWireTest,
|
|
1887
|
+
});
|
|
1888
|
+
reportSessionResult(sessionResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
1889
|
+
if (sessionResult.status === 'applied' || sessionResult.status === 'applied-untested') {
|
|
1890
|
+
const connectedSession = connectOrbSession(analysisRoot, {
|
|
1891
|
+
sessionFileAbs: sessionResult.sessionFile,
|
|
1892
|
+
});
|
|
1893
|
+
reportSessionConnectResult(connectedSession, fyodosTermColors(), { quiet: !isVerbose() });
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
if (ev.screen) {
|
|
1897
|
+
const screenResult = await wireScreen(analysisRoot, {
|
|
1898
|
+
screen: ev.screen,
|
|
1899
|
+
colors: fyodosTermColors(),
|
|
1900
|
+
quiet: !isVerbose(),
|
|
1901
|
+
assumeYes: assumeYes || Boolean(handlerConsented),
|
|
1902
|
+
framework: 'web',
|
|
1903
|
+
appName: manifest.appName,
|
|
1904
|
+
runTest: !process.argv.includes('--no-wire-test'),
|
|
1905
|
+
testRunner: runPostWireTest,
|
|
1906
|
+
});
|
|
1907
|
+
reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() });
|
|
1908
|
+
if (screenResult.status === 'applied' || screenResult.status === 'applied-untested') {
|
|
1909
|
+
const connectedScreen = connectOrbScreen(analysisRoot, {
|
|
1910
|
+
screenFileAbs: screenResult.screenFile,
|
|
1911
|
+
});
|
|
1912
|
+
reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() });
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (apiKey && handlerResult) {
|
|
1917
|
+
const { status, total, done } = actionsWiredFromHandlerResult(handlerResult);
|
|
1918
|
+
await postOrbWired({
|
|
1919
|
+
apiKey,
|
|
1920
|
+
apiUrl,
|
|
1921
|
+
file: (handlerResult.registryRel || ''),
|
|
1922
|
+
actionsStatus: status,
|
|
1923
|
+
actionsTotal: total,
|
|
1924
|
+
actionsDone: done,
|
|
1925
|
+
actions: actionsDetailFromHandlerResult(manifest, handlerResult),
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
const wired = (handlerResult && handlerResult.autoCount) || 0;
|
|
1929
|
+
const manual = (handlerResult && handlerResult.manualCount) || 0;
|
|
1930
|
+
logUser(`Rewire finished: ${wired} action(s) wired, ${manual} still need attention.`);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* Per-action wiring detail for the backend (POST /v1/developer/orb-wired).
|
|
1935
|
+
* One entry per manifest action:
|
|
1936
|
+
* { intent, handler, status: 'wired'|'manual'|'link', reason?, attempts? }
|
|
1937
|
+
* 'wired' → verified and installed (the action really runs);
|
|
1938
|
+
* 'manual' → could not be wired/verified — the backend EXCLUDES it from the
|
|
1939
|
+
* agent surface ("wired or nonexistent" invariant) and the
|
|
1940
|
+
* dashboard/Context can show and repair it;
|
|
1941
|
+
* 'link' → navigation/link action, needs no handler (always available).
|
|
1942
|
+
* Returns null when wiring did not actually run (declined/skipped): with no
|
|
1943
|
+
* verdict the backend must not filter anything.
|
|
1944
|
+
*/
|
|
1945
|
+
function actionsDetailFromHandlerResult(manifest, handlerResult) {
|
|
1946
|
+
const r = handlerResult || {};
|
|
1947
|
+
const ranStatuses = new Set(['applied', 'applied-untested', 'manual-only', 'reverted', 'failed']);
|
|
1948
|
+
if (!ranStatuses.has(r.status)) return null;
|
|
1949
|
+
const plan = r.plan || {};
|
|
1950
|
+
const autoByIntent = new Map((plan.auto || []).map((e) => [e.intent, e]));
|
|
1951
|
+
const reviewByIntent = new Map((plan.review || plan.manual || []).map((e) => [e.intent, e]));
|
|
1952
|
+
const verifByIntent = new Map((r.verification || []).map((v) => [v.intent, v]));
|
|
1953
|
+
const wholesaleFailed = r.status === 'reverted' || r.status === 'failed';
|
|
1954
|
+
|
|
1955
|
+
const detail = [];
|
|
1956
|
+
for (const a of (manifest && manifest.actions) || []) {
|
|
1957
|
+
const intent = a.intent;
|
|
1958
|
+
if (a.kind === 'link') {
|
|
1959
|
+
detail.push({ intent, handler: '', status: 'link' });
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
const verif = verifByIntent.get(intent);
|
|
1963
|
+
if (!wholesaleFailed && autoByIntent.has(intent)) {
|
|
1964
|
+
detail.push({
|
|
1965
|
+
intent,
|
|
1966
|
+
handler: a.handler || intent,
|
|
1967
|
+
status: 'wired',
|
|
1968
|
+
...(verif && verif.level ? { level: verif.level } : {}),
|
|
1969
|
+
...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
|
|
1970
|
+
});
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
const rev = reviewByIntent.get(intent);
|
|
1974
|
+
const reason = wholesaleFailed
|
|
1975
|
+
? `wiring ${r.status}: the combined install did not pass the build safety net`
|
|
1976
|
+
: String((rev && rev.reason) || (verif && verif.error) || 'could not be wired automatically');
|
|
1977
|
+
detail.push({
|
|
1978
|
+
intent,
|
|
1979
|
+
handler: a.handler || intent,
|
|
1980
|
+
status: 'manual',
|
|
1981
|
+
reason: reason.split('|')[0].replace(/\s+/g, ' ').trim().slice(0, 300),
|
|
1982
|
+
...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
return detail;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1592
1988
|
// Map a wireHandlers() result to the terminal action-wiring status the backend
|
|
1593
1989
|
// records, plus how many actions ended up wired. Drives the onboarding's third
|
|
1594
1990
|
// "actions connected" tick so it only goes green once wiring truly finished.
|
package/src/postWireTest.js
CHANGED
|
@@ -127,6 +127,13 @@ function summarizeBuildOutput(raw, maxLines = 4) {
|
|
|
127
127
|
return picked.slice(0, maxLines).join('\n').trim();
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Does this output contain a signal that the CODE is broken (vs npm/env noise)? */
|
|
131
|
+
function looksLikeCompileFailure(output) {
|
|
132
|
+
return /error\s+TS\d+|Type error:|Module not found|Can't resolve|Failed to compile|SyntaxError|Parsing error|Cannot find (?:module|name)/i.test(
|
|
133
|
+
String(output || ''),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
async function runPostWireTest(appRoot, opts = {}) {
|
|
131
138
|
const timeoutMs = opts.timeoutMs || 240000;
|
|
132
139
|
if (!fs.existsSync(path.join(appRoot, 'node_modules'))) {
|
|
@@ -143,12 +150,26 @@ async function runPostWireTest(appRoot, opts = {}) {
|
|
|
143
150
|
return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no build/typecheck script.' };
|
|
144
151
|
}
|
|
145
152
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
const runOnce = () =>
|
|
154
|
+
runAsync('npm', chosen.args, {
|
|
155
|
+
cwd: appRoot,
|
|
156
|
+
timeout: timeoutMs,
|
|
157
|
+
env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
|
|
158
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let res = await runOnce();
|
|
162
|
+
// A spawn-level or npm-level failure with ZERO compile diagnostics is
|
|
163
|
+
// environment noise (npm cache contention, a concurrent process touching
|
|
164
|
+
// package.json, transient ENOENT), not broken code. Blaming the action for
|
|
165
|
+
// it drops perfectly good wiring, so retry once before judging.
|
|
166
|
+
const npmLevelFailure =
|
|
167
|
+
(res.error && res.error.code !== 'ETIMEDOUT') ||
|
|
168
|
+
(res.status !== 0 && res.status !== null && !looksLikeCompileFailure(`${res.stdout}\n${res.stderr}`));
|
|
169
|
+
if (npmLevelFailure) {
|
|
170
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
171
|
+
res = await runOnce();
|
|
172
|
+
}
|
|
152
173
|
|
|
153
174
|
if (res.error && res.error.code === 'ETIMEDOUT') {
|
|
154
175
|
return { ok: false, stage: 'timeout', command: chosen.label, output: `build exceeded ${Math.round(timeoutMs / 1000)}s` };
|