@deeeed/metamask-harness 0.2.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 (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,1820 @@
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
+
7
+ import { createDoctorReport, renderRuntimeContext } from './doctor.ts';
8
+ import {
9
+ invalidateCompletionCache,
10
+ readFreshCandidates,
11
+ writeCompletionCandidates,
12
+ } from './completions-cache.ts';
13
+ import { detectAdapter, handleHarness } from './harness.ts';
14
+ import { handleLaunch } from './commands/launch.ts';
15
+ import { handleLogs } from './commands/logs.ts';
16
+ import { handleDebug } from './commands/debug.ts';
17
+ import { handleFixtures } from './commands/fixtures.ts';
18
+ import {
19
+ ensureOverlay,
20
+ newHealState,
21
+ parseHeal,
22
+ recipeRunning,
23
+ checkHealBounds,
24
+ } from './heal-bounds.ts';
25
+ import type { HealPolicy, HealState, HealBoundViolation } from './heal-bounds.ts';
26
+ import { ensureExtensionReady } from '../../orchestration/extension/ensure-ready.ts';
27
+ import { resolveExtensionId } from '../../orchestration/extension/extension-id.ts';
28
+ import { decideExtensionReadiness } from '../../orchestration/extension/runtime-decision.ts';
29
+ import { decideMobileReadiness } from '../../orchestration/mobile/runtime-decision.ts';
30
+ // NOTE: extension-runtime.ts loads the recipe harness at module scope, so it
31
+ // is imported LAZILY (dynamic import) only inside the handlers that drive a live
32
+ // runtime. Static-import it here and every command — manifest, doctor,
33
+ // runtime-decision (no --cdp-port) — would fail to load on a checkout without
34
+ // local harness packages built. Keep this lazy.
35
+ import { loadActionManifest, validateManifest } from './manifest.ts';
36
+ import {
37
+ assertAdapter,
38
+ importRecipeHarness,
39
+ importRecipeHarnessCli,
40
+ importRecipeProtocol,
41
+ manifestPath,
42
+ recipeHarnessPath,
43
+ recipePath,
44
+ runnerDir,
45
+ walletFixturePath,
46
+ } from './paths.ts';
47
+ import { captureHelperSupportsRecordSessionSnapshots } from './recording-target.ts';
48
+ import { startRecipeRecording, stopRecipeRecording } from './run-recording.ts';
49
+ // runner.ts → adapters.ts → library/actions/extension/platform/cdp.mjs does a
50
+ // top-level harness import, so it is imported LAZILY inside
51
+ // runRecipe only. Keeping it static would load the recipe harness for every
52
+ // command (manifest, doctor, runtime-decision), defeating their independence.
53
+ import type { RecipeRunResult } from '@farmslot/recipe-harness';
54
+ import type {
55
+ RecipeActionManifestDocument,
56
+ RecipeValidationFinding,
57
+ RecipeValidationResult,
58
+ } from '@farmslot/protocol';
59
+ import type { MetaMaskRecipeAdapter } from './types.ts';
60
+ import { usageOut } from './commands/shared.ts';
61
+
62
+ type CliOptionValue = string | boolean;
63
+ type CliOptions = Record<string, CliOptionValue>;
64
+
65
+ interface ParsedArgs {
66
+ positional: string[];
67
+ options: CliOptions;
68
+ }
69
+
70
+ interface RuntimeOptions {
71
+ cdpPort?: string;
72
+ watcherPort?: string;
73
+ launchExistingDist?: boolean;
74
+ startWatch?: boolean;
75
+ skipExtensionRuntimePrepare?: boolean;
76
+ slot?: string;
77
+ validationRuntimeDir?: string;
78
+ recordVideo?: false | 'full-run';
79
+ librarySources?: MetaMaskLibrarySource[];
80
+ // --json mode: stdout is the machine contract, so the engine logger (e.g. its
81
+ // "Recipe libraries: …" resolution line) must be kept off stdout.
82
+ stdoutIsMachineContract?: boolean;
83
+ }
84
+
85
+ interface MetaMaskLibrarySource {
86
+ name?: string;
87
+ root: string;
88
+ }
89
+
90
+ // The installed @farmslot/recipe-harness may predate recipe libraries; probe
91
+ // for the API instead of assuming it so every other command keeps working.
92
+ interface RecipeLibraryCapableHarness {
93
+ resolveRecipeLibrarySources?: (options?: {
94
+ cliEntries?: string[];
95
+ }) => Promise<MetaMaskLibrarySource[]>;
96
+ }
97
+
98
+ const COMMANDS: Record<string, (args: ParsedArgs) => Promise<number>> = {
99
+ manifest: handleManifest,
100
+ actions: handleActions,
101
+ doctor: handleDoctor,
102
+ 'runtime-health': handleRuntimeHealth,
103
+ 'runtime-decision': handleRuntimeDecision,
104
+ 'runtime-launch': handleRuntimeLaunch,
105
+ 'resolve-extension': handleResolveExtension,
106
+ 'ensure-ready': handleEnsureReady,
107
+ run: handleRun,
108
+ 'self-test': handleSelfTest,
109
+ };
110
+
111
+ // Top-level runtime-overlay commands (the tool installs a runtime overlay).
112
+ // Top-level overlay commands route to handleHarness; the `harness <sub>`
113
+ // subcommand form has been removed from the CLI surface.
114
+ const OVERLAY_COMMANDS: readonly string[] = ['install', 'verify', 'cleanup', 'live'];
115
+
116
+ function usage() {
117
+ // Help is organized by the harness mental model: mm-harness IS the tool; a
118
+ // recipe is one thing it runs; the "runtime overlay" is what it installs into a
119
+ // checkout. End state = one bin (mm-harness); help presents only that surface.
120
+ // Every command ships one copy-pasteable example (cold-start discoverability;
121
+ // zero-flag happy path where possible).
122
+ console.error(`mm-harness — the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay.
123
+ Run it from inside a MetaMask checkout; the platform (mobile | extension | core) is auto-detected.
124
+ Grammar: mm-harness <command> [target] [flags] (target is a positional: ios | android | extension; flags add agent depth)
125
+
126
+ DAILY LOOP — what a teammate runs many times a day:
127
+ launch Launch the app incl. Metro/build, surface auto-detected. Mobile: ios|android required.
128
+ mm-harness launch ios # quick relaunch (add --build | --verify)
129
+ logs Tail Metro/webpack + app logs.
130
+ mm-harness logs
131
+ debug Open the debug console (extension DevTools / mobile RN).
132
+ mm-harness debug
133
+ fixtures Sync fixture files + set up the wallet (SRP/password).
134
+ mm-harness fixtures sync # or: mm-harness fixtures set
135
+
136
+ PROVE — run recipes and inspect capabilities:
137
+ run Run a recipe and write evidence (summary/trace/artifacts).
138
+ mm-harness run recipe.json
139
+ flows List/promote recipe library flows (personal/team win over canonical).
140
+ mm-harness flows list
141
+ doctor Readiness check for a checkout (no app launch).
142
+ mm-harness doctor
143
+ actions Describe the actions a manifest declares.
144
+ mm-harness actions --adapter mobile
145
+ manifest Print/validate the action manifest for an adapter.
146
+ mm-harness manifest --adapter extension --json
147
+
148
+ RUNTIME OVERLAY — install/verify/clean the per-checkout runtime overlay:
149
+ install Install the runtime overlay into the checkout.
150
+ mm-harness install # inside a checkout, auto-detected
151
+ verify Check the overlay/runtime is present and healthy (no launch).
152
+ mm-harness verify
153
+ live Launch/reuse the app, then verify live control end-to-end.
154
+ mm-harness live
155
+ cleanup Remove the installed overlay and restore the checkout.
156
+ mm-harness cleanup
157
+ # Open sub-question (Arthur): keep these top-level (mm-harness install) or group
158
+ # them under an \`overlay\` command (mm-harness overlay install)? Top-level for now.
159
+
160
+ ADVANCED — internal runtime probes (rarely typed by hand):
161
+ runtime-health runtime-decision runtime-launch
162
+ resolve-extension ensure-ready self-test
163
+ (e.g. mm-harness runtime-decision --adapter extension --target <repo> --json)
164
+
165
+ ONE bin: mm-harness is the only command. No per-platform binaries — platform is
166
+ auto-detected, the positional target forces it (mm-harness launch ios), and
167
+ platform-specific needs are FLAGS on the same command (e.g. --sidebar, --full-build).
168
+ Human happy path = the bare command; agents add depth via flags (--json, --target, ports).
169
+ See docs/MENTAL-MODEL.md (overview) and docs/CLI-SPEC.md (full contract).
170
+ `);
171
+ }
172
+
173
+ function parseArgs(argv: string[], command?: string): ParsedArgs {
174
+ const positional: string[] = [];
175
+ const options: CliOptions = {};
176
+ const booleanOptions = new Set(['json', 'launchExistingDist', 'startWatch', 'record', 'plan', 'raw', 'fix']);
177
+ for (let i = 0; i < argv.length; i += 1) {
178
+ const arg = argv[i];
179
+ if (!arg.startsWith('--')) {
180
+ positional.push(arg);
181
+ continue;
182
+ }
183
+ const body = arg.slice(2);
184
+ const equalsIndex = body.indexOf('=');
185
+ const rawKey = equalsIndex === -1 ? body : body.slice(0, equalsIndex);
186
+ const inlineValue = equalsIndex === -1 ? undefined : body.slice(equalsIndex + 1);
187
+ const key = normalizeOptionKey(rawKey);
188
+ if (key === 'recordVideo') {
189
+ options.recordVideo = parseRecordVideoMode(inlineValue);
190
+ continue;
191
+ }
192
+ if (key === 'recordBaseline') {
193
+ options.record = true;
194
+ continue;
195
+ }
196
+ if (key === 'record') {
197
+ if (command === 'runtime-decision') {
198
+ options.record = true;
199
+ continue;
200
+ }
201
+ options.recordVideo = 'full-run';
202
+ continue;
203
+ }
204
+ if (booleanOptions.has(key)) {
205
+ options[key] = true;
206
+ continue;
207
+ }
208
+ if (inlineValue !== undefined) {
209
+ options[key] = inlineValue;
210
+ continue;
211
+ }
212
+ if (i + 1 >= argv.length) throw usageError(`Missing value for ${arg}`);
213
+ options[key] = argv[i + 1];
214
+ i += 1;
215
+ }
216
+ return { positional, options };
217
+ }
218
+
219
+ function parseRecordVideoMode(value: string | undefined): false | 'full-run' {
220
+ if (value === undefined || value === '' || value === 'true') return 'full-run';
221
+ if (value === 'off' || value === 'false') return false;
222
+ if (value === 'proof-window' || value === 'proof_window') {
223
+ throw usageError(
224
+ '--record-video=proof-window is not supported yet; use --record-video=full-run.',
225
+ );
226
+ }
227
+ if (value !== 'full-run') {
228
+ throw usageError('--record-video must be full-run or off.');
229
+ }
230
+ return 'full-run';
231
+ }
232
+
233
+ function normalizeOptionKey(key: string): string {
234
+ return key.replace(/-([a-z])/gu, (_, character: string) => character.toUpperCase());
235
+ }
236
+
237
+ function optionString(options: CliOptions, key: string): string | undefined {
238
+ const value = options[key];
239
+ if (value === undefined) return undefined;
240
+ if (typeof value !== 'string') throw usageError(`--${key} requires a value.`);
241
+ return value;
242
+ }
243
+
244
+ function optionFlag(options: CliOptions, key: string): boolean {
245
+ const value = options[key];
246
+ return value === true;
247
+ }
248
+
249
+ function requiredOption(options: CliOptions, key: string, message: string): string {
250
+ const value = optionString(options, key);
251
+ if (!value) throw usageError(message);
252
+ return value;
253
+ }
254
+
255
+ function adapterOption(options: CliOptions): MetaMaskRecipeAdapter {
256
+ const adapter = optionString(options, 'adapter');
257
+ try {
258
+ assertAdapter(adapter);
259
+ } catch (error) {
260
+ throw usageError(error instanceof Error ? error.message : String(error));
261
+ }
262
+ return adapter;
263
+ }
264
+
265
+ function targetPath(options: CliOptions): string {
266
+ return path.resolve(optionString(options, 'target') ?? optionString(options, 'projectRoot') ?? process.cwd());
267
+ }
268
+
269
+ function actionManifestPathOption(options: CliOptions, adapter: MetaMaskRecipeAdapter): string {
270
+ const configured = optionString(options, 'actionManifest');
271
+ return configured ? path.resolve(configured) : manifestPath(adapter);
272
+ }
273
+
274
+ async function runRecipe(
275
+ adapter: MetaMaskRecipeAdapter,
276
+ recipe: string,
277
+ artifactsDir: string,
278
+ projectRoot: string,
279
+ actionManifestPath?: string,
280
+ runtimeOptions: RuntimeOptions = {},
281
+ ): Promise<RecipeRunResult> {
282
+ const previousCdpPort = process.env.CDP_PORT;
283
+ const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
284
+ const previousWatcherPort = process.env.WATCHER_PORT;
285
+ const previousMetroPort = process.env.METRO_PORT;
286
+ const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
287
+ if (runtimeOptions.cdpPort) {
288
+ process.env.CDP_PORT = runtimeOptions.cdpPort;
289
+ process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
290
+ }
291
+ if (runtimeOptions.watcherPort) {
292
+ process.env.WATCHER_PORT = runtimeOptions.watcherPort;
293
+ process.env.METRO_PORT = runtimeOptions.watcherPort;
294
+ }
295
+ if (runtimeOptions.launchExistingDist) {
296
+ process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = '1';
297
+ }
298
+ try {
299
+ await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
300
+ const absoluteArtifactsDir = path.resolve(artifactsDir);
301
+ const recordVideo = runtimeOptions.recordVideo ?? false;
302
+ const useFramedExtensionRecording =
303
+ adapter === 'extension' &&
304
+ recordVideo === 'full-run' &&
305
+ captureHelperSupportsRecordSessionSnapshots(projectRoot);
306
+ const recording = useFramedExtensionRecording
307
+ ? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
308
+ record: true,
309
+ cdpPort: runtimeOptions.cdpPort,
310
+ })
311
+ : undefined;
312
+ try {
313
+ const manifest = loadActionManifest(adapter, actionManifestPath);
314
+ await validateManifest(manifest);
315
+ const { createMetaMaskRunner } = await import('./runner.ts');
316
+ const runner = await createMetaMaskRunner(adapter, manifest, {
317
+ quietStdout: runtimeOptions.stdoutIsMachineContract === true,
318
+ });
319
+ // Intersection keeps this compiling against harness versions that
320
+ // predate recipe libraries; those ignore the extra key at runtime.
321
+ const runRequest: Parameters<typeof runner.run>[0] & {
322
+ librarySources?: MetaMaskLibrarySource[];
323
+ } = {
324
+ recipePath: path.resolve(recipe),
325
+ artifactsDir: absoluteArtifactsDir,
326
+ projectRoot,
327
+ env: recipeRunEnv(adapter, runtimeOptions),
328
+ recordVideo: useFramedExtensionRecording ? false : recordVideo,
329
+ ...(runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}),
330
+ };
331
+ const result = await runner.run(runRequest);
332
+ await stopRecipeRecording(recording, result);
333
+ return result;
334
+ } finally {
335
+ await stopRecipeRecording(recording);
336
+ }
337
+ } finally {
338
+ restoreEnv('CDP_PORT', previousCdpPort);
339
+ restoreEnv('RECIPE_CDP_PORT', previousRecipeCdpPort);
340
+ restoreEnv('WATCHER_PORT', previousWatcherPort);
341
+ restoreEnv('METRO_PORT', previousMetroPort);
342
+ restoreEnv('METAMASK_RECIPE_EXTENSION_AUTOLAUNCH', previousExtensionAutolaunch);
343
+ }
344
+ }
345
+
346
+ function recipeRunEnv(
347
+ adapter: MetaMaskRecipeAdapter,
348
+ runtimeOptions: RuntimeOptions = {},
349
+ ): Record<string, string | undefined> {
350
+ const base: Record<string, string | undefined> = {
351
+ CDP_PORT: runtimeOptions.cdpPort ?? process.env.CDP_PORT,
352
+ RECIPE_CDP_PORT: runtimeOptions.cdpPort ?? process.env.RECIPE_CDP_PORT,
353
+ FARMSLOT_SLOT_ID: runtimeOptions.slot ?? process.env.FARMSLOT_SLOT_ID,
354
+ SLOT_ID: runtimeOptions.slot ?? process.env.SLOT_ID,
355
+ PLATFORM: process.env.PLATFORM,
356
+ };
357
+ if (adapter !== 'mobile') return base;
358
+ return {
359
+ ...base,
360
+ WATCHER_PORT: process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
361
+ METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
362
+ IOS_SIMULATOR: process.env.IOS_SIMULATOR,
363
+ ANDROID_DEVICE: process.env.ANDROID_DEVICE,
364
+ ADB_SERIAL: process.env.ADB_SERIAL,
365
+ ANDROID_SERIAL: process.env.ANDROID_SERIAL,
366
+ };
367
+ }
368
+
369
+ async function prepareRuntimeIfNeeded(
370
+ adapter: MetaMaskRecipeAdapter,
371
+ projectRoot: string,
372
+ runtimeOptions: RuntimeOptions,
373
+ ): Promise<void> {
374
+ if (adapter !== 'extension' || runtimeOptions.skipExtensionRuntimePrepare === true) return;
375
+ const { prepareExtensionRuntime } = await import('../../orchestration/extension/runtime.ts');
376
+ await prepareExtensionRuntime({
377
+ projectRoot,
378
+ cdpPort: runtimeOptions.cdpPort,
379
+ slot: runtimeOptions.slot,
380
+ launchExistingDist: runtimeOptions.launchExistingDist === true,
381
+ validationRuntimeDir: runtimeOptions.validationRuntimeDir,
382
+ });
383
+ }
384
+
385
+ function restoreEnv(key: string, value: string | undefined) {
386
+ if (value === undefined) delete process.env[key];
387
+ else process.env[key] = value;
388
+ }
389
+
390
+ async function runSelfTest(options: CliOptions) {
391
+ const root = optionString(options, 'artifactsDir')
392
+ ? path.resolve(requiredOption(options, 'artifactsDir', 'self-test artifacts dir missing.'))
393
+ : await mkdtemp(path.join(os.tmpdir(), 'metamask-recipe-runner-'));
394
+ const previousAutoHud = process.env.METAMASK_RECIPE_AUTO_HUD;
395
+ const runs = [];
396
+ try {
397
+ // Self-test is a package wiring check, not a live-device proof. Disable the
398
+ // automatic HUD and extension launch so it remains safe in fresh checkouts.
399
+ process.env.METAMASK_RECIPE_AUTO_HUD = '0';
400
+ for (const adapter of ['mobile', 'extension'] as const) {
401
+ const manifest = loadActionManifest(adapter);
402
+ const manifestValidation = await validateManifest(manifest);
403
+ const smokeRecipe = recipePath(
404
+ adapter === 'mobile' ? 'smoke.mobile.recipe.json' : 'smoke.extension.recipe.json',
405
+ );
406
+ const artifactsDir = path.join(root, adapter);
407
+ const result = await runRecipe(adapter, smokeRecipe, artifactsDir, runnerDir, undefined, {
408
+ skipExtensionRuntimePrepare: true,
409
+ });
410
+ runs.push({ adapter, manifestValidation: manifestValidation.summary, artifactsDir, result });
411
+ }
412
+ } finally {
413
+ restoreEnv('METAMASK_RECIPE_AUTO_HUD', previousAutoHud);
414
+ }
415
+ return {
416
+ status: runs.every((run) => run.result.status === 'pass') ? 'pass' : 'fail',
417
+ artifactsDir: root,
418
+ runs,
419
+ };
420
+ }
421
+
422
+ async function handleManifest({ options }: ParsedArgs): Promise<number> {
423
+ const adapter = adapterOption(options);
424
+ const actionManifestPath = actionManifestPathOption(options, adapter);
425
+ const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
426
+ await validateManifest(manifest);
427
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(manifest, null, 2));
428
+ else console.log(actionManifestPath);
429
+ return 0;
430
+ }
431
+
432
+ async function handleActions({ options }: ParsedArgs): Promise<number> {
433
+ const adapter = adapterOption(options);
434
+ const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
435
+ await validateManifest(manifest);
436
+ const action = optionString(options, 'action');
437
+ const actions = describeManifestActions(manifest, action);
438
+ if (optionFlag(options, 'json')) {
439
+ console.log(JSON.stringify({ adapter, actions }, null, 2));
440
+ } else {
441
+ for (const entry of actions) {
442
+ const fields = entry.fields.length ? ` fields=${entry.fields.join(',')}` : '';
443
+ console.log(`${entry.name} (${entry.kind})${fields}${entry.description ? ` — ${entry.description}` : ''}`);
444
+ }
445
+ }
446
+ return 0;
447
+ }
448
+
449
+ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
450
+ const target = targetPath(options);
451
+ // Share the exact detect-from-target logic the overlay commands (verify/install/
452
+ // cleanup) use: when --adapter/--platform is omitted, auto-detect from the
453
+ // target (defaults to cwd) instead of hard-failing on a missing flag.
454
+ const json = optionFlag(options, 'json');
455
+ const explicitAdapter = optionString(options, 'adapter') ?? optionString(options, 'platform');
456
+ const adapter = explicitAdapter ?? detectAdapter(target);
457
+ if (!adapter) {
458
+ return usageOut(
459
+ json,
460
+ 'doctor',
461
+ `could not detect the MetaMask repo type for ${target}\n Next: pass --adapter mobile|extension|core`,
462
+ );
463
+ }
464
+ assertAdapter(adapter);
465
+ const actionManifestPath = actionManifestPathOption(options, adapter);
466
+ const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
467
+ const manifestValidation = await validateManifest(manifest);
468
+
469
+ // --fix runs the shared healing steps WITHOUT launching the app (overlay
470
+ // auto-ensure + the same bounds run/launch use; never a fixture reseed) and
471
+ // reports fixed[]/failed[]. Without --fix, doctor is pure read-only.
472
+ if (optionFlag(options, 'fix')) {
473
+ const { fixed, failed } = await runDoctorFix(adapter, target, manifestValidation, json);
474
+ // Re-read the report AFTER repairs so its checks reflect the healed state.
475
+ const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
476
+ if (json) console.log(JSON.stringify({ ...result, fixed, failed }, null, 2));
477
+ else console.log(`${result.status} ${adapter} ${result.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(',')}] failed=[${failed.join(',')}]`);
478
+ return failed.length === 0 ? 0 : 1;
479
+ }
480
+
481
+ const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
482
+ if (json) console.log(JSON.stringify(result, null, 2));
483
+ else {
484
+ console.log(`${result.status} ${adapter} ${result.compatibilityMode} manifest=${actionManifestPath}`);
485
+ console.log(renderRuntimeContext(result.runtimeContext));
486
+ }
487
+ return result.status === 'pass' ? 0 : 1;
488
+ }
489
+
490
+ // `doctor --fix` repair path: the shared self-healing internals with no app launch.
491
+ // Auto-ensures the runtime overlay if missing (infra-only bound; core is headless
492
+ // and no-ops); a manifest that fails validation is not overlay-healable and is
493
+ // reported as failed. Healing NEVER touches fixtures. Returns the codes actually
494
+ // repaired (fixed[]) and the ones it could not (failed[]).
495
+ async function runDoctorFix(
496
+ adapter: MetaMaskRecipeAdapter,
497
+ target: string,
498
+ manifestValidation: RecipeValidationResult,
499
+ json: boolean,
500
+ ): Promise<{ fixed: string[]; failed: string[] }> {
501
+ const fixed: string[] = [];
502
+ const failed: string[] = [];
503
+
504
+ // Refuse to repair while a recipe is running — mid-run mutation corrupts state.
505
+ if (recipeRunning(target)) {
506
+ failed.push('recipe-running');
507
+ return { fixed, failed };
508
+ }
509
+
510
+ // Manifest validity is a read-only check: a broken manifest is not something the
511
+ // overlay install can heal, so surface it as a failed code rather than mutating.
512
+ if (Number(manifestValidation.summary?.errors ?? 0) > 0) failed.push('manifest');
513
+
514
+ // Overlay auto-ensure — the one positively-identified infra recovery available
515
+ // without a launch. ensureOverlay records a mutation only when it actually
516
+ // installs a missing overlay, so an already-present overlay is not reported.
517
+ const state = newHealState();
518
+ const ensured = await ensureOverlay(adapter, target, 'infra-only', state, json);
519
+ if (!ensured.ok) failed.push('overlay');
520
+ else if (state.mutations.length > 0) fixed.push('overlay');
521
+
522
+ return { fixed, failed };
523
+ }
524
+
525
+ function describeManifestActions(
526
+ manifest: unknown,
527
+ filterAction?: string,
528
+ ): Array<{
529
+ name: string;
530
+ kind: 'official' | 'custom';
531
+ description: string;
532
+ fields: string[];
533
+ schema?: unknown;
534
+ examples?: unknown;
535
+ }> {
536
+ const manifestRecord = isRecord(manifest) ? manifest : {};
537
+ const metadata = isRecord(manifestRecord.action_metadata) ? manifestRecord.action_metadata : {};
538
+ const official = Array.isArray(manifestRecord.supported_official_actions)
539
+ ? manifestRecord.supported_official_actions.filter((value): value is string => typeof value === 'string')
540
+ : [];
541
+ const custom = Array.isArray(manifestRecord.custom_actions)
542
+ ? manifestRecord.custom_actions.flatMap((entry) => {
543
+ if (typeof entry === 'string') return [{ name: entry, metadata: metadata[entry] }];
544
+ if (isRecord(entry) && typeof entry.name === 'string') {
545
+ const entryMetadata = { ...entry };
546
+ const metadataOverride = metadata[entry.name];
547
+ if (isRecord(metadataOverride)) Object.assign(entryMetadata, metadataOverride);
548
+ return [{ name: entry.name, metadata: entryMetadata }];
549
+ }
550
+ return [];
551
+ })
552
+ : [];
553
+ const entries = [
554
+ ...official.map((name) => describeManifestAction(name, 'official' as const, metadata[name])),
555
+ ...custom.map((entry) => describeManifestAction(entry.name, 'custom' as const, entry.metadata)),
556
+ ].filter((entry) => !filterAction || entry.name === filterAction);
557
+ if (filterAction && entries.length === 0) throw new Error(`Action not found in manifest: ${filterAction}`);
558
+ return entries;
559
+ }
560
+
561
+ function describeManifestAction(
562
+ name: string,
563
+ kind: 'official' | 'custom',
564
+ metadata: unknown,
565
+ ): {
566
+ name: string;
567
+ kind: 'official' | 'custom';
568
+ description: string;
569
+ fields: string[];
570
+ schema?: unknown;
571
+ examples?: unknown;
572
+ } {
573
+ const record = isRecord(metadata) ? metadata : {};
574
+ const schema = record.schema;
575
+ const schemaRecord = isRecord(schema) ? schema : {};
576
+ const properties = isRecord(schemaRecord.properties) ? Object.keys(schemaRecord.properties).sort() : [];
577
+ return {
578
+ name,
579
+ kind,
580
+ description: typeof record.description === 'string' ? record.description : '',
581
+ fields: properties,
582
+ schema,
583
+ examples: record.examples,
584
+ };
585
+ }
586
+
587
+ function isRecord(value: unknown): value is Record<string, unknown> {
588
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
589
+ }
590
+
591
+ async function handleRuntimeHealth({ options }: ParsedArgs): Promise<number> {
592
+ const adapter = adapterOption(options);
593
+ if (adapter !== 'extension') throw new Error('runtime-health currently applies to the extension adapter.');
594
+ const target = targetPath(options);
595
+ const cdpPort = parsePort(
596
+ optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
597
+ 'runtime-health requires --cdp-port <port>.',
598
+ );
599
+ const { checkExtensionRuntimeHealth, formatHealthFailure } = await import('../../orchestration/extension/runtime.ts');
600
+ const report = await checkExtensionRuntimeHealth(target, cdpPort);
601
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
602
+ else if (report.status === 'PASS') console.log(`PASS extension runtime cdp=${cdpPort} target=${report.targetUrl}`);
603
+ else console.error(formatHealthFailure(report, target));
604
+ return report.status === 'PASS' ? 0 : 1;
605
+ }
606
+
607
+
608
+ async function handleRuntimeLaunch({ options }: ParsedArgs): Promise<number> {
609
+ const adapter = adapterOption(options);
610
+ const target = targetPath(options);
611
+ if (adapter !== 'extension') throw new Error('runtime-launch currently applies to the extension adapter.');
612
+ const cdpPort = parsePort(
613
+ optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
614
+ 'runtime-launch requires --cdp-port <port>.',
615
+ );
616
+ const chromeUserDataDir = optionString(options, 'chromeUserDataDir');
617
+ const artifactsDir = path.resolve(
618
+ optionString(options, 'artifactsDir') ??
619
+ recipeHarnessPath(target, 'extension', 'runtime-launch', new Date().toISOString().replace(/[:.]/gu, '')),
620
+ );
621
+ const startWatch = optionFlag(options, 'startWatch');
622
+ const liveScript = recipeHarnessPath(target, 'extension', 'scripts', 'live.sh');
623
+ const command = [
624
+ 'bash',
625
+ liveScript,
626
+ '--target',
627
+ target,
628
+ '--cdp-port',
629
+ String(cdpPort),
630
+ startWatch ? '--start-watch' : '--launch-existing-dist',
631
+ '--artifacts-dir',
632
+ artifactsDir,
633
+ ];
634
+ if (chromeUserDataDir) command.push('--chrome-user-data-dir', chromeUserDataDir);
635
+ // Optional A/B feature-flag pinning. Passed straight through to live.sh, which
636
+ // patches the ephemeral runtime-dist snapshot manifest before Chrome loads it.
637
+ // Omitting it leaves the launch byte-identical to the previous behavior.
638
+ const remoteFlag = optionString(options, 'remoteFlag');
639
+ if (remoteFlag) command.push('--remote-flag', remoteFlag);
640
+
641
+ if (!fs.existsSync(liveScript)) {
642
+ const report = runtimeLaunchReport('fail', {
643
+ adapter,
644
+ target,
645
+ cdpPort,
646
+ artifactsDir,
647
+ reason: 'harness_live_script_missing',
648
+ fix: `Run metamask-recipe extension prepare --target ${shellQuote(target)}, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir)}`,
649
+ command,
650
+ });
651
+ printRuntimeLaunchReport(report, optionFlag(options, 'json'));
652
+ return 1;
653
+ }
654
+
655
+ fs.mkdirSync(artifactsDir, { recursive: true });
656
+ const jsonMode = optionFlag(options, 'json');
657
+ if (!jsonMode) {
658
+ console.error(`recipe: runtime-launch starting (cdp=${cdpPort}${startWatch ? ', clean webpack build' : ', existing dist'})`);
659
+ } else {
660
+ console.error(JSON.stringify({
661
+ schemaVersion: 1,
662
+ type: 'progress',
663
+ command: 'rebuild',
664
+ phase: 'runtime-launch',
665
+ message: startWatch ? 'clean webpack build starting' : 'launching existing dist',
666
+ cdpPort,
667
+ }));
668
+ }
669
+ const result = spawnSync(command[0], command.slice(1), {
670
+ cwd: target,
671
+ encoding: 'utf8',
672
+ maxBuffer: 5 * 1024 * 1024,
673
+ // Human launches stream webpack/rsync/verify output live; --json keeps
674
+ // machine-readable capture for wrappers that pipe the final report.
675
+ stdio: jsonMode ? ['ignore', 'pipe', 'pipe'] : 'inherit',
676
+ });
677
+ const summaryPath = path.join(artifactsDir, 'summary.json');
678
+ const summary = readJsonIfExists(summaryPath);
679
+
680
+ if (result.status === 0 && isRecord(summary) && summary.status === 'pass') {
681
+ const report = runtimeLaunchReport('pass', {
682
+ adapter,
683
+ target,
684
+ cdpPort,
685
+ artifactsDir,
686
+ summaryPath,
687
+ reason: 'runtime_ready',
688
+ fix: '',
689
+ command,
690
+ });
691
+ printRuntimeLaunchReport(report, optionFlag(options, 'json'));
692
+ return 0;
693
+ }
694
+
695
+ const launchLogPath = path.join(artifactsDir, 'launch', 'logs', 'launch.log');
696
+ const report = runtimeLaunchReport('fail', {
697
+ adapter,
698
+ target,
699
+ cdpPort,
700
+ artifactsDir,
701
+ summaryPath: fs.existsSync(summaryPath) ? summaryPath : undefined,
702
+ launchLogPath: fs.existsSync(launchLogPath) ? launchLogPath : undefined,
703
+ reason: 'runtime_launch_failed',
704
+ fix: `Read ${fs.existsSync(launchLogPath) ? launchLogPath : summaryPath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch)}`,
705
+ command,
706
+ exitCode: result.status ?? 1,
707
+ });
708
+ printRuntimeLaunchReport(report, optionFlag(options, 'json'));
709
+ return 1;
710
+ }
711
+
712
+ function runtimeLaunchCommand(target: string, cdpPort: number, chromeUserDataDir?: string, startWatch = false): string {
713
+ const base = `metamask-recipe runtime-launch --adapter extension --target ${shellQuote(target)} --cdp-port ${cdpPort}`;
714
+ const withMode = startWatch ? `${base} --start-watch` : base;
715
+ return chromeUserDataDir ? `${withMode} --chrome-user-data-dir ${shellQuote(chromeUserDataDir)}` : withMode;
716
+ }
717
+
718
+ function shellQuote(value: string): string {
719
+ return `'${value.replace(/'/gu, `'\\''`)}'`;
720
+ }
721
+
722
+ function runtimeLaunchReport(status: 'pass' | 'fail', fields: Record<string, unknown>): Record<string, unknown> {
723
+ return {
724
+ schemaVersion: 1,
725
+ status,
726
+ ...fields,
727
+ };
728
+ }
729
+
730
+ function printRuntimeLaunchReport(report: Record<string, unknown>, json: boolean): void {
731
+ if (json) {
732
+ console.log(JSON.stringify(report, null, 2));
733
+ return;
734
+ }
735
+ const status = String(report.status).toUpperCase();
736
+ console.log(`${status} runtime-launch ${report.reason}`);
737
+ if (report.status === 'fail') console.log(`Fix: ${report.fix}`);
738
+ console.log(`Artifacts: ${report.artifactsDir}`);
739
+ }
740
+
741
+ function readJsonIfExists(file: string): unknown {
742
+ if (!fs.existsSync(file)) return null;
743
+ try {
744
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
745
+ } catch {
746
+ return null;
747
+ }
748
+ }
749
+
750
+ async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
751
+ const adapter = adapterOption(options);
752
+ const target = targetPath(options);
753
+ if (adapter === 'mobile') {
754
+ const watcherPortRaw =
755
+ optionString(options, 'watcherPort') ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT;
756
+ const watcherPort =
757
+ watcherPortRaw === undefined
758
+ ? undefined
759
+ : parsePort(watcherPortRaw, 'runtime-decision --watcher-port must be a port.');
760
+ const report = await decideMobileReadiness(target, {
761
+ watcherPort,
762
+ metroLog: optionString(options, 'metroLog'),
763
+ platform: optionString(options, 'platform') ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
764
+ record: optionFlag(options, 'record'),
765
+ });
766
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
767
+ else console.log(`${report.decision} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
768
+ return 0;
769
+ }
770
+ if (adapter !== 'extension') {
771
+ const report = {
772
+ schemaVersion: 1,
773
+ adapter,
774
+ target,
775
+ decision: 'unknown',
776
+ clean: false,
777
+ reasonCode: 'adapter-unsupported',
778
+ reasons: [`runtime-decision applies to extension and mobile adapters, not ${adapter}.`],
779
+ checks: {},
780
+ actions: [],
781
+ };
782
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
783
+ else console.log(`unknown adapter-unsupported — ${report.reasons[0]}`);
784
+ return 0;
785
+ }
786
+ const cdpPortRaw = optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
787
+ const cdpPort = cdpPortRaw === undefined ? undefined : parsePort(cdpPortRaw, 'runtime-decision --cdp-port must be a port.');
788
+ const report = await decideExtensionReadiness(target, {
789
+ cdpPort,
790
+ watchLog: optionString(options, 'watchLog'),
791
+ record: optionFlag(options, 'record'),
792
+ });
793
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
794
+ else console.log(`${report.decision}${report.clean ? ' (clean)' : ''} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
795
+ return 0;
796
+ }
797
+
798
+ async function handleResolveExtension({ options }: ParsedArgs): Promise<number> {
799
+ const adapter = adapterOption(options);
800
+ if (adapter !== 'extension') throw new Error('resolve-extension currently applies to the extension adapter.');
801
+ const target = targetPath(options);
802
+ const cdpPortRaw = optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
803
+ const cdpPort = cdpPortRaw === undefined ? undefined : parsePort(cdpPortRaw, 'resolve-extension --cdp-port must be a port.');
804
+ const result = await resolveExtensionId(target, { cdpPort });
805
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(result, null, 2));
806
+ else if (result.extensionId) console.log(result.extensionId); // bare id: easy `$(... resolve-extension ...)` capture
807
+ else console.error('Could not resolve a MetaMask extension id (no dist key and no single CDP extension).');
808
+ return result.extensionId ? 0 : 1;
809
+ }
810
+
811
+ async function handleEnsureReady({ options }: ParsedArgs): Promise<number> {
812
+ const adapter = adapterOption(options);
813
+ if (adapter !== 'extension') throw new Error('ensure-ready currently applies to the extension adapter.');
814
+ const target = targetPath(options);
815
+ const cdpPort = parsePort(
816
+ optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
817
+ 'ensure-ready requires --cdp-port <port>.',
818
+ );
819
+ const result = await ensureExtensionReady(target, { cdpPort });
820
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(result, null, 2));
821
+ else console.log(`${result.ready ? 'READY' : 'NOT-READY'} ${result.reasonCode} — id=${result.extensionId} homeTabs ${result.homeTabs.before}→${result.homeTabs.after} (closed ${result.homeTabs.closed})`);
822
+ return result.ready ? 0 : 1;
823
+ }
824
+
825
+ // Exit-code taxonomy (docs/CLI-SPEC.md §5.6) — consistent across commands:
826
+ // 0 ok · 1 recipe/runtime fail (app-logic) · 2 usage / bad args ·
827
+ // 3 infra not auto-healed · 4 bounded / not-retryable ·
828
+ // 5 validation failure (run, run --plan, call — adapter-aware validation errors).
829
+ const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 } as const;
830
+
831
+ // Typed exit-code error: carries exitCode so both the global catch (metamask-recipe bin)
832
+ // and the mm-harness delegate() wrapper classify the error correctly rather than always
833
+ // returning 1. Usage errors (bad args / unsupported flags) carry EXIT.usage (2);
834
+ // validation errors carry EXIT.validation (5). Never throw a plain new Error() for
835
+ // user-facing bad-args cases — use usageError() so the exit code is preserved.
836
+ class CliError extends Error {
837
+ readonly exitCode: number;
838
+ constructor(message: string, exitCode: number) {
839
+ super(message);
840
+ this.name = 'CliError';
841
+ this.exitCode = exitCode;
842
+ }
843
+ }
844
+ function usageError(message: string): CliError {
845
+ return new CliError(message, EXIT.usage);
846
+ }
847
+
848
+ // Shared adapter resolution: explicit --adapter/--platform, else auto-detect from
849
+ // --target/cwd (the exact detect story doctor/verify/install use). Lets `call`,
850
+ // `run --plan`, and `completion-candidates` work in a checkout without a flag.
851
+ function resolveAdapter(options: CliOptions): { adapter: MetaMaskRecipeAdapter; target: string } {
852
+ const target = targetPath(options);
853
+ const explicit = optionString(options, 'adapter') ?? optionString(options, 'platform');
854
+ const adapter = explicit ?? detectAdapter(target);
855
+ if (!adapter) {
856
+ throw usageError(
857
+ `could not detect the MetaMask repo type for ${target}\n Next: pass --adapter mobile|extension|core`,
858
+ );
859
+ }
860
+ try {
861
+ assertAdapter(adapter);
862
+ } catch (error) {
863
+ throw usageError(error instanceof Error ? error.message : String(error));
864
+ }
865
+ return { adapter, target };
866
+ }
867
+
868
+ // Adapter-aware recipe validation: schema (validateRecipeDocument) + action
869
+ // existence/platform against the adapter's manifest (validateRecipeWithManifest).
870
+ // This is the "run validates first" contract, shared by `run --plan` and `call`.
871
+ //
872
+ // NOTE (pinned deps): recipes that reference library flows by `call.ref` need
873
+ // `externalFlowIds` sourced from resolveRecipeLibrarySources, which lands with
874
+ // @farmslot/recipe-harness >= 0.3.3 (pending publish). Until then, self-contained
875
+ // recipes (all in-repo recipes + every one-node `call` recipe) validate fully;
876
+ // library-ref recipes would surface their refs here. Extends when 0.3.3 lands.
877
+ async function validateRecipeAdapterAware(
878
+ recipe: unknown,
879
+ manifest: RecipeActionManifestDocument,
880
+ ): Promise<RecipeValidationResult> {
881
+ const { validateRecipeDocument, validateRecipeWithManifest } = await importRecipeProtocol();
882
+ const schema = validateRecipeDocument(recipe);
883
+ const withManifest = validateRecipeWithManifest(recipe, manifest);
884
+ const findings = [...schema.findings, ...withManifest.findings];
885
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
886
+ const warnings = findings.length - errors;
887
+ return { status: errors > 0 ? 'invalid' : 'valid', findings, summary: { errors, warnings } };
888
+ }
889
+
890
+ interface PlanItem {
891
+ step: string;
892
+ confidence: 'static' | 'conditional';
893
+ status: 'ok' | 'error' | 'planned';
894
+ detail: string;
895
+ }
896
+
897
+ function countRecipeNodes(recipe: unknown): number | undefined {
898
+ if (!isRecord(recipe)) return undefined;
899
+ const validate = isRecord(recipe.validate) ? recipe.validate : undefined;
900
+ const workflow = validate && isRecord(validate.workflow) ? validate.workflow : undefined;
901
+ const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : undefined;
902
+ return nodes ? Object.keys(nodes).length : undefined;
903
+ }
904
+
905
+ // Static, adapter-aware recipe validation shared by `run --plan` and the `run`
906
+ // execute path so both "validate first" the same way: read the file (missing /
907
+ // unparseable → usage error, nothing to validate), check the manifest, then run
908
+ // the adapter-aware schema + action-existence validation. Touches nothing and
909
+ // resolves no libraries, so it never emits the engine's "Recipe libraries: …"
910
+ // log — safe to call before the machine-contract JSON is written.
911
+ interface RunRecipeStaticValidation {
912
+ recipe: unknown;
913
+ recipeFile: string;
914
+ findings: RecipeValidationFinding[];
915
+ errorCount: number;
916
+ manifestOk: boolean;
917
+ schemaValid: boolean;
918
+ usageError?: { code: string; message: string };
919
+ }
920
+
921
+ async function validateRunRecipeStatic(
922
+ recipeArg: string,
923
+ adapter: MetaMaskRecipeAdapter,
924
+ options: CliOptions,
925
+ ): Promise<RunRecipeStaticValidation> {
926
+ const recipeFile = path.resolve(recipeArg);
927
+ const empty = { recipe: undefined, recipeFile, findings: [], errorCount: 0, manifestOk: false, schemaValid: false };
928
+ if (!fs.existsSync(recipeFile)) {
929
+ return { ...empty, usageError: { code: 'RECIPE_NOT_FOUND', message: `recipe not found: ${recipeFile}` } };
930
+ }
931
+ let recipe: unknown;
932
+ try {
933
+ recipe = JSON.parse(fs.readFileSync(recipeFile, 'utf8'));
934
+ } catch (error) {
935
+ return {
936
+ ...empty,
937
+ usageError: {
938
+ code: 'RECIPE_UNPARSEABLE',
939
+ message: `recipe is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
940
+ },
941
+ };
942
+ }
943
+
944
+ // validate.manifest (static) — manifest well-formedness (same check doctor runs).
945
+ const findings: RecipeValidationFinding[] = [];
946
+ const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
947
+ let manifestOk = true;
948
+ try {
949
+ await validateManifest(manifest);
950
+ } catch (error) {
951
+ manifestOk = false;
952
+ findings.push({
953
+ severity: 'error',
954
+ code: 'manifest.invalid',
955
+ path: actionManifestPathOption(options, adapter),
956
+ message: error instanceof Error ? error.message : String(error),
957
+ });
958
+ }
959
+
960
+ // validate.schema + validate.actions (static) — adapter-aware recipe validation.
961
+ const validation = manifestOk
962
+ ? await validateRecipeAdapterAware(recipe, manifest)
963
+ : { status: 'invalid' as const, findings: [], summary: { errors: 1, warnings: 0 } };
964
+ findings.push(...validation.findings);
965
+
966
+ const errorCount = findings.filter((finding) => finding.severity === 'error').length;
967
+ return { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid: validation.status === 'valid' };
968
+ }
969
+
970
+ // `run --plan`: validate (adapter-aware) + print the plan — touches NOTHING (no
971
+ // device, no overlay mutation, no artifacts written). Exit 0 = plan valid; exit 5
972
+ // = validation failed. `--json` = { status, adapter, recipe, findings[], plan[] }.
973
+ async function handleRunPlan(recipeArg: string, options: CliOptions): Promise<number> {
974
+ const json = optionFlag(options, 'json');
975
+ const { adapter, target } = resolveAdapter(options);
976
+
977
+ const validated = await validateRunRecipeStatic(recipeArg, adapter, options);
978
+ if (validated.usageError) {
979
+ return emitPlanUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
980
+ }
981
+ const { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid } = validated;
982
+
983
+ const status: 'pass' | 'fail' = errorCount === 0 ? 'pass' : 'fail';
984
+ const nodeCount = countRecipeNodes(recipe);
985
+ const artifactsDir = optionString(options, 'artifactsDir');
986
+
987
+ const plan: PlanItem[] = [
988
+ { step: 'resolve.recipe', confidence: 'static', status: 'ok', detail: recipeFile },
989
+ { step: 'resolve.adapter', confidence: 'static', status: 'ok', detail: adapter },
990
+ {
991
+ step: 'resolve.artifactsDir',
992
+ confidence: 'static',
993
+ status: 'ok',
994
+ detail: artifactsDir ? path.resolve(artifactsDir) : '(resolved to the slot artifacts dir at run time)',
995
+ },
996
+ {
997
+ step: 'validate.manifest',
998
+ confidence: 'static',
999
+ status: manifestOk ? 'ok' : 'error',
1000
+ detail: manifestOk ? 'action manifest is well-formed' : 'action manifest failed validation',
1001
+ },
1002
+ {
1003
+ step: 'validate.schema',
1004
+ confidence: 'static',
1005
+ status: manifestOk && schemaValid ? 'ok' : 'error',
1006
+ detail: 'recipe document schema + action existence/platform vs the adapter manifest',
1007
+ },
1008
+ {
1009
+ step: 'fixture.file',
1010
+ confidence: 'static',
1011
+ status: 'ok',
1012
+ detail: fs.existsSync(walletFixturePath(target)) ? `present: ${walletFixturePath(target)}` : `absent: ${walletFixturePath(target)} (fixtures set to seed)`,
1013
+ },
1014
+ {
1015
+ step: 'overlay.ensure',
1016
+ confidence: 'conditional',
1017
+ status: 'planned',
1018
+ detail: 'would auto-ensure the runtime overlay if missing (install phase)',
1019
+ },
1020
+ ...(adapter === 'core'
1021
+ ? []
1022
+ : [
1023
+ {
1024
+ step: 'launch.app',
1025
+ confidence: 'conditional' as const,
1026
+ status: 'planned' as const,
1027
+ detail: 'would launch/attach the app + heal transport (Metro/Chrome/CDP) before executing',
1028
+ },
1029
+ ]),
1030
+ {
1031
+ step: 'execute.nodes',
1032
+ confidence: 'conditional',
1033
+ status: 'planned',
1034
+ detail: nodeCount === undefined ? 'would execute the recipe nodes' : `would execute ${nodeCount} recipe node(s)`,
1035
+ },
1036
+ ];
1037
+
1038
+ if (json) {
1039
+ const payload: Record<string, unknown> = { schemaVersion: 1, command: 'run', mode: 'plan', status, adapter, recipe: recipeFile, findings, plan };
1040
+ if (status === 'fail') payload.error = { code: 'RECIPE_VALIDATION_FAILED', message: `recipe validation found ${errorCount} error(s)` };
1041
+ console.log(JSON.stringify(payload, null, 2));
1042
+ } else {
1043
+ console.log(`plan ${status} — ${adapter} — ${recipeFile}`);
1044
+ for (const item of plan) {
1045
+ const mark = item.status === 'error' ? '✗' : item.status === 'ok' ? '✓' : '·';
1046
+ console.log(` ${mark} [${item.confidence}] ${item.step}: ${item.detail}`);
1047
+ }
1048
+ if (findings.length) {
1049
+ console.log('findings:');
1050
+ for (const finding of findings) {
1051
+ console.log(` ${finding.severity === 'error' ? '✗' : '⚠'} ${finding.code} ${finding.path} — ${finding.message}`);
1052
+ }
1053
+ }
1054
+ }
1055
+ return status === 'pass' ? EXIT.ok : EXIT.validation;
1056
+ }
1057
+
1058
+ function emitPlanUsageError(
1059
+ json: boolean,
1060
+ adapter: MetaMaskRecipeAdapter,
1061
+ recipeFile: string,
1062
+ code: string,
1063
+ message: string,
1064
+ ): number {
1065
+ if (json) {
1066
+ console.log(
1067
+ JSON.stringify(
1068
+ { schemaVersion: 1, command: 'run', mode: 'plan', status: 'fail', adapter, recipe: recipeFile, error: { code, message } },
1069
+ null,
1070
+ 2,
1071
+ ),
1072
+ );
1073
+ } else {
1074
+ console.error(`✗ run --plan: ${message}`);
1075
+ }
1076
+ return EXIT.usage;
1077
+ }
1078
+
1079
+ // `call <action>`: execute ONE action from the adapter vocabulary AS a single-node
1080
+ // recipe through the real engine path — the SAME path `run` uses (write a recipe,
1081
+ // hand it to runRecipe). One execution path, two doors: `call` = one node, `run` =
1082
+ // a graph. Inherits run semantics: always-validates (adapter-aware, exit 5), same
1083
+ // trace/evidence artifacts, same --json contract.
1084
+ async function handleCall(argv: string[]): Promise<number> {
1085
+ // Grammar: mm-harness call <action> [--arg k=v ...] [flags].
1086
+ // If the first token is a flag, the action positional is missing — parseCallArgs
1087
+ // would wrongly consume the flag's VALUE as the action name (e.g. `--adapter core`
1088
+ // → action='core'). Catch it early before any parsing so the error is accurate.
1089
+ if (argv.length > 0 && argv[0].startsWith('--')) {
1090
+ const message = 'call requires <action> first: mm-harness call <action> [--arg k=v ...] [flags]';
1091
+ console.error(message);
1092
+ return EXIT.usage;
1093
+ }
1094
+ const { action: shortName, args, rest } = parseCallArgs(argv);
1095
+ const { options } = parseArgs(rest, 'call');
1096
+ const json = optionFlag(options, 'json');
1097
+ if (!shortName) {
1098
+ const message = 'call requires <action>. Example: mm-harness call unlock --adapter extension';
1099
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', error: { code: 'USAGE', message } }, null, 2));
1100
+ else console.error(message);
1101
+ return EXIT.usage;
1102
+ }
1103
+
1104
+ const { adapter, target } = resolveAdapter(options);
1105
+
1106
+ // Pre-execution bound: refuse while another recipe is running. Checked here
1107
+ // (before manifest load) so any action name produces exit 4, not an action
1108
+ // resolution error, when a recipe.lock is present.
1109
+ if (recipeRunning(target)) {
1110
+ const msg = 'a recipe is currently running — refusing to start while another recipe executes.';
1111
+ if (json) {
1112
+ console.log(JSON.stringify({ schemaVersion: 1, command: 'call', status: 'fail', recoverable: false, error: { code: 'RECIPE_RUNNING', message: msg } }, null, 2));
1113
+ } else {
1114
+ console.error(`✗ mm-harness call: ${msg}`);
1115
+ }
1116
+ return EXIT.bounded;
1117
+ }
1118
+
1119
+ const actionManifestOverride = optionString(options, 'actionManifest');
1120
+ const manifest = loadActionManifest(adapter, actionManifestOverride);
1121
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
1122
+ const names = getRecipeActionManifestActionNames(manifest);
1123
+
1124
+ const resolution = resolveActionName(shortName, names);
1125
+ if (resolution.status === 'unknown') {
1126
+ const message =
1127
+ `✗ call: unknown action "${shortName}" for the ${adapter} adapter.\n` +
1128
+ ` See the vocabulary: mm-harness actions --adapter ${adapter} --json`;
1129
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', adapter, action: shortName, error: { code: 'ACTION_UNKNOWN', message } }, null, 2));
1130
+ else console.error(message);
1131
+ return EXIT.usage;
1132
+ }
1133
+ if (resolution.status === 'ambiguous') {
1134
+ const message = `✗ call: "${shortName}" is ambiguous: ${resolution.candidates.join(', ')} — use the full name.`;
1135
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', adapter, action: shortName, error: { code: 'ACTION_AMBIGUOUS', message, candidates: resolution.candidates } }, null, 2));
1136
+ else console.error(message);
1137
+ return EXIT.usage;
1138
+ }
1139
+ const resolvedAction = resolution.resolved;
1140
+
1141
+ // Synthesize the one-node recipe (entry action → end). This IS the recipe `run`
1142
+ // would execute; call just authored it from the CLI instead of a file.
1143
+ const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
1144
+
1145
+ // Always-validate first (adapter-aware) — exit 5 on validation failure, before
1146
+ // the engine touches anything.
1147
+ const validation = await validateRecipeAdapterAware(recipe, manifest);
1148
+ if (validation.status === 'invalid') {
1149
+ const message = `✗ call ${resolvedAction}: recipe validation failed`;
1150
+ 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));
1151
+ else {
1152
+ console.error(message);
1153
+ for (const finding of validation.findings) console.error(` ${finding.code} ${finding.path} — ${finding.message}`);
1154
+ }
1155
+ return EXIT.validation;
1156
+ }
1157
+
1158
+ // --heal (call inherits infra-only) + auto-ensure the runtime overlay before
1159
+ // execution. core is headless → no-op.
1160
+ const prepared = await prepareHeal(adapter, target, options, json);
1161
+ if (typeof prepared === 'number') return prepared;
1162
+ const { state, heal } = prepared;
1163
+
1164
+ // Write the synthesized recipe to a temp file and run it through the exact same
1165
+ // engine path `run` uses (runRecipe → runner.run). Evidence lands in artifactsDir.
1166
+ const scratch = await mkdtemp(path.join(os.tmpdir(), 'mm-harness-call-'));
1167
+ const recipeFile = path.join(scratch, 'call.recipe.json');
1168
+ fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}\n`);
1169
+ const artifactsDir = optionString(options, 'artifactsDir') ?? path.join(scratch, 'artifacts');
1170
+
1171
+ const librarySources = await resolveMetaMaskLibrarySources(optionString(options, 'library'));
1172
+ const callRuntimeOptions: RuntimeOptions = {
1173
+ ...runtimeOptionsFromCli(options),
1174
+ ...(librarySources ? { librarySources } : {}),
1175
+ stdoutIsMachineContract: json,
1176
+ };
1177
+ // Failure/retry path wired through the shared checkHealBounds module, identical
1178
+ // to `run`: app-logic verbatim/no-heal, bounded infra recovery, and the
1179
+ // same-recovery-twice refusal.
1180
+ const { result, violation } = await executeWithHealBounds(
1181
+ () => runRecipe(adapter, recipeFile, artifactsDir, target, actionManifestOverride, callRuntimeOptions),
1182
+ adapter,
1183
+ target,
1184
+ heal,
1185
+ state,
1186
+ );
1187
+ if (violation !== null) return emitHealViolation(json, 'call', result, violation, state);
1188
+
1189
+ if (json) {
1190
+ console.log(
1191
+ JSON.stringify(
1192
+ {
1193
+ schemaVersion: 1,
1194
+ command: 'call',
1195
+ adapter,
1196
+ action: shortName,
1197
+ resolvedAction,
1198
+ args,
1199
+ status: result.status,
1200
+ summaryPath: result.summaryPath,
1201
+ tracePath: result.tracePath,
1202
+ artifactManifestPath: result.artifactManifestPath,
1203
+ recovered: state.recovered,
1204
+ mutations: state.mutations,
1205
+ exitCode: result.status === 'pass' ? EXIT.ok : EXIT.runtime,
1206
+ },
1207
+ null,
1208
+ 2,
1209
+ ),
1210
+ );
1211
+ } else {
1212
+ console.log(`call ${resolvedAction}: ${result.status}\nArtifacts: ${result.artifactManifestPath}`);
1213
+ }
1214
+ return result.status === 'pass' ? EXIT.ok : EXIT.runtime;
1215
+ }
1216
+
1217
+ interface CallArgs {
1218
+ action: string | undefined;
1219
+ args: Record<string, string>;
1220
+ rest: string[];
1221
+ }
1222
+
1223
+ // Pull the positional <action> and repeatable `--arg k=v` out of argv; everything
1224
+ // else (--adapter/--json/--target/…) flows to the shared parseArgs.
1225
+ function parseCallArgs(argv: string[]): CallArgs {
1226
+ const args: Record<string, string> = {};
1227
+ const rest: string[] = [];
1228
+ let action: string | undefined;
1229
+ for (let i = 0; i < argv.length; i += 1) {
1230
+ const arg = argv[i];
1231
+ if (arg === '--arg' || arg.startsWith('--arg=')) {
1232
+ const pair = arg === '--arg' ? argv[(i += 1)] : arg.slice('--arg='.length);
1233
+ if (pair === undefined) throw usageError('--arg requires k=v.');
1234
+ const eq = pair.indexOf('=');
1235
+ if (eq === -1) throw usageError(`--arg must be k=v: ${pair}`);
1236
+ args[pair.slice(0, eq)] = pair.slice(eq + 1);
1237
+ continue;
1238
+ }
1239
+ if (!arg.startsWith('--') && action === undefined) {
1240
+ action = arg;
1241
+ continue;
1242
+ }
1243
+ rest.push(arg);
1244
+ }
1245
+ return { action, args, rest };
1246
+ }
1247
+
1248
+ interface ActionResolution {
1249
+ status: 'ok' | 'ambiguous' | 'unknown';
1250
+ resolved: string;
1251
+ candidates: string[];
1252
+ }
1253
+
1254
+ // Fuzzy short-name resolution, most-specific tier first (exact beats substring):
1255
+ // 1. exact full action name (`metamask.wallet.ensure_unlocked`);
1256
+ // 2. exact final dot-segment (`unlock` → `metamask.wallet.unlock`);
1257
+ // 3. unique substring of a final segment (`unlock` ⊂ `ensure_unlocked`).
1258
+ // Within the first tier that has any match: 1 → ok, >1 → ambiguous. Only a fully
1259
+ // empty tier falls through, so an exact match is never overridden by a substring.
1260
+ function resolveActionName(shortName: string, names: string[]): ActionResolution {
1261
+ if (names.includes(shortName)) return { status: 'ok', resolved: shortName, candidates: [shortName] };
1262
+ const finalSegment = (name: string): string => name.split('.').pop() ?? name;
1263
+ const exactSegment = names.filter((name) => finalSegment(name) === shortName);
1264
+ const tier = exactSegment.length > 0 ? exactSegment : names.filter((name) => finalSegment(name).includes(shortName));
1265
+ if (tier.length === 1) return { status: 'ok', resolved: tier[0], candidates: tier };
1266
+ if (tier.length > 1) return { status: 'ambiguous', resolved: '', candidates: tier };
1267
+ return { status: 'unknown', resolved: '', candidates: [] };
1268
+ }
1269
+
1270
+ function synthesizeOneNodeRecipe(action: string, args: Record<string, string>): Record<string, unknown> {
1271
+ return {
1272
+ schema_version: 1,
1273
+ title: `mm-harness call ${action}`,
1274
+ description: `Ad-hoc single-action execution of ${action} via the real engine path (mm-harness call).`,
1275
+ validate: {
1276
+ workflow: {
1277
+ entry: 'call',
1278
+ nodes: {
1279
+ call: { action, ...args, next: 'done', intent: `Call ${action} in isolation` },
1280
+ done: { action: 'end', status: 'pass' },
1281
+ },
1282
+ },
1283
+ },
1284
+ };
1285
+ }
1286
+
1287
+ // Reusable one-node engine path (the same machinery `call` uses), exposed so
1288
+ // `fixtures set` can apply the canonical wallet fixture through the real engine on
1289
+ // the extension (which has no standalone porcelain set arm).
1290
+ async function runOneNode(
1291
+ adapter: MetaMaskRecipeAdapter,
1292
+ action: string,
1293
+ args: Record<string, string>,
1294
+ target: string,
1295
+ actionManifest: string | undefined,
1296
+ ): Promise<{ status: 'pass' | 'fail' }> {
1297
+ const manifest = loadActionManifest(adapter, actionManifest);
1298
+ const recipe = synthesizeOneNodeRecipe(action, args);
1299
+ const validation = await validateRecipeAdapterAware(recipe, manifest);
1300
+ if (validation.status === 'invalid') return { status: 'fail' };
1301
+ const scratch = await mkdtemp(path.join(os.tmpdir(), 'mm-harness-fixtures-'));
1302
+ const recipeFile = path.join(scratch, 'set.recipe.json');
1303
+ fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}\n`);
1304
+ const result = await runRecipe(adapter, recipeFile, path.join(scratch, 'artifacts'), target, actionManifest, {
1305
+ librarySources: await resolveMetaMaskLibrarySources(undefined),
1306
+ });
1307
+ return { status: result.status === 'pass' ? 'pass' : 'fail' };
1308
+ }
1309
+
1310
+ // Pre-execution heal setup shared by `run` and `call` (both default `infra-only`).
1311
+ // Parses --heal, then auto-ensures the runtime overlay for the device adapters
1312
+ // (mobile/extension). core is headless — its engine path needs no launch overlay.
1313
+ // Returns the heal state + policy on success, or an exit code (number) on failure
1314
+ // (invalid --heal → usage; overlay install failed → infra) — a typeof-narrowable
1315
+ // result so this works regardless of strict-mode settings.
1316
+ interface PreparedHeal {
1317
+ state: HealState;
1318
+ heal: HealPolicy;
1319
+ }
1320
+
1321
+ async function prepareHeal(
1322
+ adapter: MetaMaskRecipeAdapter,
1323
+ target: string,
1324
+ options: CliOptions,
1325
+ json: boolean,
1326
+ ): Promise<PreparedHeal | number> {
1327
+ // Bound: refuse while another recipe is running (re-entrant run would corrupt
1328
+ // in-flight state). Enforced regardless of --heal policy. Also checked earlier
1329
+ // in handleCall (before action resolution) so the test can use any action name.
1330
+ if (recipeRunning(target)) {
1331
+ const msg = 'a recipe is currently running — refusing to start while another recipe executes.';
1332
+ if (json) {
1333
+ console.log(JSON.stringify({ schemaVersion: 1, status: 'fail', recoverable: false, error: { code: 'RECIPE_RUNNING', message: msg } }, null, 2));
1334
+ } else {
1335
+ console.error(`✗ mm-harness: ${msg}`);
1336
+ }
1337
+ return EXIT.bounded;
1338
+ }
1339
+
1340
+ const healValue = optionString(options, 'heal');
1341
+ const healOpts: Record<string, string | boolean> = {};
1342
+ if (healValue !== undefined) healOpts.heal = healValue;
1343
+ const heal = parseHeal(healOpts, 'infra-only');
1344
+ if (typeof heal !== 'string') {
1345
+ console.error((heal as { error: string }).error);
1346
+ return EXIT.usage;
1347
+ }
1348
+ const state = newHealState();
1349
+ const ensured = await ensureOverlay(adapter, target, heal as HealPolicy, state, json);
1350
+ if (!ensured.ok) {
1351
+ if (json) {
1352
+ console.log(
1353
+ JSON.stringify(
1354
+ { schemaVersion: 1, status: 'fail', recoverable: false, mutations: state.mutations, error: { code: 'OVERLAY_INSTALL_FAILED', message: ensured.error } },
1355
+ null,
1356
+ 2,
1357
+ ),
1358
+ );
1359
+ } else {
1360
+ console.error(`✗ overlay auto-ensure failed: ${ensured.error}`);
1361
+ }
1362
+ return EXIT.infra;
1363
+ }
1364
+ return { state, heal: heal as HealPolicy };
1365
+ }
1366
+
1367
+ // Recovery codes for the run/call heal loop — the analog of the launch recovery
1368
+ // codes. core is headless (no transport) but still carries a code so the shared
1369
+ // same-recovery-twice bound can fire and stop blind retry loops.
1370
+ const RUN_RECOVERY_CODE: Record<MetaMaskRecipeAdapter, string> = {
1371
+ mobile: 'metro.restarted',
1372
+ extension: 'chrome.reopened',
1373
+ core: 'runtime.reset',
1374
+ };
1375
+
1376
+ // Collect the failed nodes' error text from a run's trace.json, verbatim, so the
1377
+ // shared checkHealBounds can classify the failure (app-logic vs infra vs wallet)
1378
+ // and so the ORIGINAL cause survives into the --json/human output.
1379
+ function readRunFailureText(result: RecipeRunResult): string {
1380
+ try {
1381
+ const trace = JSON.parse(fs.readFileSync(result.tracePath, 'utf8')) as {
1382
+ entries?: Array<{ ok?: boolean; error?: unknown }>;
1383
+ };
1384
+ const entries = Array.isArray(trace.entries) ? trace.entries : [];
1385
+ return entries
1386
+ .filter((entry) => entry && entry.ok === false && typeof entry.error === 'string')
1387
+ .map((entry) => entry.error as string)
1388
+ .join('\n')
1389
+ .trim();
1390
+ } catch {
1391
+ return '';
1392
+ }
1393
+ }
1394
+
1395
+ // Shared run/call failure-and-retry path wired through the same checkHealBounds
1396
+ // module launch uses. On failure it classifies the verbatim trace output:
1397
+ // app-logic → surface verbatim, no heal (exit 1); wallet → refuse (exit 4);
1398
+ // recipe-running → refuse (exit 4). Infra failures get ONE bounded recovery re-run
1399
+ // (heal != off); a second failure trips the same-recovery-twice bound (exit 4).
1400
+ // `state.recovered[]`/`attemptedRecoveries[]` are mutated for the --json contract.
1401
+ // Returns the final result plus the violation (if any) to surface.
1402
+ async function executeWithHealBounds(
1403
+ exec: () => Promise<RecipeRunResult>,
1404
+ adapter: MetaMaskRecipeAdapter,
1405
+ target: string,
1406
+ heal: HealPolicy,
1407
+ state: HealState,
1408
+ ): Promise<{ result: RecipeRunResult; violation: HealBoundViolation | null }> {
1409
+ let result = await exec();
1410
+ for (;;) {
1411
+ if (result.status === 'pass') return { result, violation: null };
1412
+ const violation = checkHealBounds(target, readRunFailureText(result), state);
1413
+ if (violation !== null) return { result, violation };
1414
+ // Unclassified/infra failure. heal=off preserves the exact broken state.
1415
+ if (heal === 'off') return { result, violation: null };
1416
+ // Bounded infra recovery: re-run once. Record BEFORE the retry so the next
1417
+ // checkHealBounds call sees it and refuses a second loop (same-recovery-twice).
1418
+ const recoveryCode = RUN_RECOVERY_CODE[adapter];
1419
+ state.attemptedRecoveries.push(recoveryCode);
1420
+ result = await exec();
1421
+ if (result.status === 'pass') {
1422
+ state.recovered.push(recoveryCode);
1423
+ return { result, violation: null };
1424
+ }
1425
+ }
1426
+ }
1427
+
1428
+ // Hidden `completion-candidates <actions|flows>` — the source shell completion
1429
+ // scripts call it. Reads/writes the per-checkout completion cache so tab
1430
+ // completion never blocks on a manifest/library read. `flows` degrades gracefully
1431
+ // until @farmslot/recipe-harness >= 0.3.3 exposes the recipe-library API.
1432
+ async function handleCompletionCandidates(argv: string[]): Promise<number> {
1433
+ const kind = argv[0];
1434
+ const { options } = parseArgs(argv.slice(1), 'completion-candidates');
1435
+ const json = optionFlag(options, 'json');
1436
+ if (kind !== 'actions' && kind !== 'flows') {
1437
+ console.error('completion-candidates requires <actions|flows>.');
1438
+ return EXIT.usage;
1439
+ }
1440
+ // Actions are adapter-scoped (they come from the adapter manifest); library
1441
+ // flows are adapter-global, so flows completion works even outside a checkout.
1442
+ const target = targetPath(options);
1443
+ let adapter: MetaMaskRecipeAdapter | undefined;
1444
+
1445
+ let candidates: string[] | undefined;
1446
+ if (kind === 'actions') {
1447
+ adapter = resolveAdapter(options).adapter;
1448
+ candidates = readFreshCandidates(target, 'actions');
1449
+ if (!candidates) {
1450
+ const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
1451
+ const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
1452
+ candidates = getRecipeActionManifestActionNames(manifest);
1453
+ writeCompletionCandidates(target, 'actions', candidates);
1454
+ }
1455
+ } else {
1456
+ candidates = readFreshCandidates(target, 'flows');
1457
+ if (!candidates) {
1458
+ const sources = await resolveMetaMaskLibrarySources(undefined).catch(() => undefined);
1459
+ if (!sources) {
1460
+ // recipe-library API absent: return empty (not an error) so the shell
1461
+ // falls back to static completion instead of blocking.
1462
+ candidates = [];
1463
+ } else {
1464
+ const harness = await importRecipeHarness();
1465
+ const resolution = await harness.loadRecipeLibraries(sources);
1466
+ candidates = [...resolution.flows.keys()].sort();
1467
+ writeCompletionCandidates(target, 'flows', candidates);
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, kind, adapter, candidates }, null, 2));
1473
+ else for (const candidate of candidates) console.log(candidate);
1474
+ return EXIT.ok;
1475
+ }
1476
+
1477
+ async function handleRun({ positional, options }: ParsedArgs): Promise<number> {
1478
+ const targetRecipe = positional[0];
1479
+ if (!targetRecipe) throw usageError('run requires <recipe.json>.');
1480
+ // run --plan: validate + print the plan, touch NOTHING (no device, no overlay).
1481
+ if (optionFlag(options, 'plan')) return handleRunPlan(targetRecipe, options);
1482
+ const adapter = adapterOption(options);
1483
+ const json = optionFlag(options, 'json');
1484
+ const target = targetPath(options);
1485
+ const artifactsDir = requiredOption(options, 'artifactsDir', 'run requires --artifacts-dir <dir>.');
1486
+
1487
+ // --heal (default infra-only) + auto-ensure the runtime overlay before
1488
+ // execution. core is headless → no-op. Also enforces the recipe-running bound
1489
+ // (exit 4) before the recipe file is read — that refusal must precede validation.
1490
+ const prepared = await prepareHeal(adapter, target, options, json);
1491
+ if (typeof prepared === 'number') return prepared;
1492
+ const { state, heal } = prepared;
1493
+
1494
+ // Validate first (adapter-aware) — the SAME static path `run --plan` uses, before
1495
+ // launching/executing or resolving libraries. A missing or unparseable recipe is
1496
+ // a usage error (exit 2); validation errors exit 5 with the structured JSON.
1497
+ // Nothing here logs "Recipe libraries: …", so the machine contract on stdout
1498
+ // stays pure on the validation-failure path.
1499
+ const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
1500
+ if (validated.usageError) {
1501
+ return emitRunUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
1502
+ }
1503
+ if (validated.errorCount > 0) {
1504
+ return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
1505
+ }
1506
+
1507
+ const librarySources = await resolveMetaMaskLibrarySources(optionString(options, 'library'));
1508
+ const runtimeOptions: RuntimeOptions = {
1509
+ ...runtimeOptionsFromCli(options),
1510
+ ...(librarySources ? { librarySources } : {}),
1511
+ stdoutIsMachineContract: json,
1512
+ };
1513
+ // Failure/retry path wired through the shared checkHealBounds module:
1514
+ // app-logic → verbatim no-heal; wallet/recipe-running → refuse; infra → one
1515
+ // bounded recovery re-run, second failure → same-recovery-twice.
1516
+ const { result, violation } = await executeWithHealBounds(
1517
+ () => runRecipe(adapter, targetRecipe, artifactsDir, target, optionString(options, 'actionManifest'), runtimeOptions),
1518
+ adapter,
1519
+ target,
1520
+ heal,
1521
+ state,
1522
+ );
1523
+ if (violation !== null) return emitHealViolation(json, 'run', result, violation, state);
1524
+ const exitCode = result.status === 'pass' ? EXIT.ok : EXIT.runtime;
1525
+ if (json) {
1526
+ // mm-harness envelope (matches launch/verify/doctor): the engine's result is
1527
+ // nested under `result`; the heal contract (recovered[]/mutations[]) and the
1528
+ // resolved exitCode ride the envelope regardless of adapter.
1529
+ console.log(
1530
+ JSON.stringify(
1531
+ {
1532
+ schemaVersion: 1,
1533
+ command: 'run',
1534
+ adapter,
1535
+ status: result.status,
1536
+ exitCode,
1537
+ recovered: state.recovered,
1538
+ mutations: state.mutations,
1539
+ result,
1540
+ },
1541
+ null,
1542
+ 2,
1543
+ ),
1544
+ );
1545
+ } else {
1546
+ console.log(`MetaMask recipe run: ${result.status}\nArtifacts: ${result.artifactManifestPath}`);
1547
+ }
1548
+ return exitCode;
1549
+ }
1550
+
1551
+ // Usage error for the `run` execute path (recipe missing / unparseable — exit 2).
1552
+ // Mirrors the plan emitter but carries the run envelope keys, not `mode: 'plan'`.
1553
+ function emitRunUsageError(
1554
+ json: boolean,
1555
+ adapter: MetaMaskRecipeAdapter,
1556
+ recipeFile: string,
1557
+ code: string,
1558
+ message: string,
1559
+ ): number {
1560
+ if (json) {
1561
+ console.log(
1562
+ JSON.stringify(
1563
+ { schemaVersion: 1, command: 'run', adapter, status: 'fail', exitCode: EXIT.usage, recipe: recipeFile, error: { code, message } },
1564
+ null,
1565
+ 2,
1566
+ ),
1567
+ );
1568
+ } else {
1569
+ console.error(`✗ run: ${message}`);
1570
+ }
1571
+ return EXIT.usage;
1572
+ }
1573
+
1574
+ // Validation failure for the `run` execute path — exit 5 with the structured
1575
+ // finding list, before the app is launched or the recipe executes (parity with
1576
+ // `run --plan` and `call`).
1577
+ function emitRunValidationError(
1578
+ json: boolean,
1579
+ adapter: MetaMaskRecipeAdapter,
1580
+ recipeFile: string,
1581
+ findings: RecipeValidationFinding[],
1582
+ errorCount: number,
1583
+ ): number {
1584
+ const message = `recipe validation found ${errorCount} error(s)`;
1585
+ if (json) {
1586
+ console.log(
1587
+ JSON.stringify(
1588
+ {
1589
+ schemaVersion: 1,
1590
+ command: 'run',
1591
+ adapter,
1592
+ status: 'fail',
1593
+ exitCode: EXIT.validation,
1594
+ recovered: [],
1595
+ mutations: [],
1596
+ recipe: recipeFile,
1597
+ findings,
1598
+ error: { code: 'RECIPE_VALIDATION_FAILED', message },
1599
+ },
1600
+ null,
1601
+ 2,
1602
+ ),
1603
+ );
1604
+ } else {
1605
+ console.error(`✗ run: ${message}`);
1606
+ for (const finding of findings) {
1607
+ if (finding.severity === 'error') console.error(` ${finding.code} ${finding.path} — ${finding.message}`);
1608
+ }
1609
+ }
1610
+ return EXIT.validation;
1611
+ }
1612
+
1613
+ // Shared failure emitter for the run/call heal path — surfaces the bound-specific
1614
+ // error code, the classification note, and the ORIGINAL failure verbatim in both
1615
+ // --json and human modes.
1616
+ function emitHealViolation(
1617
+ json: boolean,
1618
+ command: 'run' | 'call',
1619
+ result: RecipeRunResult,
1620
+ violation: HealBoundViolation,
1621
+ state: HealState,
1622
+ ): number {
1623
+ if (json) {
1624
+ console.log(
1625
+ JSON.stringify(
1626
+ {
1627
+ schemaVersion: 1,
1628
+ command,
1629
+ status: 'fail',
1630
+ recoverable: false,
1631
+ recovered: state.recovered,
1632
+ mutations: state.mutations,
1633
+ attemptedRecoveries: state.attemptedRecoveries,
1634
+ summaryPath: result.summaryPath,
1635
+ tracePath: result.tracePath,
1636
+ artifactManifestPath: result.artifactManifestPath,
1637
+ exitCode: violation.exitCode,
1638
+ error: {
1639
+ code: violation.code,
1640
+ message: violation.message,
1641
+ retryable: false,
1642
+ userAction: violation.userAction ?? null,
1643
+ originalError: violation.originalError ?? null,
1644
+ },
1645
+ },
1646
+ null,
1647
+ 2,
1648
+ ),
1649
+ );
1650
+ } else {
1651
+ console.error(
1652
+ `✗ mm-harness ${command}: ${violation.message}` +
1653
+ (violation.originalError ? `\n --- original failure ---\n${violation.originalError}` : '') +
1654
+ (violation.userAction ? `\n Next: ${violation.userAction}` : ''),
1655
+ );
1656
+ }
1657
+ return violation.exitCode;
1658
+ }
1659
+
1660
+ async function handleSelfTest({ options }: ParsedArgs): Promise<number> {
1661
+ const result = await runSelfTest(options);
1662
+ if (optionFlag(options, 'json')) console.log(JSON.stringify(result, null, 2));
1663
+ else console.log(`MetaMask runner self-test: ${result.status}\nArtifacts: ${result.artifactsDir}`);
1664
+ return result.status === 'pass' ? 0 : 1;
1665
+ }
1666
+
1667
+ function runtimeOptionsFromCli(options: CliOptions): RuntimeOptions {
1668
+ const recordVideo = options.recordVideo;
1669
+ return {
1670
+ cdpPort: optionString(options, 'cdpPort'),
1671
+ watcherPort: optionString(options, 'watcherPort') ?? optionString(options, 'metroPort'),
1672
+ launchExistingDist: optionFlag(options, 'launchExistingDist'),
1673
+ slot: optionString(options, 'slot'),
1674
+ validationRuntimeDir: optionString(options, 'validationRuntimeDir'),
1675
+ recordVideo: recordVideo === 'full-run' ? 'full-run' : false,
1676
+ };
1677
+ }
1678
+
1679
+ function parsePort(value: string | undefined, errorMessage: string): number {
1680
+ const port = Number(value);
1681
+ if (!Number.isInteger(port) || port <= 0) throw usageError(errorMessage);
1682
+ return port;
1683
+ }
1684
+
1685
+ /**
1686
+ * Resolve the ordered library sources for a run: the developer's explicit
1687
+ * entries (or their personal library) first, then this runner's canonical
1688
+ * `library/` appended last so personal/team flows win by default. Returns
1689
+ * undefined when the installed harness predates recipe libraries.
1690
+ */
1691
+ async function resolveMetaMaskLibrarySources(
1692
+ libraryEntry: string | undefined,
1693
+ ): Promise<MetaMaskLibrarySource[] | undefined> {
1694
+ const harness = (await importRecipeHarness()) as RecipeLibraryCapableHarness;
1695
+ if (typeof harness.resolveRecipeLibrarySources !== 'function') {
1696
+ if (libraryEntry) {
1697
+ throw usageError(
1698
+ '--library requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support / resolveRecipeLibrarySources). ' +
1699
+ 'The pinned 0.3.0 lacks it, and npm 0.3.2 still does not export it — recipe-library support is PENDING PUBLISH from farmslot.',
1700
+ );
1701
+ }
1702
+ return undefined;
1703
+ }
1704
+ const sources = await harness.resolveRecipeLibrarySources(
1705
+ libraryEntry ? { cliEntries: [libraryEntry] } : undefined,
1706
+ );
1707
+ sources.push({ name: 'metamask', root: path.join(runnerDir, 'library') });
1708
+ return sources;
1709
+ }
1710
+
1711
+ function serializeLibrarySources(sources: MetaMaskLibrarySource[]): string {
1712
+ return sources
1713
+ .map((source) => (source.name ? `${source.name}=${source.root}` : source.root))
1714
+ .join(':');
1715
+ }
1716
+
1717
+ // The engine's flows subcommands (grounded in registerFlowsCommand): a bare
1718
+ // `flows` (or one that leads with a flag) means list.
1719
+ const FLOWS_SUBCOMMANDS: readonly string[] = ['list', 'promote'];
1720
+
1721
+ // --target is a runner-level flag (checkout selection); the engine's flows
1722
+ // subcommands do not accept it and flows resolution is adapter-global (the
1723
+ // canonical MetaMask library + the personal library), so it has no effect here.
1724
+ // Drop it (with its value) before forwarding so the engine never sees an unknown
1725
+ // option. Every other flag/positional forwards verbatim.
1726
+ function stripTargetFlag(argv: string[]): string[] {
1727
+ const out: string[] = [];
1728
+ for (let i = 0; i < argv.length; i += 1) {
1729
+ const arg = argv[i];
1730
+ if (arg === '--target') {
1731
+ i += 1; // skip the value too
1732
+ continue;
1733
+ }
1734
+ if (arg.startsWith('--target=')) continue;
1735
+ out.push(arg);
1736
+ }
1737
+ return out;
1738
+ }
1739
+
1740
+ // flows delegates to the harness CLI with the resolved MetaMask default sources
1741
+ // exported through RECIPE_LIBRARY_PATH, so list/promote behave exactly like a
1742
+ // run's resolution.
1743
+ async function handleFlows(argv: string[]): Promise<number> {
1744
+ const sources = await resolveMetaMaskLibrarySources(undefined);
1745
+ if (!sources) {
1746
+ throw usageError(
1747
+ 'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support). ' +
1748
+ 'Next: update the dependency and run yarn install.',
1749
+ );
1750
+ }
1751
+ const forwarded = stripTargetFlag(argv);
1752
+ // The engine requires an explicit subcommand. Inspect only the FIRST token: a
1753
+ // real subcommand there is passed through; anything else (empty, or a leading
1754
+ // flag whose VALUE must not be mistaken for a subcommand) defaults to list.
1755
+ const withSubcommand = FLOWS_SUBCOMMANDS.includes(forwarded[0])
1756
+ ? forwarded
1757
+ : ['list', ...forwarded];
1758
+ const { runRecipeHarnessCli } = await importRecipeHarnessCli();
1759
+ const previousLibraryPath = process.env.RECIPE_LIBRARY_PATH;
1760
+ process.env.RECIPE_LIBRARY_PATH = serializeLibrarySources(sources);
1761
+ try {
1762
+ await runRecipeHarnessCli(['flows', ...withSubcommand]);
1763
+ } finally {
1764
+ restoreEnv('RECIPE_LIBRARY_PATH', previousLibraryPath);
1765
+ }
1766
+ return 0;
1767
+ }
1768
+
1769
+ export async function main(argv: string[]): Promise<number> {
1770
+ const command = argv[0];
1771
+ if (!command || command === '-h' || command === '--help') {
1772
+ usage();
1773
+ return command ? 0 : 2;
1774
+ }
1775
+ if (command === 'flows') return handleFlows(argv.slice(1));
1776
+ // call + completion-candidates parse repeatable/positional args of their own, so
1777
+ // they take raw argv rather than the shared Record-based parseArgs.
1778
+ if (command === 'call') return handleCall(argv.slice(1));
1779
+ if (command === 'completion-candidates') return handleCompletionCandidates(argv.slice(1));
1780
+ // Top-level overlay commands route to handleHarness; the command itself is the
1781
+ // action, so forward argv unchanged.
1782
+ if (OVERLAY_COMMANDS.includes(command)) {
1783
+ const code = await handleHarness(argv);
1784
+ // The overlay defines the action/flow vocabulary — a successful install
1785
+ // invalidates the per-checkout completion cache (dynamic-completions contract).
1786
+ if (code === 0 && command === 'install') {
1787
+ try {
1788
+ invalidateCompletionCache(targetPath(parseArgs(argv.slice(1), command).options));
1789
+ } catch {
1790
+ // Cache invalidation is best-effort; never fail an install on it.
1791
+ }
1792
+ }
1793
+ return code;
1794
+ }
1795
+ // launch/logs/debug/fixtures compose the porcelain paths and own policy,
1796
+ // healing, teaching, and the --json contract.
1797
+ if (command === 'launch') return handleLaunch(argv.slice(1));
1798
+ if (command === 'logs') return handleLogs(argv.slice(1));
1799
+ if (command === 'debug') return handleDebug(argv.slice(1));
1800
+ if (command === 'fixtures') return handleFixtures(argv.slice(1), { runOneNode });
1801
+ const handler = COMMANDS[command];
1802
+ if (!handler) throw new Error(`Unknown command: ${command}`);
1803
+ return handler(parseArgs(argv.slice(1), command));
1804
+ }
1805
+
1806
+ // Auto-run only when this file IS the process entry (orchestration/porcelain/metamask-recipe
1807
+ // execs it directly). The commander front (`runner/src/mm-harness-cli.ts`) sets this flag
1808
+ // before importing so it can reuse `main` without triggering a second dispatch.
1809
+ if (!(globalThis as Record<string, unknown>).__MM_HARNESS_WRAPPER__) {
1810
+ try {
1811
+ process.exit(await main(process.argv.slice(2)));
1812
+ } catch (error) {
1813
+ console.error(error instanceof Error ? error.message : String(error));
1814
+ process.exit(
1815
+ error !== null && typeof error === 'object' && 'exitCode' in error && typeof (error as Record<string, unknown>).exitCode === 'number'
1816
+ ? (error as { exitCode: number }).exitCode
1817
+ : 1,
1818
+ );
1819
+ }
1820
+ }