@deeeed/metamask-harness 0.15.2 → 0.17.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.
@@ -15,7 +15,8 @@ import {
15
15
  spawnScript,
16
16
  str,
17
17
  targetOf,
18
- usageOut
18
+ usageOut,
19
+ writeInteractiveProgress
19
20
  } from "../shared.js";
20
21
  import {
21
22
  RECOVERY_CODE,
@@ -33,6 +34,7 @@ import {
33
34
  import { launchMobile } from "./mobile.js";
34
35
  import { applyDeviceTargeting } from "../device-target.js";
35
36
  import { acquireCheckoutLock } from "../../checkout-lock.js";
37
+ import { JsonStreamWriter } from "../../json-stream.js";
36
38
  const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
37
39
  "build",
38
40
  "watch",
@@ -40,30 +42,58 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
40
42
  "sidepanel",
41
43
  "fullscreen",
42
44
  "runway",
43
- "json"
45
+ "json",
46
+ "jsonStream"
44
47
  ]);
45
48
  const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
46
49
  async function handleLaunch(argv) {
47
50
  const { options } = parseFlags(argv, LAUNCH_BOOLEANS);
48
51
  const json = flag(options, "json");
52
+ const stream = new JsonStreamWriter("launch", flag(options, "jsonStream"));
53
+ const jsonOutput = json && !stream.enabled;
54
+ const restoreStdout = stream.isolateStdout();
49
55
  const target = targetOf(options);
50
- if (!fs.existsSync(target)) return handleLaunchLocked(argv);
51
- const lock = acquireCheckoutLock(target, "launch");
52
- if ("message" in lock) {
53
- return checkoutBusyOut(json, "launch", lock.message, lock.path);
54
- }
55
56
  try {
56
- return await handleLaunchLocked(argv);
57
+ let exitCode;
58
+ if (!fs.existsSync(target)) {
59
+ exitCode = await handleLaunchLocked(argv, stream);
60
+ } else {
61
+ const lock = acquireCheckoutLock(target, "launch");
62
+ if ("message" in lock) {
63
+ const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
64
+ stream.error({ code: "SANDBOX_BUSY", message: lock.message, userAction });
65
+ exitCode = checkoutBusyOut(jsonOutput, "launch", lock.message, lock.path);
66
+ } else {
67
+ try {
68
+ exitCode = await handleLaunchLocked(argv, stream);
69
+ } finally {
70
+ lock.release();
71
+ }
72
+ }
73
+ }
74
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
75
+ return exitCode;
76
+ } catch (error) {
77
+ const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
78
+ stream.error({
79
+ code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "LAUNCH_FAILED",
80
+ message: error instanceof Error ? error.message : String(error),
81
+ userAction: `mm-harness doctor --target ${shellQuote(target)} --json`
82
+ });
83
+ stream.complete("fail", exitCode);
84
+ throw error;
57
85
  } finally {
58
- lock.release();
86
+ restoreStdout();
59
87
  }
60
88
  }
61
- async function handleLaunchLocked(argv) {
89
+ async function handleLaunchLocked(argv, stream) {
62
90
  const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
63
91
  const json = flag(options, "json");
92
+ const jsonOutput = json && !stream.enabled;
93
+ const machine = json || stream.enabled;
64
94
  const target = targetOf(options);
65
95
  const heal = parseHeal(options, "auto");
66
- if (typeof heal !== "string") return usageOut(json, "launch", heal.error, "use --heal off|infra-only|auto");
96
+ if (typeof heal !== "string") return launchUsage(jsonOutput, stream, heal.error, "use --heal off|infra-only|auto");
67
97
  const posToken = positional[0];
68
98
  const mobileTargetToken = posToken === "ios" || posToken === "android" ? posToken : void 0;
69
99
  const platformFlag = str(options, "platform");
@@ -72,15 +102,16 @@ async function handleLaunchLocked(argv) {
72
102
  const adapterHint = mobileTarget ? "mobile" : void 0;
73
103
  const adapter = resolveAdapter(options, target, adapterHint);
74
104
  if (!adapter) {
75
- return usageOut(json, "launch", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
105
+ return launchUsage(jsonOutput, stream, `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
76
106
  }
107
+ stream.phase("resolve", { target, adapter, platform: mobileTarget ?? null });
77
108
  if (adapter === "core") {
78
- return usageOut(json, "launch", "core is headless; there is nothing to launch.", "mm-harness verify");
109
+ return launchUsage(jsonOutput, stream, "core is headless; there is nothing to launch.", "mm-harness verify");
79
110
  }
80
111
  if (adapter === "mobile" && !mobileTarget) {
81
- return usageOut(
82
- json,
83
- "launch",
112
+ return launchUsage(
113
+ jsonOutput,
114
+ stream,
84
115
  "target is required for mobile.",
85
116
  "mm-harness launch ios or mm-harness launch android"
86
117
  );
@@ -90,15 +121,15 @@ async function handleLaunchLocked(argv) {
90
121
  const wantWatch = flag(options, "watch");
91
122
  const wantRunway = flag(options, "runway");
92
123
  if (wantRunway && adapter !== "mobile") {
93
- return usageOut(json, "launch", "runway is mobile-only.", "drop --runway for the extension");
124
+ return launchUsage(jsonOutput, stream, "runway is mobile-only.", "drop --runway for the extension");
94
125
  }
95
126
  const displayMode = flag(options, "sidepanel") && !flag(options, "fullscreen") ? "sidepanel" : "fullscreen";
96
127
  for (const portFlag of ["cdpPort", "watcherPort"]) {
97
128
  const value = str(options, portFlag);
98
129
  if (value !== void 0 && !/^\d+$/u.test(value)) {
99
- return usageOut(
100
- json,
101
- "launch",
130
+ return launchUsage(
131
+ jsonOutput,
132
+ stream,
102
133
  `--${portFlag === "cdpPort" ? "cdp-port" : "watcher-port"} must be numeric (got: ${value}).`,
103
134
  `pass a numeric port, e.g. --${portFlag === "cdpPort" ? "cdp-port 6663" : "watcher-port 8081"}`
104
135
  );
@@ -106,7 +137,7 @@ async function handleLaunchLocked(argv) {
106
137
  }
107
138
  const envResult = applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
108
139
  if (envResult && "code" in envResult) {
109
- return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
140
+ return launchUsage(jsonOutput, stream, envResult.message, envResult.userAction);
110
141
  }
111
142
  const tier = wantBuild ? "build" : "quick";
112
143
  if (adapter === "extension") {
@@ -115,44 +146,55 @@ async function handleLaunchLocked(argv) {
115
146
  process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
116
147
  }
117
148
  }
118
- if (!json && adapter === "extension") {
149
+ if (!machine && adapter === "extension") {
119
150
  const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
120
151
  const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
121
152
  console.error(
122
153
  `\u2192 extension launch \u2014 ${modeNote} \xB7 CDP :${process.env.CDP_PORT ?? "default"} \xB7 ${workNote}`
123
154
  );
124
155
  }
156
+ if (adapter === "mobile") {
157
+ const device = str(options, "device") ?? (mobileTarget === "android" ? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL : process.env.IOS_SIMULATOR ?? process.env.SIM_UDID) ?? "configured device";
158
+ const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
159
+ writeInteractiveProgress(
160
+ machine,
161
+ `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}`
162
+ );
163
+ }
125
164
  const state = newHealState();
126
165
  if (tier === "quick" && nativeInputsChanged(target, adapter)) {
127
- return usageOut(
128
- json,
129
- "launch",
166
+ return launchUsage(
167
+ jsonOutput,
168
+ stream,
130
169
  "native build inputs changed since last build.",
131
170
  `mm-harness launch ${adapter === "mobile" ? `${mobileTarget} ` : ""}--build`
132
171
  );
133
172
  }
134
- const ensured = await ensureOverlay(adapter, target, heal, state, json);
173
+ stream.phase("install");
174
+ const ensured = await ensureOverlay(adapter, target, heal, state, machine);
135
175
  if (!ensured.ok) {
136
- return launchFail(json, adapter, mobileTarget, tier, state, {
176
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
137
177
  code: "OVERLAY_INSTALL_FAILED",
138
178
  message: ensured.error ?? "overlay install failed",
139
179
  recoverable: false,
140
180
  exitCode: EXIT.infra
141
181
  });
142
182
  }
183
+ stream.phase("healthcheck");
143
184
  if (adapter === "extension") {
144
185
  const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
145
186
  if (willBuild) {
146
187
  const depsBlock = extensionDepsBlock(target);
147
188
  if (depsBlock) {
148
189
  if (heal === "off") {
149
- return usageOut(json, "launch", depsBlock.message, depsBlock.userAction);
190
+ return launchUsage(jsonOutput, stream, depsBlock.message, depsBlock.userAction);
150
191
  }
151
192
  const recoveryCode2 = "deps.installed";
152
193
  state.attemptedRecoveries.push(recoveryCode2);
194
+ stream.phase("recover");
153
195
  const repaired = await installExtensionDeps(target);
154
196
  if (repaired.status !== 0 || extensionDepsBlock(target)) {
155
- return launchFail(json, adapter, mobileTarget, tier, state, {
197
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
156
198
  code: "DEPENDENCY_INSTALL_FAILED",
157
199
  message: depsBlock.message,
158
200
  recoverable: false,
@@ -166,12 +208,13 @@ async function handleLaunchLocked(argv) {
166
208
  }
167
209
  }
168
210
  }
169
- let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
211
+ stream.phase("launch");
212
+ let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
170
213
  if (attempt.status === 0) {
171
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
214
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
172
215
  }
173
216
  if (heal === "off") {
174
- return launchFail(json, adapter, mobileTarget, tier, state, {
217
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
175
218
  code: "LAUNCH_FAILED",
176
219
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed (healing off)`,
177
220
  recoverable: false,
@@ -181,12 +224,13 @@ async function handleLaunchLocked(argv) {
181
224
  if (adapter === "extension" && tier === "quick" && extensionQuickReattachFailed(attempt.output)) {
182
225
  const recoveryCode2 = "chrome.relaunched";
183
226
  state.attemptedRecoveries.push(recoveryCode2);
184
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
227
+ stream.phase("recover");
228
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
185
229
  if (rebuildAttempt.status === 0) {
186
230
  state.recovered.push(recoveryCode2);
187
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
231
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
188
232
  }
189
- return launchFail(json, adapter, mobileTarget, tier, state, {
233
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
190
234
  code: "EXTENSION_RELAUNCH_FAILED",
191
235
  message: "quick reattach failed, and clean relaunch failed too.",
192
236
  recoverable: false,
@@ -195,7 +239,7 @@ async function handleLaunchLocked(argv) {
195
239
  });
196
240
  }
197
241
  if (adapter === "mobile" && mobileProvisioningBlocked(attempt.output)) {
198
- return launchFail(json, adapter, mobileTarget, tier, state, {
242
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
199
243
  code: "MOBILE_PROVISION_REQUIRED",
200
244
  message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
201
245
  recoverable: false,
@@ -206,7 +250,7 @@ async function handleLaunchLocked(argv) {
206
250
  }
207
251
  const bound = checkHealBounds(target, attempt.output, state);
208
252
  if (bound !== null) {
209
- return launchFail(json, adapter, mobileTarget, tier, state, {
253
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
210
254
  code: bound.code,
211
255
  message: bound.message,
212
256
  recoverable: false,
@@ -217,12 +261,13 @@ async function handleLaunchLocked(argv) {
217
261
  }
218
262
  const recoveryCode = RECOVERY_CODE[adapter];
219
263
  state.attemptedRecoveries.push(recoveryCode);
220
- attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
264
+ stream.phase("recover");
265
+ attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
221
266
  if (attempt.status === 0) {
222
267
  state.recovered.push(recoveryCode);
223
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
268
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
224
269
  }
225
- return launchFail(json, adapter, mobileTarget, tier, state, {
270
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
226
271
  code: "SAME_RECOVERY_TWICE",
227
272
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed again after ${recoveryCode} recovery \u2014 refusing to loop.`,
228
273
  recoverable: false,
@@ -275,14 +320,16 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
275
320
  return {
276
321
  ok: false,
277
322
  code: "DEVICE_WRONG_PLATFORM",
278
- message: `--device ${device} did not resolve to an Android device for launch android.`
323
+ message: `--device ${device} did not resolve to an Android device for launch android.`,
324
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
279
325
  };
280
326
  }
281
327
  if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
282
328
  return {
283
329
  ok: false,
284
330
  code: "DEVICE_WRONG_PLATFORM",
285
- message: `--device ${device} did not resolve to an iOS simulator for launch ios.`
331
+ message: `--device ${device} did not resolve to an iOS simulator for launch ios.`,
332
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
286
333
  };
287
334
  }
288
335
  }
@@ -328,26 +375,29 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
328
375
  }
329
376
  return launchExtension(target, tier, wantWatch, displayMode);
330
377
  }
331
- async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
378
+ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
332
379
  if (adapter === "extension" && displayMode === "sidepanel") {
333
380
  const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
334
381
  const sidepanelArgs = ["open"];
335
382
  if (process.env.CDP_PORT) sidepanelArgs.push("--cdp-port", process.env.CDP_PORT);
336
- if (!json) {
383
+ if (!machine) {
337
384
  console.error(
338
385
  `[sidepanel] opening panel beside ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}`
339
386
  );
340
387
  }
341
- const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, json, { REPO: target });
388
+ const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, machine, { REPO: target });
342
389
  if (sidepanel.status !== 0) {
343
390
  const recoveryCode = "chrome.relaunched";
344
391
  if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
345
392
  state.attemptedRecoveries.push(recoveryCode);
346
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
393
+ stream.phase("recover");
394
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
347
395
  if (rebuildAttempt.status === 0) {
348
396
  state.recovered.push(recoveryCode);
349
397
  return await finishLaunch(
350
- json,
398
+ jsonOutput,
399
+ machine,
400
+ stream,
351
401
  adapter,
352
402
  mobileTarget,
353
403
  tier,
@@ -359,7 +409,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
359
409
  false
360
410
  );
361
411
  }
362
- return launchFail(json, adapter, mobileTarget, tier, state, {
412
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
363
413
  code: "EXTENSION_RELAUNCH_FAILED",
364
414
  message: "sidepanel open found a blocked extension runtime, and clean relaunch failed too.",
365
415
  recoverable: false,
@@ -367,7 +417,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
367
417
  originalError: rebuildAttempt.output.trim() || void 0
368
418
  });
369
419
  }
370
- return launchFail(json, adapter, mobileTarget, tier, state, {
420
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
371
421
  code: "SIDEPANEL_OPEN_FAILED",
372
422
  message: "app launched but opening the side panel failed.",
373
423
  recoverable: false,
@@ -378,15 +428,38 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
378
428
  }
379
429
  }
380
430
  if (wantVerify) {
431
+ stream.phase("verify");
381
432
  const verifyArgs = ["verify", "--adapter", adapter, "--target", target];
382
433
  if (adapter === "mobile" && mobileTarget) verifyArgs.push("--platform", mobileTarget);
383
- if (json) verifyArgs.push("--json");
384
- return handleHarness(verifyArgs);
434
+ if (machine) verifyArgs.push("--json");
435
+ const exitCode = await handleHarness(verifyArgs);
436
+ if (stream.enabled) {
437
+ for (const mutation of state.mutations) stream.mutation(mutation);
438
+ for (const recovery of state.recovered) stream.recovery(recovery);
439
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode, {
440
+ adapter,
441
+ platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
442
+ tier,
443
+ recovered: state.recovered,
444
+ mutations: state.mutations
445
+ });
446
+ }
447
+ return exitCode;
385
448
  }
386
- return launchPass(json, adapter, mobileTarget, tier, displayMode, state);
449
+ return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, state);
387
450
  }
388
- function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
389
- if (json) {
451
+ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, state) {
452
+ if (stream.enabled) {
453
+ for (const mutation of state.mutations) stream.mutation(mutation);
454
+ for (const recovery of state.recovered) stream.recovery(recovery);
455
+ stream.complete("pass", EXIT.ok, {
456
+ adapter,
457
+ platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
458
+ tier,
459
+ recovered: state.recovered,
460
+ mutations: state.mutations
461
+ });
462
+ } else if (json) {
390
463
  console.log(
391
464
  JSON.stringify(
392
465
  {
@@ -424,8 +497,32 @@ function launchDeviceLabel(mobileTarget) {
424
497
  }
425
498
  return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
426
499
  }
427
- function launchFail(json, adapter, mobileTarget, tier, state, failure) {
428
- if (json) {
500
+ function launchUsage(json, stream, message, userAction) {
501
+ stream.error({ code: "USAGE", message, userAction });
502
+ return usageOut(json, "launch", message, userAction);
503
+ }
504
+ function launchFail(json, stream, adapter, mobileTarget, tier, state, target, failure) {
505
+ const userAction = failure.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
506
+ if (stream.enabled) {
507
+ for (const mutation of state.mutations) stream.mutation(mutation);
508
+ for (const recovery of state.recovered) stream.recovery(recovery);
509
+ stream.error({
510
+ code: failure.code,
511
+ message: failure.message,
512
+ retryable: failure.recoverable,
513
+ userAction,
514
+ originalError: failure.originalError ?? null
515
+ });
516
+ stream.complete("fail", failure.exitCode, {
517
+ adapter,
518
+ platform: mobileTarget ?? null,
519
+ tier,
520
+ recovered: state.recovered,
521
+ mutations: state.mutations,
522
+ recoverable: failure.recoverable,
523
+ attemptedRecoveries: state.attemptedRecoveries
524
+ });
525
+ } else if (json) {
429
526
  console.log(
430
527
  JSON.stringify(
431
528
  {
@@ -445,7 +542,7 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
445
542
  code: failure.code,
446
543
  message: failure.message,
447
544
  retryable: failure.recoverable,
448
- userAction: failure.userAction ?? null,
545
+ userAction,
449
546
  originalError: failure.originalError ?? null
450
547
  }
451
548
  },
@@ -458,8 +555,8 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
458
555
  `\u2717 launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed
459
556
  ${failure.message}` + (failure.originalError ? `
460
557
  --- original failure ---
461
- ${failure.originalError}` : "") + (failure.userAction ? `
462
- Next: ${failure.userAction}` : "")
558
+ ${failure.originalError}` : "") + `
559
+ Next: ${userAction}`
463
560
  );
464
561
  }
465
562
  return failure.exitCode;
@@ -16,42 +16,125 @@ async function handleManifest({ options }) {
16
16
  else console.log(actionManifestPath);
17
17
  return 0;
18
18
  }
19
- async function handleActions({ options }) {
19
+ async function handleActions({ options, positional }) {
20
20
  const { adapter } = resolveAdapter(options);
21
21
  const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
22
22
  await validateManifest(manifest);
23
23
  const json = optionFlag(options, "json");
24
24
  const action = optionString(options, "action");
25
+ const query = positional[0]?.trim();
26
+ const category = optionString(options, "category")?.toLowerCase();
27
+ const categoriesOnly = optionFlag(options, "categories");
25
28
  const all = describeManifestActions(manifest);
26
- const actions = action ? fuzzyResolveActions(all, action) : all;
29
+ const categories = summarizeActionCategories(all);
30
+ if (categoriesOnly && (action || category)) {
31
+ const message = "--categories cannot be combined with --action or --category.";
32
+ const userAction = `mm-harness actions --adapter ${adapter} --categories`;
33
+ if (json) {
34
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, error: { code: "ACTION_FILTER_CONFLICT", message, userAction } }, null, 2));
35
+ } else {
36
+ console.error(`\u2717 mm-harness actions: ${message}
37
+ Next: ${userAction}`);
38
+ }
39
+ return EXIT.usage;
40
+ }
41
+ if (categoriesOnly) {
42
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, categories }, null, 2));
43
+ else for (const entry of categories) console.log(`${entry.name} (${entry.count})`);
44
+ return EXIT.ok;
45
+ }
46
+ const categoryActions = category ? all.filter((entry) => entry.category === category) : all;
47
+ if (category && categoryActions.length === 0) {
48
+ const message = `no action category matches "${category}" for the ${adapter} adapter.`;
49
+ const userAction = `mm-harness actions --adapter ${adapter} --categories`;
50
+ if (json) {
51
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, category, availableCategories: categories, error: { code: "ACTION_CATEGORY_UNKNOWN", message, userAction } }, null, 2));
52
+ } else {
53
+ console.error(`\u2717 mm-harness actions: ${message}
54
+ Next: ${userAction}`);
55
+ }
56
+ return EXIT.usage;
57
+ }
58
+ const actions = action ? fuzzyResolveActions(categoryActions, action) : query ? searchActions(categoryActions, query) : categoryActions;
27
59
  if (action && actions.length === 0) {
28
60
  const message = `no action matches "${action}" for the ${adapter} adapter.`;
29
- const userAction = `mm-harness actions --adapter ${adapter} # list the vocabulary`;
61
+ const userAction = `mm-harness actions --adapter ${adapter}`;
62
+ if (json) {
63
+ console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, category, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
64
+ } else {
65
+ console.error(`\u2717 mm-harness actions: ${message}
66
+ Next: ${userAction}`);
67
+ }
68
+ return EXIT.usage;
69
+ }
70
+ if (query && actions.length === 0) {
71
+ const message = `no action matches search "${query}" for the ${adapter} adapter.`;
72
+ const userAction = `mm-harness actions --adapter ${adapter} --categories`;
30
73
  if (json) {
31
- console.log(JSON.stringify({ schemaVersion: 1, command: "actions", adapter, action, error: { code: "ACTION_UNKNOWN", message, userAction } }, null, 2));
74
+ console.log(JSON.stringify({
75
+ schemaVersion: 1,
76
+ command: "actions",
77
+ adapter,
78
+ query,
79
+ category,
80
+ error: { code: "ACTION_SEARCH_EMPTY", message, userAction }
81
+ }, null, 2));
32
82
  } else {
33
83
  console.error(`\u2717 mm-harness actions: ${message}
34
84
  Next: ${userAction}`);
35
85
  }
36
86
  return EXIT.usage;
37
87
  }
88
+ const relatedActions = action && actions.length === 1 ? findRelatedActions(all, actions[0]) : void 0;
38
89
  if (json) {
39
- console.log(JSON.stringify({ adapter, actions }, null, 2));
90
+ console.log(JSON.stringify({
91
+ schemaVersion: 1,
92
+ command: "actions",
93
+ adapter,
94
+ ...query ? { query } : {},
95
+ category,
96
+ actions,
97
+ ...relatedActions ? { relatedActions } : {}
98
+ }, null, 2));
40
99
  } else {
41
100
  for (const entry of actions) {
42
101
  const fields = entry.fields.length ? ` fields=${entry.fields.join(",")}` : "";
43
102
  console.log(`${entry.name} (${entry.kind})${fields}${entry.description ? ` \u2014 ${entry.description}` : ""}`);
44
103
  }
104
+ if (relatedActions) console.log(`Related: ${relatedActions.join(", ")}`);
45
105
  }
46
106
  return 0;
47
107
  }
48
108
  function fuzzyResolveActions(entries, query) {
49
109
  const exactFull = entries.filter((e) => e.name === query);
50
110
  if (exactFull.length > 0) return exactFull;
51
- const finalSegment = (name) => name.split(".").pop() ?? name;
52
- const exactSegment = entries.filter((e) => finalSegment(e.name) === query);
111
+ const finalSegment2 = (name) => name.split(".").pop() ?? name;
112
+ const exactSegment = entries.filter((e) => finalSegment2(e.name) === query);
53
113
  if (exactSegment.length > 0) return exactSegment;
54
- return entries.filter((e) => finalSegment(e.name).includes(query));
114
+ return entries.filter((e) => finalSegment2(e.name).includes(query));
115
+ }
116
+ function searchActions(entries, query) {
117
+ const terms = searchTerms(query);
118
+ if (terms.length === 0) return [];
119
+ return entries.map((entry) => {
120
+ const scores = terms.map((term) => actionSearchScore(entry, term));
121
+ return { entry, scores, score: scores.reduce((total, value) => total + value, 0) };
122
+ }).filter(({ score, scores }) => score > 0 && scores.every((value) => value > 0)).sort((left, right) => right.score - left.score || left.entry.name.localeCompare(right.entry.name)).map(({ entry }) => entry);
123
+ }
124
+ function findRelatedActions(entries, selected, limit = 5) {
125
+ const selectedNameTerms = searchTerms(finalSegment(selected.name).replaceAll("_", " "));
126
+ return entries.filter((entry) => entry.name !== selected.name).map((entry) => {
127
+ const nameTerms = new Set(searchTerms(finalSegment(entry.name).replaceAll("_", " ")));
128
+ const sharedScore = selectedNameTerms.filter((term) => nameTerms.has(term)).reduce((score2, term) => score2 + (GENERIC_OPERATION_TERMS.has(term) ? 5 : 30), 0);
129
+ const score = (entry.category === selected.category ? 100 : 0) + sharedScore;
130
+ return { name: entry.name, score };
131
+ }).filter(({ score }) => score > 0).sort((left, right) => right.score - left.score || left.name.localeCompare(right.name)).slice(0, limit).map(({ name }) => name);
132
+ }
133
+ const GENERIC_OPERATION_TERMS = /* @__PURE__ */ new Set(["assert", "call", "close", "ensure", "place", "read", "start", "teardown"]);
134
+ function summarizeActionCategories(actions) {
135
+ const counts = /* @__PURE__ */ new Map();
136
+ for (const action of actions) counts.set(action.category, (counts.get(action.category) ?? 0) + 1);
137
+ return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((left, right) => left.name.localeCompare(right.name));
55
138
  }
56
139
  function describeManifestActions(manifest) {
57
140
  const manifestRecord = isRecord(manifest) ? manifest : {};
@@ -80,15 +163,70 @@ function describeManifestAction(name, kind, metadata) {
80
163
  return {
81
164
  name,
82
165
  kind,
166
+ category: actionCategory(name, record.category),
83
167
  description: typeof record.description === "string" ? record.description : "",
84
168
  fields: properties,
85
169
  schema,
86
170
  examples: record.examples
87
171
  };
88
172
  }
173
+ function actionCategory(name, configured) {
174
+ if (typeof configured === "string" && configured.trim()) return configured.trim().toLowerCase();
175
+ const segments = name.split(".");
176
+ if (segments[0] === "metamask" && segments.length > 2) return segments[1] ?? "metamask";
177
+ if (segments[0] === "app" || segments[0] === "cdp") return "runtime";
178
+ if (segments[0] === "ui") return "ui";
179
+ if (name.startsWith("assert_")) return "assertion";
180
+ if (name === "watch_logs" || name === "index_artifacts") return "evidence";
181
+ if (name === "command" || name === "wait" || name === "call" || name === "end") return "control";
182
+ return segments.length > 1 ? segments[0] || "utility" : "utility";
183
+ }
184
+ function actionSearchScore(entry, term) {
185
+ const name = entry.name.toLowerCase();
186
+ const segment = finalSegment(name);
187
+ const nameTerms = searchTerms(name.replaceAll(".", " ").replaceAll("_", " "));
188
+ const fields = entry.fields.map((field) => field.toLowerCase());
189
+ const description = entry.description.toLowerCase();
190
+ if (name === term) return 120;
191
+ if (segment === term) return 110;
192
+ if (entry.category === term) return 100;
193
+ if (fields.includes(term)) return 90;
194
+ if (nameTerms.includes(term)) return 80;
195
+ if (name.includes(term)) return 70;
196
+ if (fields.some((field) => field.includes(term))) return 60;
197
+ if (description.includes(term)) return 50;
198
+ if ([...nameTerms, ...fields].some((candidate) => fuzzyTermMatch(term, candidate))) return 40;
199
+ return 0;
200
+ }
201
+ function fuzzyTermMatch(term, candidate) {
202
+ if (term.length < 4 || candidate.length < 4) return false;
203
+ const threshold = Math.max(1, Math.floor(Math.max(term.length, candidate.length) / 4));
204
+ return levenshtein(term, candidate) <= threshold;
205
+ }
206
+ function searchTerms(value) {
207
+ return value.toLowerCase().match(/[a-z0-9]+/gu) ?? [];
208
+ }
209
+ function finalSegment(name) {
210
+ return name.split(".").pop() ?? name;
211
+ }
212
+ function levenshtein(left, right) {
213
+ const prior = Array.from({ length: right.length + 1 }, (_, index) => index);
214
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
215
+ const current = [leftIndex];
216
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
217
+ const substitution = (prior[rightIndex - 1] ?? 0) + (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1);
218
+ current[rightIndex] = Math.min((current[rightIndex - 1] ?? 0) + 1, (prior[rightIndex] ?? 0) + 1, substitution);
219
+ }
220
+ for (let index = 0; index < current.length; index += 1) prior[index] = current[index] ?? 0;
221
+ }
222
+ return prior[right.length] ?? right.length;
223
+ }
89
224
  export {
90
225
  describeManifestActions,
226
+ findRelatedActions,
91
227
  fuzzyResolveActions,
92
228
  handleActions,
93
- handleManifest
229
+ handleManifest,
230
+ searchActions,
231
+ summarizeActionCategories
94
232
  };
@@ -19,12 +19,14 @@ function parseArgs(argv, command) {
19
19
  const options = {};
20
20
  const booleanOptions = /* @__PURE__ */ new Set([
21
21
  "json",
22
+ "jsonStream",
22
23
  "launchExistingDist",
23
24
  "startWatch",
24
25
  "record",
25
26
  "plan",
26
27
  "list",
27
28
  "raw",
29
+ "categories",
28
30
  "fix",
29
31
  "force",
30
32
  "resolveOnly",