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