@deeeed/metamask-harness 0.3.9 → 0.5.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/ADAPTER-SURFACE.md +119 -0
  42. package/docs/CLI-SPEC.md +26 -3
  43. package/docs/UX-PRINCIPLES.md +3 -0
  44. package/package.json +10 -2
  45. package/src/adapters/core/surface.ts +71 -0
  46. package/src/adapters/extension/surface.ts +88 -0
  47. package/src/adapters/mobile/provision.ts +594 -0
  48. package/src/adapters/mobile/surface.ts +71 -0
  49. package/src/adapters/slot-ports.ts +165 -0
  50. package/src/adapters/surface.ts +117 -0
  51. package/src/cli-commands.ts +1 -1
  52. package/src/cli.ts +239 -49
  53. package/src/commands/debug.ts +3 -1
  54. package/src/commands/fixtures.ts +13 -8
  55. package/src/commands/launch.ts +7 -156
  56. package/src/commands/logs.ts +29 -13
  57. package/src/harness.ts +140 -3
  58. package/src/mm-harness-cli.ts +71 -18
package/dist/cli.js ADDED
@@ -0,0 +1,1571 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { mkdtemp } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { color } from "./cli-color.js";
7
+ import { createDoctorReport, renderRuntimeContext } from "./doctor.js";
8
+ import {
9
+ invalidateCompletionCache,
10
+ readFreshCandidates,
11
+ writeCompletionCandidates
12
+ } from "./completions-cache.js";
13
+ import { detectAdapter, handleHarness } from "./harness.js";
14
+ import { handleLaunch } from "./commands/launch.js";
15
+ import { handleLogs } from "./commands/logs.js";
16
+ import { handleDebug } from "./commands/debug.js";
17
+ import { handleFixtures } from "./commands/fixtures.js";
18
+ import {
19
+ ensureOverlay,
20
+ newHealState,
21
+ parseHeal,
22
+ recipeRunning,
23
+ checkHealBounds
24
+ } from "./heal-bounds.js";
25
+ import { ensureExtensionReady } from "./adapters/extension/ensure-ready.js";
26
+ import { resolveExtensionId } from "./adapters/extension/extension-id.js";
27
+ import { decideExtensionReadiness } from "./adapters/extension/runtime-decision.js";
28
+ import { decideMobileReadiness } from "./adapters/mobile/runtime-decision.js";
29
+ import { getAdapterSurface } from "./adapters/surface.js";
30
+ import { loadActionManifest, validateManifest } from "./manifest.js";
31
+ import {
32
+ assertAdapter,
33
+ importRecipeHarness,
34
+ importRecipeHarnessCli,
35
+ importRecipeProtocol,
36
+ manifestPath,
37
+ recipeHarnessPath,
38
+ recipePath,
39
+ runnerDir,
40
+ walletFixturePath
41
+ } from "./paths.js";
42
+ import { captureHelperSupportsRecordSessionSnapshots } from "./recording-target.js";
43
+ import { startRecipeRecording, stopRecipeRecording } from "./run-recording.js";
44
+ import { ADAPTER_DETECT_NEXT, usageOut } from "./commands/shared.js";
45
+ const COMMANDS = {
46
+ manifest: handleManifest,
47
+ actions: handleActions,
48
+ doctor: handleDoctor,
49
+ "runtime-health": handleRuntimeHealth,
50
+ "runtime-decision": handleRuntimeDecision,
51
+ "runtime-launch": handleRuntimeLaunch,
52
+ provision: handleProvision,
53
+ "resolve-extension": handleResolveExtension,
54
+ "ensure-ready": handleEnsureReady,
55
+ run: handleRun,
56
+ "self-test": handleSelfTest
57
+ };
58
+ const OVERLAY_COMMANDS = ["install", "provision", "verify", "cleanup", "live"];
59
+ function usage() {
60
+ console.error(`mm-harness \u2014 the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay.
61
+ Run it from inside a MetaMask checkout; the platform (mobile | extension | core) is auto-detected.
62
+ Grammar: mm-harness <command> [target] [flags] (target is a positional: ios | android | extension; flags add agent depth)
63
+
64
+ DAILY LOOP \u2014 what a teammate runs many times a day:
65
+ launch Launch the app incl. Metro/build, surface auto-detected. Mobile: ios|android required.
66
+ mm-harness launch ios # quick relaunch (add --build | --verify)
67
+ logs Tail Metro/webpack + app logs.
68
+ mm-harness logs
69
+ debug Open the debug console (extension DevTools / mobile RN).
70
+ mm-harness debug
71
+ fixtures Sync fixture files + set up the wallet (SRP/password).
72
+ mm-harness fixtures sync # or: mm-harness fixtures set
73
+
74
+ PROVE \u2014 run recipes and inspect capabilities:
75
+ run Run a recipe and write evidence (summary/trace/artifacts).
76
+ mm-harness run recipe.json
77
+ flows List/promote recipe library flows (personal/team win over canonical).
78
+ mm-harness flows list
79
+ doctor Readiness check for a checkout (no app launch).
80
+ mm-harness doctor
81
+ actions Describe the actions a manifest declares.
82
+ mm-harness actions --adapter mobile
83
+ manifest Print/validate the action manifest for an adapter.
84
+ mm-harness manifest --adapter extension --json
85
+
86
+ RUNTIME OVERLAY \u2014 install/verify/clean the per-checkout runtime overlay:
87
+ install Install the runtime overlay into the checkout.
88
+ mm-harness install # inside a checkout, auto-detected
89
+ verify Check the overlay/runtime is present and healthy (no launch).
90
+ mm-harness verify
91
+ live Launch/reuse the app, then verify live control end-to-end.
92
+ mm-harness live
93
+ cleanup Remove the installed overlay and restore the checkout.
94
+ mm-harness cleanup
95
+ # Open sub-question (Arthur): keep these top-level (mm-harness install) or group
96
+ # them under an \`overlay\` command (mm-harness overlay install)? Top-level for now.
97
+
98
+ ADVANCED \u2014 internal runtime probes (rarely typed by hand):
99
+ runtime-health runtime-decision runtime-launch
100
+ resolve-extension ensure-ready self-test
101
+ (e.g. mm-harness runtime-decision --adapter extension --target <repo> --json)
102
+
103
+ ONE bin: mm-harness is the only command. No per-platform binaries \u2014 platform is
104
+ auto-detected, the positional target forces it (mm-harness launch ios), and
105
+ platform-specific needs are FLAGS on the same command (e.g. --sidebar, --full-build).
106
+ Human happy path = the bare command; agents add depth via flags (--json, --target, ports).
107
+ See docs/MENTAL-MODEL.md (overview) and docs/CLI-SPEC.md (full contract).
108
+ `);
109
+ }
110
+ function parseArgs(argv, command) {
111
+ const positional = [];
112
+ const options = {};
113
+ const booleanOptions = /* @__PURE__ */ new Set(["json", "launchExistingDist", "startWatch", "record", "plan", "raw", "fix", "force", "resolveOnly"]);
114
+ for (let i = 0; i < argv.length; i += 1) {
115
+ const arg = argv[i];
116
+ if (!arg.startsWith("--")) {
117
+ positional.push(arg);
118
+ continue;
119
+ }
120
+ const body = arg.slice(2);
121
+ const equalsIndex = body.indexOf("=");
122
+ const rawKey = equalsIndex === -1 ? body : body.slice(0, equalsIndex);
123
+ const inlineValue = equalsIndex === -1 ? void 0 : body.slice(equalsIndex + 1);
124
+ const key = normalizeOptionKey(rawKey);
125
+ if (key === "recordVideo") {
126
+ options.recordVideo = parseRecordVideoMode(inlineValue);
127
+ continue;
128
+ }
129
+ if (key === "recordBaseline") {
130
+ options.record = true;
131
+ continue;
132
+ }
133
+ if (key === "record") {
134
+ if (command === "runtime-decision") {
135
+ options.record = true;
136
+ continue;
137
+ }
138
+ options.recordVideo = "full-run";
139
+ continue;
140
+ }
141
+ if (booleanOptions.has(key)) {
142
+ options[key] = true;
143
+ continue;
144
+ }
145
+ if (inlineValue !== void 0) {
146
+ options[key] = inlineValue;
147
+ continue;
148
+ }
149
+ if (i + 1 >= argv.length) throw usageError(`Missing value for ${arg}`);
150
+ options[key] = argv[i + 1];
151
+ i += 1;
152
+ }
153
+ return { positional, options, rawArgv: [...argv] };
154
+ }
155
+ function parseRecordVideoMode(value) {
156
+ if (value === void 0 || value === "" || value === "true") return "full-run";
157
+ if (value === "off" || value === "false") return false;
158
+ if (value === "proof-window" || value === "proof_window") {
159
+ throw usageError(
160
+ "--record-video=proof-window is not supported yet; use --record-video=full-run."
161
+ );
162
+ }
163
+ if (value !== "full-run") {
164
+ throw usageError("--record-video must be full-run or off.");
165
+ }
166
+ return "full-run";
167
+ }
168
+ function normalizeOptionKey(key) {
169
+ return key.replace(/-([a-z])/gu, (_, character) => character.toUpperCase());
170
+ }
171
+ function optionString(options, key) {
172
+ const value = options[key];
173
+ if (value === void 0) return void 0;
174
+ if (typeof value !== "string") throw usageError(`--${key} requires a value.`);
175
+ return value;
176
+ }
177
+ function optionFlag(options, key) {
178
+ const value = options[key];
179
+ return value === true;
180
+ }
181
+ function applyRuntimeDirOption(options) {
182
+ const runtimeDir = optionString(options, "runtimeDir");
183
+ if (runtimeDir) process.env.RECIPE_RUNTIME_DIR = runtimeDir;
184
+ }
185
+ function applyWatcherPortOption(options) {
186
+ const watcherPort = optionString(options, "watcherPort");
187
+ if (!watcherPort) return;
188
+ process.env.WATCHER_PORT = watcherPort;
189
+ process.env.RECIPE_WATCHER_PORT = watcherPort;
190
+ process.env.METRO_PORT = watcherPort;
191
+ }
192
+ function requiredOption(options, key, message) {
193
+ const value = optionString(options, key);
194
+ if (!value) throw usageError(message);
195
+ return value;
196
+ }
197
+ function adapterOption(options) {
198
+ const adapter = optionString(options, "adapter");
199
+ try {
200
+ assertAdapter(adapter);
201
+ } catch (error) {
202
+ throw usageError(error instanceof Error ? error.message : String(error));
203
+ }
204
+ return adapter;
205
+ }
206
+ function targetPath(options) {
207
+ return path.resolve(optionString(options, "target") ?? optionString(options, "projectRoot") ?? process.cwd());
208
+ }
209
+ function actionManifestPathOption(options, adapter) {
210
+ const configured = optionString(options, "actionManifest");
211
+ return configured ? path.resolve(configured) : manifestPath(adapter);
212
+ }
213
+ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManifestPath, runtimeOptions = {}) {
214
+ const previousCdpPort = process.env.CDP_PORT;
215
+ const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
216
+ const previousWatcherPort = process.env.WATCHER_PORT;
217
+ const previousMetroPort = process.env.METRO_PORT;
218
+ const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
219
+ if (runtimeOptions.cdpPort) {
220
+ process.env.CDP_PORT = runtimeOptions.cdpPort;
221
+ process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
222
+ }
223
+ if (runtimeOptions.watcherPort) {
224
+ process.env.WATCHER_PORT = runtimeOptions.watcherPort;
225
+ process.env.METRO_PORT = runtimeOptions.watcherPort;
226
+ }
227
+ if (runtimeOptions.launchExistingDist) {
228
+ process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = "1";
229
+ }
230
+ try {
231
+ await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
232
+ const absoluteArtifactsDir = path.resolve(artifactsDir);
233
+ const recordVideo = runtimeOptions.recordVideo ?? false;
234
+ const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
235
+ const recording = useFramedExtensionRecording ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
236
+ record: true,
237
+ cdpPort: runtimeOptions.cdpPort
238
+ }) : void 0;
239
+ try {
240
+ const manifest = loadActionManifest(adapter, actionManifestPath);
241
+ await validateManifest(manifest);
242
+ const { createMetaMaskRunner } = await import("./runner.js");
243
+ const runner = await createMetaMaskRunner(adapter, manifest, {
244
+ quietStdout: runtimeOptions.stdoutIsMachineContract === true
245
+ });
246
+ const runRequest = {
247
+ recipePath: path.resolve(recipe),
248
+ artifactsDir: absoluteArtifactsDir,
249
+ projectRoot,
250
+ env: recipeRunEnv(adapter, runtimeOptions),
251
+ recordVideo: useFramedExtensionRecording ? false : recordVideo,
252
+ ...runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}
253
+ };
254
+ const result = await runner.run(runRequest);
255
+ await stopRecipeRecording(recording, result);
256
+ return result;
257
+ } finally {
258
+ await stopRecipeRecording(recording);
259
+ }
260
+ } finally {
261
+ restoreEnv("CDP_PORT", previousCdpPort);
262
+ restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
263
+ restoreEnv("WATCHER_PORT", previousWatcherPort);
264
+ restoreEnv("METRO_PORT", previousMetroPort);
265
+ restoreEnv("METAMASK_RECIPE_EXTENSION_AUTOLAUNCH", previousExtensionAutolaunch);
266
+ }
267
+ }
268
+ function recipeRunEnv(adapter, runtimeOptions = {}) {
269
+ const base = {
270
+ CDP_PORT: runtimeOptions.cdpPort ?? process.env.CDP_PORT,
271
+ RECIPE_CDP_PORT: runtimeOptions.cdpPort ?? process.env.RECIPE_CDP_PORT,
272
+ FARMSLOT_SLOT_ID: runtimeOptions.slot ?? process.env.FARMSLOT_SLOT_ID,
273
+ SLOT_ID: runtimeOptions.slot ?? process.env.SLOT_ID,
274
+ PLATFORM: process.env.PLATFORM
275
+ };
276
+ if (adapter !== "mobile") return base;
277
+ return {
278
+ ...base,
279
+ WATCHER_PORT: process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
280
+ METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
281
+ IOS_SIMULATOR: process.env.IOS_SIMULATOR,
282
+ ANDROID_DEVICE: process.env.ANDROID_DEVICE,
283
+ ADB_SERIAL: process.env.ADB_SERIAL,
284
+ ANDROID_SERIAL: process.env.ANDROID_SERIAL
285
+ };
286
+ }
287
+ async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
288
+ if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true) return;
289
+ const { prepareExtensionRuntime } = await import("./adapters/extension/runtime.js");
290
+ await prepareExtensionRuntime({
291
+ projectRoot,
292
+ cdpPort: runtimeOptions.cdpPort,
293
+ slot: runtimeOptions.slot,
294
+ launchExistingDist: runtimeOptions.launchExistingDist === true,
295
+ validationRuntimeDir: runtimeOptions.validationRuntimeDir
296
+ });
297
+ }
298
+ function restoreEnv(key, value) {
299
+ if (value === void 0) delete process.env[key];
300
+ else process.env[key] = value;
301
+ }
302
+ async function runSelfTest(options) {
303
+ const root = optionString(options, "artifactsDir") ? path.resolve(requiredOption(options, "artifactsDir", "self-test artifacts dir missing.")) : await mkdtemp(path.join(os.tmpdir(), "mm-harness-runner-"));
304
+ const previousAutoHud = process.env.METAMASK_RECIPE_AUTO_HUD;
305
+ const runs = [];
306
+ try {
307
+ process.env.METAMASK_RECIPE_AUTO_HUD = "0";
308
+ for (const adapter of ["mobile", "extension"]) {
309
+ const manifest = loadActionManifest(adapter);
310
+ const manifestValidation = await validateManifest(manifest);
311
+ const smokeRecipe = recipePath(
312
+ adapter === "mobile" ? "smoke.mobile.recipe.json" : "smoke.extension.recipe.json"
313
+ );
314
+ const artifactsDir = path.join(root, adapter);
315
+ const result = await runRecipe(adapter, smokeRecipe, artifactsDir, runnerDir, void 0, {
316
+ skipExtensionRuntimePrepare: true
317
+ });
318
+ runs.push({ adapter, manifestValidation: manifestValidation.summary, artifactsDir, result });
319
+ }
320
+ } finally {
321
+ restoreEnv("METAMASK_RECIPE_AUTO_HUD", previousAutoHud);
322
+ }
323
+ return {
324
+ status: runs.every((run) => run.result.status === "pass") ? "pass" : "fail",
325
+ artifactsDir: root,
326
+ runs
327
+ };
328
+ }
329
+ async function handleManifest({ options }) {
330
+ const { adapter } = resolveAdapter(options);
331
+ const actionManifestPath = actionManifestPathOption(options, adapter);
332
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
333
+ await validateManifest(manifest);
334
+ if (optionFlag(options, "json")) console.log(JSON.stringify(manifest, null, 2));
335
+ else console.log(actionManifestPath);
336
+ return 0;
337
+ }
338
+ async function handleActions({ options }) {
339
+ const { adapter } = resolveAdapter(options);
340
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
341
+ await validateManifest(manifest);
342
+ const action = optionString(options, "action");
343
+ const actions = describeManifestActions(manifest, action);
344
+ if (optionFlag(options, "json")) {
345
+ console.log(JSON.stringify({ adapter, actions }, null, 2));
346
+ } else {
347
+ for (const entry of actions) {
348
+ const fields = entry.fields.length ? ` fields=${entry.fields.join(",")}` : "";
349
+ console.log(`${entry.name} (${entry.kind})${fields}${entry.description ? ` \u2014 ${entry.description}` : ""}`);
350
+ }
351
+ }
352
+ return 0;
353
+ }
354
+ async function handleDoctor({ options }) {
355
+ applyRuntimeDirOption(options);
356
+ const target = targetPath(options);
357
+ const json = optionFlag(options, "json");
358
+ const explicitAdapter = optionString(options, "adapter") ?? optionString(options, "platform");
359
+ const adapter = explicitAdapter ?? detectAdapter(target);
360
+ if (!adapter) {
361
+ return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
362
+ }
363
+ assertAdapter(adapter);
364
+ const actionManifestPath = actionManifestPathOption(options, adapter);
365
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
366
+ const manifestValidation = await validateManifest(manifest);
367
+ if (optionFlag(options, "fix")) {
368
+ const { fixed, failed } = await runDoctorFix(adapter, target, manifestValidation, json);
369
+ const result2 = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
370
+ if (json) console.log(JSON.stringify({ ...result2, fixed, failed }, null, 2));
371
+ else console.log(`${result2.status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
372
+ return failed.length === 0 ? 0 : 1;
373
+ }
374
+ const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
375
+ let runtime;
376
+ try {
377
+ const surface = getAdapterSurface(adapter);
378
+ surface.resolveSlotPorts(target);
379
+ runtime = await surface.runtimeStatus(target);
380
+ } catch {
381
+ }
382
+ if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
383
+ else {
384
+ const out = (style, text) => color(style, text, { stream: process.stdout });
385
+ const stateStyle = (value, good) => value === good ? "ok" : "warn";
386
+ console.log(`${out(result.status === "pass" ? "ok" : "err", result.status)} ${out("bold", adapter)} ${result.compatibilityMode} ${out("dim", `manifest=${actionManifestPath}`)}`);
387
+ if (runtime) {
388
+ const provisionedDepsPending = runtime.reasonCode === "app-installed-deps-pending";
389
+ const decisionStyle = provisionedDepsPending ? "info" : runtime.decision === "ready" ? "ok" : runtime.decision === "blocked" ? "err" : "warn";
390
+ const depsStyle = provisionedDepsPending ? "info" : stateStyle(runtime.deps, "current");
391
+ const devServerStyle = provisionedDepsPending ? "info" : void 0;
392
+ const devServer = runtime.devServer ? ` ${runtime.devServer.label}=${out(devServerStyle ?? stateStyle(runtime.devServer.status, "up"), runtime.devServer.status)}` : "";
393
+ console.log(
394
+ `${out("label", "runtime:")} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out("dim", `(${runtime.reasonCode})`)}` : ""} deps=${out(depsStyle, runtime.deps ?? "unknown")}${devServer}`
395
+ );
396
+ for (const reason of runtime.reasons) console.log(` ${out("dim", reason)}`);
397
+ }
398
+ console.log(renderRuntimeContext(result.runtimeContext));
399
+ }
400
+ return result.status === "pass" ? 0 : 1;
401
+ }
402
+ async function runDoctorFix(adapter, target, manifestValidation, json) {
403
+ const fixed = [];
404
+ const failed = [];
405
+ if (recipeRunning(target)) {
406
+ failed.push("recipe-running");
407
+ return { fixed, failed };
408
+ }
409
+ if (Number(manifestValidation.summary?.errors ?? 0) > 0) failed.push("manifest");
410
+ const state = newHealState();
411
+ const ensured = await ensureOverlay(adapter, target, "infra-only", state, json);
412
+ if (!ensured.ok) failed.push("overlay");
413
+ else if (state.mutations.length > 0) fixed.push("overlay");
414
+ return { fixed, failed };
415
+ }
416
+ function describeManifestActions(manifest, filterAction) {
417
+ const manifestRecord = isRecord(manifest) ? manifest : {};
418
+ const metadata = isRecord(manifestRecord.action_metadata) ? manifestRecord.action_metadata : {};
419
+ const official = Array.isArray(manifestRecord.supported_official_actions) ? manifestRecord.supported_official_actions.filter((value) => typeof value === "string") : [];
420
+ const custom = Array.isArray(manifestRecord.custom_actions) ? manifestRecord.custom_actions.flatMap((entry) => {
421
+ if (typeof entry === "string") return [{ name: entry, metadata: metadata[entry] }];
422
+ if (isRecord(entry) && typeof entry.name === "string") {
423
+ const entryMetadata = { ...entry };
424
+ const metadataOverride = metadata[entry.name];
425
+ if (isRecord(metadataOverride)) Object.assign(entryMetadata, metadataOverride);
426
+ return [{ name: entry.name, metadata: entryMetadata }];
427
+ }
428
+ return [];
429
+ }) : [];
430
+ const entries = [
431
+ ...official.map((name) => describeManifestAction(name, "official", metadata[name])),
432
+ ...custom.map((entry) => describeManifestAction(entry.name, "custom", entry.metadata))
433
+ ].filter((entry) => !filterAction || entry.name === filterAction);
434
+ if (filterAction && entries.length === 0) throw new Error(`Action not found in manifest: ${filterAction}`);
435
+ return entries;
436
+ }
437
+ function describeManifestAction(name, kind, metadata) {
438
+ const record = isRecord(metadata) ? metadata : {};
439
+ const schema = record.schema;
440
+ const schemaRecord = isRecord(schema) ? schema : {};
441
+ const properties = isRecord(schemaRecord.properties) ? Object.keys(schemaRecord.properties).sort() : [];
442
+ return {
443
+ name,
444
+ kind,
445
+ description: typeof record.description === "string" ? record.description : "",
446
+ fields: properties,
447
+ schema,
448
+ examples: record.examples
449
+ };
450
+ }
451
+ function isRecord(value) {
452
+ return typeof value === "object" && value !== null && !Array.isArray(value);
453
+ }
454
+ async function handleProvision({ positional, options, rawArgv }) {
455
+ applyWatcherPortOption(options);
456
+ const json = optionFlag(options, "json");
457
+ const { adapter, target } = resolveProvisionAdapter(options);
458
+ const surface = getAdapterSurface(adapter);
459
+ const rerunCommand = provisionRerunCommand(rawArgv, options, adapter, target);
460
+ const result = await surface.runwayProvision.run(target, {
461
+ json,
462
+ platform: optionString(options, "platform") ?? optionString(options, "devicePlatform") ?? (positional[0] === "runway" ? positional[1] : positional[0]) ?? "ios",
463
+ branch: optionString(options, "branch"),
464
+ defaultBranch: optionString(options, "defaultBranch"),
465
+ run: optionString(options, "run"),
466
+ cacheRoot: optionString(options, "cacheRoot"),
467
+ simulator: optionString(options, "simulator") ?? optionString(options, "device"),
468
+ runtime: optionString(options, "runtime"),
469
+ deviceType: optionString(options, "deviceType"),
470
+ slot: optionString(options, "slot"),
471
+ watcherPort: optionString(options, "watcherPort"),
472
+ runtimeDir: optionString(options, "runtimeDir"),
473
+ force: optionFlag(options, "force"),
474
+ resolveOnly: optionFlag(options, "resolveOnly"),
475
+ rerunCommand
476
+ });
477
+ if (json) {
478
+ console.log(JSON.stringify(result, null, 2));
479
+ } else if (result.status === "pass") {
480
+ const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
481
+ const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
482
+ const artifact = typeof result.artifact === "object" && result.artifact ? result.artifact : void 0;
483
+ if (result.resolveOnly) {
484
+ console.error(`\u2713 resolved ${adapter} ${result.platform ?? ""} run=${artifact?.runId ?? "unknown"} revision=${artifact?.revision ?? "unknown"} artifact=${artifact?.artifactName ?? "unknown"}`);
485
+ } else {
486
+ const action = result.skipped ? "already provisioned" : "provisioned";
487
+ console.error(`\u2713 ${action} ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "unknown"}`);
488
+ }
489
+ } else {
490
+ console.error(`\u2717 mm-harness provision: ${result.error?.message ?? "provision failed"}
491
+ Next: ${result.error?.userAction ?? rerunCommand}`);
492
+ }
493
+ return result.exitCode;
494
+ }
495
+ function resolveProvisionAdapter(options) {
496
+ const target = targetPath(options);
497
+ const adapter = optionString(options, "adapter") ?? detectAdapter(target);
498
+ if (!adapter) {
499
+ throw usageError(`could not detect the MetaMask repo type for ${target}
500
+ Next: ${ADAPTER_DETECT_NEXT}`);
501
+ }
502
+ try {
503
+ assertAdapter(adapter);
504
+ } catch (error) {
505
+ throw usageError(error instanceof Error ? error.message : String(error));
506
+ }
507
+ return { adapter, target };
508
+ }
509
+ function provisionRerunCommand(rawArgv, options, adapter, target) {
510
+ const parts = ["mm-harness", "provision"];
511
+ for (const positional of provisionPositionals(rawArgv)) parts.push(shellQuoteArg(positional));
512
+ parts.push("--adapter", adapter, "--target", shellQuote(target));
513
+ const aliases = [
514
+ ["platform", ["--platform", "--device-platform"]],
515
+ ["branch", ["--branch"]],
516
+ ["defaultBranch", ["--default-branch"]],
517
+ ["run", ["--run"]],
518
+ ["cacheRoot", ["--cache-root"]],
519
+ ["simulator", ["--simulator", "--device"]],
520
+ ["runtime", ["--runtime"]],
521
+ ["deviceType", ["--device-type"]],
522
+ ["slot", ["--slot"]],
523
+ ["watcherPort", ["--watcher-port"]],
524
+ ["runtimeDir", ["--runtime-dir"]]
525
+ ];
526
+ for (const [key, flags] of aliases) {
527
+ const found = findRawOption(rawArgv, flags);
528
+ const value = found?.value ?? optionString(options, key);
529
+ if (value) parts.push(found?.flag ?? flags[0], shellQuoteArg(value));
530
+ }
531
+ if (optionFlag(options, "force")) parts.push("--force");
532
+ if (optionFlag(options, "resolveOnly")) parts.push("--resolve-only");
533
+ if (optionFlag(options, "json")) parts.push("--json");
534
+ return parts.join(" ");
535
+ }
536
+ function provisionPositionals(rawArgv) {
537
+ const positionals = [];
538
+ const valueFlags = /* @__PURE__ */ new Set([
539
+ "--adapter",
540
+ "--target",
541
+ "--project-root",
542
+ "--platform",
543
+ "--device-platform",
544
+ "--branch",
545
+ "--default-branch",
546
+ "--run",
547
+ "--cache-root",
548
+ "--simulator",
549
+ "--device",
550
+ "--runtime",
551
+ "--device-type",
552
+ "--slot",
553
+ "--watcher-port",
554
+ "--runtime-dir"
555
+ ]);
556
+ for (let i = 0; i < rawArgv.length; i += 1) {
557
+ const arg = rawArgv[i];
558
+ if (!arg.startsWith("--")) {
559
+ positionals.push(arg);
560
+ continue;
561
+ }
562
+ const key = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
563
+ if (!arg.includes("=") && valueFlags.has(key)) i += 1;
564
+ }
565
+ return positionals;
566
+ }
567
+ function findRawOption(rawArgv, flags) {
568
+ for (let i = 0; i < rawArgv.length; i += 1) {
569
+ const arg = rawArgv[i];
570
+ for (const flag of flags) {
571
+ if (arg === flag) {
572
+ const value = rawArgv[i + 1];
573
+ return value && !value.startsWith("--") ? { flag, value } : void 0;
574
+ }
575
+ if (arg.startsWith(`${flag}=`)) return { flag, value: arg.slice(flag.length + 1) };
576
+ }
577
+ }
578
+ return void 0;
579
+ }
580
+ function shellQuoteArg(value) {
581
+ return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : shellQuote(value);
582
+ }
583
+ async function handleRuntimeHealth({ options }) {
584
+ const adapter = adapterOption(options);
585
+ if (adapter !== "extension") throw new Error("runtime-health currently applies to the extension adapter.");
586
+ const target = targetPath(options);
587
+ const cdpPort = parsePort(
588
+ optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
589
+ "runtime-health requires --cdp-port <port>."
590
+ );
591
+ const { checkExtensionRuntimeHealth, formatHealthFailure } = await import("./adapters/extension/runtime.js");
592
+ const report = await checkExtensionRuntimeHealth(target, cdpPort);
593
+ if (optionFlag(options, "json")) console.log(JSON.stringify(report, null, 2));
594
+ else if (report.status === "PASS") console.log(`PASS extension runtime cdp=${cdpPort} target=${report.targetUrl}`);
595
+ else console.error(formatHealthFailure(report, target));
596
+ return report.status === "PASS" ? 0 : 1;
597
+ }
598
+ async function handleRuntimeLaunch({ options }) {
599
+ const adapter = adapterOption(options);
600
+ const target = targetPath(options);
601
+ if (adapter !== "extension") throw new Error("runtime-launch currently applies to the extension adapter.");
602
+ const cdpPort = parsePort(
603
+ optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
604
+ "runtime-launch requires --cdp-port <port>."
605
+ );
606
+ const chromeUserDataDir = optionString(options, "chromeUserDataDir");
607
+ const artifactsDir = path.resolve(
608
+ optionString(options, "artifactsDir") ?? recipeHarnessPath(target, "extension", "runtime-launch", (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/gu, ""))
609
+ );
610
+ const startWatch = optionFlag(options, "startWatch");
611
+ const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
612
+ const command = [
613
+ "bash",
614
+ liveScript,
615
+ "--target",
616
+ target,
617
+ "--cdp-port",
618
+ String(cdpPort),
619
+ startWatch ? "--start-watch" : "--launch-existing-dist",
620
+ "--artifacts-dir",
621
+ artifactsDir
622
+ ];
623
+ if (chromeUserDataDir) command.push("--chrome-user-data-dir", chromeUserDataDir);
624
+ const remoteFlag = optionString(options, "remoteFlag");
625
+ if (remoteFlag) command.push("--remote-flag", remoteFlag);
626
+ if (!fs.existsSync(liveScript)) {
627
+ const report2 = runtimeLaunchReport("fail", {
628
+ adapter,
629
+ target,
630
+ cdpPort,
631
+ artifactsDir,
632
+ reason: "harness_live_script_missing",
633
+ fix: `Run mm-harness install --adapter extension --target ${shellQuote(target)}, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir)}`,
634
+ command
635
+ });
636
+ printRuntimeLaunchReport(report2, optionFlag(options, "json"));
637
+ return 1;
638
+ }
639
+ fs.mkdirSync(artifactsDir, { recursive: true });
640
+ const jsonMode = optionFlag(options, "json");
641
+ if (!jsonMode) {
642
+ console.error(`recipe: runtime-launch starting (cdp=${cdpPort}${startWatch ? ", clean webpack build" : ", existing dist"})`);
643
+ } else {
644
+ console.error(JSON.stringify({
645
+ schemaVersion: 1,
646
+ type: "progress",
647
+ command: "rebuild",
648
+ phase: "runtime-launch",
649
+ message: startWatch ? "clean webpack build starting" : "launching existing dist",
650
+ cdpPort
651
+ }));
652
+ }
653
+ const result = spawnSync(command[0], command.slice(1), {
654
+ cwd: target,
655
+ encoding: "utf8",
656
+ maxBuffer: 5 * 1024 * 1024,
657
+ // Human launches stream webpack/rsync/verify output live; --json keeps
658
+ // machine-readable capture for wrappers that pipe the final report.
659
+ stdio: jsonMode ? ["ignore", "pipe", "pipe"] : "inherit"
660
+ });
661
+ const summaryPath = path.join(artifactsDir, "summary.json");
662
+ const summary = readJsonIfExists(summaryPath);
663
+ if (result.status === 0 && isRecord(summary) && summary.status === "pass") {
664
+ const report2 = runtimeLaunchReport("pass", {
665
+ adapter,
666
+ target,
667
+ cdpPort,
668
+ artifactsDir,
669
+ summaryPath,
670
+ reason: "runtime_ready",
671
+ fix: "",
672
+ command
673
+ });
674
+ printRuntimeLaunchReport(report2, optionFlag(options, "json"));
675
+ return 0;
676
+ }
677
+ const launchLogPath = path.join(artifactsDir, "launch", "logs", "launch.log");
678
+ const report = runtimeLaunchReport("fail", {
679
+ adapter,
680
+ target,
681
+ cdpPort,
682
+ artifactsDir,
683
+ summaryPath: fs.existsSync(summaryPath) ? summaryPath : void 0,
684
+ launchLogPath: fs.existsSync(launchLogPath) ? launchLogPath : void 0,
685
+ reason: "runtime_launch_failed",
686
+ fix: `Read ${fs.existsSync(launchLogPath) ? launchLogPath : summaryPath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch)}`,
687
+ command,
688
+ exitCode: result.status ?? 1
689
+ });
690
+ printRuntimeLaunchReport(report, optionFlag(options, "json"));
691
+ return 1;
692
+ }
693
+ function runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch = false) {
694
+ const base = `mm-harness runtime-launch --adapter extension --target ${shellQuote(target)} --cdp-port ${cdpPort}`;
695
+ const withMode = startWatch ? `${base} --start-watch` : base;
696
+ return chromeUserDataDir ? `${withMode} --chrome-user-data-dir ${shellQuote(chromeUserDataDir)}` : withMode;
697
+ }
698
+ function shellQuote(value) {
699
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
700
+ }
701
+ function runtimeLaunchReport(status, fields) {
702
+ return {
703
+ schemaVersion: 1,
704
+ status,
705
+ ...fields
706
+ };
707
+ }
708
+ function printRuntimeLaunchReport(report, json) {
709
+ if (json) {
710
+ console.log(JSON.stringify(report, null, 2));
711
+ return;
712
+ }
713
+ const status = String(report.status).toUpperCase();
714
+ console.log(`${status} runtime-launch ${report.reason}`);
715
+ if (report.status === "fail") console.log(`Fix: ${report.fix}`);
716
+ console.log(`Artifacts: ${report.artifactsDir}`);
717
+ }
718
+ function readJsonIfExists(file) {
719
+ if (!fs.existsSync(file)) return null;
720
+ try {
721
+ return JSON.parse(fs.readFileSync(file, "utf8"));
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+ async function handleRuntimeDecision({ options }) {
727
+ applyRuntimeDirOption(options);
728
+ const adapter = adapterOption(options);
729
+ const target = targetPath(options);
730
+ if (adapter === "mobile") {
731
+ const watcherPortRaw = optionString(options, "watcherPort") ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT;
732
+ const watcherPort = watcherPortRaw === void 0 ? void 0 : parsePort(watcherPortRaw, "runtime-decision --watcher-port must be a port.");
733
+ const report2 = await decideMobileReadiness(target, {
734
+ watcherPort,
735
+ metroLog: optionString(options, "metroLog"),
736
+ platform: optionString(options, "platform") ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
737
+ record: optionFlag(options, "record"),
738
+ preflightMode: optionString(options, "preflightMode")
739
+ });
740
+ if (optionFlag(options, "json")) console.log(JSON.stringify(report2, null, 2));
741
+ else console.log(`${report2.decision} ${report2.reasonCode} \u2014 ${report2.reasons[0] ?? ""}`);
742
+ return 0;
743
+ }
744
+ if (adapter !== "extension") {
745
+ const report2 = {
746
+ schemaVersion: 1,
747
+ adapter,
748
+ target,
749
+ decision: "unknown",
750
+ clean: false,
751
+ reasonCode: "adapter-unsupported",
752
+ reasons: [`runtime-decision applies to extension and mobile adapters, not ${adapter}.`],
753
+ checks: {},
754
+ actions: []
755
+ };
756
+ if (optionFlag(options, "json")) console.log(JSON.stringify(report2, null, 2));
757
+ else console.log(`unknown adapter-unsupported \u2014 ${report2.reasons[0]}`);
758
+ return 0;
759
+ }
760
+ const cdpPortRaw = optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
761
+ const cdpPort = cdpPortRaw === void 0 ? void 0 : parsePort(cdpPortRaw, "runtime-decision --cdp-port must be a port.");
762
+ const report = await decideExtensionReadiness(target, {
763
+ cdpPort,
764
+ watchLog: optionString(options, "watchLog"),
765
+ record: optionFlag(options, "record")
766
+ });
767
+ if (optionFlag(options, "json")) console.log(JSON.stringify(report, null, 2));
768
+ else console.log(`${report.decision}${report.clean ? " (clean)" : ""} ${report.reasonCode} \u2014 ${report.reasons[0] ?? ""}`);
769
+ return 0;
770
+ }
771
+ async function handleResolveExtension({ options }) {
772
+ const adapter = adapterOption(options);
773
+ if (adapter !== "extension") throw new Error("resolve-extension currently applies to the extension adapter.");
774
+ const target = targetPath(options);
775
+ const cdpPortRaw = optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
776
+ const cdpPort = cdpPortRaw === void 0 ? void 0 : parsePort(cdpPortRaw, "resolve-extension --cdp-port must be a port.");
777
+ const result = await resolveExtensionId(target, { cdpPort });
778
+ if (optionFlag(options, "json")) console.log(JSON.stringify(result, null, 2));
779
+ else if (result.extensionId) console.log(result.extensionId);
780
+ else console.error("Could not resolve a MetaMask extension id (no dist key and no single CDP extension).");
781
+ return result.extensionId ? 0 : 1;
782
+ }
783
+ async function handleEnsureReady({ options }) {
784
+ const adapter = adapterOption(options);
785
+ if (adapter !== "extension") throw new Error("ensure-ready currently applies to the extension adapter.");
786
+ const target = targetPath(options);
787
+ const cdpPort = parsePort(
788
+ optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
789
+ "ensure-ready requires --cdp-port <port>."
790
+ );
791
+ const result = await ensureExtensionReady(target, { cdpPort });
792
+ if (optionFlag(options, "json")) console.log(JSON.stringify(result, null, 2));
793
+ else console.log(`${result.ready ? "READY" : "NOT-READY"} ${result.reasonCode} \u2014 id=${result.extensionId} homeTabs ${result.homeTabs.before}\u2192${result.homeTabs.after} (closed ${result.homeTabs.closed})`);
794
+ return result.ready ? 0 : 1;
795
+ }
796
+ const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 };
797
+ class CliError extends Error {
798
+ exitCode;
799
+ constructor(message, exitCode) {
800
+ super(message);
801
+ this.name = "CliError";
802
+ this.exitCode = exitCode;
803
+ }
804
+ }
805
+ function usageError(message) {
806
+ return new CliError(message, EXIT.usage);
807
+ }
808
+ function resolveAdapter(options) {
809
+ const target = targetPath(options);
810
+ const explicit = optionString(options, "adapter") ?? optionString(options, "platform");
811
+ const adapter = explicit ?? detectAdapter(target);
812
+ if (!adapter) {
813
+ throw usageError(`could not detect the MetaMask repo type for ${target}
814
+ Next: ${ADAPTER_DETECT_NEXT}`);
815
+ }
816
+ try {
817
+ assertAdapter(adapter);
818
+ } catch (error) {
819
+ throw usageError(error instanceof Error ? error.message : String(error));
820
+ }
821
+ return { adapter, target };
822
+ }
823
+ async function validateRecipeAdapterAware(recipe, manifest, librarySources) {
824
+ const { validateRecipeDocument, validateRecipeWithManifest } = await importRecipeProtocol();
825
+ let externalFlowIds;
826
+ if (librarySources && librarySources.length > 0) {
827
+ const harness = await importRecipeHarness();
828
+ const resolution = await harness.loadRecipeLibraries(librarySources);
829
+ externalFlowIds = new Set(resolution.flows.keys());
830
+ }
831
+ const validationOptions = externalFlowIds !== void 0 ? { externalFlowIds } : void 0;
832
+ const schema = validateRecipeDocument(recipe, validationOptions);
833
+ const withManifest = validateRecipeWithManifest(recipe, manifest, validationOptions);
834
+ const findings = [...schema.findings, ...withManifest.findings];
835
+ const errors = findings.filter((finding) => finding.severity === "error").length;
836
+ const warnings = findings.length - errors;
837
+ return { status: errors > 0 ? "invalid" : "valid", findings, summary: { errors, warnings } };
838
+ }
839
+ function countRecipeNodes(recipe) {
840
+ if (!isRecord(recipe)) return void 0;
841
+ const validate = isRecord(recipe.validate) ? recipe.validate : void 0;
842
+ const workflow = validate && isRecord(validate.workflow) ? validate.workflow : void 0;
843
+ const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : void 0;
844
+ return nodes ? Object.keys(nodes).length : void 0;
845
+ }
846
+ async function validateRunRecipeStatic(recipeArg, adapter, options) {
847
+ const recipeFile = path.resolve(recipeArg);
848
+ const empty = { recipe: void 0, recipeFile, findings: [], errorCount: 0, manifestOk: false, schemaValid: false };
849
+ if (!fs.existsSync(recipeFile)) {
850
+ return { ...empty, usageError: { code: "RECIPE_NOT_FOUND", message: `recipe not found: ${recipeFile}` } };
851
+ }
852
+ let recipe;
853
+ try {
854
+ recipe = JSON.parse(fs.readFileSync(recipeFile, "utf8"));
855
+ } catch (error) {
856
+ return {
857
+ ...empty,
858
+ usageError: {
859
+ code: "RECIPE_UNPARSEABLE",
860
+ message: `recipe is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
861
+ }
862
+ };
863
+ }
864
+ const findings = [];
865
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
866
+ let manifestOk = true;
867
+ try {
868
+ await validateManifest(manifest);
869
+ } catch (error) {
870
+ manifestOk = false;
871
+ findings.push({
872
+ severity: "error",
873
+ code: "manifest.invalid",
874
+ path: actionManifestPathOption(options, adapter),
875
+ message: error instanceof Error ? error.message : String(error)
876
+ });
877
+ }
878
+ const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
879
+ const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
880
+ findings.push(...validation.findings);
881
+ const errorCount = findings.filter((finding) => finding.severity === "error").length;
882
+ return { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid: validation.status === "valid" };
883
+ }
884
+ async function handleRunPlan(recipeArg, options) {
885
+ const json = optionFlag(options, "json");
886
+ const { adapter, target } = resolveAdapter(options);
887
+ const validated = await validateRunRecipeStatic(recipeArg, adapter, options);
888
+ if (validated.usageError) {
889
+ return emitPlanUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
890
+ }
891
+ const { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid } = validated;
892
+ const status = errorCount === 0 ? "pass" : "fail";
893
+ const nodeCount = countRecipeNodes(recipe);
894
+ const artifactsDir = optionString(options, "artifactsDir");
895
+ const plan = [
896
+ { step: "resolve.recipe", confidence: "static", status: "ok", detail: recipeFile },
897
+ { step: "resolve.adapter", confidence: "static", status: "ok", detail: adapter },
898
+ {
899
+ step: "resolve.artifactsDir",
900
+ confidence: "static",
901
+ status: "ok",
902
+ detail: artifactsDir ? path.resolve(artifactsDir) : "(resolved to the slot artifacts dir at run time)"
903
+ },
904
+ {
905
+ step: "validate.manifest",
906
+ confidence: "static",
907
+ status: manifestOk ? "ok" : "error",
908
+ detail: manifestOk ? "action manifest is well-formed" : "action manifest failed validation"
909
+ },
910
+ {
911
+ step: "validate.schema",
912
+ confidence: "static",
913
+ status: manifestOk && schemaValid ? "ok" : "error",
914
+ detail: "recipe document schema + action existence/platform vs the adapter manifest"
915
+ },
916
+ {
917
+ step: "fixture.file",
918
+ confidence: "static",
919
+ status: "ok",
920
+ detail: fs.existsSync(walletFixturePath(target)) ? `present: ${walletFixturePath(target)}` : `absent: ${walletFixturePath(target)} (fixtures set to seed)`
921
+ },
922
+ {
923
+ step: "overlay.ensure",
924
+ confidence: "conditional",
925
+ status: "planned",
926
+ detail: "would auto-ensure the runtime overlay if missing (install phase)"
927
+ },
928
+ ...adapter === "core" ? [] : [
929
+ {
930
+ step: "launch.app",
931
+ confidence: "conditional",
932
+ status: "planned",
933
+ detail: "would launch/attach the app + heal transport (Metro/Chrome/CDP) before executing"
934
+ }
935
+ ],
936
+ {
937
+ step: "execute.nodes",
938
+ confidence: "conditional",
939
+ status: "planned",
940
+ detail: nodeCount === void 0 ? "would execute the recipe nodes" : `would execute ${nodeCount} recipe node(s)`
941
+ }
942
+ ];
943
+ if (json) {
944
+ const payload = { schemaVersion: 1, command: "run", mode: "plan", status, adapter, recipe: recipeFile, findings, plan };
945
+ if (status === "fail") payload.error = { code: "RECIPE_VALIDATION_FAILED", message: `recipe validation found ${errorCount} error(s)` };
946
+ console.log(JSON.stringify(payload, null, 2));
947
+ } else {
948
+ console.log(`plan ${status} \u2014 ${adapter} \u2014 ${recipeFile}`);
949
+ for (const item of plan) {
950
+ const mark = item.status === "error" ? "\u2717" : item.status === "ok" ? "\u2713" : "\xB7";
951
+ console.log(` ${mark} [${item.confidence}] ${item.step}: ${item.detail}`);
952
+ }
953
+ if (findings.length) {
954
+ console.log("findings:");
955
+ for (const finding of findings) {
956
+ console.log(` ${finding.severity === "error" ? "\u2717" : "\u26A0"} ${finding.code} ${finding.path} \u2014 ${finding.message}`);
957
+ }
958
+ }
959
+ }
960
+ return status === "pass" ? EXIT.ok : EXIT.validation;
961
+ }
962
+ function emitPlanUsageError(json, adapter, recipeFile, code, message) {
963
+ if (json) {
964
+ console.log(
965
+ JSON.stringify(
966
+ { schemaVersion: 1, command: "run", mode: "plan", status: "fail", adapter, recipe: recipeFile, error: { code, message } },
967
+ null,
968
+ 2
969
+ )
970
+ );
971
+ } else {
972
+ console.error(`\u2717 run --plan: ${message}`);
973
+ }
974
+ return EXIT.usage;
975
+ }
976
+ async function handleStop(argv) {
977
+ const { options } = parseArgs(argv, "stop");
978
+ const json = optionFlag(options, "json");
979
+ const { adapter, target } = resolveAdapter(options);
980
+ const surface = getAdapterSurface(adapter);
981
+ const explicitPort = optionString(options, "port") ?? optionString(options, "watcherPort");
982
+ if (explicitPort) {
983
+ process.env.WATCHER_PORT = explicitPort;
984
+ process.env.METRO_PORT = explicitPort;
985
+ } else {
986
+ surface.resolveSlotPorts(target);
987
+ }
988
+ const stop = surface.devServer.stop(target);
989
+ if (stop.kind === "headless") {
990
+ return usageOut(json, "stop", stop.message, stop.userAction);
991
+ }
992
+ if (json) {
993
+ console.log(
994
+ JSON.stringify(
995
+ {
996
+ schemaVersion: 1,
997
+ command: "stop",
998
+ adapter,
999
+ target,
1000
+ status: stop.status === 0 ? "pass" : "fail",
1001
+ ...stop.signalled !== void 0 ? { signalled: stop.signalled } : {},
1002
+ exitCode: stop.status,
1003
+ ...stop.output ? { output: stop.output } : {}
1004
+ },
1005
+ null,
1006
+ 2
1007
+ )
1008
+ );
1009
+ } else {
1010
+ if (stop.output) process.stderr.write(`${stop.output}
1011
+ `);
1012
+ console.error(`${color(stop.status === 0 ? "ok" : "err", stop.status === 0 ? "\u2713" : "\u2717")} ${stop.summary}`);
1013
+ }
1014
+ return stop.status;
1015
+ }
1016
+ async function handleCall(argv) {
1017
+ if (argv.length > 0 && argv[0].startsWith("--")) {
1018
+ const message = "call requires <action> first: mm-harness call <action> [--arg k=v ...] [flags]";
1019
+ console.error(message);
1020
+ return EXIT.usage;
1021
+ }
1022
+ const { action: shortName, args, rest } = parseCallArgs(argv);
1023
+ const { options } = parseArgs(rest, "call");
1024
+ const json = optionFlag(options, "json");
1025
+ if (!shortName) {
1026
+ let example = "mm-harness call <action>";
1027
+ let discovery = "mm-harness actions";
1028
+ try {
1029
+ const { adapter: adapter2 } = resolveAdapter(options);
1030
+ const manifest2 = loadActionManifest(adapter2, optionString(options, "actionManifest"));
1031
+ const { getRecipeActionManifestActionNames: getRecipeActionManifestActionNames2 } = await importRecipeProtocol();
1032
+ const exampleAction = pickCallExampleAction(getRecipeActionManifestActionNames2(manifest2));
1033
+ example = `mm-harness call ${exampleAction} --adapter ${adapter2}`;
1034
+ discovery = `mm-harness actions --adapter ${adapter2}`;
1035
+ } catch {
1036
+ }
1037
+ const message = `call requires <action>. Example: ${example}`;
1038
+ const userAction = `${example} # see the vocabulary: ${discovery}`;
1039
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", error: { code: "USAGE", message, userAction } }, null, 2));
1040
+ else console.error(`${message}
1041
+ See the vocabulary: ${discovery}`);
1042
+ return EXIT.usage;
1043
+ }
1044
+ const { adapter, target } = resolveAdapter(options);
1045
+ if (recipeRunning(target)) {
1046
+ const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
1047
+ if (json) {
1048
+ console.log(JSON.stringify({ schemaVersion: 1, command: "call", status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
1049
+ } else {
1050
+ console.error(`\u2717 mm-harness call: ${msg}`);
1051
+ }
1052
+ return EXIT.bounded;
1053
+ }
1054
+ const actionManifestOverride = optionString(options, "actionManifest");
1055
+ const manifest = loadActionManifest(adapter, actionManifestOverride);
1056
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
1057
+ const names = getRecipeActionManifestActionNames(manifest);
1058
+ const resolution = resolveActionName(shortName, names);
1059
+ if (resolution.status === "unknown") {
1060
+ const message = `\u2717 call: unknown action "${shortName}" for the ${adapter} adapter.
1061
+ See the vocabulary: mm-harness actions --adapter ${adapter} --json`;
1062
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_UNKNOWN", message } }, null, 2));
1063
+ else console.error(message);
1064
+ return EXIT.usage;
1065
+ }
1066
+ if (resolution.status === "ambiguous") {
1067
+ const message = `\u2717 call: "${shortName}" is ambiguous: ${resolution.candidates.join(", ")} \u2014 use the full name.`;
1068
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, error: { code: "ACTION_AMBIGUOUS", message, candidates: resolution.candidates } }, null, 2));
1069
+ else console.error(message);
1070
+ return EXIT.usage;
1071
+ }
1072
+ const resolvedAction = resolution.resolved;
1073
+ const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
1074
+ const validation = await validateRecipeAdapterAware(recipe, manifest);
1075
+ if (validation.status === "invalid") {
1076
+ const message = `\u2717 call ${resolvedAction}: recipe validation failed`;
1077
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, action: shortName, resolvedAction, args, findings: validation.findings, error: { code: "RECIPE_VALIDATION_FAILED", message } }, null, 2));
1078
+ else {
1079
+ console.error(message);
1080
+ for (const finding of validation.findings) console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
1081
+ }
1082
+ return EXIT.validation;
1083
+ }
1084
+ const prepared = await prepareHeal(adapter, target, options, json);
1085
+ if (typeof prepared === "number") return prepared;
1086
+ const { state, heal } = prepared;
1087
+ const scratch = await mkdtemp(path.join(os.tmpdir(), "mm-harness-call-"));
1088
+ const recipeFile = path.join(scratch, "call.recipe.json");
1089
+ fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}
1090
+ `);
1091
+ const artifactsDir = optionString(options, "artifactsDir") ?? path.join(scratch, "artifacts");
1092
+ const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
1093
+ const callRuntimeOptions = {
1094
+ ...runtimeOptionsFromCli(options),
1095
+ ...librarySources ? { librarySources } : {},
1096
+ stdoutIsMachineContract: json
1097
+ };
1098
+ const { result, violation } = await executeWithHealBounds(
1099
+ () => runRecipe(adapter, recipeFile, artifactsDir, target, actionManifestOverride, callRuntimeOptions),
1100
+ adapter,
1101
+ target,
1102
+ heal,
1103
+ state
1104
+ );
1105
+ if (violation !== null) return emitHealViolation(json, "call", result, violation, state, adapter);
1106
+ if (json) {
1107
+ console.log(
1108
+ JSON.stringify(
1109
+ {
1110
+ schemaVersion: 1,
1111
+ command: "call",
1112
+ adapter,
1113
+ action: shortName,
1114
+ resolvedAction,
1115
+ args,
1116
+ status: result.status,
1117
+ summaryPath: result.summaryPath,
1118
+ tracePath: result.tracePath,
1119
+ artifactManifestPath: result.artifactManifestPath,
1120
+ recovered: state.recovered,
1121
+ mutations: state.mutations,
1122
+ exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime
1123
+ },
1124
+ null,
1125
+ 2
1126
+ )
1127
+ );
1128
+ } else {
1129
+ console.log(`call ${resolvedAction}: ${result.status}
1130
+ Artifacts: ${result.artifactManifestPath}`);
1131
+ }
1132
+ return result.status === "pass" ? EXIT.ok : EXIT.runtime;
1133
+ }
1134
+ function parseCallArgs(argv) {
1135
+ const args = {};
1136
+ const rest = [];
1137
+ let action;
1138
+ for (let i = 0; i < argv.length; i += 1) {
1139
+ const arg = argv[i];
1140
+ if (arg === "--arg" || arg.startsWith("--arg=")) {
1141
+ const pair = arg === "--arg" ? argv[i += 1] : arg.slice("--arg=".length);
1142
+ if (pair === void 0) throw usageError("--arg requires k=v.");
1143
+ const eq = pair.indexOf("=");
1144
+ if (eq === -1) throw usageError(`--arg must be k=v: ${pair}`);
1145
+ args[pair.slice(0, eq)] = pair.slice(eq + 1);
1146
+ continue;
1147
+ }
1148
+ if (!arg.startsWith("--") && action === void 0) {
1149
+ action = arg;
1150
+ continue;
1151
+ }
1152
+ rest.push(arg);
1153
+ }
1154
+ return { action, args, rest };
1155
+ }
1156
+ function resolveActionName(shortName, names) {
1157
+ if (names.includes(shortName)) return { status: "ok", resolved: shortName, candidates: [shortName] };
1158
+ const finalSegment = (name) => name.split(".").pop() ?? name;
1159
+ const exactSegment = names.filter((name) => finalSegment(name) === shortName);
1160
+ const tier = exactSegment.length > 0 ? exactSegment : names.filter((name) => finalSegment(name).includes(shortName));
1161
+ if (tier.length === 1) return { status: "ok", resolved: tier[0], candidates: tier };
1162
+ if (tier.length > 1) return { status: "ambiguous", resolved: "", candidates: tier };
1163
+ return { status: "unknown", resolved: "", candidates: [] };
1164
+ }
1165
+ function pickCallExampleAction(names) {
1166
+ const walletish = names.find((name) => /wallet|unlock/u.test(name));
1167
+ if (walletish) return walletish;
1168
+ if (names.includes("command")) return "command";
1169
+ return names[0] ?? "command";
1170
+ }
1171
+ function synthesizeOneNodeRecipe(action, args) {
1172
+ return {
1173
+ schema_version: 1,
1174
+ title: `mm-harness call ${action}`,
1175
+ description: `Ad-hoc single-action execution of ${action} via the real engine path (mm-harness call).`,
1176
+ validate: {
1177
+ workflow: {
1178
+ entry: "call",
1179
+ nodes: {
1180
+ call: { action, ...args, next: "done", intent: `Call ${action} in isolation` },
1181
+ done: { action: "end", status: "pass" }
1182
+ }
1183
+ }
1184
+ }
1185
+ };
1186
+ }
1187
+ async function runOneNode(adapter, action, args, target, actionManifest) {
1188
+ const manifest = loadActionManifest(adapter, actionManifest);
1189
+ const recipe = synthesizeOneNodeRecipe(action, args);
1190
+ const validation = await validateRecipeAdapterAware(recipe, manifest);
1191
+ if (validation.status === "invalid") return { status: "fail" };
1192
+ const scratch = await mkdtemp(path.join(os.tmpdir(), "mm-harness-fixtures-"));
1193
+ const recipeFile = path.join(scratch, "set.recipe.json");
1194
+ fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}
1195
+ `);
1196
+ const result = await runRecipe(adapter, recipeFile, path.join(scratch, "artifacts"), target, actionManifest, {
1197
+ librarySources: await resolveMetaMaskLibrarySources(void 0)
1198
+ });
1199
+ return { status: result.status === "pass" ? "pass" : "fail" };
1200
+ }
1201
+ async function prepareHeal(adapter, target, options, json) {
1202
+ if (recipeRunning(target)) {
1203
+ const msg = "a recipe is currently running \u2014 refusing to start while another recipe executes.";
1204
+ if (json) {
1205
+ console.log(JSON.stringify({ schemaVersion: 1, status: "fail", recoverable: false, error: { code: "RECIPE_RUNNING", message: msg } }, null, 2));
1206
+ } else {
1207
+ console.error(`\u2717 mm-harness: ${msg}`);
1208
+ }
1209
+ return EXIT.bounded;
1210
+ }
1211
+ const healValue = optionString(options, "heal");
1212
+ const healOpts = {};
1213
+ if (healValue !== void 0) healOpts.heal = healValue;
1214
+ const heal = parseHeal(healOpts, "infra-only");
1215
+ if (typeof heal !== "string") {
1216
+ console.error(heal.error);
1217
+ return EXIT.usage;
1218
+ }
1219
+ const state = newHealState();
1220
+ const ensured = await ensureOverlay(adapter, target, heal, state, json);
1221
+ if (!ensured.ok) {
1222
+ if (json) {
1223
+ console.log(
1224
+ JSON.stringify(
1225
+ { schemaVersion: 1, status: "fail", recoverable: false, mutations: state.mutations, error: { code: "OVERLAY_INSTALL_FAILED", message: ensured.error } },
1226
+ null,
1227
+ 2
1228
+ )
1229
+ );
1230
+ } else {
1231
+ console.error(`\u2717 overlay auto-ensure failed: ${ensured.error}`);
1232
+ }
1233
+ return EXIT.infra;
1234
+ }
1235
+ return { state, heal };
1236
+ }
1237
+ const RUN_RECOVERY_CODE = {
1238
+ mobile: "metro.restarted",
1239
+ extension: "chrome.reopened",
1240
+ core: "runtime.reset"
1241
+ };
1242
+ function readRunFailureText(result) {
1243
+ try {
1244
+ const trace = JSON.parse(fs.readFileSync(result.tracePath, "utf8"));
1245
+ const entries = Array.isArray(trace.entries) ? trace.entries : [];
1246
+ return entries.filter((entry) => entry && entry.ok === false && typeof entry.error === "string").map((entry) => entry.error).join("\n").trim();
1247
+ } catch {
1248
+ return "";
1249
+ }
1250
+ }
1251
+ async function executeWithHealBounds(exec, adapter, target, heal, state) {
1252
+ let result = await exec();
1253
+ for (; ; ) {
1254
+ if (result.status === "pass") return { result, violation: null };
1255
+ const violation = checkHealBounds(target, readRunFailureText(result), state);
1256
+ if (violation !== null) return { result, violation };
1257
+ if (heal === "off") return { result, violation: null };
1258
+ const recoveryCode = RUN_RECOVERY_CODE[adapter];
1259
+ state.attemptedRecoveries.push(recoveryCode);
1260
+ result = await exec();
1261
+ if (result.status === "pass") {
1262
+ state.recovered.push(recoveryCode);
1263
+ return { result, violation: null };
1264
+ }
1265
+ }
1266
+ }
1267
+ async function handleCompletionCandidates(argv) {
1268
+ const kind = argv[0];
1269
+ const { options } = parseArgs(argv.slice(1), "completion-candidates");
1270
+ const json = optionFlag(options, "json");
1271
+ if (kind !== "actions" && kind !== "flows") {
1272
+ console.error("completion-candidates requires <actions|flows>.");
1273
+ return EXIT.usage;
1274
+ }
1275
+ const target = targetPath(options);
1276
+ let adapter;
1277
+ let candidates;
1278
+ if (kind === "actions") {
1279
+ adapter = resolveAdapter(options).adapter;
1280
+ candidates = readFreshCandidates(target, "actions");
1281
+ if (!candidates) {
1282
+ const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
1283
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
1284
+ candidates = getRecipeActionManifestActionNames(manifest);
1285
+ writeCompletionCandidates(target, "actions", candidates);
1286
+ }
1287
+ } else {
1288
+ candidates = readFreshCandidates(target, "flows");
1289
+ if (!candidates) {
1290
+ const sources = await resolveMetaMaskLibrarySources(void 0).catch(() => void 0);
1291
+ if (!sources) {
1292
+ candidates = [];
1293
+ } else {
1294
+ const harness = await importRecipeHarness();
1295
+ const resolution = await harness.loadRecipeLibraries(sources);
1296
+ candidates = [...resolution.flows.keys()].sort();
1297
+ writeCompletionCandidates(target, "flows", candidates);
1298
+ }
1299
+ }
1300
+ }
1301
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, kind, adapter, candidates }, null, 2));
1302
+ else for (const candidate of candidates) console.log(candidate);
1303
+ return EXIT.ok;
1304
+ }
1305
+ async function handleRun({ positional, options }) {
1306
+ const targetRecipe = positional[0];
1307
+ if (!targetRecipe) throw usageError("run requires <recipe.json>.");
1308
+ if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
1309
+ const adapter = adapterOption(options);
1310
+ const json = optionFlag(options, "json");
1311
+ const target = targetPath(options);
1312
+ const artifactsDir = requiredOption(options, "artifactsDir", "run requires --artifacts-dir <dir>.");
1313
+ const prepared = await prepareHeal(adapter, target, options, json);
1314
+ if (typeof prepared === "number") return prepared;
1315
+ const { state, heal } = prepared;
1316
+ const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
1317
+ if (validated.usageError) {
1318
+ return emitRunUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
1319
+ }
1320
+ if (validated.errorCount > 0) {
1321
+ return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
1322
+ }
1323
+ const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
1324
+ const runtimeOptions = {
1325
+ ...runtimeOptionsFromCli(options),
1326
+ ...librarySources ? { librarySources } : {},
1327
+ stdoutIsMachineContract: json
1328
+ };
1329
+ const { result, violation } = await executeWithHealBounds(
1330
+ () => runRecipe(adapter, targetRecipe, artifactsDir, target, optionString(options, "actionManifest"), runtimeOptions),
1331
+ adapter,
1332
+ target,
1333
+ heal,
1334
+ state
1335
+ );
1336
+ if (violation !== null) return emitHealViolation(json, "run", result, violation, state, adapter);
1337
+ const exitCode = result.status === "pass" ? EXIT.ok : EXIT.runtime;
1338
+ if (json) {
1339
+ console.log(
1340
+ JSON.stringify(
1341
+ {
1342
+ schemaVersion: 1,
1343
+ command: "run",
1344
+ adapter,
1345
+ status: result.status,
1346
+ exitCode,
1347
+ recovered: state.recovered,
1348
+ mutations: state.mutations,
1349
+ result
1350
+ },
1351
+ null,
1352
+ 2
1353
+ )
1354
+ );
1355
+ } else {
1356
+ console.log(`MetaMask recipe run: ${result.status}
1357
+ Artifacts: ${result.artifactManifestPath}`);
1358
+ }
1359
+ return exitCode;
1360
+ }
1361
+ function emitRunUsageError(json, adapter, recipeFile, code, message) {
1362
+ if (json) {
1363
+ console.log(
1364
+ JSON.stringify(
1365
+ { schemaVersion: 1, command: "run", adapter, status: "fail", exitCode: EXIT.usage, recipe: recipeFile, error: { code, message } },
1366
+ null,
1367
+ 2
1368
+ )
1369
+ );
1370
+ } else {
1371
+ console.error(`\u2717 run: ${message}`);
1372
+ }
1373
+ return EXIT.usage;
1374
+ }
1375
+ function emitRunValidationError(json, adapter, recipeFile, findings, errorCount) {
1376
+ const message = `recipe validation found ${errorCount} error(s)`;
1377
+ if (json) {
1378
+ console.log(
1379
+ JSON.stringify(
1380
+ {
1381
+ schemaVersion: 1,
1382
+ command: "run",
1383
+ adapter,
1384
+ status: "fail",
1385
+ exitCode: EXIT.validation,
1386
+ recovered: [],
1387
+ mutations: [],
1388
+ recipe: recipeFile,
1389
+ findings,
1390
+ error: { code: "RECIPE_VALIDATION_FAILED", message }
1391
+ },
1392
+ null,
1393
+ 2
1394
+ )
1395
+ );
1396
+ } else {
1397
+ console.error(`\u2717 run: ${message}`);
1398
+ for (const finding of findings) {
1399
+ if (finding.severity === "error") console.error(` ${finding.code} ${finding.path} \u2014 ${finding.message}`);
1400
+ }
1401
+ }
1402
+ return EXIT.validation;
1403
+ }
1404
+ function emitHealViolation(json, command, result, violation, state, adapter) {
1405
+ const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? 'set MM_TEST_ACCOUNT_ADDRESS=<0x\u2026> in env, or add "account": "<0x\u2026>" to the node block in the recipe' : violation.userAction;
1406
+ if (json) {
1407
+ console.log(
1408
+ JSON.stringify(
1409
+ {
1410
+ schemaVersion: 1,
1411
+ command,
1412
+ status: "fail",
1413
+ recoverable: false,
1414
+ recovered: state.recovered,
1415
+ mutations: state.mutations,
1416
+ attemptedRecoveries: state.attemptedRecoveries,
1417
+ summaryPath: result.summaryPath,
1418
+ tracePath: result.tracePath,
1419
+ artifactManifestPath: result.artifactManifestPath,
1420
+ exitCode: violation.exitCode,
1421
+ error: {
1422
+ code: violation.code,
1423
+ message: violation.message,
1424
+ retryable: false,
1425
+ userAction: userAction ?? null,
1426
+ originalError: violation.originalError ?? null
1427
+ }
1428
+ },
1429
+ null,
1430
+ 2
1431
+ )
1432
+ );
1433
+ } else {
1434
+ console.error(
1435
+ `\u2717 mm-harness ${command}: ${violation.message}` + (violation.originalError ? `
1436
+ --- original failure ---
1437
+ ${violation.originalError}` : "") + (userAction ? `
1438
+ Next: ${userAction}` : "")
1439
+ );
1440
+ }
1441
+ return violation.exitCode;
1442
+ }
1443
+ async function handleSelfTest({ options }) {
1444
+ const result = await runSelfTest(options);
1445
+ if (optionFlag(options, "json")) console.log(JSON.stringify(result, null, 2));
1446
+ else console.log(`MetaMask runner self-test: ${result.status}
1447
+ Artifacts: ${result.artifactsDir}`);
1448
+ return result.status === "pass" ? 0 : 1;
1449
+ }
1450
+ function runtimeOptionsFromCli(options) {
1451
+ const recordVideo = options.recordVideo;
1452
+ return {
1453
+ cdpPort: optionString(options, "cdpPort"),
1454
+ watcherPort: optionString(options, "watcherPort") ?? optionString(options, "metroPort"),
1455
+ launchExistingDist: optionFlag(options, "launchExistingDist"),
1456
+ slot: optionString(options, "slot"),
1457
+ validationRuntimeDir: optionString(options, "validationRuntimeDir"),
1458
+ recordVideo: recordVideo === "full-run" ? "full-run" : false
1459
+ };
1460
+ }
1461
+ function parsePort(value, errorMessage) {
1462
+ const port = Number(value);
1463
+ if (!Number.isInteger(port) || port <= 0) throw usageError(errorMessage);
1464
+ return port;
1465
+ }
1466
+ async function resolveMetaMaskLibrarySources(libraryEntry) {
1467
+ const harness = await importRecipeHarness();
1468
+ if (typeof harness.resolveRecipeLibrarySources !== "function") {
1469
+ if (libraryEntry) {
1470
+ throw usageError(
1471
+ "--library requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support / resolveRecipeLibrarySources). The pinned 0.3.0 lacks it, and npm 0.3.2 still does not export it \u2014 recipe-library support is PENDING PUBLISH from farmslot."
1472
+ );
1473
+ }
1474
+ return void 0;
1475
+ }
1476
+ const sources = await harness.resolveRecipeLibrarySources(
1477
+ libraryEntry ? { cliEntries: [libraryEntry] } : void 0
1478
+ );
1479
+ sources.push({ name: "metamask", root: path.join(runnerDir, "library") });
1480
+ return sources;
1481
+ }
1482
+ function serializeLibrarySources(sources) {
1483
+ return sources.map((source) => source.name ? `${source.name}=${source.root}` : source.root).join(":");
1484
+ }
1485
+ function flowSourceTier(source, index, total) {
1486
+ if (index === total - 1 && source.name === "metamask") return "canonical";
1487
+ if (source.name === "personal") return "personal";
1488
+ return "team";
1489
+ }
1490
+ const FLOWS_SUBCOMMANDS = ["list", "promote"];
1491
+ function stripTargetFlag(argv) {
1492
+ const out = [];
1493
+ for (let i = 0; i < argv.length; i += 1) {
1494
+ const arg = argv[i];
1495
+ if (arg === "--target") {
1496
+ i += 1;
1497
+ continue;
1498
+ }
1499
+ if (arg.startsWith("--target=")) continue;
1500
+ out.push(arg);
1501
+ }
1502
+ return out;
1503
+ }
1504
+ async function handleFlows(argv) {
1505
+ const sources = await resolveMetaMaskLibrarySources(void 0);
1506
+ if (!sources) {
1507
+ throw usageError(
1508
+ "flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support; flows resolve personal > team > canonical). Next: update the dependency and run yarn install."
1509
+ );
1510
+ }
1511
+ const forwarded = stripTargetFlag(argv);
1512
+ const jsonMode = forwarded.includes("--json");
1513
+ if (!jsonMode) {
1514
+ console.error(color("label", "flows resolve by precedence (highest wins; local shadows canonical):"));
1515
+ sources.forEach((source, index) => {
1516
+ const name = source.name ?? path.basename(source.root);
1517
+ console.error(` ${index + 1}. ${color("cmd", name)} ${color("dim", `[${flowSourceTier(source, index, sources.length)}]`)}`);
1518
+ });
1519
+ }
1520
+ const withSubcommand = FLOWS_SUBCOMMANDS.includes(forwarded[0]) ? forwarded : ["list", ...forwarded];
1521
+ const { runRecipeHarnessCli } = await importRecipeHarnessCli();
1522
+ const previousLibraryPath = process.env.RECIPE_LIBRARY_PATH;
1523
+ process.env.RECIPE_LIBRARY_PATH = serializeLibrarySources(sources);
1524
+ try {
1525
+ await runRecipeHarnessCli(["flows", ...withSubcommand]);
1526
+ } finally {
1527
+ restoreEnv("RECIPE_LIBRARY_PATH", previousLibraryPath);
1528
+ }
1529
+ return 0;
1530
+ }
1531
+ async function main(argv) {
1532
+ const command = argv[0];
1533
+ if (!command || command === "-h" || command === "--help") {
1534
+ usage();
1535
+ return command ? 0 : 2;
1536
+ }
1537
+ if (command === "flows") return handleFlows(argv.slice(1));
1538
+ if (command === "call") return handleCall(argv.slice(1));
1539
+ if (command === "completion-candidates") return handleCompletionCandidates(argv.slice(1));
1540
+ if (OVERLAY_COMMANDS.includes(command)) {
1541
+ const code = await handleHarness(argv);
1542
+ if (code === 0 && command === "install") {
1543
+ try {
1544
+ invalidateCompletionCache(targetPath(parseArgs(argv.slice(1), command).options));
1545
+ } catch {
1546
+ }
1547
+ }
1548
+ return code;
1549
+ }
1550
+ if (command === "launch") return handleLaunch(argv.slice(1));
1551
+ if (command === "stop") return handleStop(argv.slice(1));
1552
+ if (command === "logs") return handleLogs(argv.slice(1));
1553
+ if (command === "debug") return handleDebug(argv.slice(1));
1554
+ if (command === "fixtures") return handleFixtures(argv.slice(1), { runOneNode });
1555
+ const handler = COMMANDS[command];
1556
+ if (!handler) throw new Error(`Unknown command: ${command}`);
1557
+ return handler(parseArgs(argv.slice(1), command));
1558
+ }
1559
+ if (!globalThis.__MM_HARNESS_WRAPPER__) {
1560
+ try {
1561
+ process.exit(await main(process.argv.slice(2)));
1562
+ } catch (error) {
1563
+ console.error(error instanceof Error ? error.message : String(error));
1564
+ process.exit(
1565
+ error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : 1
1566
+ );
1567
+ }
1568
+ }
1569
+ export {
1570
+ main
1571
+ };